agentxchain 2.33.1 → 2.35.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/bin/agentxchain.js +7 -0
- package/package.json +1 -1
- package/scripts/release-bump.sh +68 -11
- package/src/commands/restart.js +222 -0
- package/src/commands/status.js +67 -0
- package/src/lib/dashboard/state-reader.js +2 -0
- package/src/lib/export.js +2 -0
- package/src/lib/governed-state.js +12 -0
- package/src/lib/repo-observer.js +1 -0
- package/src/lib/report.js +73 -0
- package/src/lib/session-checkpoint.js +92 -0
package/bin/agentxchain.js
CHANGED
|
@@ -76,6 +76,7 @@ import { approveCompletionCommand } from '../src/commands/approve-completion.js'
|
|
|
76
76
|
import { dashboardCommand } from '../src/commands/dashboard.js';
|
|
77
77
|
import { exportCommand } from '../src/commands/export.js';
|
|
78
78
|
import { restoreCommand } from '../src/commands/restore.js';
|
|
79
|
+
import { restartCommand } from '../src/commands/restart.js';
|
|
79
80
|
import { reportCommand } from '../src/commands/report.js';
|
|
80
81
|
import {
|
|
81
82
|
pluginInstallCommand,
|
|
@@ -146,6 +147,12 @@ program
|
|
|
146
147
|
.requiredOption('--input <path>', 'Path to a prior run export artifact')
|
|
147
148
|
.action(restoreCommand);
|
|
148
149
|
|
|
150
|
+
program
|
|
151
|
+
.command('restart')
|
|
152
|
+
.description('Restart a governed run from the last checkpoint (cross-session recovery)')
|
|
153
|
+
.option('--role <role>', 'Override the next role assignment')
|
|
154
|
+
.action(restartCommand);
|
|
155
|
+
|
|
149
156
|
program
|
|
150
157
|
.command('report')
|
|
151
158
|
.description('Render a human-readable governance summary from an export artifact')
|
package/package.json
CHANGED
package/scripts/release-bump.sh
CHANGED
|
@@ -81,7 +81,7 @@ stage_if_present() {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
# 1. Assert only allowed release-surface dirt is present
|
|
84
|
-
echo "[1/
|
|
84
|
+
echo "[1/8] Checking release-prep tree state..."
|
|
85
85
|
DISALLOWED_DIRTY=()
|
|
86
86
|
while IFS= read -r status_line; do
|
|
87
87
|
[[ -z "$status_line" ]] && continue
|
|
@@ -99,7 +99,7 @@ fi
|
|
|
99
99
|
echo " OK: tree contains only allowed release-prep changes"
|
|
100
100
|
|
|
101
101
|
# 2. Assert not already at target version
|
|
102
|
-
echo "[2/
|
|
102
|
+
echo "[2/8] Checking current version..."
|
|
103
103
|
CURRENT_VERSION=$(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).version)")
|
|
104
104
|
if [[ "$CURRENT_VERSION" == "$TARGET_VERSION" ]]; then
|
|
105
105
|
echo "FAIL: package.json is already at ${TARGET_VERSION}. Cannot double-bump." >&2
|
|
@@ -108,20 +108,77 @@ fi
|
|
|
108
108
|
echo " OK: current version is ${CURRENT_VERSION}, bumping to ${TARGET_VERSION}"
|
|
109
109
|
|
|
110
110
|
# 3. Assert tag does not already exist
|
|
111
|
-
echo "[3/
|
|
111
|
+
echo "[3/8] Checking for existing tag..."
|
|
112
112
|
if git rev-parse "v${TARGET_VERSION}" >/dev/null 2>&1; then
|
|
113
113
|
echo "FAIL: tag v${TARGET_VERSION} already exists. Delete it first or choose a different version." >&2
|
|
114
114
|
exit 1
|
|
115
115
|
fi
|
|
116
116
|
echo " OK: tag v${TARGET_VERSION} does not exist"
|
|
117
117
|
|
|
118
|
-
# 4.
|
|
119
|
-
|
|
118
|
+
# 4. Pre-bump version-surface alignment guard
|
|
119
|
+
# Ensures all governed version surfaces already reference the target version
|
|
120
|
+
# BEFORE the bump commit is created. This catches stale drift that would
|
|
121
|
+
# otherwise only be discovered after minting local release identities.
|
|
122
|
+
echo "[4/8] Verifying version-surface alignment for ${TARGET_VERSION}..."
|
|
123
|
+
SURFACE_ERRORS=()
|
|
124
|
+
|
|
125
|
+
# 4a. CHANGELOG top heading
|
|
126
|
+
CHANGELOG_TOP=$(grep -m1 -E '^## [0-9]+\.[0-9]+\.[0-9]+$' "${REPO_ROOT}/cli/CHANGELOG.md" 2>/dev/null | sed 's/^## //' || true)
|
|
127
|
+
if [[ "$CHANGELOG_TOP" != "$TARGET_VERSION" ]]; then
|
|
128
|
+
SURFACE_ERRORS+=("CHANGELOG.md top heading is '${CHANGELOG_TOP:-missing}', expected '${TARGET_VERSION}'")
|
|
129
|
+
fi
|
|
130
|
+
|
|
131
|
+
# 4b. Release notes page exists
|
|
132
|
+
RELEASE_DOC_ID="v${TARGET_VERSION//./-}"
|
|
133
|
+
RELEASE_DOC_PATH="website-v2/docs/releases/${RELEASE_DOC_ID}.mdx"
|
|
134
|
+
if [[ ! -f "${REPO_ROOT}/${RELEASE_DOC_PATH}" ]]; then
|
|
135
|
+
SURFACE_ERRORS+=("release notes page missing: ${RELEASE_DOC_PATH}")
|
|
136
|
+
fi
|
|
137
|
+
|
|
138
|
+
# 4c. Docs sidebar links the release page
|
|
139
|
+
if ! grep -q "'releases/${RELEASE_DOC_ID}'" "${REPO_ROOT}/website-v2/sidebars.ts" 2>/dev/null; then
|
|
140
|
+
SURFACE_ERRORS+=("sidebars.ts does not link 'releases/${RELEASE_DOC_ID}'")
|
|
141
|
+
fi
|
|
142
|
+
|
|
143
|
+
# 4d. Homepage hero badge shows target version
|
|
144
|
+
if ! grep -q "v${TARGET_VERSION}" "${REPO_ROOT}/website-v2/src/pages/index.tsx" 2>/dev/null; then
|
|
145
|
+
SURFACE_ERRORS+=("homepage index.tsx does not contain 'v${TARGET_VERSION}'")
|
|
146
|
+
fi
|
|
147
|
+
|
|
148
|
+
# 4e. Conformance capabilities version
|
|
149
|
+
CAPS_VERSION=$(node -e "try{console.log(JSON.parse(require('fs').readFileSync('${REPO_ROOT}/.agentxchain-conformance/capabilities.json','utf8')).version)}catch{console.log('missing')}" 2>/dev/null || echo "missing")
|
|
150
|
+
if [[ "$CAPS_VERSION" != "$TARGET_VERSION" ]]; then
|
|
151
|
+
SURFACE_ERRORS+=("capabilities.json version is '${CAPS_VERSION}', expected '${TARGET_VERSION}'")
|
|
152
|
+
fi
|
|
153
|
+
|
|
154
|
+
# 4f. Protocol implementor guide example
|
|
155
|
+
if ! grep -q "\"version\": \"${TARGET_VERSION}\"" "${REPO_ROOT}/website-v2/docs/protocol-implementor-guide.mdx" 2>/dev/null; then
|
|
156
|
+
SURFACE_ERRORS+=("protocol-implementor-guide.mdx does not contain '\"version\": \"${TARGET_VERSION}\"'")
|
|
157
|
+
fi
|
|
158
|
+
|
|
159
|
+
# 4g. Launch evidence report title
|
|
160
|
+
ESCAPED_VERSION="${TARGET_VERSION//./\\.}"
|
|
161
|
+
if ! grep -qE "^# Launch Evidence Report — AgentXchain v${ESCAPED_VERSION}" "${REPO_ROOT}/.planning/LAUNCH_EVIDENCE_REPORT.md" 2>/dev/null; then
|
|
162
|
+
SURFACE_ERRORS+=("LAUNCH_EVIDENCE_REPORT.md title does not carry v${TARGET_VERSION}")
|
|
163
|
+
fi
|
|
164
|
+
|
|
165
|
+
if [[ "${#SURFACE_ERRORS[@]}" -gt 0 ]]; then
|
|
166
|
+
echo "FAIL: ${#SURFACE_ERRORS[@]} version-surface(s) not aligned to ${TARGET_VERSION}:" >&2
|
|
167
|
+
printf ' - %s\n' "${SURFACE_ERRORS[@]}" >&2
|
|
168
|
+
echo "" >&2
|
|
169
|
+
echo "Fix these surfaces before running release-bump. The bump script refuses to" >&2
|
|
170
|
+
echo "create release identity when governed surfaces are stale." >&2
|
|
171
|
+
exit 1
|
|
172
|
+
fi
|
|
173
|
+
echo " OK: all 7 governed version surfaces reference ${TARGET_VERSION}"
|
|
174
|
+
|
|
175
|
+
# 5. Update version files (no git operations)
|
|
176
|
+
echo "[5/8] Updating version files..."
|
|
120
177
|
npm version "$TARGET_VERSION" --no-git-tag-version
|
|
121
178
|
echo " OK: package.json updated to ${TARGET_VERSION}"
|
|
122
179
|
|
|
123
|
-
#
|
|
124
|
-
echo "[
|
|
180
|
+
# 6. Stage version files
|
|
181
|
+
echo "[6/8] Staging version files..."
|
|
125
182
|
git add -- package.json
|
|
126
183
|
if [[ -f package-lock.json ]]; then
|
|
127
184
|
git add -- package-lock.json
|
|
@@ -131,8 +188,8 @@ for rel_path in "${ALLOWED_RELEASE_PATHS[@]}"; do
|
|
|
131
188
|
done
|
|
132
189
|
echo " OK: version files and allowed release surfaces staged"
|
|
133
190
|
|
|
134
|
-
#
|
|
135
|
-
echo "[
|
|
191
|
+
# 7. Create release commit
|
|
192
|
+
echo "[7/8] Creating release commit..."
|
|
136
193
|
git commit -m "${TARGET_VERSION}"
|
|
137
194
|
RELEASE_SHA=$(git rev-parse HEAD)
|
|
138
195
|
COMMIT_MSG=$(git log -1 --format=%s)
|
|
@@ -142,8 +199,8 @@ if [[ "$COMMIT_MSG" != "$TARGET_VERSION" ]]; then
|
|
|
142
199
|
fi
|
|
143
200
|
echo " OK: commit ${RELEASE_SHA:0:7} with message '${TARGET_VERSION}'"
|
|
144
201
|
|
|
145
|
-
#
|
|
146
|
-
echo "[
|
|
202
|
+
# 8. Create annotated tag
|
|
203
|
+
echo "[8/8] Creating annotated tag..."
|
|
147
204
|
git tag -a "v${TARGET_VERSION}" -m "v${TARGET_VERSION}"
|
|
148
205
|
TAG_SHA=$(git rev-parse "v${TARGET_VERSION}")
|
|
149
206
|
if [[ -z "$TAG_SHA" ]]; then
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentxchain restart — governed-only command.
|
|
3
|
+
*
|
|
4
|
+
* Reconstructs dispatch context from durable checkpoint state and assigns the
|
|
5
|
+
* next turn. Unlike `resume` (which assumes the caller has session context),
|
|
6
|
+
* `restart` assumes the caller has NO session context and rebuilds everything
|
|
7
|
+
* from .agentxchain/session.json + .agentxchain/state.json.
|
|
8
|
+
*
|
|
9
|
+
* Use case: terminal died mid-run, machine restarted, new agent session
|
|
10
|
+
* picking up a long-horizon governed run.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
15
|
+
import { join, dirname } from 'path';
|
|
16
|
+
import { loadProjectContext, loadProjectState } from '../lib/config.js';
|
|
17
|
+
import {
|
|
18
|
+
assignGovernedTurn,
|
|
19
|
+
getActiveTurns,
|
|
20
|
+
getActiveTurnCount,
|
|
21
|
+
reactivateGovernedRun,
|
|
22
|
+
STATE_PATH,
|
|
23
|
+
HISTORY_PATH,
|
|
24
|
+
LEDGER_PATH,
|
|
25
|
+
} from '../lib/governed-state.js';
|
|
26
|
+
import { readSessionCheckpoint, SESSION_PATH } from '../lib/session-checkpoint.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Generate a session recovery report summarizing the run state
|
|
30
|
+
* so a new agent session can orient quickly.
|
|
31
|
+
*/
|
|
32
|
+
function generateRecoveryReport(root, state, checkpoint) {
|
|
33
|
+
const lines = [
|
|
34
|
+
'# Session Recovery Report',
|
|
35
|
+
'',
|
|
36
|
+
`> Auto-generated by \`agentxchain restart\` at ${new Date().toISOString()}`,
|
|
37
|
+
'',
|
|
38
|
+
'## Run Identity',
|
|
39
|
+
'',
|
|
40
|
+
`- **Run ID**: \`${state.run_id}\``,
|
|
41
|
+
`- **Project**: \`${state.project_id || 'unknown'}\``,
|
|
42
|
+
`- **Status**: ${state.status}`,
|
|
43
|
+
`- **Phase**: ${state.phase || state.current_phase || 'unknown'}`,
|
|
44
|
+
'',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
if (checkpoint) {
|
|
48
|
+
lines.push(
|
|
49
|
+
'## Last Checkpoint',
|
|
50
|
+
'',
|
|
51
|
+
`- **Session**: \`${checkpoint.session_id}\``,
|
|
52
|
+
`- **Last turn**: \`${checkpoint.last_turn_id || 'none'}\``,
|
|
53
|
+
`- **Last role**: ${checkpoint.last_role || 'unknown'}`,
|
|
54
|
+
`- **Reason**: ${checkpoint.checkpoint_reason}`,
|
|
55
|
+
`- **Time**: ${checkpoint.last_checkpoint_at}`,
|
|
56
|
+
'',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Recent decisions from ledger
|
|
61
|
+
const ledgerPath = join(root, LEDGER_PATH);
|
|
62
|
+
if (existsSync(ledgerPath)) {
|
|
63
|
+
try {
|
|
64
|
+
const entries = readFileSync(ledgerPath, 'utf8')
|
|
65
|
+
.trim()
|
|
66
|
+
.split('\n')
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.map(l => JSON.parse(l));
|
|
69
|
+
const recent = entries.slice(-5);
|
|
70
|
+
if (recent.length > 0) {
|
|
71
|
+
lines.push('## Recent Decisions', '');
|
|
72
|
+
for (const entry of recent) {
|
|
73
|
+
lines.push(`- \`${entry.decision_id || entry.id || 'unknown'}\`: ${entry.summary || entry.description || JSON.stringify(entry).slice(0, 100)}`);
|
|
74
|
+
}
|
|
75
|
+
lines.push('');
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Non-fatal
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Turn history summary
|
|
83
|
+
const historyPath = join(root, HISTORY_PATH);
|
|
84
|
+
if (existsSync(historyPath)) {
|
|
85
|
+
try {
|
|
86
|
+
const entries = readFileSync(historyPath, 'utf8')
|
|
87
|
+
.trim()
|
|
88
|
+
.split('\n')
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.map(l => JSON.parse(l));
|
|
91
|
+
lines.push(`## Turn History`, '', `Total turns completed: ${entries.length}`, '');
|
|
92
|
+
const recent = entries.slice(-3);
|
|
93
|
+
if (recent.length > 0) {
|
|
94
|
+
lines.push('### Last 3 Turns', '');
|
|
95
|
+
for (const entry of recent) {
|
|
96
|
+
lines.push(`- **${entry.turn_id}** (${entry.role}, phase: ${entry.phase}): ${entry.status}`);
|
|
97
|
+
}
|
|
98
|
+
lines.push('');
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// Non-fatal
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
lines.push('## Next Steps', '', 'The next turn has been assigned. Check the dispatch bundle for context.', '');
|
|
106
|
+
|
|
107
|
+
return lines.join('\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function restartCommand(opts) {
|
|
111
|
+
const context = loadProjectContext();
|
|
112
|
+
if (!context) {
|
|
113
|
+
console.log(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const { root, config } = context;
|
|
118
|
+
|
|
119
|
+
if (config.protocol_mode !== 'governed') {
|
|
120
|
+
console.log(chalk.red('The restart command is only available for governed projects.'));
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Load state
|
|
125
|
+
const statePath = join(root, STATE_PATH);
|
|
126
|
+
if (!existsSync(statePath)) {
|
|
127
|
+
console.log(chalk.red('No governed run found. Use `agentxchain resume` or `agentxchain run` to start.'));
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const state = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
132
|
+
|
|
133
|
+
// Load checkpoint (optional — restart can work without it, just with less context)
|
|
134
|
+
const checkpoint = readSessionCheckpoint(root);
|
|
135
|
+
|
|
136
|
+
// Validate run_id agreement if checkpoint exists
|
|
137
|
+
if (checkpoint && checkpoint.run_id !== state.run_id) {
|
|
138
|
+
console.log(chalk.red(`Checkpoint run_id mismatch: session.json has ${checkpoint.run_id}, state.json has ${state.run_id}`));
|
|
139
|
+
console.log(chalk.dim('The checkpoint is stale. Proceeding with state.json as ground truth.'));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Check terminal states
|
|
143
|
+
if (state.status === 'completed') {
|
|
144
|
+
console.log(chalk.red('Run is in terminal state: completed.'));
|
|
145
|
+
console.log(chalk.dim(`Run ${state.run_id} completed at ${state.completed_at || 'unknown'}.`));
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (state.status === 'failed') {
|
|
150
|
+
console.log(chalk.red('Run is in terminal state: failed.'));
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (state.status === 'blocked') {
|
|
155
|
+
console.log(chalk.red('Run is blocked. Resolve the blocker first.'));
|
|
156
|
+
if (state.blocked_reason) {
|
|
157
|
+
console.log(chalk.dim(`Reason: ${typeof state.blocked_reason === 'string' ? state.blocked_reason : JSON.stringify(state.blocked_reason)}`));
|
|
158
|
+
}
|
|
159
|
+
console.log(chalk.dim('Use `agentxchain step --resume` or resolve the blocker, then try again.'));
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Handle abandoned active turns (assigned but never completed)
|
|
164
|
+
const activeTurns = getActiveTurns(state);
|
|
165
|
+
const activeTurnCount = getActiveTurnCount(state);
|
|
166
|
+
if (activeTurnCount > 0 && state.status === 'active') {
|
|
167
|
+
const turnIds = Object.keys(activeTurns);
|
|
168
|
+
console.log(chalk.yellow(`Warning: ${activeTurnCount} turn(s) were assigned but never completed: ${turnIds.join(', ')}`));
|
|
169
|
+
console.log(chalk.dim('These turns will be available for the next agent to complete.'));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// If paused, reactivate
|
|
173
|
+
if (state.status === 'paused' || state.status === 'idle') {
|
|
174
|
+
const reactivated = reactivateGovernedRun(root, state, {
|
|
175
|
+
reason: 'session_restart',
|
|
176
|
+
});
|
|
177
|
+
if (!reactivated.ok) {
|
|
178
|
+
console.log(chalk.red(`Failed to reactivate run: ${reactivated.error}`));
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Determine role from option or routing
|
|
184
|
+
const phase = state.phase || state.current_phase;
|
|
185
|
+
const routing = config.routing?.[phase];
|
|
186
|
+
const roleId = opts.role || routing?.entry_role || Object.keys(config.roles || {})[0] || null;
|
|
187
|
+
|
|
188
|
+
if (!roleId) {
|
|
189
|
+
console.log(chalk.red('Cannot determine which role to assign. Use --role to specify.'));
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Assign next turn if no active turn exists
|
|
194
|
+
if (activeTurnCount === 0) {
|
|
195
|
+
const assignment = assignGovernedTurn(root, config, roleId);
|
|
196
|
+
if (!assignment.ok) {
|
|
197
|
+
console.log(chalk.red(`Failed to assign turn: ${assignment.error}`));
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.log(chalk.green(`✓ Restarted run ${state.run_id}`));
|
|
202
|
+
console.log(chalk.dim(` Phase: ${phase}`));
|
|
203
|
+
console.log(chalk.dim(` Turn: ${assignment.turn?.id || 'assigned'}`));
|
|
204
|
+
console.log(chalk.dim(` Role: ${assignment.turn?.role || roleId || 'routing default'}`));
|
|
205
|
+
if (checkpoint) {
|
|
206
|
+
console.log(chalk.dim(` Last checkpoint: ${checkpoint.checkpoint_reason} at ${checkpoint.last_checkpoint_at}`));
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
console.log(chalk.green(`✓ Reconnected to run ${state.run_id}`));
|
|
210
|
+
console.log(chalk.dim(` Phase: ${phase}`));
|
|
211
|
+
console.log(chalk.dim(` Active turns: ${Object.keys(activeTurns).join(', ')}`));
|
|
212
|
+
console.log(chalk.dim(' Complete the active turn(s) with `agentxchain accept-turn` or `agentxchain step`.'));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Write session recovery report
|
|
216
|
+
const recoveryReport = generateRecoveryReport(root, state, checkpoint);
|
|
217
|
+
const recoveryPath = join(root, '.agentxchain/SESSION_RECOVERY.md');
|
|
218
|
+
const recoveryDir = dirname(recoveryPath);
|
|
219
|
+
if (!existsSync(recoveryDir)) mkdirSync(recoveryDir, { recursive: true });
|
|
220
|
+
writeFileSync(recoveryPath, recoveryReport);
|
|
221
|
+
console.log(chalk.dim(` Recovery report: .agentxchain/SESSION_RECOVERY.md`));
|
|
222
|
+
}
|
package/src/commands/status.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
1
3
|
import chalk from 'chalk';
|
|
2
4
|
import { loadConfig, loadLock, loadProjectContext, loadProjectState, loadState } from '../lib/config.js';
|
|
3
5
|
import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
|
|
4
6
|
import { getActiveTurn, getActiveTurnCount, getActiveTurns } from '../lib/governed-state.js';
|
|
7
|
+
import { readSessionCheckpoint } from '../lib/session-checkpoint.js';
|
|
8
|
+
|
|
9
|
+
const SESSION_RECOVERY_PATH = '.agentxchain/SESSION_RECOVERY.md';
|
|
5
10
|
|
|
6
11
|
export async function statusCommand(opts) {
|
|
7
12
|
const context = loadProjectContext();
|
|
@@ -74,6 +79,7 @@ export async function statusCommand(opts) {
|
|
|
74
79
|
function renderGovernedStatus(context, opts) {
|
|
75
80
|
const { root, config, version } = context;
|
|
76
81
|
const state = loadProjectState(root, config);
|
|
82
|
+
const continuity = getContinuityStatus(root, state);
|
|
77
83
|
|
|
78
84
|
if (opts.json) {
|
|
79
85
|
console.log(JSON.stringify({
|
|
@@ -82,6 +88,7 @@ function renderGovernedStatus(context, opts) {
|
|
|
82
88
|
template: config.template || 'generic',
|
|
83
89
|
config,
|
|
84
90
|
state,
|
|
91
|
+
continuity,
|
|
85
92
|
}, null, 2));
|
|
86
93
|
return;
|
|
87
94
|
}
|
|
@@ -101,6 +108,8 @@ function renderGovernedStatus(context, opts) {
|
|
|
101
108
|
}
|
|
102
109
|
console.log('');
|
|
103
110
|
|
|
111
|
+
renderContinuityStatus(continuity, state);
|
|
112
|
+
|
|
104
113
|
const activeTurnCount = getActiveTurnCount(state);
|
|
105
114
|
const activeTurns = getActiveTurns(state);
|
|
106
115
|
const singleActiveTurn = getActiveTurn(state);
|
|
@@ -238,6 +247,64 @@ function renderGovernedStatus(context, opts) {
|
|
|
238
247
|
console.log('');
|
|
239
248
|
}
|
|
240
249
|
|
|
250
|
+
function getContinuityStatus(root, state) {
|
|
251
|
+
const checkpoint = readSessionCheckpoint(root);
|
|
252
|
+
const recoveryReportPath = existsSync(join(root, SESSION_RECOVERY_PATH))
|
|
253
|
+
? SESSION_RECOVERY_PATH
|
|
254
|
+
: null;
|
|
255
|
+
|
|
256
|
+
if (!checkpoint && !recoveryReportPath) return null;
|
|
257
|
+
|
|
258
|
+
const staleCheckpoint = !!(
|
|
259
|
+
checkpoint?.run_id
|
|
260
|
+
&& state?.run_id
|
|
261
|
+
&& checkpoint.run_id !== state.run_id
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
checkpoint,
|
|
266
|
+
stale_checkpoint: staleCheckpoint,
|
|
267
|
+
recovery_report_path: recoveryReportPath,
|
|
268
|
+
restart_recommended: !!state && !['blocked', 'completed', 'failed'].includes(state.status),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function renderContinuityStatus(continuity, state) {
|
|
273
|
+
if (!continuity) return;
|
|
274
|
+
|
|
275
|
+
console.log(` ${chalk.dim('Continuity:')}`);
|
|
276
|
+
|
|
277
|
+
if (continuity.checkpoint) {
|
|
278
|
+
const checkpoint = continuity.checkpoint;
|
|
279
|
+
const checkpointSummary = checkpoint.last_checkpoint_at
|
|
280
|
+
? `${checkpoint.checkpoint_reason || 'unknown'} at ${checkpoint.last_checkpoint_at}`
|
|
281
|
+
: (checkpoint.checkpoint_reason || 'unknown');
|
|
282
|
+
console.log(` ${chalk.dim('Session:')} ${checkpoint.session_id || chalk.dim('unknown')}`);
|
|
283
|
+
console.log(` ${chalk.dim('Checkpoint:')} ${checkpointSummary}`);
|
|
284
|
+
console.log(` ${chalk.dim('Last turn:')} ${checkpoint.last_turn_id || chalk.dim('none')}`);
|
|
285
|
+
console.log(` ${chalk.dim('Last role:')} ${checkpoint.last_role || chalk.dim('unknown')}`);
|
|
286
|
+
if (continuity.stale_checkpoint) {
|
|
287
|
+
console.log(
|
|
288
|
+
` ${chalk.dim('Warning:')} ${chalk.yellow(
|
|
289
|
+
`session checkpoint tracks ${checkpoint.run_id}, but state.json tracks ${state?.run_id || 'unknown'}; state.json remains source of truth`
|
|
290
|
+
)}`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
console.log(` ${chalk.dim('Checkpoint:')} ${chalk.yellow('No session checkpoint recorded')}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (continuity.restart_recommended) {
|
|
298
|
+
console.log(` ${chalk.dim('Restart:')} ${chalk.cyan('agentxchain restart')} (rebuild session context from disk)`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (continuity.recovery_report_path) {
|
|
302
|
+
console.log(` ${chalk.dim('Report:')} ${continuity.recovery_report_path}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
console.log('');
|
|
306
|
+
}
|
|
307
|
+
|
|
241
308
|
function formatPhase(phase) {
|
|
242
309
|
const colors = { discovery: chalk.blue, build: chalk.green, qa: chalk.yellow, deploy: chalk.magenta, blocked: chalk.red };
|
|
243
310
|
return (colors[phase] || chalk.white)(phase);
|
|
@@ -10,6 +10,7 @@ import { readFileSync, existsSync } from 'fs';
|
|
|
10
10
|
import { join, normalize } from 'path';
|
|
11
11
|
|
|
12
12
|
const STATE_FILE = 'state.json';
|
|
13
|
+
const SESSION_FILE = 'session.json';
|
|
13
14
|
const HISTORY_FILE = 'history.jsonl';
|
|
14
15
|
const LEDGER_FILE = 'decision-ledger.jsonl';
|
|
15
16
|
const HOOK_AUDIT_FILE = 'hook-audit.jsonl';
|
|
@@ -23,6 +24,7 @@ const BARRIER_LEDGER_FILE = 'barrier-ledger.jsonl';
|
|
|
23
24
|
*/
|
|
24
25
|
export const RESOURCE_MAP = {
|
|
25
26
|
'/api/state': STATE_FILE,
|
|
27
|
+
'/api/continuity': SESSION_FILE,
|
|
26
28
|
'/api/history': HISTORY_FILE,
|
|
27
29
|
'/api/ledger': LEDGER_FILE,
|
|
28
30
|
'/api/hooks/audit': HOOK_AUDIT_FILE,
|
package/src/lib/export.js
CHANGED
|
@@ -23,6 +23,7 @@ export const RUN_EXPORT_INCLUDED_ROOTS = [
|
|
|
23
23
|
'agentxchain.json',
|
|
24
24
|
'TALK.md',
|
|
25
25
|
'.agentxchain/state.json',
|
|
26
|
+
'.agentxchain/session.json',
|
|
26
27
|
'.agentxchain/history.jsonl',
|
|
27
28
|
'.agentxchain/decision-ledger.jsonl',
|
|
28
29
|
'.agentxchain/hook-audit.jsonl',
|
|
@@ -43,6 +44,7 @@ export const RUN_RESTORE_ROOTS = [
|
|
|
43
44
|
'agentxchain.json',
|
|
44
45
|
'TALK.md',
|
|
45
46
|
'.agentxchain/state.json',
|
|
47
|
+
'.agentxchain/session.json',
|
|
46
48
|
'.agentxchain/history.jsonl',
|
|
47
49
|
'.agentxchain/decision-ledger.jsonl',
|
|
48
50
|
'.agentxchain/hook-audit.jsonl',
|
|
@@ -35,6 +35,7 @@ import { getMaxConcurrentTurns } from './normalized-config.js';
|
|
|
35
35
|
import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir, getReviewArtifactPath } from './turn-paths.js';
|
|
36
36
|
import { runHooks } from './hook-runner.js';
|
|
37
37
|
import { emitNotifications } from './notification-runner.js';
|
|
38
|
+
import { writeSessionCheckpoint } from './session-checkpoint.js';
|
|
38
39
|
|
|
39
40
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
40
41
|
|
|
@@ -2483,6 +2484,11 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2483
2484
|
}, currentTurn);
|
|
2484
2485
|
}
|
|
2485
2486
|
|
|
2487
|
+
// Session checkpoint — non-fatal, written after every successful acceptance
|
|
2488
|
+
writeSessionCheckpoint(root, updatedState, 'turn_accepted', {
|
|
2489
|
+
role: historyEntry?.role,
|
|
2490
|
+
});
|
|
2491
|
+
|
|
2486
2492
|
return {
|
|
2487
2493
|
ok: true,
|
|
2488
2494
|
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
@@ -2791,6 +2797,9 @@ export function approvePhaseTransition(root, config) {
|
|
|
2791
2797
|
|
|
2792
2798
|
writeState(root, updatedState);
|
|
2793
2799
|
|
|
2800
|
+
// Session checkpoint — non-fatal
|
|
2801
|
+
writeSessionCheckpoint(root, updatedState, 'phase_approved');
|
|
2802
|
+
|
|
2794
2803
|
return {
|
|
2795
2804
|
ok: true,
|
|
2796
2805
|
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
@@ -2889,6 +2898,9 @@ export function approveRunCompletion(root, config) {
|
|
|
2889
2898
|
requested_by_turn: completion.requested_by_turn || null,
|
|
2890
2899
|
}, completion.requested_by_turn ? getActiveTurns(state)[completion.requested_by_turn] || null : null);
|
|
2891
2900
|
|
|
2901
|
+
// Session checkpoint — non-fatal
|
|
2902
|
+
writeSessionCheckpoint(root, updatedState, 'run_completed');
|
|
2903
|
+
|
|
2892
2904
|
return {
|
|
2893
2905
|
ok: true,
|
|
2894
2906
|
state: attachLegacyCurrentTurnAlias(updatedState),
|
package/src/lib/repo-observer.js
CHANGED
|
@@ -34,6 +34,7 @@ const OPERATIONAL_PATH_PREFIXES = [
|
|
|
34
34
|
// These are written exclusively by the orchestrator (§4.1 State Ownership Rule).
|
|
35
35
|
const ORCHESTRATOR_STATE_FILES = [
|
|
36
36
|
'.agentxchain/state.json',
|
|
37
|
+
'.agentxchain/session.json',
|
|
37
38
|
'.agentxchain/history.jsonl',
|
|
38
39
|
'.agentxchain/decision-ledger.jsonl',
|
|
39
40
|
'.agentxchain/lock.json',
|
package/src/lib/report.js
CHANGED
|
@@ -503,6 +503,30 @@ export function extractWorkflowKitArtifacts(artifact) {
|
|
|
503
503
|
.sort((a, b) => a.path.localeCompare(b.path, 'en'));
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
+
function extractContinuityMetadata(artifact) {
|
|
507
|
+
const checkpoint = extractFileData(artifact, '.agentxchain/session.json');
|
|
508
|
+
if (!checkpoint || typeof checkpoint !== 'object') return null;
|
|
509
|
+
|
|
510
|
+
const runId = artifact.summary?.run_id || artifact.state?.run_id || null;
|
|
511
|
+
const staleCheckpoint = !!(
|
|
512
|
+
checkpoint.run_id
|
|
513
|
+
&& runId
|
|
514
|
+
&& checkpoint.run_id !== runId
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
session_id: checkpoint.session_id || null,
|
|
519
|
+
run_id: checkpoint.run_id || null,
|
|
520
|
+
started_at: checkpoint.started_at || null,
|
|
521
|
+
last_checkpoint_at: checkpoint.last_checkpoint_at || null,
|
|
522
|
+
last_turn_id: checkpoint.last_turn_id || null,
|
|
523
|
+
last_phase: checkpoint.last_phase || null,
|
|
524
|
+
last_role: checkpoint.last_role || null,
|
|
525
|
+
checkpoint_reason: checkpoint.checkpoint_reason || null,
|
|
526
|
+
stale_checkpoint: staleCheckpoint,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
506
530
|
function buildRunSubject(artifact) {
|
|
507
531
|
const activeTurns = artifact.summary?.active_turn_ids || [];
|
|
508
532
|
const retainedTurns = artifact.summary?.retained_turn_ids || [];
|
|
@@ -519,6 +543,7 @@ function buildRunSubject(artifact) {
|
|
|
519
543
|
const gateSummary = extractGateSummary(artifact);
|
|
520
544
|
const intakeLinks = extractIntakeLinks(artifact);
|
|
521
545
|
const recoverySummary = extractRecoverySummary(artifact);
|
|
546
|
+
const continuity = extractContinuityMetadata(artifact);
|
|
522
547
|
|
|
523
548
|
return {
|
|
524
549
|
kind: 'governed_run',
|
|
@@ -550,6 +575,7 @@ function buildRunSubject(artifact) {
|
|
|
550
575
|
gate_summary: gateSummary,
|
|
551
576
|
intake_links: intakeLinks,
|
|
552
577
|
recovery_summary: recoverySummary,
|
|
578
|
+
continuity,
|
|
553
579
|
workflow_kit_artifacts: extractWorkflowKitArtifacts(artifact),
|
|
554
580
|
},
|
|
555
581
|
artifacts: {
|
|
@@ -620,6 +646,7 @@ function buildCoordinatorSubject(artifact) {
|
|
|
620
646
|
base.hook_summary = extractHookSummary(childExport);
|
|
621
647
|
base.gate_summary = extractGateSummary(childExport);
|
|
622
648
|
base.recovery_summary = extractRecoverySummary(childExport);
|
|
649
|
+
base.continuity = extractContinuityMetadata(childExport);
|
|
623
650
|
base.blocked_on = childExport.state?.blocked_on || null;
|
|
624
651
|
return base;
|
|
625
652
|
});
|
|
@@ -843,6 +870,18 @@ export function formatGovernanceReportText(report) {
|
|
|
843
870
|
lines.push(` Turn retained: ${run.recovery_summary.turn_retained == null ? 'n/a' : yesNo(run.recovery_summary.turn_retained)}`);
|
|
844
871
|
}
|
|
845
872
|
|
|
873
|
+
if (run.continuity) {
|
|
874
|
+
lines.push('', 'Continuity:');
|
|
875
|
+
lines.push(` Session: ${run.continuity.session_id || 'unknown'}`);
|
|
876
|
+
lines.push(` Checkpoint: ${run.continuity.checkpoint_reason || 'unknown'} at ${run.continuity.last_checkpoint_at || 'n/a'}`);
|
|
877
|
+
lines.push(` Last turn: ${run.continuity.last_turn_id || 'none'}`);
|
|
878
|
+
lines.push(` Last role: ${run.continuity.last_role || 'unknown'}`);
|
|
879
|
+
lines.push(` Last phase: ${run.continuity.last_phase || 'unknown'}`);
|
|
880
|
+
if (run.continuity.stale_checkpoint) {
|
|
881
|
+
lines.push(` WARNING: checkpoint tracks run ${run.continuity.run_id}, but export tracks ${run.run_id}`);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
846
885
|
if (Array.isArray(run.workflow_kit_artifacts) && run.workflow_kit_artifacts.length > 0) {
|
|
847
886
|
lines.push('', `Workflow Artifacts (${run.phase || 'unknown'} phase):`);
|
|
848
887
|
for (const art of run.workflow_kit_artifacts) {
|
|
@@ -985,6 +1024,17 @@ export function formatGovernanceReportText(report) {
|
|
|
985
1024
|
if (repo.recovery_summary) {
|
|
986
1025
|
repoLines.push(` Recovery: ${repo.recovery_summary.category || 'unknown'} — ${repo.recovery_summary.typed_reason || 'unknown'} (owner: ${repo.recovery_summary.owner || 'unknown'})`);
|
|
987
1026
|
}
|
|
1027
|
+
if (repo.continuity) {
|
|
1028
|
+
repoLines.push(' Continuity:');
|
|
1029
|
+
repoLines.push(` Session: ${repo.continuity.session_id || 'unknown'}`);
|
|
1030
|
+
repoLines.push(` Checkpoint: ${repo.continuity.checkpoint_reason || 'unknown'} at ${repo.continuity.last_checkpoint_at || 'n/a'}`);
|
|
1031
|
+
repoLines.push(` Last turn: ${repo.continuity.last_turn_id || 'none'}`);
|
|
1032
|
+
repoLines.push(` Last role: ${repo.continuity.last_role || 'unknown'}`);
|
|
1033
|
+
repoLines.push(` Last phase: ${repo.continuity.last_phase || 'unknown'}`);
|
|
1034
|
+
if (repo.continuity.stale_checkpoint) {
|
|
1035
|
+
repoLines.push(` WARNING: checkpoint tracks run ${repo.continuity.run_id}, but repo export tracks ${repo.run_id}`);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
988
1038
|
return repoLines;
|
|
989
1039
|
}));
|
|
990
1040
|
return lines.join('\n');
|
|
@@ -1110,6 +1160,18 @@ export function formatGovernanceReportMarkdown(report) {
|
|
|
1110
1160
|
lines.push(`- Turn retained: \`${run.recovery_summary.turn_retained == null ? 'n/a' : yesNo(run.recovery_summary.turn_retained)}\``);
|
|
1111
1161
|
}
|
|
1112
1162
|
|
|
1163
|
+
if (run.continuity) {
|
|
1164
|
+
lines.push('', '## Continuity', '');
|
|
1165
|
+
lines.push(`- Session: \`${run.continuity.session_id || 'unknown'}\``);
|
|
1166
|
+
lines.push(`- Checkpoint: \`${run.continuity.checkpoint_reason || 'unknown'}\` at \`${run.continuity.last_checkpoint_at || 'n/a'}\``);
|
|
1167
|
+
lines.push(`- Last turn: \`${run.continuity.last_turn_id || 'none'}\``);
|
|
1168
|
+
lines.push(`- Last role: \`${run.continuity.last_role || 'unknown'}\``);
|
|
1169
|
+
lines.push(`- Last phase: \`${run.continuity.last_phase || 'unknown'}\``);
|
|
1170
|
+
if (run.continuity.stale_checkpoint) {
|
|
1171
|
+
lines.push(`- **Warning:** checkpoint tracks run \`${run.continuity.run_id}\`, but export tracks \`${run.run_id}\``);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1113
1175
|
if (Array.isArray(run.workflow_kit_artifacts) && run.workflow_kit_artifacts.length > 0) {
|
|
1114
1176
|
lines.push('', '## Workflow Artifacts', '');
|
|
1115
1177
|
lines.push(`Phase: \`${run.phase || 'unknown'}\``, '');
|
|
@@ -1258,6 +1320,17 @@ export function formatGovernanceReportMarkdown(report) {
|
|
|
1258
1320
|
if (repo.recovery_summary) {
|
|
1259
1321
|
repoLines.push('', '#### Recovery', '', `- Category: \`${repo.recovery_summary.category || 'unknown'}\``, `- Typed reason: \`${repo.recovery_summary.typed_reason || 'unknown'}\``, `- Owner: \`${repo.recovery_summary.owner || 'unknown'}\``, `- Action: \`${repo.recovery_summary.recovery_action || 'n/a'}\``);
|
|
1260
1322
|
}
|
|
1323
|
+
if (repo.continuity) {
|
|
1324
|
+
repoLines.push('', '#### Continuity', '');
|
|
1325
|
+
repoLines.push(`- Session: \`${repo.continuity.session_id || 'unknown'}\``);
|
|
1326
|
+
repoLines.push(`- Checkpoint: \`${repo.continuity.checkpoint_reason || 'unknown'}\` at \`${repo.continuity.last_checkpoint_at || 'n/a'}\``);
|
|
1327
|
+
repoLines.push(`- Last turn: \`${repo.continuity.last_turn_id || 'none'}\``);
|
|
1328
|
+
repoLines.push(`- Last role: \`${repo.continuity.last_role || 'unknown'}\``);
|
|
1329
|
+
repoLines.push(`- Last phase: \`${repo.continuity.last_phase || 'unknown'}\``);
|
|
1330
|
+
if (repo.continuity.stale_checkpoint) {
|
|
1331
|
+
repoLines.push(`- **Warning:** checkpoint tracks run \`${repo.continuity.run_id}\`, but repo export tracks \`${repo.run_id}\``);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1261
1334
|
repoLines.push('');
|
|
1262
1335
|
return repoLines;
|
|
1263
1336
|
}));
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session checkpoint — automatic state markers for cross-session restart.
|
|
3
|
+
*
|
|
4
|
+
* Writes .agentxchain/session.json at every governance boundary
|
|
5
|
+
* (turn acceptance, phase transition, gate approval, run completion)
|
|
6
|
+
* so that `agentxchain restart` can reconstruct dispatch context
|
|
7
|
+
* without any in-memory session state.
|
|
8
|
+
*
|
|
9
|
+
* Design rules:
|
|
10
|
+
* - Checkpoint writes are non-fatal: failures log a warning and do not
|
|
11
|
+
* block the governance operation.
|
|
12
|
+
* - The file is always overwritten, not appended.
|
|
13
|
+
* - run_id in session.json must agree with state.json; mismatch is a
|
|
14
|
+
* corruption signal.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
|
|
18
|
+
import { join, dirname } from 'path';
|
|
19
|
+
import { randomBytes } from 'crypto';
|
|
20
|
+
|
|
21
|
+
const SESSION_PATH = '.agentxchain/session.json';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Generate a new session ID.
|
|
25
|
+
*/
|
|
26
|
+
function generateSessionId() {
|
|
27
|
+
return `session_${randomBytes(8).toString('hex')}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read the current session checkpoint, or null if none exists.
|
|
32
|
+
*/
|
|
33
|
+
export function readSessionCheckpoint(root) {
|
|
34
|
+
const filePath = join(root, SESSION_PATH);
|
|
35
|
+
if (!existsSync(filePath)) return null;
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Write or update the session checkpoint.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} root - project root
|
|
47
|
+
* @param {object} state - current governed state (from state.json)
|
|
48
|
+
* @param {string} reason - checkpoint reason (e.g. 'turn_accepted', 'phase_approved', 'run_completed')
|
|
49
|
+
* @param {object} [extra] - optional extra context fields
|
|
50
|
+
*/
|
|
51
|
+
export function writeSessionCheckpoint(root, state, reason, extra = {}) {
|
|
52
|
+
const filePath = join(root, SESSION_PATH);
|
|
53
|
+
try {
|
|
54
|
+
// Read existing session to preserve session_id across checkpoints in the same session
|
|
55
|
+
const existing = readSessionCheckpoint(root);
|
|
56
|
+
const sessionId = (existing && existing.run_id === state.run_id)
|
|
57
|
+
? existing.session_id
|
|
58
|
+
: generateSessionId();
|
|
59
|
+
|
|
60
|
+
const currentTurn = state.current_turn || null;
|
|
61
|
+
const lastTurnId = currentTurn?.id || currentTurn?.turn_id || state.last_completed_turn_id || null;
|
|
62
|
+
const lastRole = currentTurn?.role || currentTurn?.assigned_role || extra.role || null;
|
|
63
|
+
const lastPhase = state.current_phase || state.phase || null;
|
|
64
|
+
|
|
65
|
+
const checkpoint = {
|
|
66
|
+
session_id: sessionId,
|
|
67
|
+
run_id: state.run_id,
|
|
68
|
+
started_at: existing?.started_at || new Date().toISOString(),
|
|
69
|
+
last_checkpoint_at: new Date().toISOString(),
|
|
70
|
+
last_turn_id: lastTurnId,
|
|
71
|
+
last_phase: lastPhase,
|
|
72
|
+
last_role: lastRole,
|
|
73
|
+
run_status: state.status || null,
|
|
74
|
+
checkpoint_reason: reason,
|
|
75
|
+
agent_context: {
|
|
76
|
+
adapter: extra.adapter || null,
|
|
77
|
+
dispatch_dir: extra.dispatch_dir || null,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const dir = dirname(filePath);
|
|
82
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
83
|
+
writeFileSync(filePath, JSON.stringify(checkpoint, null, 2) + '\n');
|
|
84
|
+
} catch (err) {
|
|
85
|
+
// Non-fatal — warn but do not block governance operations
|
|
86
|
+
if (process.env.AGENTXCHAIN_DEBUG) {
|
|
87
|
+
console.error(`[session-checkpoint] Warning: failed to write checkpoint: ${err.message}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export { SESSION_PATH };
|