pilotswarm-sdk 0.5.77 → 0.5.78

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 (67) hide show
  1. package/dist/managed-session.js +4 -4
  2. package/dist/managed-session.js.map +1 -1
  3. package/dist/orchestration/agents.d.ts.map +1 -1
  4. package/dist/orchestration/agents.js +4 -0
  5. package/dist/orchestration/agents.js.map +1 -1
  6. package/dist/orchestration/index.d.ts +1 -1
  7. package/dist/orchestration/index.js +1 -1
  8. package/dist/orchestration/runtime.d.ts +1 -1
  9. package/dist/orchestration-registry.d.ts.map +1 -1
  10. package/dist/orchestration-registry.js +4 -2
  11. package/dist/orchestration-registry.js.map +1 -1
  12. package/dist/orchestration-version.d.ts +1 -1
  13. package/dist/orchestration-version.js +1 -1
  14. package/dist/orchestration.d.ts +2 -2
  15. package/dist/orchestration.js +1 -1
  16. package/dist/orchestration_1_0_46.d.ts +1 -1
  17. package/dist/orchestration_1_0_47.d.ts +1 -1
  18. package/dist/orchestration_1_0_48.d.ts +1 -1
  19. package/dist/orchestration_1_0_49.d.ts +1 -1
  20. package/dist/orchestration_1_0_50.d.ts +1 -1
  21. package/dist/orchestration_1_0_78/agents.d.ts +66 -0
  22. package/dist/orchestration_1_0_78/agents.d.ts.map +1 -0
  23. package/dist/orchestration_1_0_78/agents.js +894 -0
  24. package/dist/orchestration_1_0_78/agents.js.map +1 -0
  25. package/dist/orchestration_1_0_78/index.d.ts +24 -0
  26. package/dist/orchestration_1_0_78/index.d.ts.map +1 -0
  27. package/dist/orchestration_1_0_78/index.js +13 -0
  28. package/dist/orchestration_1_0_78/index.js.map +1 -0
  29. package/dist/orchestration_1_0_78/lifecycle.d.ts +51 -0
  30. package/dist/orchestration_1_0_78/lifecycle.d.ts.map +1 -0
  31. package/dist/orchestration_1_0_78/lifecycle.js +981 -0
  32. package/dist/orchestration_1_0_78/lifecycle.js.map +1 -0
  33. package/dist/orchestration_1_0_78/queue.d.ts +20 -0
  34. package/dist/orchestration_1_0_78/queue.d.ts.map +1 -0
  35. package/dist/orchestration_1_0_78/queue.js +894 -0
  36. package/dist/orchestration_1_0_78/queue.js.map +1 -0
  37. package/dist/orchestration_1_0_78/runtime.d.ts +31 -0
  38. package/dist/orchestration_1_0_78/runtime.d.ts.map +1 -0
  39. package/dist/orchestration_1_0_78/runtime.js +244 -0
  40. package/dist/orchestration_1_0_78/runtime.js.map +1 -0
  41. package/dist/orchestration_1_0_78/state.d.ts +214 -0
  42. package/dist/orchestration_1_0_78/state.d.ts.map +1 -0
  43. package/dist/orchestration_1_0_78/state.js +178 -0
  44. package/dist/orchestration_1_0_78/state.js.map +1 -0
  45. package/dist/orchestration_1_0_78/turn.d.ts +34 -0
  46. package/dist/orchestration_1_0_78/turn.d.ts.map +1 -0
  47. package/dist/orchestration_1_0_78/turn.js +1433 -0
  48. package/dist/orchestration_1_0_78/turn.js.map +1 -0
  49. package/dist/orchestration_1_0_78/utils.d.ts +39 -0
  50. package/dist/orchestration_1_0_78/utils.d.ts.map +1 -0
  51. package/dist/orchestration_1_0_78/utils.js +322 -0
  52. package/dist/orchestration_1_0_78/utils.js.map +1 -0
  53. package/dist/session-list-timestamps.d.ts +5 -0
  54. package/dist/session-list-timestamps.d.ts.map +1 -0
  55. package/dist/session-list-timestamps.js +16 -0
  56. package/dist/session-list-timestamps.js.map +1 -0
  57. package/dist/session-proxy.d.ts +1 -0
  58. package/dist/session-proxy.d.ts.map +1 -1
  59. package/dist/session-proxy.js +6 -1
  60. package/dist/session-proxy.js.map +1 -1
  61. package/dist/types.d.ts +9 -2
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/types.js.map +1 -1
  64. package/dist/worker.d.ts.map +1 -1
  65. package/dist/worker.js +2 -0
  66. package/dist/worker.js.map +1 -1
  67. package/package.json +3 -3
@@ -0,0 +1,1433 @@
1
+ import { PROVIDER_BUDGET_WAKE_PROMPT } from "../provider-budgets.js";
2
+ import { appendSystemContextBlock, splitSystemContextBlock } from "../prompt-system-context.js";
3
+ import { SESSION_STATE_MISSING_PREFIX, stopTurnQueueName } from "../types.js";
4
+ import { createSessionProxy } from "../session-proxy.js";
5
+ import { planHoldRelease } from "../wait-affinity.js";
6
+ import { buildShutdownWaitReason, failPendingShutdown, getChildResultFromStatus, getStillRunningAgentIds, handleSubAgentAction, isSubAgentTerminalStatus, maybeResolveAgentWaitCompletion, refreshTrackedSubAgents, } from "./agents.js";
7
+ import { applyCronAtAction, applyCronAction, continueInput, continueInputWithPrompt, drainLeadingQueuedScheduleActions, ensureTaskContext, flushPendingChildDigestIntoPrompt, maybeSummarize, publishStatus, releaseAffinity, versionedContinueAsNew, wrapWithResumeContext, writeCommandResponse, writeLatestResponse, } from "./lifecycle.js";
8
+ import { describeCronAt } from "../cron-at.js";
9
+ import { shouldWakeParentForChildUpdate } from "../child-notifications.js";
10
+ import { INTERNAL_SYSTEM_TURN_PROMPT, MAX_RETRIES, SHUTDOWN_POLL_INTERVAL_MS, SHUTDOWN_TIMEOUT_MS, } from "./state.js";
11
+ 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";
12
+ function currentModelLabel(runtime) {
13
+ const model = runtime.state.config.model || "(default)";
14
+ const effort = runtime.state.config.reasoningEffort;
15
+ return effort ? `${model}:${effort}` : model;
16
+ }
17
+ /**
18
+ * Scan a finished turn's captured events for a failed `set_session_model` tool
19
+ * call. The inline control tool returns its outcome as a plain string (see
20
+ * session-proxy `setSessionModel`), so `tool.execution_complete.data.result`
21
+ * is usually a string; some transports wrap it as `{ content }`. We match the
22
+ * `set_session_model failed` marker across both shapes. Exported for unit tests.
23
+ */
24
+ export function detectFailedModelSwitch(events) {
25
+ if (!Array.isArray(events))
26
+ return null;
27
+ for (const event of events) {
28
+ if (event?.eventType !== "tool.execution_complete")
29
+ continue;
30
+ const data = event.data || {};
31
+ const result = data.result ?? data.output;
32
+ const content = typeof result === "string"
33
+ ? result
34
+ : String(result?.content ?? result?.detailedContent ?? data.content ?? "");
35
+ if (/set_session_model (?:failed|is unavailable|rejected)/i.test(content))
36
+ return content.trim();
37
+ }
38
+ return null;
39
+ }
40
+ function captureFailedModelSwitchNotice(runtime, result) {
41
+ const failure = detectFailedModelSwitch(result?.events);
42
+ if (!failure)
43
+ return null;
44
+ const modelLabel = currentModelLabel(runtime);
45
+ runtime.state.runtimeModelNotice = `Previous model switch failed; current runtime model is ${modelLabel}. If asked what model you are using, answer this value.`;
46
+ runtime.ctx.traceInfo(`[orch] queued failed model-switch correction: ${failure.slice(0, 160)}`);
47
+ return `Continue on ${modelLabel}; the requested model switch failed.`;
48
+ }
49
+ function* handleConnectionClosedRetry(runtime, errorMessage, rc) {
50
+ const { state } = runtime;
51
+ if (state.retryCount <= COPILOT_CONNECTION_CLOSED_MAX_RETRIES) {
52
+ const retryDetail = buildConnectionClosedRetryDetail(state.retryCount);
53
+ publishStatus(runtime, "error", {
54
+ error: `${errorMessage} (${retryDetail})`,
55
+ recoverableTransportLoss: true,
56
+ });
57
+ runtime.ctx.traceInfo(`[orch] live Copilot connection lost; retrying in ${COPILOT_CONNECTION_CLOSED_RETRY_DELAY_SECONDS}s`);
58
+ // Lifecycle protocol: nothing to dehydrate — the last commit is the
59
+ // durable truth. Release affinity so the retry can land anywhere;
60
+ // the retry's preamble hydrates clean from the committed snapshot
61
+ // (the broken warm session is detected via the turn sentinel).
62
+ if (state.blobEnabled) {
63
+ yield* releaseAffinity(runtime, "error", {
64
+ detail: retryDetail,
65
+ error: errorMessage,
66
+ phase: rc.phase,
67
+ retryAttempt: state.retryCount,
68
+ maxRetries: COPILOT_CONNECTION_CLOSED_MAX_RETRIES,
69
+ retryDelaySeconds: COPILOT_CONNECTION_CLOSED_RETRY_DELAY_SECONDS,
70
+ });
71
+ }
72
+ yield runtime.ctx.scheduleTimer(COPILOT_CONNECTION_CLOSED_RETRY_DELAY_SECONDS * 1000);
73
+ yield* versionedContinueAsNew(runtime, continueInput(runtime, retryContinueOverrides(state, rc)));
74
+ return;
75
+ }
76
+ const handoffMessage = buildLossyHandoffSummary(errorMessage);
77
+ runtime.ctx.traceInfo(`[orch] ${handoffMessage}`);
78
+ publishStatus(runtime, "error", {
79
+ error: handoffMessage,
80
+ retriesExhausted: true,
81
+ lossyHandoff: true,
82
+ });
83
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
84
+ eventType: "session.lossy_handoff",
85
+ data: {
86
+ message: handoffMessage,
87
+ error: errorMessage,
88
+ phase: rc.phase,
89
+ retries: COPILOT_CONNECTION_CLOSED_MAX_RETRIES,
90
+ retryDelaySeconds: COPILOT_CONNECTION_CLOSED_RETRY_DELAY_SECONDS,
91
+ nextStep: "release_affinity_and_resume_on_any_worker",
92
+ },
93
+ }]);
94
+ if (state.blobEnabled) {
95
+ yield* releaseAffinity(runtime, "lossy_handoff", {
96
+ detail: handoffMessage,
97
+ error: errorMessage,
98
+ phase: rc.phase,
99
+ retries: COPILOT_CONNECTION_CLOSED_MAX_RETRIES,
100
+ retryDelaySeconds: COPILOT_CONNECTION_CLOSED_RETRY_DELAY_SECONDS,
101
+ nextStep: "release_affinity_and_resume_on_any_worker",
102
+ });
103
+ yield* versionedContinueAsNew(runtime, continueInput(runtime, {
104
+ ...retryContinueOverrides(state, rc),
105
+ retryCount: 0,
106
+ rehydrationMessage: buildLossyHandoffRehydrationMessage(errorMessage),
107
+ }));
108
+ return;
109
+ }
110
+ publishStatus(runtime, "error", {
111
+ error: `${handoffMessage} Durable handoff is unavailable because blob persistence is disabled.`,
112
+ retriesExhausted: true,
113
+ lossyHandoff: false,
114
+ });
115
+ state.retryCount = 0;
116
+ }
117
+ function retryContinueOverrides(state, rc) {
118
+ if (rc.phase === "turn.result.error") {
119
+ return {
120
+ prompt: rc.sourcePrompt,
121
+ ...(rc.requiredTool ? { requiredTool: rc.requiredTool } : {}),
122
+ ...(rc.cycleOrigin ? { cycleOrigin: rc.cycleOrigin } : {}),
123
+ retryCount: state.retryCount,
124
+ needsHydration: state.needsHydration,
125
+ };
126
+ }
127
+ // 1.0.71: `sourcePrompt` already carries the turn's note as a trailing
128
+ // <system_context> block, so the retried execution gets it from the
129
+ // prompt alone. Forwarding `turnSystemPrompt` as well would land it in
130
+ // `pendingSystemPrompt` and append it a SECOND time. A system-only turn
131
+ // forwards its prompt too (INTERNAL_SYSTEM_TURN_PROMPT + block) and keeps
132
+ // its bootstrap flag, which is what ≤1.0.70 re-derived from the bare
133
+ // systemPrompt.
134
+ return {
135
+ prompt: rc.sourcePrompt,
136
+ ...(rc.systemOnlyTurn ? { bootstrapPrompt: true } : {}),
137
+ ...(rc.requiredTool ? { requiredTool: rc.requiredTool } : {}),
138
+ ...(rc.cycleOrigin ? { cycleOrigin: rc.cycleOrigin } : {}),
139
+ retryCount: state.retryCount,
140
+ needsHydration: state.needsHydration,
141
+ };
142
+ }
143
+ /** @internal Project a non-retryable credential failure without terminating the orchestration. */
144
+ export function* projectAuthFailure(runtime, errorMessage) {
145
+ const blockedDetail = `${errorMessage} — ${AUTH_FAILURE_USER_HINT}`;
146
+ runtime.state.blockedError = { message: blockedDetail, authFailure: true };
147
+ publishStatus(runtime, "error", {
148
+ error: blockedDetail,
149
+ retriesExhausted: true,
150
+ authFailure: true,
151
+ });
152
+ yield* writeLatestResponse(runtime, {
153
+ iteration: runtime.state.iteration,
154
+ type: "error",
155
+ content: blockedDetail,
156
+ });
157
+ yield runtime.manager.updateCmsState(runtime.input.sessionId, "error", blockedDetail, null);
158
+ runtime.state.retryCount = 0;
159
+ }
160
+ function* projectNonRetryableTurnFailure(runtime, errorMessage) {
161
+ runtime.state.blockedError = { message: errorMessage };
162
+ publishStatus(runtime, "error", {
163
+ error: errorMessage,
164
+ retriesExhausted: true,
165
+ nonRetryable: true,
166
+ });
167
+ yield* writeLatestResponse(runtime, {
168
+ iteration: runtime.state.iteration,
169
+ type: "error",
170
+ content: errorMessage,
171
+ });
172
+ yield runtime.manager.updateCmsState(runtime.input.sessionId, "error", errorMessage, null);
173
+ if (runtime.options.parentSessionId && !runtime.state.reportedFirstCompletionToParent) {
174
+ try {
175
+ yield runtime.manager.sendToSession(runtime.options.parentSessionId, `[CHILD_UPDATE from=${runtime.input.sessionId} type=failed iter=${runtime.state.iteration} verdict=failed]\n${errorMessage.slice(0, 2000)}`);
176
+ runtime.state.reportedFirstCompletionToParent = true;
177
+ }
178
+ catch (err) {
179
+ runtime.ctx.traceInfo(`[orch] sendToSession(parent) non-retryable failure failed: ${err.message} (non-fatal)`);
180
+ }
181
+ }
182
+ runtime.state.retryCount = 0;
183
+ }
184
+ function* handleGenericRetry(runtime, errorMessage, rc) {
185
+ const { state } = runtime;
186
+ if (state.retryCount >= MAX_RETRIES) {
187
+ runtime.ctx.traceInfo(`[orch] max retries exhausted, waiting for user input`);
188
+ publishStatus(runtime, "error", {
189
+ error: `Failed after ${MAX_RETRIES} attempts: ${errorMessage}`,
190
+ retriesExhausted: true,
191
+ });
192
+ // The status plane above is transient — the next park wiped it, so
193
+ // the session settled idle with NO error and a silently lost prompt
194
+ // (2026-08-24 campaign). Persist the failure on the session row;
195
+ // the next successful turn's writeback clears it.
196
+ // (New yield — part of the 1.0.69 schedule, first shipped there.)
197
+ yield runtime.manager.updateCmsState(runtime.input.sessionId, "error", `Failed after ${MAX_RETRIES} attempts: ${errorMessage}`);
198
+ state.retryCount = 0;
199
+ return;
200
+ }
201
+ const retryDelay = 15 * Math.pow(2, state.retryCount - 1);
202
+ publishStatus(runtime, "error", {
203
+ error: `${errorMessage} (retry ${state.retryCount}/${MAX_RETRIES} in ${retryDelay}s)`,
204
+ });
205
+ runtime.ctx.traceInfo(`[orch] retrying in ${retryDelay}s${rc.phase === "turn.result.error" ? " after turn error" : ""}`);
206
+ if (state.blobEnabled) {
207
+ yield* releaseAffinity(runtime, "error", {
208
+ detail: errorMessage,
209
+ error: errorMessage,
210
+ phase: rc.phase,
211
+ retryAttempt: state.retryCount,
212
+ maxRetries: MAX_RETRIES,
213
+ retryDelaySeconds: retryDelay,
214
+ });
215
+ }
216
+ yield runtime.ctx.scheduleTimer(retryDelay * 1000);
217
+ yield* versionedContinueAsNew(runtime, continueInput(runtime, retryContinueOverrides(state, rc)));
218
+ }
219
+ // ─── processPrompt: hydrate → runTurn → handleTurnResult ────
220
+ export function* processPrompt(runtime, promptText, isBootstrap, requiredTool, clientMessageIds, cycleOrigin, sender, attachments) {
221
+ const { ctx, state } = runtime;
222
+ // A provider-budget refusal stashes the whole pending turn contract, not
223
+ // just its text. Any wake or interrupt that finally reaches the model must
224
+ // enforce the same requiredTool as the refused attempt.
225
+ requiredTool ??= state.budgetStash?.find((entry) => entry.requiredTool)?.requiredTool;
226
+ let prompt = promptText;
227
+ let promptIsBootstrap = isBootstrap;
228
+ // Lifecycle protocol (P5): no needsHydration probe. The old protocol
229
+ // asked a worker "do you have my files?" before every turn — an extra
230
+ // session activity whose answer could desync from reality, and whose
231
+ // "no" triggered a legacy hydrate that the runTurn preamble would then
232
+ // repeat (double download per cold wake). The preamble self-validates
233
+ // against the versioned store; state.needsHydration survives only as
234
+ // one-shot normalization of legacy (≤1.0.56) continue-as-new inputs.
235
+ if (state.needsHydration && state.blobEnabled && prompt) {
236
+ prompt = wrapWithResumeContext(runtime, prompt);
237
+ }
238
+ let turnSystemPrompt = state.pendingSystemPrompt;
239
+ state.pendingSystemPrompt = undefined;
240
+ const extractedPrompt = extractPromptSystemContext(prompt);
241
+ prompt = extractedPrompt.prompt ?? "";
242
+ turnSystemPrompt = mergePrompt(turnSystemPrompt, extractedPrompt.systemPrompt);
243
+ if (prompt && state.runtimeModelNotice) {
244
+ turnSystemPrompt = mergePrompt(turnSystemPrompt, state.runtimeModelNotice);
245
+ state.runtimeModelNotice = undefined;
246
+ }
247
+ const systemOnlyTurn = !prompt && !!turnSystemPrompt;
248
+ if (systemOnlyTurn) {
249
+ prompt = INTERNAL_SYSTEM_TURN_PROMPT;
250
+ promptIsBootstrap = true;
251
+ }
252
+ // 1.0.71: the note is delivered INSIDE the user turn, not the system
253
+ // message. `turnSystemPrompt` is still set — session-proxy records it as
254
+ // the `system.message` event, exactly as before — but the flag tells
255
+ // session-manager not to render it into `last_instructions`. A note that
256
+ // changes every wake-up in the system message rewrote the request prefix
257
+ // and cost the whole provider cache behind it (chk: 12% hit vs 93–99%).
258
+ // See prompt-system-context.ts.
259
+ state.config.turnSystemPrompt = turnSystemPrompt;
260
+ state.config.systemContextInPrompt = true;
261
+ prompt = appendSystemContextBlock(prompt, turnSystemPrompt);
262
+ ctx.traceInfo(`[turn ${state.iteration}] session=${runtime.input.sessionId} prompt="${prompt.slice(0, 80)}"`);
263
+ if (state.needsHydration && state.blobEnabled) {
264
+ let hydrateAttempts = 0;
265
+ while (true) {
266
+ try {
267
+ if (!state.preserveAffinityOnHydrate) {
268
+ state.affinityKey = yield ctx.newGuid();
269
+ }
270
+ runtime.session = createSessionProxy(ctx, runtime.input.sessionId, state.affinityKey, state.config, "agent-handoff-v2");
271
+ yield runtime.session.hydrate();
272
+ state.needsHydration = false;
273
+ state.preserveAffinityOnHydrate = false;
274
+ break;
275
+ }
276
+ catch (hydrateErr) {
277
+ const hMsg = hydrateErr.message || String(hydrateErr);
278
+ if (hMsg.includes("blob does not exist")
279
+ || hMsg.includes("BlobNotFound")
280
+ || hMsg.includes("Session archive not found")
281
+ || hMsg.includes("404")) {
282
+ ctx.traceInfo(`[orch] hydrate skipped — blob not found, starting fresh session`);
283
+ state.needsHydration = false;
284
+ state.preserveAffinityOnHydrate = false;
285
+ break;
286
+ }
287
+ hydrateAttempts++;
288
+ ctx.traceInfo(`[orch] hydrate FAILED (attempt ${hydrateAttempts}/${MAX_RETRIES}): ${hMsg}`);
289
+ if (hydrateAttempts >= MAX_RETRIES) {
290
+ publishStatus(runtime, "error", {
291
+ error: `Hydrate failed after ${MAX_RETRIES} attempts: ${hMsg}`,
292
+ retriesExhausted: true,
293
+ });
294
+ break;
295
+ }
296
+ const hydrateDelay = 10 * Math.pow(2, hydrateAttempts - 1);
297
+ publishStatus(runtime, "error", {
298
+ error: `Hydrate failed: ${hMsg} (retry ${hydrateAttempts}/${MAX_RETRIES} in ${hydrateDelay}s)`,
299
+ });
300
+ yield ctx.scheduleTimer(hydrateDelay * 1000);
301
+ }
302
+ }
303
+ if (state.needsHydration)
304
+ return;
305
+ }
306
+ if (state.config.agentIdentity !== "facts-manager") {
307
+ try {
308
+ yield runtime.manager.loadKnowledgeIndex();
309
+ }
310
+ catch (knErr) {
311
+ ctx.traceInfo(`[orch] loadKnowledgeIndex failed (non-fatal): ${knErr.message || knErr}`);
312
+ }
313
+ }
314
+ publishStatus(runtime, "running", { iteration: state.iteration + 1 });
315
+ let turnResult;
316
+ try {
317
+ // Stop-turn race: the in-flight runTurn activity vs a dequeue on the
318
+ // TURN-SCOPED stop queue (stopTurn.<iteration>). Scoping the queue to
319
+ // the turn index makes stale stop events structurally unable to kill a
320
+ // later turn — a race loser is dropped and cannot be un-dropped.
321
+ // When the stop wins, duroxide cancel-requests the dropped runTurn
322
+ // work item (lock-steal → isCancelled poll → SDK abort) as the
323
+ // guaranteed backstop; handleTurnStopped layers the fast-path
324
+ // same-affinity abortTurn on top.
325
+ // Lifecycle protocol: a deterministic per-turn key (recorded GUID)
326
+ // rides in the activity input with the last committed version. The
327
+ // worker self-validates against them (preamble) and commits the
328
+ // post-turn snapshot inside the activity, returning the new version.
329
+ const snapshotTurnKey = state.blobEnabled ? yield ctx.newGuid() : "";
330
+ const turnTask = runtime.session.runTurn(prompt, promptIsBootstrap, state.iteration, {
331
+ ...(runtime.options.parentSessionId ? { parentSessionId: runtime.options.parentSessionId } : {}),
332
+ nestingLevel: runtime.options.nestingLevel,
333
+ // Session regeneration: scope the worker's store access to the
334
+ // current epoch chain; the first post-flip turn dispatches as
335
+ // runTurn2 (conditional epoch init — see createSessionProxy).
336
+ ...(state.transcriptEpoch > 0 ? { transcriptEpoch: state.transcriptEpoch } : {}),
337
+ ...(state.epochStartPending ? { epochStart: true } : {}),
338
+ ...(requiredTool ? { requiredTool } : {}),
339
+ ...(cycleOrigin ? { cycleOrigin } : {}),
340
+ retryCount: state.retryCount,
341
+ ...(clientMessageIds && clientMessageIds.length > 0 ? { clientMessageIds } : {}),
342
+ // Prompts the gate refused earlier, already durably recorded as
343
+ // user.message at stash time. The activity folds them in front of
344
+ // the model prompt and does NOT re-record them.
345
+ ...(state.budgetStash && state.budgetStash.length > 0
346
+ ? { stashedPrompts: state.budgetStash.map((s) => s.prompt) }
347
+ : {}),
348
+ ...(sender ? { sender } : {}),
349
+ ...(attachments && attachments.length > 0 ? { attachments } : {}),
350
+ // Store-wins (1.0.59): send only the turnKey. expectedVersion is
351
+ // retired from the wire — the store-wins worker reconciles against
352
+ // the store's own version, never the orchestration's belief.
353
+ // state.snapshotVersion remains an internal telemetry mirror (it
354
+ // powers snapshot_lineage_jump); it is simply no longer transmitted.
355
+ ...(snapshotTurnKey
356
+ ? { snapshot: { turnKey: snapshotTurnKey } }
357
+ : {}),
358
+ });
359
+ const stopTask = ctx.dequeueEvent(stopTurnQueueName(state.iteration));
360
+ const race = yield ctx.race(turnTask, stopTask);
361
+ if (race.index === 1) {
362
+ yield* handleTurnStopped(runtime, race.value, clientMessageIds);
363
+ return;
364
+ }
365
+ // The select bridge flattens activity failures into their raw error
366
+ // string (duroxide-node make_select_future) instead of throwing, so a
367
+ // failed runTurn must be re-thrown here to reach the existing retry
368
+ // machinery in the catch below.
369
+ const raced = normalizeRacedTurnValue(race.value);
370
+ if (raced.kind === "error") {
371
+ throw new Error(raced.message);
372
+ }
373
+ turnResult = raced.result;
374
+ // Session regeneration: the rebirth is PROVEN only by the epoch-start
375
+ // turn's committed snapshot (or, for storeless sessions, a non-error
376
+ // result). Until then health reads rebuilding and session.regenerated
377
+ // never fires; a failing grounding turn retries with epochStartPending
378
+ // intact so a retry re-enters the conditional epoch init.
379
+ if (state.epochStartPending && state.pendingEpochCommit) {
380
+ const resultType = String(turnResult?.type ?? "");
381
+ const snapVersion = Number(turnResult?.snapshotVersion);
382
+ const proven = Number.isFinite(snapVersion) && snapVersion >= 1
383
+ ? true
384
+ : (!state.blobEnabled && resultType !== "error" && resultType !== "stopped");
385
+ if (proven) {
386
+ const commit = state.pendingEpochCommit;
387
+ state.epochStartPending = false;
388
+ state.pendingEpochCommit = null;
389
+ const nowMs = yield ctx.utcNow();
390
+ yield runtime.manager.recordRegenerated(runtime.input.sessionId, {
391
+ epoch: commit.toEpoch,
392
+ attemptId: commit.attemptId,
393
+ stats: {
394
+ kind: "regen",
395
+ fromEpoch: commit.fromEpoch,
396
+ toEpoch: commit.toEpoch,
397
+ trigger: commit.trigger,
398
+ ...(commit.archiveMs ? { archiveMs: commit.archiveMs } : {}),
399
+ ...(commit.distillMs ? { distillMs: commit.distillMs } : {}),
400
+ ...(commit.turnsArchived ? { turnsArchived: commit.turnsArchived } : {}),
401
+ ...(commit.compactionsArchived ? { compactionsArchived: commit.compactionsArchived } : {}),
402
+ ...(commit.distillMode ? { distillMode: commit.distillMode } : {}),
403
+ ...(commit.distillerModel ? { distillerModel: commit.distillerModel } : {}),
404
+ ...(commit.distillerSessionId ? { distillerSessionId: commit.distillerSessionId } : {}),
405
+ totalMs: Math.max(0, nowMs - commit.requestedAtMs || 0),
406
+ },
407
+ });
408
+ ctx.traceInfo(`[orch] epoch ${commit.toEpoch} rebirth proven (snapshot v${snapVersion || 0})`);
409
+ }
410
+ }
411
+ }
412
+ catch (err) {
413
+ state.config.turnSystemPrompt = undefined;
414
+ const errorMsg = err.message || String(err);
415
+ const missingStateIndex = errorMsg.indexOf(SESSION_STATE_MISSING_PREFIX);
416
+ if (missingStateIndex >= 0) {
417
+ const fatalError = errorMsg.slice(missingStateIndex + SESSION_STATE_MISSING_PREFIX.length).trim();
418
+ ctx.traceInfo(`[orch] fatal missing session state: ${fatalError}`);
419
+ publishStatus(runtime, "failed", { error: fatalError, fatal: true });
420
+ yield runtime.manager.updateCmsState(runtime.input.sessionId, "failed", fatalError);
421
+ throw new Error(fatalError);
422
+ }
423
+ if (isAuthFailureError(errorMsg)) {
424
+ ctx.traceInfo(`[orch] runTurn FAILED with auth error; not retrying: ${errorMsg}`);
425
+ yield* projectAuthFailure(runtime, errorMsg);
426
+ return;
427
+ }
428
+ state.retryCount++;
429
+ ctx.traceInfo(`[orch] runTurn FAILED (attempt ${state.retryCount}/${MAX_RETRIES}): ${errorMsg}`);
430
+ const rc = {
431
+ sourcePrompt: prompt,
432
+ systemOnlyTurn,
433
+ requiredTool,
434
+ cycleOrigin,
435
+ turnSystemPrompt,
436
+ phase: "runTurn.throw",
437
+ };
438
+ if (isCopilotConnectionClosedError(errorMsg)) {
439
+ yield* handleConnectionClosedRetry(runtime, errorMsg, rc);
440
+ return;
441
+ }
442
+ yield* handleGenericRetry(runtime, errorMsg, rc);
443
+ return;
444
+ }
445
+ state.config.turnSystemPrompt = undefined;
446
+ let result = typeof turnResult === "string" ? JSON.parse(turnResult) : turnResult;
447
+ // Reset the retry counter only when the turn actually succeeded. This
448
+ // blanket-reset used to run for EVERY returned result — including
449
+ // {type:"error"} — so a failure the activity returned (rather than
450
+ // threw) could never count past "retry 1/3": each cycle wiped the
451
+ // carried count, continued-as-new, and started over. An invalid
452
+ // credential looped an execution every ~18 seconds for ever
453
+ // (2026-08-24, found live by the regression workflow).
454
+ if (result?.type !== "error")
455
+ state.retryCount = 0;
456
+ // Lifecycle protocol: adopt the version the activity committed. The
457
+ // returned value is authoritative even when it disagrees with the
458
+ // expectation (self-healing after a store restore). state.snapshotVersion
459
+ // is a telemetry MIRROR only — store-wins never gates on it.
460
+ if (typeof result?.snapshotVersion === "number") {
461
+ const adoptedVersion = result.snapshotVersion;
462
+ const priorVersion = state.snapshotVersion;
463
+ // Store-wins observability: the adopted store version diverged from
464
+ // prior+1 — someone else moved the store the control plane didn't author.
465
+ // forward (adopted > prior+1): a discarded/foreign turn published in
466
+ // the gap and this turn hydrated + committed on top (the
467
+ // incident's self-heal).
468
+ // backward (adopted < prior): the store regressed below the mirror — a
469
+ // restore from an older backup / data loss — which is silent
470
+ // on a fresh markerless worker (no local marker → no
471
+ // snapshot_regressed), so the mirror is the only witness.
472
+ // Deterministic on replay: both operands come from recorded state and
473
+ // the recorded activity result. (New yield in 1.0.59 — the reason this
474
+ // change required freezing 1.0.58; see orchestration_1_0_58/.)
475
+ const forwardJump = adoptedVersion > priorVersion + 1;
476
+ const backwardJump = adoptedVersion < priorVersion;
477
+ if (priorVersion > 0 && (forwardJump || backwardJump)) {
478
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
479
+ eventType: "session.snapshot_lineage_jump",
480
+ data: { from: priorVersion, to: adoptedVersion, direction: backwardJump ? "backward" : "forward" },
481
+ }]);
482
+ }
483
+ state.snapshotVersion = adoptedVersion;
484
+ }
485
+ const observedAt = yield ctx.utcNow();
486
+ state.contextUsage = updateContextUsageFromEvents(state.contextUsage, result?.events, observedAt);
487
+ const failedModelSwitchContinuePrompt = captureFailedModelSwitchNotice(runtime, result);
488
+ if (failedModelSwitchContinuePrompt && result.type === "completed") {
489
+ result = {
490
+ ...result,
491
+ forceContinuePrompt: failedModelSwitchContinuePrompt,
492
+ };
493
+ }
494
+ // A gate-refused turn never ran: no model call, no Copilot state, no
495
+ // charge. Burning a turn index for it made the NEXT turn ask for
496
+ // resumable state at an index that never existed — the worker threw
497
+ // SESSION_STATE_MISSING, and the runtime then wrote a lossy_handoff
498
+ // blaming "a worker restart" that never happened. Deterministic on any
499
+ // session whose first turn was refused.
500
+ const budgetRefused = result.type === "wait" && result.budget === true;
501
+ if (!budgetRefused)
502
+ state.iteration++;
503
+ yield* maybeSummarize(runtime);
504
+ yield* refreshTrackedSubAgents(runtime);
505
+ if ("queuedActions" in result && Array.isArray(result.queuedActions) && result.queuedActions.length > 0) {
506
+ state.pendingToolActions.push(...result.queuedActions);
507
+ ctx.traceInfo(`[orch] queued ${result.queuedActions.length} extra action(s) from turn`);
508
+ }
509
+ yield* drainLeadingQueuedScheduleActions(runtime, prompt);
510
+ yield* handleTurnResult(runtime, result, prompt, cycleOrigin, clientMessageIds, promptIsBootstrap, requiredTool);
511
+ }
512
+ // ─── Stop-turn race support ─────────────────────────────────
513
+ /**
514
+ * Normalize a raced runTurn branch value. The duroxide-node select bridge
515
+ * flattens activity failures into their raw error string (make_select_future:
516
+ * `Ok(v) => v, Err(e) => e`) instead of throwing into the generator, so the
517
+ * caller must distinguish a TurnResult payload from an error message.
518
+ */
519
+ export function normalizeRacedTurnValue(value) {
520
+ let v = value;
521
+ if (typeof v === "string") {
522
+ try {
523
+ v = JSON.parse(v);
524
+ }
525
+ catch {
526
+ return { kind: "error", message: value };
527
+ }
528
+ }
529
+ if (v && typeof v === "object" && typeof v.type === "string") {
530
+ return { kind: "result", result: v };
531
+ }
532
+ return { kind: "error", message: typeof value === "string" ? value : JSON.stringify(value ?? null) };
533
+ }
534
+ /**
535
+ * Stop won the race against the in-flight runTurn activity.
536
+ *
537
+ * The dropped runTurn future is already cancel-requested by duroxide (the
538
+ * guaranteed backstop: lock-steal → isCancelled poll → SDK abort, ~2-7s).
539
+ * This path layers the fast-path interrupt on top and owns the authoritative
540
+ * durable bookkeeping — the aborted activity's own writeback is best-effort
541
+ * (it is skipped entirely when the backstop delivered the abort).
542
+ */
543
+ function* handleTurnStopped(runtime, stopEventRaw, clientMessageIds) {
544
+ const { ctx, state } = runtime;
545
+ let stopEvent = stopEventRaw;
546
+ if (typeof stopEvent === "string") {
547
+ try {
548
+ stopEvent = JSON.parse(stopEvent);
549
+ }
550
+ catch {
551
+ stopEvent = {};
552
+ }
553
+ }
554
+ if (!stopEvent || typeof stopEvent !== "object")
555
+ stopEvent = {};
556
+ const reason = typeof stopEvent.reason === "string" && stopEvent.reason ? stopEvent.reason : "Stopped by user";
557
+ const stoppedIteration = state.iteration;
558
+ ctx.traceInfo(`[orch] stop_turn won the race for turn ${stoppedIteration}; aborting in-flight turn`);
559
+ state.config.turnSystemPrompt = undefined;
560
+ state.retryCount = 0;
561
+ // Fast-path interrupt: same-affinity abortTurn lands on the worker owning
562
+ // the warm session and aborts the SDK request immediately (concurrent
563
+ // dispatch requires stable workerNodeId + a free slot; otherwise the
564
+ // backstop still stops the turn, just slower). Awaiting it also
565
+ // guarantees the per-session run-turn lock is free again before this
566
+ // loop can dispatch the next prompt.
567
+ let abortOutcome = null;
568
+ try {
569
+ const raw = yield runtime.session.abortTurn(reason, stoppedIteration);
570
+ abortOutcome = typeof raw === "string" ? JSON.parse(raw) : raw;
571
+ }
572
+ catch (err) {
573
+ ctx.traceInfo(`[orch] abortTurn activity failed (backstop cancellation still applies): ${err?.message ?? err}`);
574
+ abortOutcome = { outcome: "no_active_turn", detail: `abortTurn failed: ${err?.message ?? err}` };
575
+ }
576
+ // The race already decided the turn's fate: even when abortTurn reports
577
+ // no_active_turn (the backstop got there first, or the turn had just
578
+ // ended), the user's stop is the durable outcome. Record turn_stopped
579
+ // unconditionally and annotate how the interrupt was delivered.
580
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [
581
+ {
582
+ eventType: "session.turn_stopped",
583
+ data: {
584
+ reason,
585
+ turnIndex: stoppedIteration,
586
+ interrupt: abortOutcome?.outcome ?? "unknown",
587
+ ...(abortOutcome?.detail ? { detail: abortOutcome.detail } : {}),
588
+ ...(clientMessageIds && clientMessageIds.length > 0 ? { clientMessageIds } : {}),
589
+ },
590
+ },
591
+ { eventType: "system.message", data: { content: "Turn stopped by user." } },
592
+ ]);
593
+ // Authoritative CMS transition — also clears active_turn_index (migration
594
+ // 0024 clears it on any state transition away from "running").
595
+ yield runtime.manager.updateCmsState(runtime.input.sessionId, "idle");
596
+ // The turn ran and consumed context even though its result was discarded.
597
+ state.iteration++;
598
+ if (typeof stopEvent.id === "string" && stopEvent.id) {
599
+ yield* writeCommandResponse(runtime, {
600
+ id: stopEvent.id,
601
+ cmd: "stop_turn",
602
+ result: {
603
+ outcome: abortOutcome?.outcome === "stop_forced" ? "stop_forced" : "stopped",
604
+ turnIndex: stoppedIteration,
605
+ ...(abortOutcome?.detail ? { detail: abortOutcome.detail } : {}),
606
+ },
607
+ });
608
+ }
609
+ // Same scheduling semantics as a completed turn: resume interrupted
610
+ // timers, re-arm cron schedules, else idle (skips: writeLatestResponse,
611
+ // parent CHILD_UPDATE notify, forgotten-timer nudge).
612
+ yield* schedulePostTurnContinuation(runtime);
613
+ }
614
+ // ─── Post-turn continuation: resume timers / re-arm schedules / go idle ───
615
+ //
616
+ // Extracted verbatim from the tail of the `completed` turn-result case so the
617
+ // stop-turn path shares identical scheduling semantics: stopping a turn must
618
+ // not silently kill a recurring session's cron loop or a resumable wait
619
+ // (stop-turn plan, edge E9).
620
+ function* schedulePostTurnContinuation(runtime) {
621
+ const { ctx, state, options } = runtime;
622
+ // A PROVIDER BUDGET pause is never re-armed. Every other wait is the
623
+ // agent's own: it asked to sleep for N seconds, a message arrived, and
624
+ // the remaining time is still owed. A budget pause owes nothing — it
625
+ // exists only while the budget blocks, and the turn that just ran
626
+ // re-asked the gate on its way in. Re-arming it here is what put a
627
+ // just-released session back to sleep for the rest of its window, so
628
+ // that the raise which woke it appeared to do nothing.
629
+ //
630
+ // THIS is the 1.0.69 schedule change: for a budget wait the yields below
631
+ // (utcNow, and possibly releaseAffinity) do not happen at all. 1.0.68 is
632
+ // frozen beside this file because of it.
633
+ if (state.interruptedWaitTimer?.budget) {
634
+ ctx.traceInfo(`[orch] dropping interrupted budget wait — the gate decides afresh each turn`);
635
+ state.interruptedWaitTimer = null;
636
+ }
637
+ if (state.interruptedWaitTimer && state.interruptedWaitTimer.remainingSec > 0) {
638
+ const saved = state.interruptedWaitTimer;
639
+ state.interruptedWaitTimer = null;
640
+ ctx.traceInfo(`[orch] auto-resuming interrupted wait: ${saved.remainingSec}s (${saved.reason})`);
641
+ // Lifecycle protocol: state is durable from the turn commit — the
642
+ // wait only decides hold (keep GUID, worker stays warm) vs release
643
+ // (rotate GUID, wake-up hydrates anywhere).
644
+ const resumeWaitPlan = planHoldRelease({
645
+ blobEnabled: state.blobEnabled,
646
+ seconds: saved.remainingSec,
647
+ holdWindowSeconds: options.idleTimeout,
648
+ });
649
+ if (resumeWaitPlan.shouldRelease) {
650
+ yield* releaseAffinity(runtime, "timer");
651
+ }
652
+ const resumeNow = yield ctx.utcNow();
653
+ publishStatus(runtime, "waiting", {
654
+ waitSeconds: saved.remainingSec,
655
+ waitReason: saved.reason,
656
+ waitStartedAt: resumeNow,
657
+ });
658
+ state.activeTimer = {
659
+ deadlineMs: resumeNow + saved.remainingSec * 1000,
660
+ originalDurationMs: saved.remainingSec * 1000,
661
+ reason: saved.reason,
662
+ type: "wait",
663
+ };
664
+ return;
665
+ }
666
+ if (state.interruptedCronTimer && state.interruptedCronTimer.remainingMs > 0) {
667
+ const saved = state.interruptedCronTimer;
668
+ state.interruptedCronTimer = null;
669
+ const remainingMs = Math.max(0, saved.remainingMs);
670
+ const remainingSec = Math.max(1, Math.round(remainingMs / 1000));
671
+ ctx.traceInfo(`[orch] auto-resuming interrupted cron: ${remainingSec}s remain (${saved.reason})`);
672
+ const cronResumePlan = planHoldRelease({
673
+ blobEnabled: state.blobEnabled,
674
+ seconds: remainingSec,
675
+ holdWindowSeconds: options.idleTimeout,
676
+ });
677
+ if (cronResumePlan.shouldRelease) {
678
+ yield* releaseAffinity(runtime, "cron");
679
+ }
680
+ const resumeNow = yield ctx.utcNow();
681
+ publishStatus(runtime, "waiting", {
682
+ waitSeconds: remainingSec,
683
+ waitReason: saved.reason,
684
+ waitStartedAt: resumeNow,
685
+ });
686
+ state.activeTimer = {
687
+ deadlineMs: resumeNow + remainingMs,
688
+ originalDurationMs: remainingMs,
689
+ reason: saved.reason,
690
+ type: "cron",
691
+ };
692
+ return;
693
+ }
694
+ if (state.cronSchedule) {
695
+ const activeCron = { ...state.cronSchedule };
696
+ const cronPlan = planHoldRelease({
697
+ blobEnabled: state.blobEnabled,
698
+ seconds: activeCron.intervalSeconds,
699
+ holdWindowSeconds: options.idleTimeout,
700
+ });
701
+ if (cronPlan.shouldRelease) {
702
+ yield* releaseAffinity(runtime, "cron");
703
+ }
704
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
705
+ eventType: "session.cron_started",
706
+ data: { intervalSeconds: activeCron.intervalSeconds, reason: activeCron.reason },
707
+ }]);
708
+ const cronStartedAt = yield ctx.utcNow();
709
+ ctx.traceInfo(`[orch] cron timer: ${activeCron.intervalSeconds}s (${activeCron.reason})`);
710
+ publishStatus(runtime, "waiting", {
711
+ waitSeconds: activeCron.intervalSeconds,
712
+ waitReason: activeCron.reason,
713
+ waitStartedAt: cronStartedAt,
714
+ });
715
+ state.activeTimer = {
716
+ deadlineMs: cronStartedAt + activeCron.intervalSeconds * 1000,
717
+ originalDurationMs: activeCron.intervalSeconds * 1000,
718
+ reason: activeCron.reason,
719
+ type: "cron",
720
+ };
721
+ return;
722
+ }
723
+ if (state.cronAtSchedule) {
724
+ const activeCronAt = { ...state.cronAtSchedule };
725
+ if (activeCronAt.maxFires !== undefined && activeCronAt.firesCompleted >= activeCronAt.maxFires) {
726
+ state.cronAtSchedule = undefined;
727
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
728
+ eventType: "session.cron_at_completed",
729
+ data: { reason: activeCronAt.reason, firesCompleted: activeCronAt.firesCompleted, maxFires: activeCronAt.maxFires },
730
+ }]);
731
+ return;
732
+ }
733
+ const nowMs = yield ctx.utcNow();
734
+ let nextFireAtMs = activeCronAt.nextFireAtMs;
735
+ let nextOccurrenceKey = activeCronAt.nextOccurrenceKey;
736
+ if (!nextFireAtMs || !nextOccurrenceKey) {
737
+ const nextFire = yield runtime.manager.computeCronAtNextFire(activeCronAt, nowMs, activeCronAt.lastOccurrenceKey);
738
+ nextFireAtMs = nextFire.nextFireAtMs;
739
+ nextOccurrenceKey = nextFire.occurrenceKey;
740
+ state.cronAtSchedule = {
741
+ ...activeCronAt,
742
+ nextFireAtMs,
743
+ nextOccurrenceKey,
744
+ };
745
+ }
746
+ if (nextFireAtMs === undefined || !nextOccurrenceKey) {
747
+ throw new Error("cron_at next-fire computation did not return a fire time");
748
+ }
749
+ const waitMs = Math.max(0, nextFireAtMs - nowMs);
750
+ const waitSeconds = Math.max(0, Math.ceil(waitMs / 1000));
751
+ const cronAtPlan = planHoldRelease({
752
+ blobEnabled: state.blobEnabled,
753
+ seconds: waitSeconds,
754
+ holdWindowSeconds: options.idleTimeout,
755
+ });
756
+ if (cronAtPlan.shouldRelease) {
757
+ yield* releaseAffinity(runtime, "cron_at");
758
+ }
759
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
760
+ eventType: "session.cron_at_started",
761
+ data: {
762
+ ...state.cronAtSchedule,
763
+ nextFireAt: new Date(nextFireAtMs).toISOString(),
764
+ },
765
+ }]);
766
+ publishStatus(runtime, "waiting", {
767
+ waitSeconds,
768
+ waitReason: activeCronAt.reason,
769
+ waitStartedAt: nowMs,
770
+ });
771
+ state.activeTimer = {
772
+ deadlineMs: nowMs + waitMs,
773
+ originalDurationMs: waitMs,
774
+ reason: activeCronAt.reason,
775
+ type: "cron_at",
776
+ };
777
+ return;
778
+ }
779
+ if (!state.blobEnabled || options.idleTimeout < 0) {
780
+ return;
781
+ }
782
+ // The idle timer IS the affinity hold window (lifecycle protocol §3.4):
783
+ // any session activity re-arms it via the drain machinery, and its fire
784
+ // releases the worker — it no longer dehydrates.
785
+ publishStatus(runtime, "idle");
786
+ const idleNow = yield ctx.utcNow();
787
+ state.activeTimer = {
788
+ deadlineMs: idleNow + options.idleTimeout * 1000,
789
+ originalDurationMs: options.idleTimeout * 1000,
790
+ reason: "idle timeout",
791
+ type: "idle",
792
+ };
793
+ }
794
+ // ─── handleTurnResult: dispatch on TurnResult variant ───────
795
+ function coerceChildQuestionToWait(runtime, result) {
796
+ if (result.type === "completed"
797
+ && runtime.options.parentSessionId
798
+ && typeof result.content === "string"
799
+ && /^QUESTION FOR PARENT:/i.test(result.content.trim())) {
800
+ runtime.ctx.traceInfo("[orch] coercing child QUESTION FOR PARENT result into durable wait");
801
+ return {
802
+ type: "wait",
803
+ seconds: 60,
804
+ reason: "waiting for parent answer",
805
+ content: result.content.trim(),
806
+ model: result.model,
807
+ };
808
+ }
809
+ return result;
810
+ }
811
+ /**
812
+ * Keep a prompt the budget gate refused, so the wake can replay it.
813
+ *
814
+ * Skips prompts that are nobody's words: the wake nudge, internal [SYSTEM:]
815
+ * traffic, and anything already stashed (the same prompt comes back through
816
+ * here on every refused retry).
817
+ */
818
+ function* stashBudgetRefusedPrompt(runtime, sourcePrompt, clientMessageIds, isBootstrap, requiredTool) {
819
+ const { state } = runtime;
820
+ // 1.0.71: the turn's note rides in the prompt as a trailing block. It is
821
+ // turn-scoped machinery, not anybody's words — a stashed prompt replays on
822
+ // a LATER turn that carries its own note — so it is dropped here, and the
823
+ // guards below see the bare prompt exactly as ≤1.0.70 did.
824
+ const bare = splitSystemContextBlock(typeof sourcePrompt === "string" ? sourcePrompt : "").prompt;
825
+ const prompt = bare.trim();
826
+ if (!prompt)
827
+ return;
828
+ if (/^\[SYSTEM:/i.test(prompt))
829
+ return;
830
+ if (prompt === PROVIDER_BUDGET_WAKE_PROMPT)
831
+ return;
832
+ // The wake nudge never reaches here verbatim: its [SYSTEM:] body is
833
+ // extracted into system context and the turn runs on the substituted
834
+ // internal prompt. That substitute is machinery, not anybody's words —
835
+ // stashing it painted "Internal orchestration wake-up." into transcripts
836
+ // as a queued USER message (caught by the resume tests).
837
+ if (prompt === INTERNAL_SYSTEM_TURN_PROMPT)
838
+ return;
839
+ const ids = Array.isArray(clientMessageIds)
840
+ ? clientMessageIds.filter((id) => typeof id === "string" && id)
841
+ : [];
842
+ const key = ids.length > 0 ? ids.join(",") : prompt;
843
+ const stash = state.budgetStash ?? [];
844
+ const existing = stash.find((entry) => {
845
+ const entryIds = entry.clientMessageIds ?? [];
846
+ const entryKey = entryIds.length > 0 ? entryIds.join(",") : entry.prompt;
847
+ return entryKey === key;
848
+ });
849
+ if (existing) {
850
+ if (!existing.requiredTool && requiredTool)
851
+ existing.requiredTool = requiredTool;
852
+ return;
853
+ }
854
+ // The durable record, with the ids the outbox acks by — this is what
855
+ // turns the optimistic ✓ into a true one and shows the message in the
856
+ // transcript while the session is still paused.
857
+ //
858
+ // 1.0.70: say WHO wrote it. This event is the one place a bootstrap
859
+ // prompt can reach a transcript — runTurn refuses to record one (see the
860
+ // `!input.bootstrap` guard) — and 1.0.69 wrote it bare. A user-role
861
+ // message with no sender renders from the READER's perspective, so a
862
+ // session the gate blocked at creation opened with the agent's own
863
+ // kickoff instructions under the reader's name.
864
+ //
865
+ // Stamped rather than skipped: the message stays visible (the portal
866
+ // folds a system-sender one into a collapsed row), and the reader can
867
+ // still see what the session was told to do while it sits paused.
868
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
869
+ eventType: "user.message",
870
+ data: {
871
+ content: prompt,
872
+ ...(ids.length > 0 ? { clientMessageIds: ids } : {}),
873
+ // Marked, so a reader of the raw events can tell a message that
874
+ // ran from one waiting for the budget to clear.
875
+ budgetQueued: true,
876
+ ...(isBootstrap ? { sender: { kind: "system", display: "agent kickoff" } } : {}),
877
+ },
878
+ }]);
879
+ stash.push({
880
+ prompt,
881
+ ...(ids.length > 0 ? { clientMessageIds: ids } : {}),
882
+ ...(requiredTool ? { requiredTool } : {}),
883
+ });
884
+ state.budgetStash = stash;
885
+ runtime.ctx.traceInfo(`[orch] stashed prompt refused by the budget gate (${stash.length} waiting)`);
886
+ }
887
+ function* synthesizeWaitInterruptReplyIfNeeded(runtime, result) {
888
+ if (runtime.state.interruptedWaitTimer?.interruptKind === "user"
889
+ && (result.type === "completed" || result.type === "wait")
890
+ && !(typeof result.content === "string" && result.content.trim())) {
891
+ const content = "I'm here. Resuming the timer.";
892
+ const next = { ...result, content };
893
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
894
+ eventType: "assistant.message",
895
+ data: {
896
+ content,
897
+ synthetic: true,
898
+ reason: "wait_interrupt_empty_reply",
899
+ },
900
+ }]);
901
+ runtime.ctx.traceInfo("[orch] synthesized visible assistant reply for wait interrupt");
902
+ return next;
903
+ }
904
+ return result;
905
+ }
906
+ export function* handleTurnResult(runtime, result, sourcePrompt, cycleOrigin, clientMessageIds,
907
+ // Whether the prompt was an agent's own bootstrap rather than anyone's
908
+ // words. Only the budget stash below needs it, and only to attribute the
909
+ // durable record it writes.
910
+ isBootstrap, requiredTool) {
911
+ const { ctx, state, options } = runtime;
912
+ result = coerceChildQuestionToWait(runtime, result);
913
+ const budgetRefusal = result.type === "wait" && result.budget === true;
914
+ // "I'm here. Resuming the timer." is for a turn that RAN and said
915
+ // nothing. A gate refusal is a turn that never ran — fabricating an
916
+ // assistant reply for it put words in the transcript that answered a
917
+ // message which was not there.
918
+ if (!budgetRefusal) {
919
+ result = yield* synthesizeWaitInterruptReplyIfNeeded(runtime, result);
920
+ }
921
+ // Any result other than a gate refusal means the turn actually reached
922
+ // the model, and the activity folded the stashed prompts into it. They
923
+ // are delivered; holding them longer would replay them twice.
924
+ if (!budgetRefusal && state.budgetStash) {
925
+ state.budgetStash = null;
926
+ }
927
+ switch (result.type) {
928
+ case "completed": {
929
+ ctx.traceInfo(`[response] ${result.content}`);
930
+ yield* writeLatestResponse(runtime, {
931
+ iteration: state.iteration,
932
+ type: "completed",
933
+ content: result.content,
934
+ model: result.model,
935
+ });
936
+ if (result.forceContinuePrompt) {
937
+ ctx.traceInfo(`[orch] continuing after terminal model switch failure`);
938
+ yield* versionedContinueAsNew(runtime, continueInputWithPrompt(runtime, result.forceContinuePrompt, {
939
+ bootstrapPrompt: true,
940
+ }));
941
+ return;
942
+ }
943
+ if (options.parentSessionId) {
944
+ const cycleReport = result.cycleReport;
945
+ const cycleMaterial = cycleReport?.status === "material" || cycleReport?.status === "blocked"
946
+ ? true
947
+ : cycleReport?.status === "quiet"
948
+ ? false
949
+ : undefined;
950
+ const wakeDecision = shouldWakeParentForChildUpdate({
951
+ update: {
952
+ kind: "completed",
953
+ summary: cycleReport?.summary || result.content,
954
+ ...(cycleOrigin ? { cyclic: true } : {}),
955
+ ...(cycleMaterial !== undefined ? { material: cycleMaterial } : {}),
956
+ ...(cycleReport?.status === "blocked" ? { result: { verdict: "blocked" } } : {}),
957
+ },
958
+ contract: state.config.childContract,
959
+ });
960
+ // A spawned child's FIRST completion always reaches the parent
961
+ // regardless of the wake policy: suppressing it (e.g. a
962
+ // wakeOn=completion contract classifying a verdict-less final
963
+ // answer as merely "material") strands the parent until a
964
+ // human pokes. Later completions respect the contract.
965
+ const firstParentReport = !cycleOrigin && !state.reportedFirstCompletionToParent;
966
+ if (wakeDecision.wake || firstParentReport) {
967
+ state.reportedFirstCompletionToParent = true;
968
+ try {
969
+ const meta = [
970
+ `from=${runtime.input.sessionId}`,
971
+ `type=completed`,
972
+ `iter=${state.iteration}`,
973
+ ...(cycleOrigin ? [`cycle=${cycleOrigin}`] : []),
974
+ ...(cycleReport?.status ? [`status=${cycleReport.status}`] : []),
975
+ ].join(" ");
976
+ const notifyContent = cycleReport?.summary || result.content;
977
+ yield runtime.manager.sendToSession(options.parentSessionId, `[CHILD_UPDATE ${meta}]\n${notifyContent.slice(0, 2000)}`);
978
+ }
979
+ catch (err) {
980
+ ctx.traceInfo(`[orch] sendToSession(parent) failed: ${err.message} (non-fatal)`);
981
+ }
982
+ }
983
+ else {
984
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
985
+ eventType: "session.child_update_suppressed",
986
+ data: { direction: "child_to_parent", updateType: "completed", cycleOrigin, cycleReport, ...wakeDecision },
987
+ }]);
988
+ }
989
+ if (runtime.input.isSystem && !state.cronSchedule && !state.cronAtSchedule) {
990
+ ctx.traceInfo(`[orch] system sub-agent completed turn, continuing loop`);
991
+ return;
992
+ }
993
+ }
994
+ yield* schedulePostTurnContinuation(runtime);
995
+ return;
996
+ }
997
+ case "cron":
998
+ applyCronAction(runtime, result, sourcePrompt);
999
+ return;
1000
+ case "cron_at":
1001
+ yield* applyCronAtAction(runtime, result, sourcePrompt);
1002
+ return;
1003
+ case "wait": {
1004
+ state.interruptedWaitTimer = null;
1005
+ ensureTaskContext(runtime, sourcePrompt);
1006
+ // ── a gate refusal must not destroy the prompt that asked ──
1007
+ //
1008
+ // The transcript write for a prompt lives INSIDE the turn, so a
1009
+ // prompt whose turn the gate refuses was consumed from the queue
1010
+ // and then simply lost: no user.message, no replay, no trace.
1011
+ // The person saw a ✓ and their words went nowhere — including
1012
+ // the very FIRST message of a session blocked at creation.
1013
+ //
1014
+ // So: record it durably NOW (the ✓ becomes true), stash it, and
1015
+ // let it ride into every retry until a turn actually runs.
1016
+ if (budgetRefusal) {
1017
+ yield* stashBudgetRefusedPrompt(runtime, sourcePrompt, clientMessageIds, isBootstrap, requiredTool);
1018
+ }
1019
+ if (options.parentSessionId) {
1020
+ const notifyContent = result.content
1021
+ ? result.content.slice(0, 2000)
1022
+ : `[wait: ${result.reason} (${result.seconds}s)]`;
1023
+ // ≥1.0.71: a bare wait is a heartbeat (waitIsHeartbeat). The
1024
+ // child interrupts its parent from a wait only with
1025
+ // wait({material: true}); the QUESTION FOR PARENT coercion
1026
+ // stays material inside the classifier.
1027
+ const wakeDecision = shouldWakeParentForChildUpdate({
1028
+ update: {
1029
+ kind: "wait",
1030
+ summary: notifyContent,
1031
+ waitIsHeartbeat: true,
1032
+ ...(result.material === true ? { material: true } : {}),
1033
+ },
1034
+ contract: state.config.childContract,
1035
+ });
1036
+ if (wakeDecision.wake) {
1037
+ try {
1038
+ yield runtime.manager.sendToSession(options.parentSessionId, `[CHILD_UPDATE from=${runtime.input.sessionId} type=wait iter=${state.iteration}]\n${notifyContent}`);
1039
+ }
1040
+ catch (err) {
1041
+ ctx.traceInfo(`[orch] sendToSession(parent) wait failed: ${err.message} (non-fatal)`);
1042
+ }
1043
+ }
1044
+ else {
1045
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
1046
+ eventType: "session.child_update_suppressed",
1047
+ data: { direction: "child_to_parent", updateType: "wait", ...wakeDecision },
1048
+ }]);
1049
+ }
1050
+ }
1051
+ ctx.traceInfo(`[orch] durable timer: ${result.seconds}s (${result.reason})`);
1052
+ // Lifecycle protocol: waits within the hold window keep the
1053
+ // affinity GUID (worker stays warm — this is now the default,
1054
+ // no wait_on_worker opt-in needed); longer waits release. The
1055
+ // legacy `preserveWorkerAffinity` flag is accepted and simply
1056
+ // subsumed: holds within the window always preserve affinity.
1057
+ const waitPlan = planHoldRelease({
1058
+ blobEnabled: state.blobEnabled,
1059
+ seconds: result.seconds,
1060
+ holdWindowSeconds: options.idleTimeout,
1061
+ });
1062
+ if (waitPlan.shouldRelease) {
1063
+ yield* releaseAffinity(runtime, "timer");
1064
+ }
1065
+ const waitStartedAt = yield ctx.utcNow();
1066
+ if (result.content) {
1067
+ yield* writeLatestResponse(runtime, {
1068
+ iteration: state.iteration,
1069
+ type: "wait",
1070
+ content: result.content,
1071
+ waitReason: result.reason,
1072
+ waitSeconds: result.seconds,
1073
+ waitStartedAt,
1074
+ model: result.model,
1075
+ });
1076
+ ctx.traceInfo(`[orch] intermediate: ${result.content.slice(0, 80)}`);
1077
+ }
1078
+ publishStatus(runtime, "waiting", {
1079
+ waitSeconds: result.seconds,
1080
+ waitReason: result.reason,
1081
+ waitStartedAt,
1082
+ preserveWorkerAffinity: !waitPlan.shouldRelease,
1083
+ });
1084
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
1085
+ eventType: "session.wait_started",
1086
+ data: { seconds: result.seconds, reason: result.reason, preserveAffinity: !waitPlan.shouldRelease },
1087
+ }]);
1088
+ state.activeTimer = {
1089
+ deadlineMs: waitStartedAt + result.seconds * 1000,
1090
+ originalDurationMs: result.seconds * 1000,
1091
+ reason: result.reason,
1092
+ type: "wait",
1093
+ content: result.content,
1094
+ budget: result.budget === true,
1095
+ };
1096
+ return;
1097
+ }
1098
+ case "input_required": {
1099
+ ctx.traceInfo(`[orch] waiting for user input: ${result.question}`);
1100
+ yield* writeLatestResponse(runtime, {
1101
+ iteration: state.iteration,
1102
+ type: "input_required",
1103
+ question: result.question,
1104
+ choices: result.choices,
1105
+ allowFreeform: result.allowFreeform,
1106
+ model: result.model,
1107
+ });
1108
+ state.pendingInputQuestion = {
1109
+ iteration: state.iteration,
1110
+ question: result.question,
1111
+ choices: result.choices,
1112
+ allowFreeform: result.allowFreeform,
1113
+ };
1114
+ publishStatus(runtime, "input_required");
1115
+ if (!state.blobEnabled || options.inputGracePeriod < 0) {
1116
+ return;
1117
+ }
1118
+ // Lifecycle protocol: waiting on a human is a HOLD, not a
1119
+ // dehydrate — arm the hold-window timer directly (its fire
1120
+ // releases affinity; an answer within the window lands warm).
1121
+ if (options.inputGracePeriod === 0) {
1122
+ const inputHoldNow = yield ctx.utcNow();
1123
+ const inputHoldSeconds = options.idleTimeout > 0 ? options.idleTimeout : 1_800;
1124
+ state.activeTimer = {
1125
+ deadlineMs: inputHoldNow + inputHoldSeconds * 1000,
1126
+ originalDurationMs: inputHoldSeconds * 1000,
1127
+ reason: "idle timeout (input required)",
1128
+ type: "idle",
1129
+ };
1130
+ return;
1131
+ }
1132
+ const graceNow = yield ctx.utcNow();
1133
+ state.activeTimer = {
1134
+ deadlineMs: graceNow + options.inputGracePeriod * 1000,
1135
+ originalDurationMs: options.inputGracePeriod * 1000,
1136
+ reason: "input grace period",
1137
+ type: "input-grace",
1138
+ question: result.question,
1139
+ choices: result.choices,
1140
+ allowFreeform: result.allowFreeform,
1141
+ };
1142
+ return;
1143
+ }
1144
+ case "cancelled":
1145
+ ctx.traceInfo("[session] turn cancelled");
1146
+ return;
1147
+ case "stopped": {
1148
+ // Defensive: a turn only classifies "stopped" when the stop marker
1149
+ // was set, which normally means handleTurnStopped already ran via
1150
+ // the race. Handle it anyway so a marker-set turn that somehow
1151
+ // returns through the normal path still lands idle with the event
1152
+ // trail (processPrompt already incremented state.iteration).
1153
+ ctx.traceInfo("[session] turn reported stopped");
1154
+ state.retryCount = 0;
1155
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
1156
+ eventType: "session.turn_stopped",
1157
+ data: {
1158
+ reason: result.reason ?? "Stopped by user",
1159
+ turnIndex: state.iteration - 1,
1160
+ interrupt: "turn-result",
1161
+ ...(clientMessageIds && clientMessageIds.length > 0 ? { clientMessageIds } : {}),
1162
+ },
1163
+ }]);
1164
+ yield runtime.manager.updateCmsState(runtime.input.sessionId, "idle");
1165
+ yield* schedulePostTurnContinuation(runtime);
1166
+ return;
1167
+ }
1168
+ case "spawn_agent":
1169
+ case "message_agent":
1170
+ case "check_agents":
1171
+ case "list_sessions":
1172
+ case "wait_for_agents":
1173
+ case "complete_agent":
1174
+ case "cancel_agent":
1175
+ case "delete_agent":
1176
+ yield* handleSubAgentAction(runtime, result);
1177
+ return;
1178
+ case "error": {
1179
+ const missingStateIndex = result.message.indexOf(SESSION_STATE_MISSING_PREFIX);
1180
+ if (missingStateIndex >= 0) {
1181
+ const fatalError = result.message.slice(missingStateIndex + SESSION_STATE_MISSING_PREFIX.length).trim();
1182
+ ctx.traceInfo(`[orch] fatal missing session state: ${fatalError}`);
1183
+ publishStatus(runtime, "failed", { error: fatalError, fatal: true });
1184
+ yield runtime.manager.updateCmsState(runtime.input.sessionId, "failed", fatalError);
1185
+ throw new Error(fatalError);
1186
+ }
1187
+ // The throw path short-circuits auth failures to an honest
1188
+ // "fix your key" stop; a 401 the activity RETURNED took the
1189
+ // generic retry loop instead. Same failure, same answer.
1190
+ if (isAuthFailureError(result.message)) {
1191
+ ctx.traceInfo(`[orch] turn returned auth error; not retrying: ${result.message}`);
1192
+ yield* projectAuthFailure(runtime, result.message);
1193
+ return;
1194
+ }
1195
+ if (result.retryable === false) {
1196
+ ctx.traceInfo(`[orch] turn returned non-retryable error: ${result.message}`);
1197
+ yield* projectNonRetryableTurnFailure(runtime, result.message);
1198
+ return;
1199
+ }
1200
+ state.retryCount++;
1201
+ ctx.traceInfo(`[orch] turn returned error (attempt ${state.retryCount}/${MAX_RETRIES}): ${result.message}`);
1202
+ const rc = {
1203
+ sourcePrompt,
1204
+ systemOnlyTurn: false,
1205
+ requiredTool,
1206
+ cycleOrigin,
1207
+ phase: "turn.result.error",
1208
+ };
1209
+ if (isCopilotConnectionClosedError(result.message)) {
1210
+ yield* handleConnectionClosedRetry(runtime, result.message, rc);
1211
+ return;
1212
+ }
1213
+ yield* handleGenericRetry(runtime, result.message, rc);
1214
+ return;
1215
+ }
1216
+ }
1217
+ }
1218
+ // ─── processTimer: handle fired timers by type ──────────────
1219
+ export function* processTimer(runtime, timerItem) {
1220
+ const { ctx, state } = runtime;
1221
+ const timer = timerItem.timer;
1222
+ switch (timer.type) {
1223
+ case "wait": {
1224
+ const seconds = Math.round(timer.originalDurationMs / 1000);
1225
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
1226
+ eventType: "session.wait_completed",
1227
+ data: { seconds },
1228
+ }]);
1229
+ const timerPrompt = `The ${seconds} second wait is now complete. Continue with your task.`;
1230
+ const resumeSystemPrompt = [
1231
+ timer.reason ? `Wait reason: "${timer.reason}".` : undefined,
1232
+ state.taskContext ? `Original user request: "${state.taskContext}".` : undefined,
1233
+ "Resume the interrupted task now.",
1234
+ "Do not treat this as a new unrelated user request.",
1235
+ "Do not call wait() again for the delay that already finished.",
1236
+ ].filter(Boolean).join(" ");
1237
+ // ≥1.0.71: a child digest held for this wake-up (queue.ts
1238
+ // nextTimerCandidate) rides into the prompt here, so holding it
1239
+ // never loses it.
1240
+ yield* processPrompt(runtime, flushPendingChildDigestIntoPrompt(runtime, appendSystemContext(timerPrompt, resumeSystemPrompt) ?? timerPrompt) ?? timerPrompt, false);
1241
+ return;
1242
+ }
1243
+ case "cron": {
1244
+ const activeCron = state.cronSchedule;
1245
+ if (!activeCron) {
1246
+ // A cancel cannot retract the already-scheduled durable timer,
1247
+ // so a stale cron fire with no schedule is expected — ignore it.
1248
+ ctx.traceInfo("[orch] cron timer fired but no active cronSchedule exists");
1249
+ return;
1250
+ }
1251
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
1252
+ eventType: "session.cron_fired",
1253
+ data: {},
1254
+ }]);
1255
+ 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.";
1256
+ const cronPrompt = `[SYSTEM: Scheduled cron wake-up for: "${activeCron.reason}". Resume your recurring task. ${cycleReportGuidance}]`;
1257
+ if (timer.shouldRehydrate) {
1258
+ yield* processPrompt(runtime, flushPendingChildDigestIntoPrompt(runtime, wrapWithResumeContext(runtime, "Resume your recurring task.", `Scheduled cron wake-up for: "${activeCron.reason}". ${cycleReportGuidance}`)) ?? cronPrompt, true, undefined, undefined, "cron");
1259
+ }
1260
+ else {
1261
+ yield* processPrompt(runtime, flushPendingChildDigestIntoPrompt(runtime, cronPrompt) ?? cronPrompt, true, undefined, undefined, "cron");
1262
+ }
1263
+ return;
1264
+ }
1265
+ case "cron_at": {
1266
+ const activeCronAt = state.cronAtSchedule;
1267
+ if (!activeCronAt) {
1268
+ ctx.traceInfo("[orch] cron_at timer fired but no active cronAtSchedule exists");
1269
+ return;
1270
+ }
1271
+ const scheduledAtMs = activeCronAt.nextFireAtMs ?? timer.deadlineMs;
1272
+ const occurrenceKey = activeCronAt.nextOccurrenceKey;
1273
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
1274
+ eventType: "session.cron_at_fired",
1275
+ data: {
1276
+ scheduledAt: new Date(scheduledAtMs).toISOString(),
1277
+ occurrenceKey,
1278
+ tz: activeCronAt.tz,
1279
+ minute: activeCronAt.minute,
1280
+ hour: activeCronAt.hour,
1281
+ dayOfWeek: activeCronAt.dayOfWeek,
1282
+ dayOfMonth: activeCronAt.dayOfMonth,
1283
+ firesCompleted: activeCronAt.firesCompleted + 1,
1284
+ },
1285
+ }]);
1286
+ const firedSchedule = {
1287
+ ...activeCronAt,
1288
+ firesCompleted: activeCronAt.firesCompleted + 1,
1289
+ ...(occurrenceKey ? { lastOccurrenceKey: occurrenceKey } : {}),
1290
+ nextFireAtMs: undefined,
1291
+ nextOccurrenceKey: undefined,
1292
+ };
1293
+ const finalFire = firedSchedule.maxFires !== undefined && firedSchedule.firesCompleted >= firedSchedule.maxFires;
1294
+ state.cronAtSchedule = finalFire ? undefined : firedSchedule;
1295
+ if (finalFire) {
1296
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
1297
+ eventType: "session.cron_at_completed",
1298
+ data: { reason: firedSchedule.reason, firesCompleted: firedSchedule.firesCompleted, maxFires: firedSchedule.maxFires },
1299
+ }]);
1300
+ }
1301
+ const description = describeCronAt(activeCronAt);
1302
+ const cronAtPrompt = `[SYSTEM: Scheduled wall-clock cron wake-up for "${activeCronAt.reason}". ` +
1303
+ `Schedule: ${description}. Scheduled fire: ${new Date(scheduledAtMs).toISOString()}. ` +
1304
+ `Resume your recurring task now. ` +
1305
+ `If this cycle finds material changes or blockers that should wake your parent, call report_cycle(status='material' or status='blocked', summary='...') before finishing. ` +
1306
+ `If nothing material changed, do NOT call report_cycle at all — just end the turn silently. ` +
1307
+ `Do not emit report_cycle(status='quiet') on an uneventful cycle, and never write a tool call as text.]`;
1308
+ if (timer.shouldRehydrate) {
1309
+ yield* processPrompt(runtime, flushPendingChildDigestIntoPrompt(runtime, wrapWithResumeContext(runtime, "Resume your recurring task.", `Scheduled wall-clock cron wake-up for "${activeCronAt.reason}". ` +
1310
+ `Schedule: ${description}. Scheduled fire: ${new Date(scheduledAtMs).toISOString()}. ` +
1311
+ `If this cycle finds material changes or blockers that should wake your parent, call report_cycle(status='material' or status='blocked', summary='...') before finishing. ` +
1312
+ `If nothing material changed, do NOT call report_cycle at all — just end the turn silently. ` +
1313
+ `Do not emit report_cycle(status='quiet') on an uneventful cycle, and never write a tool call as text.`)) ?? cronAtPrompt, true, undefined, undefined, "cron_at");
1314
+ }
1315
+ else {
1316
+ yield* processPrompt(runtime, flushPendingChildDigestIntoPrompt(runtime, cronAtPrompt) ?? cronAtPrompt, true, undefined, undefined, "cron_at");
1317
+ }
1318
+ return;
1319
+ }
1320
+ case "idle": {
1321
+ // Lifecycle protocol: hold window expired → release the worker.
1322
+ // No dehydrate — every completed turn already committed its
1323
+ // snapshot; the old worker's copy is a cache its own eviction
1324
+ // clock reclaims.
1325
+ ctx.traceInfo("[session] hold window expired, releasing worker affinity");
1326
+ yield* releaseAffinity(runtime, "idle");
1327
+ return;
1328
+ }
1329
+ case "agent-poll": {
1330
+ if (state.waitingForAgentIds) {
1331
+ const stillRunning = state.waitingForAgentIds.filter(id => {
1332
+ const agent = state.subAgents.find(a => a.orchId === id);
1333
+ return agent && !isSubAgentTerminalStatus(agent.status);
1334
+ });
1335
+ ctx.traceInfo(`[orch] wait_for_agents: fallback poll, checking ${stillRunning.length} agents`);
1336
+ for (const targetId of stillRunning) {
1337
+ const agent = state.subAgents.find(a => a.orchId === targetId);
1338
+ if (!agent || isSubAgentTerminalStatus(agent.status))
1339
+ continue;
1340
+ try {
1341
+ const rawStatus = yield runtime.manager.getSessionStatus(agent.sessionId);
1342
+ const parsed = JSON.parse(rawStatus);
1343
+ if (parsed.status === "failed") {
1344
+ agent.status = "failed";
1345
+ }
1346
+ else if (parsed.status === "completed") {
1347
+ agent.status = "completed";
1348
+ }
1349
+ else if (parsed.status === "cancelled") {
1350
+ agent.status = "cancelled";
1351
+ }
1352
+ else if (parsed.status === "waiting") {
1353
+ agent.status = "waiting";
1354
+ }
1355
+ else if (parsed.status === "idle") {
1356
+ // Quiescent child: answered and parked with an empty
1357
+ // queue. It will never speak again unprompted, so
1358
+ // treating it as still-running polls forever
1359
+ // (observed live: 70+ min of 30s polls). "idle"
1360
+ // satisfies the wait via isAgentWaitSettledStatus.
1361
+ agent.status = "idle";
1362
+ }
1363
+ else if (parsed.status === "input_required") {
1364
+ agent.status = "input_required";
1365
+ }
1366
+ agent.result = getChildResultFromStatus(parsed, agent.result)?.slice(0, 2000);
1367
+ }
1368
+ catch { }
1369
+ }
1370
+ if (yield* maybeResolveAgentWaitCompletion(runtime)) {
1371
+ return;
1372
+ }
1373
+ const nowRunning = getStillRunningAgentIds(state.subAgents, state.waitingForAgentIds);
1374
+ if (state.pendingShutdown) {
1375
+ const now = yield ctx.utcNow();
1376
+ if (now >= state.pendingShutdown.deadlineAtMs) {
1377
+ const timeoutMessage = `Graceful ${state.pendingShutdown.mode} timed out after ${Math.round(SHUTDOWN_TIMEOUT_MS / 1000)}s ` +
1378
+ `waiting for ${nowRunning.length} child session(s): ${nowRunning.join(", ") || "unknown"}`;
1379
+ yield* failPendingShutdown(runtime, timeoutMessage);
1380
+ return;
1381
+ }
1382
+ const remainingMs = Math.max(0, state.pendingShutdown.deadlineAtMs - now);
1383
+ const nextPollMs = Math.min(SHUTDOWN_POLL_INTERVAL_MS, remainingMs);
1384
+ state.activeTimer = {
1385
+ deadlineMs: now + nextPollMs,
1386
+ originalDurationMs: nextPollMs,
1387
+ reason: buildShutdownWaitReason(state.pendingShutdown),
1388
+ type: "agent-poll",
1389
+ agentIds: state.waitingForAgentIds,
1390
+ };
1391
+ publishStatus(runtime, "waiting", {
1392
+ waitReason: buildShutdownWaitReason(state.pendingShutdown),
1393
+ waitStartedAt: state.pendingShutdown.startedAtMs,
1394
+ waitSeconds: Math.ceil(remainingMs / 1000),
1395
+ });
1396
+ }
1397
+ else {
1398
+ const now = yield ctx.utcNow();
1399
+ state.activeTimer = {
1400
+ deadlineMs: now + 30_000,
1401
+ originalDurationMs: 30_000,
1402
+ reason: `waiting for ${nowRunning.length} agent(s)`,
1403
+ type: "agent-poll",
1404
+ agentIds: state.waitingForAgentIds,
1405
+ };
1406
+ // Re-assert "waiting" each poll (mirrors the shutdown
1407
+ // branch) so a stale "running" from a mid-wait worker
1408
+ // swap self-heals instead of spinning "Working…".
1409
+ publishStatus(runtime, "waiting", {
1410
+ waitReason: `waiting for ${nowRunning.length} agent(s)`,
1411
+ waitStartedAt: now,
1412
+ });
1413
+ }
1414
+ }
1415
+ return;
1416
+ }
1417
+ case "input-grace": {
1418
+ // Lifecycle protocol: grace elapsed without an answer → enter
1419
+ // the hold window (idle timer). The eventual idle fire releases
1420
+ // affinity; an answer any time before that lands warm.
1421
+ const graceElapsedNow = yield runtime.ctx.utcNow();
1422
+ const holdSeconds = runtime.options.idleTimeout > 0 ? runtime.options.idleTimeout : 1_800;
1423
+ state.activeTimer = {
1424
+ deadlineMs: graceElapsedNow + holdSeconds * 1000,
1425
+ originalDurationMs: holdSeconds * 1000,
1426
+ reason: "idle timeout (input required)",
1427
+ type: "idle",
1428
+ };
1429
+ return;
1430
+ }
1431
+ }
1432
+ }
1433
+ //# sourceMappingURL=turn.js.map