pilotswarm-sdk 0.3.1 → 0.3.3

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 (137) hide show
  1. package/dist/cms-migrations.d.ts.map +1 -1
  2. package/dist/cms-migrations.js +1090 -1
  3. package/dist/cms-migrations.js.map +1 -1
  4. package/dist/cms.d.ts +41 -1
  5. package/dist/cms.d.ts.map +1 -1
  6. package/dist/cms.js +51 -1
  7. package/dist/cms.js.map +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/inspect-tools.d.ts.map +1 -1
  11. package/dist/inspect-tools.js +27 -0
  12. package/dist/inspect-tools.js.map +1 -1
  13. package/dist/managed-session.d.ts +36 -0
  14. package/dist/managed-session.d.ts.map +1 -1
  15. package/dist/managed-session.js +317 -21
  16. package/dist/managed-session.js.map +1 -1
  17. package/dist/management-client.d.ts +36 -2
  18. package/dist/management-client.d.ts.map +1 -1
  19. package/dist/management-client.js +114 -2
  20. package/dist/management-client.js.map +1 -1
  21. package/dist/orchestration/index.d.ts +2 -2
  22. package/dist/orchestration/index.js +1 -1
  23. package/dist/orchestration/lifecycle.d.ts.map +1 -1
  24. package/dist/orchestration/lifecycle.js +74 -4
  25. package/dist/orchestration/lifecycle.js.map +1 -1
  26. package/dist/orchestration/runtime.d.ts +1 -1
  27. package/dist/orchestration/state.d.ts +1 -0
  28. package/dist/orchestration/state.d.ts.map +1 -1
  29. package/dist/orchestration/state.js +1 -0
  30. package/dist/orchestration/state.js.map +1 -1
  31. package/dist/orchestration/turn.d.ts +24 -0
  32. package/dist/orchestration/turn.d.ts.map +1 -1
  33. package/dist/orchestration/turn.js +378 -167
  34. package/dist/orchestration/turn.js.map +1 -1
  35. package/dist/orchestration-registry.d.ts.map +1 -1
  36. package/dist/orchestration-registry.js +6 -2
  37. package/dist/orchestration-registry.js.map +1 -1
  38. package/dist/orchestration-version.d.ts +1 -1
  39. package/dist/orchestration-version.js +1 -1
  40. package/dist/orchestration.d.ts +2 -2
  41. package/dist/orchestration.js +1 -1
  42. package/dist/orchestration_1_0_46.d.ts +1 -1
  43. package/dist/orchestration_1_0_47.d.ts +1 -1
  44. package/dist/orchestration_1_0_48.d.ts +1 -1
  45. package/dist/orchestration_1_0_49.d.ts +1 -1
  46. package/dist/orchestration_1_0_50.d.ts +1 -1
  47. package/dist/orchestration_1_0_54/agents.d.ts +41 -0
  48. package/dist/orchestration_1_0_54/agents.d.ts.map +1 -0
  49. package/dist/orchestration_1_0_54/agents.js +758 -0
  50. package/dist/orchestration_1_0_54/agents.js.map +1 -0
  51. package/dist/orchestration_1_0_54/index.d.ts +24 -0
  52. package/dist/orchestration_1_0_54/index.d.ts.map +1 -0
  53. package/dist/orchestration_1_0_54/index.js +13 -0
  54. package/dist/orchestration_1_0_54/index.js.map +1 -0
  55. package/dist/orchestration_1_0_54/lifecycle.d.ts +41 -0
  56. package/dist/orchestration_1_0_54/lifecycle.d.ts.map +1 -0
  57. package/dist/orchestration_1_0_54/lifecycle.js +510 -0
  58. package/dist/orchestration_1_0_54/lifecycle.js.map +1 -0
  59. package/dist/orchestration_1_0_54/queue.d.ts +7 -0
  60. package/dist/orchestration_1_0_54/queue.d.ts.map +1 -0
  61. package/dist/orchestration_1_0_54/queue.js +644 -0
  62. package/dist/orchestration_1_0_54/queue.js.map +1 -0
  63. package/dist/orchestration_1_0_54/runtime.d.ts +29 -0
  64. package/dist/orchestration_1_0_54/runtime.d.ts.map +1 -0
  65. package/dist/orchestration_1_0_54/runtime.js +194 -0
  66. package/dist/orchestration_1_0_54/runtime.js.map +1 -0
  67. package/dist/orchestration_1_0_54/state.d.ts +125 -0
  68. package/dist/orchestration_1_0_54/state.d.ts.map +1 -0
  69. package/dist/orchestration_1_0_54/state.js +105 -0
  70. package/dist/orchestration_1_0_54/state.js.map +1 -0
  71. package/dist/orchestration_1_0_54/turn.d.ts +6 -0
  72. package/dist/orchestration_1_0_54/turn.d.ts.map +1 -0
  73. package/dist/orchestration_1_0_54/turn.js +864 -0
  74. package/dist/orchestration_1_0_54/turn.js.map +1 -0
  75. package/dist/orchestration_1_0_54/utils.d.ts +22 -0
  76. package/dist/orchestration_1_0_54/utils.d.ts.map +1 -0
  77. package/dist/orchestration_1_0_54/utils.js +226 -0
  78. package/dist/orchestration_1_0_54/utils.js.map +1 -0
  79. package/dist/orchestration_1_0_55/agents.d.ts +41 -0
  80. package/dist/orchestration_1_0_55/agents.d.ts.map +1 -0
  81. package/dist/orchestration_1_0_55/agents.js +758 -0
  82. package/dist/orchestration_1_0_55/agents.js.map +1 -0
  83. package/dist/orchestration_1_0_55/index.d.ts +24 -0
  84. package/dist/orchestration_1_0_55/index.d.ts.map +1 -0
  85. package/dist/orchestration_1_0_55/index.js +13 -0
  86. package/dist/orchestration_1_0_55/index.js.map +1 -0
  87. package/dist/orchestration_1_0_55/lifecycle.d.ts +41 -0
  88. package/dist/orchestration_1_0_55/lifecycle.d.ts.map +1 -0
  89. package/dist/orchestration_1_0_55/lifecycle.js +580 -0
  90. package/dist/orchestration_1_0_55/lifecycle.js.map +1 -0
  91. package/dist/orchestration_1_0_55/queue.d.ts +7 -0
  92. package/dist/orchestration_1_0_55/queue.d.ts.map +1 -0
  93. package/dist/orchestration_1_0_55/queue.js +644 -0
  94. package/dist/orchestration_1_0_55/queue.js.map +1 -0
  95. package/dist/orchestration_1_0_55/runtime.d.ts +29 -0
  96. package/dist/orchestration_1_0_55/runtime.d.ts.map +1 -0
  97. package/dist/orchestration_1_0_55/runtime.js +194 -0
  98. package/dist/orchestration_1_0_55/runtime.js.map +1 -0
  99. package/dist/orchestration_1_0_55/state.d.ts +126 -0
  100. package/dist/orchestration_1_0_55/state.d.ts.map +1 -0
  101. package/dist/orchestration_1_0_55/state.js +106 -0
  102. package/dist/orchestration_1_0_55/state.js.map +1 -0
  103. package/dist/orchestration_1_0_55/turn.d.ts +17 -0
  104. package/dist/orchestration_1_0_55/turn.d.ts.map +1 -0
  105. package/dist/orchestration_1_0_55/turn.js +921 -0
  106. package/dist/orchestration_1_0_55/turn.js.map +1 -0
  107. package/dist/orchestration_1_0_55/utils.d.ts +22 -0
  108. package/dist/orchestration_1_0_55/utils.d.ts.map +1 -0
  109. package/dist/orchestration_1_0_55/utils.js +226 -0
  110. package/dist/orchestration_1_0_55/utils.js.map +1 -0
  111. package/dist/session-manager.d.ts +24 -1
  112. package/dist/session-manager.d.ts.map +1 -1
  113. package/dist/session-manager.js +84 -0
  114. package/dist/session-manager.js.map +1 -1
  115. package/dist/session-proxy.d.ts +19 -0
  116. package/dist/session-proxy.d.ts.map +1 -1
  117. package/dist/session-proxy.js +172 -14
  118. package/dist/session-proxy.js.map +1 -1
  119. package/dist/sweeper-tools.d.ts +1 -1
  120. package/dist/sweeper-tools.d.ts.map +1 -1
  121. package/dist/sweeper-tools.js +20 -75
  122. package/dist/sweeper-tools.js.map +1 -1
  123. package/dist/system-agents.d.ts +2 -0
  124. package/dist/system-agents.d.ts.map +1 -1
  125. package/dist/system-agents.js +3 -0
  126. package/dist/system-agents.js.map +1 -1
  127. package/dist/types.d.ts +46 -1
  128. package/dist/types.d.ts.map +1 -1
  129. package/dist/types.js +10 -0
  130. package/dist/types.js.map +1 -1
  131. package/dist/worker.d.ts.map +1 -1
  132. package/dist/worker.js +10 -0
  133. package/dist/worker.js.map +1 -1
  134. package/package.json +3 -3
  135. package/plugins/mgmt/agents/agent-tuner.agent.md +36 -2
  136. package/plugins/mgmt/agents/sweeper.agent.md +7 -7
  137. package/plugins/system/agents/default.agent.md +3 -1
@@ -1 +1 @@
1
- {"version":3,"file":"turn.d.ts","sourceRoot":"","sources":["../../src/orchestration/turn.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,UAAU,EAAE,MAAM,aAAa,CAAC;AA8BlE,OAAO,EAKH,KAAK,qBAAqB,EAC7B,MAAM,YAAY,CAAC;AAkKpB,wBAAiB,aAAa,CAC1B,OAAO,EAAE,qBAAqB,EAC9B,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,OAAO,EACpB,YAAY,CAAC,EAAE,MAAM,EACrB,gBAAgB,CAAC,EAAE,MAAM,EAAE,EAC3B,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,GACjC,SAAS,CAAC,GAAG,EAAE,IAAI,EAAE,GAAG,CAAC,CA8J3B;AAmDD,wBAAiB,gBAAgB,CAC7B,OAAO,EAAE,qBAAqB,EAC9B,MAAM,EAAE,UAAU,EAClB,YAAY,EAAE,MAAM,EACpB,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,GACjC,SAAS,CAAC,GAAG,EAAE,IAAI,EAAE,GAAG,CAAC,CAya3B;AAID,wBAAiB,YAAY,CACzB,OAAO,EAAE,qBAAqB,EAC9B,SAAS,EAAE,GAAG,GACf,SAAS,CAAC,GAAG,EAAE,IAAI,EAAE,GAAG,CAAC,CA8L3B"}
1
+ {"version":3,"file":"turn.d.ts","sourceRoot":"","sources":["../../src/orchestration/turn.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,UAAU,EAAE,MAAM,aAAa,CAAC;AA+BlE,OAAO,EAKH,KAAK,qBAAqB,EAC7B,MAAM,YAAY,CAAC;AAkCpB;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,KAAK,CAAC;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,GAAG,CAAA;CAAE,CAAC,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAYpH;AAiJD,wBAAiB,aAAa,CAC1B,OAAO,EAAE,qBAAqB,EAC9B,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,OAAO,EACpB,YAAY,CAAC,EAAE,MAAM,EACrB,gBAAgB,CAAC,EAAE,MAAM,EAAE,EAC3B,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,GACjC,SAAS,CAAC,GAAG,EAAE,IAAI,EAAE,GAAG,CAAC,CAkM3B;AAID;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,GAAG,GAAG;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAaxH;AA+TD,wBAAiB,gBAAgB,CAC7B,OAAO,EAAE,qBAAqB,EAC9B,MAAM,EAAE,UAAU,EAClB,YAAY,EAAE,MAAM,EACpB,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,GACjC,SAAS,CAAC,GAAG,EAAE,IAAI,EAAE,GAAG,CAAC,CA0R3B;AAID,wBAAiB,YAAY,CACzB,OAAO,EAAE,qBAAqB,EAC9B,SAAS,EAAE,GAAG,GACf,SAAS,CAAC,GAAG,EAAE,IAAI,EAAE,GAAG,CAAC,CAgM3B"}
@@ -1,12 +1,49 @@
1
- import { SESSION_STATE_MISSING_PREFIX } from "../types.js";
1
+ import { SESSION_STATE_MISSING_PREFIX, stopTurnQueueName } from "../types.js";
2
2
  import { createSessionProxy } from "../session-proxy.js";
3
3
  import { planWaitHandling } from "../wait-affinity.js";
4
4
  import { buildShutdownWaitReason, failPendingShutdown, getStillRunningAgentIds, handleSubAgentAction, isSubAgentTerminalStatus, maybeResolveAgentWaitCompletion, refreshTrackedSubAgents, } from "./agents.js";
5
- import { applyCronAtAction, applyCronAction, continueInput, continueInputWithPrompt, dehydrateForNextTurn, drainLeadingQueuedScheduleActions, ensureTaskContext, maybeCheckpoint, maybeSummarize, publishStatus, versionedContinueAsNew, wrapWithResumeContext, writeLatestResponse, } from "./lifecycle.js";
5
+ import { applyCronAtAction, applyCronAction, continueInput, continueInputWithPrompt, dehydrateForNextTurn, drainLeadingQueuedScheduleActions, ensureTaskContext, maybeCheckpoint, maybeSummarize, publishStatus, versionedContinueAsNew, wrapWithResumeContext, writeCommandResponse, writeLatestResponse, } from "./lifecycle.js";
6
6
  import { describeCronAt } from "../cron-at.js";
7
7
  import { shouldWakeParentForChildUpdate } from "../child-notifications.js";
8
8
  import { INTERNAL_SYSTEM_TURN_PROMPT, MAX_RETRIES, SHUTDOWN_POLL_INTERVAL_MS, SHUTDOWN_TIMEOUT_MS, } from "./state.js";
9
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
+ }
10
47
  function* handleConnectionClosedRetry(runtime, errorMessage, rc) {
11
48
  const { state } = runtime;
12
49
  if (state.retryCount <= COPILOT_CONNECTION_CLOSED_MAX_RETRIES) {
@@ -140,6 +177,10 @@ export function* processPrompt(runtime, promptText, isBootstrap, requiredTool, c
140
177
  const extractedPrompt = extractPromptSystemContext(prompt);
141
178
  prompt = extractedPrompt.prompt ?? "";
142
179
  turnSystemPrompt = mergePrompt(turnSystemPrompt, extractedPrompt.systemPrompt);
180
+ if (prompt && state.runtimeModelNotice) {
181
+ turnSystemPrompt = mergePrompt(turnSystemPrompt, state.runtimeModelNotice);
182
+ state.runtimeModelNotice = undefined;
183
+ }
143
184
  const systemOnlyTurn = !prompt && !!turnSystemPrompt;
144
185
  if (systemOnlyTurn) {
145
186
  prompt = INTERNAL_SYSTEM_TURN_PROMPT;
@@ -203,7 +244,15 @@ export function* processPrompt(runtime, promptText, isBootstrap, requiredTool, c
203
244
  let turnResult;
204
245
  try {
205
246
  state.lastLiveSessionAction = "session-activity";
206
- turnResult = yield runtime.session.runTurn(prompt, promptIsBootstrap, state.iteration, {
247
+ // Stop-turn race: the in-flight runTurn activity vs a dequeue on the
248
+ // TURN-SCOPED stop queue (stopTurn.<iteration>). Scoping the queue to
249
+ // the turn index makes stale stop events structurally unable to kill a
250
+ // later turn — a race loser is dropped and cannot be un-dropped.
251
+ // When the stop wins, duroxide cancel-requests the dropped runTurn
252
+ // work item (lock-steal → isCancelled poll → SDK abort) as the
253
+ // guaranteed backstop; handleTurnStopped layers the fast-path
254
+ // same-affinity abortTurn on top.
255
+ const turnTask = runtime.session.runTurn(prompt, promptIsBootstrap, state.iteration, {
207
256
  ...(runtime.options.parentSessionId ? { parentSessionId: runtime.options.parentSessionId } : {}),
208
257
  nestingLevel: runtime.options.nestingLevel,
209
258
  ...(requiredTool ? { requiredTool } : {}),
@@ -211,6 +260,21 @@ export function* processPrompt(runtime, promptText, isBootstrap, requiredTool, c
211
260
  retryCount: state.retryCount,
212
261
  ...(clientMessageIds && clientMessageIds.length > 0 ? { clientMessageIds } : {}),
213
262
  });
263
+ const stopTask = ctx.dequeueEvent(stopTurnQueueName(state.iteration));
264
+ const race = yield ctx.race(turnTask, stopTask);
265
+ if (race.index === 1) {
266
+ yield* handleTurnStopped(runtime, race.value);
267
+ return;
268
+ }
269
+ // The select bridge flattens activity failures into their raw error
270
+ // string (duroxide-node make_select_future) instead of throwing, so a
271
+ // failed runTurn must be re-thrown here to reach the existing retry
272
+ // machinery in the catch below.
273
+ const raced = normalizeRacedTurnValue(race.value);
274
+ if (raced.kind === "error") {
275
+ throw new Error(raced.message);
276
+ }
277
+ turnResult = raced.result;
214
278
  }
215
279
  catch (err) {
216
280
  state.config.turnSystemPrompt = undefined;
@@ -253,9 +317,16 @@ export function* processPrompt(runtime, promptText, isBootstrap, requiredTool, c
253
317
  }
254
318
  state.config.turnSystemPrompt = undefined;
255
319
  state.retryCount = 0;
256
- const result = typeof turnResult === "string" ? JSON.parse(turnResult) : turnResult;
320
+ let result = typeof turnResult === "string" ? JSON.parse(turnResult) : turnResult;
257
321
  const observedAt = yield ctx.utcNow();
258
322
  state.contextUsage = updateContextUsageFromEvents(state.contextUsage, result?.events, observedAt);
323
+ const failedModelSwitchContinuePrompt = captureFailedModelSwitchNotice(runtime, result);
324
+ if (failedModelSwitchContinuePrompt && result.type === "completed") {
325
+ result = {
326
+ ...result,
327
+ forceContinuePrompt: failedModelSwitchContinuePrompt,
328
+ };
329
+ }
259
330
  state.iteration++;
260
331
  yield* maybeSummarize(runtime);
261
332
  yield* refreshTrackedSubAgents(runtime);
@@ -266,6 +337,276 @@ export function* processPrompt(runtime, promptText, isBootstrap, requiredTool, c
266
337
  yield* drainLeadingQueuedScheduleActions(runtime, prompt);
267
338
  yield* handleTurnResult(runtime, result, prompt, cycleOrigin);
268
339
  }
340
+ // ─── Stop-turn race support ─────────────────────────────────
341
+ /**
342
+ * Normalize a raced runTurn branch value. The duroxide-node select bridge
343
+ * flattens activity failures into their raw error string (make_select_future:
344
+ * `Ok(v) => v, Err(e) => e`) instead of throwing into the generator, so the
345
+ * caller must distinguish a TurnResult payload from an error message.
346
+ */
347
+ export function normalizeRacedTurnValue(value) {
348
+ let v = value;
349
+ if (typeof v === "string") {
350
+ try {
351
+ v = JSON.parse(v);
352
+ }
353
+ catch {
354
+ return { kind: "error", message: value };
355
+ }
356
+ }
357
+ if (v && typeof v === "object" && typeof v.type === "string") {
358
+ return { kind: "result", result: v };
359
+ }
360
+ return { kind: "error", message: typeof value === "string" ? value : JSON.stringify(value ?? null) };
361
+ }
362
+ /**
363
+ * Stop won the race against the in-flight runTurn activity.
364
+ *
365
+ * The dropped runTurn future is already cancel-requested by duroxide (the
366
+ * guaranteed backstop: lock-steal → isCancelled poll → SDK abort, ~2-7s).
367
+ * This path layers the fast-path interrupt on top and owns the authoritative
368
+ * durable bookkeeping — the aborted activity's own writeback is best-effort
369
+ * (it is skipped entirely when the backstop delivered the abort).
370
+ */
371
+ function* handleTurnStopped(runtime, stopEventRaw) {
372
+ const { ctx, state } = runtime;
373
+ let stopEvent = stopEventRaw;
374
+ if (typeof stopEvent === "string") {
375
+ try {
376
+ stopEvent = JSON.parse(stopEvent);
377
+ }
378
+ catch {
379
+ stopEvent = {};
380
+ }
381
+ }
382
+ if (!stopEvent || typeof stopEvent !== "object")
383
+ stopEvent = {};
384
+ const reason = typeof stopEvent.reason === "string" && stopEvent.reason ? stopEvent.reason : "Stopped by user";
385
+ const stoppedIteration = state.iteration;
386
+ ctx.traceInfo(`[orch] stop_turn won the race for turn ${stoppedIteration}; aborting in-flight turn`);
387
+ state.config.turnSystemPrompt = undefined;
388
+ state.retryCount = 0;
389
+ // Fast-path interrupt: same-affinity abortTurn lands on the worker owning
390
+ // the warm session and aborts the SDK request immediately (concurrent
391
+ // dispatch requires stable workerNodeId + a free slot; otherwise the
392
+ // backstop still stops the turn, just slower). Awaiting it also
393
+ // guarantees the per-session run-turn lock is free again before this
394
+ // loop can dispatch the next prompt.
395
+ let abortOutcome = null;
396
+ try {
397
+ const raw = yield runtime.session.abortTurn(reason, stoppedIteration);
398
+ abortOutcome = typeof raw === "string" ? JSON.parse(raw) : raw;
399
+ }
400
+ catch (err) {
401
+ ctx.traceInfo(`[orch] abortTurn activity failed (backstop cancellation still applies): ${err?.message ?? err}`);
402
+ abortOutcome = { outcome: "no_active_turn", detail: `abortTurn failed: ${err?.message ?? err}` };
403
+ }
404
+ // The race already decided the turn's fate: even when abortTurn reports
405
+ // no_active_turn (the backstop got there first, or the turn had just
406
+ // ended), the user's stop is the durable outcome. Record turn_stopped
407
+ // unconditionally and annotate how the interrupt was delivered.
408
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [
409
+ {
410
+ eventType: "session.turn_stopped",
411
+ data: {
412
+ reason,
413
+ turnIndex: stoppedIteration,
414
+ interrupt: abortOutcome?.outcome ?? "unknown",
415
+ ...(abortOutcome?.detail ? { detail: abortOutcome.detail } : {}),
416
+ },
417
+ },
418
+ { eventType: "system.message", data: { content: "Turn stopped by user." } },
419
+ ]);
420
+ // Authoritative CMS transition — also clears active_turn_index (migration
421
+ // 0024 clears it on any state transition away from "running").
422
+ yield runtime.manager.updateCmsState(runtime.input.sessionId, "idle");
423
+ // The turn ran and consumed context even though its result was discarded.
424
+ state.iteration++;
425
+ if (typeof stopEvent.id === "string" && stopEvent.id) {
426
+ yield* writeCommandResponse(runtime, {
427
+ id: stopEvent.id,
428
+ cmd: "stop_turn",
429
+ result: {
430
+ outcome: abortOutcome?.outcome === "stop_forced" ? "stop_forced" : "stopped",
431
+ turnIndex: stoppedIteration,
432
+ ...(abortOutcome?.detail ? { detail: abortOutcome.detail } : {}),
433
+ },
434
+ });
435
+ }
436
+ // Same scheduling semantics as a completed turn: resume interrupted
437
+ // timers, re-arm cron schedules, else idle (skips: writeLatestResponse,
438
+ // parent CHILD_UPDATE notify, forgotten-timer nudge).
439
+ yield* schedulePostTurnContinuation(runtime);
440
+ }
441
+ // ─── Post-turn continuation: resume timers / re-arm schedules / go idle ───
442
+ //
443
+ // Extracted verbatim from the tail of the `completed` turn-result case so the
444
+ // stop-turn path shares identical scheduling semantics: stopping a turn must
445
+ // not silently kill a recurring session's cron loop or a resumable wait
446
+ // (stop-turn plan, edge E9).
447
+ function* schedulePostTurnContinuation(runtime) {
448
+ const { ctx, state, options } = runtime;
449
+ if (state.interruptedWaitTimer && state.interruptedWaitTimer.remainingSec > 0) {
450
+ const saved = state.interruptedWaitTimer;
451
+ state.interruptedWaitTimer = null;
452
+ ctx.traceInfo(`[orch] auto-resuming interrupted wait: ${saved.remainingSec}s (${saved.reason})`);
453
+ if (saved.shouldRehydrate) {
454
+ yield* dehydrateForNextTurn(runtime, "timer", saved.waitPlan?.resetAffinityOnDehydrate ?? true);
455
+ }
456
+ const resumeNow = yield ctx.utcNow();
457
+ publishStatus(runtime, "waiting", {
458
+ waitSeconds: saved.remainingSec,
459
+ waitReason: saved.reason,
460
+ waitStartedAt: resumeNow,
461
+ });
462
+ if (!saved.shouldRehydrate)
463
+ yield* maybeCheckpoint(runtime);
464
+ state.activeTimer = {
465
+ deadlineMs: resumeNow + saved.remainingSec * 1000,
466
+ originalDurationMs: saved.remainingSec * 1000,
467
+ reason: saved.reason,
468
+ type: "wait",
469
+ shouldRehydrate: saved.shouldRehydrate,
470
+ waitPlan: saved.waitPlan,
471
+ };
472
+ return;
473
+ }
474
+ if (state.interruptedCronTimer && state.interruptedCronTimer.remainingMs > 0) {
475
+ const saved = state.interruptedCronTimer;
476
+ state.interruptedCronTimer = null;
477
+ const remainingMs = Math.max(0, saved.remainingMs);
478
+ const remainingSec = Math.max(1, Math.round(remainingMs / 1000));
479
+ ctx.traceInfo(`[orch] auto-resuming interrupted cron: ${remainingSec}s remain (${saved.reason})`);
480
+ const cronResumePlan = planWaitHandling({
481
+ blobEnabled: state.blobEnabled,
482
+ seconds: remainingSec,
483
+ dehydrateThreshold: options.dehydrateThreshold,
484
+ });
485
+ if (cronResumePlan.shouldDehydrate) {
486
+ yield* dehydrateForNextTurn(runtime, "cron", cronResumePlan.resetAffinityOnDehydrate);
487
+ }
488
+ const resumeNow = yield ctx.utcNow();
489
+ publishStatus(runtime, "waiting", {
490
+ waitSeconds: remainingSec,
491
+ waitReason: saved.reason,
492
+ waitStartedAt: resumeNow,
493
+ });
494
+ if (!cronResumePlan.shouldDehydrate)
495
+ yield* maybeCheckpoint(runtime);
496
+ state.activeTimer = {
497
+ deadlineMs: resumeNow + remainingMs,
498
+ originalDurationMs: remainingMs,
499
+ reason: saved.reason,
500
+ type: "cron",
501
+ shouldRehydrate: cronResumePlan.shouldDehydrate,
502
+ };
503
+ return;
504
+ }
505
+ if (state.cronSchedule) {
506
+ const activeCron = { ...state.cronSchedule };
507
+ const cronPlan = planWaitHandling({
508
+ blobEnabled: state.blobEnabled,
509
+ seconds: activeCron.intervalSeconds,
510
+ dehydrateThreshold: options.dehydrateThreshold,
511
+ });
512
+ if (cronPlan.shouldDehydrate) {
513
+ yield* dehydrateForNextTurn(runtime, "cron", cronPlan.resetAffinityOnDehydrate);
514
+ }
515
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
516
+ eventType: "session.cron_started",
517
+ data: { intervalSeconds: activeCron.intervalSeconds, reason: activeCron.reason },
518
+ }]);
519
+ const cronStartedAt = yield ctx.utcNow();
520
+ ctx.traceInfo(`[orch] cron timer: ${activeCron.intervalSeconds}s (${activeCron.reason})`);
521
+ publishStatus(runtime, "waiting", {
522
+ waitSeconds: activeCron.intervalSeconds,
523
+ waitReason: activeCron.reason,
524
+ waitStartedAt: cronStartedAt,
525
+ });
526
+ if (!cronPlan.shouldDehydrate)
527
+ yield* maybeCheckpoint(runtime);
528
+ state.activeTimer = {
529
+ deadlineMs: cronStartedAt + activeCron.intervalSeconds * 1000,
530
+ originalDurationMs: activeCron.intervalSeconds * 1000,
531
+ reason: activeCron.reason,
532
+ type: "cron",
533
+ shouldRehydrate: cronPlan.shouldDehydrate,
534
+ };
535
+ return;
536
+ }
537
+ if (state.cronAtSchedule) {
538
+ const activeCronAt = { ...state.cronAtSchedule };
539
+ if (activeCronAt.maxFires !== undefined && activeCronAt.firesCompleted >= activeCronAt.maxFires) {
540
+ state.cronAtSchedule = undefined;
541
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
542
+ eventType: "session.cron_at_completed",
543
+ data: { reason: activeCronAt.reason, firesCompleted: activeCronAt.firesCompleted, maxFires: activeCronAt.maxFires },
544
+ }]);
545
+ return;
546
+ }
547
+ const nowMs = yield ctx.utcNow();
548
+ let nextFireAtMs = activeCronAt.nextFireAtMs;
549
+ let nextOccurrenceKey = activeCronAt.nextOccurrenceKey;
550
+ if (!nextFireAtMs || !nextOccurrenceKey) {
551
+ const nextFire = yield runtime.manager.computeCronAtNextFire(activeCronAt, nowMs, activeCronAt.lastOccurrenceKey);
552
+ nextFireAtMs = nextFire.nextFireAtMs;
553
+ nextOccurrenceKey = nextFire.occurrenceKey;
554
+ state.cronAtSchedule = {
555
+ ...activeCronAt,
556
+ nextFireAtMs,
557
+ nextOccurrenceKey,
558
+ };
559
+ }
560
+ if (nextFireAtMs === undefined || !nextOccurrenceKey) {
561
+ throw new Error("cron_at next-fire computation did not return a fire time");
562
+ }
563
+ const waitMs = Math.max(0, nextFireAtMs - nowMs);
564
+ const waitSeconds = Math.max(0, Math.ceil(waitMs / 1000));
565
+ const cronAtPlan = planWaitHandling({
566
+ blobEnabled: state.blobEnabled,
567
+ seconds: waitSeconds,
568
+ dehydrateThreshold: options.dehydrateThreshold,
569
+ });
570
+ if (cronAtPlan.shouldDehydrate) {
571
+ yield* dehydrateForNextTurn(runtime, "cron_at", cronAtPlan.resetAffinityOnDehydrate);
572
+ }
573
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
574
+ eventType: "session.cron_at_started",
575
+ data: {
576
+ ...state.cronAtSchedule,
577
+ nextFireAt: new Date(nextFireAtMs).toISOString(),
578
+ },
579
+ }]);
580
+ publishStatus(runtime, "waiting", {
581
+ waitSeconds,
582
+ waitReason: activeCronAt.reason,
583
+ waitStartedAt: nowMs,
584
+ });
585
+ if (!cronAtPlan.shouldDehydrate)
586
+ yield* maybeCheckpoint(runtime);
587
+ state.activeTimer = {
588
+ deadlineMs: nowMs + waitMs,
589
+ originalDurationMs: waitMs,
590
+ reason: activeCronAt.reason,
591
+ type: "cron_at",
592
+ shouldRehydrate: cronAtPlan.shouldDehydrate,
593
+ };
594
+ return;
595
+ }
596
+ if (!state.blobEnabled || options.idleTimeout < 0) {
597
+ yield* maybeCheckpoint(runtime);
598
+ return;
599
+ }
600
+ publishStatus(runtime, "idle");
601
+ yield* maybeCheckpoint(runtime);
602
+ const idleNow = yield ctx.utcNow();
603
+ state.activeTimer = {
604
+ deadlineMs: idleNow + options.idleTimeout * 1000,
605
+ originalDurationMs: options.idleTimeout * 1000,
606
+ reason: "idle timeout",
607
+ type: "idle",
608
+ };
609
+ }
269
610
  // ─── handleTurnResult: dispatch on TurnResult variant ───────
270
611
  function coerceChildQuestionToWait(runtime, result) {
271
612
  if (result.type === "completed"
@@ -315,6 +656,13 @@ export function* handleTurnResult(runtime, result, sourcePrompt, cycleOrigin) {
315
656
  content: result.content,
316
657
  model: result.model,
317
658
  });
659
+ if (result.forceContinuePrompt) {
660
+ ctx.traceInfo(`[orch] continuing after terminal model switch failure`);
661
+ yield* versionedContinueAsNew(runtime, continueInputWithPrompt(runtime, result.forceContinuePrompt, {
662
+ bootstrapPrompt: true,
663
+ }));
664
+ return;
665
+ }
318
666
  if (options.parentSessionId) {
319
667
  const cycleReport = result.cycleReport;
320
668
  const cycleMaterial = cycleReport?.status === "material" || cycleReport?.status === "blocked"
@@ -372,166 +720,7 @@ export function* handleTurnResult(runtime, result, sourcePrompt, cycleOrigin) {
372
720
  return;
373
721
  }
374
722
  }
375
- if (state.interruptedWaitTimer && state.interruptedWaitTimer.remainingSec > 0) {
376
- const saved = state.interruptedWaitTimer;
377
- state.interruptedWaitTimer = null;
378
- ctx.traceInfo(`[orch] auto-resuming interrupted wait: ${saved.remainingSec}s (${saved.reason})`);
379
- if (saved.shouldRehydrate) {
380
- yield* dehydrateForNextTurn(runtime, "timer", saved.waitPlan?.resetAffinityOnDehydrate ?? true);
381
- }
382
- const resumeNow = yield ctx.utcNow();
383
- publishStatus(runtime, "waiting", {
384
- waitSeconds: saved.remainingSec,
385
- waitReason: saved.reason,
386
- waitStartedAt: resumeNow,
387
- });
388
- if (!saved.shouldRehydrate)
389
- yield* maybeCheckpoint(runtime);
390
- state.activeTimer = {
391
- deadlineMs: resumeNow + saved.remainingSec * 1000,
392
- originalDurationMs: saved.remainingSec * 1000,
393
- reason: saved.reason,
394
- type: "wait",
395
- shouldRehydrate: saved.shouldRehydrate,
396
- waitPlan: saved.waitPlan,
397
- };
398
- return;
399
- }
400
- if (state.interruptedCronTimer && state.interruptedCronTimer.remainingMs > 0) {
401
- const saved = state.interruptedCronTimer;
402
- state.interruptedCronTimer = null;
403
- const remainingMs = Math.max(0, saved.remainingMs);
404
- const remainingSec = Math.max(1, Math.round(remainingMs / 1000));
405
- ctx.traceInfo(`[orch] auto-resuming interrupted cron: ${remainingSec}s remain (${saved.reason})`);
406
- const cronResumePlan = planWaitHandling({
407
- blobEnabled: state.blobEnabled,
408
- seconds: remainingSec,
409
- dehydrateThreshold: options.dehydrateThreshold,
410
- });
411
- if (cronResumePlan.shouldDehydrate) {
412
- yield* dehydrateForNextTurn(runtime, "cron", cronResumePlan.resetAffinityOnDehydrate);
413
- }
414
- const resumeNow = yield ctx.utcNow();
415
- publishStatus(runtime, "waiting", {
416
- waitSeconds: remainingSec,
417
- waitReason: saved.reason,
418
- waitStartedAt: resumeNow,
419
- });
420
- if (!cronResumePlan.shouldDehydrate)
421
- yield* maybeCheckpoint(runtime);
422
- state.activeTimer = {
423
- deadlineMs: resumeNow + remainingMs,
424
- originalDurationMs: remainingMs,
425
- reason: saved.reason,
426
- type: "cron",
427
- shouldRehydrate: cronResumePlan.shouldDehydrate,
428
- };
429
- return;
430
- }
431
- if (state.cronSchedule) {
432
- const activeCron = { ...state.cronSchedule };
433
- const cronPlan = planWaitHandling({
434
- blobEnabled: state.blobEnabled,
435
- seconds: activeCron.intervalSeconds,
436
- dehydrateThreshold: options.dehydrateThreshold,
437
- });
438
- if (cronPlan.shouldDehydrate) {
439
- yield* dehydrateForNextTurn(runtime, "cron", cronPlan.resetAffinityOnDehydrate);
440
- }
441
- yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
442
- eventType: "session.cron_started",
443
- data: { intervalSeconds: activeCron.intervalSeconds, reason: activeCron.reason },
444
- }]);
445
- const cronStartedAt = yield ctx.utcNow();
446
- ctx.traceInfo(`[orch] cron timer: ${activeCron.intervalSeconds}s (${activeCron.reason})`);
447
- publishStatus(runtime, "waiting", {
448
- waitSeconds: activeCron.intervalSeconds,
449
- waitReason: activeCron.reason,
450
- waitStartedAt: cronStartedAt,
451
- });
452
- if (!cronPlan.shouldDehydrate)
453
- yield* maybeCheckpoint(runtime);
454
- state.activeTimer = {
455
- deadlineMs: cronStartedAt + activeCron.intervalSeconds * 1000,
456
- originalDurationMs: activeCron.intervalSeconds * 1000,
457
- reason: activeCron.reason,
458
- type: "cron",
459
- shouldRehydrate: cronPlan.shouldDehydrate,
460
- };
461
- return;
462
- }
463
- if (state.cronAtSchedule) {
464
- const activeCronAt = { ...state.cronAtSchedule };
465
- if (activeCronAt.maxFires !== undefined && activeCronAt.firesCompleted >= activeCronAt.maxFires) {
466
- state.cronAtSchedule = undefined;
467
- yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
468
- eventType: "session.cron_at_completed",
469
- data: { reason: activeCronAt.reason, firesCompleted: activeCronAt.firesCompleted, maxFires: activeCronAt.maxFires },
470
- }]);
471
- return;
472
- }
473
- const nowMs = yield ctx.utcNow();
474
- let nextFireAtMs = activeCronAt.nextFireAtMs;
475
- let nextOccurrenceKey = activeCronAt.nextOccurrenceKey;
476
- if (!nextFireAtMs || !nextOccurrenceKey) {
477
- const nextFire = yield runtime.manager.computeCronAtNextFire(activeCronAt, nowMs, activeCronAt.lastOccurrenceKey);
478
- nextFireAtMs = nextFire.nextFireAtMs;
479
- nextOccurrenceKey = nextFire.occurrenceKey;
480
- state.cronAtSchedule = {
481
- ...activeCronAt,
482
- nextFireAtMs,
483
- nextOccurrenceKey,
484
- };
485
- }
486
- if (nextFireAtMs === undefined || !nextOccurrenceKey) {
487
- throw new Error("cron_at next-fire computation did not return a fire time");
488
- }
489
- const waitMs = Math.max(0, nextFireAtMs - nowMs);
490
- const waitSeconds = Math.max(0, Math.ceil(waitMs / 1000));
491
- const cronAtPlan = planWaitHandling({
492
- blobEnabled: state.blobEnabled,
493
- seconds: waitSeconds,
494
- dehydrateThreshold: options.dehydrateThreshold,
495
- });
496
- if (cronAtPlan.shouldDehydrate) {
497
- yield* dehydrateForNextTurn(runtime, "cron_at", cronAtPlan.resetAffinityOnDehydrate);
498
- }
499
- yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
500
- eventType: "session.cron_at_started",
501
- data: {
502
- ...state.cronAtSchedule,
503
- nextFireAt: new Date(nextFireAtMs).toISOString(),
504
- },
505
- }]);
506
- publishStatus(runtime, "waiting", {
507
- waitSeconds,
508
- waitReason: activeCronAt.reason,
509
- waitStartedAt: nowMs,
510
- });
511
- if (!cronAtPlan.shouldDehydrate)
512
- yield* maybeCheckpoint(runtime);
513
- state.activeTimer = {
514
- deadlineMs: nowMs + waitMs,
515
- originalDurationMs: waitMs,
516
- reason: activeCronAt.reason,
517
- type: "cron_at",
518
- shouldRehydrate: cronAtPlan.shouldDehydrate,
519
- };
520
- return;
521
- }
522
- if (!state.blobEnabled || options.idleTimeout < 0) {
523
- yield* maybeCheckpoint(runtime);
524
- return;
525
- }
526
- publishStatus(runtime, "idle");
527
- yield* maybeCheckpoint(runtime);
528
- const idleNow = yield ctx.utcNow();
529
- state.activeTimer = {
530
- deadlineMs: idleNow + options.idleTimeout * 1000,
531
- originalDurationMs: options.idleTimeout * 1000,
532
- reason: "idle timeout",
533
- type: "idle",
534
- };
723
+ yield* schedulePostTurnContinuation(runtime);
535
724
  return;
536
725
  }
537
726
  case "cron":
@@ -651,6 +840,26 @@ export function* handleTurnResult(runtime, result, sourcePrompt, cycleOrigin) {
651
840
  case "cancelled":
652
841
  ctx.traceInfo("[session] turn cancelled");
653
842
  return;
843
+ case "stopped": {
844
+ // Defensive: a turn only classifies "stopped" when the stop marker
845
+ // was set, which normally means handleTurnStopped already ran via
846
+ // the race. Handle it anyway so a marker-set turn that somehow
847
+ // returns through the normal path still lands idle with the event
848
+ // trail (processPrompt already incremented state.iteration).
849
+ ctx.traceInfo("[session] turn reported stopped");
850
+ state.retryCount = 0;
851
+ yield runtime.manager.recordSessionEvent(runtime.input.sessionId, [{
852
+ eventType: "session.turn_stopped",
853
+ data: {
854
+ reason: result.reason ?? "Stopped by user",
855
+ turnIndex: state.iteration - 1,
856
+ interrupt: "turn-result",
857
+ },
858
+ }]);
859
+ yield runtime.manager.updateCmsState(runtime.input.sessionId, "idle");
860
+ yield* schedulePostTurnContinuation(runtime);
861
+ return;
862
+ }
654
863
  case "spawn_agent":
655
864
  case "message_agent":
656
865
  case "check_agents":
@@ -715,7 +924,7 @@ export function* processTimer(runtime, timerItem) {
715
924
  data: {},
716
925
  }]);
717
926
  const activeCron = state.cronSchedule;
718
- 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, omit report_cycle or call report_cycle(status='quiet').";
927
+ 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.";
719
928
  const cronPrompt = `[SYSTEM: Scheduled cron wake-up for: "${activeCron.reason}". Resume your recurring task. ${cycleReportGuidance}]`;
720
929
  if (timer.shouldRehydrate) {
721
930
  yield* processPrompt(runtime, wrapWithResumeContext(runtime, "Resume your recurring task.", `Scheduled cron wake-up for: "${activeCron.reason}". ${cycleReportGuidance}`), true, undefined, undefined, "cron");
@@ -766,12 +975,14 @@ export function* processTimer(runtime, timerItem) {
766
975
  `Schedule: ${description}. Scheduled fire: ${new Date(scheduledAtMs).toISOString()}. ` +
767
976
  `Resume your recurring task now. ` +
768
977
  `If this cycle finds material changes or blockers that should wake your parent, call report_cycle(status='material' or status='blocked', summary='...') before finishing. ` +
769
- `If nothing material changed, omit report_cycle or call report_cycle(status='quiet').]`;
978
+ `If nothing material changed, do NOT call report_cycle at all — just end the turn silently. ` +
979
+ `Do not emit report_cycle(status='quiet') on an uneventful cycle, and never write a tool call as text.]`;
770
980
  if (timer.shouldRehydrate) {
771
981
  yield* processPrompt(runtime, wrapWithResumeContext(runtime, "Resume your recurring task.", `Scheduled wall-clock cron wake-up for "${activeCronAt.reason}". ` +
772
982
  `Schedule: ${description}. Scheduled fire: ${new Date(scheduledAtMs).toISOString()}. ` +
773
983
  `If this cycle finds material changes or blockers that should wake your parent, call report_cycle(status='material' or status='blocked', summary='...') before finishing. ` +
774
- `If nothing material changed, omit report_cycle or call report_cycle(status='quiet').`), true, undefined, undefined, "cron_at");
984
+ `If nothing material changed, do NOT call report_cycle at all just end the turn silently. ` +
985
+ `Do not emit report_cycle(status='quiet') on an uneventful cycle, and never write a tool call as text.`), true, undefined, undefined, "cron_at");
775
986
  }
776
987
  else {
777
988
  yield* processPrompt(runtime, cronAtPrompt, true, undefined, undefined, "cron_at");