@wjarka/cezarion 0.14.7-pr488.741 → 0.14.8-dev.817

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 (199) hide show
  1. package/README.md +76 -2
  2. package/dist/application-update/bin-recovery.d.ts +5 -0
  3. package/dist/application-update/bin-recovery.js +96 -0
  4. package/dist/application-update/bin-recovery.js.map +1 -0
  5. package/dist/application-update/discovery.d.ts +22 -0
  6. package/dist/application-update/discovery.js +91 -0
  7. package/dist/application-update/discovery.js.map +1 -0
  8. package/dist/application-update/helper.d.ts +57 -0
  9. package/dist/application-update/helper.js +374 -0
  10. package/dist/application-update/helper.js.map +1 -0
  11. package/dist/application-update/launcher.d.ts +8 -0
  12. package/dist/application-update/launcher.js +53 -0
  13. package/dist/application-update/launcher.js.map +1 -0
  14. package/dist/application-update/lock.d.ts +4 -0
  15. package/dist/application-update/lock.js +70 -0
  16. package/dist/application-update/lock.js.map +1 -0
  17. package/dist/application-update/npm-process.d.ts +17 -0
  18. package/dist/application-update/npm-process.js +133 -0
  19. package/dist/application-update/npm-process.js.map +1 -0
  20. package/dist/application-update/service.d.ts +69 -0
  21. package/dist/application-update/service.js +456 -0
  22. package/dist/application-update/service.js.map +1 -0
  23. package/dist/artifacts/cli.d.ts +1 -0
  24. package/dist/artifacts/cli.js +32 -0
  25. package/dist/artifacts/cli.js.map +1 -0
  26. package/dist/artifacts/lifecycle.d.ts +4 -0
  27. package/dist/artifacts/lifecycle.js +42 -0
  28. package/dist/artifacts/lifecycle.js.map +1 -0
  29. package/dist/artifacts/resolve.d.ts +18 -0
  30. package/dist/artifacts/resolve.js +102 -0
  31. package/dist/artifacts/resolve.js.map +1 -0
  32. package/dist/artifacts/store.d.ts +8 -0
  33. package/dist/artifacts/store.js +317 -0
  34. package/dist/artifacts/store.js.map +1 -0
  35. package/dist/ci-wait/mcp.d.ts +3 -0
  36. package/dist/ci-wait/mcp.js +14 -3
  37. package/dist/ci-wait/mcp.js.map +1 -1
  38. package/dist/contract/application-update.d.ts +32 -0
  39. package/dist/contract/artifacts.d.ts +83 -0
  40. package/dist/contract/ask-schema.d.ts +32 -0
  41. package/dist/contract/ask.d.ts +2 -32
  42. package/dist/contract/conversations.d.ts +67 -0
  43. package/dist/contract/delegation.d.ts +24 -0
  44. package/dist/contract/events.d.ts +42 -0
  45. package/dist/contract/health.d.ts +12 -0
  46. package/dist/contract/index.d.ts +2 -0
  47. package/dist/contract/index.js +1361 -1263
  48. package/dist/contract/runs.d.ts +108 -0
  49. package/dist/contract/skills.d.ts +21 -0
  50. package/dist/core/agent-runner.d.ts +33 -4
  51. package/dist/core/agent-runner.js +5 -0
  52. package/dist/core/agent-runner.js.map +1 -1
  53. package/dist/core/claude-cli-runner.d.ts +6 -12
  54. package/dist/core/claude-cli-runner.js +89 -19
  55. package/dist/core/claude-cli-runner.js.map +1 -1
  56. package/dist/core/codex-app-server-runner.d.ts +2 -1
  57. package/dist/core/codex-app-server-runner.js +327 -92
  58. package/dist/core/codex-app-server-runner.js.map +1 -1
  59. package/dist/core/codex-app-server-transport.d.ts +13 -1
  60. package/dist/core/codex-app-server-transport.js +58 -17
  61. package/dist/core/codex-app-server-transport.js.map +1 -1
  62. package/dist/core/cursor-acp-runner.d.ts +4 -1
  63. package/dist/core/cursor-acp-runner.js +3 -0
  64. package/dist/core/cursor-acp-runner.js.map +1 -1
  65. package/dist/core/cursor-provider-error.js +3 -1
  66. package/dist/core/cursor-provider-error.js.map +1 -1
  67. package/dist/core/input-submissions.d.ts +14 -0
  68. package/dist/core/input-submissions.js +26 -0
  69. package/dist/core/input-submissions.js.map +1 -0
  70. package/dist/core/opencode-server-runner.d.ts +4 -1
  71. package/dist/core/opencode-server-runner.js +144 -21
  72. package/dist/core/opencode-server-runner.js.map +1 -1
  73. package/dist/core/pi-runner.d.ts +2 -1
  74. package/dist/core/pi-runner.js +72 -7
  75. package/dist/core/pi-runner.js.map +1 -1
  76. package/dist/delegation/conversations.js +13 -5
  77. package/dist/delegation/conversations.js.map +1 -1
  78. package/dist/delegation/provision.js +5 -4
  79. package/dist/delegation/provision.js.map +1 -1
  80. package/dist/delegation/questions.d.ts +19 -0
  81. package/dist/delegation/questions.js +45 -0
  82. package/dist/delegation/questions.js.map +1 -0
  83. package/dist/delegation/results.d.ts +1 -1
  84. package/dist/delegation/results.js +23 -13
  85. package/dist/delegation/results.js.map +1 -1
  86. package/dist/delegation/routes.d.ts +76 -16
  87. package/dist/delegation/service.d.ts +3 -3
  88. package/dist/delegation/service.js +24 -5
  89. package/dist/delegation/service.js.map +1 -1
  90. package/dist/delegation/transport.d.ts +72 -12
  91. package/dist/index.js +72 -26
  92. package/dist/index.js.map +1 -1
  93. package/dist/open-url.d.ts +2 -0
  94. package/dist/open-url.js +20 -0
  95. package/dist/open-url.js.map +1 -0
  96. package/dist/release/snapshot.d.ts +3 -3
  97. package/dist/release/snapshot.js +6 -6
  98. package/dist/release/snapshot.js.map +1 -1
  99. package/dist/runs/client-request.d.ts +11 -0
  100. package/dist/runs/client-request.js +26 -0
  101. package/dist/runs/client-request.js.map +1 -0
  102. package/dist/runs/delegation-state.d.ts +17 -0
  103. package/dist/runs/event-history.js +6 -0
  104. package/dist/runs/event-history.js.map +1 -1
  105. package/dist/runs/store.d.ts +57 -5
  106. package/dist/runs/store.js +61 -0
  107. package/dist/runs/store.js.map +1 -1
  108. package/dist/server/server.d.ts +4709 -2750
  109. package/dist/server/server.js +170 -4
  110. package/dist/server/server.js.map +1 -1
  111. package/dist/task-cli/cli.d.ts +38 -0
  112. package/dist/task-cli/cli.js +463 -0
  113. package/dist/task-cli/cli.js.map +1 -0
  114. package/dist/task-cli/discovery.d.ts +22 -0
  115. package/dist/task-cli/discovery.js +95 -0
  116. package/dist/task-cli/discovery.js.map +1 -0
  117. package/dist/task-cli/http.d.ts +43 -0
  118. package/dist/task-cli/http.js +101 -0
  119. package/dist/task-cli/http.js.map +1 -0
  120. package/dist/task-cli/projections.d.ts +40 -0
  121. package/dist/task-cli/projections.js +45 -0
  122. package/dist/task-cli/projections.js.map +1 -0
  123. package/dist/task-cli/watch.d.ts +59 -0
  124. package/dist/task-cli/watch.js +303 -0
  125. package/dist/task-cli/watch.js.map +1 -0
  126. package/dist/workflows/run.d.ts +79 -0
  127. package/dist/workflows/run.js +676 -70
  128. package/dist/workflows/run.js.map +1 -1
  129. package/dist/workspace/projects-cli.js +1 -1
  130. package/package.json +3 -3
  131. package/scripts/mock-claude.mjs +38 -2
  132. package/scripts/mock-codex-app-server.mjs +98 -1
  133. package/scripts/mock-cursor-acp.mjs +3 -0
  134. package/scripts/mock-opencode-serve.mjs +69 -2
  135. package/scripts/mock-pi-rpc.mjs +63 -2
  136. package/web/dist/assets/agent-accounts-B7aDlJ4I.js +1 -0
  137. package/web/dist/assets/centered-state-wm14n-hB.js +43 -0
  138. package/web/dist/assets/collapsible-Bb2y0asn.js +1 -0
  139. package/web/dist/assets/commit-list-Bz4vV9X3.js +1 -0
  140. package/web/dist/assets/compare-variants-kXDxHaTA.js +1 -0
  141. package/web/dist/assets/diff-controls-frbAqBK1.js +1 -0
  142. package/web/dist/assets/{diff-BvZErqfg2.js → diff-rDeblLVZ2.js} +2 -2
  143. package/web/dist/assets/{diff-stat-CVFxf2Ar.js → diff-stat-Cn7rzy1H.js} +1 -1
  144. package/web/dist/assets/{diff-view-B35r8kQK.js → diff-view-D6N_QzSq.js} +1 -1
  145. package/web/dist/assets/dropdown-menu-Bwvi4Ono.js +1 -0
  146. package/web/dist/assets/git-toolbar-5TyOh819.js +1 -0
  147. package/web/dist/assets/github-CNO6b6Gn.js +1 -0
  148. package/web/dist/assets/{image-preview-BBqbaKaq.js → image-preview-B618kDBH.js} +1 -1
  149. package/web/dist/assets/index-C4lWUOn4.css +1 -0
  150. package/web/dist/assets/index-_YqA5M2j.js +7 -0
  151. package/web/dist/assets/markdown-BnjmXdz2.js +2 -0
  152. package/web/dist/assets/pill-DqEXer06.js +1 -0
  153. package/web/dist/assets/project-router-BSpCQc8A.js +1 -0
  154. package/web/dist/assets/prompt-templates-BsHnTixc.js +15 -0
  155. package/web/dist/assets/repo-git-CXsnC6Qk.js +1 -0
  156. package/web/dist/assets/{run-diff-fChfUdwr.js → run-diff-CFeYdPGE.js} +2 -2
  157. package/web/dist/assets/run-header-G5PQQyme.js +1 -0
  158. package/web/dist/assets/skills-D8ap2P2f.js +1 -0
  159. package/web/dist/assets/{tab-link-Db6yDTIQ.js → tab-link-BXDesc8g.js} +1 -1
  160. package/web/dist/assets/task-changes-DuhqLfCv.js +1 -0
  161. package/web/dist/assets/task-commits--GyJzH7L.js +1 -0
  162. package/web/dist/assets/task-files-CrKJTTrk.js +2 -0
  163. package/web/dist/assets/task-thread-BL7keYuU.js +9 -0
  164. package/web/dist/assets/task-thread-BQFdz1dJ.css +1 -0
  165. package/web/dist/assets/textarea-COQtp9cB.js +1 -0
  166. package/web/dist/assets/thread-loading-B7mgkPaL.js +1 -0
  167. package/web/dist/assets/token-metrics-CFWQYcgY.js +1 -0
  168. package/web/dist/assets/utils-DKheTxGD.js +64 -0
  169. package/web/dist/assets/workflows-DqEfQDeS.js +11 -0
  170. package/web/dist/assets/{zoomable-image-DpL5gtv4.js → zoomable-image-YW3Qdrr_.js} +1 -1
  171. package/web/dist/index.html +14 -14
  172. package/web/dist/assets/agent-accounts-DveiLHoy.js +0 -1
  173. package/web/dist/assets/alert-dialog-BRmsCA3M.js +0 -43
  174. package/web/dist/assets/collapsible-BWfM2PdZ.js +0 -1
  175. package/web/dist/assets/commit-list-C-rCgcKm.js +0 -1
  176. package/web/dist/assets/compare-variants-Ccp9IYp-.js +0 -1
  177. package/web/dist/assets/diff-controls-BFI8ngtw.js +0 -1
  178. package/web/dist/assets/dropdown-menu-C_fj6kzP.js +0 -1
  179. package/web/dist/assets/git-toolbar-BaqAvRC1.js +0 -1
  180. package/web/dist/assets/github-DIkBazPA.js +0 -1
  181. package/web/dist/assets/index-BIae70yp.js +0 -7
  182. package/web/dist/assets/index-CXZukB1O.css +0 -1
  183. package/web/dist/assets/markdown-kouQNPB6.js +0 -2
  184. package/web/dist/assets/pill-mHX_rKXP.js +0 -1
  185. package/web/dist/assets/project-router-Ds9EvxjN.js +0 -1
  186. package/web/dist/assets/prompt-templates-AaKKdIiY.js +0 -15
  187. package/web/dist/assets/repo-git-6StCwqhj.js +0 -1
  188. package/web/dist/assets/run-header-B3ZPhDZr.js +0 -1
  189. package/web/dist/assets/skills-DSWaVvcu.js +0 -1
  190. package/web/dist/assets/task-changes-DdmerESW.js +0 -1
  191. package/web/dist/assets/task-commits-Co-bb1P6.js +0 -1
  192. package/web/dist/assets/task-files-B2KASfyL.js +0 -2
  193. package/web/dist/assets/task-thread-8kGLJO4Q.js +0 -9
  194. package/web/dist/assets/task-thread-Dc3wplva.css +0 -1
  195. package/web/dist/assets/textarea-Qhdzn_oU.js +0 -1
  196. package/web/dist/assets/thread-loading-C6ENzZYk.js +0 -1
  197. package/web/dist/assets/token-metrics-BsT6Rqji.js +0 -1
  198. package/web/dist/assets/utils-VFstALJW.js +0 -64
  199. package/web/dist/assets/workflows-UKoofNFJ.js +0 -11
@@ -1,5 +1,6 @@
1
1
  import { ciWaitRequestSchema, ciWaitResultSchema } from '../contract/index.js';
2
2
  import { acquireCiResources } from '../ci-wait/resources.js';
3
+ import { artifactInstructions, provisionArtifactDirectory } from '../artifacts/lifecycle.js';
3
4
  import { workerContextHash, workerWorkflowHash, workerStepIdentity, captureWorkerAccount, boundWorkerAccountEnv, WorkerIdentityError } from '../delegation/execution-identity.js';
4
5
  import { buildChildEnv } from '../core/agent-env.js';
5
6
  import { randomUUID } from 'node:crypto';
@@ -19,7 +20,8 @@ import { todosPath } from '../todos.js';
19
20
  // Contract VALUES, like `workspaceUiStateSchema` in workspace/migrations.ts: the attachment
20
21
  // vocabulary the routes validate with is the same one the engine stores and re-reads by, so the
21
22
  // wire and the disk can never disagree about what counts as an image (#950).
22
- import { requestWaitRequestSchema, workerWaitRequestSchema, attachmentExtension, delegationStateSchema, pendingHumanAsk, isImageAttachmentName, isImageMediaType, } from '../contract/index.js';
23
+ import { requestWaitRequestSchema, workerWaitRequestSchema, attachmentExtension, delegationStateSchema, pendingHumanAsk, askRequestSchema, isImageAttachmentName, isImageMediaType, } from '../contract/index.js';
24
+ import { inputDeliveryOf } from '../core/agent-runner.js';
23
25
  import { discoverSkills } from '../skills.js';
24
26
  import { materializeSkillDir } from '../skills-remote.js';
25
27
  import { seedAgentConfigLocalLayer } from '../agent-config/seed.js';
@@ -35,6 +37,7 @@ import { DelegationPolicyError } from '../delegation/policy.js';
35
37
  import { reconcileWorkerWait } from '../delegation/wait.js';
36
38
  import { reconcileConversationState, projectConversationEvents } from '../delegation/conversations.js';
37
39
  import { parentReadiness } from '../delegation/readiness.js';
40
+ import { answersQuestion, openQuestions, questionMessage } from '../delegation/questions.js';
38
41
  import { workerOutcome } from '../runs/delegation-state.js';
39
42
  import { loadWorkflows } from './load.js';
40
43
  import { reclaimWorktrees, rematerializeReclaimedWorktree } from '../runs/retention.js';
@@ -122,6 +125,8 @@ function stripDoneMarker(text) {
122
125
  function stripMonitoringMarker(text) {
123
126
  return text.replace(/\s*CEZ:MONITORING\s*$/, '');
124
127
  }
128
+ /** #505: a worker question routed to its parent is answered there until it falls back. */
129
+ const ROUTED_QUESTION_REFUSAL = 'This question was sent to the parent task; answer it there.';
125
130
  /** Emit the v2 `ask.requested` event for a parsed marker (the cockpit renders
126
131
  * it as an ask card, #473). Returns the minted request id. */
127
132
  function emitAskRequested(sink, ask) {
@@ -213,6 +218,9 @@ export function repositoryRootLockDisabled(env = process.env) {
213
218
  }
214
219
  const REPOSITORY_ROOT_LOCK_DISABLED_NOTE = 'repository-root lock disabled by CEZ_DISABLE_REPO_LOCK=1 (shared checkout is unsafe)';
215
220
  /** Safety cap on autonomous auto-continues per run — stops a stuck agent from nudging forever. */
221
+ /** #505: a real steer is read at the harness's next model step; a quiet boundary this long
222
+ * with input still unread means the harness will not read it without help. */
223
+ export const UNREAD_INPUT_GRACE_MS = 30_000;
216
224
  const MAX_AUTO_CONTINUES = 40;
217
225
  const AUTONOMOUS_NUDGE = 'Continue working autonomously until the task is fully complete. Do not ask me for confirmation or clarification — make reasonable assumptions and proceed. When everything is done, end the session with your done signal.';
218
226
  const MONITORING_WAKE_NUDGE = 'Re-check the downstream work you were monitoring. Continue toward the task goal; emit CEZ:MONITORING again only if it is still pending.';
@@ -596,6 +604,8 @@ export class RunManager {
596
604
  inboxClaimTimers = new Map();
597
605
  reconcilingWorkers = false;
598
606
  recovering = false;
607
+ /** Quiet window before unread input is resubmitted or left unconfirmed (#505). */
608
+ unreadInputGraceMs;
599
609
  disposed = false;
600
610
  onDelegationRun = (run) => {
601
611
  if (!this.disposed && !['queued', 'running', 'waiting'].includes(run.status))
@@ -666,6 +676,7 @@ export class RunManager {
666
676
  this.store = store;
667
677
  this.repoRoot = repoRoot;
668
678
  this.dataDir = join(repoRoot, '.ai/cezar');
679
+ this.unreadInputGraceMs = options.unreadInputGraceMs ?? UNREAD_INPUT_GRACE_MS;
669
680
  this.semaphore = options.semaphore ?? new WorkspaceSemaphore();
670
681
  this.ciResources = acquireCiResources(this.semaphore);
671
682
  this.ciSupervisor = this.ciResources.supervisor;
@@ -809,6 +820,7 @@ export class RunManager {
809
820
  * turning into empty command output inside a running agent. */
810
821
  agentEnv(runId, generateFollowups = true) {
811
822
  return {
823
+ CEZ_ARTIFACTS_DIR: provisionArtifactDirectory(this.dataDir, runId),
812
824
  CEZ_HANDOFF_FILE: handoffPath(this.dataDir, runId),
813
825
  CEZ_TASK_ID: runId,
814
826
  CEZ_TODOS_FILE: generateFollowups ? todosPath(this.dataDir) : '',
@@ -1194,7 +1206,19 @@ export class RunManager {
1194
1206
  throw new DelegationPolicyError('invalid_input', error instanceof Error ? error.message : 'Worker execution selection is unavailable');
1195
1207
  }
1196
1208
  }
1197
- startRun(workflow, input, group) {
1209
+ /**
1210
+ * Idempotent start (#504, spec 2026-09-24-cez-task-cli). Lookup and create share one
1211
+ * synchronous path, so the event loop serialises concurrent retries: the first creates, every
1212
+ * later one with the same hash gets that run back, and a different hash is a conflict.
1213
+ */
1214
+ startRunIdempotent(workflow, input, request) {
1215
+ const existing = this.store.findRunByClientRequestId(request.id);
1216
+ if (existing) {
1217
+ return existing.clientRequestHash === request.hash ? { run: existing, created: false } : { conflict: true };
1218
+ }
1219
+ return { run: this.startRun(workflow, input, undefined, request), created: true };
1220
+ }
1221
+ startRun(workflow, input, group, clientRequest) {
1198
1222
  // Sanitize at the manager boundary so CLI runs, workflows, variants, and
1199
1223
  // direct callers cannot bypass the HTTP policy.
1200
1224
  const effectiveInput = agentModelsLocked(this.repoRoot)
@@ -1225,6 +1249,8 @@ export class RunManager {
1225
1249
  worktree: !group && input.worktree === false ? false : undefined,
1226
1250
  groupId: group?.groupId,
1227
1251
  variant: group?.variant,
1252
+ clientRequestId: clientRequest?.id,
1253
+ clientRequestHash: clientRequest?.hash,
1228
1254
  steps: workflow.steps.map((s) => ({ id: s.id, name: s.name ?? s.id, kind: stepKind(s) })),
1229
1255
  });
1230
1256
  // Persist the full definition so a queued run survives a restart (#367) —
@@ -1485,6 +1511,11 @@ export class RunManager {
1485
1511
  this.workerWakeAdmitted.add(runId);
1486
1512
  this.flushAgentInputs(runId);
1487
1513
  }
1514
+ else if (state.pendingHumanAsk && this.routedAsk(runId) && this.workerWaiting.delete(runId)) {
1515
+ // A parent's answer to the worker's routed question (#505).
1516
+ this.workerWakeAdmitted.add(runId);
1517
+ this.answerRoutedQuestion(runId, true);
1518
+ }
1488
1519
  continue;
1489
1520
  }
1490
1521
  if (this.workerWakeQueuedAt.delete(runId))
@@ -1682,6 +1713,13 @@ export class RunManager {
1682
1713
  for (const run of this.store.listRuns()) {
1683
1714
  this.store.clearExpiredInboxClaims(run.id, new Date().toISOString());
1684
1715
  this.armInboxClaimExpiry(run.id);
1716
+ // #505: the session that accepted this input died before the model read it.
1717
+ // Delivery is at-least-once: the replay keeps the input's ID in its text.
1718
+ if (['queued', 'running', 'waiting'].includes(run.status) && !this.isActive(run.id)) {
1719
+ const replayed = this.store.requeueAwaitingReadInputs(run.id);
1720
+ if (replayed.length)
1721
+ this.store.appendEvent(run.id, { type: 'note', message: `replaying ${replayed.length} message${replayed.length === 1 ? '' : 's'} accepted before the restart but not read` });
1722
+ }
1685
1723
  }
1686
1724
  this.reconcileWorkerWaits();
1687
1725
  const live = this.store
@@ -1837,7 +1875,15 @@ export class RunManager {
1837
1875
  finally {
1838
1876
  this.recovering = false;
1839
1877
  }
1878
+ // A question a crash caught before or after its commit to the parent still gets routed (#505).
1879
+ for (const run of this.store.listRuns())
1880
+ if (run.delegation?.role === 'worker')
1881
+ this.routeWorkerQuestion(run.id);
1840
1882
  this.reconcileWorkerWaits();
1883
+ // A parent reply accepted just before the crash still answers its worker (#505).
1884
+ for (const run of this.store.listRuns())
1885
+ if (run.delegation?.role === 'worker')
1886
+ this.answerRoutedQuestion(run.id);
1841
1887
  for (const run of this.store.listRuns()) {
1842
1888
  if (run.ciWait && !['running', 'waiting', 'queued'].includes(run.status))
1843
1889
  this.withdrawCiWait(run.id);
@@ -2719,6 +2765,9 @@ export class RunManager {
2719
2765
  return undefined;
2720
2766
  if (this.hasPendingHumanAsk(runId) || this.active.get(runId)?.pendingHumanAsk)
2721
2767
  return 'Answer the pending human question before finishing.';
2768
+ const questions = this.unansweredQuestions(runId);
2769
+ if (questions.length)
2770
+ return `Answer your workers' questions before finishing: ${questions.map(q => q.workerId).join(', ')}`;
2722
2771
  const blockers = this.parentCompletionBlockers(runId);
2723
2772
  return blockers.length ? `Workers must finish or be stopped, prove termination, and have their latest results collected before finishing: ${blockers.map(b => `${b.workerId} (${b.reason})`).join(', ')}` : undefined;
2724
2773
  }
@@ -2759,6 +2808,8 @@ export class RunManager {
2759
2808
  const state = this.active.get(runId);
2760
2809
  const blockers = this.parentCompletionBlockers(runId);
2761
2810
  const pendingAsk = this.hasPendingHumanAsk(runId) || state?.pendingHumanAsk;
2811
+ // A worker waiting on this parent's answer (#505): waiting on that worker would deadlock.
2812
+ const questions = pendingAsk ? [] : this.unansweredQuestions(runId);
2762
2813
  if (!blockers.length && !pendingAsk) {
2763
2814
  this.resetParentCompletion(runId);
2764
2815
  return false;
@@ -2771,7 +2822,7 @@ export class RunManager {
2771
2822
  }
2772
2823
  if (parent.delegation?.role !== 'root')
2773
2824
  return false;
2774
- if (parent.delegation.wait && !pendingAsk) {
2825
+ if (parent.delegation.wait && !pendingAsk && !questions.length) {
2775
2826
  if (state?.session?.open)
2776
2827
  this.parkWorkerWait(runId, state);
2777
2828
  else
@@ -2779,11 +2830,12 @@ export class RunManager {
2779
2830
  this.store.flush();
2780
2831
  return true;
2781
2832
  }
2782
- const message = pendingAsk ? 'Completion blocked: answer the pending human question.' :
2833
+ const message = pendingAsk ? 'Completion blocked: answer the pending human question.' : questions.length
2834
+ ? `Completion blocked: answer your workers' questions first: ${questions.map(q => `${q.workerId} (request ${q.messageId})`).join(', ')}.` :
2783
2835
  `Completion blocked: finish or explicitly stop outstanding workers, then collect their latest settled results: ${blockers.map(b => `${b.workerId} (${b.reason})`).join(', ')}.`;
2784
2836
  this.store.appendEvent(runId, { type: 'note', tone: 'warning', message });
2785
2837
  const workerIds = blockers.map(b => b.workerId).filter(id => this.store.getRun(id)?.delegation?.role === 'worker');
2786
- if (!pendingAsk && !parent.delegation.completion && workerIds.length) {
2838
+ if (!pendingAsk && !questions.length && !parent.delegation.completion && workerIds.length) {
2787
2839
  const wait = { id: randomUUID(), mode: 'all', workerIds,
2788
2840
  revisions: workerIds.map(workerId => { const d = this.store.getRun(workerId).delegation; return { workerId, revision: d?.role === 'worker' ? d.executionRevision ?? 0 : 0 }; }),
2789
2841
  deadline: new Date(Date.now() + 600_000).toISOString(), phase: 'parked', outcomes: [] };
@@ -2796,7 +2848,7 @@ export class RunManager {
2796
2848
  this.reconcileWorkerWaits();
2797
2849
  }
2798
2850
  else {
2799
- const monitoring = !pendingAsk && !!state?.session?.open && this.hasLiveWorkers(runId);
2851
+ const monitoring = !pendingAsk && !questions.length && !!state?.session?.open && this.hasLiveWorkers(runId);
2800
2852
  this.store.commitDelegation([{ id: runId, delegation: { ...parent.delegation, completion: { ...parent.delegation.completion, phase: 'attention' } } }]);
2801
2853
  this.store.updateRun(runId, { status: monitoring ? 'running' : 'waiting', activity: monitoring ? 'monitoring' : undefined, finishedAt: undefined });
2802
2854
  this.store.flush();
@@ -2991,8 +3043,14 @@ export class RunManager {
2991
3043
  const run = this.store.getRun(runId);
2992
3044
  if (!run?.delegation || run.delegation.role === 'invalid' || this.historyDeletionPending(runId) ||
2993
3045
  this.executionBlockedByRootFinish(run) || !['queued', 'running', 'waiting'].includes(run.status) ||
2994
- this.workerExecutionStopped(runId) || this.hasPendingHumanAsk(runId))
3046
+ this.workerExecutionStopped(runId))
3047
+ return;
3048
+ // A pending question holds every other input; only the parent's reply to a routed one
3049
+ // answers it (#505), and the held inputs follow right behind that answer.
3050
+ if (this.hasPendingHumanAsk(runId)) {
3051
+ this.answerRoutedQuestion(runId);
2995
3052
  return;
3053
+ }
2996
3054
  // Retain conversation input until the registered CI wait receives scheduler admission.
2997
3055
  // Converting this monitor into a worker wake would strand both admission paths.
2998
3056
  if (run.ciWait) {
@@ -3024,6 +3082,231 @@ export class RunManager {
3024
3082
  else if (!this.recovering)
3025
3083
  this.flushAgentInputs(runId);
3026
3084
  }
3085
+ /** A worker's pending question goes to its owning parent as a conversation request
3086
+ * (#505). The worker still parks on its ask; only the parent's reply, or a human after
3087
+ * fallback, answers it. Routing is idempotent: the message ID derives from the ask seq. */
3088
+ routeWorkerQuestion(workerId) {
3089
+ const worker = this.store.getRun(workerId);
3090
+ if (worker?.delegation?.role !== 'worker')
3091
+ return;
3092
+ const events = this.store.readEvents(workerId);
3093
+ const ask = pendingHumanAsk(events);
3094
+ if (!ask)
3095
+ return;
3096
+ if (events.some(event => (event.type === 'worker-question-routed' || event.type === 'worker-question-fallback') && event.askSeq === ask.seq))
3097
+ return;
3098
+ const fallback = (reason) => { this.store.appendEvent(workerId, { type: 'worker-question-fallback', askSeq: ask.seq, reason }); };
3099
+ const request = askRequestSchema.safeParse({ questions: ask.questions });
3100
+ if (!request.success) {
3101
+ fallback('the question cannot be carried to the parent');
3102
+ return;
3103
+ }
3104
+ const parentId = worker.delegation.parentRunId;
3105
+ const root = this.store.getRun(parentId);
3106
+ const now = new Date().toISOString();
3107
+ const message = questionMessage({ workerRunId: workerId, parentRunId: parentId, askSeq: ask.seq, request: request.data, now });
3108
+ // Already committed (a crash before the routing event): the question did reach the parent,
3109
+ // whatever its capacity or state is now. Record the routing; fallback sweeps follow.
3110
+ const committed = root?.delegation?.role === 'root' && !!root.delegation.conversation?.messages.some(m => m.id === message.id);
3111
+ if (committed) {
3112
+ this.store.appendEvent(workerId, { type: 'worker-question-routed', askSeq: ask.seq, messageId: message.id, parentRunId: parentId });
3113
+ return;
3114
+ }
3115
+ // Recovery can find a question that never left a worker which has since stopped.
3116
+ if (!['queued', 'running', 'waiting'].includes(worker.status)) {
3117
+ fallback('worker-stopped');
3118
+ return;
3119
+ }
3120
+ if (root?.delegation?.role !== 'root' || !this.parentCanReceive(root)) {
3121
+ fallback('the parent can no longer receive messages');
3122
+ return;
3123
+ }
3124
+ const state = root.delegation.conversation ?? { messages: [], outcomes: [] };
3125
+ // Room in the ledger for the question AND its reply, beside the replies other open questions
3126
+ // reserve. The reply itself is always admitted to this worker's inbox (service.send).
3127
+ if (state.messages.length + openQuestions(state).length + 2 > 1024 || (root.agentInputs ?? []).filter(input => !input.deliveredAt).length >= 32 ||
3128
+ state.messages.filter(m => m.kind === 'request' && m.state === 'accepted' && !state.outcomes.some(o => o.requestId === m.id)).length >= 32) {
3129
+ fallback('the parent conversation is at capacity');
3130
+ return;
3131
+ }
3132
+ const input = { id: message.id, source: 'agent', parentRunId: parentId, text: message.text, createdAt: now,
3133
+ conversation: { senderRunId: workerId, recipientRunId: parentId, kind: 'request' } };
3134
+ this.store.commitConversation(parentId, { ...state, messages: [...state.messages, message] }, { recipientRunId: parentId, input });
3135
+ projectConversationEvents(this.store, this.store.getRun(parentId));
3136
+ this.store.appendEvent(workerId, { type: 'worker-question-routed', askSeq: ask.seq, messageId: message.id, parentRunId: parentId });
3137
+ this.deliverConversationInput(parentId);
3138
+ }
3139
+ /** A human answering a worker question its parent handed back (#505): the reviewing parent
3140
+ * can no longer answer it, so it must not also block the human. */
3141
+ answersFallenBackAsk(runId, opts, deferred) {
3142
+ if (deferred || !(opts.text?.trim() || opts.images?.length))
3143
+ return false;
3144
+ const events = this.store.readEvents(runId);
3145
+ const ask = pendingHumanAsk(events);
3146
+ return !!ask && events.some(event => event.type === 'worker-question-fallback' && event.askSeq === ask.seq);
3147
+ }
3148
+ /** The worker's pending ask when it went to the parent and has not fallen back to a human. */
3149
+ routedAsk(runId) {
3150
+ const run = this.store.getRun(runId);
3151
+ if (run?.delegation?.role !== 'worker')
3152
+ return undefined;
3153
+ const events = this.store.readEvents(runId);
3154
+ const ask = pendingHumanAsk(events);
3155
+ if (!ask || events.some(event => event.type === 'worker-question-fallback' && event.askSeq === ask.seq))
3156
+ return undefined;
3157
+ const routed = events.find(event => event.type === 'worker-question-routed' && event.askSeq === ask.seq);
3158
+ const root = this.store.getRun(run.delegation.parentRunId);
3159
+ const message = routed && root?.delegation?.role === 'root'
3160
+ ? root.delegation.conversation?.messages.find(entry => entry.id === routed.messageId) : undefined;
3161
+ return message ? { askSeq: ask.seq, message } : undefined;
3162
+ }
3163
+ /** The parent's reply answers the routed question like a human answer would: through the
3164
+ * live session's native answer seam, or by reopening a session that is gone (#505). */
3165
+ answerRoutedQuestion(runId, admitted = false) {
3166
+ const routed = this.routedAsk(runId);
3167
+ const run = this.store.getRun(runId);
3168
+ const reply = routed && run?.agentInputs?.find(input => !input.deliveredAt && answersQuestion(routed.message, input));
3169
+ // A stopped worker is the human's to continue; a parent answer never revives it.
3170
+ if (!routed || !reply || run?.delegation?.role !== 'worker' || !['queued', 'running', 'waiting'].includes(run.status))
3171
+ return;
3172
+ // Verbatim: a native answer seam parses `Header: label` lines, exactly as a human's.
3173
+ const text = reply.text;
3174
+ const state = this.active.get(runId);
3175
+ if (!state) {
3176
+ // An automated answer is not a human override: the reopened session waits for capacity.
3177
+ if (this.isActive(runId))
3178
+ return;
3179
+ // As on the live path, the answer supersedes a wait registered before the question.
3180
+ if (this.workerWait(runId)) {
3181
+ try {
3182
+ this.withdrawWorkerWait(runId);
3183
+ }
3184
+ catch {
3185
+ return;
3186
+ }
3187
+ }
3188
+ const result = this.continueRun(runId, { text, answerInputId: reply.id }, true);
3189
+ if (!result.ok)
3190
+ this.store.appendEvent(runId, { type: 'note', tone: 'warning', message: `parent answer not delivered: ${result.error}` });
3191
+ return;
3192
+ }
3193
+ if (!state.session?.open || state.cancelled || state.openingAgentInputId === reply.id)
3194
+ return;
3195
+ // Already waiting for admission: only the admission itself may deliver it.
3196
+ if (!admitted && (this.workerWaiting.has(runId) || this.workerWakeQueuedAt.has(runId)))
3197
+ return;
3198
+ if (!admitted && (this.waiting.has(runId) || this.monitoring.has(runId))) {
3199
+ // The parked worker released its slot: the answer rides the ordinary message wake,
3200
+ // which delivers it here once the scheduler admits the worker.
3201
+ const existing = this.workerWait(runId);
3202
+ if (existing?.wakeId !== reply.id) {
3203
+ // An answer supersedes a wait the worker registered before asking, exactly as a
3204
+ // human answer withdraws it; a failed checkpoint leaves both for a later retry.
3205
+ if (existing) {
3206
+ try {
3207
+ this.withdrawWorkerWait(runId);
3208
+ }
3209
+ catch {
3210
+ return;
3211
+ }
3212
+ }
3213
+ const wait = { id: randomUUID(), workerIds: [], outcomes: [],
3214
+ deadline: new Date().toISOString(), phase: 'wake-pending', reason: 'message', wakeId: reply.id };
3215
+ const current = this.store.getRun(runId)?.delegation;
3216
+ if (current?.role !== 'worker')
3217
+ return;
3218
+ this.store.commitDelegation([{ id: runId, delegation: { ...current, wait } }]);
3219
+ }
3220
+ this.clearIdleTimer(state);
3221
+ this.clearMonitoringWakeTimer(state, runId);
3222
+ this.waiting.delete(runId);
3223
+ this.monitoring.delete(runId);
3224
+ this.workerWaiting.add(runId);
3225
+ this.queueWorkerWake(runId);
3226
+ return;
3227
+ }
3228
+ const bundle = state.atTurnBoundary === state.session ? this.pendingConversationBatch(runId, state, reply.id) : undefined;
3229
+ if (!state.session.sendMessage([{ type: 'text', text }, ...(bundle ? [{ type: 'text', text: bundle.text }] : [])]))
3230
+ return;
3231
+ this.store.appendEvent(runId, { type: 'human-input-delivered', askSeq: routed.askSeq, source: 'parent' });
3232
+ const delivered = [reply.id, ...(bundle?.inputs.map(input => input.id) ?? [])];
3233
+ this.commitBundledDelivery(runId, state, delivered);
3234
+ // As in submitAgentInput: the wake that admitted this answer is spent, and a stale one
3235
+ // would hold back every later message until the answer's turn ends.
3236
+ if (delivered.includes(this.workerWait(runId)?.wakeId ?? '')) {
3237
+ state.workerWakeTurn = state.session;
3238
+ try {
3239
+ this.withdrawWorkerWait(runId);
3240
+ }
3241
+ catch { /* reconcileWorkerWaits retires it at the turn end */ }
3242
+ }
3243
+ state.pendingHumanAsk = this.hasPendingHumanAsk(runId);
3244
+ if (!state.pendingHumanAsk)
3245
+ this.flushAgentInputs(runId);
3246
+ this.resumeParkedRun(runId, state);
3247
+ }
3248
+ /** A routed question settled without a reply (its worker stopped, a deadline, fallback) can
3249
+ * never be answered by the parent any more: hand it to the human on the worker (#505). */
3250
+ retireSettledQuestions(parentId) {
3251
+ const root = this.store.getRun(parentId);
3252
+ const conversation = root?.delegation?.role === 'root' ? root.delegation.conversation : undefined;
3253
+ if (!conversation)
3254
+ return;
3255
+ for (const message of conversation.messages) {
3256
+ const outcome = message.question && conversation.outcomes.find(entry => entry.requestId === message.id);
3257
+ // A reply still waiting for a live worker answers it; one a stopped worker never took does not.
3258
+ const worker = this.store.getRun(message.senderRunId);
3259
+ const live = !!worker && ['queued', 'running', 'waiting'].includes(worker.status) && !worker.stopping;
3260
+ if (!outcome || (outcome.status === 'replied' && live))
3261
+ continue;
3262
+ const routed = this.routedAsk(message.senderRunId);
3263
+ if (routed?.message.id === message.id) {
3264
+ this.store.appendEvent(message.senderRunId, { type: 'worker-question-fallback', askSeq: routed.askSeq, reason: outcome.status === 'human-fallback' ? `parent-${root.status}` : outcome.status === 'replied' ? 'worker-stopped' : `question-${outcome.status}` });
3265
+ }
3266
+ }
3267
+ }
3268
+ /** Routed questions this parent has not answered yet, by asking worker. */
3269
+ unansweredQuestions(parentId) {
3270
+ return this.store.listRuns().flatMap(run => {
3271
+ if (run.delegation?.role !== 'worker' || run.delegation.parentRunId !== parentId)
3272
+ return [];
3273
+ const routed = this.routedAsk(run.id);
3274
+ const root = this.store.getRun(parentId);
3275
+ const answered = root?.delegation?.role === 'root' && root.delegation.conversation?.outcomes.some(outcome => outcome.requestId === routed?.message.id);
3276
+ return routed && !answered ? [{ workerId: run.id, messageId: routed.message.id }] : [];
3277
+ });
3278
+ }
3279
+ /** The parent can no longer answer: its workers' routed questions go back to the human
3280
+ * (#505). The outcome lands first, so a crash before the worker's event repeats this. */
3281
+ fallbackRoutedQuestions(parentId, reason) {
3282
+ const parent = this.store.getRun(parentId);
3283
+ if (parent?.delegation?.role !== 'root')
3284
+ return;
3285
+ const state = parent.delegation.conversation ?? { messages: [], outcomes: [] };
3286
+ const routed = this.store.listRuns().flatMap(run => {
3287
+ const ask = run.delegation?.role === 'worker' && run.delegation.parentRunId === parentId ? this.routedAsk(run.id) : undefined;
3288
+ // An accepted reply still on its way to a live worker answers it; no human card beside it.
3289
+ const replied = ask && state.outcomes.some(outcome => outcome.requestId === ask.message.id && outcome.status === 'replied');
3290
+ return ask && !(replied && ['queued', 'running', 'waiting'].includes(run.status) && !run.stopping) ? [{ workerId: run.id, ...ask }] : [];
3291
+ });
3292
+ if (!routed.length)
3293
+ return;
3294
+ const observedAt = new Date().toISOString();
3295
+ const missing = routed.filter(entry => !state.outcomes.some(outcome => outcome.requestId === entry.message.id));
3296
+ if (missing.length) {
3297
+ this.store.commitConversation(parentId, { ...state, outcomes: [...state.outcomes,
3298
+ ...missing.map(entry => ({ requestId: entry.message.id, status: 'human-fallback', observedAt }))] });
3299
+ projectConversationEvents(this.store, this.store.getRun(parentId));
3300
+ }
3301
+ for (const entry of routed)
3302
+ this.store.appendEvent(entry.workerId, { type: 'worker-question-fallback', askSeq: entry.askSeq, reason });
3303
+ }
3304
+ /** The parent can still read and answer a routed question; answering needs its steer grant. */
3305
+ parentCanReceive(parent) {
3306
+ return parent.delegation?.role === 'root' && parent.delegation.permissions.includes('steer') &&
3307
+ ['queued', 'running', 'waiting'].includes(parent.status) && !parent.stopping &&
3308
+ !this.rootFinishRequested(parent.id) && !this.historyDeletionPending(parent.id);
3309
+ }
3027
3310
  /** Cancel only the wait; persist its settlement before ordinary wake admission. */
3028
3311
  cancelWorkerWait(parentId, waitId) {
3029
3312
  const run = this.store.getRun(parentId);
@@ -3093,6 +3376,7 @@ export class RunManager {
3093
3376
  if (next && next !== root.delegation.conversation)
3094
3377
  this.store.commitConversation(root.id, next);
3095
3378
  projectConversationEvents(this.store, this.store.getRun(root.id));
3379
+ this.retireSettledQuestions(root.id);
3096
3380
  const key = `conversation:${root.id}`;
3097
3381
  const timer = this.workerWaitTimers.get(key);
3098
3382
  if (timer) {
@@ -3138,6 +3422,7 @@ export class RunManager {
3138
3422
  if (!['queued', 'running', 'waiting'].includes(parent.status)) {
3139
3423
  if (!this.recovering) {
3140
3424
  this.withdrawWorkerWait(parent.id);
3425
+ this.fallbackRoutedQuestions(parent.id, `parent-${parent.status}`);
3141
3426
  for (const child of this.store.listRuns().filter(child => parent.delegation?.role === 'root' && parent.status !== 'review' && child.delegation?.role === 'worker' && child.delegation.parentRunId === parent.id)) {
3142
3427
  if (['queued', 'running', 'waiting'].includes(child.status))
3143
3428
  this.cancel(child.id);
@@ -3318,7 +3603,9 @@ export class RunManager {
3318
3603
  if (this.recovering)
3319
3604
  return;
3320
3605
  const state = this.active.get(parentId);
3321
- if (this.hasPendingHumanAsk(parentId) || state?.pendingHumanAsk || this.workerWakeQueuedAt.has(parentId))
3606
+ // A parent's reply to a routed question (#505) is the one message a pending ask admits.
3607
+ const parentAnswer = wait.reason === 'message' && this.routedAsk(parentId) !== undefined;
3608
+ if ((!parentAnswer && (this.hasPendingHumanAsk(parentId) || state?.pendingHumanAsk)) || this.workerWakeQueuedAt.has(parentId))
3322
3609
  return;
3323
3610
  if (state) {
3324
3611
  // Expiring a registered intent NEVER exempts a still-executing turn.
@@ -3408,7 +3695,227 @@ export class RunManager {
3408
3695
  const run = this.store.getRun(runId);
3409
3696
  return !!run && ['queued', 'running', 'waiting'].includes(run.status) &&
3410
3697
  !(run.delegation?.role === 'worker' && run.delegation.destroy) &&
3411
- !!run.agentInputs?.some(input => !input.deliveredAt);
3698
+ (!!run.agentInputs?.some(input => !input.deliveredAt) || this.harnessOwesInput(this.active.get(runId)));
3699
+ }
3700
+ /** Only the current session's observation writes consumedAt (#505). An ID reported
3701
+ * before its acceptance checkpoint lands is applied when that checkpoint commits. */
3702
+ handleAgentInputConsumed(runId, state, session, ids) {
3703
+ if (!session || this.active.get(runId) !== state || state.session !== session)
3704
+ return;
3705
+ const inFlight = state.agentInputFlight?.inputIds ?? [];
3706
+ for (const id of ids)
3707
+ if (inFlight.includes(id))
3708
+ state.consumedBeforeAck?.add(id);
3709
+ const read = ids.filter(id => !inFlight.includes(id));
3710
+ for (const id of read)
3711
+ state.unreadInputIds?.delete(id);
3712
+ if (!this.harnessOwesInput(state))
3713
+ this.clearUnreadInputTimer(state);
3714
+ try {
3715
+ if (!read.length)
3716
+ return;
3717
+ this.store.commitAgentInputsConsumed(runId, read, new Date().toISOString());
3718
+ this.reconcileWorkerWaits(); // projects the consumed receipt
3719
+ }
3720
+ catch (error) {
3721
+ console.warn(`[cez] agent input consumption checkpoint failed: ${error instanceof Error ? error.message : String(error)}`);
3722
+ }
3723
+ }
3724
+ /** #505: input the harness accepted but will never read returns to the queue, so
3725
+ * DONE, auto-end and park see queued work. Out of a turn (an OpenCode lost wake)
3726
+ * it is submitted right away; at a turn-end the handler's own flush submits it. */
3727
+ retireUnreadInputs(runId, state, ids, outOfTurn) {
3728
+ if (!ids.length || state.cancelled || this.active.get(runId) !== state)
3729
+ return;
3730
+ // Durable first: a failed checkpoint must never let this boundary finish (DONE, park)
3731
+ // over input that is still marked delivered but was never read.
3732
+ try {
3733
+ this.store.requeueUnconsumedAgentInputs(runId, ids);
3734
+ }
3735
+ catch (error) {
3736
+ if (state.agentInputError)
3737
+ return;
3738
+ state.agentInputError = `agent input requeue checkpoint failed: ${error instanceof Error ? error.message : String(error)}`;
3739
+ try {
3740
+ state.session?.interrupt();
3741
+ }
3742
+ finally {
3743
+ try {
3744
+ this.store.appendEvent(runId, { type: 'error', message: state.agentInputError });
3745
+ }
3746
+ catch {
3747
+ console.error(`[cez] ${state.agentInputError}`);
3748
+ }
3749
+ }
3750
+ return;
3751
+ }
3752
+ for (const id of ids)
3753
+ state.unreadInputIds?.delete(id);
3754
+ // Reported before its own acknowledgement landed: that acknowledgement must not
3755
+ // mark it delivered again.
3756
+ for (const id of ids)
3757
+ if (state.agentInputFlight?.inputIds.includes(id))
3758
+ (state.retiredInFlight ??= new Set()).add(id);
3759
+ this.store.appendEvent(runId, { type: 'note', message: `resubmitting ${ids.length} message${ids.length === 1 ? '' : 's'} the agent did not read` });
3760
+ if (outOfTurn)
3761
+ this.flushAgentInputs(runId);
3762
+ }
3763
+ /** #505: the harness started the opening turn, so the opening input was accepted.
3764
+ * Its replay-prompt retirement still waits for that turn's successful end. */
3765
+ recordOpeningAccepted(runId, state) {
3766
+ const id = state.openingAgentInputId;
3767
+ if (!id || state.cancelled || this.active.get(runId) !== state)
3768
+ return;
3769
+ const inputs = this.store.getRun(runId)?.agentInputs ?? [];
3770
+ if (!inputs.some(input => input.id === id && !input.deliveredAt))
3771
+ return;
3772
+ const deliveredAt = new Date().toISOString();
3773
+ try {
3774
+ this.store.commitAgentInputs(runId, inputs.map(input => input.id === id ? { ...input, deliveredAt } : input));
3775
+ }
3776
+ catch (error) {
3777
+ console.warn(`[cez] opening input acceptance checkpoint failed: ${error instanceof Error ? error.message : String(error)}`);
3778
+ }
3779
+ }
3780
+ /** The conversation messages the next submission would carry, if nothing but
3781
+ * conversation input leads the queue (lifecycle input stays a barrier). */
3782
+ pendingConversationBatch(runId, state, answerId) {
3783
+ const run = this.store.getRun(runId);
3784
+ if (!run || run.ciWait || state.agentInputFlight || this.workerWait(runId))
3785
+ return undefined;
3786
+ const batch = agentInputBatch((run.agentInputs ?? []).filter(input => input.id !== state.openingAgentInputId && input.id !== answerId), input => this.formatAgentInput(runId, input));
3787
+ return batch?.inputs.every(input => input.conversation) ? batch : undefined;
3788
+ }
3789
+ /** Bundled messages rode the answer's own write: delivered now, read when that turn ends. */
3790
+ commitBundledDelivery(runId, state, ids) {
3791
+ const deliveredAt = new Date().toISOString();
3792
+ const awaiting = state.inputDelivery?.consumption === 'observable' ? { awaitingRead: true } : {};
3793
+ try {
3794
+ this.store.commitAgentInputs(runId, (this.store.getRun(runId)?.agentInputs ?? []).map(input => ids.includes(input.id) && !input.deliveredAt ? { ...input, deliveredAt, ...awaiting } : input));
3795
+ state.bundledInputIds = [...(state.bundledInputIds ?? []), ...ids];
3796
+ }
3797
+ catch (error) {
3798
+ console.warn(`[cez] bundled delivery checkpoint failed: ${error instanceof Error ? error.message : String(error)}`);
3799
+ }
3800
+ }
3801
+ /** The turn that carried bundled messages has ended: the model read them. */
3802
+ readBundledInputs(runId, state) {
3803
+ const ids = state.bundledInputIds ?? [];
3804
+ state.bundledInputIds = undefined;
3805
+ if (!ids.length || state.cancelled)
3806
+ return;
3807
+ try {
3808
+ this.store.commitAgentInputsConsumed(runId, ids, new Date().toISOString());
3809
+ this.reconcileWorkerWaits();
3810
+ }
3811
+ catch (error) {
3812
+ console.warn(`[cez] bundled read checkpoint failed: ${error instanceof Error ? error.message : String(error)}`);
3813
+ }
3814
+ }
3815
+ /** #505: the session closed without reporting it read (failure, stop, exit): the input
3816
+ * goes back to the queue so a later session delivers it; never shown as delivered. */
3817
+ requeueUnreadAtClose(runId, state, session) {
3818
+ this.clearUnreadInputTimer(state);
3819
+ // Everything this session accepted but never saw read: steered input, messages bundled
3820
+ // behind a human answer, and an opening instruction whose turn never succeeded.
3821
+ const opening = state.openingAgentInputId && this.store.getRun(runId)?.agentInputs?.some(input => input.id === state.openingAgentInputId && input.deliveredAt && !input.consumedAt) ? [state.openingAgentInputId] : [];
3822
+ const ids = [...new Set([...(state.unreadInputIds ?? []), ...(state.bundledInputIds ?? []), ...opening])];
3823
+ if (!session || !ids.length || this.active.get(runId) !== state || state.session !== session)
3824
+ return;
3825
+ state.unreadInputIds?.clear();
3826
+ state.bundledInputIds = undefined;
3827
+ try {
3828
+ this.store.requeueUnconsumedAgentInputs(runId, ids);
3829
+ }
3830
+ catch (error) {
3831
+ console.warn(`[cez] agent input requeue failed: ${error instanceof Error ? error.message : String(error)}`);
3832
+ }
3833
+ }
3834
+ /** An idle, open boundary with nothing left to deliver: DONE closes, otherwise the
3835
+ * run parks as waiting or monitoring. Shared by acknowledgements that land after a
3836
+ * turn ended and by the unread-input grace timer (#505). */
3837
+ settleIdleBoundary(runId, state, session) {
3838
+ if (state.atTurnBoundary !== session || state.pendingHumanAsk || this.workerWait(runId) || this.store.getRun(runId)?.ciWait || this.hasQueuedAgentInputs(runId))
3839
+ return;
3840
+ if (state.doneAtBoundary === session) {
3841
+ if (this.deferParentCompletion(runId))
3842
+ return;
3843
+ this.store.appendEvent(runId, { type: 'lifecycle', message: 'goal achieved — session closed' });
3844
+ appendHandoffHeartbeat(this.dataDir, runId, 'turn complete — goal achieved, session closed');
3845
+ session.end();
3846
+ }
3847
+ else if (state.parkAfterAck?.session === session && !this.waiting.has(runId) && !this.monitoring.has(runId)) {
3848
+ // A wake turn can finish before its HTTP/RPC acknowledgement. Retiring
3849
+ // that wait must release its completed turn, not invent another turn.
3850
+ if ((state.parkAfterAck.monitoring || this.hasLiveWorkers(runId)) && !this.parentCompletionAttention(runId)) {
3851
+ this.store.updateRun(runId, { status: 'running', activity: 'monitoring' });
3852
+ if (state.currentStepId)
3853
+ this.store.updateStep(runId, state.currentStepId, { status: 'running' });
3854
+ this.monitoring.add(runId);
3855
+ this.clearIdleTimer(state);
3856
+ this.armMonitoringWakeTimer(runId, state);
3857
+ }
3858
+ else {
3859
+ this.store.updateRun(runId, { status: 'waiting', activity: undefined });
3860
+ if (state.currentStepId)
3861
+ this.store.updateStep(runId, state.currentStepId, { status: 'waiting' });
3862
+ this.store.flush(); // Match ordinary turn-end durability before releasing capacity.
3863
+ this.armIdleTimer(runId, state);
3864
+ }
3865
+ this.waiting.add(runId);
3866
+ this.releaseSlot();
3867
+ }
3868
+ }
3869
+ /** #505 liveness bound: a harness that accepted input and then neither read it nor ran
3870
+ * another turn must not hold the run and its slot forever. After a quiet grace window the
3871
+ * input is resubmitted once; after a second it is left delivered but unconfirmed. */
3872
+ armUnreadInputTimer(runId, state) {
3873
+ this.clearUnreadInputTimer(state);
3874
+ const session = state.session;
3875
+ if (!session?.open || !this.harnessOwesInput(state) || state.agentInputFlight)
3876
+ return;
3877
+ const timer = setTimeout(() => {
3878
+ state.unreadInputTimer = undefined;
3879
+ if (this.disposed || this.active.get(runId) !== state || state.session !== session || !session.open || state.cancelled ||
3880
+ state.agentInputFlight || !this.harnessOwesInput(state))
3881
+ return;
3882
+ const ids = [...state.unreadInputIds];
3883
+ const retried = ids.filter(id => state.unreadRetried?.has(id));
3884
+ const fresh = ids.filter(id => !state.unreadRetried?.has(id));
3885
+ if (retried.length) {
3886
+ for (const id of retried)
3887
+ state.unreadInputIds?.delete(id);
3888
+ try {
3889
+ this.store.commitAgentInputsUnconfirmed(runId, retried);
3890
+ }
3891
+ catch { /* receipt stays awaiting; a restart replays it */ }
3892
+ this.store.appendEvent(runId, { type: 'note', message: `delivered ${retried.length} message${retried.length === 1 ? '' : 's'}; the agent did not confirm reading ${retried.length === 1 ? 'it' : 'them'}` });
3893
+ }
3894
+ if (fresh.length) {
3895
+ state.unreadRetried ??= new Set();
3896
+ for (const id of fresh)
3897
+ state.unreadRetried.add(id);
3898
+ this.retireUnreadInputs(runId, state, fresh, true);
3899
+ }
3900
+ // No content since the last turn ended: the harness is idle, whatever turn it announced.
3901
+ if (!this.harnessOwesInput(state) && !state.agentInputFlight) {
3902
+ state.atTurnBoundary = session;
3903
+ if (state.parkAfterAck?.session !== session)
3904
+ state.parkAfterAck = { session, monitoring: false };
3905
+ this.settleIdleBoundary(runId, state, session);
3906
+ }
3907
+ }, this.unreadInputGraceMs);
3908
+ timer.unref?.();
3909
+ state.unreadInputTimer = timer;
3910
+ }
3911
+ clearUnreadInputTimer(state) {
3912
+ if (state.unreadInputTimer)
3913
+ clearTimeout(state.unreadInputTimer);
3914
+ state.unreadInputTimer = undefined;
3915
+ }
3916
+ /** #505: the harness accepted input this session has not reported reading yet. */
3917
+ harnessOwesInput(state) {
3918
+ return !!state?.unreadInputIds?.size;
3412
3919
  }
3413
3920
  /** A callback from an old/replaced/disposed session carries no authority. */
3414
3921
  handleAgentInputReady(runId, state, session) {
@@ -3444,7 +3951,7 @@ export class RunManager {
3444
3951
  };
3445
3952
  let acknowledgement;
3446
3953
  try {
3447
- acknowledgement = session.sendAgentMessage(content);
3954
+ acknowledgement = session.sendAgentMessage(content, inputIds);
3448
3955
  }
3449
3956
  catch (error) {
3450
3957
  fail(error, false);
@@ -3477,8 +3984,25 @@ export class RunManager {
3477
3984
  }
3478
3985
  else {
3479
3986
  const deliveredAt = new Date().toISOString();
3480
- this.store.commitAgentInputs(runId, queue.map(input => inputIds.includes(input.id) && !input.deliveredAt
3481
- ? { ...input, deliveredAt } : input));
3987
+ // An observable harness still owes a read: mark it so a restart replays it (#505).
3988
+ const awaiting = state.inputDelivery?.consumption === 'observable' ? { awaitingRead: true } : {};
3989
+ // Input the harness reported unread while this very POST was pending stays queued:
3990
+ // the flush after this acknowledgement resubmits it (#505 review).
3991
+ const accepted = inputIds.filter(id => !state.retiredInFlight?.has(id));
3992
+ for (const id of inputIds)
3993
+ state.retiredInFlight?.delete(id);
3994
+ this.store.commitAgentInputs(runId, queue.map(input => accepted.includes(input.id) && !input.deliveredAt
3995
+ ? { ...input, deliveredAt, ...awaiting } : input));
3996
+ if (state.inputDelivery?.consumption === 'observable') {
3997
+ const early = accepted.filter(id => state.consumedBeforeAck?.has(id));
3998
+ for (const id of accepted)
3999
+ if (!early.includes(id))
4000
+ state.unreadInputIds?.add(id);
4001
+ for (const id of early)
4002
+ state.consumedBeforeAck?.delete(id);
4003
+ if (early.length)
4004
+ this.store.commitAgentInputsConsumed(runId, early, deliveredAt);
4005
+ }
3482
4006
  }
3483
4007
  if (inputIds.includes(this.workerWait(runId)?.wakeId ?? ''))
3484
4008
  this.withdrawWorkerWait(runId);
@@ -3495,38 +4019,13 @@ export class RunManager {
3495
4019
  state.agentInputFlight = undefined;
3496
4020
  if (!authorized || !session.open || state.agentSessionError || state.agentInputError)
3497
4021
  return;
4022
+ // A liveness resubmission gets its own quiet window, and so does input acknowledged
4023
+ // after its turn already ended: no later turn-end would arm one (#505 review).
4024
+ if (inputIds.some(id => state.unreadRetried?.has(id)) || state.atTurnBoundary === session)
4025
+ this.armUnreadInputTimer(runId, state);
3498
4026
  if (this.flushAgentInputs(runId))
3499
4027
  return;
3500
- if (state.atTurnBoundary !== session || state.pendingHumanAsk || this.workerWait(runId) || this.store.getRun(runId)?.ciWait || this.hasQueuedAgentInputs(runId))
3501
- return;
3502
- if (state.doneAtBoundary === session) {
3503
- if (this.deferParentCompletion(runId))
3504
- return;
3505
- this.store.appendEvent(runId, { type: 'lifecycle', message: 'goal achieved — session closed' });
3506
- appendHandoffHeartbeat(this.dataDir, runId, 'turn complete — goal achieved, session closed');
3507
- session.end();
3508
- }
3509
- else if (state.parkAfterAck?.session === session && !this.waiting.has(runId) && !this.monitoring.has(runId)) {
3510
- // A wake turn can finish before its HTTP/RPC acknowledgement. Retiring
3511
- // that wait must release its completed turn, not invent another turn.
3512
- if ((state.parkAfterAck.monitoring || this.hasLiveWorkers(runId)) && !this.parentCompletionAttention(runId)) {
3513
- this.store.updateRun(runId, { status: 'running', activity: 'monitoring' });
3514
- if (state.currentStepId)
3515
- this.store.updateStep(runId, state.currentStepId, { status: 'running' });
3516
- this.monitoring.add(runId);
3517
- this.clearIdleTimer(state);
3518
- this.armMonitoringWakeTimer(runId, state);
3519
- }
3520
- else {
3521
- this.store.updateRun(runId, { status: 'waiting', activity: undefined });
3522
- if (state.currentStepId)
3523
- this.store.updateStep(runId, state.currentStepId, { status: 'waiting' });
3524
- this.store.flush(); // Match ordinary turn-end durability before releasing capacity.
3525
- this.armIdleTimer(runId, state);
3526
- }
3527
- this.waiting.add(runId);
3528
- this.releaseSlot();
3529
- }
4028
+ this.settleIdleBoundary(runId, state, session);
3530
4029
  }).catch(error => {
3531
4030
  // Bookkeeping failures cannot become unhandled promise rejections.
3532
4031
  if (state.agentInputFlight === undefined)
@@ -3561,10 +4060,9 @@ export class RunManager {
3561
4060
  return false;
3562
4061
  if (state.pendingHumanAsk || this.hasUnansweredHumanAsk(runId))
3563
4062
  return false;
3564
- // The opening prompt already carries this input. It must not be resubmitted
3565
- // by a readiness callback before the first successful turn checkpoints it.
3566
- if (state.openingAgentInputId)
3567
- return false;
4063
+ // The opening prompt already carries its own input; later input steers the
4064
+ // opening turn instead of waiting for it to end (#505).
4065
+ const queue = (run.agentInputs ?? []).filter(input => input.id !== state.openingAgentInputId);
3568
4066
  if (run.ciWait) {
3569
4067
  if (!this.ciWakeAdmitted.has(runId) || state.atTurnBoundary !== state.session)
3570
4068
  return false;
@@ -3572,7 +4070,7 @@ export class RunManager {
3572
4070
  const input = run.agentInputs?.find(entry => entry.id === run.ciWait?.wakeId && !entry.deliveredAt);
3573
4071
  return !!input && this.submitAgentInput(runId, state, [{ type: 'text', text: this.formatAgentInput(runId, input) }], [input.id]);
3574
4072
  }
3575
- const batch = agentInputBatch(run.agentInputs ?? [], input => this.formatAgentInput(runId, input));
4073
+ const batch = agentInputBatch(queue, input => this.formatAgentInput(runId, input));
3576
4074
  return !!batch && this.submitAgentInput(runId, state, [{ type: 'text', text: batch.text }], batch.inputs.map(input => input.id));
3577
4075
  }
3578
4076
  formatAgentInput(runId, input) {
@@ -3669,6 +4167,10 @@ export class RunManager {
3669
4167
  return false;
3670
4168
  if (!userAuthored && run.ciWait)
3671
4169
  return false;
4170
+ if (userAuthored && this.routedAsk(runId)) {
4171
+ this.store.appendEvent(runId, { type: 'note', tone: 'warning', message: ROUTED_QUESTION_REFUSAL });
4172
+ return false;
4173
+ }
3672
4174
  const text = content
3673
4175
  .filter((b) => b.type === 'text')
3674
4176
  .map((b) => b.text)
@@ -3700,7 +4202,11 @@ export class RunManager {
3700
4202
  ? [...expanded, pastedAttachmentsNote(persisted)]
3701
4203
  : expanded.length ? expanded : [{ type: 'text', text: 'The user attached a document, but it could not be saved. Ask them to attach it again before discussing its contents.' }];
3702
4204
  const answeringAskSeq = userAuthored ? this.pendingHumanAskSeq(runId) : undefined;
3703
- const delivered = userAuthored ? state.session.sendMessage(deliverable)
4205
+ // #505: an answer that opens a new turn (a CEZ:ASK answer at a boundary) carries the
4206
+ // held conversation messages in the same submission, after the answer.
4207
+ const bundle = userAuthored && answeringAskSeq !== undefined && state.atTurnBoundary === state.session
4208
+ ? this.pendingConversationBatch(runId, state) : undefined;
4209
+ const delivered = userAuthored ? state.session.sendMessage(bundle ? [...deliverable, { type: 'text', text: bundle.text }] : deliverable)
3704
4210
  : this.submitAgentInput(runId, state, deliverable);
3705
4211
  if (delivered) {
3706
4212
  if (userAuthored) {
@@ -3710,6 +4216,12 @@ export class RunManager {
3710
4216
  this.store.appendEvent(runId, { type: 'human-input-delivered', askSeq: answeringAskSeq });
3711
4217
  }
3712
4218
  state.pendingHumanAsk = this.hasPendingHumanAsk(runId);
4219
+ if (bundle)
4220
+ this.commitBundledDelivery(runId, state, bundle.inputs.map(input => input.id));
4221
+ // A native answer continues the turn, and a bundle can leave messages that did not
4222
+ // fit its batch: either way the rest steer right behind the answer.
4223
+ if (answeringAskSeq !== undefined && !state.pendingHumanAsk)
4224
+ this.flushAgentInputs(runId);
3713
4225
  }
3714
4226
  this.resumeParkedRun(runId, state);
3715
4227
  }
@@ -3831,7 +4343,7 @@ export class RunManager {
3831
4343
  return { ok: false, error: 'run is still stopping' };
3832
4344
  if (this.historyDeletionPending(runId))
3833
4345
  return { ok: false, error: 'Parent history deletion is pending; retry deletion' };
3834
- if (run.delegation?.role === 'worker' && this.store.getRun(run.delegation.parentRunId)?.status === 'review') {
4346
+ if (run.delegation?.role === 'worker' && this.store.getRun(run.delegation.parentRunId)?.status === 'review' && !opts.answerInputId && !this.answersFallenBackAsk(runId, opts, deferForCapacity)) {
3835
4347
  return { ok: false, error: 'Continue the reviewing parent before continuing its worker' };
3836
4348
  }
3837
4349
  // Held against the identity of the step this continuation extends (#452), which for a mixed
@@ -3866,8 +4378,10 @@ export class RunManager {
3866
4378
  return { ok: false, error: 'worker cannot continue' };
3867
4379
  if (this.executionBlockedByRootFinish(run))
3868
4380
  return { ok: false, error: 'parent finish is pending' };
4381
+ if (pendingHumanAsk && !deferForCapacity && !opts.answerInputId && this.routedAsk(runId))
4382
+ return { ok: false, error: ROUTED_QUESTION_REFUSAL };
3869
4383
  const answers = deferForCapacity ? [run.continuationMessage, ...(run.queuedMessages ?? [])] : [opts];
3870
- if (pendingHumanAsk && (conversation || (deferForCapacity && run.continuationMessage?.origin !== 'human') ||
4384
+ if (pendingHumanAsk && !opts.answerInputId && (conversation || (deferForCapacity && run.continuationMessage?.origin !== 'human') ||
3871
4385
  !answers.some(answer => answer?.text?.trim() || answer?.images?.length))) {
3872
4386
  return { ok: false, error: 'pending human question requires an explicit answer' };
3873
4387
  }
@@ -4015,7 +4529,7 @@ export class RunManager {
4015
4529
  const continuationStep = { id: stepId, name: 'Continue', kind: 'agent', synthetic: 'continuation' };
4016
4530
  if (run.delegation?.role !== 'worker')
4017
4531
  this.store.addStep(runId, continuationStep);
4018
- const recovered = deferForCapacity && !conversation ? run.continuationMessage : undefined;
4532
+ const recovered = deferForCapacity && !conversation && !opts.answerInputId ? run.continuationMessage : undefined;
4019
4533
  const message = recovered ?? this.toQueuedMessage(runId, [
4020
4534
  ...(opts.text?.trim() ? [{ type: 'text', text: opts.text.trim() }] : []),
4021
4535
  ...(opts.images ?? []),
@@ -4025,8 +4539,9 @@ export class RunManager {
4025
4539
  // and the original prompt/URLs must reach disk before the first startup await.
4026
4540
  const acceptedPatch = {
4027
4541
  continuationMessage: { ...message, id: stepId, text: prompt,
4028
- ...(conversation ? { agentInputId: conversation.input.id } : {}),
4029
- origin: recovered ? recovered.origin : deferForCapacity ? 'lifecycle' : 'human' },
4542
+ ...(conversation ? { agentInputId: conversation.input.id } : opts.answerInputId ? { agentInputId: opts.answerInputId } : {}),
4543
+ // A parent's answer (#505) answers the ask exactly like a human's.
4544
+ origin: recovered ? recovered.origin : deferForCapacity && !opts.answerInputId ? 'lifecycle' : 'human' },
4030
4545
  status: deferForCapacity ? 'queued' : 'running',
4031
4546
  error: undefined,
4032
4547
  finishedAt: undefined,
@@ -4203,12 +4718,15 @@ export class RunManager {
4203
4718
  const openingInput = record?.agentInputs?.find(input => input.id === checkpoint?.agentInputId);
4204
4719
  // Hydration has already included later human updates and task edits. Append
4205
4720
  // the current obligation outcome without replacing that composed prompt.
4206
- if (openingInput)
4721
+ // A parent's answer to a routed question (#505) is shown as its conversation reply, not
4722
+ // as a human message, and its own reply outcome is not news to the worker.
4723
+ const parentAnswer = openingInput?.conversation?.kind === 'reply' && this.routedAsk(runId) !== undefined;
4724
+ if (openingInput && !parentAnswer)
4207
4725
  prompt += this.agentInputOutcomeText(openingInput);
4208
4726
  const saved = this.readPersistedAttachments(runId, checkpoint?.id === stepId ? checkpoint.images ?? [] : [], 'continuation');
4209
4727
  const openingImages = [...(images.length ? contentBlocksOf(images) : saved.blocks), ...persistedImages];
4210
4728
  const attachments = [...saved.attachments, ...persistedAttachments];
4211
- if (checkpoint?.origin !== 'lifecycle')
4729
+ if (checkpoint?.origin !== 'lifecycle' && !parentAnswer)
4212
4730
  this.store.appendEvent(runId, {
4213
4731
  type: 'user-message',
4214
4732
  stepId,
@@ -4238,12 +4756,19 @@ export class RunManager {
4238
4756
  return;
4239
4757
  }
4240
4758
  if (event.type === 'text') {
4759
+ this.clearUnreadInputTimer(state); // real content: the harness is working
4241
4760
  turnText = appendTurnText(turnText, event.text);
4242
4761
  const text = stripAskMarker(stripTaskMarkers(stripMonitoringMarker(stripDoneMarker(event.text))), false);
4243
4762
  if (text)
4244
4763
  this.store.appendEvent(runId, { type: 'text', text, stepId });
4245
4764
  return;
4246
4765
  }
4766
+ if (event.type === 'input-unconsumed') {
4767
+ this.retireUnreadInputs(runId, state, event.inputIds, true);
4768
+ return;
4769
+ }
4770
+ if (event.type === 'tool-call' || event.type === 'tool-result')
4771
+ this.clearUnreadInputTimer(state);
4247
4772
  this.store.appendEvent(runId, { ...event, stepId });
4248
4773
  if (event.type === 'error') {
4249
4774
  // Preserve the initiating fault: interrupt may fail an in-flight HTTP request.
@@ -4267,6 +4792,8 @@ export class RunManager {
4267
4792
  if (isClaudeScheduleWakeup(event, backend))
4268
4793
  sawClaudeScheduleWakeup = true;
4269
4794
  if (event.type === 'turn-end') {
4795
+ this.retireUnreadInputs(runId, state, event.unconsumedInputIds ?? [], false);
4796
+ this.readBundledInputs(runId, state);
4270
4797
  state.workerWakeTurn = undefined;
4271
4798
  state.ciWakeTurn = undefined;
4272
4799
  state.atTurnBoundary = state.session;
@@ -4278,7 +4805,13 @@ export class RunManager {
4278
4805
  const id = state.openingAgentInputId;
4279
4806
  const deliveredAt = new Date().toISOString();
4280
4807
  try {
4281
- this.store.commitAgentInputs(runId, (this.store.getRun(runId)?.agentInputs ?? []).map(input => input.id === id && !input.deliveredAt ? { ...input, deliveredAt } : input), id);
4808
+ // The successful opening turn handled its instruction: delivered and read (#505).
4809
+ this.store.commitAgentInputs(runId, (this.store.getRun(runId)?.agentInputs ?? []).map(input => {
4810
+ if (input.id !== id)
4811
+ return input;
4812
+ const { awaitingRead: _awaiting, ...handled } = input;
4813
+ return { ...handled, deliveredAt: input.deliveredAt ?? deliveredAt, consumedAt: input.consumedAt ?? deliveredAt };
4814
+ }), id);
4282
4815
  }
4283
4816
  catch (error) {
4284
4817
  state.agentInputError = `agent input delivery checkpoint failed: ${error instanceof Error ? error.message : String(error)}`;
@@ -4299,7 +4832,7 @@ export class RunManager {
4299
4832
  this.reconcileWorkerWaits();
4300
4833
  }
4301
4834
  if (openingAnswerAskSeq !== undefined && state.session?.open) {
4302
- this.store.appendEvent(runId, { type: 'human-input-delivered', askSeq: openingAnswerAskSeq });
4835
+ this.store.appendEvent(runId, { type: 'human-input-delivered', askSeq: openingAnswerAskSeq, ...(parentAnswer ? { source: 'parent' } : {}) });
4303
4836
  openingAnswerAskSeq = undefined;
4304
4837
  state.openingAnswerAskSeq = undefined;
4305
4838
  }
@@ -4337,7 +4870,11 @@ export class RunManager {
4337
4870
  const workerWaitParked = ciWaitParked || completionBlocked || (!!sessionOpen && this.parkWorkerWait(runId, state));
4338
4871
  state.doneAtBoundary = done ? state.session : undefined;
4339
4872
  state.parkAfterAck = sessionOpen && state.session ? { session: state.session, monitoring: !!monitoring } : undefined;
4340
- const agentInputDelivered = !ask && !workerWaitParked && this.flushAgentInputs(runId);
4873
+ // #505: input the harness accepted but has not read yet runs as its next turn.
4874
+ // An in-flight submission is settled by its acknowledgement (parkAfterAck), not here.
4875
+ const agentInputDelivered = !ask && !workerWaitParked && (this.flushAgentInputs(runId) || this.harnessOwesInput(state) || !!state.agentInputFlight);
4876
+ if (!ask && !workerWaitParked)
4877
+ this.armUnreadInputTimer(runId, state);
4341
4878
  if (state.agentInputError)
4342
4879
  return;
4343
4880
  if (done && !workerWaitParked && !state.pendingHumanAsk && !agentInputDelivered && !state.agentInputFlight && !this.hasQueuedAgentInputs(runId)) {
@@ -4373,8 +4910,10 @@ export class RunManager {
4373
4910
  // `ScheduleWakeup` → non-attention `running`/`activity:'monitoring'`
4374
4911
  // (#490, #46). Both free the slot; only genuine user waits keep the
4375
4912
  // idle timer. The autonomous nudge above still wins over either.
4376
- if (ask)
4913
+ if (ask) {
4377
4914
  emitAskRequested(sink, ask);
4915
+ this.routeWorkerQuestion(runId);
4916
+ }
4378
4917
  if (monitoring) {
4379
4918
  this.store.updateRun(runId, { status: 'running', activity: 'monitoring' });
4380
4919
  this.store.updateStep(runId, stepId, { status: 'running' });
@@ -4511,11 +5050,14 @@ export class RunManager {
4511
5050
  failBeforeSpawn(err.message);
4512
5051
  return;
4513
5052
  }
4514
- if (state.cancelled) {
5053
+ const settlePrelaunchCancellation = () => {
4515
5054
  this.store.updateStep(runId, stepId, { status: 'cancelled' });
4516
5055
  this.store.updateRun(runId, { status: 'cancelled', finishedAt: new Date().toISOString(), currentStepId: undefined });
4517
5056
  this.clearAutosaveTimer(state);
4518
5057
  this.dropActive(runId);
5058
+ };
5059
+ if (state.cancelled) {
5060
+ settlePrelaunchCancellation();
4519
5061
  return;
4520
5062
  }
4521
5063
  if (this.holdContinuationForRootFinish(runId, { stepId, sessionId, backend, prompt, images }, state))
@@ -4540,12 +5082,36 @@ export class RunManager {
4540
5082
  }
4541
5083
  const delegation = this.provisionSession(runId, state);
4542
5084
  const ciTools = await this.provisionCiSession(runId, state);
5085
+ // Stop/Finish can arrive while the local tool controller is starting.
5086
+ const launchRun = this.store.getRun(runId);
5087
+ if (state.cancelled || this.workerExecutionStopped(runId) || this.active.get(runId) !== state ||
5088
+ (launchRun && this.executionBlockedByRootFinish(launchRun))) {
5089
+ ciTools?.revoke();
5090
+ if (state.revokeCiTools === ciTools?.revoke)
5091
+ state.revokeCiTools = undefined;
5092
+ delegation?.revoke();
5093
+ if (state.revokeDelegation === delegation?.revoke)
5094
+ state.revokeDelegation = undefined;
5095
+ if (this.active.get(runId) !== state)
5096
+ return;
5097
+ if (state.cancelled || this.workerExecutionStopped(runId)) {
5098
+ state.cancelled = true;
5099
+ settlePrelaunchCancellation();
5100
+ }
5101
+ else {
5102
+ this.holdContinuationForRootFinish(runId, { stepId, sessionId, backend, prompt, images }, state);
5103
+ }
5104
+ return;
5105
+ }
5106
+ state.inputDelivery = inputDeliveryOf(runner);
5107
+ state.unreadInputIds = new Set();
5108
+ state.consumedBeforeAck = new Set();
4543
5109
  try {
4544
5110
  session = runner.startSession({
4545
5111
  // The Continue step is a fresh agent session on the same run — the
4546
5112
  // run's extra system prompt (already resolved at execute time and
4547
5113
  // echoed on the record) rides along with the handoff contract.
4548
- systemPrompt: composeSystemPrompt(record?.systemPrompt, delegation?.instructions, generateFollowups ? HANDOFF_INSTRUCTIONS : HANDOFF_ONLY_INSTRUCTIONS),
5114
+ systemPrompt: composeSystemPrompt(record?.systemPrompt, delegation?.instructions, artifactInstructions(continueProfile.env.CEZ_ARTIFACTS_DIR), generateFollowups ? HANDOFF_INSTRUCTIONS : HANDOFF_ONLY_INSTRUCTIONS),
4549
5115
  userPrompt: attachments.length
4550
5116
  ? `${openingPrompt}\n\n${pastedAttachmentsText(attachments)}`
4551
5117
  : openingPrompt,
@@ -4565,9 +5131,12 @@ export class RunManager {
4565
5131
  }, onEvent, {
4566
5132
  onUiEvent: (event) => {
4567
5133
  completedAssistantText = appendCompletedAssistantText(completedAssistantText, event);
5134
+ if (event.type === 'turn.started')
5135
+ this.recordOpeningAccepted(runId, state);
4568
5136
  this.handleRunnerUiEvent(runId, state, sink, event);
4569
5137
  },
4570
5138
  onAgentInputReady: () => this.handleAgentInputReady(runId, state, session),
5139
+ onAgentInputConsumed: (ids) => this.handleAgentInputConsumed(runId, state, session, ids),
4571
5140
  });
4572
5141
  }
4573
5142
  catch (error) {
@@ -4589,7 +5158,7 @@ export class RunManager {
4589
5158
  if (session.pid !== undefined)
4590
5159
  registerRunProcess(runId, session.pid);
4591
5160
  setupComplete = true;
4592
- const result = await session.result.finally(() => state.agentInputFlight?.settled);
5161
+ const result = await session.result.finally(() => state.agentInputFlight?.settled).finally(() => this.requeueUnreadAtClose(runId, state, session));
4593
5162
  if (this.preserveRunAfterDisposal(runId, state))
4594
5163
  return;
4595
5164
  this.persistWorkerAssistantResult(runId, stepId, result.text);
@@ -4978,6 +5547,11 @@ export class RunManager {
4978
5547
  startImages = undefined;
4979
5548
  startAttachments = [];
4980
5549
  checkFailure = null;
5550
+ // Stale setup cannot finalize a replacement owner. Disposal also clears
5551
+ // active, but must still honor a previously accepted Stop/Finish intent.
5552
+ if (this.active.get(runId) !== state &&
5553
+ !(this.disposed && (state.cancelled || state.finishRequested)))
5554
+ return;
4981
5555
  if (state.cancelled || this.preserveRunAfterDisposal(runId, state))
4982
5556
  break;
4983
5557
  const current = this.store.getRun(runId);
@@ -5168,12 +5742,19 @@ export class RunManager {
5168
5742
  return;
5169
5743
  }
5170
5744
  if (event.type === 'text') {
5745
+ this.clearUnreadInputTimer(state); // real content: the harness is working
5171
5746
  turnText = appendTurnText(turnText, event.text);
5172
5747
  const text = stripAskMarker(stripTaskMarkers(stripMonitoringMarker(stripDoneMarker(event.text))), false);
5173
5748
  if (text)
5174
5749
  emit({ type: 'text', text, stepId: step.id });
5175
5750
  return;
5176
5751
  }
5752
+ if (event.type === 'input-unconsumed') {
5753
+ this.retireUnreadInputs(runId, state, event.inputIds, true);
5754
+ return;
5755
+ }
5756
+ if (event.type === 'tool-call' || event.type === 'tool-result')
5757
+ this.clearUnreadInputTimer(state);
5177
5758
  emit({ ...event, stepId: step.id });
5178
5759
  if (event.type === 'error') {
5179
5760
  // Preserve the initiating fault: interrupt may fail an in-flight HTTP request.
@@ -5198,6 +5779,8 @@ export class RunManager {
5198
5779
  if (isClaudeScheduleWakeup(event, backend))
5199
5780
  sawClaudeScheduleWakeup = true;
5200
5781
  if (event.type === 'turn-end') {
5782
+ this.retireUnreadInputs(runId, state, event.unconsumedInputIds ?? [], false);
5783
+ this.readBundledInputs(runId, state);
5201
5784
  state.workerWakeTurn = undefined;
5202
5785
  state.ciWakeTurn = undefined;
5203
5786
  state.atTurnBoundary = state.session;
@@ -5233,7 +5816,11 @@ export class RunManager {
5233
5816
  const workerWaitParked = ciWaitParked || completionBlocked || (!!sessionOpen && this.parkWorkerWait(runId, state));
5234
5817
  state.doneAtBoundary = done ? state.session : undefined;
5235
5818
  state.parkAfterAck = interactive && sessionOpen && state.session ? { session: state.session, monitoring: !!monitoring } : undefined;
5236
- const agentInputDelivered = !ask && !workerWaitParked && this.flushAgentInputs(runId);
5819
+ // #505: input the harness accepted but has not read yet runs as its next turn.
5820
+ // An in-flight submission is settled by its acknowledgement (parkAfterAck), not here.
5821
+ const agentInputDelivered = !ask && !workerWaitParked && (this.flushAgentInputs(runId) || this.harnessOwesInput(state) || !!state.agentInputFlight);
5822
+ if (!ask && !workerWaitParked)
5823
+ this.armUnreadInputTimer(runId, state);
5237
5824
  if (state.agentInputError)
5238
5825
  return;
5239
5826
  if (done && !workerWaitParked && !state.pendingHumanAsk && !agentInputDelivered && !state.agentInputFlight && !this.hasQueuedAgentInputs(runId)) {
@@ -5252,8 +5839,10 @@ export class RunManager {
5252
5839
  // still working through `CEZ:MONITORING` or Claude's native
5253
5840
  // `ScheduleWakeup`. Monitoring parks as `running` with no user-wait
5254
5841
  // idle timer, so the cockpit stays non-attention (#490, #46).
5255
- if (ask)
5842
+ if (ask) {
5256
5843
  emitAskRequested(sink, ask);
5844
+ this.routeWorkerQuestion(runId);
5845
+ }
5257
5846
  if (monitoring) {
5258
5847
  this.store.updateRun(runId, { status: 'running', activity: 'monitoring' });
5259
5848
  this.store.updateStep(runId, step.id, { status: 'running' });
@@ -5356,15 +5945,30 @@ export class RunManager {
5356
5945
  this.beginUsageInvocation(runId, state, step.id);
5357
5946
  const delegation = this.provisionSession(runId, state);
5358
5947
  const ciTools = await this.provisionCiSession(runId, state);
5359
- try {
5360
- if (this.workerExecutionStopped(runId)) {
5948
+ // Revalidate after provisioning, then let execute settle cancellation or
5949
+ // requeue a Finish-held step through its existing no-session paths.
5950
+ const launchRun = this.store.getRun(runId);
5951
+ if (state.cancelled || this.workerExecutionStopped(runId) || this.active.get(runId) !== state ||
5952
+ (launchRun && this.executionBlockedByRootFinish(launchRun))) {
5953
+ ciTools?.revoke();
5954
+ if (state.revokeCiTools === ciTools?.revoke)
5955
+ state.revokeCiTools = undefined;
5956
+ delegation?.revoke();
5957
+ if (state.revokeDelegation === delegation?.revoke)
5958
+ state.revokeDelegation = undefined;
5959
+ if (this.workerExecutionStopped(runId))
5361
5960
  state.cancelled = true;
5362
- throw new Error('Worker stopped before launch');
5363
- }
5961
+ state.currentStepId = undefined;
5962
+ return null;
5963
+ }
5964
+ state.inputDelivery = inputDeliveryOf(runner);
5965
+ state.unreadInputIds = new Set();
5966
+ state.consumedBeforeAck = new Set();
5967
+ try {
5364
5968
  session = runner.startSession({
5365
5969
  // Skill body, then the run's extra prompt (POST override or config
5366
5970
  // default), then the handoff/todos contract — every agent step.
5367
- systemPrompt: composeSystemPrompt(systemPrompt, extraSystemPrompt, delegation?.instructions, followupsEnabled() && input.generateFollowups !== false
5971
+ systemPrompt: composeSystemPrompt(systemPrompt, extraSystemPrompt, delegation?.instructions, artifactInstructions(stepProfile.env.CEZ_ARTIFACTS_DIR), followupsEnabled() && input.generateFollowups !== false
5368
5972
  ? HANDOFF_INSTRUCTIONS
5369
5973
  : HANDOFF_ONLY_INSTRUCTIONS),
5370
5974
  userPrompt,
@@ -5391,6 +5995,7 @@ export class RunManager {
5391
5995
  this.handleRunnerUiEvent(runId, state, sink, event);
5392
5996
  },
5393
5997
  onAgentInputReady: () => this.handleAgentInputReady(runId, state, session),
5998
+ onAgentInputConsumed: (ids) => this.handleAgentInputConsumed(runId, state, session, ids),
5394
5999
  });
5395
6000
  }
5396
6001
  catch (err) {
@@ -5421,7 +6026,7 @@ export class RunManager {
5421
6026
  if (session.pid !== undefined)
5422
6027
  registerRunProcess(runId, session.pid);
5423
6028
  setupComplete = true;
5424
- const result = await session.result.finally(() => state.agentInputFlight?.settled);
6029
+ const result = await session.result.finally(() => state.agentInputFlight?.settled).finally(() => this.requeueUnreadAtClose(runId, state, session));
5425
6030
  if (this.isDisposedDelegatedRun(runId))
5426
6031
  return null;
5427
6032
  this.persistWorkerAssistantResult(runId, step.id, result.text);
@@ -5547,6 +6152,7 @@ export class RunManager {
5547
6152
  if (state.currentStepId)
5548
6153
  this.store.updateStep(runId, state.currentStepId, { status: 'waiting' });
5549
6154
  this.releaseSlot();
6155
+ this.routeWorkerQuestion(runId);
5550
6156
  return;
5551
6157
  }
5552
6158
  // Pi can resume autonomously when an async subagent completes — the mapper