@wjarka/cezarion 0.14.6 → 0.14.7

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 (123) hide show
  1. package/README.md +37 -2
  2. package/dist/agent-config/model-settings/shared.d.ts +2 -1
  3. package/dist/agent-config/model-settings/shared.js +1 -1
  4. package/dist/agent-config/model-settings/shared.js.map +1 -1
  5. package/dist/ci-wait/client.d.ts +4 -0
  6. package/dist/ci-wait/client.js +67 -0
  7. package/dist/ci-wait/client.js.map +1 -0
  8. package/dist/ci-wait/controller.d.ts +78 -0
  9. package/dist/ci-wait/controller.js +148 -0
  10. package/dist/ci-wait/controller.js.map +1 -0
  11. package/dist/ci-wait/github.d.ts +61 -0
  12. package/dist/ci-wait/github.js +209 -0
  13. package/dist/ci-wait/github.js.map +1 -0
  14. package/dist/ci-wait/injection.d.ts +3 -0
  15. package/dist/ci-wait/injection.js +26 -0
  16. package/dist/ci-wait/injection.js.map +1 -0
  17. package/dist/ci-wait/mcp.d.ts +97 -0
  18. package/dist/ci-wait/mcp.js +35 -0
  19. package/dist/ci-wait/mcp.js.map +1 -0
  20. package/dist/ci-wait/resources.d.ts +7 -0
  21. package/dist/ci-wait/resources.js +32 -0
  22. package/dist/ci-wait/resources.js.map +1 -0
  23. package/dist/ci-wait/supervisor.d.ts +21 -0
  24. package/dist/ci-wait/supervisor.js +163 -0
  25. package/dist/ci-wait/supervisor.js.map +1 -0
  26. package/dist/contract/ci-wait.d.ts +151 -0
  27. package/dist/contract/conversations.d.ts +7 -0
  28. package/dist/contract/delegation.d.ts +2 -0
  29. package/dist/contract/events.d.ts +3 -0
  30. package/dist/contract/index.d.ts +1 -0
  31. package/dist/contract/index.js +1016 -935
  32. package/dist/contract/runs.d.ts +461 -0
  33. package/dist/contract/skills.d.ts +92 -0
  34. package/dist/core/agent-env.js +2 -0
  35. package/dist/core/agent-env.js.map +1 -1
  36. package/dist/core/agent-runner.d.ts +6 -0
  37. package/dist/core/agent-runner.js +1 -0
  38. package/dist/core/agent-runner.js.map +1 -1
  39. package/dist/core/claude-cli-runner.js +10 -0
  40. package/dist/core/claude-cli-runner.js.map +1 -1
  41. package/dist/core/codex-app-server-runner.js +5 -1
  42. package/dist/core/codex-app-server-runner.js.map +1 -1
  43. package/dist/core/codex-app-server-transport.js +3 -1
  44. package/dist/core/codex-app-server-transport.js.map +1 -1
  45. package/dist/core/cursor-acp-runner.js +9 -1
  46. package/dist/core/cursor-acp-runner.js.map +1 -1
  47. package/dist/core/opencode-server-runner.js +6 -2
  48. package/dist/core/opencode-server-runner.js.map +1 -1
  49. package/dist/core/pi-runner.js +7 -1
  50. package/dist/core/pi-runner.js.map +1 -1
  51. package/dist/delegation/cli.js +8 -4
  52. package/dist/delegation/cli.js.map +1 -1
  53. package/dist/delegation/conversations.js +2 -1
  54. package/dist/delegation/conversations.js.map +1 -1
  55. package/dist/delegation/input.d.ts +6 -0
  56. package/dist/delegation/input.js +22 -0
  57. package/dist/delegation/input.js.map +1 -1
  58. package/dist/delegation/provision.js +3 -1
  59. package/dist/delegation/provision.js.map +1 -1
  60. package/dist/delegation/routes.d.ts +11 -0
  61. package/dist/delegation/service.js +31 -7
  62. package/dist/delegation/service.js.map +1 -1
  63. package/dist/delegation/transport.d.ts +11 -0
  64. package/dist/runs/delegation-state.d.ts +2 -0
  65. package/dist/runs/run-index.js +2 -3
  66. package/dist/runs/run-index.js.map +1 -1
  67. package/dist/runs/store.d.ts +514 -3
  68. package/dist/runs/store.js +85 -6
  69. package/dist/runs/store.js.map +1 -1
  70. package/dist/server/server.d.ts +1376 -12
  71. package/dist/workflows/run.d.ts +25 -1
  72. package/dist/workflows/run.js +470 -43
  73. package/dist/workflows/run.js.map +1 -1
  74. package/package.json +4 -3
  75. package/scripts/mock-ci-tool.mjs +53 -0
  76. package/scripts/mock-claude.mjs +8 -0
  77. package/scripts/mock-codex-app-server.mjs +212 -0
  78. package/scripts/mock-cursor-acp.mjs +6 -2
  79. package/scripts/mock-opencode-serve.mjs +392 -0
  80. package/scripts/mock-pi-rpc.mjs +7 -0
  81. package/scripts/pi-ci-wait.mjs +14 -0
  82. package/web/dist/assets/{agent-accounts-lP_xK83o.js → agent-accounts-BZm_2WAv.js} +1 -1
  83. package/web/dist/assets/{alert-dialog-CmDuefYh.js → alert-dialog-B422RI-k.js} +1 -1
  84. package/web/dist/assets/{collapsible-BqX8REmY.js → collapsible-Cy_Fmkrd.js} +1 -1
  85. package/web/dist/assets/{commit-list-BWvv1H7A.js → commit-list-CLxXMZvP.js} +1 -1
  86. package/web/dist/assets/{compare-variants-Bg7eLW3z.js → compare-variants-D2dRj6H_.js} +1 -1
  87. package/web/dist/assets/{diff-controls-HwWHHait.js → diff-controls-BGrXGi7O.js} +1 -1
  88. package/web/dist/assets/{diff-stat-mNkhAc5m.js → diff-stat-Cgnl_hYF.js} +1 -1
  89. package/web/dist/assets/{diff-0GBum40Q2.js → diff-tRE59UNb2.js} +2 -2
  90. package/web/dist/assets/{diff-view-DW64doDY.js → diff-view-DN1ort7Q.js} +1 -1
  91. package/web/dist/assets/{dropdown-menu-sZjYPcLh.js → dropdown-menu-dwDGT8MM.js} +1 -1
  92. package/web/dist/assets/{git-toolbar-DBW5EIcY.js → git-toolbar-UiHnGvE0.js} +1 -1
  93. package/web/dist/assets/{github-CkZ4hWI5.js → github-CaGjGQnW.js} +1 -1
  94. package/web/dist/assets/{image-preview-DZicGNkU.js → image-preview-C2JGcBlC.js} +1 -1
  95. package/web/dist/assets/index-CXZukB1O.css +1 -0
  96. package/web/dist/assets/{index-DWWI-B9n.js → index-D0RwD9-c.js} +3 -3
  97. package/web/dist/assets/{markdown-AY2K3kIV.js → markdown-C_IcKVQd.js} +1 -1
  98. package/web/dist/assets/{pill-CcJqA1cL.js → pill-BsbO6G7y.js} +1 -1
  99. package/web/dist/assets/{project-router-SwsXRKXs.js → project-router-DbLkll25.js} +1 -1
  100. package/web/dist/assets/{prompt-templates-iV2WD2jy.js → prompt-templates-BlTGJB5W.js} +1 -1
  101. package/web/dist/assets/{repo-git-BW3TOs3U.js → repo-git-CWD2dO8S.js} +1 -1
  102. package/web/dist/assets/{run-diff-DPUNj56q.js → run-diff-CyG0Nciv.js} +1 -1
  103. package/web/dist/assets/{run-header-CSaJIGXl.css → run-header-BJQfFL2l.css} +1 -1
  104. package/web/dist/assets/run-header-IFyIGCey.js +1 -0
  105. package/web/dist/assets/{skills-Dv8Vwv5D.js → skills-e7wHnuoc.js} +1 -1
  106. package/web/dist/assets/{tab-link-DSftvkZS.js → tab-link-BWNp3mLY.js} +1 -1
  107. package/web/dist/assets/{task-changes-CTCuVls1.js → task-changes-CpJImtq8.js} +1 -1
  108. package/web/dist/assets/{task-commits-weJQ5GSl.js → task-commits-hujrykPI.js} +1 -1
  109. package/web/dist/assets/{task-files-B4bUimY8.js → task-files-CjTiGtjt.js} +1 -1
  110. package/web/dist/assets/{task-thread-BuXrVB9K.js → task-thread-DDPFDS8C.js} +4 -4
  111. package/web/dist/assets/task-thread-Dc3wplva.css +1 -0
  112. package/web/dist/assets/{textarea-CxRdTWlq.js → textarea-FjaU370i.js} +1 -1
  113. package/web/dist/assets/thread-loading-77sJ8Yu_.js +1 -0
  114. package/web/dist/assets/{token-metrics-Z3UFYbuD.js → token-metrics-Clxh03bE.js} +1 -1
  115. package/web/dist/assets/utils-PYYcpCFZ.js +64 -0
  116. package/web/dist/assets/{workflows-vA27Yi8W.js → workflows-DGUQmWy7.js} +1 -1
  117. package/web/dist/assets/{zoomable-image-DUE1KFJ0.js → zoomable-image-Z3JDnm0b.js} +1 -1
  118. package/web/dist/index.html +14 -14
  119. package/web/dist/assets/index-DK0Mkv2m.css +0 -1
  120. package/web/dist/assets/run-header-DbDluWoV.js +0 -1
  121. package/web/dist/assets/task-thread-DMRD0IVt.css +0 -1
  122. package/web/dist/assets/thread-loading-JlP6dUpX.js +0 -1
  123. package/web/dist/assets/utils-CUnaAifW.js +0 -64
@@ -1,3 +1,5 @@
1
+ import { ciWaitRequestSchema, ciWaitResultSchema } from '../contract/index.js';
2
+ import { acquireCiResources } from '../ci-wait/resources.js';
1
3
  import { workerContextHash, workerWorkflowHash, workerStepIdentity, captureWorkerAccount, boundWorkerAccountEnv, WorkerIdentityError } from '../delegation/execution-identity.js';
2
4
  import { buildChildEnv } from '../core/agent-env.js';
3
5
  import { randomUUID } from 'node:crypto';
@@ -27,7 +29,7 @@ import { autosaveCommit, createWorktree, resolveBaseRef, worktreeDiff, worktreeS
27
29
  import { getHeadCommit, getRepoInfo } from '../server/git.js';
28
30
  import { ensureOwnedWorkspace, verifyOwnedWorkspace } from '../delegation/workspace.js';
29
31
  import { verifyWorkerContext } from '../delegation/context.js';
30
- import { enqueueAgentInput, nextAgentInput } from '../delegation/input.js';
32
+ import { enqueueAgentInput, agentInputBatch } from '../delegation/input.js';
31
33
  import { parseDelegationEffort } from '../delegation/effort.js';
32
34
  import { DelegationPolicyError } from '../delegation/policy.js';
33
35
  import { reconcileWorkerWait } from '../delegation/wait.js';
@@ -477,6 +479,14 @@ export class RunManager {
477
479
  if (this.executions.get(runId) === execution)
478
480
  this.executions.delete(runId);
479
481
  execution.resolve();
482
+ if (!this.disposed) {
483
+ try {
484
+ this.queueCiWake(runId);
485
+ }
486
+ catch {
487
+ this.retryCiAdmission(runId);
488
+ }
489
+ }
480
490
  if (!this.disposed && this.store.getRun(runId)?.stopping)
481
491
  this.store.updateRun(runId, { stopping: undefined });
482
492
  if (!this.disposed)
@@ -572,6 +582,13 @@ export class RunManager {
572
582
  /** Durable monitoring subset. Only the configured number receives the waiting-slot exemption. */
573
583
  monitoring = new Set();
574
584
  /** Separate from human waiting: these resumes MUST reacquire scheduler capacity. */
585
+ ciWakeQueuedAt = new Map();
586
+ ciWakeAdmitted = new Set();
587
+ ciWatches = new Map();
588
+ ciRegistrations = new Map();
589
+ ciRetries = new Map();
590
+ ciResources;
591
+ ciSupervisor;
575
592
  workerWaiting = new Set();
576
593
  workerWakeAdmitted = new Set();
577
594
  workerWakeQueuedAt = new Map();
@@ -580,6 +597,8 @@ export class RunManager {
580
597
  recovering = false;
581
598
  disposed = false;
582
599
  onDelegationRun = (run) => {
600
+ if (!this.disposed && !['queued', 'running', 'waiting'].includes(run.status))
601
+ this.withdrawCiWait(run.id);
583
602
  if (run.delegation && !['queued', 'running', 'waiting'].includes(run.status))
584
603
  this.active.get(run.id)?.revokeDelegation?.();
585
604
  if (this.disposed || this.recovering || this.reconcilingWorkers)
@@ -647,6 +666,8 @@ export class RunManager {
647
666
  this.repoRoot = repoRoot;
648
667
  this.dataDir = join(repoRoot, '.ai/cezar');
649
668
  this.semaphore = options.semaphore ?? new WorkspaceSemaphore();
669
+ this.ciResources = acquireCiResources(this.semaphore);
670
+ this.ciSupervisor = this.ciResources.supervisor;
650
671
  this.offSemaphore = this.semaphore.register({
651
672
  busySlots: () => this.busySlots(),
652
673
  pump: () => this.pump(),
@@ -672,6 +693,16 @@ export class RunManager {
672
693
  */
673
694
  dispose() {
674
695
  this.disposed = true;
696
+ for (const task of this.ciWatches.values())
697
+ task.abort.abort();
698
+ this.ciWatches.clear();
699
+ for (const task of this.ciRegistrations.values())
700
+ task.abort.abort();
701
+ this.ciRegistrations.clear();
702
+ for (const timer of this.ciRetries.values())
703
+ clearTimeout(timer);
704
+ this.ciRetries.clear();
705
+ this.ciResources.release();
675
706
  this.delegationProvisioner = undefined;
676
707
  this.finalizedWorkers.clear();
677
708
  for (const settle of this.terminationWaiters)
@@ -687,6 +718,8 @@ export class RunManager {
687
718
  this.offSemaphore();
688
719
  clearInterval(this.queueWatchdog);
689
720
  for (const [runId, state] of this.active) {
721
+ state.revokeCiTools?.();
722
+ state.revokeCiTools = undefined;
690
723
  state.revokeDelegation?.();
691
724
  state.revokeDelegation = undefined;
692
725
  this.clearIdleTimer(state);
@@ -917,6 +950,196 @@ export class RunManager {
917
950
  state.revokeDelegation = session?.revoke;
918
951
  return session;
919
952
  }
953
+ /** Provision the same capability and descriptor on every start, Continue and recovered launch. */
954
+ async provisionCiSession(runId, state) {
955
+ state.revokeCiTools?.();
956
+ state.revokeCiTools = undefined;
957
+ state.ciGeneration = randomUUID();
958
+ state.ciTurnId = randomUUID();
959
+ const generation = state.ciGeneration;
960
+ const controller = await this.ciResources.controller();
961
+ if (!controller || this.disposed || state.cancelled || this.active.get(runId) !== state)
962
+ return;
963
+ const provisioned = controller.provision((request, signal) => this.registerCiWait(runId, request, generation, signal));
964
+ state.revokeCiTools = provisioned.revoke;
965
+ return provisioned;
966
+ }
967
+ /** Trusted capability callbacks supply run identity; the model supplies only a PR and deadline. */
968
+ async registerCiWait(runId, request, generation, capabilitySignal) {
969
+ const parsed = ciWaitRequestSchema.parse(request);
970
+ const state = this.active.get(runId);
971
+ const authorized = () => {
972
+ const run = this.store.getRun(runId);
973
+ return !this.disposed && !capabilitySignal?.aborted && !!run && ['running', 'waiting'].includes(run.status) && !run.stopping &&
974
+ this.active.get(runId) === state && !!state?.session?.open && !state.cancelled && !state.finishRequested &&
975
+ state.ciGeneration === generation && !state.pendingHumanAsk && !this.hasUnansweredHumanAsk(runId) &&
976
+ !this.workerWait(runId) && !this.workerExecutionStopped(runId) && !this.executionBlockedByRootFinish(run);
977
+ };
978
+ if (!authorized() || !state)
979
+ throw Object.assign(new Error('Run cannot register a CI wait in its current state'), { code: 'unauthorized' });
980
+ const current = this.store.getRun(runId)?.ciWait;
981
+ if (current) {
982
+ if (current.prUrl.toLowerCase() === parsed.pr.replace(/\/$/, '').toLowerCase() && current.timeoutSeconds === parsed.timeout_seconds)
983
+ return current;
984
+ throw Object.assign(new Error('wait_conflict: a different CI wait is already registered'), { code: 'wait_conflict' });
985
+ }
986
+ const pending = this.ciRegistrations.get(runId);
987
+ if (pending) {
988
+ if (pending.pr === parsed.pr && pending.seconds === parsed.timeout_seconds)
989
+ return pending.promise;
990
+ throw Object.assign(new Error('wait_conflict: a different CI registration is already in progress'), { code: 'wait_conflict' });
991
+ }
992
+ const turnId = state.ciTurnId ??= randomUUID();
993
+ const abort = new AbortController();
994
+ const signal = capabilitySignal ? AbortSignal.any([abort.signal, capabilitySignal]) : abort.signal;
995
+ const promise = (async () => {
996
+ const identity = await this.ciSupervisor.resolve(parsed.pr, signal);
997
+ if (signal.aborted || !authorized() || state.ciTurnId !== turnId)
998
+ throw Object.assign(new Error('Run cannot register a CI wait after interruption'), { code: 'unauthorized' });
999
+ const registeredAt = new Date().toISOString();
1000
+ const wait = { ...identity, id: randomUUID(), generation, turnId, timeoutSeconds: parsed.timeout_seconds,
1001
+ registeredAt, deadline: new Date(Date.now() + parsed.timeout_seconds * 1000).toISOString(), phase: 'registered' };
1002
+ try {
1003
+ this.store.commitCiWait(runId, wait);
1004
+ }
1005
+ catch {
1006
+ throw Object.assign(new Error('CI registration could not be saved; retry when storage is writable'), { code: 'persistence' });
1007
+ }
1008
+ this.startCiWatch(runId, wait);
1009
+ if (state.atTurnBoundary === state.session || this.waiting.has(runId))
1010
+ this.parkCiWait(runId, state);
1011
+ return this.store.getRun(runId).ciWait ?? wait;
1012
+ })();
1013
+ this.ciRegistrations.set(runId, { abort, pr: parsed.pr, seconds: parsed.timeout_seconds, promise });
1014
+ try {
1015
+ return await promise;
1016
+ }
1017
+ finally {
1018
+ if (this.ciRegistrations.get(runId)?.abort === abort)
1019
+ this.ciRegistrations.delete(runId);
1020
+ }
1021
+ }
1022
+ startCiWatch(runId, wait) {
1023
+ if (this.disposed || this.ciWatches.has(runId) || wait.result)
1024
+ return;
1025
+ const abort = new AbortController();
1026
+ const watch = { id: wait.id, abort };
1027
+ this.ciWatches.set(runId, watch);
1028
+ const current = () => !this.disposed && !abort.signal.aborted && this.ciWatches.get(runId) === watch &&
1029
+ this.store.getRun(runId)?.ciWait?.id === wait.id;
1030
+ const settle = (result) => {
1031
+ if (!current())
1032
+ return;
1033
+ try {
1034
+ const latest = this.store.getRun(runId)?.ciWait;
1035
+ if (!latest || latest.id !== wait.id)
1036
+ return;
1037
+ const parsed = ciWaitResultSchema.safeParse(result);
1038
+ const validated = parsed.success ? parsed.data : { outcome: 'error', headSha: wait.headSha,
1039
+ observedAt: new Date().toISOString(), checks: [], totalChecks: 0, truncated: false,
1040
+ diagnostic: 'CI watcher returned an invalid observation; inspect the PR and register a new wait.' };
1041
+ const settled = { ...latest, phase: 'wake-pending', result: validated, wakeId: wait.id };
1042
+ const input = { id: wait.id, parentRunId: runId, source: 'lifecycle', createdAt: new Date().toISOString(),
1043
+ text: `CI wait observation ${wait.id} for ${wait.prUrl}. External data follows: ${JSON.stringify(validated)}. This observation is not merge approval or task completion. Reported checks may not include every expected workflow. Duplicate observations retain this ID.` };
1044
+ this.store.commitCiWait(runId, settled, input);
1045
+ this.ciWatches.delete(runId);
1046
+ try {
1047
+ this.queueCiWake(runId);
1048
+ }
1049
+ catch {
1050
+ this.retryCiAdmission(runId);
1051
+ }
1052
+ }
1053
+ catch {
1054
+ // A failed durable checkpoint may not drop the accepted intent or publish a false result.
1055
+ if (!current())
1056
+ return;
1057
+ console.warn('[cez] CI wait checkpoint unavailable; retaining result for retry.');
1058
+ const timer = setTimeout(() => { this.ciRetries.delete(runId); settle(result); }, 1000);
1059
+ timer.unref();
1060
+ this.ciRetries.set(runId, timer);
1061
+ }
1062
+ };
1063
+ void this.ciSupervisor.watch(wait, abort.signal).then(settle, () => settle({ outcome: 'error', headSha: wait.headSha,
1064
+ observedAt: new Date().toISOString(), checks: [], totalChecks: 0, truncated: false,
1065
+ diagnostic: 'CI watcher unavailable; inspect the pull request and retry the wait.' }));
1066
+ }
1067
+ retryCiAdmission(runId) {
1068
+ if (this.disposed || this.ciRetries.has(runId) || !this.store.getRun(runId)?.ciWait)
1069
+ return;
1070
+ const timer = setTimeout(() => {
1071
+ this.ciRetries.delete(runId);
1072
+ try {
1073
+ this.queueCiWake(runId);
1074
+ }
1075
+ catch {
1076
+ this.retryCiAdmission(runId);
1077
+ }
1078
+ }, 1000);
1079
+ timer.unref();
1080
+ this.ciRetries.set(runId, timer);
1081
+ }
1082
+ parkCiWait(runId, state) {
1083
+ const wait = this.store.getRun(runId)?.ciWait;
1084
+ if (!wait || !state.session?.open || state.cancelled || state.pendingHumanAsk || this.ciWakeAdmitted.has(runId))
1085
+ return false;
1086
+ if (wait.phase === 'registered')
1087
+ this.store.commitCiWait(runId, { ...wait, phase: 'parked' });
1088
+ this.clearIdleTimer(state);
1089
+ this.clearMonitoringWakeTimer(state, runId);
1090
+ this.waiting.add(runId);
1091
+ this.monitoring.add(runId);
1092
+ this.store.updateRun(runId, { status: 'running', activity: 'monitoring', monitoringWakeAt: undefined });
1093
+ if (state.currentStepId)
1094
+ this.store.updateStep(runId, state.currentStepId, { status: 'running' });
1095
+ this.queueCiWake(runId);
1096
+ this.releaseSlot();
1097
+ return true;
1098
+ }
1099
+ queueCiWake(runId) {
1100
+ const run = this.store.getRun(runId);
1101
+ const wait = run?.ciWait;
1102
+ if (this.disposed || this.recovering || !run || !wait?.result || !wait.wakeId || this.ciWakeQueuedAt.has(runId) ||
1103
+ this.ciWakeAdmitted.has(runId) || !['running', 'waiting', 'queued'].includes(run.status) || run.stopping ||
1104
+ this.hasPendingHumanAsk(runId) || this.workerExecutionStopped(runId) || this.executionBlockedByRootFinish(run))
1105
+ return;
1106
+ const state = this.active.get(runId);
1107
+ if (state) {
1108
+ if (!this.monitoring.has(runId) || state.atTurnBoundary !== state.session || state.pendingHumanAsk)
1109
+ return;
1110
+ this.ciWakeQueuedAt.set(runId, Date.now());
1111
+ if (!this.queue.includes(runId))
1112
+ this.queue.push(runId);
1113
+ }
1114
+ else if (!this.isActive(runId)) {
1115
+ // No live session: create a scheduler-owned continuation, never a direct provider call.
1116
+ this.store.updateRun(runId, { status: 'waiting', activity: undefined });
1117
+ const resumed = this.continueRun(runId, { text: 'Resume to receive the queued CI observation. End this opening turn so Cezar can deliver it; do not poll CI.' }, true);
1118
+ if (!resumed.ok) {
1119
+ this.store.appendEvent(runId, { type: 'note', message: `CI result retained; continuation unavailable: ${resumed.error ?? 'unknown'}` });
1120
+ return;
1121
+ }
1122
+ this.ciWakeQueuedAt.set(runId, Date.now());
1123
+ }
1124
+ this.releaseSlot();
1125
+ }
1126
+ withdrawCiWait(runId, message) {
1127
+ this.ciRegistrations.get(runId)?.abort.abort();
1128
+ this.ciRegistrations.delete(runId);
1129
+ this.store.commitCiWaitWithdrawal(runId, message);
1130
+ this.ciWatches.get(runId)?.abort.abort();
1131
+ this.ciWatches.delete(runId);
1132
+ const timer = this.ciRetries.get(runId);
1133
+ if (timer)
1134
+ clearTimeout(timer);
1135
+ this.ciRetries.delete(runId);
1136
+ if (this.ciWakeQueuedAt.delete(runId) && this.active.has(runId)) {
1137
+ const index = this.queue.indexOf(runId);
1138
+ if (index >= 0)
1139
+ this.queue.splice(index, 1);
1140
+ }
1141
+ this.ciWakeAdmitted.delete(runId);
1142
+ }
920
1143
  /** Snapshot of the actual active session, never guessed from partial run settings. */
921
1144
  delegationExecutionSettings(runId) {
922
1145
  const state = this.active.get(runId);
@@ -1117,7 +1340,7 @@ export class RunManager {
1117
1340
  const head = this.queue[0];
1118
1341
  if (!head)
1119
1342
  return null;
1120
- const wakeQueuedAt = this.workerWakeQueuedAt.get(head);
1343
+ const wakeQueuedAt = this.workerWakeQueuedAt.get(head) ?? this.ciWakeQueuedAt.get(head);
1121
1344
  if (wakeQueuedAt !== undefined)
1122
1345
  return wakeQueuedAt;
1123
1346
  const createdAt = this.store.getRun(head)?.createdAt;
@@ -1196,13 +1419,16 @@ export class RunManager {
1196
1419
  // Only pay for the config read when something is actually held: a queued record may name
1197
1420
  // no runner, and then the account it would use is the configured default.
1198
1421
  const defaultRunner = anyHold ? (await loadConfig(this.repoRoot)).defaultRunner : undefined;
1199
- while (this.queue.length > 0 && capacity()) {
1422
+ // Removing one monitor above the exemption cap needs no new slot: it already holds one.
1423
+ const chargedCiWake = (id) => this.ciWakeQueuedAt.has(id) && this.monitoring.has(id) &&
1424
+ this.monitoring.size > this.semaphore.maxMonitoringSessions();
1425
+ while (this.queue.length > 0) {
1200
1426
  // FIFO among the runs that CAN start; a held one keeps its place in the queue rather
1201
1427
  // than being dequeued and re-queued (which would churn its position and its record).
1202
1428
  const next = this.queue.findIndex((id) => {
1203
1429
  const queued = this.store.getRun(id);
1204
- return !queued || this.historyDeletionPending(id) || ((!this.executions.get(id)?.admitted || this.active.has(id)) && !this.executionBlockedByRootFinish(queued) &&
1205
- (!anyHold || !accountHeldFor(queued, holds, defaultRunner ?? 'claude')));
1430
+ return (capacity() || chargedCiWake(id)) && (!queued || this.historyDeletionPending(id) || ((!this.executions.get(id)?.admitted || this.active.has(id)) && !this.executionBlockedByRootFinish(queued) &&
1431
+ (!anyHold || !accountHeldFor(queued, holds, defaultRunner ?? 'claude'))));
1206
1432
  });
1207
1433
  if (next === -1)
1208
1434
  break; // every queued run has a durable reason to remain held
@@ -1226,6 +1452,19 @@ export class RunManager {
1226
1452
  const runId = this.queue.splice(next, 1)[0];
1227
1453
  if (!runId)
1228
1454
  break;
1455
+ if (this.ciWakeQueuedAt.has(runId) && this.active.has(runId)) {
1456
+ this.ciWakeQueuedAt.delete(runId);
1457
+ const state = this.active.get(runId);
1458
+ if (!state.pendingHumanAsk && this.store.getRun(runId)?.ciWait?.result && state.atTurnBoundary === state.session) {
1459
+ this.ciWakeAdmitted.add(runId);
1460
+ this.waiting.delete(runId);
1461
+ this.monitoring.delete(runId);
1462
+ this.flushAgentInputs(runId);
1463
+ }
1464
+ continue;
1465
+ }
1466
+ if (this.ciWakeQueuedAt.delete(runId))
1467
+ this.ciWakeAdmitted.add(runId);
1229
1468
  if (this.workerWakeQueuedAt.has(runId) && this.active.has(runId)) {
1230
1469
  this.workerWakeQueuedAt.delete(runId);
1231
1470
  const state = this.active.get(runId);
@@ -1476,6 +1715,15 @@ export class RunManager {
1476
1715
  this.continueRun(run.id, {}, true);
1477
1716
  continue;
1478
1717
  }
1718
+ if (run.ciWait && !this.hasPendingHumanAsk(run.id)) {
1719
+ if (run.ciWait.phase === 'registered')
1720
+ this.store.commitCiWait(run.id, { ...run.ciWait, phase: 'parked' });
1721
+ this.store.updateRun(run.id, { status: 'running', activity: 'monitoring', monitoringWakeAt: undefined });
1722
+ this.startCiWatch(run.id, this.store.getRun(run.id).ciWait);
1723
+ continue;
1724
+ }
1725
+ if (run.ciWait)
1726
+ this.withdrawCiWait(run.id);
1479
1727
  if (run.status === 'queued') {
1480
1728
  await this.reviveQueuedRun(run, 'cezar restarted');
1481
1729
  continue;
@@ -1573,6 +1821,12 @@ export class RunManager {
1573
1821
  this.recovering = false;
1574
1822
  }
1575
1823
  this.reconcileWorkerWaits();
1824
+ for (const run of this.store.listRuns()) {
1825
+ if (run.ciWait && !['running', 'waiting', 'queued'].includes(run.status))
1826
+ this.withdrawCiWait(run.id);
1827
+ else if (run.ciWait)
1828
+ this.queueCiWake(run.id);
1829
+ }
1576
1830
  this.reconcileAutoResumes();
1577
1831
  void this.pump();
1578
1832
  }
@@ -1589,7 +1843,7 @@ export class RunManager {
1589
1843
  }
1590
1844
  isDisposedDelegatedRun(runId) {
1591
1845
  const role = this.store.getRun(runId)?.delegation?.role;
1592
- return this.disposed && (role === 'root' || role === 'worker');
1846
+ return this.disposed && (role === 'root' || role === 'worker' || !!this.store.getRun(runId)?.ciWait);
1593
1847
  }
1594
1848
  /** Shutdown is not completion. Explicit terminal controls retain their precedence. */
1595
1849
  preserveRunAfterDisposal(runId, state) {
@@ -1599,6 +1853,7 @@ export class RunManager {
1599
1853
  dropActive(runId) {
1600
1854
  const state = this.active.get(runId);
1601
1855
  state?.releaseRepoRoot?.();
1856
+ state?.revokeCiTools?.();
1602
1857
  if (state) {
1603
1858
  state.releaseRepoRoot = undefined;
1604
1859
  this.clearIdleTimer(state);
@@ -1608,9 +1863,17 @@ export class RunManager {
1608
1863
  this.monitoring.delete(runId);
1609
1864
  this.workerWaiting.delete(runId);
1610
1865
  this.workerWakeAdmitted.delete(runId);
1866
+ this.ciWakeAdmitted.delete(runId);
1867
+ if (this.ciWakeQueuedAt.delete(runId)) {
1868
+ const index = this.queue.indexOf(runId);
1869
+ if (index >= 0)
1870
+ this.queue.splice(index, 1);
1871
+ }
1611
1872
  if (state)
1612
1873
  this.clearMonitoringWakeTimer(state, runId);
1613
1874
  this.active.delete(runId);
1875
+ if (!this.disposed && this.store.getRun(runId)?.ciWait)
1876
+ this.queueCiWake(runId);
1614
1877
  if (!this.executions.has(runId) && this.store.getRun(runId)?.stopping)
1615
1878
  this.store.updateRun(runId, { stopping: undefined });
1616
1879
  this.memoryPausing.delete(runId);
@@ -2062,6 +2325,8 @@ export class RunManager {
2062
2325
  this.store.commitWorkerExecutionComplete(runId, proof.generation);
2063
2326
  }
2064
2327
  }
2328
+ const hadCiWait = !!this.store.getRun(runId)?.ciWait;
2329
+ this.withdrawCiWait(runId);
2065
2330
  const cancelledFinish = this.store.commitRootFinishCancellation(runId);
2066
2331
  this.withdrawWorkerWait(runId);
2067
2332
  // Still waiting in the queue: just drop it there.
@@ -2087,7 +2352,7 @@ export class RunManager {
2087
2352
  if (cancelledFinish)
2088
2353
  return true;
2089
2354
  const run = this.store.getRun(runId);
2090
- if (run?.delegation && ['queued', 'running', 'waiting'].includes(run.status)) {
2355
+ if (run && (run.delegation || hadCiWait) && ['queued', 'running', 'waiting'].includes(run.status)) {
2091
2356
  this.store.updateRun(runId, { status: 'cancelled', finishedAt: new Date().toISOString() });
2092
2357
  return true;
2093
2358
  }
@@ -2096,6 +2361,8 @@ export class RunManager {
2096
2361
  state.cancelled = true;
2097
2362
  this.store.updateRun(runId, { stopping: true });
2098
2363
  this.store.flush();
2364
+ state.revokeCiTools?.();
2365
+ state.revokeCiTools = undefined;
2099
2366
  state.revokeDelegation?.();
2100
2367
  state.revokeDelegation = undefined;
2101
2368
  this.clearIdleTimer(state);
@@ -2251,7 +2518,9 @@ export class RunManager {
2251
2518
  if (!run || run.stopping || this.workerExecutionStopped(runId) || this.executionBlockedByRootFinish(run))
2252
2519
  return null;
2253
2520
  const message = this.toQueuedMessage(runId, content);
2254
- if (this.workerWait(runId))
2521
+ if (run.ciWait)
2522
+ this.withdrawCiWait(runId, message);
2523
+ else if (this.workerWait(runId))
2255
2524
  this.withdrawWorkerWait(runId, message);
2256
2525
  else
2257
2526
  this.store.updateRun(runId, { queuedMessages: [...(run.queuedMessages ?? []), message] });
@@ -2381,8 +2650,10 @@ export class RunManager {
2381
2650
  return false;
2382
2651
  // The pending maps were removed only AFTER hydration. This entry therefore
2383
2652
  // needs live delivery, never a second fold into the already-created opening.
2384
- const message = this.workerWait(runId) ? this.toQueuedMessage(runId, content) : undefined;
2385
- if (message)
2653
+ const message = this.workerWait(runId) || run.ciWait ? this.toQueuedMessage(runId, content) : undefined;
2654
+ if (run.ciWait)
2655
+ this.withdrawCiWait(runId, message);
2656
+ else if (message)
2386
2657
  this.withdrawWorkerWait(runId, message);
2387
2658
  const pending = this.deferredMessages.get(runId) ?? [];
2388
2659
  pending.push({ content, ...(message ? { messageId: message.id } : {}) });
@@ -2533,10 +2804,9 @@ export class RunManager {
2533
2804
  const parsed = workerWaitRequestSchema.parse(request);
2534
2805
  const run = this.store.getRun(parentId);
2535
2806
  const state = this.active.get(parentId);
2536
- if (run?.delegation?.role !== 'root' || run.delegation.historyDeletion || !['running', 'waiting'].includes(run.status) ||
2537
- !state?.session?.open || state.cancelled || state.pendingHumanAsk || this.hasPendingHumanAsk(parentId) || run.delegation.wait || run.delegation.finishRequestedAt) {
2538
- throw new DelegationPolicyError('incompatible_state', 'parent cannot register a worker wait');
2539
- }
2807
+ this.assertCanRegisterWorkerWait(parentId);
2808
+ if (run?.delegation?.role !== 'root' || !state)
2809
+ throw new DelegationPolicyError('denied_scope', 'Only a parent may wait for owned workers');
2540
2810
  for (const id of parsed.workerIds) {
2541
2811
  const worker = this.store.getRun(id);
2542
2812
  if (worker?.delegation?.role !== 'worker' || worker.delegation.parentRunId !== parentId ||
@@ -2553,15 +2823,45 @@ export class RunManager {
2553
2823
  this.parkWorkerWait(parentId, state);
2554
2824
  return this.workerWait(parentId);
2555
2825
  }
2826
+ assertCanRegisterWorkerWait(runId) {
2827
+ const run = this.store.getRun(runId);
2828
+ const state = this.active.get(runId);
2829
+ const refuse = (message) => { throw new DelegationPolicyError('incompatible_state', message); };
2830
+ if (!run?.delegation || run.delegation.role === 'invalid')
2831
+ refuse('Worker waits require an owned worker or parent run');
2832
+ if (this.historyDeletionPending(runId))
2833
+ refuse('History deletion is pending; finish deletion before registering a wait');
2834
+ if (this.executionBlockedByRootFinish(run))
2835
+ refuse('Parent finish is pending; a finishing parent cannot register a wait');
2836
+ if (state?.pendingHumanAsk || this.hasUnansweredHumanAsk(runId))
2837
+ refuse('A human question is pending; answer it before registering a wait');
2838
+ if (run?.ciWait)
2839
+ refuse('A CI wait is already active; finish or withdraw it before registering a worker wait');
2840
+ const wait = this.workerWait(runId);
2841
+ if (wait)
2842
+ refuse(`Wait ${wait.id} is already active; keep it or run worker cancel-wait ${wait.id} before registering another`);
2843
+ if (!['running', 'waiting'].includes(run.status) || !state?.session?.open || state.cancelled) {
2844
+ refuse(`Run is ${run.status} with no available active session; register a wait from an executing parent or worker`);
2845
+ }
2846
+ }
2847
+ /** An answer already executing in this generation is not an unanswered question.
2848
+ * Keep the durable checkpoint at turn-end for crash recovery, and match its exact
2849
+ * sequence so it can never hide a newer question. */
2850
+ hasUnansweredHumanAsk(runId) {
2851
+ const seq = this.pendingHumanAskSeq(runId);
2852
+ const state = this.active.get(runId);
2853
+ return seq !== undefined && !(state?.session?.open && !state.cancelled && state.openingAnswerAskSeq === seq);
2854
+ }
2556
2855
  /** Both roles can select their own requests without acquiring worker-management authority. */
2557
2856
  registerRequestWait(runId, request) {
2558
2857
  const parsed = requestWaitRequestSchema.parse(request);
2559
2858
  this.reconcileWorkerWaits();
2560
2859
  const run = this.store.getRun(runId);
2561
2860
  const state = this.active.get(runId);
2861
+ this.assertCanRegisterWorkerWait(runId);
2562
2862
  if (!run?.delegation || run.delegation.role === 'invalid' || this.historyDeletionPending(runId) ||
2563
2863
  this.executionBlockedByRootFinish(run) || !['running', 'waiting'].includes(run.status) ||
2564
- !state?.session?.open || state.cancelled || state.pendingHumanAsk || this.hasPendingHumanAsk(runId) || run.delegation.wait) {
2864
+ !state?.session?.open || state.cancelled) {
2565
2865
  throw new DelegationPolicyError('incompatible_state', 'run cannot register a request wait');
2566
2866
  }
2567
2867
  const root = run.delegation.role === 'root' ? run : this.store.getRun(run.delegation.parentRunId);
@@ -2592,6 +2892,12 @@ export class RunManager {
2592
2892
  this.executionBlockedByRootFinish(run) || !['queued', 'running', 'waiting'].includes(run.status) ||
2593
2893
  this.workerExecutionStopped(runId) || this.hasPendingHumanAsk(runId))
2594
2894
  return;
2895
+ // Retain conversation input until the registered CI wait receives scheduler admission.
2896
+ // Converting this monitor into a worker wake would strand both admission paths.
2897
+ if (run.ciWait) {
2898
+ this.queueCiWake(runId);
2899
+ return;
2900
+ }
2595
2901
  const input = run.agentInputs?.find(entry => entry.conversation && !entry.deliveredAt);
2596
2902
  if (!input)
2597
2903
  return;
@@ -2788,14 +3094,19 @@ export class RunManager {
2788
3094
  const root = parent.delegation.role === 'root' ? parent : this.store.getRun(parent.delegation.parentRunId);
2789
3095
  const requests = root?.delegation?.role === 'root' ? root.delegation.conversation?.outcomes ?? [] : [];
2790
3096
  let next = reconcileWorkerWait(selected, outcomes, now, requests);
2791
- const incoming = parent.agentInputs?.find(input => input.conversation && !input.deliveredAt &&
3097
+ // The opening prompt (also during recovery) and a reserved submission are
3098
+ // already being processed. Their delayed receipts cannot interrupt a wait
3099
+ // registered by that very turn. Only inputs still awaiting a turn can.
3100
+ const flight = this.active.get(parent.id)?.agentInputFlight;
3101
+ const pendingInputs = parent.agentInputs?.filter(input => !input.deliveredAt &&
3102
+ input.id !== parent.continuationMessage?.agentInputId && !flight?.inputIds.includes(input.id)) ?? [];
3103
+ const incoming = pendingInputs.find(input => input.conversation &&
2792
3104
  !(input.conversation.kind === 'reply' && input.conversation.requestId && wait.requestIds?.includes(input.conversation.requestId)));
2793
3105
  if (incoming && next.phase !== 'wake-pending')
2794
3106
  next = { ...next, phase: 'wake-pending', reason: 'message', wakeId: incoming.id };
2795
3107
  // A full queue of selected replies still needs admission. Use the last
2796
3108
  // already accepted input as the checkpoint rather than adding a 33rd input.
2797
3109
  // Its content is preserved; delivery includes the wait receipt separately.
2798
- const pendingInputs = parent.agentInputs?.filter(input => !input.deliveredAt) ?? [];
2799
3110
  if (next.phase === 'wake-pending' && pendingInputs.length >= 32 &&
2800
3111
  !parent.agentInputs?.some(input => input.id === next.wakeId)) {
2801
3112
  next = { ...next, wakeId: pendingInputs.at(-1).id };
@@ -2989,11 +3300,11 @@ export class RunManager {
2989
3300
  this.flushAgentInputs(runId);
2990
3301
  }
2991
3302
  /** Reservation is synchronous; only an acknowledged transport may checkpoint delivery. */
2992
- submitAgentInput(runId, state, content, inputId) {
3303
+ submitAgentInput(runId, state, content, inputIds = []) {
2993
3304
  const session = state.session;
2994
3305
  if (!session?.open || state.agentInputFlight || state.cancelled || state.finishRequested || state.agentInputError || state.pendingHumanAsk)
2995
3306
  return false;
2996
- const flight = { session, inputId };
3307
+ const flight = { session, inputIds };
2997
3308
  state.agentInputFlight = flight;
2998
3309
  const current = () => !this.disposed && this.active.get(runId) === state && state.session === session &&
2999
3310
  state.agentInputFlight === flight && !state.cancelled && !state.finishRequested && !this.workerExecutionStopped(runId) &&
@@ -3036,13 +3347,24 @@ export class RunManager {
3036
3347
  if (!current())
3037
3348
  return;
3038
3349
  try {
3039
- if (inputId) {
3350
+ if (inputIds.length) {
3040
3351
  const queue = this.store.getRun(runId)?.agentInputs ?? [];
3041
- if (!queue.some(input => input.id === inputId && !input.deliveredAt))
3352
+ if (!queue.some(input => inputIds.includes(input.id) && !input.deliveredAt))
3042
3353
  return;
3043
- this.store.commitAgentInputs(runId, queue.map(input => input.id === inputId
3044
- ? { ...input, deliveredAt: new Date().toISOString() } : input));
3045
- if (this.workerWait(runId)?.wakeId === inputId)
3354
+ const ciWakeId = this.store.getRun(runId)?.ciWait?.wakeId;
3355
+ if (ciWakeId && inputIds.includes(ciWakeId)) {
3356
+ // CI lifecycle input owns its turn; never retire a conversation batch with it.
3357
+ if (inputIds.length !== 1)
3358
+ throw new Error('CI observation must be delivered separately');
3359
+ this.store.commitCiWaitDelivery(runId, ciWakeId);
3360
+ this.ciWakeAdmitted.delete(runId);
3361
+ }
3362
+ else {
3363
+ const deliveredAt = new Date().toISOString();
3364
+ this.store.commitAgentInputs(runId, queue.map(input => inputIds.includes(input.id) && !input.deliveredAt
3365
+ ? { ...input, deliveredAt } : input));
3366
+ }
3367
+ if (inputIds.includes(this.workerWait(runId)?.wakeId ?? ''))
3046
3368
  this.withdrawWorkerWait(runId);
3047
3369
  this.reconcileWorkerWaits();
3048
3370
  }
@@ -3059,7 +3381,7 @@ export class RunManager {
3059
3381
  return;
3060
3382
  if (this.flushAgentInputs(runId))
3061
3383
  return;
3062
- if (state.atTurnBoundary !== session || state.pendingHumanAsk || this.workerWait(runId) || this.hasQueuedAgentInputs(runId))
3384
+ if (state.atTurnBoundary !== session || state.pendingHumanAsk || this.workerWait(runId) || this.store.getRun(runId)?.ciWait || this.hasQueuedAgentInputs(runId))
3063
3385
  return;
3064
3386
  if (state.doneAtBoundary === session) {
3065
3387
  if (this.deferParentCompletion(runId))
@@ -3105,8 +3427,10 @@ export class RunManager {
3105
3427
  deliveries?.add(flight.settled);
3106
3428
  void flight.settled.then(() => deliveries?.delete(flight.settled));
3107
3429
  this.resumeParkedRun(runId, state);
3108
- if (inputId && this.workerWait(runId)?.wakeId === inputId)
3430
+ if (inputIds.includes(this.workerWait(runId)?.wakeId ?? ''))
3109
3431
  state.workerWakeTurn = session;
3432
+ if (inputIds.includes(this.store.getRun(runId)?.ciWait?.wakeId ?? ''))
3433
+ state.ciWakeTurn = session;
3110
3434
  return true;
3111
3435
  }
3112
3436
  /** False leaves the durable entry untouched. No human-answer/slash-expansion fallback. */
@@ -3119,9 +3443,23 @@ export class RunManager {
3119
3443
  (this.workerWait(runId)?.phase === 'registered') ||
3120
3444
  (this.workerWait(runId)?.phase === 'wake-pending' && !this.workerWakeAdmitted.has(runId)))
3121
3445
  return false;
3122
- const input = nextAgentInput(run.agentInputs ?? [], state.pendingHumanAsk);
3123
- if (!input)
3446
+ if (state.pendingHumanAsk || this.hasUnansweredHumanAsk(runId))
3447
+ return false;
3448
+ // The opening prompt already carries this input. It must not be resubmitted
3449
+ // by a readiness callback before the first successful turn checkpoints it.
3450
+ if (state.openingAgentInputId)
3124
3451
  return false;
3452
+ if (run.ciWait) {
3453
+ if (!this.ciWakeAdmitted.has(runId) || state.atTurnBoundary !== state.session)
3454
+ return false;
3455
+ // Preserve CI admission and atomic retirement; conversations batch on the next turn.
3456
+ const input = run.agentInputs?.find(entry => entry.id === run.ciWait?.wakeId && !entry.deliveredAt);
3457
+ return !!input && this.submitAgentInput(runId, state, [{ type: 'text', text: this.formatAgentInput(runId, input) }], [input.id]);
3458
+ }
3459
+ const batch = agentInputBatch(run.agentInputs ?? [], input => this.formatAgentInput(runId, input));
3460
+ return !!batch && this.submitAgentInput(runId, state, [{ type: 'text', text: batch.text }], batch.inputs.map(input => input.id));
3461
+ }
3462
+ formatAgentInput(runId, input) {
3125
3463
  const attribution = input.conversation;
3126
3464
  const text = attribution
3127
3465
  ? `Agent conversation ${JSON.stringify({ id: input.id, ...attribution })}${this.workerWait(runId)?.reason === 'message' ? `; interrupted wait ${this.workerWait(runId).id} to receive this message; outstanding requests remain pending` : ''}\n${input.text}`
@@ -3129,7 +3467,12 @@ export class RunManager {
3129
3467
  const wait = this.workerWait(runId);
3130
3468
  const receipt = wait?.wakeId === input.id && wait.wakeId !== wait.id && wait.reason !== 'message'
3131
3469
  ? `\nWait ${wait.id} (${wait.reason}); request outcomes: ${JSON.stringify(wait.requestOutcomes ?? [])}; worker outcomes: ${JSON.stringify(wait.outcomes.map(outcome => ({ ...outcome, ...(outcome.summary ? { summary: outcome.summary.slice(0, 256) } : {}) })))}. This is not review approval.` : '';
3132
- return this.submitAgentInput(runId, state, [{ type: 'text', text: text + receipt }], input.id);
3470
+ return text + receipt + this.agentInputOutcomeText(input);
3471
+ }
3472
+ agentInputOutcomeText(input) {
3473
+ const root = this.store.getRun(input.parentRunId);
3474
+ const outcome = input.conversation && root?.delegation?.role === 'root' ? root.delegation.conversation?.outcomes.find(entry => entry.requestId === (input.conversation?.requestId ?? input.id)) : undefined;
3475
+ return outcome ? `\nRequest outcome: ${outcome.status}. This delivery does not reopen the settled request.` : '';
3133
3476
  }
3134
3477
  /** Deliver anything `deferMessage` buffered, once the session is live. */
3135
3478
  flushDeferred(runId) {
@@ -3208,6 +3551,8 @@ export class RunManager {
3208
3551
  const run = this.store.getRun(runId);
3209
3552
  if (!run || run.stopping || this.workerExecutionStopped(runId) || this.executionBlockedByRootFinish(run))
3210
3553
  return false;
3554
+ if (!userAuthored && run.ciWait)
3555
+ return false;
3211
3556
  const text = content
3212
3557
  .filter((b) => b.type === 'text')
3213
3558
  .map((b) => b.text)
@@ -3217,7 +3562,10 @@ export class RunManager {
3217
3562
  // these as user attachments (vs. agent tool screenshots) on disk (#357).
3218
3563
  const persisted = userAuthored ? this.persistPastedAttachments(runId, content) : [];
3219
3564
  const images = persisted.map((saved) => saved.url);
3565
+ const ciHumanMessage = userAuthored && run.ciWait
3566
+ ? { id: randomUUID(), text, images, createdAt: new Date().toISOString() } : undefined;
3220
3567
  if (userAuthored) {
3568
+ this.withdrawCiWait(runId, ciHumanMessage);
3221
3569
  this.withdrawWorkerWait(runId);
3222
3570
  this.store.appendEvent(runId, {
3223
3571
  type: 'user-message',
@@ -3240,6 +3588,8 @@ export class RunManager {
3240
3588
  : this.submitAgentInput(runId, state, deliverable);
3241
3589
  if (delivered) {
3242
3590
  if (userAuthored) {
3591
+ if (ciHumanMessage)
3592
+ this.store.commitQueuedMessageDelivery(runId, ciHumanMessage.id);
3243
3593
  if (answeringAskSeq !== undefined) {
3244
3594
  this.store.appendEvent(runId, { type: 'human-input-delivered', askSeq: answeringAskSeq });
3245
3595
  }
@@ -3264,6 +3614,7 @@ export class RunManager {
3264
3614
  const state = this.active.get(runId);
3265
3615
  if (decision.action === 'session' && state?.session) {
3266
3616
  try {
3617
+ this.withdrawCiWait(runId);
3267
3618
  this.withdrawWorkerWait(runId);
3268
3619
  }
3269
3620
  catch {
@@ -3348,7 +3699,7 @@ export class RunManager {
3348
3699
  continueRun(runId, opts = {},
3349
3700
  /** Restart recovery may discover several interrupted tasks at once. Those
3350
3701
  * continuations are queued; an explicit user Continue remains immediate. */
3351
- deferForCapacity = false) {
3702
+ deferForCapacity = false, conversation) {
3352
3703
  if (agentModelsLocked(this.repoRoot) && (opts.model?.trim() || opts.effort?.trim())) {
3353
3704
  return { ok: false, error: AGENT_MODELS_LOCKED_ERROR };
3354
3705
  }
@@ -3357,6 +3708,9 @@ export class RunManager {
3357
3708
  const run = this.store.getRun(runId);
3358
3709
  if (!run)
3359
3710
  return { ok: false, error: 'not found' };
3711
+ if (conversation && (!deferForCapacity || run.delegation?.role !== 'worker' || run.delegation.parentRunId !== conversation.rootId ||
3712
+ !['done', 'review', 'failed'].includes(run.status)))
3713
+ return { ok: false, error: 'Only a settled owned worker can be resumed by its parent message' };
3360
3714
  if (run.stopping)
3361
3715
  return { ok: false, error: 'run is still stopping' };
3362
3716
  if (this.historyDeletionPending(runId))
@@ -3397,15 +3751,17 @@ export class RunManager {
3397
3751
  if (this.executionBlockedByRootFinish(run))
3398
3752
  return { ok: false, error: 'parent finish is pending' };
3399
3753
  const answers = deferForCapacity ? [run.continuationMessage, ...(run.queuedMessages ?? [])] : [opts];
3400
- if (pendingHumanAsk && ((deferForCapacity && run.continuationMessage?.origin !== 'human') ||
3754
+ if (pendingHumanAsk && (conversation || (deferForCapacity && run.continuationMessage?.origin !== 'human') ||
3401
3755
  !answers.some(answer => answer?.text?.trim() || answer?.images?.length))) {
3402
3756
  return { ok: false, error: 'pending human question requires an explicit answer' };
3403
3757
  }
3758
+ // A disconnected CI monitor has durable intent but no live session to receive human input.
3759
+ const disconnectedCiWait = !!run.ciWait && !this.isActive(runId) && !hasRegisteredRunProcess(runId);
3404
3760
  // `review` is continuable too — that's the "Send back" path (spec 009).
3405
- if (!['done', 'failed', 'cancelled', 'review'].includes(run.status) && !(pendingHumanAsk && run.status === 'waiting') &&
3761
+ if (!disconnectedCiWait && !['done', 'failed', 'cancelled', 'review'].includes(run.status) && !(pendingHumanAsk && run.status === 'waiting') &&
3406
3762
  !(run.status === 'waiting' && !this.isActive(runId) && ((!hasRegisteredRunProcess(runId) &&
3407
3763
  (run.delegation === undefined || run.delegation.role === 'root')) ||
3408
- (deferForCapacity && run.delegation?.role === 'worker' && !!this.workerWait(runId))))) {
3764
+ (deferForCapacity && run.delegation?.role === 'worker' && (!!this.workerWait(runId) || !!run.ciWait))))) {
3409
3765
  return { ok: false, error: `cannot continue a ${run.status} run` };
3410
3766
  }
3411
3767
  const sessionStep = [...run.steps].reverse().find((s) => s.sessionId);
@@ -3532,6 +3888,7 @@ export class RunManager {
3532
3888
  // bound UNATTENDED resumes.
3533
3889
  this.clearAutoResume(runId);
3534
3890
  if (!deferForCapacity) {
3891
+ this.withdrawCiWait(runId);
3535
3892
  this.withdrawWorkerWait(runId);
3536
3893
  this.resetParentCompletion(runId);
3537
3894
  }
@@ -3542,7 +3899,7 @@ export class RunManager {
3542
3899
  const continuationStep = { id: stepId, name: 'Continue', kind: 'agent', synthetic: 'continuation' };
3543
3900
  if (run.delegation?.role !== 'worker')
3544
3901
  this.store.addStep(runId, continuationStep);
3545
- const recovered = deferForCapacity ? run.continuationMessage : undefined;
3902
+ const recovered = deferForCapacity && !conversation ? run.continuationMessage : undefined;
3546
3903
  const message = recovered ?? this.toQueuedMessage(runId, [
3547
3904
  ...(opts.text?.trim() ? [{ type: 'text', text: opts.text.trim() }] : []),
3548
3905
  ...(opts.images ?? []),
@@ -3552,6 +3909,7 @@ export class RunManager {
3552
3909
  // and the original prompt/URLs must reach disk before the first startup await.
3553
3910
  const acceptedPatch = {
3554
3911
  continuationMessage: { ...message, id: stepId, text: prompt,
3912
+ ...(conversation ? { agentInputId: conversation.input.id } : {}),
3555
3913
  origin: recovered ? recovered.origin : deferForCapacity ? 'lifecycle' : 'human' },
3556
3914
  status: deferForCapacity ? 'queued' : 'running',
3557
3915
  error: undefined,
@@ -3563,7 +3921,7 @@ export class RunManager {
3563
3921
  };
3564
3922
  if (run.delegation?.role === 'worker') {
3565
3923
  try {
3566
- this.store.commitWorkerContinuation(runId, acceptedPatch, continuationStep);
3924
+ this.store.commitWorkerContinuation(runId, acceptedPatch, continuationStep, conversation);
3567
3925
  }
3568
3926
  catch {
3569
3927
  // No session or async launch exists yet. Release the private execution
@@ -3725,6 +4083,12 @@ export class RunManager {
3725
4083
  // blocks before they reach the model). An image ALSO rides along as a base64 block so the
3726
4084
  // model can view it; a file (#950) has nothing to view and travels as its path alone.
3727
4085
  const checkpoint = record?.continuationMessage;
4086
+ state.openingAgentInputId = checkpoint?.agentInputId;
4087
+ const openingInput = record?.agentInputs?.find(input => input.id === checkpoint?.agentInputId);
4088
+ // Hydration has already included later human updates and task edits. Append
4089
+ // the current obligation outcome without replacing that composed prompt.
4090
+ if (openingInput)
4091
+ prompt += this.agentInputOutcomeText(openingInput);
3728
4092
  const saved = this.readPersistedAttachments(runId, checkpoint?.id === stepId ? checkpoint.images ?? [] : [], 'continuation');
3729
4093
  const openingImages = [...(images.length ? contentBlocksOf(images) : saved.blocks), ...persistedImages];
3730
4094
  const attachments = [...saved.attachments, ...persistedAttachments];
@@ -3737,6 +4101,7 @@ export class RunManager {
3737
4101
  ...(attachments.length ? { images: attachments.map((saved) => saved.url) } : {}),
3738
4102
  });
3739
4103
  let openingAnswerAskSeq = checkpoint?.origin === 'human' ? this.pendingHumanAskSeq(runId) : undefined;
4104
+ state.openingAnswerAskSeq = openingAnswerAskSeq;
3740
4105
  state.pendingHumanAsk = openingAnswerAskSeq === undefined && this.hasPendingHumanAsk(runId);
3741
4106
  let stepCost = 0;
3742
4107
  let turnText = '';
@@ -3787,13 +4152,40 @@ export class RunManager {
3787
4152
  sawClaudeScheduleWakeup = true;
3788
4153
  if (event.type === 'turn-end') {
3789
4154
  state.workerWakeTurn = undefined;
4155
+ state.ciWakeTurn = undefined;
3790
4156
  state.atTurnBoundary = state.session;
4157
+ state.ciTurnId = randomUUID();
3791
4158
  // A completed turn acknowledges the opening payload. Until then a restart
3792
4159
  // must replay it, including a crash after startSession but before delivery.
3793
4160
  if (!state.cancelled) {
4161
+ if (state.openingAgentInputId && state.session?.open) {
4162
+ const id = state.openingAgentInputId;
4163
+ const deliveredAt = new Date().toISOString();
4164
+ try {
4165
+ this.store.commitAgentInputs(runId, (this.store.getRun(runId)?.agentInputs ?? []).map(input => input.id === id && !input.deliveredAt ? { ...input, deliveredAt } : input), id);
4166
+ }
4167
+ catch (error) {
4168
+ state.agentInputError = `agent input delivery checkpoint failed: ${error instanceof Error ? error.message : String(error)}`;
4169
+ try {
4170
+ state.session.interrupt();
4171
+ }
4172
+ finally {
4173
+ try {
4174
+ this.store.appendEvent(runId, { type: 'error', message: state.agentInputError });
4175
+ }
4176
+ catch {
4177
+ console.error(`[cez] ${state.agentInputError}`);
4178
+ }
4179
+ }
4180
+ return;
4181
+ }
4182
+ state.openingAgentInputId = undefined;
4183
+ this.reconcileWorkerWaits();
4184
+ }
3794
4185
  if (openingAnswerAskSeq !== undefined && state.session?.open) {
3795
4186
  this.store.appendEvent(runId, { type: 'human-input-delivered', askSeq: openingAnswerAskSeq });
3796
4187
  openingAnswerAskSeq = undefined;
4188
+ state.openingAnswerAskSeq = undefined;
3797
4189
  }
3798
4190
  this.store.updateRun(runId, { continuationMessage: undefined });
3799
4191
  this.store.flush();
@@ -3824,8 +4216,9 @@ export class RunManager {
3824
4216
  this.store.updateRun(runId, { invalidAsk: !ask && askNotes.length > 0 ? true : undefined });
3825
4217
  if (ask)
3826
4218
  this.prepareHumanAsk(runId, state);
3827
- const completionBlocked = !!done && this.deferParentCompletion(runId);
3828
- const workerWaitParked = completionBlocked || (!!sessionOpen && this.parkWorkerWait(runId, state));
4219
+ const ciWaitParked = !!sessionOpen && this.parkCiWait(runId, state);
4220
+ const completionBlocked = !ciWaitParked && !!done && this.deferParentCompletion(runId);
4221
+ const workerWaitParked = ciWaitParked || completionBlocked || (!!sessionOpen && this.parkWorkerWait(runId, state));
3829
4222
  state.doneAtBoundary = done ? state.session : undefined;
3830
4223
  state.parkAfterAck = sessionOpen && state.session ? { session: state.session, monitoring: !!monitoring } : undefined;
3831
4224
  const agentInputDelivered = !ask && !workerWaitParked && this.flushAgentInputs(runId);
@@ -4030,6 +4423,7 @@ export class RunManager {
4030
4423
  throw new Error('Worker stopped before launch');
4031
4424
  }
4032
4425
  const delegation = this.provisionSession(runId, state);
4426
+ const ciTools = await this.provisionCiSession(runId, state);
4033
4427
  try {
4034
4428
  session = runner.startSession({
4035
4429
  // The Continue step is a fresh agent session on the same run — the
@@ -4044,7 +4438,8 @@ export class RunManager {
4044
4438
  allowedTools: grants.allowedTools,
4045
4439
  bashAllowlist: grants.bashAllowlist,
4046
4440
  additionalDirectories: agentDirectories(join(this.dataDir, 'runs'), continueProfile.env),
4047
- env: { ...continueProfile.env, ...delegation?.env },
4441
+ env: { ...continueProfile.env, ...delegation?.env, ...ciTools?.env },
4442
+ ...(ciTools ? { cezarTools: ciTools.descriptor } : {}),
4048
4443
  ...(delegation ? { restrictNativeDelegation: delegation.restrictNativeDelegation } : {}),
4049
4444
  model: continueModel,
4050
4445
  effort: continueEffort,
@@ -4060,6 +4455,8 @@ export class RunManager {
4060
4455
  });
4061
4456
  }
4062
4457
  catch (error) {
4458
+ ciTools?.revoke();
4459
+ state.revokeCiTools = undefined;
4063
4460
  delegation?.revoke();
4064
4461
  state.revokeDelegation = undefined;
4065
4462
  throw error;
@@ -4080,6 +4477,10 @@ export class RunManager {
4080
4477
  if (this.preserveRunAfterDisposal(runId, state))
4081
4478
  return;
4082
4479
  this.persistWorkerAssistantResult(runId, stepId, result.text);
4480
+ if (this.store.getRun(runId)?.ciWait && !state.cancelled) {
4481
+ this.store.updateRun(runId, { status: 'running', activity: 'monitoring' });
4482
+ return;
4483
+ }
4083
4484
  if (sessionError || state.agentInputError)
4084
4485
  throw new Error(sessionError ?? state.agentInputError);
4085
4486
  if (!state.cancelled) {
@@ -4149,6 +4550,10 @@ export class RunManager {
4149
4550
  if (this.preserveRunAfterDisposal(runId, state))
4150
4551
  return;
4151
4552
  // Keep the provider failure that triggered teardown, even if teardown rejects.
4553
+ if (this.store.getRun(runId)?.ciWait && !state.cancelled) {
4554
+ this.store.updateRun(runId, { status: 'running', activity: 'monitoring' });
4555
+ return;
4556
+ }
4152
4557
  const message = sessionError ?? state.agentInputError ?? (err instanceof Error ? err.message : String(err));
4153
4558
  sink.sessionEnded('error', message);
4154
4559
  this.store.updateStep(runId, stepId, { status: 'failed', error: message, finishedAt: finishedAt() });
@@ -4162,6 +4567,8 @@ export class RunManager {
4162
4567
  this.store.appendEvent(runId, { type: 'lifecycle', message: `continue failed — ${message}` });
4163
4568
  }
4164
4569
  finally {
4570
+ ciTools?.revoke();
4571
+ state.revokeCiTools = undefined;
4165
4572
  delegation?.revoke();
4166
4573
  state.revokeDelegation = undefined;
4167
4574
  this.recordUsagePeaks(runId);
@@ -4463,6 +4870,11 @@ export class RunManager {
4463
4870
  this.requeueWhileHeld(runId, workflow, input, taskBackend, state, i);
4464
4871
  return;
4465
4872
  }
4873
+ if (this.store.getRun(runId)?.ciWait && !state.cancelled) {
4874
+ this.store.updateRun(runId, { status: 'running', activity: 'monitoring' });
4875
+ this.dropActive(runId);
4876
+ return;
4877
+ }
4466
4878
  if (failure) {
4467
4879
  this.finishStep(runId, step.id, 'failed', failure, emit);
4468
4880
  runError = `step "${step.id}" failed: ${failure}`;
@@ -4671,7 +5083,9 @@ export class RunManager {
4671
5083
  sawClaudeScheduleWakeup = true;
4672
5084
  if (event.type === 'turn-end') {
4673
5085
  state.workerWakeTurn = undefined;
5086
+ state.ciWakeTurn = undefined;
4674
5087
  state.atTurnBoundary = state.session;
5088
+ state.ciTurnId = randomUUID();
4675
5089
  // v2 `turn.completed` already flushed the coalescers; the v1 turn
4676
5090
  // boundary flushes again (idempotent) as a backstop.
4677
5091
  sink.flushAll();
@@ -4680,7 +5094,7 @@ export class RunManager {
4680
5094
  const done = interactive && sessionOpen && DONE_MARKER_RE.test(turnText.trimEnd());
4681
5095
  // `CEZ:ASK` → the user is blocked; wins over `CEZ:MONITORING`, loses to
4682
5096
  // `CEZ:DONE` (#473).
4683
- const askTurn = resolveAskTurn(turnText, completedAssistantText, Boolean((interactive || this.workerWait(runId)) && sessionOpen) && !done);
5097
+ const askTurn = resolveAskTurn(turnText, completedAssistantText, Boolean((interactive || this.workerWait(runId) || this.store.getRun(runId)?.ciWait) && sessionOpen) && !done);
4684
5098
  const { ask, notes: askNotes } = askTurn;
4685
5099
  discardQueuedMessagesOnAsk(state.session, askTurn);
4686
5100
  const monitoring = !this.parentCompletionAttention(runId) &&
@@ -4698,8 +5112,9 @@ export class RunManager {
4698
5112
  this.store.updateRun(runId, { invalidAsk: !ask && askNotes.length > 0 ? true : undefined });
4699
5113
  if (ask)
4700
5114
  this.prepareHumanAsk(runId, state);
4701
- const completionBlocked = !!done && this.deferParentCompletion(runId);
4702
- const workerWaitParked = completionBlocked || (!!sessionOpen && this.parkWorkerWait(runId, state));
5115
+ const ciWaitParked = !!sessionOpen && this.parkCiWait(runId, state);
5116
+ const completionBlocked = !ciWaitParked && !!done && this.deferParentCompletion(runId);
5117
+ const workerWaitParked = ciWaitParked || completionBlocked || (!!sessionOpen && this.parkWorkerWait(runId, state));
4703
5118
  state.doneAtBoundary = done ? state.session : undefined;
4704
5119
  state.parkAfterAck = interactive && sessionOpen && state.session ? { session: state.session, monitoring: !!monitoring } : undefined;
4705
5120
  const agentInputDelivered = !ask && !workerWaitParked && this.flushAgentInputs(runId);
@@ -4820,6 +5235,7 @@ export class RunManager {
4820
5235
  state.currentStepId = step.id;
4821
5236
  this.beginUsageInvocation(runId, state, step.id);
4822
5237
  const delegation = this.provisionSession(runId, state);
5238
+ const ciTools = await this.provisionCiSession(runId, state);
4823
5239
  try {
4824
5240
  if (this.workerExecutionStopped(runId)) {
4825
5241
  state.cancelled = true;
@@ -4838,7 +5254,8 @@ export class RunManager {
4838
5254
  bashAllowlist: grants.bashAllowlist,
4839
5255
  // The handoff file lives outside the worktree — grant access.
4840
5256
  additionalDirectories: agentDirectories(join(this.dataDir, 'runs'), stepProfile.env),
4841
- env: { ...stepProfile.env, ...delegation?.env },
5257
+ env: { ...stepProfile.env, ...delegation?.env, ...ciTools?.env },
5258
+ ...(ciTools ? { cezarTools: ciTools.descriptor } : {}),
4842
5259
  ...(delegation ? { restrictNativeDelegation: delegation.restrictNativeDelegation } : {}),
4843
5260
  model: backendModel,
4844
5261
  effort: effectiveEffort,
@@ -4848,7 +5265,7 @@ export class RunManager {
4848
5265
  }, onEvent, {
4849
5266
  autoEndAfterFirstTurn: !interactive,
4850
5267
  shouldAutoEnd: () => this.active.get(runId) !== state || state.session !== session ||
4851
- (!this.workerWait(runId) && !this.parentCompletionPending(runId) && state.workerWakeTurn !== session && !state.agentInputFlight),
5268
+ (!this.workerWait(runId) && !this.store.getRun(runId)?.ciWait && !this.parentCompletionPending(runId) && state.workerWakeTurn !== session && state.ciWakeTurn !== session && !state.agentInputFlight),
4852
5269
  onUiEvent: (event) => {
4853
5270
  completedAssistantText = appendCompletedAssistantText(completedAssistantText, event);
4854
5271
  this.handleRunnerUiEvent(runId, state, sink, event);
@@ -4857,6 +5274,8 @@ export class RunManager {
4857
5274
  });
4858
5275
  }
4859
5276
  catch (err) {
5277
+ ciTools?.revoke();
5278
+ state.revokeCiTools = undefined;
4860
5279
  delegation?.revoke();
4861
5280
  state.revokeDelegation = undefined;
4862
5281
  state.currentStepId = undefined;
@@ -4906,6 +5325,8 @@ export class RunManager {
4906
5325
  return message;
4907
5326
  }
4908
5327
  finally {
5328
+ ciTools?.revoke();
5329
+ state.revokeCiTools = undefined;
4909
5330
  delegation?.revoke();
4910
5331
  state.revokeDelegation = undefined;
4911
5332
  this.recordUsagePeaks(runId);
@@ -4921,6 +5342,7 @@ export class RunManager {
4921
5342
  this.queue.splice(at, 1);
4922
5343
  }
4923
5344
  state.workerWakeTurn = undefined;
5345
+ state.ciWakeTurn = undefined;
4924
5346
  state.atTurnBoundary = undefined;
4925
5347
  state.doneAtBoundary = undefined;
4926
5348
  state.parkAfterAck = undefined;
@@ -4962,6 +5384,7 @@ export class RunManager {
4962
5384
  /** Ask precedence also releases an admitted but not yet delivered worker wake.
4963
5385
  * Its durable input/wait remain queued until an actual human answer. */
4964
5386
  prepareHumanAsk(runId, state) {
5387
+ this.withdrawCiWait(runId);
4965
5388
  state.pendingHumanAsk = true;
4966
5389
  this.workerWaiting.delete(runId);
4967
5390
  this.workerWakeAdmitted.delete(runId);
@@ -5426,6 +5849,10 @@ export class RunManager {
5426
5849
  const run = this.store.getRun(runId);
5427
5850
  if (!run || run.stopping || this.workerExecutionStopped(runId) || this.executionBlockedByRootFinish(run))
5428
5851
  return;
5852
+ if (run.ciWait) {
5853
+ this.clearMonitoringWakeTimer(state, runId);
5854
+ return;
5855
+ }
5429
5856
  const minutes = this.semaphore.monitoringWakeIntervalMinutes();
5430
5857
  if (minutes === null) {
5431
5858
  this.clearMonitoringWakeTimer(state, runId);