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.
- package/package.json +3 -2
- package/scripts/collect-pack-sha-diagnostic.mjs +344 -0
- package/scripts/prepublish-gate.sh +132 -0
- package/scripts/release-bump.sh +2 -2
- package/scripts/render-github-release-body.mjs +11 -4
- package/scripts/reproduce-bug-54.mjs +81 -15
- package/src/commands/init.js +36 -4
- package/src/commands/mission.js +6 -2
- package/src/commands/restart.js +1 -1
- package/src/commands/turn.js +14 -5
- package/src/lib/adapters/local-cli-adapter.js +25 -23
- package/src/lib/approval-policy.js +44 -0
- package/src/lib/governed-state.js +118 -1
- package/src/lib/governed-templates.js +1 -0
- package/src/lib/mission-plans.js +14 -2
- package/src/lib/normalized-config.js +23 -0
- package/src/lib/schemas/agentxchain-config.schema.json +90 -2
- package/src/lib/stale-turn-watchdog.js +3 -3
- package/src/templates/governed/enterprise-app.json +35 -5
package/src/commands/init.js
CHANGED
|
@@ -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,
|
package/src/commands/mission.js
CHANGED
|
@@ -1377,7 +1377,9 @@ export async function missionPlanAutopilotCommand(planTarget, opts) {
|
|
|
1377
1377
|
}
|
|
1378
1378
|
|
|
1379
1379
|
if (waveNum === maxWaves) {
|
|
1380
|
-
terminalReason =
|
|
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 =
|
|
2035
|
+
terminalReason = totalFailed > 0
|
|
2036
|
+
? (continueOnFailure ? 'plan_incomplete' : 'failure_stopped')
|
|
2037
|
+
: 'wave_limit_reached';
|
|
2034
2038
|
break;
|
|
2035
2039
|
}
|
|
2036
2040
|
|
package/src/commands/restart.js
CHANGED
|
@@ -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) {
|
package/src/commands/turn.js
CHANGED
|
@@ -120,7 +120,14 @@ function buildArtifactIndex(root, turnId) {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
function buildTurnPayload(turnId, turn, state, artifacts, assignment, config) {
|
|
123
|
-
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
166
|
-
console.log(` ${chalk.dim('Started:')} ${
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
package/src/lib/mission-plans.js
CHANGED
|
@@ -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
|
-
"
|
|
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
|
|
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
|