| ... | @@ -56,6 +56,7 @@ import * as Option from "effect/Option"; | ... | @@ -56,6 +56,7 @@ import * as Option from "effect/Option"; |
| 56 | import * as Duration from "effect/Duration"; | 56 | import * as Duration from "effect/Duration"; |
| 57 | import * as Effect from "effect/Effect"; | 57 | import * as Effect from "effect/Effect"; |
| 58 | import * as FileSystem from "effect/FileSystem"; | 58 | import * as FileSystem from "effect/FileSystem"; |
| | 59 | import * as Fiber from "effect/Fiber"; |
| 59 | import * as Layer from "effect/Layer"; | 60 | import * as Layer from "effect/Layer"; |
| 60 | import * as Queue from "effect/Queue"; | 61 | import * as Queue from "effect/Queue"; |
| 61 | import * as Schema from "effect/Schema"; | 62 | import * as Schema from "effect/Schema"; |
| ... | @@ -456,6 +457,8 @@ interface PendingPiPrompt { | ... | @@ -456,6 +457,8 @@ interface PendingPiPrompt { |
| 456 | readonly method: "select" | "confirm" | "input" | "editor"; | 457 | readonly method: "select" | "confirm" | "input" | "editor"; |
| 457 | readonly questionId: string; | 458 | readonly questionId: string; |
| 458 | readonly approvalKey: string; | 459 | readonly approvalKey: string; |
| | 460 | readonly expiresAt: number | null; |
| | 461 | expirationFiber: Fiber.Fiber<void> | null; |
| 459 | runtimeRequest: OrchestrationV2RuntimeRequest; | 462 | runtimeRequest: OrchestrationV2RuntimeRequest; |
| 460 | readonly node: OrchestrationV2ExecutionNode; | 463 | readonly node: OrchestrationV2ExecutionNode; |
| 461 | readonly turnItem: OrchestrationV2TurnItem; | 464 | readonly turnItem: OrchestrationV2TurnItem; |
| ... | @@ -1270,6 +1273,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( | ... | @@ -1270,6 +1273,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 1270 | | 1273 | |
| 1271 | const cancelPrompt = (pending: PendingPiPrompt, resolvedAt: DateTime.Utc) => | 1274 | const cancelPrompt = (pending: PendingPiPrompt, resolvedAt: DateTime.Utc) => |
| 1272 | Effect.gen(function* () { | 1275 | Effect.gen(function* () { |
| | 1276 | if (pending.expirationFiber !== null) yield* Fiber.interrupt(pending.expirationFiber); |
| 1273 | yield* connection | 1277 | yield* connection |
| 1274 | .send({ | 1278 | .send({ |
| 1275 | type: "extension_ui_response", | 1279 | type: "extension_ui_response", |
| ... | @@ -1368,6 +1372,12 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( | ... | @@ -1368,6 +1372,12 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 1368 | const state = threadState; | 1372 | const state = threadState; |
| 1369 | const turn = state?.activeTurn ?? null; | 1373 | const turn = state?.activeTurn ?? null; |
| 1370 | const createdAt = yield* DateTime.now; | 1374 | const createdAt = yield* DateTime.now; |
| | 1375 | const timeout = recordNumber(event, "timeout"); |
| | 1376 | // Pi treats zero as no timeout and negative durations as immediate expiry. |
| | 1377 | const expiresAt = |
| | 1378 | timeout === undefined || timeout === 0 |
| | 1379 | ? null |
| | 1380 | : DateTime.toEpochMillis(createdAt) + Math.max(0, timeout); |
| 1371 | const requestId = yield* idAllocator.allocate.runtimeRequest({ | 1381 | const requestId = yield* idAllocator.allocate.runtimeRequest({ |
| 1372 | driver: PI_PROVIDER, | 1382 | driver: PI_PROVIDER, |
| 1373 | ...(turn === null ? {} : { providerTurnId: turn.providerTurn.id }), | 1383 | ...(turn === null ? {} : { providerTurnId: turn.providerTurn.id }), |
| ... | @@ -1440,15 +1450,18 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( | ... | @@ -1440,15 +1450,18 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 1440 | requestId, | 1450 | requestId, |
| 1441 | questions: [piQuestion(nativeRequestId, method, title, event)], | 1451 | questions: [piQuestion(nativeRequestId, method, title, event)], |
| 1442 | }; | 1452 | }; |
| 1443 | pendingPrompts.set(String(requestId), { | 1453 | const pending: PendingPiPrompt = { |
| 1444 | nativeRequestId, | 1454 | nativeRequestId, |
| 1445 | method, | 1455 | method, |
| 1446 | questionId: nativeRequestId, | 1456 | questionId: nativeRequestId, |
| 1447 | approvalKey, | 1457 | approvalKey, |
| | 1458 | expiresAt, |
| | 1459 | expirationFiber: null, |
| 1448 | runtimeRequest, | 1460 | runtimeRequest, |
| 1449 | node, | 1461 | node, |
| 1450 | turnItem, | 1462 | turnItem, |
| 1451 | }); | 1463 | }; |
| | 1464 | pendingPrompts.set(String(requestId), pending); |
| 1452 | yield* emit({ | 1465 | yield* emit({ |
| 1453 | type: "runtime_request.updated", | 1466 | type: "runtime_request.updated", |
| 1454 | driver: PI_PROVIDER, | 1467 | driver: PI_PROVIDER, |
| ... | @@ -1457,6 +1470,22 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( | ... | @@ -1457,6 +1470,22 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 1457 | }); | 1470 | }); |
| 1458 | yield* emit({ type: "node.updated", driver: PI_PROVIDER, node }); | 1471 | yield* emit({ type: "node.updated", driver: PI_PROVIDER, node }); |
| 1459 | yield* emit({ type: "turn_item.updated", driver: PI_PROVIDER, turnItem }); | 1472 | yield* emit({ type: "turn_item.updated", driver: PI_PROVIDER, turnItem }); |
| | 1473 | if (expiresAt !== null) { |
| | 1474 | const now = yield* DateTime.now; |
| | 1475 | pending.expirationFiber = yield* Effect.sleep( |
| | 1476 | Duration.millis(Math.max(0, expiresAt - DateTime.toEpochMillis(now))), |
| | 1477 | ).pipe( |
| | 1478 | Effect.andThen( |
| | 1479 | Queue.offer(connection.events, { |
| | 1480 | type: "t3.ui_request_expired", |
| | 1481 | requestId: String(requestId), |
| | 1482 | pending, |
| | 1483 | }), |
| | 1484 | ), |
| | 1485 | Effect.asVoid, |
| | 1486 | Effect.forkIn(scope), |
| | 1487 | ); |
| | 1488 | } |
| 1460 | }); | 1489 | }); |
| 1461 | | 1490 | |
| 1462 | const emitExtensionError = Effect.fnUntraced(function* (event: PiRpcRecord) { | 1491 | const emitExtensionError = Effect.fnUntraced(function* (event: PiRpcRecord) { |
| ... | @@ -2132,6 +2161,15 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( | ... | @@ -2132,6 +2161,15 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 2132 | } | 2161 | } |
| 2133 | return; | 2162 | return; |
| 2134 | } | 2163 | } |
| | 2164 | case "t3.ui_request_expired": { |
| | 2165 | const requestId = recordString(event, "requestId"); |
| | 2166 | if (requestId === undefined) return; |
| | 2167 | const pending = pendingPrompts.get(requestId); |
| | 2168 | if (pending === undefined || pending !== event["pending"]) return; |
| | 2169 | pendingPrompts.delete(requestId); |
| | 2170 | yield* cancelPrompt(pending, yield* DateTime.now); |
| | 2171 | return; |
| | 2172 | } |
| 2135 | case "t3.flush_extension_errors": { | 2173 | case "t3.flush_extension_errors": { |
| 2136 | // Startup extension failures are informational and do not block | 2174 | // Startup extension failures are informational and do not block |
| 2137 | // Pi, so attach them to the next real turn instead of creating a | 2175 | // Pi, so attach them to the next real turn instead of creating a |
| ... | @@ -2211,7 +2249,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( | ... | @@ -2211,7 +2249,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 2211 | rollbackBarrier, | 2249 | rollbackBarrier, |
| 2212 | protocolError("Pi transport closed during rollback"), | 2250 | protocolError("Pi transport closed during rollback"), |
| 2213 | ); | 2251 | ); |
| 2214 | const hadPendingWake = pendingWake !== null; | | |
| 2215 | pendingWake = null; | 2252 | pendingWake = null; |
| 2216 | const interrupted = state?.activeTurn?.interrupted === true; | 2253 | const interrupted = state?.activeTurn?.interrupted === true; |
| 2217 | if (state?.activeTurn != null) { | 2254 | if (state?.activeTurn != null) { |
| ... | @@ -2223,7 +2260,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( | ... | @@ -2223,7 +2260,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 2223 | class: "transport_error", | 2260 | class: "transport_error", |
| 2224 | }); | 2261 | }); |
| 2225 | yield* finalizeTurn(state, false); | 2262 | yield* finalizeTurn(state, false); |
| 2226 | } else if (hadPendingWake) { | 2263 | } else { |
| 2227 | yield* cancelPendingPrompts(yield* DateTime.now); | 2264 | yield* cancelPendingPrompts(yield* DateTime.now); |
| 2228 | } | 2265 | } |
| 2229 | if (unsolicitedActivityDetected) { | 2266 | if (unsolicitedActivityDetected) { |
| ... | @@ -2844,6 +2881,17 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( | ... | @@ -2844,6 +2881,17 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 2844 | `No pending Pi extension request ${requestInput.requestId}`, | 2881 | `No pending Pi extension request ${requestInput.requestId}`, |
| 2845 | ); | 2882 | ); |
| 2846 | } | 2883 | } |
| | 2884 | const answeredAt = yield* DateTime.now; |
| | 2885 | if ( |
| | 2886 | pending.expiresAt !== null && |
| | 2887 | DateTime.toEpochMillis(answeredAt) >= pending.expiresAt |
| | 2888 | ) { |
| | 2889 | pendingPrompts.delete(String(requestInput.requestId)); |
| | 2890 | yield* cancelPrompt(pending, answeredAt); |
| | 2891 | return yield* protocolError( |
| | 2892 | `Pi extension request ${requestInput.requestId} has expired`, |
| | 2893 | ); |
| | 2894 | } |
| 2847 | const response = piUiResponse(pending, requestInput.decision, requestInput.answers); | 2895 | const response = piUiResponse(pending, requestInput.decision, requestInput.answers); |
| 2848 | yield* connection.send({ | 2896 | yield* connection.send({ |
| 2849 | type: "extension_ui_response", | 2897 | type: "extension_ui_response", |
| ... | @@ -2853,6 +2901,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( | ... | @@ -2853,6 +2901,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 2853 | // Dropped only once Pi has the answer, so a failed send leaves the | 2901 | // Dropped only once Pi has the answer, so a failed send leaves the |
| 2854 | // request retryable and still cancellable during teardown. | 2902 | // request retryable and still cancellable during teardown. |
| 2855 | pendingPrompts.delete(String(requestInput.requestId)); | 2903 | pendingPrompts.delete(String(requestInput.requestId)); |
| | 2904 | if (pending.expirationFiber !== null) yield* Fiber.interrupt(pending.expirationFiber); |
| 2856 | if (pending.method === "confirm" && requestInput.decision === "acceptForSession") { | 2905 | if (pending.method === "confirm" && requestInput.decision === "acceptForSession") { |
| 2857 | sessionApprovals.add(pending.approvalKey); | 2906 | sessionApprovals.add(pending.approvalKey); |
| 2858 | } | 2907 | } |