agentxchain 2.36.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.
- package/dashboard/components/timeline.js +13 -2
- package/package.json +1 -1
- package/scripts/release-bump.sh +46 -29
- package/src/commands/restart.js +109 -5
- package/src/commands/status.js +15 -2
- package/src/lib/adapters/api-proxy-adapter.js +130 -3
- package/src/lib/continuity-status.js +112 -2
- package/src/lib/governed-state.js +12 -0
- package/src/lib/normalized-config.js +1 -1
- package/src/lib/session-checkpoint.js +59 -8
|
@@ -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/scripts/release-bump.sh
CHANGED
|
@@ -121,7 +121,11 @@ echo " OK: tag v${TARGET_VERSION} does not exist"
|
|
|
121
121
|
# Ensures all governed version surfaces already reference the target version
|
|
122
122
|
# BEFORE the bump commit is created. This catches stale drift that would
|
|
123
123
|
# otherwise only be discovered after minting local release identities.
|
|
124
|
-
|
|
124
|
+
#
|
|
125
|
+
# NOTE: Homebrew mirror formula and README are NOT checked here. They are
|
|
126
|
+
# auto-aligned in step 5 because the registry SHA256 is inherently a
|
|
127
|
+
# post-publish artifact. See DEC-HOMEBREW-SHA-SPLIT-001.
|
|
128
|
+
echo "[4/9] Verifying version-surface alignment for ${TARGET_VERSION}..."
|
|
125
129
|
SURFACE_ERRORS=()
|
|
126
130
|
|
|
127
131
|
# 4a. CHANGELOG top heading
|
|
@@ -164,25 +168,6 @@ if ! grep -qE "^# Launch Evidence Report — AgentXchain v${ESCAPED_VERSION}" "$
|
|
|
164
168
|
SURFACE_ERRORS+=("LAUNCH_EVIDENCE_REPORT.md title does not carry v${TARGET_VERSION}")
|
|
165
169
|
fi
|
|
166
170
|
|
|
167
|
-
# 4h. Homebrew mirror formula version
|
|
168
|
-
HOMEBREW_MIRROR="${REPO_ROOT}/cli/homebrew/agentxchain.rb"
|
|
169
|
-
if [[ -f "$HOMEBREW_MIRROR" ]]; then
|
|
170
|
-
if ! grep -q "agentxchain-${TARGET_VERSION}\.tgz" "$HOMEBREW_MIRROR" 2>/dev/null; then
|
|
171
|
-
SURFACE_ERRORS+=("homebrew mirror formula does not reference agentxchain-${TARGET_VERSION}.tgz")
|
|
172
|
-
fi
|
|
173
|
-
fi
|
|
174
|
-
|
|
175
|
-
# 4i. Homebrew mirror maintainer README version
|
|
176
|
-
HOMEBREW_MIRROR_README="${REPO_ROOT}/cli/homebrew/README.md"
|
|
177
|
-
if [[ -f "$HOMEBREW_MIRROR_README" ]]; then
|
|
178
|
-
if ! grep -q -- "- version: \`${TARGET_VERSION}\`" "$HOMEBREW_MIRROR_README" 2>/dev/null; then
|
|
179
|
-
SURFACE_ERRORS+=("homebrew mirror README does not declare version ${TARGET_VERSION}")
|
|
180
|
-
fi
|
|
181
|
-
if ! grep -q "agentxchain-${TARGET_VERSION}\.tgz" "$HOMEBREW_MIRROR_README" 2>/dev/null; then
|
|
182
|
-
SURFACE_ERRORS+=("homebrew mirror README does not reference agentxchain-${TARGET_VERSION}.tgz")
|
|
183
|
-
fi
|
|
184
|
-
fi
|
|
185
|
-
|
|
186
171
|
if [[ "${#SURFACE_ERRORS[@]}" -gt 0 ]]; then
|
|
187
172
|
echo "FAIL: ${#SURFACE_ERRORS[@]} version-surface(s) not aligned to ${TARGET_VERSION}:" >&2
|
|
188
173
|
printf ' - %s\n' "${SURFACE_ERRORS[@]}" >&2
|
|
@@ -191,15 +176,47 @@ if [[ "${#SURFACE_ERRORS[@]}" -gt 0 ]]; then
|
|
|
191
176
|
echo "create release identity when governed surfaces are stale." >&2
|
|
192
177
|
exit 1
|
|
193
178
|
fi
|
|
194
|
-
echo " OK: all
|
|
179
|
+
echo " OK: all 7 governed version surfaces reference ${TARGET_VERSION}"
|
|
180
|
+
|
|
181
|
+
# 5. Auto-align Homebrew mirror to target version
|
|
182
|
+
# The formula URL and README version/tarball are updated automatically.
|
|
183
|
+
# The SHA256 is carried from the previous version — it is inherently a
|
|
184
|
+
# post-publish artifact (npm registry tarballs are not byte-identical to
|
|
185
|
+
# local npm-pack output). sync-homebrew.sh corrects the SHA after publish.
|
|
186
|
+
echo "[5/9] Auto-aligning Homebrew mirror to ${TARGET_VERSION}..."
|
|
187
|
+
HOMEBREW_MIRROR="${REPO_ROOT}/cli/homebrew/agentxchain.rb"
|
|
188
|
+
HOMEBREW_MIRROR_README="${REPO_ROOT}/cli/homebrew/README.md"
|
|
189
|
+
TARBALL_URL="https://registry.npmjs.org/agentxchain/-/agentxchain-${TARGET_VERSION}.tgz"
|
|
190
|
+
HOMEBREW_ALIGNED=false
|
|
191
|
+
|
|
192
|
+
if [[ -f "$HOMEBREW_MIRROR" ]]; then
|
|
193
|
+
ESCAPED_URL="$(printf '%s' "$TARBALL_URL" | sed 's/[&/\]/\\&/g')"
|
|
194
|
+
sed -i.bak -E "s|^([[:space:]]*url \").*(\")|\1${ESCAPED_URL}\2|" "$HOMEBREW_MIRROR"
|
|
195
|
+
rm -f "${HOMEBREW_MIRROR}.bak"
|
|
196
|
+
HOMEBREW_ALIGNED=true
|
|
197
|
+
echo " OK: formula URL -> ${TARBALL_URL}"
|
|
198
|
+
fi
|
|
199
|
+
|
|
200
|
+
if [[ -f "$HOMEBREW_MIRROR_README" ]]; then
|
|
201
|
+
sed -i.bak -E "s|^(- version: \`).*(\`)|\1${TARGET_VERSION}\2|" "$HOMEBREW_MIRROR_README"
|
|
202
|
+
sed -i.bak -E "s|^(- source tarball: \`).*(\`)|\1${TARBALL_URL}\2|" "$HOMEBREW_MIRROR_README"
|
|
203
|
+
rm -f "${HOMEBREW_MIRROR_README}.bak"
|
|
204
|
+
echo " OK: README version and tarball -> ${TARGET_VERSION}"
|
|
205
|
+
fi
|
|
206
|
+
|
|
207
|
+
if $HOMEBREW_ALIGNED; then
|
|
208
|
+
echo " Note: SHA carried from previous version; sync-homebrew.sh will set the real registry SHA post-publish"
|
|
209
|
+
else
|
|
210
|
+
echo " Skipped: no Homebrew mirror files found"
|
|
211
|
+
fi
|
|
195
212
|
|
|
196
|
-
#
|
|
197
|
-
echo "[
|
|
213
|
+
# 6. Update version files (no git operations)
|
|
214
|
+
echo "[6/9] Updating version files..."
|
|
198
215
|
npm version "$TARGET_VERSION" --no-git-tag-version
|
|
199
216
|
echo " OK: package.json updated to ${TARGET_VERSION}"
|
|
200
217
|
|
|
201
|
-
#
|
|
202
|
-
echo "[
|
|
218
|
+
# 7. Stage version files
|
|
219
|
+
echo "[7/9] Staging version files..."
|
|
203
220
|
git add -- package.json
|
|
204
221
|
if [[ -f package-lock.json ]]; then
|
|
205
222
|
git add -- package-lock.json
|
|
@@ -209,8 +226,8 @@ for rel_path in "${ALLOWED_RELEASE_PATHS[@]}"; do
|
|
|
209
226
|
done
|
|
210
227
|
echo " OK: version files and allowed release surfaces staged"
|
|
211
228
|
|
|
212
|
-
#
|
|
213
|
-
echo "[
|
|
229
|
+
# 8. Create release commit
|
|
230
|
+
echo "[8/9] Creating release commit..."
|
|
214
231
|
git commit -m "${TARGET_VERSION}"
|
|
215
232
|
RELEASE_SHA=$(git rev-parse HEAD)
|
|
216
233
|
COMMIT_MSG=$(git log -1 --format=%s)
|
|
@@ -220,8 +237,8 @@ if [[ "$COMMIT_MSG" != "$TARGET_VERSION" ]]; then
|
|
|
220
237
|
fi
|
|
221
238
|
echo " OK: commit ${RELEASE_SHA:0:7} with message '${TARGET_VERSION}'"
|
|
222
239
|
|
|
223
|
-
#
|
|
224
|
-
echo "[
|
|
240
|
+
# 9. Create annotated tag
|
|
241
|
+
echo "[9/9] Creating annotated tag..."
|
|
225
242
|
git tag -a "v${TARGET_VERSION}" -m "v${TARGET_VERSION}"
|
|
226
243
|
TAG_SHA=$(git rev-parse "v${TARGET_VERSION}")
|
|
227
244
|
if [[ -z "$TAG_SHA" ]]; then
|
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) {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* All error returns include a `classified` ApiProxyError object with
|
|
24
24
|
* error_class, recovery instructions, and retryable flag.
|
|
25
25
|
*
|
|
26
|
-
* Supported providers: "anthropic", "openai"
|
|
26
|
+
* Supported providers: "anthropic", "openai", "google"
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from 'fs';
|
|
@@ -45,9 +45,12 @@ import {
|
|
|
45
45
|
import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
|
|
46
46
|
|
|
47
47
|
// Provider endpoint registry
|
|
48
|
+
// Google Gemini endpoint requires the model name interpolated at call time;
|
|
49
|
+
// the registry stores a template with {model} as a placeholder.
|
|
48
50
|
const PROVIDER_ENDPOINTS = {
|
|
49
51
|
anthropic: 'https://api.anthropic.com/v1/messages',
|
|
50
52
|
openai: 'https://api.openai.com/v1/chat/completions',
|
|
53
|
+
google: 'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent',
|
|
51
54
|
};
|
|
52
55
|
|
|
53
56
|
// Bundled cost rates per million tokens (USD).
|
|
@@ -67,6 +70,10 @@ const BUNDLED_COST_RATES = {
|
|
|
67
70
|
'o3': { input_per_1m: 2.00, output_per_1m: 8.00 },
|
|
68
71
|
'o3-mini': { input_per_1m: 1.10, output_per_1m: 4.40 },
|
|
69
72
|
'o4-mini': { input_per_1m: 1.10, output_per_1m: 4.40 },
|
|
73
|
+
// Google Gemini — verified 2026-04-09 (training knowledge)
|
|
74
|
+
'gemini-2.5-pro': { input_per_1m: 1.25, output_per_1m: 10.00 },
|
|
75
|
+
'gemini-2.5-flash': { input_per_1m: 0.15, output_per_1m: 0.60 },
|
|
76
|
+
'gemini-2.0-flash': { input_per_1m: 0.10, output_per_1m: 0.40 },
|
|
70
77
|
};
|
|
71
78
|
|
|
72
79
|
// Resolve cost rates: operator-supplied cost_rates override bundled defaults
|
|
@@ -135,6 +142,25 @@ const PROVIDER_ERROR_MAPS = {
|
|
|
135
142
|
{ provider_error_type: 'rate_limit_error', http_status: 429, error_class: 'rate_limited', retryable: true },
|
|
136
143
|
],
|
|
137
144
|
},
|
|
145
|
+
google: {
|
|
146
|
+
extractErrorType(body) {
|
|
147
|
+
// Google errors use { error: { status: "INVALID_ARGUMENT", ... } }
|
|
148
|
+
return typeof body?.error?.status === 'string' ? body.error.status : null;
|
|
149
|
+
},
|
|
150
|
+
extractErrorCode(body) {
|
|
151
|
+
return typeof body?.error?.code === 'number' ? String(body.error.code) : null;
|
|
152
|
+
},
|
|
153
|
+
mappings: [
|
|
154
|
+
{ provider_error_type: 'UNAUTHENTICATED', http_status: 401, error_class: 'auth_failure', retryable: false },
|
|
155
|
+
{ provider_error_type: 'PERMISSION_DENIED', http_status: 403, error_class: 'auth_failure', retryable: false },
|
|
156
|
+
{ provider_error_type: 'NOT_FOUND', http_status: 404, error_class: 'model_not_found', retryable: false },
|
|
157
|
+
{ provider_error_type: 'RESOURCE_EXHAUSTED', http_status: 429, error_class: 'rate_limited', retryable: true },
|
|
158
|
+
{ provider_error_type: 'INVALID_ARGUMENT', http_status: 400, body_pattern: /token.*limit|context|too.long/i, error_class: 'context_overflow', retryable: false },
|
|
159
|
+
{ provider_error_type: 'INVALID_ARGUMENT', http_status: 400, error_class: 'invalid_request', retryable: false },
|
|
160
|
+
{ provider_error_type: 'UNAVAILABLE', http_status: 503, error_class: 'provider_overloaded', retryable: true },
|
|
161
|
+
{ provider_error_type: 'INTERNAL', http_status: 500, error_class: 'unknown_api_error', retryable: true },
|
|
162
|
+
],
|
|
163
|
+
},
|
|
138
164
|
};
|
|
139
165
|
|
|
140
166
|
// ── Error classification ──────────────────────────────────────────────────────
|
|
@@ -442,6 +468,10 @@ function usageFromTelemetry(provider, model, usage, config) {
|
|
|
442
468
|
if (provider === 'openai') {
|
|
443
469
|
inputTokens = Number.isFinite(usage.prompt_tokens) ? usage.prompt_tokens : 0;
|
|
444
470
|
outputTokens = Number.isFinite(usage.completion_tokens) ? usage.completion_tokens : 0;
|
|
471
|
+
} else if (provider === 'google') {
|
|
472
|
+
// Google Gemini returns usageMetadata at root level with promptTokenCount / candidatesTokenCount
|
|
473
|
+
inputTokens = Number.isFinite(usage.promptTokenCount) ? usage.promptTokenCount : 0;
|
|
474
|
+
outputTokens = Number.isFinite(usage.candidatesTokenCount) ? usage.candidatesTokenCount : 0;
|
|
445
475
|
} else {
|
|
446
476
|
inputTokens = Number.isFinite(usage.input_tokens) ? usage.input_tokens : 0;
|
|
447
477
|
outputTokens = Number.isFinite(usage.output_tokens) ? usage.output_tokens : 0;
|
|
@@ -691,7 +721,9 @@ async function executeApiCall({
|
|
|
691
721
|
};
|
|
692
722
|
}
|
|
693
723
|
|
|
694
|
-
|
|
724
|
+
// Google Gemini returns usage at responseData.usageMetadata; others at responseData.usage
|
|
725
|
+
const usageSource = provider === 'google' ? responseData.usageMetadata : responseData.usage;
|
|
726
|
+
const usage = usageFromTelemetry(provider, model, usageSource, config);
|
|
695
727
|
const extraction = extractTurnResult(responseData, provider);
|
|
696
728
|
|
|
697
729
|
if (!extraction.ok) {
|
|
@@ -791,7 +823,7 @@ export async function dispatchApiProxy(root, state, config, options = {}) {
|
|
|
791
823
|
return errorReturn(root, turn.turn_id, classified);
|
|
792
824
|
}
|
|
793
825
|
|
|
794
|
-
|
|
826
|
+
let endpoint = runtime.base_url || PROVIDER_ENDPOINTS[provider];
|
|
795
827
|
if (!endpoint) {
|
|
796
828
|
const classified = classifyError(
|
|
797
829
|
'unsupported_provider',
|
|
@@ -802,6 +834,12 @@ export async function dispatchApiProxy(root, state, config, options = {}) {
|
|
|
802
834
|
return errorReturn(root, turn.turn_id, classified);
|
|
803
835
|
}
|
|
804
836
|
|
|
837
|
+
// Google Gemini: interpolate model into endpoint URL and append API key as query param
|
|
838
|
+
if (provider === 'google') {
|
|
839
|
+
endpoint = endpoint.replace('{model}', encodeURIComponent(model));
|
|
840
|
+
endpoint += (endpoint.includes('?') ? '&' : '?') + `key=${encodeURIComponent(apiKey)}`;
|
|
841
|
+
}
|
|
842
|
+
|
|
805
843
|
// Build request
|
|
806
844
|
const maxOutputTokens = runtime.max_output_tokens || 4096;
|
|
807
845
|
const timeoutSeconds = runtime.timeout_seconds || 120;
|
|
@@ -1086,10 +1124,92 @@ function buildOpenAiRequest(promptMd, contextMd, model, maxOutputTokens) {
|
|
|
1086
1124
|
};
|
|
1087
1125
|
}
|
|
1088
1126
|
|
|
1127
|
+
function buildGoogleHeaders(_apiKey) {
|
|
1128
|
+
// Google Gemini uses API key as a query parameter, not a header
|
|
1129
|
+
return {
|
|
1130
|
+
'Content-Type': 'application/json',
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function buildGoogleRequest(promptMd, contextMd, model, maxOutputTokens) {
|
|
1135
|
+
const userContent = contextMd
|
|
1136
|
+
? `${promptMd}${SEPARATOR}${contextMd}`
|
|
1137
|
+
: promptMd;
|
|
1138
|
+
|
|
1139
|
+
return {
|
|
1140
|
+
systemInstruction: {
|
|
1141
|
+
parts: [{ text: SYSTEM_PROMPT }],
|
|
1142
|
+
},
|
|
1143
|
+
contents: [
|
|
1144
|
+
{
|
|
1145
|
+
role: 'user',
|
|
1146
|
+
parts: [{ text: userContent }],
|
|
1147
|
+
},
|
|
1148
|
+
],
|
|
1149
|
+
generationConfig: {
|
|
1150
|
+
maxOutputTokens,
|
|
1151
|
+
responseMimeType: 'application/json',
|
|
1152
|
+
},
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function extractGoogleTurnResult(responseData) {
|
|
1157
|
+
const promptBlockReason = responseData?.promptFeedback?.blockReason;
|
|
1158
|
+
if (typeof promptBlockReason === 'string' && promptBlockReason.trim()) {
|
|
1159
|
+
return {
|
|
1160
|
+
ok: false,
|
|
1161
|
+
error: `Google Gemini blocked the prompt before generation (blockReason: ${promptBlockReason})`,
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
if (!Array.isArray(responseData?.candidates) || responseData.candidates.length === 0) {
|
|
1166
|
+
return { ok: false, error: 'API response has no candidates' };
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const candidate = responseData.candidates[0];
|
|
1170
|
+
const finishReason = typeof candidate?.finishReason === 'string'
|
|
1171
|
+
? candidate.finishReason
|
|
1172
|
+
: null;
|
|
1173
|
+
const parts = candidate?.content?.parts;
|
|
1174
|
+
if (!Array.isArray(parts) || parts.length === 0) {
|
|
1175
|
+
if (finishReason && finishReason !== 'STOP') {
|
|
1176
|
+
return {
|
|
1177
|
+
ok: false,
|
|
1178
|
+
error: `Google Gemini candidate has no content parts (finishReason: ${finishReason})`,
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
return { ok: false, error: 'API response candidate has no content parts' };
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const textPart = parts.find(p => typeof p.text === 'string');
|
|
1185
|
+
if (!textPart?.text?.trim()) {
|
|
1186
|
+
if (finishReason && finishReason !== 'STOP') {
|
|
1187
|
+
return {
|
|
1188
|
+
ok: false,
|
|
1189
|
+
error: `Google Gemini returned no extractable text (finishReason: ${finishReason})`,
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
return { ok: false, error: 'API response has no text content part' };
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const extraction = extractTurnResultFromText(textPart.text);
|
|
1196
|
+
if (!extraction.ok && finishReason && finishReason !== 'STOP') {
|
|
1197
|
+
return {
|
|
1198
|
+
ok: false,
|
|
1199
|
+
error: `Google Gemini returned non-extractable turn JSON (finishReason: ${finishReason})`,
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
return extraction;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1089
1206
|
function buildProviderHeaders(provider, apiKey) {
|
|
1090
1207
|
if (provider === 'openai') {
|
|
1091
1208
|
return buildOpenAiHeaders(apiKey);
|
|
1092
1209
|
}
|
|
1210
|
+
if (provider === 'google') {
|
|
1211
|
+
return buildGoogleHeaders(apiKey);
|
|
1212
|
+
}
|
|
1093
1213
|
return buildAnthropicHeaders(apiKey);
|
|
1094
1214
|
}
|
|
1095
1215
|
|
|
@@ -1097,6 +1217,9 @@ function buildProviderRequest(provider, promptMd, contextMd, model, maxOutputTok
|
|
|
1097
1217
|
if (provider === 'openai') {
|
|
1098
1218
|
return buildOpenAiRequest(promptMd, contextMd, model, maxOutputTokens);
|
|
1099
1219
|
}
|
|
1220
|
+
if (provider === 'google') {
|
|
1221
|
+
return buildGoogleRequest(promptMd, contextMd, model, maxOutputTokens);
|
|
1222
|
+
}
|
|
1100
1223
|
return buildAnthropicRequest(promptMd, contextMd, model, maxOutputTokens);
|
|
1101
1224
|
}
|
|
1102
1225
|
|
|
@@ -1184,6 +1307,9 @@ function extractTurnResult(responseData, provider = 'anthropic') {
|
|
|
1184
1307
|
if (provider === 'openai') {
|
|
1185
1308
|
return extractOpenAiTurnResult(responseData);
|
|
1186
1309
|
}
|
|
1310
|
+
if (provider === 'google') {
|
|
1311
|
+
return extractGoogleTurnResult(responseData);
|
|
1312
|
+
}
|
|
1187
1313
|
return extractAnthropicTurnResult(responseData);
|
|
1188
1314
|
}
|
|
1189
1315
|
|
|
@@ -1198,6 +1324,7 @@ export {
|
|
|
1198
1324
|
extractTurnResult,
|
|
1199
1325
|
buildAnthropicRequest,
|
|
1200
1326
|
buildOpenAiRequest,
|
|
1327
|
+
buildGoogleRequest,
|
|
1201
1328
|
classifyError,
|
|
1202
1329
|
classifyHttpError,
|
|
1203
1330
|
BUNDLED_COST_RATES,
|
|
@@ -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) {
|
|
@@ -18,7 +18,7 @@ import { SUPPORTED_TOKEN_COUNTER_PROVIDERS } from './token-counter.js';
|
|
|
18
18
|
|
|
19
19
|
const VALID_WRITE_AUTHORITIES = ['authoritative', 'proposed', 'review_only'];
|
|
20
20
|
const VALID_RUNTIME_TYPES = ['manual', 'local_cli', 'api_proxy', 'mcp', 'remote_agent'];
|
|
21
|
-
const VALID_API_PROXY_PROVIDERS = ['anthropic', 'openai'];
|
|
21
|
+
const VALID_API_PROXY_PROVIDERS = ['anthropic', 'openai', 'google'];
|
|
22
22
|
export const VALID_PROMPT_TRANSPORTS = ['argv', 'stdin', 'dispatch_bundle_only'];
|
|
23
23
|
const VALID_MCP_TRANSPORTS = ['stdio', 'streamable_http'];
|
|
24
24
|
const DEFAULT_PHASES = ['planning', 'implementation', 'qa'];
|
|
@@ -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,
|