agentxchain 2.37.0 → 2.38.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.
|
@@ -154,8 +154,19 @@ function renderContinuityPanel(continuity) {
|
|
|
154
154
|
html += `<div class="turn-detail"><span class="detail-label">Checkpoint:</span> No session checkpoint recorded</div>`;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
if (continuity.
|
|
158
|
-
html += `<div class="turn-detail"><span class="detail-label">
|
|
157
|
+
if (continuity.drift_detected === true && Array.isArray(continuity.drift_warnings) && continuity.drift_warnings.length > 0) {
|
|
158
|
+
html += `<div class="turn-detail risks"><span class="detail-label">Drift:</span><ul>`;
|
|
159
|
+
for (const warning of continuity.drift_warnings) {
|
|
160
|
+
html += `<li>${esc(warning)}</li>`;
|
|
161
|
+
}
|
|
162
|
+
html += `</ul></div>`;
|
|
163
|
+
} else if (continuity.drift_detected === false) {
|
|
164
|
+
html += `<div class="turn-detail"><span class="detail-label">Drift:</span> none detected since checkpoint</div>`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (continuity.recommended_command) {
|
|
168
|
+
const detail = continuity.recommended_detail ? ` (${continuity.recommended_detail})` : '';
|
|
169
|
+
html += `<div class="turn-detail"><span class="detail-label">Action:</span> <span class="mono">${esc(continuity.recommended_command)}</span>${esc(detail)}</div>`;
|
|
159
170
|
}
|
|
160
171
|
|
|
161
172
|
if (continuity.recovery_report_path) {
|
package/package.json
CHANGED
package/src/commands/restart.js
CHANGED
|
@@ -23,13 +23,13 @@ import {
|
|
|
23
23
|
HISTORY_PATH,
|
|
24
24
|
LEDGER_PATH,
|
|
25
25
|
} from '../lib/governed-state.js';
|
|
26
|
-
import { readSessionCheckpoint, SESSION_PATH } from '../lib/session-checkpoint.js';
|
|
26
|
+
import { readSessionCheckpoint, writeSessionCheckpoint, captureBaselineRef, SESSION_PATH } from '../lib/session-checkpoint.js';
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* Generate a session recovery report summarizing the run state
|
|
30
30
|
* so a new agent session can orient quickly.
|
|
31
31
|
*/
|
|
32
|
-
function generateRecoveryReport(root, state, checkpoint) {
|
|
32
|
+
function generateRecoveryReport(root, state, checkpoint, driftWarnings = []) {
|
|
33
33
|
const lines = [
|
|
34
34
|
'# Session Recovery Report',
|
|
35
35
|
'',
|
|
@@ -102,7 +102,50 @@ function generateRecoveryReport(root, state, checkpoint) {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
// Pending gate / run completion surfacing
|
|
106
|
+
if (state.pending_phase_transition) {
|
|
107
|
+
const pt = state.pending_phase_transition;
|
|
108
|
+
lines.push(
|
|
109
|
+
'## Pending Phase Transition',
|
|
110
|
+
'',
|
|
111
|
+
`- **From**: ${pt.from}`,
|
|
112
|
+
`- **To**: ${pt.to}`,
|
|
113
|
+
`- **Gate**: ${pt.gate}`,
|
|
114
|
+
`- **Requested by**: ${pt.requested_by_turn || 'unknown'}`,
|
|
115
|
+
`- **Action**: Run \`agentxchain approve-transition\` to approve`,
|
|
116
|
+
'',
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (state.pending_run_completion) {
|
|
121
|
+
const pc = state.pending_run_completion;
|
|
122
|
+
lines.push(
|
|
123
|
+
'## Pending Run Completion',
|
|
124
|
+
'',
|
|
125
|
+
`- **Gate**: ${pc.gate}`,
|
|
126
|
+
`- **Requested by**: ${pc.requested_by_turn || 'unknown'}`,
|
|
127
|
+
`- **Action**: Run \`agentxchain approve-completion\` to approve`,
|
|
128
|
+
'',
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Repo-drift warnings
|
|
133
|
+
if (checkpoint?.baseline_ref && driftWarnings?.length > 0) {
|
|
134
|
+
lines.push('## Continuity Warnings', '');
|
|
135
|
+
for (const warning of driftWarnings) {
|
|
136
|
+
lines.push(`- ⚠ ${warning}`);
|
|
137
|
+
}
|
|
138
|
+
lines.push('');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
lines.push('## Next Steps', '');
|
|
142
|
+
if (state.pending_phase_transition) {
|
|
143
|
+
lines.push('A phase transition is pending approval. Run `agentxchain approve-transition` before assigning new turns.', '');
|
|
144
|
+
} else if (state.pending_run_completion) {
|
|
145
|
+
lines.push('A run completion is pending approval. Run `agentxchain approve-completion` to finalize.', '');
|
|
146
|
+
} else {
|
|
147
|
+
lines.push('The next turn has been assigned. Check the dispatch bundle for context.', '');
|
|
148
|
+
}
|
|
106
149
|
|
|
107
150
|
return lines.join('\n');
|
|
108
151
|
}
|
|
@@ -160,6 +203,42 @@ export async function restartCommand(opts) {
|
|
|
160
203
|
process.exit(1);
|
|
161
204
|
}
|
|
162
205
|
|
|
206
|
+
// ── Repo-drift detection ────────────────────────────────────────────────
|
|
207
|
+
const driftWarnings = [];
|
|
208
|
+
if (checkpoint?.baseline_ref) {
|
|
209
|
+
const currentBaseline = captureBaselineRef(root);
|
|
210
|
+
const prev = checkpoint.baseline_ref;
|
|
211
|
+
|
|
212
|
+
if (prev.git_head && currentBaseline.git_head && prev.git_head !== currentBaseline.git_head) {
|
|
213
|
+
driftWarnings.push(`Git HEAD has moved since checkpoint: ${prev.git_head.slice(0, 8)} → ${currentBaseline.git_head.slice(0, 8)}`);
|
|
214
|
+
}
|
|
215
|
+
if (prev.git_branch && currentBaseline.git_branch && prev.git_branch !== currentBaseline.git_branch) {
|
|
216
|
+
driftWarnings.push(`Branch changed since checkpoint: ${prev.git_branch} → ${currentBaseline.git_branch}`);
|
|
217
|
+
}
|
|
218
|
+
if (prev.workspace_dirty === false && currentBaseline.workspace_dirty === true) {
|
|
219
|
+
driftWarnings.push('Workspace was clean at checkpoint but is now dirty');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (driftWarnings.length > 0) {
|
|
224
|
+
for (const warning of driftWarnings) {
|
|
225
|
+
console.log(chalk.yellow(`⚠ ${warning}`));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Pending gate / completion check ────────────────────────────────────
|
|
230
|
+
if (state.pending_phase_transition) {
|
|
231
|
+
const pt = state.pending_phase_transition;
|
|
232
|
+
console.log(chalk.yellow(`Pending phase transition: ${pt.from} → ${pt.to} (gate: ${pt.gate})`));
|
|
233
|
+
console.log(chalk.dim('Run `agentxchain approve-transition` to approve before assigning new turns.'));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (state.pending_run_completion) {
|
|
237
|
+
const pc = state.pending_run_completion;
|
|
238
|
+
console.log(chalk.yellow(`Pending run completion (gate: ${pc.gate})`));
|
|
239
|
+
console.log(chalk.dim('Run `agentxchain approve-completion` to finalize.'));
|
|
240
|
+
}
|
|
241
|
+
|
|
163
242
|
// Handle abandoned active turns (assigned but never completed)
|
|
164
243
|
const activeTurns = getActiveTurns(state);
|
|
165
244
|
const activeTurnCount = getActiveTurnCount(state);
|
|
@@ -167,9 +246,29 @@ export async function restartCommand(opts) {
|
|
|
167
246
|
const turnIds = Object.keys(activeTurns);
|
|
168
247
|
console.log(chalk.yellow(`Warning: ${activeTurnCount} turn(s) were assigned but never completed: ${turnIds.join(', ')}`));
|
|
169
248
|
console.log(chalk.dim('These turns will be available for the next agent to complete.'));
|
|
249
|
+
|
|
250
|
+
// Fail closed if retained turn + irreconcilable drift
|
|
251
|
+
if (driftWarnings.length > 0) {
|
|
252
|
+
console.log(chalk.yellow('Active turns exist with repo drift since checkpoint. Reconnecting with warnings.'));
|
|
253
|
+
console.log(chalk.dim('Inspect the drift before continuing work on the retained turns.'));
|
|
254
|
+
}
|
|
170
255
|
}
|
|
171
256
|
|
|
172
|
-
// If
|
|
257
|
+
// If pending gate/completion, do not bypass — surface and exit with recovery
|
|
258
|
+
if (state.pending_phase_transition || state.pending_run_completion) {
|
|
259
|
+
// Write checkpoint for the reconnect
|
|
260
|
+
writeSessionCheckpoint(root, state, 'restart_reconnect');
|
|
261
|
+
|
|
262
|
+
const recoveryReport = generateRecoveryReport(root, state, checkpoint, driftWarnings);
|
|
263
|
+
const recoveryPath = join(root, '.agentxchain/SESSION_RECOVERY.md');
|
|
264
|
+
const recoveryDir = dirname(recoveryPath);
|
|
265
|
+
if (!existsSync(recoveryDir)) mkdirSync(recoveryDir, { recursive: true });
|
|
266
|
+
writeFileSync(recoveryPath, recoveryReport);
|
|
267
|
+
console.log(chalk.dim(` Recovery report: .agentxchain/SESSION_RECOVERY.md`));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// If paused or idle without a pending approval gate, reactivate.
|
|
173
272
|
if (state.status === 'paused' || state.status === 'idle') {
|
|
174
273
|
const reactivated = reactivateGovernedRun(root, state, {
|
|
175
274
|
reason: 'session_restart',
|
|
@@ -198,6 +297,8 @@ export async function restartCommand(opts) {
|
|
|
198
297
|
process.exit(1);
|
|
199
298
|
}
|
|
200
299
|
|
|
300
|
+
// assignGovernedTurn already writes a checkpoint at turn_assigned
|
|
301
|
+
|
|
201
302
|
console.log(chalk.green(`✓ Restarted run ${state.run_id}`));
|
|
202
303
|
console.log(chalk.dim(` Phase: ${phase}`));
|
|
203
304
|
console.log(chalk.dim(` Turn: ${assignment.turn?.id || 'assigned'}`));
|
|
@@ -206,6 +307,9 @@ export async function restartCommand(opts) {
|
|
|
206
307
|
console.log(chalk.dim(` Last checkpoint: ${checkpoint.checkpoint_reason} at ${checkpoint.last_checkpoint_at}`));
|
|
207
308
|
}
|
|
208
309
|
} else {
|
|
310
|
+
// Reconnect to existing active turns — write checkpoint
|
|
311
|
+
writeSessionCheckpoint(root, state, 'restart_reconnect');
|
|
312
|
+
|
|
209
313
|
console.log(chalk.green(`✓ Reconnected to run ${state.run_id}`));
|
|
210
314
|
console.log(chalk.dim(` Phase: ${phase}`));
|
|
211
315
|
console.log(chalk.dim(` Active turns: ${Object.keys(activeTurns).join(', ')}`));
|
|
@@ -213,7 +317,7 @@ export async function restartCommand(opts) {
|
|
|
213
317
|
}
|
|
214
318
|
|
|
215
319
|
// Write session recovery report
|
|
216
|
-
const recoveryReport = generateRecoveryReport(root, state, checkpoint);
|
|
320
|
+
const recoveryReport = generateRecoveryReport(root, state, checkpoint, driftWarnings);
|
|
217
321
|
const recoveryPath = join(root, '.agentxchain/SESSION_RECOVERY.md');
|
|
218
322
|
const recoveryDir = dirname(recoveryPath);
|
|
219
323
|
if (!existsSync(recoveryDir)) mkdirSync(recoveryDir, { recursive: true });
|
package/src/commands/status.js
CHANGED
|
@@ -268,8 +268,21 @@ function renderContinuityStatus(continuity, state) {
|
|
|
268
268
|
console.log(` ${chalk.dim('Checkpoint:')} ${chalk.yellow('No session checkpoint recorded')}`);
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
-
if (continuity.
|
|
272
|
-
|
|
271
|
+
if (continuity.drift_detected === true) {
|
|
272
|
+
const [firstWarning, ...remainingWarnings] = continuity.drift_warnings || [];
|
|
273
|
+
if (firstWarning) {
|
|
274
|
+
console.log(` ${chalk.dim('Drift:')} ${chalk.yellow(firstWarning)}`);
|
|
275
|
+
}
|
|
276
|
+
for (const warning of remainingWarnings) {
|
|
277
|
+
console.log(` ${chalk.dim(' ')} ${chalk.yellow(warning)}`);
|
|
278
|
+
}
|
|
279
|
+
} else if (continuity.drift_detected === false) {
|
|
280
|
+
console.log(` ${chalk.dim('Drift:')} ${chalk.green('none detected since checkpoint')}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (continuity.recommended_command) {
|
|
284
|
+
const detail = continuity.recommended_detail ? ` (${continuity.recommended_detail})` : '';
|
|
285
|
+
console.log(` ${chalk.dim('Action:')} ${chalk.cyan(continuity.recommended_command)}${chalk.dim(detail)}`);
|
|
273
286
|
}
|
|
274
287
|
|
|
275
288
|
if (continuity.recovery_report_path) {
|
|
@@ -1,9 +1,111 @@
|
|
|
1
1
|
import { existsSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
-
import { readSessionCheckpoint } from './session-checkpoint.js';
|
|
3
|
+
import { captureBaselineRef, readSessionCheckpoint } from './session-checkpoint.js';
|
|
4
4
|
|
|
5
5
|
export const SESSION_RECOVERY_PATH = '.agentxchain/SESSION_RECOVERY.md';
|
|
6
6
|
|
|
7
|
+
function deriveRecommendedContinuityAction(state) {
|
|
8
|
+
if (!state) {
|
|
9
|
+
return {
|
|
10
|
+
recommended_command: null,
|
|
11
|
+
recommended_reason: 'no_state',
|
|
12
|
+
recommended_detail: null,
|
|
13
|
+
restart_recommended: false,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (state.pending_phase_transition) {
|
|
18
|
+
const pt = state.pending_phase_transition;
|
|
19
|
+
return {
|
|
20
|
+
recommended_command: 'agentxchain approve-transition',
|
|
21
|
+
recommended_reason: 'pending_phase_transition',
|
|
22
|
+
recommended_detail: `${pt.from || 'unknown'} -> ${pt.to || 'unknown'} (gate: ${pt.gate || 'unknown'})`,
|
|
23
|
+
restart_recommended: false,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (state.pending_run_completion) {
|
|
28
|
+
const pc = state.pending_run_completion;
|
|
29
|
+
return {
|
|
30
|
+
recommended_command: 'agentxchain approve-completion',
|
|
31
|
+
recommended_reason: 'pending_run_completion',
|
|
32
|
+
recommended_detail: pc.gate ? `gate: ${pc.gate}` : null,
|
|
33
|
+
restart_recommended: false,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!['blocked', 'completed', 'failed'].includes(state.status)) {
|
|
38
|
+
return {
|
|
39
|
+
recommended_command: 'agentxchain restart',
|
|
40
|
+
recommended_reason: 'restart_available',
|
|
41
|
+
recommended_detail: 'rebuild session context from disk',
|
|
42
|
+
restart_recommended: true,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
recommended_command: null,
|
|
48
|
+
recommended_reason: state.status === 'blocked' ? 'blocked' : 'terminal_state',
|
|
49
|
+
recommended_detail: null,
|
|
50
|
+
restart_recommended: false,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function deriveCheckpointDrift(root, checkpoint, staleCheckpoint) {
|
|
55
|
+
if (!checkpoint?.baseline_ref || staleCheckpoint) {
|
|
56
|
+
return {
|
|
57
|
+
drift_detected: null,
|
|
58
|
+
drift_warnings: [],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const currentBaseline = captureBaselineRef(root);
|
|
63
|
+
const previousBaseline = checkpoint.baseline_ref;
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
currentBaseline.git_head == null
|
|
67
|
+
&& currentBaseline.git_branch == null
|
|
68
|
+
&& currentBaseline.workspace_dirty == null
|
|
69
|
+
) {
|
|
70
|
+
return {
|
|
71
|
+
drift_detected: null,
|
|
72
|
+
drift_warnings: [],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const driftWarnings = [];
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
previousBaseline.git_head
|
|
80
|
+
&& currentBaseline.git_head
|
|
81
|
+
&& previousBaseline.git_head !== currentBaseline.git_head
|
|
82
|
+
) {
|
|
83
|
+
driftWarnings.push(
|
|
84
|
+
`Git HEAD has moved since checkpoint: ${previousBaseline.git_head.slice(0, 8)} -> ${currentBaseline.git_head.slice(0, 8)}`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (
|
|
89
|
+
previousBaseline.git_branch
|
|
90
|
+
&& currentBaseline.git_branch
|
|
91
|
+
&& previousBaseline.git_branch !== currentBaseline.git_branch
|
|
92
|
+
) {
|
|
93
|
+
driftWarnings.push(`Branch changed since checkpoint: ${previousBaseline.git_branch} -> ${currentBaseline.git_branch}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (
|
|
97
|
+
previousBaseline.workspace_dirty === false
|
|
98
|
+
&& currentBaseline.workspace_dirty === true
|
|
99
|
+
) {
|
|
100
|
+
driftWarnings.push('Workspace was clean at checkpoint but is now dirty');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
drift_detected: driftWarnings.length > 0,
|
|
105
|
+
drift_warnings: driftWarnings,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
7
109
|
export function getContinuityStatus(root, state) {
|
|
8
110
|
const checkpoint = readSessionCheckpoint(root);
|
|
9
111
|
const recoveryReportPath = existsSync(join(root, SESSION_RECOVERY_PATH))
|
|
@@ -18,10 +120,18 @@ export function getContinuityStatus(root, state) {
|
|
|
18
120
|
&& checkpoint.run_id !== state.run_id
|
|
19
121
|
);
|
|
20
122
|
|
|
123
|
+
const action = deriveRecommendedContinuityAction(state);
|
|
124
|
+
const drift = deriveCheckpointDrift(root, checkpoint, staleCheckpoint);
|
|
125
|
+
|
|
21
126
|
return {
|
|
22
127
|
checkpoint,
|
|
23
128
|
stale_checkpoint: staleCheckpoint,
|
|
24
129
|
recovery_report_path: recoveryReportPath,
|
|
25
|
-
restart_recommended:
|
|
130
|
+
restart_recommended: action.restart_recommended,
|
|
131
|
+
recommended_command: action.recommended_command,
|
|
132
|
+
recommended_reason: action.recommended_reason,
|
|
133
|
+
recommended_detail: action.recommended_detail,
|
|
134
|
+
drift_detected: drift.drift_detected,
|
|
135
|
+
drift_warnings: drift.drift_warnings,
|
|
26
136
|
};
|
|
27
137
|
}
|
|
@@ -1352,6 +1352,11 @@ export function markRunBlocked(root, details) {
|
|
|
1352
1352
|
|
|
1353
1353
|
writeState(root, updatedState);
|
|
1354
1354
|
|
|
1355
|
+
// Session checkpoint — non-fatal, written after blocked state is persisted
|
|
1356
|
+
writeSessionCheckpoint(root, updatedState, 'blocked', {
|
|
1357
|
+
role: turnId ? (getActiveTurns(updatedState)[turnId]?.assigned_role || null) : null,
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1355
1360
|
emitBlockedNotification(root, details.notificationConfig, updatedState, {
|
|
1356
1361
|
category: details.category,
|
|
1357
1362
|
blockedOn: details.blockedOn,
|
|
@@ -1753,6 +1758,13 @@ export function assignGovernedTurn(root, config, roleId) {
|
|
|
1753
1758
|
};
|
|
1754
1759
|
|
|
1755
1760
|
writeState(root, updatedState);
|
|
1761
|
+
|
|
1762
|
+
// Session checkpoint — non-fatal, written after every successful turn assignment
|
|
1763
|
+
writeSessionCheckpoint(root, updatedState, 'turn_assigned', {
|
|
1764
|
+
role: roleId,
|
|
1765
|
+
dispatch_dir: `.agentxchain/dispatch/turns/${turnId}`,
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1756
1768
|
const assignedTurn = updatedState.active_turns[turnId];
|
|
1757
1769
|
const result = { ok: true, state: attachLegacyCurrentTurnAlias(updatedState), turn: assignedTurn };
|
|
1758
1770
|
if (warnings.length > 0) {
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Session checkpoint — automatic state markers for cross-session restart.
|
|
3
3
|
*
|
|
4
4
|
* Writes .agentxchain/session.json at every governance boundary
|
|
5
|
-
* (turn acceptance, phase transition,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* (turn assignment, acceptance, phase transition, blocked state,
|
|
6
|
+
* gate approval, run completion, restart/reconnect) so that
|
|
7
|
+
* `agentxchain restart` can reconstruct dispatch context without
|
|
8
|
+
* any in-memory session state.
|
|
8
9
|
*
|
|
9
10
|
* Design rules:
|
|
10
11
|
* - Checkpoint writes are non-fatal: failures log a warning and do not
|
|
@@ -12,11 +13,13 @@
|
|
|
12
13
|
* - The file is always overwritten, not appended.
|
|
13
14
|
* - run_id in session.json must agree with state.json; mismatch is a
|
|
14
15
|
* corruption signal.
|
|
16
|
+
* - state.json is always authoritative; session.json is recovery metadata.
|
|
15
17
|
*/
|
|
16
18
|
|
|
17
19
|
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
|
|
18
20
|
import { join, dirname } from 'path';
|
|
19
21
|
import { randomBytes } from 'crypto';
|
|
22
|
+
import { execSync as shellExec } from 'child_process';
|
|
20
23
|
|
|
21
24
|
const SESSION_PATH = '.agentxchain/session.json';
|
|
22
25
|
|
|
@@ -27,6 +30,25 @@ function generateSessionId() {
|
|
|
27
30
|
return `session_${randomBytes(8).toString('hex')}`;
|
|
28
31
|
}
|
|
29
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Capture git baseline ref for repo-drift detection.
|
|
35
|
+
* Non-fatal: returns partial/null fields on failure.
|
|
36
|
+
*/
|
|
37
|
+
export function captureBaselineRef(root) {
|
|
38
|
+
try {
|
|
39
|
+
const gitHead = shellExec('git rev-parse HEAD', { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
40
|
+
const gitBranch = shellExec('git rev-parse --abbrev-ref HEAD', { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
41
|
+
const statusOutput = shellExec('git status --porcelain', { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
42
|
+
return {
|
|
43
|
+
git_head: gitHead,
|
|
44
|
+
git_branch: gitBranch,
|
|
45
|
+
workspace_dirty: statusOutput.length > 0,
|
|
46
|
+
};
|
|
47
|
+
} catch {
|
|
48
|
+
return { git_head: null, git_branch: null, workspace_dirty: null };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
30
52
|
/**
|
|
31
53
|
* Read the current session checkpoint, or null if none exists.
|
|
32
54
|
*/
|
|
@@ -40,12 +62,21 @@ export function readSessionCheckpoint(root) {
|
|
|
40
62
|
}
|
|
41
63
|
}
|
|
42
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Extract active turn IDs from governed state.
|
|
67
|
+
*/
|
|
68
|
+
function getActiveTurnIds(state) {
|
|
69
|
+
const turns = state.active_turns || {};
|
|
70
|
+
return Object.keys(turns);
|
|
71
|
+
}
|
|
72
|
+
|
|
43
73
|
/**
|
|
44
74
|
* Write or update the session checkpoint.
|
|
45
75
|
*
|
|
46
76
|
* @param {string} root - project root
|
|
47
77
|
* @param {object} state - current governed state (from state.json)
|
|
48
|
-
* @param {string} reason - checkpoint reason (e.g. '
|
|
78
|
+
* @param {string} reason - checkpoint reason (e.g. 'turn_assigned', 'turn_accepted',
|
|
79
|
+
* 'phase_approved', 'run_completed', 'blocked', 'restart_reconnect')
|
|
49
80
|
* @param {object} [extra] - optional extra context fields
|
|
50
81
|
*/
|
|
51
82
|
export function writeSessionCheckpoint(root, state, reason, extra = {}) {
|
|
@@ -58,20 +89,40 @@ export function writeSessionCheckpoint(root, state, reason, extra = {}) {
|
|
|
58
89
|
: generateSessionId();
|
|
59
90
|
|
|
60
91
|
const currentTurn = state.current_turn || null;
|
|
61
|
-
const
|
|
92
|
+
const activeTurnIds = getActiveTurnIds(state);
|
|
93
|
+
const lastTurnId = currentTurn?.id || currentTurn?.turn_id
|
|
94
|
+
|| (activeTurnIds.length > 0 ? activeTurnIds[activeTurnIds.length - 1] : null)
|
|
95
|
+
|| state.last_completed_turn_id || null;
|
|
62
96
|
const lastRole = currentTurn?.role || currentTurn?.assigned_role || extra.role || null;
|
|
63
97
|
const lastPhase = state.current_phase || state.phase || null;
|
|
64
98
|
|
|
99
|
+
// Derive last_completed_turn_id from history or state
|
|
100
|
+
const lastCompletedTurnId = state.last_completed_turn_id || existing?.last_completed_turn_id || null;
|
|
101
|
+
|
|
102
|
+
// Derive pending gates
|
|
103
|
+
const pendingGate = state.pending_phase_transition?.gate || state.pending_transition?.gate || null;
|
|
104
|
+
const pendingRunCompletion = state.pending_run_completion?.gate || null;
|
|
105
|
+
|
|
106
|
+
// Capture git baseline for repo-drift detection
|
|
107
|
+
const baselineRef = extra.baseline_ref || captureBaselineRef(root);
|
|
108
|
+
|
|
65
109
|
const checkpoint = {
|
|
66
110
|
session_id: sessionId,
|
|
67
111
|
run_id: state.run_id,
|
|
68
112
|
started_at: existing?.started_at || new Date().toISOString(),
|
|
69
113
|
last_checkpoint_at: new Date().toISOString(),
|
|
114
|
+
checkpoint_reason: reason,
|
|
115
|
+
run_status: state.status || null,
|
|
116
|
+
phase: lastPhase,
|
|
117
|
+
last_phase: lastPhase, // backward compat alias for report.js consumers
|
|
70
118
|
last_turn_id: lastTurnId,
|
|
71
|
-
|
|
119
|
+
last_completed_turn_id: lastCompletedTurnId,
|
|
120
|
+
active_turn_ids: activeTurnIds,
|
|
72
121
|
last_role: lastRole,
|
|
73
|
-
|
|
74
|
-
|
|
122
|
+
pending_gate: pendingGate,
|
|
123
|
+
pending_run_completion: pendingRunCompletion ? true : null,
|
|
124
|
+
blocked: state.status === 'blocked',
|
|
125
|
+
baseline_ref: baselineRef,
|
|
75
126
|
agent_context: {
|
|
76
127
|
adapter: extra.adapter || null,
|
|
77
128
|
dispatch_dir: extra.dispatch_dir || null,
|