pilotswarm-sdk 0.4.0 → 0.5.0

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