agentxchain 2.154.3 → 2.154.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/resume.js +112 -1
package/package.json
CHANGED
package/src/commands/resume.js
CHANGED
|
@@ -44,6 +44,101 @@ import { summarizeRunProvenance } from '../lib/run-provenance.js';
|
|
|
44
44
|
import { consumeNextApprovedIntent } from '../lib/intake.js';
|
|
45
45
|
import { reconcileStaleTurns } from '../lib/stale-turn-watchdog.js';
|
|
46
46
|
|
|
47
|
+
function hasStandingPendingExitGate(state, config) {
|
|
48
|
+
const phase = state?.phase;
|
|
49
|
+
const gateId = phase ? config?.routing?.[phase]?.exit_gate : null;
|
|
50
|
+
return Boolean(gateId && (state?.phase_gate_status || {})[gateId] === 'pending');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getStandingPendingExitGate(state, config) {
|
|
54
|
+
const phase = state?.phase;
|
|
55
|
+
const gateId = phase ? config?.routing?.[phase]?.exit_gate : null;
|
|
56
|
+
if (!gateId || (state?.phase_gate_status || {})[gateId] !== 'pending') {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return config?.gates?.[gateId] || null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function entrySatisfiesSyntheticGateVerification(gate, entry) {
|
|
63
|
+
if (!gate?.requires_verification_pass) return true;
|
|
64
|
+
const verificationStatus = entry?.verification?.status;
|
|
65
|
+
return verificationStatus === 'pass' || verificationStatus === 'attested_pass';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function turnContributedToHumanApprovalGateArtifacts(root, state, config, entry) {
|
|
69
|
+
// Returns true only when the accepted turn itself produced at least one of
|
|
70
|
+
// the phase exit gate's required_files AND all required_files are present
|
|
71
|
+
// on disk. This distinguishes a PM turn that finished phase work and
|
|
72
|
+
// escalated for final sign-off (BUG-52 third variant) from a generic
|
|
73
|
+
// escalation where the agent blocked BEFORE writing gate artifacts
|
|
74
|
+
// (schedule-daemon `needs_decision` fixture).
|
|
75
|
+
const phase = state?.phase;
|
|
76
|
+
const gateId = phase ? config?.routing?.[phase]?.exit_gate : null;
|
|
77
|
+
if (!gateId) return false;
|
|
78
|
+
const gate = config?.gates?.[gateId];
|
|
79
|
+
if (!gate || !Array.isArray(gate.requires_files) || gate.requires_files.length === 0) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
if (!gate.requires_human_approval) return false;
|
|
83
|
+
const filesChanged = Array.isArray(entry?.files_changed) ? entry.files_changed : [];
|
|
84
|
+
const required = gate.requires_files.filter((p) => typeof p === 'string' && p.trim());
|
|
85
|
+
if (required.length === 0) return false;
|
|
86
|
+
const changedSet = new Set(filesChanged.filter((p) => typeof p === 'string'));
|
|
87
|
+
const contributed = required.some((relPath) => changedSet.has(relPath));
|
|
88
|
+
if (!contributed) return false;
|
|
89
|
+
for (const relPath of required) {
|
|
90
|
+
if (!existsSync(join(root, relPath))) return false;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function latestCompletedTurnWantsPhaseContinuation(root, state, config) {
|
|
96
|
+
const turnId = state?.last_completed_turn_id || state?.blocked_reason?.turn_id || null;
|
|
97
|
+
if (!turnId) return false;
|
|
98
|
+
const historyPath = join(root, config?.files?.history || '.agentxchain/history.jsonl');
|
|
99
|
+
if (!existsSync(historyPath)) return false;
|
|
100
|
+
const lines = readFileSync(historyPath, 'utf8').trim().split('\n').filter(Boolean);
|
|
101
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
102
|
+
let entry = null;
|
|
103
|
+
try {
|
|
104
|
+
entry = JSON.parse(lines[index]);
|
|
105
|
+
} catch {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (entry?.turn_id !== turnId) continue;
|
|
109
|
+
if (entry.phase_transition_request) return true;
|
|
110
|
+
const standingGate = getStandingPendingExitGate(state, config);
|
|
111
|
+
const proposed = typeof entry.proposed_next_role === 'string' ? entry.proposed_next_role.trim() : '';
|
|
112
|
+
if (proposed && proposed !== 'human') {
|
|
113
|
+
return entrySatisfiesSyntheticGateVerification(standingGate, entry);
|
|
114
|
+
}
|
|
115
|
+
// Turn 205 extension (refines DEC-BUG52-UNBLOCK-STANDING-GATE-DISCRIMINATOR-001):
|
|
116
|
+
// The realistic BUG-52 third-variant PM shape sets `status: 'needs_human'`,
|
|
117
|
+
// `phase_transition_request: null`, and `proposed_next_role: 'human'` — the
|
|
118
|
+
// PM is escalating specifically for the phase exit gate's human-approval
|
|
119
|
+
// check and has already written the gate's required artifacts to disk.
|
|
120
|
+
// When the current phase's exit gate requires human approval, all of its
|
|
121
|
+
// `requires_files` are present on disk, and any gate-level verification
|
|
122
|
+
// predicate was satisfied by the accepted turn, an `unblock` on that
|
|
123
|
+
// escalation IS the final gate approval and must run the standing-gate
|
|
124
|
+
// reconcile.
|
|
125
|
+
//
|
|
126
|
+
// Distinguished from generic schedule-daemon `needs_decision` escalations
|
|
127
|
+
// (which block BEFORE the agent writes gate artifacts, so `requires_files`
|
|
128
|
+
// are not yet on disk) — those correctly continue to re-dispatch the
|
|
129
|
+
// in-phase role rather than force-advancing the phase.
|
|
130
|
+
if (
|
|
131
|
+
entry.status === 'needs_human'
|
|
132
|
+
&& entrySatisfiesSyntheticGateVerification(standingGate, entry)
|
|
133
|
+
&& turnContributedToHumanApprovalGateArtifacts(root, state, config, entry)
|
|
134
|
+
) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
47
142
|
export async function resumeCommand(opts) {
|
|
48
143
|
const context = loadProjectContext();
|
|
49
144
|
if (!context) {
|
|
@@ -143,7 +238,23 @@ export async function resumeCommand(opts) {
|
|
|
143
238
|
// patched defensively) once the schema citation + migration citation are
|
|
144
239
|
// documented in code and the coverage matrix.
|
|
145
240
|
|
|
146
|
-
|
|
241
|
+
// BUG-52 third variant (Turn 203/204): operator_unblock must run the
|
|
242
|
+
// standing-gate reconcile path regardless of `activeCount`, but only when the
|
|
243
|
+
// current phase actually has a standing pending exit gate and the blocked
|
|
244
|
+
// turn was trying to continue into a non-human phase role. The tester's
|
|
245
|
+
// v2.151.0 lights-out repro on `tusq.dev` left `active_turns: {}` with
|
|
246
|
+
// `phase_gate_status.planning_signoff: "pending"`, so the old
|
|
247
|
+
// `activeCount > 0` guard skipped the only path that could synthesize the
|
|
248
|
+
// missing transition source. Non-gate human escalations (OAuth, external
|
|
249
|
+
// decisions, schedule recovery with `proposed_next_role: "human"`) must keep
|
|
250
|
+
// the normal unblock/resume behavior instead of being forced through a
|
|
251
|
+
// phase-transition materialization check.
|
|
252
|
+
if (
|
|
253
|
+
state.status === 'blocked'
|
|
254
|
+
&& resumeVia === 'operator_unblock'
|
|
255
|
+
&& hasStandingPendingExitGate(state, config)
|
|
256
|
+
&& latestCompletedTurnWantsPhaseContinuation(root, state, config)
|
|
257
|
+
) {
|
|
147
258
|
const reactivated = reactivateGovernedRun(root, state, { via: resumeVia, notificationConfig: config });
|
|
148
259
|
if (!reactivated.ok) {
|
|
149
260
|
console.log(chalk.red(`Failed to reactivate blocked run: ${reactivated.error}`));
|