pilotswarm-sdk 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/api/src/http-api-transport.js +10 -0
  2. package/api/src/protocol.js +2 -0
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +12 -0
  5. package/dist/client.js.map +1 -1
  6. package/dist/cms.d.ts +10 -0
  7. package/dist/cms.d.ts.map +1 -1
  8. package/dist/cms.js +15 -0
  9. package/dist/cms.js.map +1 -1
  10. package/dist/management-client.d.ts +20 -0
  11. package/dist/management-client.d.ts.map +1 -1
  12. package/dist/management-client.js +45 -4
  13. package/dist/management-client.js.map +1 -1
  14. package/dist/model-providers.d.ts +20 -0
  15. package/dist/model-providers.d.ts.map +1 -1
  16. package/dist/model-providers.js +64 -9
  17. package/dist/model-providers.js.map +1 -1
  18. package/dist/orchestration/agents.d.ts.map +1 -1
  19. package/dist/orchestration/agents.js +9 -1
  20. package/dist/orchestration/agents.js.map +1 -1
  21. package/dist/orchestration/index.d.ts +2 -2
  22. package/dist/orchestration/index.js +1 -1
  23. package/dist/orchestration/runtime.d.ts +1 -1
  24. package/dist/orchestration/turn.d.ts.map +1 -1
  25. package/dist/orchestration/turn.js +31 -3
  26. package/dist/orchestration/turn.js.map +1 -1
  27. package/dist/orchestration-registry.d.ts.map +1 -1
  28. package/dist/orchestration-registry.js +4 -2
  29. package/dist/orchestration-registry.js.map +1 -1
  30. package/dist/orchestration-version.d.ts +1 -1
  31. package/dist/orchestration-version.js +1 -1
  32. package/dist/orchestration.d.ts +2 -2
  33. package/dist/orchestration.js +1 -1
  34. package/dist/orchestration_1_0_46.d.ts +1 -1
  35. package/dist/orchestration_1_0_47.d.ts +1 -1
  36. package/dist/orchestration_1_0_48.d.ts +1 -1
  37. package/dist/orchestration_1_0_49.d.ts +1 -1
  38. package/dist/orchestration_1_0_50.d.ts +1 -1
  39. package/dist/orchestration_1_0_58/agents.d.ts +41 -0
  40. package/dist/orchestration_1_0_58/agents.d.ts.map +1 -0
  41. package/dist/orchestration_1_0_58/agents.js +758 -0
  42. package/dist/orchestration_1_0_58/agents.js.map +1 -0
  43. package/dist/orchestration_1_0_58/index.d.ts +24 -0
  44. package/dist/orchestration_1_0_58/index.d.ts.map +1 -0
  45. package/dist/orchestration_1_0_58/index.js +13 -0
  46. package/dist/orchestration_1_0_58/index.js.map +1 -0
  47. package/dist/orchestration_1_0_58/lifecycle.d.ts +48 -0
  48. package/dist/orchestration_1_0_58/lifecycle.d.ts.map +1 -0
  49. package/dist/orchestration_1_0_58/lifecycle.js +546 -0
  50. package/dist/orchestration_1_0_58/lifecycle.js.map +1 -0
  51. package/dist/orchestration_1_0_58/queue.d.ts +7 -0
  52. package/dist/orchestration_1_0_58/queue.d.ts.map +1 -0
  53. package/dist/orchestration_1_0_58/queue.js +644 -0
  54. package/dist/orchestration_1_0_58/queue.js.map +1 -0
  55. package/dist/orchestration_1_0_58/runtime.d.ts +29 -0
  56. package/dist/orchestration_1_0_58/runtime.d.ts.map +1 -0
  57. package/dist/orchestration_1_0_58/runtime.js +199 -0
  58. package/dist/orchestration_1_0_58/runtime.js.map +1 -0
  59. package/dist/orchestration_1_0_58/state.d.ts +130 -0
  60. package/dist/orchestration_1_0_58/state.d.ts.map +1 -0
  61. package/dist/orchestration_1_0_58/state.js +115 -0
  62. package/dist/orchestration_1_0_58/state.js.map +1 -0
  63. package/dist/orchestration_1_0_58/turn.d.ts +30 -0
  64. package/dist/orchestration_1_0_58/turn.d.ts.map +1 -0
  65. package/dist/orchestration_1_0_58/turn.js +1107 -0
  66. package/dist/orchestration_1_0_58/turn.js.map +1 -0
  67. package/dist/orchestration_1_0_58/utils.d.ts +22 -0
  68. package/dist/orchestration_1_0_58/utils.d.ts.map +1 -0
  69. package/dist/orchestration_1_0_58/utils.js +226 -0
  70. package/dist/orchestration_1_0_58/utils.js.map +1 -0
  71. package/dist/session-lifecycle.d.ts +39 -5
  72. package/dist/session-lifecycle.d.ts.map +1 -1
  73. package/dist/session-lifecycle.js +143 -76
  74. package/dist/session-lifecycle.js.map +1 -1
  75. package/dist/session-manager.d.ts +7 -0
  76. package/dist/session-manager.d.ts.map +1 -1
  77. package/dist/session-manager.js +67 -14
  78. package/dist/session-manager.js.map +1 -1
  79. package/dist/session-proxy.d.ts +1 -1
  80. package/dist/session-proxy.d.ts.map +1 -1
  81. package/dist/session-proxy.js +50 -2
  82. package/dist/session-proxy.js.map +1 -1
  83. package/dist/web/web-management-client.d.ts +2 -0
  84. package/dist/web/web-management-client.d.ts.map +1 -1
  85. package/dist/web/web-management-client.js +6 -0
  86. package/dist/web/web-management-client.js.map +1 -1
  87. package/dist/worker.d.ts +3 -0
  88. package/dist/worker.d.ts.map +1 -1
  89. package/dist/worker.js +25 -3
  90. package/dist/worker.js.map +1 -1
  91. package/package.json +2 -2
@@ -0,0 +1,1107 @@
1
+ import { SESSION_STATE_MISSING_PREFIX, stopTurnQueueName } from "../types.js";
2
+ import { createSessionProxy } from "../session-proxy.js";
3
+ import { planHoldRelease } from "../wait-affinity.js";
4
+ import { buildShutdownWaitReason, failPendingShutdown, getStillRunningAgentIds, handleSubAgentAction, isSubAgentTerminalStatus, maybeResolveAgentWaitCompletion, refreshTrackedSubAgents, } from "./agents.js";
5
+ import { applyCronAtAction, applyCronAction, continueInput, continueInputWithPrompt, drainLeadingQueuedScheduleActions, ensureTaskContext, maybeSummarize, publishStatus, releaseAffinity, versionedContinueAsNew, wrapWithResumeContext, writeCommandResponse, writeLatestResponse, } from "./lifecycle.js";
6
+ import { describeCronAt } from "../cron-at.js";
7
+ import { shouldWakeParentForChildUpdate } from "../child-notifications.js";
8
+ import { INTERNAL_SYSTEM_TURN_PROMPT, MAX_RETRIES, SHUTDOWN_POLL_INTERVAL_MS, SHUTDOWN_TIMEOUT_MS, } from "./state.js";
9
+ import { AUTH_FAILURE_USER_HINT, COPILOT_CONNECTION_CLOSED_MAX_RETRIES, COPILOT_CONNECTION_CLOSED_RETRY_DELAY_SECONDS, appendSystemContext, buildConnectionClosedRetryDetail, buildLossyHandoffRehydrationMessage, buildLossyHandoffSummary, extractPromptSystemContext, isAuthFailureError, isCopilotConnectionClosedError, mergePrompt, updateContextUsageFromEvents, } from "./utils.js";
10
+ function currentModelLabel(runtime) {
11
+ const model = runtime.state.config.model || "(default)";
12
+ const effort = runtime.state.config.reasoningEffort;
13
+ return effort ? `${model}:${effort}` : model;
14
+ }
15
+ /**
16
+ * Scan a finished turn's captured events for a failed `set_session_model` tool
17
+ * call. The inline control tool returns its outcome as a plain string (see
18
+ * session-proxy `setSessionModel`), so `tool.execution_complete.data.result`
19
+ * is usually a string; some transports wrap it as `{ content }`. We match the
20
+ * `set_session_model failed` marker across both shapes. Exported for unit tests.
21
+ */
22
+ export function detectFailedModelSwitch(events) {
23
+ if (!Array.isArray(events))
24
+ return null;
25
+ for (const event of events) {
26
+ if (event?.eventType !== "tool.execution_complete")
27
+ continue;
28
+ const data = event.data || {};
29
+ const result = data.result ?? data.output;
30
+ const content = typeof result === "string"
31
+ ? result
32
+ : String(result?.content ?? result?.detailedContent ?? data.content ?? "");
33
+ if (/set_session_model (?:failed|is unavailable|rejected)/i.test(content))
34
+ return content.trim();
35
+ }
36
+ return null;
37
+ }
38
+ function captureFailedModelSwitchNotice(runtime, result) {
39
+ const failure = detectFailedModelSwitch(result?.events);
40
+ if (!failure)
41
+ return null;
42
+ const modelLabel = currentModelLabel(runtime);
43
+ runtime.state.runtimeModelNotice = `Previous model switch failed; current runtime model is ${modelLabel}. If asked what model you are using, answer this value.`;
44
+ runtime.ctx.traceInfo(`[orch] queued failed model-switch correction: ${failure.slice(0, 160)}`);
45
+ return `Continue on ${modelLabel}; the requested model switch failed.`;
46
+ }
47
+ function* handleConnectionClosedRetry(runtime, errorMessage, rc) {
48
+ const { state } = runtime;
49
+ if (state.retryCount <= COPILOT_CONNECTION_CLOSED_MAX_RETRIES) {
50
+ const retryDetail = buildConnectionClosedRetryDetail(state.retryCount);
51
+ publishStatus(runtime, "error", {
52
+ error: `${errorMessage} (${retryDetail})`,
53
+ recoverableTransportLoss: true,
54
+ });
55
+ runtime.ctx.traceInfo(`[orch] live Copilot connection lost; retrying in ${COPILOT_CONNECTION_CLOSED_RETRY_DELAY_SECONDS}s`);
56
+ // Lifecycle protocol: nothing to dehydrate — the last commit is the
57
+ // durable truth. Release affinity so the retry can land anywhere;
58
+ // the retry's preamble hydrates clean from the committed snapshot
59
+ // (the broken warm session is detected via the turn sentinel).
60
+ if (state.blobEnabled) {
61
+ yield* releaseAffinity(runtime, "error", {
62
+ detail: retryDetail,
63
+ error: errorMessage,
64
+ phase: rc.phase,
65
+ retryAttempt: state.retryCount,
66
+ maxRetries: COPILOT_CONNECTION_CLOSED_MAX_RETRIES,
67
+ retryDelaySeconds: COPILOT_CONNECTION_CLOSED_RETRY_DELAY_SECONDS,
68
+ });
69
+ }
70
+ yield runtime.ctx.scheduleTimer(COPILOT_CONNECTION_CLOSED_RETRY_DELAY_SECONDS * 1000);
71
+ yield* versionedContinueAsNew(runtime, continueInput(runtime, retryContinueOverrides(state, rc)));
72
+ return;
73
+ }
74
+ const handoffMessage = buildLossyHandoffSummary(errorMessage);
75
+ runtime.ctx.traceInfo(`[orch] ${handoffMessage}`);
76
+ publishStatus(runtime, "error", {
77
+ error: handoffMessage,
78
+ retriesExhausted: true,
79
+ lossyHandoff: true,
80
+ });
81
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
82
+ eventType: "session.lossy_handoff",
83
+ data: {
84
+ message: handoffMessage,
85
+ error: errorMessage,
86
+ phase: rc.phase,
87
+ retries: COPILOT_CONNECTION_CLOSED_MAX_RETRIES,
88
+ retryDelaySeconds: COPILOT_CONNECTION_CLOSED_RETRY_DELAY_SECONDS,
89
+ nextStep: "release_affinity_and_resume_on_any_worker",
90
+ },
91
+ }]);
92
+ if (state.blobEnabled) {
93
+ yield* releaseAffinity(runtime, "lossy_handoff", {
94
+ detail: handoffMessage,
95
+ error: errorMessage,
96
+ phase: rc.phase,
97
+ retries: COPILOT_CONNECTION_CLOSED_MAX_RETRIES,
98
+ retryDelaySeconds: COPILOT_CONNECTION_CLOSED_RETRY_DELAY_SECONDS,
99
+ nextStep: "release_affinity_and_resume_on_any_worker",
100
+ });
101
+ yield* versionedContinueAsNew(runtime, continueInput(runtime, {
102
+ ...retryContinueOverrides(state, rc),
103
+ retryCount: 0,
104
+ rehydrationMessage: buildLossyHandoffRehydrationMessage(errorMessage),
105
+ }));
106
+ return;
107
+ }
108
+ publishStatus(runtime, "error", {
109
+ error: `${handoffMessage} Durable handoff is unavailable because blob persistence is disabled.`,
110
+ retriesExhausted: true,
111
+ lossyHandoff: false,
112
+ });
113
+ state.retryCount = 0;
114
+ }
115
+ function retryContinueOverrides(state, rc) {
116
+ if (rc.phase === "turn.result.error") {
117
+ return {
118
+ prompt: rc.sourcePrompt,
119
+ ...(rc.cycleOrigin ? { cycleOrigin: rc.cycleOrigin } : {}),
120
+ retryCount: state.retryCount,
121
+ needsHydration: state.needsHydration,
122
+ };
123
+ }
124
+ return {
125
+ ...(rc.systemOnlyTurn ? {} : { prompt: rc.sourcePrompt }),
126
+ ...(rc.requiredTool ? { requiredTool: rc.requiredTool } : {}),
127
+ ...(rc.turnSystemPrompt ? { systemPrompt: rc.turnSystemPrompt } : {}),
128
+ ...(rc.cycleOrigin ? { cycleOrigin: rc.cycleOrigin } : {}),
129
+ retryCount: state.retryCount,
130
+ needsHydration: state.needsHydration,
131
+ };
132
+ }
133
+ function* handleGenericRetry(runtime, errorMessage, rc) {
134
+ const { state } = runtime;
135
+ if (state.retryCount >= MAX_RETRIES) {
136
+ runtime.ctx.traceInfo(`[orch] max retries exhausted, waiting for user input`);
137
+ publishStatus(runtime, "error", {
138
+ error: `Failed after ${MAX_RETRIES} attempts: ${errorMessage}`,
139
+ retriesExhausted: true,
140
+ });
141
+ state.retryCount = 0;
142
+ return;
143
+ }
144
+ const retryDelay = 15 * Math.pow(2, state.retryCount - 1);
145
+ publishStatus(runtime, "error", {
146
+ error: `${errorMessage} (retry ${state.retryCount}/${MAX_RETRIES} in ${retryDelay}s)`,
147
+ });
148
+ runtime.ctx.traceInfo(`[orch] retrying in ${retryDelay}s${rc.phase === "turn.result.error" ? " after turn error" : ""}`);
149
+ if (state.blobEnabled) {
150
+ yield* releaseAffinity(runtime, "error", {
151
+ detail: errorMessage,
152
+ error: errorMessage,
153
+ phase: rc.phase,
154
+ retryAttempt: state.retryCount,
155
+ maxRetries: MAX_RETRIES,
156
+ retryDelaySeconds: retryDelay,
157
+ });
158
+ }
159
+ yield runtime.ctx.scheduleTimer(retryDelay * 1000);
160
+ yield* versionedContinueAsNew(runtime, continueInput(runtime, retryContinueOverrides(state, rc)));
161
+ }
162
+ // ─── processPrompt: hydrate → runTurn → handleTurnResult ────
163
+ export function* processPrompt(runtime, promptText, isBootstrap, requiredTool, clientMessageIds, cycleOrigin) {
164
+ const { ctx, state } = runtime;
165
+ let prompt = promptText;
166
+ let promptIsBootstrap = isBootstrap;
167
+ // Lifecycle protocol (P5): no needsHydration probe. The old protocol
168
+ // asked a worker "do you have my files?" before every turn — an extra
169
+ // session activity whose answer could desync from reality, and whose
170
+ // "no" triggered a legacy hydrate that the runTurn preamble would then
171
+ // repeat (double download per cold wake). The preamble self-validates
172
+ // against the versioned store; state.needsHydration survives only as
173
+ // one-shot normalization of legacy (≤1.0.56) continue-as-new inputs.
174
+ if (state.needsHydration && state.blobEnabled && prompt) {
175
+ prompt = wrapWithResumeContext(runtime, prompt);
176
+ }
177
+ let turnSystemPrompt = state.pendingSystemPrompt;
178
+ state.pendingSystemPrompt = undefined;
179
+ const extractedPrompt = extractPromptSystemContext(prompt);
180
+ prompt = extractedPrompt.prompt ?? "";
181
+ turnSystemPrompt = mergePrompt(turnSystemPrompt, extractedPrompt.systemPrompt);
182
+ if (prompt && state.runtimeModelNotice) {
183
+ turnSystemPrompt = mergePrompt(turnSystemPrompt, state.runtimeModelNotice);
184
+ state.runtimeModelNotice = undefined;
185
+ }
186
+ const systemOnlyTurn = !prompt && !!turnSystemPrompt;
187
+ if (systemOnlyTurn) {
188
+ prompt = INTERNAL_SYSTEM_TURN_PROMPT;
189
+ promptIsBootstrap = true;
190
+ }
191
+ state.config.turnSystemPrompt = turnSystemPrompt;
192
+ ctx.traceInfo(`[turn ${state.iteration}] session=${runtime.input.sessionId} prompt="${prompt.slice(0, 80)}"`);
193
+ if (state.needsHydration && state.blobEnabled) {
194
+ let hydrateAttempts = 0;
195
+ while (true) {
196
+ try {
197
+ if (!state.preserveAffinityOnHydrate) {
198
+ state.affinityKey = yield ctx.newGuid();
199
+ }
200
+ runtime.session = createSessionProxy(ctx, runtime.input.sessionId, state.affinityKey, state.config);
201
+ yield runtime.session.hydrate();
202
+ state.needsHydration = false;
203
+ state.preserveAffinityOnHydrate = false;
204
+ break;
205
+ }
206
+ catch (hydrateErr) {
207
+ const hMsg = hydrateErr.message || String(hydrateErr);
208
+ if (hMsg.includes("blob does not exist")
209
+ || hMsg.includes("BlobNotFound")
210
+ || hMsg.includes("Session archive not found")
211
+ || hMsg.includes("404")) {
212
+ ctx.traceInfo(`[orch] hydrate skipped — blob not found, starting fresh session`);
213
+ state.needsHydration = false;
214
+ state.preserveAffinityOnHydrate = false;
215
+ break;
216
+ }
217
+ hydrateAttempts++;
218
+ ctx.traceInfo(`[orch] hydrate FAILED (attempt ${hydrateAttempts}/${MAX_RETRIES}): ${hMsg}`);
219
+ if (hydrateAttempts >= MAX_RETRIES) {
220
+ publishStatus(runtime, "error", {
221
+ error: `Hydrate failed after ${MAX_RETRIES} attempts: ${hMsg}`,
222
+ retriesExhausted: true,
223
+ });
224
+ break;
225
+ }
226
+ const hydrateDelay = 10 * Math.pow(2, hydrateAttempts - 1);
227
+ publishStatus(runtime, "error", {
228
+ error: `Hydrate failed: ${hMsg} (retry ${hydrateAttempts}/${MAX_RETRIES} in ${hydrateDelay}s)`,
229
+ });
230
+ yield ctx.scheduleTimer(hydrateDelay * 1000);
231
+ }
232
+ }
233
+ if (state.needsHydration)
234
+ return;
235
+ }
236
+ if (state.config.agentIdentity !== "facts-manager") {
237
+ try {
238
+ yield runtime.manager.loadKnowledgeIndex();
239
+ }
240
+ catch (knErr) {
241
+ ctx.traceInfo(`[orch] loadKnowledgeIndex failed (non-fatal): ${knErr.message || knErr}`);
242
+ }
243
+ }
244
+ publishStatus(runtime, "running", { iteration: state.iteration + 1 });
245
+ let turnResult;
246
+ try {
247
+ // Stop-turn race: the in-flight runTurn activity vs a dequeue on the
248
+ // TURN-SCOPED stop queue (stopTurn.<iteration>). Scoping the queue to
249
+ // the turn index makes stale stop events structurally unable to kill a
250
+ // later turn — a race loser is dropped and cannot be un-dropped.
251
+ // When the stop wins, duroxide cancel-requests the dropped runTurn
252
+ // work item (lock-steal → isCancelled poll → SDK abort) as the
253
+ // guaranteed backstop; handleTurnStopped layers the fast-path
254
+ // same-affinity abortTurn on top.
255
+ // Lifecycle protocol: a deterministic per-turn key (recorded GUID)
256
+ // rides in the activity input with the last committed version. The
257
+ // worker self-validates against them (preamble) and commits the
258
+ // post-turn snapshot inside the activity, returning the new version.
259
+ const snapshotTurnKey = state.blobEnabled ? yield ctx.newGuid() : "";
260
+ const turnTask = runtime.session.runTurn(prompt, promptIsBootstrap, state.iteration, {
261
+ ...(runtime.options.parentSessionId ? { parentSessionId: runtime.options.parentSessionId } : {}),
262
+ nestingLevel: runtime.options.nestingLevel,
263
+ ...(requiredTool ? { requiredTool } : {}),
264
+ ...(cycleOrigin ? { cycleOrigin } : {}),
265
+ retryCount: state.retryCount,
266
+ ...(clientMessageIds && clientMessageIds.length > 0 ? { clientMessageIds } : {}),
267
+ ...(snapshotTurnKey
268
+ ? { snapshot: { expectedVersion: state.snapshotVersion, turnKey: snapshotTurnKey } }
269
+ : {}),
270
+ });
271
+ const stopTask = ctx.dequeueEvent(stopTurnQueueName(state.iteration));
272
+ const race = yield ctx.race(turnTask, stopTask);
273
+ if (race.index === 1) {
274
+ yield* handleTurnStopped(runtime, race.value);
275
+ return;
276
+ }
277
+ // The select bridge flattens activity failures into their raw error
278
+ // string (duroxide-node make_select_future) instead of throwing, so a
279
+ // failed runTurn must be re-thrown here to reach the existing retry
280
+ // machinery in the catch below.
281
+ const raced = normalizeRacedTurnValue(race.value);
282
+ if (raced.kind === "error") {
283
+ throw new Error(raced.message);
284
+ }
285
+ turnResult = raced.result;
286
+ }
287
+ catch (err) {
288
+ state.config.turnSystemPrompt = undefined;
289
+ const errorMsg = err.message || String(err);
290
+ const missingStateIndex = errorMsg.indexOf(SESSION_STATE_MISSING_PREFIX);
291
+ if (missingStateIndex >= 0) {
292
+ const fatalError = errorMsg.slice(missingStateIndex + SESSION_STATE_MISSING_PREFIX.length).trim();
293
+ ctx.traceInfo(`[orch] fatal missing session state: ${fatalError}`);
294
+ publishStatus(runtime, "failed", { error: fatalError, fatal: true });
295
+ yield runtime.manager.updateCmsState(runtime.input.sessionId, "failed", fatalError);
296
+ throw new Error(fatalError);
297
+ }
298
+ if (isAuthFailureError(errorMsg)) {
299
+ const blockedDetail = `${errorMsg} — ${AUTH_FAILURE_USER_HINT}`;
300
+ ctx.traceInfo(`[orch] runTurn FAILED with auth error; not retrying: ${errorMsg}`);
301
+ publishStatus(runtime, "error", {
302
+ error: blockedDetail,
303
+ retriesExhausted: true,
304
+ authFailure: true,
305
+ });
306
+ state.retryCount = 0;
307
+ return;
308
+ }
309
+ state.retryCount++;
310
+ ctx.traceInfo(`[orch] runTurn FAILED (attempt ${state.retryCount}/${MAX_RETRIES}): ${errorMsg}`);
311
+ const rc = {
312
+ sourcePrompt: prompt,
313
+ systemOnlyTurn,
314
+ requiredTool,
315
+ cycleOrigin,
316
+ turnSystemPrompt,
317
+ phase: "runTurn.throw",
318
+ };
319
+ if (isCopilotConnectionClosedError(errorMsg)) {
320
+ yield* handleConnectionClosedRetry(runtime, errorMsg, rc);
321
+ return;
322
+ }
323
+ yield* handleGenericRetry(runtime, errorMsg, rc);
324
+ return;
325
+ }
326
+ state.config.turnSystemPrompt = undefined;
327
+ state.retryCount = 0;
328
+ let result = typeof turnResult === "string" ? JSON.parse(turnResult) : turnResult;
329
+ // Lifecycle protocol: adopt the version the activity committed. The
330
+ // returned value is authoritative even when it disagrees with the
331
+ // expectation (self-healing after a store restore).
332
+ if (typeof result?.snapshotVersion === "number") {
333
+ state.snapshotVersion = result.snapshotVersion;
334
+ }
335
+ const observedAt = yield ctx.utcNow();
336
+ state.contextUsage = updateContextUsageFromEvents(state.contextUsage, result?.events, observedAt);
337
+ const failedModelSwitchContinuePrompt = captureFailedModelSwitchNotice(runtime, result);
338
+ if (failedModelSwitchContinuePrompt && result.type === "completed") {
339
+ result = {
340
+ ...result,
341
+ forceContinuePrompt: failedModelSwitchContinuePrompt,
342
+ };
343
+ }
344
+ state.iteration++;
345
+ yield* maybeSummarize(runtime);
346
+ yield* refreshTrackedSubAgents(runtime);
347
+ if ("queuedActions" in result && Array.isArray(result.queuedActions) && result.queuedActions.length > 0) {
348
+ state.pendingToolActions.push(...result.queuedActions);
349
+ ctx.traceInfo(`[orch] queued ${result.queuedActions.length} extra action(s) from turn`);
350
+ }
351
+ yield* drainLeadingQueuedScheduleActions(runtime, prompt);
352
+ yield* handleTurnResult(runtime, result, prompt, cycleOrigin);
353
+ }
354
+ // ─── Stop-turn race support ─────────────────────────────────
355
+ /**
356
+ * Normalize a raced runTurn branch value. The duroxide-node select bridge
357
+ * flattens activity failures into their raw error string (make_select_future:
358
+ * `Ok(v) => v, Err(e) => e`) instead of throwing into the generator, so the
359
+ * caller must distinguish a TurnResult payload from an error message.
360
+ */
361
+ export function normalizeRacedTurnValue(value) {
362
+ let v = value;
363
+ if (typeof v === "string") {
364
+ try {
365
+ v = JSON.parse(v);
366
+ }
367
+ catch {
368
+ return { kind: "error", message: value };
369
+ }
370
+ }
371
+ if (v && typeof v === "object" && typeof v.type === "string") {
372
+ return { kind: "result", result: v };
373
+ }
374
+ return { kind: "error", message: typeof value === "string" ? value : JSON.stringify(value ?? null) };
375
+ }
376
+ /**
377
+ * Stop won the race against the in-flight runTurn activity.
378
+ *
379
+ * The dropped runTurn future is already cancel-requested by duroxide (the
380
+ * guaranteed backstop: lock-steal → isCancelled poll → SDK abort, ~2-7s).
381
+ * This path layers the fast-path interrupt on top and owns the authoritative
382
+ * durable bookkeeping — the aborted activity's own writeback is best-effort
383
+ * (it is skipped entirely when the backstop delivered the abort).
384
+ */
385
+ function* handleTurnStopped(runtime, stopEventRaw) {
386
+ const { ctx, state } = runtime;
387
+ let stopEvent = stopEventRaw;
388
+ if (typeof stopEvent === "string") {
389
+ try {
390
+ stopEvent = JSON.parse(stopEvent);
391
+ }
392
+ catch {
393
+ stopEvent = {};
394
+ }
395
+ }
396
+ if (!stopEvent || typeof stopEvent !== "object")
397
+ stopEvent = {};
398
+ const reason = typeof stopEvent.reason === "string" && stopEvent.reason ? stopEvent.reason : "Stopped by user";
399
+ const stoppedIteration = state.iteration;
400
+ ctx.traceInfo(`[orch] stop_turn won the race for turn ${stoppedIteration}; aborting in-flight turn`);
401
+ state.config.turnSystemPrompt = undefined;
402
+ state.retryCount = 0;
403
+ // Fast-path interrupt: same-affinity abortTurn lands on the worker owning
404
+ // the warm session and aborts the SDK request immediately (concurrent
405
+ // dispatch requires stable workerNodeId + a free slot; otherwise the
406
+ // backstop still stops the turn, just slower). Awaiting it also
407
+ // guarantees the per-session run-turn lock is free again before this
408
+ // loop can dispatch the next prompt.
409
+ let abortOutcome = null;
410
+ try {
411
+ const raw = yield runtime.session.abortTurn(reason, stoppedIteration);
412
+ abortOutcome = typeof raw === "string" ? JSON.parse(raw) : raw;
413
+ }
414
+ catch (err) {
415
+ ctx.traceInfo(`[orch] abortTurn activity failed (backstop cancellation still applies): ${err?.message ?? err}`);
416
+ abortOutcome = { outcome: "no_active_turn", detail: `abortTurn failed: ${err?.message ?? err}` };
417
+ }
418
+ // The race already decided the turn's fate: even when abortTurn reports
419
+ // no_active_turn (the backstop got there first, or the turn had just
420
+ // ended), the user's stop is the durable outcome. Record turn_stopped
421
+ // unconditionally and annotate how the interrupt was delivered.
422
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [
423
+ {
424
+ eventType: "session.turn_stopped",
425
+ data: {
426
+ reason,
427
+ turnIndex: stoppedIteration,
428
+ interrupt: abortOutcome?.outcome ?? "unknown",
429
+ ...(abortOutcome?.detail ? { detail: abortOutcome.detail } : {}),
430
+ },
431
+ },
432
+ { eventType: "system.message", data: { content: "Turn stopped by user." } },
433
+ ]);
434
+ // Authoritative CMS transition — also clears active_turn_index (migration
435
+ // 0024 clears it on any state transition away from "running").
436
+ yield runtime.manager.updateCmsState(runtime.input.sessionId, "idle");
437
+ // The turn ran and consumed context even though its result was discarded.
438
+ state.iteration++;
439
+ if (typeof stopEvent.id === "string" && stopEvent.id) {
440
+ yield* writeCommandResponse(runtime, {
441
+ id: stopEvent.id,
442
+ cmd: "stop_turn",
443
+ result: {
444
+ outcome: abortOutcome?.outcome === "stop_forced" ? "stop_forced" : "stopped",
445
+ turnIndex: stoppedIteration,
446
+ ...(abortOutcome?.detail ? { detail: abortOutcome.detail } : {}),
447
+ },
448
+ });
449
+ }
450
+ // Same scheduling semantics as a completed turn: resume interrupted
451
+ // timers, re-arm cron schedules, else idle (skips: writeLatestResponse,
452
+ // parent CHILD_UPDATE notify, forgotten-timer nudge).
453
+ yield* schedulePostTurnContinuation(runtime);
454
+ }
455
+ // ─── Post-turn continuation: resume timers / re-arm schedules / go idle ───
456
+ //
457
+ // Extracted verbatim from the tail of the `completed` turn-result case so the
458
+ // stop-turn path shares identical scheduling semantics: stopping a turn must
459
+ // not silently kill a recurring session's cron loop or a resumable wait
460
+ // (stop-turn plan, edge E9).
461
+ function* schedulePostTurnContinuation(runtime) {
462
+ const { ctx, state, options } = runtime;
463
+ if (state.interruptedWaitTimer && state.interruptedWaitTimer.remainingSec > 0) {
464
+ const saved = state.interruptedWaitTimer;
465
+ state.interruptedWaitTimer = null;
466
+ ctx.traceInfo(`[orch] auto-resuming interrupted wait: ${saved.remainingSec}s (${saved.reason})`);
467
+ // Lifecycle protocol: state is durable from the turn commit — the
468
+ // wait only decides hold (keep GUID, worker stays warm) vs release
469
+ // (rotate GUID, wake-up hydrates anywhere).
470
+ const resumeWaitPlan = planHoldRelease({
471
+ blobEnabled: state.blobEnabled,
472
+ seconds: saved.remainingSec,
473
+ holdWindowSeconds: options.idleTimeout,
474
+ });
475
+ if (resumeWaitPlan.shouldRelease) {
476
+ yield* releaseAffinity(runtime, "timer");
477
+ }
478
+ const resumeNow = yield ctx.utcNow();
479
+ publishStatus(runtime, "waiting", {
480
+ waitSeconds: saved.remainingSec,
481
+ waitReason: saved.reason,
482
+ waitStartedAt: resumeNow,
483
+ });
484
+ state.activeTimer = {
485
+ deadlineMs: resumeNow + saved.remainingSec * 1000,
486
+ originalDurationMs: saved.remainingSec * 1000,
487
+ reason: saved.reason,
488
+ type: "wait",
489
+ };
490
+ return;
491
+ }
492
+ if (state.interruptedCronTimer && state.interruptedCronTimer.remainingMs > 0) {
493
+ const saved = state.interruptedCronTimer;
494
+ state.interruptedCronTimer = null;
495
+ const remainingMs = Math.max(0, saved.remainingMs);
496
+ const remainingSec = Math.max(1, Math.round(remainingMs / 1000));
497
+ ctx.traceInfo(`[orch] auto-resuming interrupted cron: ${remainingSec}s remain (${saved.reason})`);
498
+ const cronResumePlan = planHoldRelease({
499
+ blobEnabled: state.blobEnabled,
500
+ seconds: remainingSec,
501
+ holdWindowSeconds: options.idleTimeout,
502
+ });
503
+ if (cronResumePlan.shouldRelease) {
504
+ yield* releaseAffinity(runtime, "cron");
505
+ }
506
+ const resumeNow = yield ctx.utcNow();
507
+ publishStatus(runtime, "waiting", {
508
+ waitSeconds: remainingSec,
509
+ waitReason: saved.reason,
510
+ waitStartedAt: resumeNow,
511
+ });
512
+ state.activeTimer = {
513
+ deadlineMs: resumeNow + remainingMs,
514
+ originalDurationMs: remainingMs,
515
+ reason: saved.reason,
516
+ type: "cron",
517
+ };
518
+ return;
519
+ }
520
+ if (state.cronSchedule) {
521
+ const activeCron = { ...state.cronSchedule };
522
+ const cronPlan = planHoldRelease({
523
+ blobEnabled: state.blobEnabled,
524
+ seconds: activeCron.intervalSeconds,
525
+ holdWindowSeconds: options.idleTimeout,
526
+ });
527
+ if (cronPlan.shouldRelease) {
528
+ yield* releaseAffinity(runtime, "cron");
529
+ }
530
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
531
+ eventType: "session.cron_started",
532
+ data: { intervalSeconds: activeCron.intervalSeconds, reason: activeCron.reason },
533
+ }]);
534
+ const cronStartedAt = yield ctx.utcNow();
535
+ ctx.traceInfo(`[orch] cron timer: ${activeCron.intervalSeconds}s (${activeCron.reason})`);
536
+ publishStatus(runtime, "waiting", {
537
+ waitSeconds: activeCron.intervalSeconds,
538
+ waitReason: activeCron.reason,
539
+ waitStartedAt: cronStartedAt,
540
+ });
541
+ state.activeTimer = {
542
+ deadlineMs: cronStartedAt + activeCron.intervalSeconds * 1000,
543
+ originalDurationMs: activeCron.intervalSeconds * 1000,
544
+ reason: activeCron.reason,
545
+ type: "cron",
546
+ };
547
+ return;
548
+ }
549
+ if (state.cronAtSchedule) {
550
+ const activeCronAt = { ...state.cronAtSchedule };
551
+ if (activeCronAt.maxFires !== undefined && activeCronAt.firesCompleted >= activeCronAt.maxFires) {
552
+ state.cronAtSchedule = undefined;
553
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
554
+ eventType: "session.cron_at_completed",
555
+ data: { reason: activeCronAt.reason, firesCompleted: activeCronAt.firesCompleted, maxFires: activeCronAt.maxFires },
556
+ }]);
557
+ return;
558
+ }
559
+ const nowMs = yield ctx.utcNow();
560
+ let nextFireAtMs = activeCronAt.nextFireAtMs;
561
+ let nextOccurrenceKey = activeCronAt.nextOccurrenceKey;
562
+ if (!nextFireAtMs || !nextOccurrenceKey) {
563
+ const nextFire = yield runtime.manager.computeCronAtNextFire(activeCronAt, nowMs, activeCronAt.lastOccurrenceKey);
564
+ nextFireAtMs = nextFire.nextFireAtMs;
565
+ nextOccurrenceKey = nextFire.occurrenceKey;
566
+ state.cronAtSchedule = {
567
+ ...activeCronAt,
568
+ nextFireAtMs,
569
+ nextOccurrenceKey,
570
+ };
571
+ }
572
+ if (nextFireAtMs === undefined || !nextOccurrenceKey) {
573
+ throw new Error("cron_at next-fire computation did not return a fire time");
574
+ }
575
+ const waitMs = Math.max(0, nextFireAtMs - nowMs);
576
+ const waitSeconds = Math.max(0, Math.ceil(waitMs / 1000));
577
+ const cronAtPlan = planHoldRelease({
578
+ blobEnabled: state.blobEnabled,
579
+ seconds: waitSeconds,
580
+ holdWindowSeconds: options.idleTimeout,
581
+ });
582
+ if (cronAtPlan.shouldRelease) {
583
+ yield* releaseAffinity(runtime, "cron_at");
584
+ }
585
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
586
+ eventType: "session.cron_at_started",
587
+ data: {
588
+ ...state.cronAtSchedule,
589
+ nextFireAt: new Date(nextFireAtMs).toISOString(),
590
+ },
591
+ }]);
592
+ publishStatus(runtime, "waiting", {
593
+ waitSeconds,
594
+ waitReason: activeCronAt.reason,
595
+ waitStartedAt: nowMs,
596
+ });
597
+ state.activeTimer = {
598
+ deadlineMs: nowMs + waitMs,
599
+ originalDurationMs: waitMs,
600
+ reason: activeCronAt.reason,
601
+ type: "cron_at",
602
+ };
603
+ return;
604
+ }
605
+ if (!state.blobEnabled || options.idleTimeout < 0) {
606
+ return;
607
+ }
608
+ // The idle timer IS the affinity hold window (lifecycle protocol §3.4):
609
+ // any session activity re-arms it via the drain machinery, and its fire
610
+ // releases the worker — it no longer dehydrates.
611
+ publishStatus(runtime, "idle");
612
+ const idleNow = yield ctx.utcNow();
613
+ state.activeTimer = {
614
+ deadlineMs: idleNow + options.idleTimeout * 1000,
615
+ originalDurationMs: options.idleTimeout * 1000,
616
+ reason: "idle timeout",
617
+ type: "idle",
618
+ };
619
+ }
620
+ // ─── handleTurnResult: dispatch on TurnResult variant ───────
621
+ function coerceChildQuestionToWait(runtime, result) {
622
+ if (result.type === "completed"
623
+ && runtime.options.parentSessionId
624
+ && typeof result.content === "string"
625
+ && /^QUESTION FOR PARENT:/i.test(result.content.trim())) {
626
+ runtime.ctx.traceInfo("[orch] coercing child QUESTION FOR PARENT result into durable wait");
627
+ return {
628
+ type: "wait",
629
+ seconds: 60,
630
+ reason: "waiting for parent answer",
631
+ content: result.content.trim(),
632
+ model: result.model,
633
+ };
634
+ }
635
+ return result;
636
+ }
637
+ function* synthesizeWaitInterruptReplyIfNeeded(runtime, result) {
638
+ if (runtime.state.interruptedWaitTimer?.interruptKind === "user"
639
+ && (result.type === "completed" || result.type === "wait")
640
+ && !(typeof result.content === "string" && result.content.trim())) {
641
+ const content = "I'm here. Resuming the timer.";
642
+ const next = { ...result, content };
643
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
644
+ eventType: "assistant.message",
645
+ data: {
646
+ content,
647
+ synthetic: true,
648
+ reason: "wait_interrupt_empty_reply",
649
+ },
650
+ }]);
651
+ runtime.ctx.traceInfo("[orch] synthesized visible assistant reply for wait interrupt");
652
+ return next;
653
+ }
654
+ return result;
655
+ }
656
+ export function* handleTurnResult(runtime, result, sourcePrompt, cycleOrigin) {
657
+ const { ctx, state, options } = runtime;
658
+ result = coerceChildQuestionToWait(runtime, result);
659
+ result = yield* synthesizeWaitInterruptReplyIfNeeded(runtime, result);
660
+ switch (result.type) {
661
+ case "completed": {
662
+ ctx.traceInfo(`[response] ${result.content}`);
663
+ yield* writeLatestResponse(runtime, {
664
+ iteration: state.iteration,
665
+ type: "completed",
666
+ content: result.content,
667
+ model: result.model,
668
+ });
669
+ if (result.forceContinuePrompt) {
670
+ ctx.traceInfo(`[orch] continuing after terminal model switch failure`);
671
+ yield* versionedContinueAsNew(runtime, continueInputWithPrompt(runtime, result.forceContinuePrompt, {
672
+ bootstrapPrompt: true,
673
+ }));
674
+ return;
675
+ }
676
+ if (options.parentSessionId) {
677
+ const cycleReport = result.cycleReport;
678
+ const cycleMaterial = cycleReport?.status === "material" || cycleReport?.status === "blocked"
679
+ ? true
680
+ : cycleReport?.status === "quiet"
681
+ ? false
682
+ : undefined;
683
+ const wakeDecision = shouldWakeParentForChildUpdate({
684
+ update: {
685
+ kind: "completed",
686
+ summary: cycleReport?.summary || result.content,
687
+ ...(cycleOrigin ? { cyclic: true } : {}),
688
+ ...(cycleMaterial !== undefined ? { material: cycleMaterial } : {}),
689
+ ...(cycleReport?.status === "blocked" ? { result: { verdict: "blocked" } } : {}),
690
+ },
691
+ contract: state.config.childContract,
692
+ });
693
+ if (wakeDecision.wake) {
694
+ try {
695
+ const meta = [
696
+ `from=${runtime.input.sessionId}`,
697
+ `type=completed`,
698
+ `iter=${state.iteration}`,
699
+ ...(cycleOrigin ? [`cycle=${cycleOrigin}`] : []),
700
+ ...(cycleReport?.status ? [`status=${cycleReport.status}`] : []),
701
+ ].join(" ");
702
+ const notifyContent = cycleReport?.summary || result.content;
703
+ yield runtime.manager.sendToSession(options.parentSessionId, `[CHILD_UPDATE ${meta}]\n${notifyContent.slice(0, 2000)}`);
704
+ }
705
+ catch (err) {
706
+ ctx.traceInfo(`[orch] sendToSession(parent) failed: ${err.message} (non-fatal)`);
707
+ }
708
+ }
709
+ else {
710
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
711
+ eventType: "session.child_update_suppressed",
712
+ data: { direction: "child_to_parent", updateType: "completed", cycleOrigin, cycleReport, ...wakeDecision },
713
+ }]);
714
+ }
715
+ if (runtime.input.isSystem && !state.cronSchedule && !state.cronAtSchedule) {
716
+ ctx.traceInfo(`[orch] system sub-agent completed turn, continuing loop`);
717
+ return;
718
+ }
719
+ }
720
+ // Forgotten-timer safety net
721
+ {
722
+ const runningAgents = state.subAgents.filter(a => a.status === "running");
723
+ if (runningAgents.length > 0 && !runtime.input.forgottenTimerNudged && !state.cronSchedule && !state.cronAtSchedule) {
724
+ const names = runningAgents.map(a => a.task?.slice(0, 40) || a.orchId).join(", ");
725
+ ctx.traceInfo(`[orch] forgotten-timer safety: ${runningAgents.length} agents still running, nudging LLM`);
726
+ yield* versionedContinueAsNew(runtime, continueInputWithPrompt(runtime, `[SYSTEM: You ended your turn without calling wait(), but you have ${runningAgents.length} sub-agent(s) still running: ${names}. ` +
727
+ `Without a wait() call, your monitoring/polling loop is DEAD — the orchestration will NOT wake you up automatically. ` +
728
+ `You MUST call wait() now to schedule your next check-in. Call wait() with an appropriate interval to continue your loop.]`, { forgottenTimerNudged: true }));
729
+ return;
730
+ }
731
+ }
732
+ yield* schedulePostTurnContinuation(runtime);
733
+ return;
734
+ }
735
+ case "cron":
736
+ applyCronAction(runtime, result, sourcePrompt);
737
+ return;
738
+ case "cron_at":
739
+ yield* applyCronAtAction(runtime, result, sourcePrompt);
740
+ return;
741
+ case "wait": {
742
+ state.interruptedWaitTimer = null;
743
+ ensureTaskContext(runtime, sourcePrompt);
744
+ if (options.parentSessionId) {
745
+ const notifyContent = result.content
746
+ ? result.content.slice(0, 2000)
747
+ : `[wait: ${result.reason} (${result.seconds}s)]`;
748
+ const wakeDecision = shouldWakeParentForChildUpdate({
749
+ update: { kind: "wait", summary: notifyContent },
750
+ contract: state.config.childContract,
751
+ });
752
+ if (wakeDecision.wake) {
753
+ try {
754
+ yield runtime.manager.sendToSession(options.parentSessionId, `[CHILD_UPDATE from=${runtime.input.sessionId} type=wait iter=${state.iteration}]\n${notifyContent}`);
755
+ }
756
+ catch (err) {
757
+ ctx.traceInfo(`[orch] sendToSession(parent) wait failed: ${err.message} (non-fatal)`);
758
+ }
759
+ }
760
+ else {
761
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
762
+ eventType: "session.child_update_suppressed",
763
+ data: { direction: "child_to_parent", updateType: "wait", ...wakeDecision },
764
+ }]);
765
+ }
766
+ }
767
+ ctx.traceInfo(`[orch] durable timer: ${result.seconds}s (${result.reason})`);
768
+ // Lifecycle protocol: waits within the hold window keep the
769
+ // affinity GUID (worker stays warm — this is now the default,
770
+ // no wait_on_worker opt-in needed); longer waits release. The
771
+ // legacy `preserveWorkerAffinity` flag is accepted and simply
772
+ // subsumed: holds within the window always preserve affinity.
773
+ const waitPlan = planHoldRelease({
774
+ blobEnabled: state.blobEnabled,
775
+ seconds: result.seconds,
776
+ holdWindowSeconds: options.idleTimeout,
777
+ });
778
+ if (waitPlan.shouldRelease) {
779
+ yield* releaseAffinity(runtime, "timer");
780
+ }
781
+ const waitStartedAt = yield ctx.utcNow();
782
+ if (result.content) {
783
+ yield* writeLatestResponse(runtime, {
784
+ iteration: state.iteration,
785
+ type: "wait",
786
+ content: result.content,
787
+ waitReason: result.reason,
788
+ waitSeconds: result.seconds,
789
+ waitStartedAt,
790
+ model: result.model,
791
+ });
792
+ ctx.traceInfo(`[orch] intermediate: ${result.content.slice(0, 80)}`);
793
+ }
794
+ publishStatus(runtime, "waiting", {
795
+ waitSeconds: result.seconds,
796
+ waitReason: result.reason,
797
+ waitStartedAt,
798
+ preserveWorkerAffinity: !waitPlan.shouldRelease,
799
+ });
800
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
801
+ eventType: "session.wait_started",
802
+ data: { seconds: result.seconds, reason: result.reason, preserveAffinity: !waitPlan.shouldRelease },
803
+ }]);
804
+ state.activeTimer = {
805
+ deadlineMs: waitStartedAt + result.seconds * 1000,
806
+ originalDurationMs: result.seconds * 1000,
807
+ reason: result.reason,
808
+ type: "wait",
809
+ content: result.content,
810
+ };
811
+ return;
812
+ }
813
+ case "input_required": {
814
+ ctx.traceInfo(`[orch] waiting for user input: ${result.question}`);
815
+ yield* writeLatestResponse(runtime, {
816
+ iteration: state.iteration,
817
+ type: "input_required",
818
+ question: result.question,
819
+ choices: result.choices,
820
+ allowFreeform: result.allowFreeform,
821
+ model: result.model,
822
+ });
823
+ state.pendingInputQuestion = {
824
+ question: result.question,
825
+ choices: result.choices,
826
+ allowFreeform: result.allowFreeform,
827
+ };
828
+ publishStatus(runtime, "input_required");
829
+ if (!state.blobEnabled || options.inputGracePeriod < 0) {
830
+ return;
831
+ }
832
+ // Lifecycle protocol: waiting on a human is a HOLD, not a
833
+ // dehydrate — arm the hold-window timer directly (its fire
834
+ // releases affinity; an answer within the window lands warm).
835
+ if (options.inputGracePeriod === 0) {
836
+ const inputHoldNow = yield ctx.utcNow();
837
+ const inputHoldSeconds = options.idleTimeout > 0 ? options.idleTimeout : 1_800;
838
+ state.activeTimer = {
839
+ deadlineMs: inputHoldNow + inputHoldSeconds * 1000,
840
+ originalDurationMs: inputHoldSeconds * 1000,
841
+ reason: "idle timeout (input required)",
842
+ type: "idle",
843
+ };
844
+ return;
845
+ }
846
+ const graceNow = yield ctx.utcNow();
847
+ state.activeTimer = {
848
+ deadlineMs: graceNow + options.inputGracePeriod * 1000,
849
+ originalDurationMs: options.inputGracePeriod * 1000,
850
+ reason: "input grace period",
851
+ type: "input-grace",
852
+ question: result.question,
853
+ choices: result.choices,
854
+ allowFreeform: result.allowFreeform,
855
+ };
856
+ return;
857
+ }
858
+ case "cancelled":
859
+ ctx.traceInfo("[session] turn cancelled");
860
+ return;
861
+ case "stopped": {
862
+ // Defensive: a turn only classifies "stopped" when the stop marker
863
+ // was set, which normally means handleTurnStopped already ran via
864
+ // the race. Handle it anyway so a marker-set turn that somehow
865
+ // returns through the normal path still lands idle with the event
866
+ // trail (processPrompt already incremented state.iteration).
867
+ ctx.traceInfo("[session] turn reported stopped");
868
+ state.retryCount = 0;
869
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
870
+ eventType: "session.turn_stopped",
871
+ data: {
872
+ reason: result.reason ?? "Stopped by user",
873
+ turnIndex: state.iteration - 1,
874
+ interrupt: "turn-result",
875
+ },
876
+ }]);
877
+ yield runtime.manager.updateCmsState(runtime.input.sessionId, "idle");
878
+ yield* schedulePostTurnContinuation(runtime);
879
+ return;
880
+ }
881
+ case "spawn_agent":
882
+ case "message_agent":
883
+ case "check_agents":
884
+ case "list_sessions":
885
+ case "wait_for_agents":
886
+ case "complete_agent":
887
+ case "cancel_agent":
888
+ case "delete_agent":
889
+ yield* handleSubAgentAction(runtime, result);
890
+ return;
891
+ case "error": {
892
+ const missingStateIndex = result.message.indexOf(SESSION_STATE_MISSING_PREFIX);
893
+ if (missingStateIndex >= 0) {
894
+ const fatalError = result.message.slice(missingStateIndex + SESSION_STATE_MISSING_PREFIX.length).trim();
895
+ ctx.traceInfo(`[orch] fatal missing session state: ${fatalError}`);
896
+ publishStatus(runtime, "failed", { error: fatalError, fatal: true });
897
+ yield runtime.manager.updateCmsState(runtime.input.sessionId, "failed", fatalError);
898
+ throw new Error(fatalError);
899
+ }
900
+ state.retryCount++;
901
+ ctx.traceInfo(`[orch] turn returned error (attempt ${state.retryCount}/${MAX_RETRIES}): ${result.message}`);
902
+ const rc = {
903
+ sourcePrompt,
904
+ systemOnlyTurn: false,
905
+ cycleOrigin,
906
+ phase: "turn.result.error",
907
+ };
908
+ if (isCopilotConnectionClosedError(result.message)) {
909
+ yield* handleConnectionClosedRetry(runtime, result.message, rc);
910
+ return;
911
+ }
912
+ yield* handleGenericRetry(runtime, result.message, rc);
913
+ return;
914
+ }
915
+ }
916
+ }
917
+ // ─── processTimer: handle fired timers by type ──────────────
918
+ export function* processTimer(runtime, timerItem) {
919
+ const { ctx, state } = runtime;
920
+ const timer = timerItem.timer;
921
+ switch (timer.type) {
922
+ case "wait": {
923
+ const seconds = Math.round(timer.originalDurationMs / 1000);
924
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
925
+ eventType: "session.wait_completed",
926
+ data: { seconds },
927
+ }]);
928
+ const timerPrompt = `The ${seconds} second wait is now complete. Continue with your task.`;
929
+ const resumeSystemPrompt = [
930
+ timer.reason ? `Wait reason: "${timer.reason}".` : undefined,
931
+ state.taskContext ? `Original user request: "${state.taskContext}".` : undefined,
932
+ "Resume the interrupted task now.",
933
+ "Do not treat this as a new unrelated user request.",
934
+ "Do not call wait() again for the delay that already finished.",
935
+ ].filter(Boolean).join(" ");
936
+ yield* processPrompt(runtime, appendSystemContext(timerPrompt, resumeSystemPrompt) ?? timerPrompt, false);
937
+ return;
938
+ }
939
+ case "cron": {
940
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
941
+ eventType: "session.cron_fired",
942
+ data: {},
943
+ }]);
944
+ const activeCron = state.cronSchedule;
945
+ const cycleReportGuidance = "If this cycle finds material changes or blockers that should wake your parent, call report_cycle(status='material' or status='blocked', summary='...') before finishing. If nothing material changed, do NOT call report_cycle at all — just end the turn silently. Do not emit report_cycle(status='quiet') on an uneventful cycle, and never write a tool call as text.";
946
+ const cronPrompt = `[SYSTEM: Scheduled cron wake-up for: "${activeCron.reason}". Resume your recurring task. ${cycleReportGuidance}]`;
947
+ if (timer.shouldRehydrate) {
948
+ yield* processPrompt(runtime, wrapWithResumeContext(runtime, "Resume your recurring task.", `Scheduled cron wake-up for: "${activeCron.reason}". ${cycleReportGuidance}`), true, undefined, undefined, "cron");
949
+ }
950
+ else {
951
+ yield* processPrompt(runtime, cronPrompt, true, undefined, undefined, "cron");
952
+ }
953
+ return;
954
+ }
955
+ case "cron_at": {
956
+ const activeCronAt = state.cronAtSchedule;
957
+ if (!activeCronAt) {
958
+ ctx.traceInfo("[orch] cron_at timer fired but no active cronAtSchedule exists");
959
+ return;
960
+ }
961
+ const scheduledAtMs = activeCronAt.nextFireAtMs ?? timer.deadlineMs;
962
+ const occurrenceKey = activeCronAt.nextOccurrenceKey;
963
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
964
+ eventType: "session.cron_at_fired",
965
+ data: {
966
+ scheduledAt: new Date(scheduledAtMs).toISOString(),
967
+ occurrenceKey,
968
+ tz: activeCronAt.tz,
969
+ minute: activeCronAt.minute,
970
+ hour: activeCronAt.hour,
971
+ dayOfWeek: activeCronAt.dayOfWeek,
972
+ dayOfMonth: activeCronAt.dayOfMonth,
973
+ firesCompleted: activeCronAt.firesCompleted + 1,
974
+ },
975
+ }]);
976
+ const firedSchedule = {
977
+ ...activeCronAt,
978
+ firesCompleted: activeCronAt.firesCompleted + 1,
979
+ ...(occurrenceKey ? { lastOccurrenceKey: occurrenceKey } : {}),
980
+ nextFireAtMs: undefined,
981
+ nextOccurrenceKey: undefined,
982
+ };
983
+ const finalFire = firedSchedule.maxFires !== undefined && firedSchedule.firesCompleted >= firedSchedule.maxFires;
984
+ state.cronAtSchedule = finalFire ? undefined : firedSchedule;
985
+ if (finalFire) {
986
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
987
+ eventType: "session.cron_at_completed",
988
+ data: { reason: firedSchedule.reason, firesCompleted: firedSchedule.firesCompleted, maxFires: firedSchedule.maxFires },
989
+ }]);
990
+ }
991
+ const description = describeCronAt(activeCronAt);
992
+ const cronAtPrompt = `[SYSTEM: Scheduled wall-clock cron wake-up for "${activeCronAt.reason}". ` +
993
+ `Schedule: ${description}. Scheduled fire: ${new Date(scheduledAtMs).toISOString()}. ` +
994
+ `Resume your recurring task now. ` +
995
+ `If this cycle finds material changes or blockers that should wake your parent, call report_cycle(status='material' or status='blocked', summary='...') before finishing. ` +
996
+ `If nothing material changed, do NOT call report_cycle at all — just end the turn silently. ` +
997
+ `Do not emit report_cycle(status='quiet') on an uneventful cycle, and never write a tool call as text.]`;
998
+ if (timer.shouldRehydrate) {
999
+ yield* processPrompt(runtime, wrapWithResumeContext(runtime, "Resume your recurring task.", `Scheduled wall-clock cron wake-up for "${activeCronAt.reason}". ` +
1000
+ `Schedule: ${description}. Scheduled fire: ${new Date(scheduledAtMs).toISOString()}. ` +
1001
+ `If this cycle finds material changes or blockers that should wake your parent, call report_cycle(status='material' or status='blocked', summary='...') before finishing. ` +
1002
+ `If nothing material changed, do NOT call report_cycle at all — just end the turn silently. ` +
1003
+ `Do not emit report_cycle(status='quiet') on an uneventful cycle, and never write a tool call as text.`), true, undefined, undefined, "cron_at");
1004
+ }
1005
+ else {
1006
+ yield* processPrompt(runtime, cronAtPrompt, true, undefined, undefined, "cron_at");
1007
+ }
1008
+ return;
1009
+ }
1010
+ case "idle": {
1011
+ // Lifecycle protocol: hold window expired → release the worker.
1012
+ // No dehydrate — every completed turn already committed its
1013
+ // snapshot; the old worker's copy is a cache its own eviction
1014
+ // clock reclaims.
1015
+ ctx.traceInfo("[session] hold window expired, releasing worker affinity");
1016
+ yield* releaseAffinity(runtime, "idle");
1017
+ return;
1018
+ }
1019
+ case "agent-poll": {
1020
+ if (state.waitingForAgentIds) {
1021
+ const stillRunning = state.waitingForAgentIds.filter(id => {
1022
+ const agent = state.subAgents.find(a => a.orchId === id);
1023
+ return agent && !isSubAgentTerminalStatus(agent.status);
1024
+ });
1025
+ ctx.traceInfo(`[orch] wait_for_agents: fallback poll, checking ${stillRunning.length} agents`);
1026
+ for (const targetId of stillRunning) {
1027
+ const agent = state.subAgents.find(a => a.orchId === targetId);
1028
+ if (!agent || isSubAgentTerminalStatus(agent.status))
1029
+ continue;
1030
+ try {
1031
+ const rawStatus = yield runtime.manager.getSessionStatus(agent.sessionId);
1032
+ const parsed = JSON.parse(rawStatus);
1033
+ if (parsed.status === "failed") {
1034
+ agent.status = "failed";
1035
+ }
1036
+ else if (parsed.status === "completed") {
1037
+ agent.status = "completed";
1038
+ }
1039
+ else if (parsed.status === "cancelled") {
1040
+ agent.status = "cancelled";
1041
+ }
1042
+ else if (parsed.status === "waiting") {
1043
+ agent.status = "waiting";
1044
+ }
1045
+ if (parsed.result) {
1046
+ agent.result = parsed.result.slice(0, 2000);
1047
+ }
1048
+ }
1049
+ catch { }
1050
+ }
1051
+ if (yield* maybeResolveAgentWaitCompletion(runtime)) {
1052
+ return;
1053
+ }
1054
+ const nowRunning = getStillRunningAgentIds(state.subAgents, state.waitingForAgentIds);
1055
+ if (state.pendingShutdown) {
1056
+ const now = yield ctx.utcNow();
1057
+ if (now >= state.pendingShutdown.deadlineAtMs) {
1058
+ const timeoutMessage = `Graceful ${state.pendingShutdown.mode} timed out after ${Math.round(SHUTDOWN_TIMEOUT_MS / 1000)}s ` +
1059
+ `waiting for ${nowRunning.length} child session(s): ${nowRunning.join(", ") || "unknown"}`;
1060
+ yield* failPendingShutdown(runtime, timeoutMessage);
1061
+ return;
1062
+ }
1063
+ const remainingMs = Math.max(0, state.pendingShutdown.deadlineAtMs - now);
1064
+ const nextPollMs = Math.min(SHUTDOWN_POLL_INTERVAL_MS, remainingMs);
1065
+ state.activeTimer = {
1066
+ deadlineMs: now + nextPollMs,
1067
+ originalDurationMs: nextPollMs,
1068
+ reason: buildShutdownWaitReason(state.pendingShutdown),
1069
+ type: "agent-poll",
1070
+ agentIds: state.waitingForAgentIds,
1071
+ };
1072
+ publishStatus(runtime, "waiting", {
1073
+ waitReason: buildShutdownWaitReason(state.pendingShutdown),
1074
+ waitStartedAt: state.pendingShutdown.startedAtMs,
1075
+ waitSeconds: Math.ceil(remainingMs / 1000),
1076
+ });
1077
+ }
1078
+ else {
1079
+ const now = yield ctx.utcNow();
1080
+ state.activeTimer = {
1081
+ deadlineMs: now + 30_000,
1082
+ originalDurationMs: 30_000,
1083
+ reason: `waiting for ${nowRunning.length} agent(s)`,
1084
+ type: "agent-poll",
1085
+ agentIds: state.waitingForAgentIds,
1086
+ };
1087
+ }
1088
+ }
1089
+ return;
1090
+ }
1091
+ case "input-grace": {
1092
+ // Lifecycle protocol: grace elapsed without an answer → enter
1093
+ // the hold window (idle timer). The eventual idle fire releases
1094
+ // affinity; an answer any time before that lands warm.
1095
+ const graceElapsedNow = yield runtime.ctx.utcNow();
1096
+ const holdSeconds = runtime.options.idleTimeout > 0 ? runtime.options.idleTimeout : 1_800;
1097
+ state.activeTimer = {
1098
+ deadlineMs: graceElapsedNow + holdSeconds * 1000,
1099
+ originalDurationMs: holdSeconds * 1000,
1100
+ reason: "idle timeout (input required)",
1101
+ type: "idle",
1102
+ };
1103
+ return;
1104
+ }
1105
+ }
1106
+ }
1107
+ //# sourceMappingURL=turn.js.map