agentxchain 2.149.2 → 2.151.0

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.
@@ -195,15 +195,44 @@ const GOVERNED_ROUTING = {
195
195
  const GOVERNED_GATES = {
196
196
  planning_signoff: {
197
197
  requires_files: ['.planning/PM_SIGNOFF.md', '.planning/ROADMAP.md', '.planning/SYSTEM_SPEC.md'],
198
- requires_human_approval: true
198
+ requires_human_approval: true,
199
+ credentialed: false
199
200
  },
200
201
  implementation_complete: {
201
202
  requires_files: ['.planning/IMPLEMENTATION_NOTES.md'],
202
- requires_verification_pass: true
203
+ requires_verification_pass: true,
204
+ credentialed: false
203
205
  },
204
206
  qa_ship_verdict: {
205
207
  requires_files: ['.planning/acceptance-matrix.md', '.planning/ship-verdict.md', '.planning/RELEASE_NOTES.md'],
206
- requires_human_approval: true
208
+ requires_human_approval: true,
209
+ requires_verification_pass: true,
210
+ credentialed: false
211
+ }
212
+ };
213
+
214
+ const GOVERNED_APPROVAL_POLICY = {
215
+ phase_transitions: {
216
+ default: 'require_human',
217
+ rules: [
218
+ {
219
+ from_phase: 'planning',
220
+ to_phase: 'implementation',
221
+ action: 'auto_approve',
222
+ when: {
223
+ gate_passed: true,
224
+ credentialed_gate: false
225
+ }
226
+ }
227
+ ]
228
+ },
229
+ run_completion: {
230
+ action: 'auto_approve',
231
+ when: {
232
+ gate_passed: true,
233
+ all_phases_visited: true,
234
+ credentialed_gate: false
235
+ }
207
236
  }
208
237
  };
209
238
 
@@ -713,6 +742,7 @@ function buildScaffoldConfigFromTemplate(template, localDevRuntime, workflowKitC
713
742
 
714
743
  const routing = cloneJsonCompatible(blueprint?.routing || GOVERNED_ROUTING);
715
744
  const gates = cloneJsonCompatible(blueprint?.gates || GOVERNED_GATES);
745
+ const approvalPolicy = cloneJsonCompatible(blueprint?.approval_policy || GOVERNED_APPROVAL_POLICY);
716
746
  const effectiveWorkflowKitConfig = workflowKitConfig || cloneJsonCompatible(blueprint?.workflow_kit || null);
717
747
  const prompts = Object.fromEntries(
718
748
  Object.keys(roles).map((roleId) => [roleId, `.agentxchain/prompts/${roleId}.md`])
@@ -725,6 +755,7 @@ function buildScaffoldConfigFromTemplate(template, localDevRuntime, workflowKitC
725
755
  runtimes,
726
756
  routing,
727
757
  gates,
758
+ approvalPolicy,
728
759
  policies,
729
760
  prompts,
730
761
  workflowKitConfig: effectiveWorkflowKitConfig,
@@ -778,7 +809,7 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
778
809
  const template = loadGovernedTemplate(templateId);
779
810
  const { runtime: localDevRuntime } = resolveGovernedLocalDevRuntime(runtimeOptions);
780
811
  const scaffoldConfig = buildScaffoldConfigFromTemplate(template, localDevRuntime, workflowKitConfig, runtimeOptions);
781
- const { roles, runtimes, routing, gates, policies, prompts, workflowKitConfig: effectiveWorkflowKitConfig } = scaffoldConfig;
812
+ const { roles, runtimes, routing, gates, approvalPolicy, policies, prompts, workflowKitConfig: effectiveWorkflowKitConfig } = scaffoldConfig;
782
813
  const scaffoldWorkflowKitConfig = effectiveWorkflowKitConfig
783
814
  ? normalizeWorkflowKit(effectiveWorkflowKitConfig, Object.keys(routing))
784
815
  : null;
@@ -804,6 +835,7 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
804
835
  runtimes,
805
836
  routing,
806
837
  gates,
838
+ approval_policy: approvalPolicy,
807
839
  budget: {
808
840
  per_turn_max_usd: 2.0,
809
841
  per_run_max_usd: 50.0,
@@ -1377,7 +1377,9 @@ export async function missionPlanAutopilotCommand(planTarget, opts) {
1377
1377
  }
1378
1378
 
1379
1379
  if (waveNum === maxWaves) {
1380
- terminalReason = 'wave_limit_reached';
1380
+ terminalReason = totalFailed > 0
1381
+ ? (continueOnFailure ? 'plan_incomplete' : 'failure_stopped')
1382
+ : 'wave_limit_reached';
1381
1383
  break;
1382
1384
  }
1383
1385
 
@@ -2030,7 +2032,9 @@ async function coordinatorAutopilot(planTarget, opts, context, mission) {
2030
2032
  }
2031
2033
 
2032
2034
  if (waveNum === maxWaves) {
2033
- terminalReason = 'wave_limit_reached';
2035
+ terminalReason = totalFailed > 0
2036
+ ? (continueOnFailure ? 'plan_incomplete' : 'failure_stopped')
2037
+ : 'wave_limit_reached';
2034
2038
  break;
2035
2039
  }
2036
2040
 
@@ -219,7 +219,7 @@ export async function restartCommand(opts) {
219
219
  process.exit(1);
220
220
  }
221
221
 
222
- if (state.status === 'blocked') {
222
+ if (state.status === 'blocked' && !state.pending_phase_transition && !state.pending_run_completion) {
223
223
  console.log(chalk.red('Run is blocked. Resolve the blocker first.'));
224
224
  const recovery = deriveRecoveryDescriptor(state, config);
225
225
  if (recovery) {
@@ -120,7 +120,14 @@ function buildArtifactIndex(root, turnId) {
120
120
  }
121
121
 
122
122
  function buildTurnPayload(turnId, turn, state, artifacts, assignment, config) {
123
- const elapsedMs = getElapsedMs(turn.started_at);
123
+ // Effective start time for display: BUG-51 hardening clears `started_at`
124
+ // when a turn transitions to `dispatched` so ghost-turn detection can key
125
+ // off `dispatched_at`. For manual runtimes (no subprocess), no later
126
+ // `starting` transition re-sets `started_at`, so fall back to
127
+ // `dispatched_at` (operator-dispatched = effective start) and
128
+ // `assigned_at` as a last resort so the timing surface stays populated.
129
+ const effectiveStartedAt = turn.started_at || turn.dispatched_at || turn.assigned_at || null;
130
+ const elapsedMs = getElapsedMs(effectiveStartedAt);
124
131
  const payload = {
125
132
  turn_id: turnId,
126
133
  run_id: state.run_id || assignment?.run_id || null,
@@ -129,7 +136,7 @@ function buildTurnPayload(turnId, turn, state, artifacts, assignment, config) {
129
136
  runtime: turn.runtime_id,
130
137
  status: turn.status,
131
138
  attempt: turn.attempt,
132
- started_at: turn.started_at || null,
139
+ started_at: effectiveStartedAt,
133
140
  elapsed_ms: elapsedMs,
134
141
  dispatch_dir: getDispatchTurnDir(turnId),
135
142
  staging_result_path: assignment?.staging_result_path || null,
@@ -152,7 +159,9 @@ function buildTurnPayload(turnId, turn, state, artifacts, assignment, config) {
152
159
  }
153
160
 
154
161
  function printTurnSummary(turnId, turn, state, artifacts, assignment, config) {
155
- const elapsedMs = getElapsedMs(turn.started_at);
162
+ // See buildTurnPayload for rationale on fallback ordering.
163
+ const effectiveStartedAt = turn.started_at || turn.dispatched_at || turn.assigned_at || null;
164
+ const elapsedMs = getElapsedMs(effectiveStartedAt);
156
165
  console.log('');
157
166
  console.log(chalk.bold(` Turn: ${chalk.cyan(turnId)}`));
158
167
  console.log(chalk.dim(' ' + '─'.repeat(44)));
@@ -162,8 +171,8 @@ function printTurnSummary(turnId, turn, state, artifacts, assignment, config) {
162
171
  console.log(` ${chalk.dim('Runtime:')} ${turn.runtime_id}`);
163
172
  console.log(` ${chalk.dim('Status:')} ${turn.status}`);
164
173
  console.log(` ${chalk.dim('Attempt:')} ${turn.attempt}`);
165
- if (turn.started_at) {
166
- console.log(` ${chalk.dim('Started:')} ${turn.started_at}`);
174
+ if (effectiveStartedAt) {
175
+ console.log(` ${chalk.dim('Started:')} ${effectiveStartedAt}`);
167
176
  }
168
177
  if (elapsedMs != null) {
169
178
  console.log(` ${chalk.dim('Elapsed:')} ${formatElapsed(elapsedMs)}`);
@@ -41,6 +41,7 @@ const DIAGNOSTIC_ENV_KEYS = [
41
41
  'AGENTXCHAIN_TURN_ID',
42
42
  ];
43
43
  const DIAGNOSTIC_STDERR_EXCERPT_LIMIT = 800;
44
+ const DEFAULT_STARTUP_WATCHDOG_MS = 180_000;
44
45
 
45
46
  /**
46
47
  * Launch a local CLI subprocess for a governed turn.
@@ -264,7 +265,29 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
264
265
  armStartupWatchdog();
265
266
  });
266
267
 
267
- // Deliver prompt via stdin if transport is "stdin"; otherwise close immediately
268
+ // Collect stdout/stderr
269
+ if (child.stdout) {
270
+ child.stdout.on('data', (chunk) => {
271
+ const text = chunk.toString();
272
+ stdoutBytes += Buffer.byteLength(text);
273
+ recordFirstOutput('stdout');
274
+ logs.push(text);
275
+ if (onStdout) onStdout(text);
276
+ });
277
+ }
278
+
279
+ if (child.stderr) {
280
+ child.stderr.on('data', (chunk) => {
281
+ const text = chunk.toString();
282
+ stderrBytes += Buffer.byteLength(text);
283
+ stderrExcerpt = appendDiagnosticExcerpt(stderrExcerpt, text, DIAGNOSTIC_STDERR_EXCERPT_LIMIT);
284
+ logs.push('[stderr] ' + text);
285
+ if (onStderr) onStderr(text);
286
+ });
287
+ }
288
+
289
+ // Deliver prompt only after output listeners are registered. This removes
290
+ // the remaining adapter-side ordering risk for fast stdin-driven children.
268
291
  if (child.stdin) {
269
292
  child.stdin.on('error', (err) => {
270
293
  appendDiagnostic(logs, 'stdin_error', {
@@ -287,27 +310,6 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
287
310
  }
288
311
  }
289
312
 
290
- // Collect stdout/stderr
291
- if (child.stdout) {
292
- child.stdout.on('data', (chunk) => {
293
- const text = chunk.toString();
294
- stdoutBytes += Buffer.byteLength(text);
295
- recordFirstOutput('stdout');
296
- logs.push(text);
297
- if (onStdout) onStdout(text);
298
- });
299
- }
300
-
301
- if (child.stderr) {
302
- child.stderr.on('data', (chunk) => {
303
- const text = chunk.toString();
304
- stderrBytes += Buffer.byteLength(text);
305
- stderrExcerpt = appendDiagnosticExcerpt(stderrExcerpt, text, DIAGNOSTIC_STDERR_EXCERPT_LIMIT);
306
- logs.push('[stderr] ' + text);
307
- if (onStderr) onStderr(text);
308
- });
309
- }
310
-
311
313
  // Timeout handling per §20.4
312
314
  let timeoutHandle;
313
315
  let sigkillHandle;
@@ -578,7 +580,7 @@ function resolveStartupWatchdogMs(config, runtime) {
578
580
  if (Number.isInteger(config?.run_loop?.startup_watchdog_ms) && config.run_loop.startup_watchdog_ms > 0) {
579
581
  return config.run_loop.startup_watchdog_ms;
580
582
  }
581
- return 30_000;
583
+ return DEFAULT_STARTUP_WATCHDOG_MS;
582
584
  }
583
585
 
584
586
  /**
@@ -37,7 +37,26 @@ export function evaluateApprovalPolicy({ gateResult, gateType, state, config })
37
37
  return evaluatePhaseTransitionPolicy({ gateResult, state, config, policy });
38
38
  }
39
39
 
40
+ // BUG-59 (DEC-BUG59-CREDENTIALED-GATE-HARD-STOP-001): gate definitions may
41
+ // carry `credentialed: true` to mark gates protecting external, irreversible,
42
+ // or operator-owned credentialed actions. Credentialed gates are never
43
+ // auto-approvable by policy, even under a catch-all `default: auto_approve`
44
+ // rule. The guard runs before any rule evaluation so a missing `when` block
45
+ // cannot bypass it.
46
+ function isCredentialedGate(config, gateId) {
47
+ if (!gateId) return false;
48
+ return config?.gates?.[gateId]?.credentialed === true;
49
+ }
50
+
40
51
  function evaluateRunCompletionPolicy({ gateResult, state, config, policy }) {
52
+ if (isCredentialedGate(config, gateResult?.gate_id)) {
53
+ return {
54
+ action: 'require_human',
55
+ matched_rule: null,
56
+ reason: 'credentialed gate — policy auto-approval forbidden',
57
+ };
58
+ }
59
+
41
60
  const rc = policy.run_completion;
42
61
  if (!rc || !rc.action) {
43
62
  return { action: 'require_human', matched_rule: null, reason: 'no run_completion policy' };
@@ -59,6 +78,14 @@ function evaluateRunCompletionPolicy({ gateResult, state, config, policy }) {
59
78
  }
60
79
 
61
80
  function evaluatePhaseTransitionPolicy({ gateResult, state, config, policy }) {
81
+ if (isCredentialedGate(config, gateResult?.gate_id)) {
82
+ return {
83
+ action: 'require_human',
84
+ matched_rule: null,
85
+ reason: 'credentialed gate — policy auto-approval forbidden',
86
+ };
87
+ }
88
+
62
89
  const pt = policy.phase_transitions;
63
90
  if (!pt) {
64
91
  return { action: 'require_human', matched_rule: null, reason: 'no phase_transitions policy' };
@@ -120,6 +147,23 @@ function checkConditions(when, { gateResult, state, config }) {
120
147
  }
121
148
  }
122
149
 
150
+ // credentialed_gate (BUG-59, DEC-BUG59-CREDENTIALED-GATE-PREDICATE-NEGATIVE-ONLY-001):
151
+ // only `false` is a valid runtime value — asserts the gate is NOT credentialed
152
+ // as a defensive precondition. Credentialed gates are hard-stopped upstream so
153
+ // this predicate never sees them when value is `false` (matches → condition ok).
154
+ // Value `true` is treated as unmet because the hard-stop prevents credentialed
155
+ // gates from reaching condition evaluation anyway; schema validation (slice 2)
156
+ // will reject `true` at config load time for unambiguous intent.
157
+ if (Object.prototype.hasOwnProperty.call(when, 'credentialed_gate')) {
158
+ const gateIsCredentialed = config?.gates?.[gateResult?.gate_id]?.credentialed === true;
159
+ if (when.credentialed_gate === false && gateIsCredentialed) {
160
+ return { ok: false, reason: 'condition credentialed_gate: false not met — gate is credentialed' };
161
+ }
162
+ if (when.credentialed_gate === true) {
163
+ return { ok: false, reason: 'condition credentialed_gate: true not supported — credentialed gates are hard-stopped upstream' };
164
+ }
165
+ }
166
+
123
167
  // all_phases_visited: every routing phase must appear in history
124
168
  if (when.all_phases_visited === true) {
125
169
  const routingPhases = Object.keys(config.routing || {});
@@ -1383,6 +1383,10 @@ function classifyAcceptanceOverlap(targetTurn, conflictFiles, historyEntries, co
1383
1383
  const forwardRevisionTurns = new Map();
1384
1384
 
1385
1385
  for (const entry of historyEntries) {
1386
+ if (targetTurn?.run_id && entry.run_id && entry.run_id !== targetTurn.run_id) {
1387
+ continue;
1388
+ }
1389
+
1386
1390
  if ((entry.accepted_sequence || 0) <= (targetTurn.assigned_sequence || 0)) {
1387
1391
  continue;
1388
1392
  }
@@ -2096,6 +2100,24 @@ function inferApprovalPauseFromState(state, config) {
2096
2100
  };
2097
2101
  }
2098
2102
 
2103
+ function shouldNormalizeApprovalPauseBlock(state, inferred) {
2104
+ if (!state || typeof state !== 'object') {
2105
+ return false;
2106
+ }
2107
+
2108
+ const blockedOn = typeof state.blocked_on === 'string' ? state.blocked_on : '';
2109
+ const recovery = state.blocked_reason?.recovery;
2110
+ const typedReason = recovery?.typed_reason || null;
2111
+
2112
+ if (blockedOn.startsWith('human_approval:')) {
2113
+ return true;
2114
+ }
2115
+
2116
+ return state.status === 'paused'
2117
+ && state.blocked_reason != null
2118
+ && (!typedReason || typedReason === inferred.typedReason);
2119
+ }
2120
+
2099
2121
  export function reconcileApprovalPausesWithConfig(state, config) {
2100
2122
  if (!state || typeof state !== 'object' || !config) {
2101
2123
  return { state, changed: false };
@@ -2117,7 +2139,7 @@ export function reconcileApprovalPausesWithConfig(state, config) {
2117
2139
  changed = true;
2118
2140
  }
2119
2141
 
2120
- if (nextState.status === 'blocked' || nextState.blocked_reason != null) {
2142
+ if (shouldNormalizeApprovalPauseBlock(nextState, inferred)) {
2121
2143
  nextState = {
2122
2144
  ...nextState,
2123
2145
  status: 'paused',
@@ -2643,6 +2665,91 @@ export function reconcilePhaseAdvanceBeforeDispatch(root, config, state = null)
2643
2665
  });
2644
2666
 
2645
2667
  if (gateResult.action === 'awaiting_human_approval') {
2668
+ // BUG-59 (DEC-BUG59-PLAN-LAYERED-FIX-001, slice 3): before falling back to
2669
+ // the BUG-52 "human already unblocked" advancement path, consult
2670
+ // approval_policy. If the configured policy auto-approves this transition
2671
+ // (and the gate is not credentialed), advance directly and write an
2672
+ // `approval_policy` ledger entry matching the accepted-turn path shape at
2673
+ // governed-state.js:4909-4919. Credentialed gates are hard-stopped inside
2674
+ // evaluateApprovalPolicy per DEC-BUG59-CREDENTIALED-GATE-HARD-STOP-001, so
2675
+ // a credentialed gate lands here with action === 'require_human' and falls
2676
+ // through to the existing approvePhaseTransition path (which itself
2677
+ // requires paused/blocked status produced by a real human unblock).
2678
+ const approvalResult = evaluateApprovalPolicy({
2679
+ gateResult,
2680
+ gateType: 'phase_transition',
2681
+ state: { ...currentState, history: historyEntries },
2682
+ config,
2683
+ });
2684
+
2685
+ if (approvalResult.action === 'auto_approve') {
2686
+ const now = new Date().toISOString();
2687
+ const prevPhase = currentState.phase;
2688
+ const nextState = {
2689
+ ...currentState,
2690
+ phase: gateResult.next_phase,
2691
+ phase_entered_at: now,
2692
+ blocked_on: null,
2693
+ blocked_reason: null,
2694
+ last_gate_failure: null,
2695
+ pending_phase_transition: null,
2696
+ queued_phase_transition: null,
2697
+ phase_gate_status: {
2698
+ ...(currentState.phase_gate_status || {}),
2699
+ [gateResult.gate_id || 'no_gate']: 'passed',
2700
+ },
2701
+ };
2702
+ writeState(root, nextState);
2703
+ appendJsonl(root, LEDGER_PATH, {
2704
+ type: 'approval_policy',
2705
+ gate_type: 'phase_transition',
2706
+ action: 'auto_approve',
2707
+ matched_rule: approvalResult.matched_rule,
2708
+ from_phase: prevPhase,
2709
+ to_phase: gateResult.next_phase,
2710
+ reason: approvalResult.reason,
2711
+ gate_id: gateResult.gate_id || null,
2712
+ timestamp: now,
2713
+ });
2714
+ const retiredIntentIds = retireApprovedPhaseScopedIntents(root, nextState, config, prevPhase, now);
2715
+ if (retiredIntentIds.length > 0) {
2716
+ emitRunEvent(root, 'intent_retired_by_phase_advance', {
2717
+ run_id: nextState.run_id,
2718
+ phase: nextState.phase,
2719
+ status: nextState.status,
2720
+ turn: phaseSource.turn_id ? { turn_id: phaseSource.turn_id, role_id: phaseSource.role || phaseSource.assigned_role || null } : undefined,
2721
+ payload: {
2722
+ exited_phase: prevPhase,
2723
+ entered_phase: gateResult.next_phase,
2724
+ retired_count: retiredIntentIds.length,
2725
+ retired_intent_ids: retiredIntentIds,
2726
+ },
2727
+ });
2728
+ }
2729
+ emitRunEvent(root, 'phase_entered', {
2730
+ run_id: nextState.run_id,
2731
+ phase: nextState.phase,
2732
+ status: nextState.status,
2733
+ turn: phaseSource.turn_id ? { turn_id: phaseSource.turn_id, role_id: phaseSource.role || phaseSource.assigned_role || null } : undefined,
2734
+ payload: {
2735
+ from: prevPhase,
2736
+ to: gateResult.next_phase,
2737
+ gate_id: gateResult.gate_id || 'no_gate',
2738
+ trigger: 'auto_approved',
2739
+ },
2740
+ });
2741
+ return {
2742
+ ok: true,
2743
+ state: attachLegacyCurrentTurnAlias(nextState),
2744
+ advanced: true,
2745
+ from_phase: prevPhase,
2746
+ to_phase: gateResult.next_phase,
2747
+ gate_id: gateResult.gate_id || null,
2748
+ gateResult,
2749
+ approval_policy: approvalResult,
2750
+ };
2751
+ }
2752
+
2646
2753
  const pausedState = {
2647
2754
  ...currentState,
2648
2755
  status: 'paused',
@@ -2667,6 +2774,7 @@ export function reconcilePhaseAdvanceBeforeDispatch(root, config, state = null)
2667
2774
  to_phase: approved.state?.phase || gateResult.next_phase || null,
2668
2775
  gate_id: gateResult.gate_id || null,
2669
2776
  gateResult,
2777
+ approval_policy: approvalResult,
2670
2778
  };
2671
2779
  }
2672
2780
 
@@ -3034,12 +3142,20 @@ export function assignGovernedTurn(root, config, roleId, options = {}) {
3034
3142
  const concurrentWith = Object.keys(activeTurns);
3035
3143
 
3036
3144
  // Build the new turn object
3145
+ // `started_at` is seeded at assignment so that direct assign→accept flows
3146
+ // (non-dispatched, non-subprocess turns) still carry timing into history and
3147
+ // the turn_accepted event payload (per TURN_TIMING_OBSERVABILITY_SPEC.md).
3148
+ // BUG-51's dispatched-lifecycle path explicitly deletes this and the
3149
+ // starting/running transitions re-set it, so dispatch-driven turns still
3150
+ // reflect true subprocess-startup timing.
3037
3151
  const newTurn = {
3038
3152
  turn_id: turnId,
3153
+ run_id: state.run_id,
3039
3154
  assigned_role: roleId,
3040
3155
  status: 'assigned',
3041
3156
  attempt: 1,
3042
3157
  assigned_at: now,
3158
+ started_at: now,
3043
3159
  deadline_at: new Date(Date.now() + timeoutMinutes * 60 * 1000).toISOString(),
3044
3160
  runtime_id: runtimeId,
3045
3161
  baseline,
@@ -5469,6 +5585,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
5469
5585
  status: 'completed',
5470
5586
  payload: { completed_at: updatedState.completed_at || now },
5471
5587
  });
5588
+ recordRunHistory(root, updatedState, config, 'completed');
5472
5589
  }
5473
5590
 
5474
5591
  // Session checkpoint — non-fatal, written after every successful acceptance
@@ -83,6 +83,7 @@ const VALID_SCAFFOLD_BLUEPRINT_KEYS = new Set([
83
83
  'gates',
84
84
  'policies',
85
85
  'workflow_kit',
86
+ 'approval_policy',
86
87
  ]);
87
88
 
88
89
  function validateScaffoldBlueprint(scaffoldBlueprint, errors) {
@@ -489,8 +489,8 @@ function isAcceptedRepoHistoryEntry(entry) {
489
489
  return Boolean(entry?.accepted_at) || entry?.status === 'accepted';
490
490
  }
491
491
 
492
- const REPO_FAILURE_STATUSES = new Set(['failed_acceptance', 'failed', 'rejected', 'retrying', 'conflicted']);
493
- const RETRYABLE_COORDINATOR_FAILURE_STATUSES = new Set(['failed', 'failed_acceptance']);
492
+ const REPO_FAILURE_STATUSES = new Set(['failed_acceptance', 'failed', 'failed_start', 'rejected', 'retrying', 'conflicted']);
493
+ const RETRYABLE_COORDINATOR_FAILURE_STATUSES = new Set(['failed', 'failed_acceptance', 'failed_start']);
494
494
 
495
495
  function getLatestRepoDispatches(launchRecord) {
496
496
  const latestByRepo = new Map();
@@ -708,6 +708,18 @@ function synchronizeCoordinatorWorkstreamStatuses(root, plan, coordinatorConfig,
708
708
  continue;
709
709
  }
710
710
 
711
+ if (launchRecord?.status === 'failed' || (launchRecord?.terminal_reason && launchRecord.terminal_reason !== 'completed')) {
712
+ if (ws.launch_status !== 'needs_attention') {
713
+ ws.launch_status = 'needs_attention';
714
+ changed = true;
715
+ }
716
+ if (plan.status !== 'needs_attention') {
717
+ plan.status = 'needs_attention';
718
+ changed = true;
719
+ }
720
+ continue;
721
+ }
722
+
711
723
  if ((launchRecord?.repo_dispatches?.length || 0) > 0 || progress.accepted_repo_count > 0) {
712
724
  if (ws.launch_status !== 'launched') {
713
725
  ws.launch_status = 'launched';
@@ -557,6 +557,7 @@ export function validateV4Config(data, projectRoot) {
557
557
  // Gates (optional but validated if present)
558
558
  if (data.gates) {
559
559
  validateGateActionsConfig(data.gates, errors);
560
+ validateGateCredentialedConfig(data.gates, errors);
560
561
  if (data.gates && typeof data.gates === 'object' && !Array.isArray(data.gates) && data.routing) {
561
562
  for (const [, route] of Object.entries(data.routing)) {
562
563
  if (route.exit_gate && !data.gates[route.exit_gate]) {
@@ -996,6 +997,21 @@ export function validateWorkflowKitConfig(wk, routing, roles, runtimes = {}) {
996
997
 
997
998
  const VALID_APPROVAL_ACTIONS = ['auto_approve', 'require_human'];
998
999
 
1000
+ function validateGateCredentialedConfig(gates, errors) {
1001
+ if (!gates || typeof gates !== 'object' || Array.isArray(gates)) {
1002
+ return;
1003
+ }
1004
+
1005
+ for (const [gateId, gate] of Object.entries(gates)) {
1006
+ if (!gate || typeof gate !== 'object' || Array.isArray(gate)) {
1007
+ continue;
1008
+ }
1009
+ if (gate.credentialed !== undefined && typeof gate.credentialed !== 'boolean') {
1010
+ errors.push(`gates.${gateId}.credentialed must be a boolean when provided`);
1011
+ }
1012
+ }
1013
+ }
1014
+
999
1015
  /**
1000
1016
  * Validate the approval_policy config section.
1001
1017
  * Returns an array of error strings.
@@ -1098,6 +1114,13 @@ function validateApprovalWhen(when, prefix) {
1098
1114
  if (when.all_phases_visited !== undefined && typeof when.all_phases_visited !== 'boolean') {
1099
1115
  errors.push(`${prefix}.when.all_phases_visited must be a boolean`);
1100
1116
  }
1117
+ if (when.credentialed_gate !== undefined) {
1118
+ if (typeof when.credentialed_gate !== 'boolean') {
1119
+ errors.push(`${prefix}.when.credentialed_gate must be a boolean`);
1120
+ } else if (when.credentialed_gate !== false) {
1121
+ errors.push(`${prefix}.when.credentialed_gate must be false when provided (DEC-BUG59-CREDENTIALED-GATE-PREDICATE-NEGATIVE-ONLY-001)`);
1122
+ }
1123
+ }
1101
1124
  return errors;
1102
1125
  }
1103
1126
 
@@ -61,7 +61,14 @@
61
61
  "type": ["array", "object"]
62
62
  },
63
63
  "approval_policy": {
64
- "type": ["object", "null"]
64
+ "oneOf": [
65
+ {
66
+ "$ref": "#/$defs/approval_policy"
67
+ },
68
+ {
69
+ "type": "null"
70
+ }
71
+ ]
65
72
  },
66
73
  "timeouts": {
67
74
  "type": ["object", "null"]
@@ -91,7 +98,7 @@
91
98
  "startup_watchdog_ms": {
92
99
  "type": "integer",
93
100
  "minimum": 1,
94
- "description": "Milliseconds to wait after dispatch for worker attach/first-output proof before retaining the turn as failed_start. Default 30000."
101
+ "description": "Milliseconds to wait after dispatch for worker attach/first-output proof before retaining the turn as failed_start. Default 180000."
95
102
  },
96
103
  "stale_turn_threshold_ms": {
97
104
  "type": "integer",
@@ -220,6 +227,87 @@
220
227
  },
221
228
  "requires_verification_pass": {
222
229
  "type": "boolean"
230
+ },
231
+ "credentialed": {
232
+ "type": "boolean",
233
+ "description": "When true, this gate protects a credentialed, irreversible, or operator-owned action and cannot be auto-approved by approval_policy."
234
+ }
235
+ },
236
+ "additionalProperties": true
237
+ },
238
+ "approval_policy": {
239
+ "type": "object",
240
+ "properties": {
241
+ "phase_transitions": {
242
+ "$ref": "#/$defs/approval_phase_transitions"
243
+ },
244
+ "run_completion": {
245
+ "$ref": "#/$defs/approval_run_completion"
246
+ }
247
+ },
248
+ "additionalProperties": true
249
+ },
250
+ "approval_phase_transitions": {
251
+ "type": "object",
252
+ "properties": {
253
+ "default": {
254
+ "enum": ["auto_approve", "require_human"]
255
+ },
256
+ "rules": {
257
+ "type": "array",
258
+ "items": {
259
+ "$ref": "#/$defs/approval_phase_rule"
260
+ }
261
+ }
262
+ },
263
+ "additionalProperties": true
264
+ },
265
+ "approval_phase_rule": {
266
+ "type": "object",
267
+ "properties": {
268
+ "from_phase": {
269
+ "$ref": "#/$defs/non_empty_string"
270
+ },
271
+ "to_phase": {
272
+ "$ref": "#/$defs/non_empty_string"
273
+ },
274
+ "action": {
275
+ "enum": ["auto_approve", "require_human"]
276
+ },
277
+ "when": {
278
+ "$ref": "#/$defs/approval_when"
279
+ }
280
+ },
281
+ "additionalProperties": true
282
+ },
283
+ "approval_run_completion": {
284
+ "type": "object",
285
+ "properties": {
286
+ "action": {
287
+ "enum": ["auto_approve", "require_human"]
288
+ },
289
+ "when": {
290
+ "$ref": "#/$defs/approval_when"
291
+ }
292
+ },
293
+ "additionalProperties": true
294
+ },
295
+ "approval_when": {
296
+ "type": "object",
297
+ "properties": {
298
+ "gate_passed": {
299
+ "type": "boolean"
300
+ },
301
+ "roles_participated": {
302
+ "$ref": "#/$defs/string_array"
303
+ },
304
+ "all_phases_visited": {
305
+ "type": "boolean"
306
+ },
307
+ "credentialed_gate": {
308
+ "type": "boolean",
309
+ "enum": [false],
310
+ "description": "Only false is valid. Credentialed gates are hard-stopped before policy rule matching."
223
311
  }
224
312
  },
225
313
  "additionalProperties": true