@fitlab-ai/agent-infra 0.9.5 → 0.9.6

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 (106) hide show
  1. package/dist/bin/internal-cli.js +3 -3
  2. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +126 -16
  3. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +14 -2
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +48 -4
  5. package/dist/lib/agent-clients/adapters/codex.js +1 -1
  6. package/dist/lib/git/workflow.js +0 -1
  7. package/dist/lib/internal/codex-lifecycle.js +100 -2
  8. package/dist/lib/internal/release-workflow.js +108 -11
  9. package/dist/lib/internal/sandbox-control.js +43 -6
  10. package/dist/lib/internal/task-create.js +12 -4
  11. package/dist/lib/internal/task-orchestration.js +5 -4
  12. package/dist/lib/platform/issue-comments.js +2 -1
  13. package/dist/lib/release/workflow.js +29 -21
  14. package/dist/lib/sandbox/commands/create.js +15 -16
  15. package/dist/lib/sandbox/commands/prune.js +40 -16
  16. package/dist/lib/sandbox/commands/rm.js +318 -89
  17. package/dist/lib/sandbox/control/client.js +138 -22
  18. package/dist/lib/sandbox/control/executor.js +136 -0
  19. package/dist/lib/sandbox/control/lifecycle.js +348 -0
  20. package/dist/lib/sandbox/control/protocol.js +62 -26
  21. package/dist/lib/sandbox/control/server.js +222 -102
  22. package/dist/lib/sandbox/control/state.js +181 -0
  23. package/dist/lib/sandbox/index.js +2 -2
  24. package/dist/lib/sandbox/managed-fs.js +14 -2
  25. package/dist/lib/sandbox/mounts.js +35 -24
  26. package/dist/lib/sandbox/recovery.js +262 -45
  27. package/dist/lib/sandbox/workspace-view.js +73 -10
  28. package/dist/lib/sandbox/worktree-safety.js +196 -0
  29. package/dist/lib/server/process-control.js +6 -0
  30. package/dist/lib/task/artifact-lifecycle.js +5 -4
  31. package/dist/lib/task/codex-orchestration.js +161 -0
  32. package/dist/lib/task/commands/status.js +3 -0
  33. package/dist/lib/task/commands/validate.js +289 -0
  34. package/dist/lib/task/commit-finalization.js +36 -9
  35. package/dist/lib/task/delegation-receipts.js +25 -0
  36. package/dist/lib/task/events.js +10 -4
  37. package/dist/lib/task/finalization-artifacts.js +2 -1
  38. package/dist/lib/task/implementation-inputs.js +1 -7
  39. package/dist/lib/task/index.js +9 -0
  40. package/dist/lib/task/orchestration.js +133 -4
  41. package/dist/lib/task/verification-engine.js +38 -6
  42. package/dist/lib/task/verification.js +1 -0
  43. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +142 -15
  44. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +19 -3
  45. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +51 -3
  46. package/lib/agent-clients/adapters/codex.ts +1 -1
  47. package/lib/git/workflow.ts +0 -1
  48. package/lib/internal/codex-lifecycle.ts +107 -3
  49. package/lib/internal/release-workflow.ts +107 -11
  50. package/lib/internal/sandbox-control.ts +38 -6
  51. package/lib/internal/task-create.ts +16 -4
  52. package/lib/internal/task-orchestration.ts +4 -4
  53. package/lib/platform/issue-comments.ts +2 -1
  54. package/lib/release/workflow.ts +69 -20
  55. package/lib/sandbox/commands/create.ts +17 -21
  56. package/lib/sandbox/commands/prune.ts +53 -15
  57. package/lib/sandbox/commands/rm.ts +368 -103
  58. package/lib/sandbox/control/client.ts +152 -28
  59. package/lib/sandbox/control/executor.ts +163 -0
  60. package/lib/sandbox/control/lifecycle.ts +342 -0
  61. package/lib/sandbox/control/protocol.ts +96 -69
  62. package/lib/sandbox/control/server.ts +239 -110
  63. package/lib/sandbox/control/state.ts +196 -0
  64. package/lib/sandbox/index.ts +2 -2
  65. package/lib/sandbox/managed-fs.ts +17 -2
  66. package/lib/sandbox/mounts.ts +31 -13
  67. package/lib/sandbox/recovery.ts +301 -60
  68. package/lib/sandbox/workspace-view.ts +81 -11
  69. package/lib/sandbox/worktree-safety.ts +234 -0
  70. package/lib/server/process-control.ts +11 -0
  71. package/lib/task/artifact-lifecycle.ts +5 -4
  72. package/lib/task/codex-orchestration.ts +233 -0
  73. package/lib/task/commands/status.ts +2 -0
  74. package/lib/task/commands/validate.ts +282 -0
  75. package/lib/task/commit-finalization.ts +52 -9
  76. package/lib/task/delegation-receipts.ts +52 -3
  77. package/lib/task/events.ts +9 -4
  78. package/lib/task/finalization-artifacts.ts +2 -1
  79. package/lib/task/implementation-inputs.ts +1 -5
  80. package/lib/task/index.ts +8 -0
  81. package/lib/task/orchestration.ts +199 -4
  82. package/lib/task/verification-engine.ts +42 -8
  83. package/lib/task/verification.ts +2 -1
  84. package/package.json +1 -1
  85. package/templates/.agents/hooks/lifecycle-delegation.js +48 -12
  86. package/templates/.agents/rules/lifecycle-orchestration.en.md +1 -1
  87. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +1 -1
  88. package/templates/.agents/rules/no-mid-flow-questions.en.md +1 -0
  89. package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +1 -0
  90. package/templates/.agents/skills/post-release/SKILL.en.md +32 -6
  91. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +32 -6
  92. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +42 -0
  93. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +42 -0
  94. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +14 -0
  95. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +14 -0
  96. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +44 -0
  97. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +44 -0
  98. package/templates/.agents/skills/run-task/SKILL.en.md +2 -2
  99. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +2 -2
  100. package/templates/.agents/skills/run-task/reference/host-validation.en.md +3 -3
  101. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +3 -3
  102. package/templates/.claude/commands/run-manual-validation.en.md +9 -0
  103. package/templates/.claude/commands/run-manual-validation.zh-CN.md +9 -0
  104. package/templates/.codex/hooks.json +8 -8
  105. package/templates/.opencode/commands/run-manual-validation.en.md +11 -0
  106. package/templates/.opencode/commands/run-manual-validation.zh-CN.md +11 -0
@@ -159,6 +159,22 @@ function resolveProjectPolicy(repoRoot, client) {
159
159
  const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
160
160
  return normalizeAgentClients(raw).state[client].orchestration;
161
161
  }
162
+ function canRecoverCodexUnsupportedPause(run, taskDir) {
163
+ return run.schemaVersion === 2
164
+ && run.status === 'paused'
165
+ && run.pause?.code === 'ORCHESTRATION_CLIENT_UNSUPPORTED'
166
+ && run.modelPolicySource?.client === 'codex'
167
+ && run.stepCount === 0
168
+ && run.nextStage === null
169
+ && run.baseline === ''
170
+ && run.pendingDelegation === null
171
+ && run.receipts.length === 0
172
+ && run.commitAuthorization?.issuedAt === null
173
+ && run.commitAuthorization?.consumedAt === null
174
+ && run.completionEvidence == null
175
+ && !fs.existsSync(commitIntentPath(taskDir))
176
+ && (run.recoveryHistory ?? []).every((entry) => entry.code === 'MODEL_POLICY_SUPPLEMENTED');
177
+ }
162
178
  function beginOrResumeOrchestration(taskRef, options = {}) {
163
179
  const resolved = resolveTaskRef(taskRef, { repoRoot: options.repoRoot });
164
180
  if (!resolved.ok)
@@ -204,6 +220,39 @@ function beginOrResumeOrchestration(taskRef, options = {}) {
204
220
  return failed('ORCHESTRATION_MODEL_POLICY_MISMATCH', 'provided model policy does not match the persisted run policy', resolved.taskId);
205
221
  }
206
222
  }
223
+ if (existing.status === 'paused' && existing.pause?.code === 'ORCHESTRATION_CLIENT_UNSUPPORTED') {
224
+ if (!canRecoverCodexUnsupportedPause(existing, resolved.taskDir)) {
225
+ return { status: 'paused', changed: false, taskId: resolved.taskId, run: existing, next: null, error: null };
226
+ }
227
+ const now = (options.now ?? (() => new Date().toISOString()))();
228
+ const previousPause = existing.pause;
229
+ const recovery = {
230
+ code: 'CLIENT_CAPABILITY_ENABLED',
231
+ recoveredAt: now,
232
+ previousSchemaVersion: 2,
233
+ previousStatus: 'paused',
234
+ previousPause,
235
+ client: 'codex',
236
+ guards: {
237
+ stepCount: 0,
238
+ nextStage: null,
239
+ baselineEmpty: true,
240
+ receiptCount: 0,
241
+ pendingDelegation: false,
242
+ commitAuthorizationUnused: true,
243
+ completionEvidenceAbsent: true,
244
+ commitIntentAbsent: true
245
+ },
246
+ resultingStatus: 'running'
247
+ };
248
+ const resumed = withUpdatedRun(existing, {
249
+ status: 'running',
250
+ pause: null,
251
+ recoveryHistory: Object.freeze([...(existing.recoveryHistory ?? []), recovery])
252
+ }, () => now);
253
+ saveRun(resolved.taskDir, resumed);
254
+ return { status: 'running', changed: true, taskId: resolved.taskId, run: resumed, next: null, error: null };
255
+ }
207
256
  if (existing.status === 'paused' && existing.pause?.recoverable && existing.pendingDelegation === null) {
208
257
  const resumed = withUpdatedRun(existing, { status: 'running', pause: null });
209
258
  saveRun(resolved.taskDir, resumed);
@@ -1059,7 +1108,7 @@ function matchingDelegations(predicate, options) {
1059
1108
  .filter((entry) => entry.isDirectory())
1060
1109
  .flatMap((entry) => {
1061
1110
  const run = readRun(path.join(activeRoot, entry.name));
1062
- if (!run?.pendingDelegation)
1111
+ if (run?.status !== 'running' || !run.pendingDelegation)
1063
1112
  return [];
1064
1113
  const receipt = run.pendingDelegation;
1065
1114
  return predicate(receipt)
@@ -1080,13 +1129,36 @@ function activateMatchingOrchestrationDelegation(client, event, options = {}) {
1080
1129
  if (!role)
1081
1130
  return failed('DELEGATION_IGNORED', `subagent '${event.nativeAgent}' is not lifecycle-managed`);
1082
1131
  const matched = uniqueMatchingDelegation(client, (receipt) => receipt.status === 'prepared', options);
1083
- if ('status' in matched)
1084
- return matched;
1132
+ if ('status' in matched) {
1133
+ if (matched.error?.code !== 'ORCHESTRATION_DELEGATION_MISSING')
1134
+ return matched;
1135
+ const replay = uniqueMatchingDelegation(client, (receipt) => receipt.childId === event.childId && ['activated', 'stage-completed'].includes(receipt.status), options);
1136
+ if ('status' in replay)
1137
+ return matched;
1138
+ const receipt = replay.run.pendingDelegation;
1139
+ const sameEvidence = receipt.parentId === event.parentId
1140
+ && receipt.spawnMode === event.spawnMode
1141
+ && receipt.actualModel === event.actualModel
1142
+ && receipt.actualReasoningEffort === event.actualReasoningEffort
1143
+ && receipt.modelFallbackReason === (event.modelFallbackReason ?? null)
1144
+ && receipt.reasoningEffortFallbackReason === (event.reasoningEffortFallbackReason ?? null)
1145
+ && receipt.hostEvidence?.hookDefinitionHash === event.hostEvidence?.hookDefinitionHash;
1146
+ if (!sameEvidence) {
1147
+ return pauseOrchestration(replay.taskId, 'DELEGATION_REPLAY_CONFLICT', 'replayed native start evidence conflicts with the active receipt', true, options);
1148
+ }
1149
+ return { status: replay.run.status, changed: false, taskId: replay.taskId, run: replay.run, next: null, error: null };
1150
+ }
1085
1151
  if (matched.run.receipts.some((receipt) => receipt.childId === event.childId)) {
1086
1152
  return pauseOrchestration(matched.taskId, 'DELEGATION_IDENTITY_REUSED', 'native child identity was already used by this run', true, options);
1087
1153
  }
1088
1154
  return activateOrchestrationDelegation(matched.taskId, event, options);
1089
1155
  }
1156
+ function pauseMatchingOrchestrationDelegation(client, code, message, options = {}) {
1157
+ const matched = uniqueMatchingDelegation(client, () => true, options);
1158
+ if ('status' in matched)
1159
+ return matched;
1160
+ return pauseOrchestration(matched.taskId, code, message, true, options);
1161
+ }
1090
1162
  function sealMatchingOrchestrationDelegation(client, event, options = {}) {
1091
1163
  const role = managedDelegationRole(event.nativeAgent);
1092
1164
  if (!role)
@@ -1119,6 +1191,63 @@ function sealMatchingOrchestrationDelegation(client, event, options = {}) {
1119
1191
  return pauseOrchestration(matched.taskId, 'ORCHESTRATION_SNAPSHOT_FAILED', error instanceof Error ? error.message : String(error), true, options);
1120
1192
  }
1121
1193
  }
1194
+ function sealMatchingOrchestrationDelegationWithHostEvidence(client, event, consumeEvidence, options = {}) {
1195
+ const role = managedDelegationRole(event.nativeAgent);
1196
+ if (!role)
1197
+ return failed('DELEGATION_IGNORED', `subagent '${event.nativeAgent}' is not lifecycle-managed`);
1198
+ const matched = uniqueMatchingDelegation(client, (receipt) => ((receipt.status === 'stage-completed' || receipt.status === 'sealed')
1199
+ && receipt.childId === event.childId), options);
1200
+ if ('status' in matched)
1201
+ return matched;
1202
+ const receipt = matched.run.pendingDelegation;
1203
+ if (receipt.status === 'sealed') {
1204
+ return reconcileMatchingOrchestrationDelegation(client, event.childId, options);
1205
+ }
1206
+ if (receipt.role !== role) {
1207
+ return pauseOrchestration(matched.taskId, 'DELEGATION_ROLE_MISMATCH', `managed role ${role} does not match ${receipt.role}`, true, options);
1208
+ }
1209
+ const repoRoot = options.repoRoot ?? process.cwd();
1210
+ try {
1211
+ const gitRoot = gitRootFor(repoRoot, options);
1212
+ const snapshotTaskId = receipt.workspaceSnapshotScope === 'task' ? receipt.taskId : null;
1213
+ const afterFingerprint = (options.captureWorkspace ?? captureWorkspaceSnapshot)({
1214
+ gitRoot,
1215
+ stateRoot: repoRoot,
1216
+ taskId: snapshotTaskId
1217
+ });
1218
+ const changedPaths = (options.diffWorkspace ?? diffWorkspaceSnapshots)(gitRoot, receipt.beforeFingerprint, afterFingerprint);
1219
+ const baseEvent = { childId: event.childId, exitCode: 0, afterFingerprint, changedPaths };
1220
+ const validated = sealDelegation(receipt, baseEvent, { now: options.now });
1221
+ if (!validated.ok) {
1222
+ return pauseOrchestration(matched.taskId, validated.code, validated.message, true, options);
1223
+ }
1224
+ const hostEvidence = consumeEvidence(receipt);
1225
+ const sealed = sealDelegation(receipt, { ...baseEvent, hostEvidence }, { now: options.now });
1226
+ if (!sealed.ok)
1227
+ return pauseOrchestration(matched.taskId, sealed.code, sealed.message, true, options);
1228
+ const resolved = resolveTaskRef(matched.taskId, { repoRoot });
1229
+ if (!resolved.ok)
1230
+ return failed(resolved.code, resolved.message, resolved.taskId);
1231
+ const updated = withUpdatedRun(matched.run, { pendingDelegation: sealed.receipt }, options.now);
1232
+ saveRun(resolved.taskDir, updated);
1233
+ return { status: 'running', changed: true, taskId: matched.taskId, run: updated, next: null, error: null };
1234
+ }
1235
+ catch (error) {
1236
+ return pauseOrchestration(matched.taskId, 'ORCHESTRATION_CODEX_EVIDENCE_FAILED', error instanceof Error ? error.message : String(error), true, options);
1237
+ }
1238
+ }
1239
+ function reconcileMatchingOrchestrationDelegation(client, childId, options = {}) {
1240
+ const matched = uniqueMatchingDelegation(client, (receipt) => receipt.childId === childId, options);
1241
+ if ('status' in matched)
1242
+ return matched;
1243
+ const receipt = matched.run.pendingDelegation;
1244
+ if (receipt.status !== 'sealed'
1245
+ || receipt.hostEvidence?.consumer !== receipt.id
1246
+ || receipt.hostEvidence.consumedAt === null) {
1247
+ return pauseOrchestration(matched.taskId, 'ORCHESTRATION_CODEX_RECONCILIATION_FAILED', 'Codex native spawn did not produce one sealed receipt with consumed host evidence', true, options);
1248
+ }
1249
+ return { status: matched.run.status, changed: false, taskId: matched.taskId, run: matched.run, next: null, error: null };
1250
+ }
1122
1251
  function activateOrchestrationDelegation(taskRef, event, options = {}) {
1123
1252
  const resolved = resolveTaskRef(taskRef, { repoRoot: options.repoRoot });
1124
1253
  if (!resolved.ok)
@@ -1251,5 +1380,5 @@ function pauseOrchestration(taskRef, code, message, recoverable, options = {}) {
1251
1380
  saveRun(resolved.taskDir, updated);
1252
1381
  return { status: 'paused', changed: true, taskId: resolved.taskId, run: updated, next: null, error: null };
1253
1382
  }
1254
- export { activateMatchingOrchestrationDelegation, activateOrchestrationDelegation, advanceOrchestration, abortCommitIntent, beginCommitIntent, beginOrResumeOrchestration, checkpointCommitIntent, commitOrchestrationStageCompletion, completeCommitIntent, completeOrchestrationStage, inspectOrchestrationStage, orchestrationPath, pauseOrchestration, planOrchestrationStageCompletion, prepareOrchestrationDelegation, readRun, recoverCommitIntent, routeOrchestration, sealMatchingOrchestrationDelegation, sealOrchestrationDelegation, startCommitAttempt, statusCommitIntent, statusOrchestration, terminateCommitAttempt };
1383
+ export { activateMatchingOrchestrationDelegation, activateOrchestrationDelegation, advanceOrchestration, abortCommitIntent, beginCommitIntent, beginOrResumeOrchestration, checkpointCommitIntent, commitOrchestrationStageCompletion, completeCommitIntent, completeOrchestrationStage, inspectOrchestrationStage, orchestrationPath, pauseMatchingOrchestrationDelegation, pauseOrchestration, planOrchestrationStageCompletion, prepareOrchestrationDelegation, readRun, reconcileMatchingOrchestrationDelegation, recoverCommitIntent, routeOrchestration, sealMatchingOrchestrationDelegation, sealMatchingOrchestrationDelegationWithHostEvidence, sealOrchestrationDelegation, startCommitAttempt, statusCommitIntent, statusOrchestration, terminateCommitAttempt };
1255
1384
  //# sourceMappingURL=orchestration.js.map
@@ -239,12 +239,29 @@ function checkOrchestrationEvidence({ taskDir }) {
239
239
  return failResult('orchestration-evidence', 'Run model policy source or recovery history is invalid');
240
240
  }
241
241
  for (const recovery of run.recoveryHistory) {
242
- if (recovery.code !== 'MODEL_POLICY_SUPPLEMENTED'
243
- || recovery.previousSchemaVersion !== 1
244
- || recovery.receiptCount !== 0
245
- || recovery.pendingDelegation !== false
246
- || !exactText(recovery.recoveredAt)
247
- || !recovery.policySource) {
242
+ const validModelRecovery = recovery.code === 'MODEL_POLICY_SUPPLEMENTED'
243
+ && recovery.previousSchemaVersion === 1
244
+ && recovery.receiptCount === 0
245
+ && recovery.pendingDelegation === false
246
+ && exactText(recovery.recoveredAt)
247
+ && recovery.policySource;
248
+ const guards = recovery.guards;
249
+ const validCapabilityRecovery = recovery.code === 'CLIENT_CAPABILITY_ENABLED'
250
+ && recovery.previousSchemaVersion === 2
251
+ && recovery.previousStatus === 'paused'
252
+ && recovery.previousPause?.code === 'ORCHESTRATION_CLIENT_UNSUPPORTED'
253
+ && recovery.client === 'codex'
254
+ && recovery.resultingStatus === 'running'
255
+ && exactText(recovery.recoveredAt)
256
+ && guards?.stepCount === 0
257
+ && guards?.nextStage === null
258
+ && guards?.baselineEmpty === true
259
+ && guards?.receiptCount === 0
260
+ && guards?.pendingDelegation === false
261
+ && guards?.commitAuthorizationUnused === true
262
+ && guards?.completionEvidenceAbsent === true
263
+ && guards?.commitIntentAbsent === true;
264
+ if (!validModelRecovery && !validCapabilityRecovery) {
248
265
  return failResult('orchestration-evidence', 'Run recovery history contains invalid provenance');
249
266
  }
250
267
  }
@@ -298,6 +315,21 @@ function checkOrchestrationEvidence({ taskDir }) {
298
315
  && receipt.reasoningEffortFallbackReason !== null) {
299
316
  return failResult('orchestration-evidence', `Receipt '${receipt.id ?? '(unknown)'}' has an unrelated reasoning-effort fallback reason`);
300
317
  }
318
+ if (receipt.client === 'codex') {
319
+ const host = receipt.hostEvidence;
320
+ if (host?.kind !== 'codex-lifecycle-v1'
321
+ || !exactText(host.hookDefinitionHash)
322
+ || !Number.isSafeInteger(host.startRevision)
323
+ || host.startRevision < 1) {
324
+ return failResult('orchestration-evidence', `Receipt '${receipt.id ?? '(unknown)'}' has invalid Codex start evidence`);
325
+ }
326
+ if (['sealed', 'consumed'].includes(receipt.status) && (!Number.isSafeInteger(host.stopRevision)
327
+ || host.stopRevision <= host.startRevision
328
+ || host.consumer !== receipt.id
329
+ || !exactText(host.consumedAt))) {
330
+ return failResult('orchestration-evidence', `Receipt '${receipt.id ?? '(unknown)'}' has invalid Codex consumed stop evidence`);
331
+ }
332
+ }
301
333
  }
302
334
  }
303
335
  if (run.receipts.some((receipt) => receipt.status !== 'consumed')) {
@@ -16,6 +16,7 @@ const VERIFICATION_CATALOG = {
16
16
  'code.completed': gate('code-task', 'active', 'code'),
17
17
  'review-code.completed': gate('review-code', 'active', 'review-code'),
18
18
  'manual-validation.completed': gate('complete-manual-validation', 'active', 'manual-validation'),
19
+ 'validation-run.completed': gate('run-manual-validation', 'active', 'validation-run'),
19
20
  'block-task.completed': gate('block-task', 'blocked'),
20
21
  'cancel-task.completed': gate('cancel-task', 'completed'),
21
22
  'commit.completed': gate('commit', 'active'),
@@ -27,6 +27,8 @@ type AppServerTransportOptions = Readonly<{
27
27
  cwd?: string;
28
28
  env?: NodeJS.ProcessEnv;
29
29
  timeoutMs?: number;
30
+ rolloutReadAttempts?: number;
31
+ rolloutRetryMs?: number;
30
32
  }>;
31
33
 
32
34
  type CodexRuntimeIdentity = Readonly<{
@@ -37,10 +39,12 @@ type CodexRuntimeIdentity = Readonly<{
37
39
 
38
40
  const LIFECYCLE_HOOKS = Object.freeze([
39
41
  Object.freeze({ event: 'PreToolUse', matcher: '^collaborationspawn_agent$', phase: 'pre-tool' }),
40
- Object.freeze({ event: 'PostToolUse', matcher: '^collaborationspawn_agent$', phase: 'post-tool' }),
41
- Object.freeze({ event: 'SubagentStart', matcher: '^agent-infra-lifecycle-(executor|reviewer)$', phase: 'subagent-start' }),
42
- Object.freeze({ event: 'SubagentStop', matcher: '^agent-infra-lifecycle-(executor|reviewer)$', phase: 'subagent-stop' })
42
+ Object.freeze({ event: 'PostToolUse', matcher: '', phase: 'post-tool' }),
43
+ Object.freeze({ event: 'SubagentStart', matcher: '', phase: 'subagent-start' }),
44
+ Object.freeze({ event: 'SubagentStop', matcher: '', phase: 'subagent-stop' })
43
45
  ]);
46
+ const DEFAULT_ROLLOUT_READ_ATTEMPTS = 81;
47
+ const DEFAULT_ROLLOUT_RETRY_MS = 100;
44
48
 
45
49
  function object(value: unknown): JsonObject | null {
46
50
  return value && typeof value === 'object' && !Array.isArray(value)
@@ -52,6 +56,65 @@ function nonEmpty(value: unknown): string | null {
52
56
  return typeof value === 'string' && value.trim() ? value : null;
53
57
  }
54
58
 
59
+ function resolveCodexSpawnedChild(
60
+ transcriptPath: string,
61
+ expected: Readonly<{
62
+ sessionId: string;
63
+ toolUseId: string;
64
+ nativeAgent: string;
65
+ taskName: string;
66
+ requestedModel?: string;
67
+ requestedReasoningEffort?: string;
68
+ }>
69
+ ): string {
70
+ const name = path.basename(transcriptPath);
71
+ if (!transcriptPath || (!name.endsWith(`-${expected.sessionId}.jsonl`) && name !== `${expected.sessionId}.jsonl`)) {
72
+ throw new Error('Codex parent rollout path is invalid');
73
+ }
74
+ const stat = fs.lstatSync(transcriptPath);
75
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Codex parent rollout is not a regular file');
76
+ const maxBytes = 8 * 1024 * 1024;
77
+ const offset = Math.max(0, stat.size - maxBytes);
78
+ const buffer = Buffer.alloc(stat.size - offset);
79
+ const descriptor = fs.openSync(transcriptPath, 'r');
80
+ try {
81
+ fs.readSync(descriptor, buffer, 0, buffer.length, offset);
82
+ } finally {
83
+ fs.closeSync(descriptor);
84
+ }
85
+ let text = buffer.toString('utf8');
86
+ if (offset) text = text.slice(text.indexOf('\n') + 1);
87
+ const records = text.split(/\r?\n/).filter(Boolean).map((line) => object(JSON.parse(line))).filter(Boolean);
88
+ const calls = records.filter((record) => {
89
+ const payload = object(record?.payload);
90
+ return record?.type === 'response_item'
91
+ && payload?.type === 'function_call'
92
+ && payload.call_id === expected.toolUseId
93
+ && payload.namespace === 'collaboration'
94
+ && payload.name === 'spawn_agent';
95
+ });
96
+ if (calls.length !== 1) throw new Error('Codex parent rollout spawn call was not found uniquely');
97
+ const call = object(calls[0]?.payload);
98
+ const args = object(JSON.parse(nonEmpty(call?.arguments) ?? '{}'));
99
+ if (
100
+ args?.agent_type !== expected.nativeAgent
101
+ || args.task_name !== expected.taskName
102
+ || (expected.requestedModel && args.model !== expected.requestedModel)
103
+ || (expected.requestedReasoningEffort && args.reasoning_effort !== expected.requestedReasoningEffort)
104
+ ) throw new Error('Codex parent rollout spawn identity does not match the hook event');
105
+ const activities = records.filter((record) => {
106
+ const payload = object(record?.payload);
107
+ return record?.type === 'event_msg'
108
+ && payload?.type === 'sub_agent_activity'
109
+ && payload.event_id === expected.toolUseId
110
+ && payload.kind === 'started'
111
+ && payload.agent_path === `/root/${expected.taskName}`
112
+ && nonEmpty(payload.agent_thread_id);
113
+ });
114
+ if (activities.length !== 1) throw new Error('Codex parent rollout child activity was not found uniquely');
115
+ return nonEmpty(object(activities[0]?.payload)?.agent_thread_id)!;
116
+ }
117
+
55
118
  function validateCodexLifecycleHookConfig(value: unknown): void {
56
119
  const hooks = object(object(value)?.hooks);
57
120
  const valid = hooks && LIFECYCLE_HOOKS.every(({ event, matcher, phase }) => {
@@ -63,7 +126,7 @@ function validateCodexLifecycleHookConfig(value: unknown): void {
63
126
  return entry.hooks.some((hookValue) => {
64
127
  const hook = object(hookValue);
65
128
  return hook?.type === 'command'
66
- && hook.timeout === 5
129
+ && hook.timeout === 15
67
130
  && hook.command === `node "$(git rev-parse --show-toplevel)/.agents/hooks/lifecycle-delegation.js" --client codex --event ${phase}`;
68
131
  });
69
132
  });
@@ -120,26 +183,38 @@ function hasCodexRuntimeLiveness(
120
183
  });
121
184
  }
122
185
 
123
- function parseCodexThreadResolution(readValue: unknown, resumeValue: unknown): ThreadResolution {
186
+ function parseCodexThreadResolution(readValue: unknown, rolloutRecords: readonly unknown[]): ThreadResolution {
124
187
  const readRoot = object(readValue);
125
- const resumeRoot = object(resumeValue);
126
188
  const thread = object(readRoot?.thread);
127
- const resumedThread = object(resumeRoot?.thread);
128
189
  const source = object(thread?.source);
129
190
  const subAgent = object(source?.subAgent);
130
191
  const threadSpawn = object(subAgent?.thread_spawn);
192
+ const sessionMeta = rolloutRecords
193
+ .map(object)
194
+ .find((record) => record?.type === 'session_meta');
195
+ const sessionPayload = object(sessionMeta?.payload);
196
+ const turnContext = rolloutRecords
197
+ .map(object)
198
+ .filter((record) => record?.type === 'turn_context')
199
+ .at(-1);
200
+ const turnPayload = object(turnContext?.payload);
131
201
  const childThreadId = nonEmpty(thread?.id);
132
202
  const parentThreadId = nonEmpty(thread?.parentThreadId);
133
203
  const sourceParentThreadId = nonEmpty(threadSpawn?.parent_thread_id);
134
- const resumedChildThreadId = nonEmpty(resumedThread?.id);
135
- const model = nonEmpty(resumeRoot?.model);
136
- const reasoningEffort = nonEmpty(resumeRoot?.reasoningEffort ?? object(resumeRoot?.settings)?.effort);
204
+ const rolloutChildThreadId = nonEmpty(sessionPayload?.id);
205
+ const rolloutParentThreadId = nonEmpty(sessionPayload?.parent_thread_id);
206
+ const agentRole = nonEmpty(sessionPayload?.agent_role);
207
+ const model = nonEmpty(turnPayload?.model);
208
+ const reasoningEffort = nonEmpty(turnPayload?.effort);
137
209
  const forkedFromId = thread?.forkedFromId;
138
210
  if (
139
211
  !childThreadId
140
- || resumedChildThreadId !== childThreadId
212
+ || rolloutChildThreadId !== childThreadId
141
213
  || !parentThreadId
142
214
  || !sourceParentThreadId
215
+ || rolloutParentThreadId !== parentThreadId
216
+ || rolloutParentThreadId !== sourceParentThreadId
217
+ || !/^agent-infra-lifecycle-(executor|reviewer)$/.test(agentRole ?? '')
143
218
  || !model
144
219
  || !reasoningEffort
145
220
  || (forkedFromId !== null && typeof forkedFromId !== 'string')
@@ -152,7 +227,8 @@ function parseCodexThreadResolution(readValue: unknown, resumeValue: unknown): T
152
227
  childThreadId,
153
228
  parentThreadId,
154
229
  forkedFromId,
155
- sourceParentThreadId
230
+ sourceParentThreadId,
231
+ nativeAgent: agentRole!
156
232
  }),
157
233
  settings: Object.freeze({
158
234
  type: 'app-settings',
@@ -163,6 +239,49 @@ function parseCodexThreadResolution(readValue: unknown, resumeValue: unknown): T
163
239
  });
164
240
  }
165
241
 
242
+ function readCodexRolloutRecords(readValue: unknown, childThreadId: string): readonly unknown[] {
243
+ const thread = object(object(readValue)?.thread);
244
+ const rolloutPath = nonEmpty(thread?.path);
245
+ const rolloutName = rolloutPath ? path.basename(rolloutPath) : '';
246
+ if (!rolloutPath || (rolloutName !== `${childThreadId}.jsonl` && !rolloutName.endsWith(`-${childThreadId}.jsonl`))) {
247
+ throw new Error('Codex App Server rollout path is invalid');
248
+ }
249
+ const stat = fs.statSync(rolloutPath);
250
+ if (!stat.isFile()) throw new Error('Codex App Server rollout path is not a regular file');
251
+ const maxBytes = 1024 * 1024;
252
+ const bytes = Math.min(stat.size, maxBytes);
253
+ const descriptor = fs.openSync(rolloutPath, 'r');
254
+ try {
255
+ const buffer = Buffer.alloc(bytes);
256
+ fs.readSync(descriptor, buffer, 0, bytes, 0);
257
+ const text = buffer.toString('utf8');
258
+ const completeText = stat.size <= maxBytes ? text : text.slice(0, text.lastIndexOf('\n'));
259
+ return Object.freeze(completeText.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)));
260
+ } finally {
261
+ fs.closeSync(descriptor);
262
+ }
263
+ }
264
+
265
+ async function resolveCodexRolloutRecords(
266
+ readValue: unknown,
267
+ childThreadId: string,
268
+ attempts: number,
269
+ retryMs: number
270
+ ): Promise<readonly unknown[]> {
271
+ let lastError: unknown;
272
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
273
+ try {
274
+ const records = readCodexRolloutRecords(readValue, childThreadId);
275
+ parseCodexThreadResolution(readValue, records);
276
+ return records;
277
+ } catch (error) {
278
+ lastError = error;
279
+ if (attempt + 1 < attempts) await new Promise((resolve) => setTimeout(resolve, retryMs));
280
+ }
281
+ }
282
+ throw lastError;
283
+ }
284
+
166
285
  function parseCodexTurnCompleted(value: unknown): Extract<CodexLifecycleEvent, { type: 'app-terminal' }> {
167
286
  const root = object(value);
168
287
  const turn = object(root?.turn);
@@ -333,12 +452,17 @@ async function resolveCodexThread(
333
452
  const readResult = object(await transport.request('thread/read', { threadId: childThreadId, includeTurns: false }));
334
453
  const readThread = object(readResult?.thread);
335
454
  if (nonEmpty(readThread?.id) !== childThreadId) throw new Error('Codex App Server returned the wrong child thread');
336
- const resumeResult = await transport.request('thread/resume', { threadId: childThreadId });
337
455
  let resolution: ThreadResolution;
338
456
  try {
339
- resolution = parseCodexThreadResolution(readResult, resumeResult);
457
+ const rolloutRecords = await resolveCodexRolloutRecords(
458
+ readResult,
459
+ childThreadId,
460
+ options.rolloutReadAttempts ?? DEFAULT_ROLLOUT_READ_ATTEMPTS,
461
+ options.rolloutRetryMs ?? DEFAULT_ROLLOUT_RETRY_MS
462
+ );
463
+ resolution = parseCodexThreadResolution(readResult, rolloutRecords);
340
464
  } catch {
341
- throw new Error('Codex App Server resumed settings are unavailable');
465
+ throw new Error('Codex App Server rollout metadata is unavailable');
342
466
  }
343
467
  const reroutes = transport.notifications
344
468
  .filter((entry) => entry.method === 'model/rerouted')
@@ -368,6 +492,8 @@ async function resolveCodexTerminal(
368
492
  if (nonEmpty(thread?.id) !== childThreadId) throw new Error('Codex App Server returned the wrong child thread');
369
493
  const turns = Array.isArray(thread?.turns) ? thread.turns : [];
370
494
  const turn = turns.at(-1);
495
+ const turnValue = object(turn);
496
+ if (!turnValue || turnValue.status === 'inProgress') throw new Error('CODEX_TURN_NOT_TERMINAL');
371
497
  return parseCodexTurnCompleted({ threadId: childThreadId, turn });
372
498
  } finally {
373
499
  transport.close();
@@ -434,6 +560,7 @@ export {
434
560
  parseCodexHooksList,
435
561
  parseCodexThreadResolution,
436
562
  parseCodexTurnCompleted,
563
+ resolveCodexSpawnedChild,
437
564
  preflightCodexLifecycleEvidence,
438
565
  resolveCodexTerminal,
439
566
  resolveCodexThread,
@@ -21,7 +21,9 @@ type CodexLifecycleEvent =
21
21
  sessionId: string;
22
22
  turnId: string;
23
23
  childThreadId: string;
24
+ parentThreadId: string;
24
25
  nativeAgent: string;
26
+ source?: 'hook' | 'parent-rollout';
25
27
  }>
26
28
  | Readonly<{
27
29
  type: 'app-thread';
@@ -29,6 +31,7 @@ type CodexLifecycleEvent =
29
31
  parentThreadId: string;
30
32
  forkedFromId: string | null;
31
33
  sourceParentThreadId: string;
34
+ nativeAgent: string;
32
35
  }>
33
36
  | Readonly<{
34
37
  type: 'app-settings';
@@ -56,6 +59,7 @@ type CodexLifecycleEvent =
56
59
  turnId: string;
57
60
  childThreadId: string;
58
61
  nativeAgent: string;
62
+ source?: 'hook' | 'parent-rollout';
59
63
  }>;
60
64
 
61
65
  type CodexStartEvidence = Readonly<{
@@ -79,7 +83,7 @@ type CodexStopEvidence = Readonly<{
79
83
  childThreadId: string;
80
84
  turnId: string;
81
85
  terminalStatus: 'completed';
82
- hookStopObserved: true;
86
+ hookStopObserved: boolean;
83
87
  }>;
84
88
 
85
89
  type CodexLifecycleStatus =
@@ -153,20 +157,32 @@ function requiredStrings(event: CodexLifecycleEvent): boolean {
153
157
  function derive(state: CodexLifecycleState): CodexLifecycleState {
154
158
  const { spawn, child, thread, settings, reroute, terminal, stop } = state;
155
159
  if (spawn && child && (
156
- spawn.sessionId !== child.sessionId
160
+ spawn.sessionId !== child.parentThreadId
157
161
  || spawn.nativeAgent !== child.nativeAgent
158
162
  )) {
159
163
  return invalid(state, 'CODEX_EVIDENCE_IDENTITY_MISMATCH', 'hook spawn and child identity do not match');
160
164
  }
165
+ if (child && child.sessionId !== child.parentThreadId) {
166
+ return invalid(state, 'CODEX_EVIDENCE_IDENTITY_MISMATCH', 'child event session does not match the parent thread');
167
+ }
161
168
  if (child && thread && child.childThreadId !== thread.childThreadId) {
162
169
  return invalid(state, 'CODEX_EVIDENCE_IDENTITY_MISMATCH', 'hook and App Server child identity do not match');
163
170
  }
171
+ if (child && thread && child.nativeAgent !== thread.nativeAgent) {
172
+ return invalid(state, 'CODEX_EVIDENCE_IDENTITY_MISMATCH', 'hook and App Server agent roles do not match');
173
+ }
174
+ if (child && thread && child.parentThreadId !== thread.parentThreadId) {
175
+ return invalid(state, 'CODEX_EVIDENCE_PARENT_MISMATCH', 'hook and App Server parent identity do not match');
176
+ }
164
177
  if (spawn && thread && (
165
178
  thread.parentThreadId !== spawn.sessionId
166
179
  || thread.sourceParentThreadId !== spawn.sessionId
167
180
  )) {
168
181
  return invalid(state, 'CODEX_EVIDENCE_PARENT_MISMATCH', 'App Server parent identity does not match the spawning session');
169
182
  }
183
+ if (spawn && thread && spawn.nativeAgent !== thread.nativeAgent) {
184
+ return invalid(state, 'CODEX_EVIDENCE_IDENTITY_MISMATCH', 'spawn and App Server agent roles do not match');
185
+ }
170
186
  if (thread?.forkedFromId !== null && thread?.forkedFromId !== undefined) {
171
187
  return invalid(state, 'CODEX_EVIDENCE_FORK_FORBIDDEN', 'forked child threads are not fresh lifecycle evidence');
172
188
  }
@@ -233,7 +249,7 @@ function derive(state: CodexLifecycleState): CodexLifecycleState {
233
249
  childThreadId: startEvidence.childThreadId,
234
250
  turnId: terminal.turnId,
235
251
  terminalStatus: 'completed',
236
- hookStopObserved: true
252
+ hookStopObserved: stop.source !== 'parent-rollout'
237
253
  });
238
254
  }
239
255
  const status: CodexLifecycleStatus = stopEvidence
@@ -131,7 +131,7 @@ function createCodexLifecycleStore(options: CodexLifecycleStoreOptions) {
131
131
  if (event.type === 'hook-child') {
132
132
  const matches = recordFiles(root).filter((file) => {
133
133
  const state = readRecord(file).state;
134
- return state.spawn?.sessionId === event.sessionId
134
+ return state.spawn?.sessionId === event.parentThreadId
135
135
  && state.spawn.nativeAgent === event.nativeAgent
136
136
  && (!state.child || state.child.childThreadId === event.childThreadId);
137
137
  });
@@ -179,6 +179,45 @@ function createCodexLifecycleStore(options: CodexLifecycleStoreOptions) {
179
179
  });
180
180
  }
181
181
 
182
+ function applyToSpawn(
183
+ identity: Readonly<{ sessionId: string; turnId: string; toolUseId: string }>,
184
+ event: Extract<CodexLifecycleEvent, { type: 'hook-child' }>
185
+ ): CodexLifecycleStoreResult {
186
+ return withWriteLock(() => {
187
+ const file = path.join(root, `${digest(`${identity.sessionId}\0${identity.turnId}\0${identity.toolUseId}`)}.json`);
188
+ if (!fs.existsSync(file)) throw new Error('Codex lifecycle spawn identity was not found');
189
+ const current = readRecord(file);
190
+ if (current.consumer) throw new Error(`Codex lifecycle evidence was already consumed by '${current.consumer}'`);
191
+ if (
192
+ current.state.spawn?.sessionId !== identity.sessionId
193
+ || current.state.spawn.turnId !== identity.turnId
194
+ || current.state.spawn.toolUseId !== identity.toolUseId
195
+ ) throw new Error('Codex lifecycle spawn identity does not match the stored event');
196
+ const nextState = reduceCodexLifecycleEvent(current.state, event);
197
+ const next = Object.freeze({
198
+ ...current,
199
+ revision: current.revision + 1,
200
+ state: nextState,
201
+ updatedAt: now()
202
+ });
203
+ writeRecord(file, next, current.revision);
204
+ return Object.freeze({ path: file, revision: next.revision, state: next.state });
205
+ });
206
+ }
207
+
208
+ function findByParent(parentThreadId: string): readonly StoredCodexLifecycle[] {
209
+ const matches = recordFiles(root)
210
+ .map(readRecord)
211
+ .filter((record) => record.state.startEvidence?.parentThreadId === parentThreadId
212
+ && ['start-ready', 'observed-terminal', 'stop-ready'].includes(record.state.status));
213
+ const unconsumed = matches.filter((record) => !record.consumer);
214
+ if (unconsumed.length > 1) throw new Error(`Codex lifecycle parent '${parentThreadId}' has ambiguous active children`);
215
+ if (unconsumed.length === 1) return Object.freeze(unconsumed);
216
+ return Object.freeze(matches
217
+ .filter((record) => record.consumer && record.state.status === 'stop-ready')
218
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)));
219
+ }
220
+
182
221
  function read(childThreadId: string): StoredCodexLifecycle {
183
222
  const matches = findByChild(childThreadId);
184
223
  if (matches.length !== 1) throw new Error(`Codex lifecycle child '${childThreadId}' was not found uniquely`);
@@ -196,7 +235,16 @@ function createCodexLifecycleStore(options: CodexLifecycleStoreOptions) {
196
235
  if (matches.length !== 1) throw new Error(`Codex lifecycle child '${childThreadId}' was not found uniquely`);
197
236
  const file = matches[0]!;
198
237
  const current = readRecord(file);
199
- if (current.consumer) throw new Error(`Codex lifecycle evidence was already consumed by '${current.consumer}'`);
238
+ if (current.consumer) {
239
+ if (current.consumer !== consumer) {
240
+ throw new Error(`Codex lifecycle evidence was already consumed by '${current.consumer}'`);
241
+ }
242
+ if (
243
+ expectedHookDefinitionHash
244
+ && current.state.startEvidence?.hookDefinitionHash !== expectedHookDefinitionHash
245
+ ) throw new Error('Codex lifecycle hook definition hash is stale');
246
+ return current;
247
+ }
200
248
  if (current.state.status !== 'stop-ready') throw new Error('Codex lifecycle evidence is not stop-ready');
201
249
  if (
202
250
  expectedHookDefinitionHash
@@ -239,7 +287,7 @@ function createCodexLifecycleStore(options: CodexLifecycleStoreOptions) {
239
287
  });
240
288
  }
241
289
 
242
- return Object.freeze({ root, apply, consume, expireBefore, read });
290
+ return Object.freeze({ root, apply, applyToSpawn, consume, expireBefore, findByParent, read });
243
291
  }
244
292
 
245
293
  export { createCodexLifecycleStore };
@@ -15,7 +15,7 @@ const codexAdapter = defineAgentClientAdapter({
15
15
  commands: { level: 'integrated' },
16
16
  hooks: { level: 'integrated' },
17
17
  subagents: { level: 'experimental' },
18
- orchestration: { level: 'unsupported' },
18
+ orchestration: { level: 'experimental' },
19
19
  sandbox: { level: 'integrated' },
20
20
  verification: { level: 'compatible' }
21
21
  },