agentxchain 2.153.0 → 2.154.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 +8 -0
- package/package.json +1 -1
- package/src/commands/reconcile-state.js +49 -0
- package/src/lib/continuity-status.js +8 -1
- package/src/lib/continuous-run.js +114 -1
- package/src/lib/normalized-config.js +15 -0
- package/src/lib/operator-commit-reconcile.js +260 -0
- package/src/lib/run-events.js +2 -0
package/bin/agentxchain.js
CHANGED
|
@@ -73,6 +73,7 @@ import { injectCommand } from '../src/commands/inject.js';
|
|
|
73
73
|
import { escalateCommand } from '../src/commands/escalate.js';
|
|
74
74
|
import { acceptTurnCommand } from '../src/commands/accept-turn.js';
|
|
75
75
|
import { checkpointTurnCommand } from '../src/commands/checkpoint-turn.js';
|
|
76
|
+
import { reconcileStateCommand } from '../src/commands/reconcile-state.js';
|
|
76
77
|
import { rejectTurnCommand } from '../src/commands/reject-turn.js';
|
|
77
78
|
import { reissueTurnCommand } from '../src/commands/reissue-turn.js';
|
|
78
79
|
import { proposalListCommand, proposalDiffCommand, proposalApplyCommand, proposalRejectCommand } from '../src/commands/proposal.js';
|
|
@@ -701,6 +702,12 @@ program
|
|
|
701
702
|
.option('--turn <id>', 'Checkpoint a specific accepted turn from history')
|
|
702
703
|
.action(checkpointTurnCommand);
|
|
703
704
|
|
|
705
|
+
program
|
|
706
|
+
.command('reconcile-state')
|
|
707
|
+
.description('Reconcile safe operator commits into governed run state')
|
|
708
|
+
.option('--accept-operator-head', 'Accept safe fast-forward operator commits as the new governed baseline')
|
|
709
|
+
.action(reconcileStateCommand);
|
|
710
|
+
|
|
704
711
|
program
|
|
705
712
|
.command('reject-turn')
|
|
706
713
|
.description('Reject the current governed turn result and retry or escalate')
|
|
@@ -757,6 +764,7 @@ program
|
|
|
757
764
|
.option('--no-auto-retry-on-ghost', 'Disable bounded automatic retry for continuous-mode startup ghost turns')
|
|
758
765
|
.option('--auto-retry-on-ghost-max-retries <n>', 'Maximum startup ghost retries per continuous run (default: config or 3)', parseInt)
|
|
759
766
|
.option('--auto-retry-on-ghost-cooldown-seconds <n>', 'Seconds to wait between startup ghost retries (default: config or 5)', parseInt)
|
|
767
|
+
.option('--reconcile-operator-commits <mode>', 'Continuous reconcile posture for operator commits: manual, auto_safe_only, or disabled (default: config or manual; auto_safe_only under full-auto approval policy)')
|
|
760
768
|
.option('--auto-checkpoint', 'Auto-commit accepted writable turns after acceptance')
|
|
761
769
|
.option('--no-auto-checkpoint', 'Disable automatic checkpointing after accepted writable turns')
|
|
762
770
|
.action(runCommand);
|
package/package.json
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { loadProjectContext } from '../lib/config.js';
|
|
3
|
+
import { reconcileOperatorHead } from '../lib/operator-commit-reconcile.js';
|
|
4
|
+
|
|
5
|
+
export async function reconcileStateCommand(opts = {}) {
|
|
6
|
+
const context = loadProjectContext();
|
|
7
|
+
if (!context) {
|
|
8
|
+
console.log(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { root, config } = context;
|
|
13
|
+
if (config.protocol_mode !== 'governed') {
|
|
14
|
+
console.log(chalk.red('The reconcile-state command is only available for governed projects.'));
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!opts.acceptOperatorHead) {
|
|
19
|
+
console.log(chalk.red('No reconciliation action selected.'));
|
|
20
|
+
console.log(chalk.dim('Run `agentxchain reconcile-state --accept-operator-head` to accept safe operator commits on top of the last checkpoint.'));
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const result = reconcileOperatorHead(root);
|
|
25
|
+
if (!result.ok) {
|
|
26
|
+
console.log(chalk.red(`Reconcile refused (${result.error_class || 'unknown'}).`));
|
|
27
|
+
console.log(chalk.red(result.error || 'Unable to reconcile operator commits.'));
|
|
28
|
+
if (result.offending_path) {
|
|
29
|
+
console.log(chalk.dim(`Offending path: ${result.offending_path}`));
|
|
30
|
+
}
|
|
31
|
+
if (result.offending_commit) {
|
|
32
|
+
console.log(chalk.dim(`Offending commit: ${result.offending_commit}`));
|
|
33
|
+
}
|
|
34
|
+
console.log(chalk.dim('Manual recovery: inspect the commit range, restore governed state artifacts if needed, then restart from an explicit checkpoint.'));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (result.no_op) {
|
|
39
|
+
console.log(chalk.green(`State already reconciled at ${result.accepted_head.slice(0, 8)}.`));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log(chalk.green(`Reconciled ${result.accepted_commits.length} operator commit(s).`));
|
|
44
|
+
console.log(chalk.dim(`Previous baseline: ${result.previous_baseline}`));
|
|
45
|
+
console.log(chalk.dim(`Accepted HEAD: ${result.accepted_head}`));
|
|
46
|
+
if (result.paths_touched.length > 0) {
|
|
47
|
+
console.log(chalk.dim(`Paths touched: ${result.paths_touched.join(', ')}`));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -129,8 +129,15 @@ export function getContinuityStatus(root, state) {
|
|
|
129
129
|
&& checkpoint.run_id !== state.run_id
|
|
130
130
|
);
|
|
131
131
|
|
|
132
|
-
const action = deriveRecommendedContinuityAction(state);
|
|
133
132
|
const drift = deriveCheckpointDrift(root, checkpoint, staleCheckpoint);
|
|
133
|
+
const action = drift.drift_detected === true
|
|
134
|
+
? {
|
|
135
|
+
recommended_command: 'agentxchain reconcile-state --accept-operator-head',
|
|
136
|
+
recommended_reason: 'operator_commit_drift',
|
|
137
|
+
recommended_detail: 'accept safe fast-forward operator commits as the new baseline',
|
|
138
|
+
restart_recommended: false,
|
|
139
|
+
}
|
|
140
|
+
: deriveRecommendedContinuityAction(state);
|
|
134
141
|
|
|
135
142
|
return {
|
|
136
143
|
checkpoint,
|
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
buildGhostRetryExhaustionMirror,
|
|
34
34
|
classifyGhostRetryDecision,
|
|
35
35
|
} from './ghost-retry.js';
|
|
36
|
+
import { reconcileOperatorHead } from './operator-commit-reconcile.js';
|
|
37
|
+
import { getContinuityStatus } from './continuity-status.js';
|
|
36
38
|
import {
|
|
37
39
|
archiveStaleIntentsForRun,
|
|
38
40
|
formatLegacyIntentMigrationNotice,
|
|
@@ -335,6 +337,99 @@ export function findNextQueuedIntent(root, options = {}) {
|
|
|
335
337
|
return findNextDispatchableIntent(root, { run_id: options.run_id || null });
|
|
336
338
|
}
|
|
337
339
|
|
|
340
|
+
/**
|
|
341
|
+
* BUG-62 slice 2: when `run_loop.continuous.reconcile_operator_commits` is
|
|
342
|
+
* `auto_safe_only`, the continuous loop consults the session-checkpoint /
|
|
343
|
+
* governed-state baseline vs current git HEAD before dispatch. If operator
|
|
344
|
+
* commits landed on top of the baseline and the Turn 184 safety primitive
|
|
345
|
+
* accepts them, the baseline is auto-rolled forward so the next dispatch
|
|
346
|
+
* proceeds without manual `agentxchain reconcile-state` intervention. If the
|
|
347
|
+
* safety primitive refuses the commits (governed-state edits or history
|
|
348
|
+
* rewrite), the continuous loop pauses with the refusal class mirrored into
|
|
349
|
+
* `blocked_reason.recovery.detail`, preserving the manual primitive as the
|
|
350
|
+
* operator's single audited safety function per the BUG-62 spec.
|
|
351
|
+
*/
|
|
352
|
+
export function maybeAutoReconcileOperatorCommits(context, session, contOpts, log = console.log) {
|
|
353
|
+
const mode = contOpts.reconcileOperatorCommits || 'manual';
|
|
354
|
+
if (mode !== 'auto_safe_only') {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
const { root } = context;
|
|
358
|
+
const state = loadProjectState(root, context.config);
|
|
359
|
+
const continuity = getContinuityStatus(root, state);
|
|
360
|
+
if (!continuity || continuity.drift_detected !== true) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const result = reconcileOperatorHead(root, { safetyMode: 'auto_safe_only' });
|
|
365
|
+
if (result.ok) {
|
|
366
|
+
if (result.no_op) {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
const acceptedCount = result.accepted_commits?.length || 0;
|
|
370
|
+
log(
|
|
371
|
+
`Operator-commit auto-reconcile accepted ${acceptedCount} commit${acceptedCount === 1 ? '' : 's'} `
|
|
372
|
+
+ `(${result.previous_baseline.slice(0, 8)} -> ${result.accepted_head.slice(0, 8)}).`
|
|
373
|
+
);
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const errorClass = result.error_class || 'reconcile_refused';
|
|
378
|
+
const detailLines = [
|
|
379
|
+
`Operator-commit auto-reconcile refused (${errorClass}).`,
|
|
380
|
+
result.error || 'Unsafe operator commits detected; manual recovery required.',
|
|
381
|
+
'Run: agentxchain reconcile-state --accept-operator-head once the unsafe changes are resolved, or revert them.',
|
|
382
|
+
];
|
|
383
|
+
const detail = detailLines.join(' ');
|
|
384
|
+
|
|
385
|
+
if (state) {
|
|
386
|
+
const nextState = {
|
|
387
|
+
...state,
|
|
388
|
+
status: 'blocked',
|
|
389
|
+
blocked_on: state.blocked_on || 'operator_commit_reconcile_refused',
|
|
390
|
+
blocked_reason: {
|
|
391
|
+
...(state.blocked_reason || {}),
|
|
392
|
+
category: 'operator_commit_reconcile_refused',
|
|
393
|
+
error_class: errorClass,
|
|
394
|
+
recovery: {
|
|
395
|
+
...((state.blocked_reason || {}).recovery || {}),
|
|
396
|
+
recovery_action: 'agentxchain reconcile-state --accept-operator-head',
|
|
397
|
+
detail,
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
safeWriteJson(join(root, '.agentxchain', 'state.json'), nextState);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
emitRunEvent(root, 'operator_commit_reconcile_refused', {
|
|
405
|
+
run_id: state?.run_id || session.current_run_id || null,
|
|
406
|
+
phase: state?.phase || state?.current_phase || null,
|
|
407
|
+
status: 'blocked',
|
|
408
|
+
payload: {
|
|
409
|
+
error_class: errorClass,
|
|
410
|
+
message: result.error || null,
|
|
411
|
+
previous_baseline: result.previous_baseline || null,
|
|
412
|
+
current_head: result.current_head || null,
|
|
413
|
+
offending_commit: result.offending_commit || null,
|
|
414
|
+
offending_path: result.offending_path || null,
|
|
415
|
+
safety_mode: 'auto_safe_only',
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
session.status = 'paused';
|
|
420
|
+
writeContinuousSession(root, session);
|
|
421
|
+
log(detail);
|
|
422
|
+
return {
|
|
423
|
+
ok: true,
|
|
424
|
+
status: 'blocked',
|
|
425
|
+
action: 'operator_commit_reconcile_refused',
|
|
426
|
+
run_id: session.current_run_id,
|
|
427
|
+
recovery_action: 'agentxchain reconcile-state --accept-operator-head',
|
|
428
|
+
blocked_category: 'operator_commit_reconcile_refused',
|
|
429
|
+
error_class: errorClass,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
338
433
|
function reconcileContinuousStartupState(context, session, contOpts, log) {
|
|
339
434
|
const { root, config } = context;
|
|
340
435
|
const governedState = loadProjectState(root, config);
|
|
@@ -483,10 +578,24 @@ export function resolveContinuousOptions(opts, config) {
|
|
|
483
578
|
const configCont = config?.run_loop?.continuous || {};
|
|
484
579
|
const configGhostRetry = configCont.auto_retry_on_ghost || {};
|
|
485
580
|
const explicitConfigGhostEnabled = Object.prototype.hasOwnProperty.call(configGhostRetry, 'enabled');
|
|
486
|
-
const
|
|
581
|
+
const fullAuto = Boolean((opts.continuous ?? configCont.enabled ?? false) && isFullAutoApprovalPolicy(config));
|
|
582
|
+
const fullAutoGhostDefault = fullAuto;
|
|
487
583
|
const resolvedGhostEnabled = opts.autoRetryOnGhost
|
|
488
584
|
?? (explicitConfigGhostEnabled ? configGhostRetry.enabled : fullAutoGhostDefault);
|
|
489
585
|
|
|
586
|
+
const validReconcileModes = new Set(['manual', 'auto_safe_only', 'disabled']);
|
|
587
|
+
const configuredReconcile = typeof configCont.reconcile_operator_commits === 'string'
|
|
588
|
+
&& validReconcileModes.has(configCont.reconcile_operator_commits)
|
|
589
|
+
? configCont.reconcile_operator_commits
|
|
590
|
+
: null;
|
|
591
|
+
const cliReconcile = typeof opts.reconcileOperatorCommits === 'string'
|
|
592
|
+
&& validReconcileModes.has(opts.reconcileOperatorCommits)
|
|
593
|
+
? opts.reconcileOperatorCommits
|
|
594
|
+
: null;
|
|
595
|
+
const reconcileOperatorCommits = cliReconcile
|
|
596
|
+
?? configuredReconcile
|
|
597
|
+
?? (fullAuto ? 'auto_safe_only' : 'manual');
|
|
598
|
+
|
|
490
599
|
return {
|
|
491
600
|
enabled: opts.continuous ?? configCont.enabled ?? false,
|
|
492
601
|
continueFrom: opts.continueFrom ?? null,
|
|
@@ -507,6 +616,7 @@ export function resolveContinuousOptions(opts, config) {
|
|
|
507
616
|
?? configGhostRetry.cooldown_seconds
|
|
508
617
|
?? 5,
|
|
509
618
|
},
|
|
619
|
+
reconcileOperatorCommits,
|
|
510
620
|
};
|
|
511
621
|
}
|
|
512
622
|
|
|
@@ -564,6 +674,9 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
564
674
|
|
|
565
675
|
reconcileContinuousStartupState(context, session, contOpts, log);
|
|
566
676
|
|
|
677
|
+
const reconcileBlock = maybeAutoReconcileOperatorCommits(context, session, contOpts, log);
|
|
678
|
+
if (reconcileBlock) return reconcileBlock;
|
|
679
|
+
|
|
567
680
|
// Paused-session guard: if session is paused (blocked run awaiting unblock),
|
|
568
681
|
// check governed state before attempting to advance. Without this guard, the
|
|
569
682
|
// loop would try to startIntent() on a blocked project, hit the blocked-state
|
|
@@ -646,6 +646,8 @@ export function validateRunLoopConfig(runLoop) {
|
|
|
646
646
|
return errors;
|
|
647
647
|
}
|
|
648
648
|
|
|
649
|
+
export const VALID_RECONCILE_OPERATOR_COMMITS = ['manual', 'auto_safe_only', 'disabled'];
|
|
650
|
+
|
|
649
651
|
function validateRunLoopContinuousConfig(path, continuous, errors) {
|
|
650
652
|
if (typeof continuous !== 'object' || Array.isArray(continuous)) {
|
|
651
653
|
errors.push(`${path} must be an object`);
|
|
@@ -654,6 +656,19 @@ function validateRunLoopContinuousConfig(path, continuous, errors) {
|
|
|
654
656
|
if (continuous.auto_retry_on_ghost !== undefined && continuous.auto_retry_on_ghost !== null) {
|
|
655
657
|
validateAutoRetryOnGhostConfig(`${path}.auto_retry_on_ghost`, continuous.auto_retry_on_ghost, errors);
|
|
656
658
|
}
|
|
659
|
+
if (
|
|
660
|
+
continuous.reconcile_operator_commits !== undefined
|
|
661
|
+
&& continuous.reconcile_operator_commits !== null
|
|
662
|
+
) {
|
|
663
|
+
if (
|
|
664
|
+
typeof continuous.reconcile_operator_commits !== 'string'
|
|
665
|
+
|| !VALID_RECONCILE_OPERATOR_COMMITS.includes(continuous.reconcile_operator_commits)
|
|
666
|
+
) {
|
|
667
|
+
errors.push(
|
|
668
|
+
`${path}.reconcile_operator_commits must be one of: ${VALID_RECONCILE_OPERATOR_COMMITS.join(', ')}`
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
657
672
|
}
|
|
658
673
|
|
|
659
674
|
function validateAutoRetryOnGhostConfig(path, value, errors) {
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { emitRunEvent } from './run-events.js';
|
|
5
|
+
import { captureBaselineRef, readSessionCheckpoint, SESSION_PATH } from './session-checkpoint.js';
|
|
6
|
+
import { safeWriteJson } from './safe-write.js';
|
|
7
|
+
|
|
8
|
+
const STATE_PATH = '.agentxchain/state.json';
|
|
9
|
+
const CRITICAL_DELETION_PATHS = new Set([
|
|
10
|
+
'.planning/acceptance-matrix.md',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
function git(root, args) {
|
|
14
|
+
return execFileSync('git', args, {
|
|
15
|
+
cwd: root,
|
|
16
|
+
encoding: 'utf8',
|
|
17
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
18
|
+
}).trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function gitOk(root, args) {
|
|
22
|
+
try {
|
|
23
|
+
execFileSync('git', args, {
|
|
24
|
+
cwd: root,
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
27
|
+
});
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function extractGitError(err) {
|
|
35
|
+
const stderr = typeof err?.stderr === 'string' ? err.stderr.trim() : '';
|
|
36
|
+
const stdout = typeof err?.stdout === 'string' ? err.stdout.trim() : '';
|
|
37
|
+
return stderr || stdout || err?.message || 'git command failed';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readState(root) {
|
|
41
|
+
const statePath = join(root, STATE_PATH);
|
|
42
|
+
if (!existsSync(statePath)) return null;
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(readFileSync(statePath, 'utf8'));
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeGitRef(ref) {
|
|
51
|
+
if (typeof ref !== 'string') return null;
|
|
52
|
+
const trimmed = ref.trim();
|
|
53
|
+
if (!trimmed) return null;
|
|
54
|
+
return trimmed.startsWith('git:') ? trimmed.slice(4).trim() || null : trimmed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolvePreviousBaseline(state, session) {
|
|
58
|
+
return (
|
|
59
|
+
normalizeGitRef(session?.baseline_ref?.git_head)
|
|
60
|
+
|| normalizeGitRef(state?.accepted_integration_ref)
|
|
61
|
+
|| normalizeGitRef(state?.last_completed_turn?.checkpoint_sha)
|
|
62
|
+
|| null
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseNameStatus(raw) {
|
|
67
|
+
if (!raw.trim()) return [];
|
|
68
|
+
return raw.split('\n').filter(Boolean).map((line) => {
|
|
69
|
+
const parts = line.split('\t');
|
|
70
|
+
const status = parts[0] || '';
|
|
71
|
+
const paths = parts.slice(1).filter(Boolean);
|
|
72
|
+
return { status, paths };
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function listCommitPaths(root, sha) {
|
|
77
|
+
const raw = git(root, ['diff-tree', '--no-commit-id', '--name-status', '-r', sha]);
|
|
78
|
+
return parseNameStatus(raw);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function summarizeCommit(root, sha) {
|
|
82
|
+
const subject = git(root, ['log', '-1', '--format=%s', sha]);
|
|
83
|
+
const entries = listCommitPaths(root, sha);
|
|
84
|
+
const paths = [...new Set(entries.flatMap((entry) => entry.paths))].sort();
|
|
85
|
+
return {
|
|
86
|
+
sha,
|
|
87
|
+
subject,
|
|
88
|
+
paths_touched: paths,
|
|
89
|
+
name_status: entries,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function classifyUnsafeCommit(commit) {
|
|
94
|
+
for (const entry of commit.name_status) {
|
|
95
|
+
for (const pathName of entry.paths) {
|
|
96
|
+
if (pathName === '.agentxchain' || pathName.startsWith('.agentxchain/')) {
|
|
97
|
+
return {
|
|
98
|
+
error_class: 'governance_state_modified',
|
|
99
|
+
message: `Commit ${commit.sha.slice(0, 8)} modifies governed state path ${pathName}; reconcile cannot auto-accept .agentxchain edits.`,
|
|
100
|
+
commit: commit.sha,
|
|
101
|
+
path: pathName,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (entry.status.startsWith('D') && CRITICAL_DELETION_PATHS.has(pathName)) {
|
|
105
|
+
return {
|
|
106
|
+
error_class: 'critical_artifact_deleted',
|
|
107
|
+
message: `Commit ${commit.sha.slice(0, 8)} deletes critical governed evidence ${pathName}; restore the artifact or restart from an explicit recovery point.`,
|
|
108
|
+
commit: commit.sha,
|
|
109
|
+
path: pathName,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function writeSessionBaseline(root, state, previousBaseline, acceptedHead, acceptedCommits) {
|
|
118
|
+
const existing = readSessionCheckpoint(root) || {};
|
|
119
|
+
const checkpoint = {
|
|
120
|
+
...existing,
|
|
121
|
+
run_id: state?.run_id || existing.run_id || null,
|
|
122
|
+
last_checkpoint_at: new Date().toISOString(),
|
|
123
|
+
checkpoint_reason: 'operator_commit_reconciled',
|
|
124
|
+
run_status: state?.status || existing.run_status || null,
|
|
125
|
+
phase: state?.phase || state?.current_phase || existing.phase || null,
|
|
126
|
+
last_phase: state?.phase || state?.current_phase || existing.last_phase || null,
|
|
127
|
+
last_completed_turn_id: state?.last_completed_turn_id || existing.last_completed_turn_id || null,
|
|
128
|
+
baseline_ref: captureBaselineRef(root),
|
|
129
|
+
operator_commit_reconciliation: {
|
|
130
|
+
previous_baseline: previousBaseline,
|
|
131
|
+
accepted_head: acceptedHead,
|
|
132
|
+
commit_count: acceptedCommits.length,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
writeFileSync(join(root, SESSION_PATH), JSON.stringify(checkpoint, null, 2) + '\n');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function reconcileOperatorHead(root, opts = {}) {
|
|
139
|
+
let currentHead;
|
|
140
|
+
try {
|
|
141
|
+
if (git(root, ['rev-parse', '--is-inside-work-tree']) !== 'true') {
|
|
142
|
+
return { ok: false, error_class: 'not_git_repo', error: 'reconcile-state requires a git repository.' };
|
|
143
|
+
}
|
|
144
|
+
currentHead = git(root, ['rev-parse', 'HEAD']);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
return { ok: false, error_class: 'git_unavailable', error: `Unable to inspect git HEAD: ${extractGitError(err)}` };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const state = readState(root);
|
|
150
|
+
const session = readSessionCheckpoint(root);
|
|
151
|
+
const previousBaseline = resolvePreviousBaseline(state, session);
|
|
152
|
+
if (!previousBaseline) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
error_class: 'missing_baseline',
|
|
156
|
+
error: 'No prior checkpoint baseline found in session.json, state.accepted_integration_ref, or last_completed_turn.checkpoint_sha.',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (previousBaseline === currentHead) {
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
no_op: true,
|
|
164
|
+
previous_baseline: previousBaseline,
|
|
165
|
+
accepted_head: currentHead,
|
|
166
|
+
accepted_commits: [],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!gitOk(root, ['merge-base', '--is-ancestor', previousBaseline, currentHead])) {
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
error_class: 'history_rewrite',
|
|
174
|
+
error: `Cannot reconcile operator HEAD: baseline ${previousBaseline.slice(0, 8)} is not an ancestor of current HEAD ${currentHead.slice(0, 8)}.`,
|
|
175
|
+
previous_baseline: previousBaseline,
|
|
176
|
+
current_head: currentHead,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let shas;
|
|
181
|
+
try {
|
|
182
|
+
shas = git(root, ['rev-list', '--reverse', `${previousBaseline}..${currentHead}`])
|
|
183
|
+
.split('\n')
|
|
184
|
+
.map((value) => value.trim())
|
|
185
|
+
.filter(Boolean);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
return { ok: false, error_class: 'commit_walk_failed', error: `Failed to inspect operator commits: ${extractGitError(err)}` };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const commits = shas.map((sha) => summarizeCommit(root, sha));
|
|
191
|
+
for (const commit of commits) {
|
|
192
|
+
const unsafe = classifyUnsafeCommit(commit);
|
|
193
|
+
if (unsafe) {
|
|
194
|
+
return {
|
|
195
|
+
ok: false,
|
|
196
|
+
error_class: unsafe.error_class,
|
|
197
|
+
error: unsafe.message,
|
|
198
|
+
offending_commit: unsafe.commit,
|
|
199
|
+
offending_path: unsafe.path,
|
|
200
|
+
previous_baseline: previousBaseline,
|
|
201
|
+
current_head: currentHead,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const acceptedAt = new Date().toISOString();
|
|
207
|
+
const pathsTouched = [...new Set(commits.flatMap((commit) => commit.paths_touched))].sort();
|
|
208
|
+
const nextState = state
|
|
209
|
+
? {
|
|
210
|
+
...state,
|
|
211
|
+
accepted_integration_ref: `git:${currentHead}`,
|
|
212
|
+
operator_commit_reconciliation: {
|
|
213
|
+
reconciled_at: acceptedAt,
|
|
214
|
+
previous_baseline: previousBaseline,
|
|
215
|
+
accepted_head: currentHead,
|
|
216
|
+
commit_count: commits.length,
|
|
217
|
+
safety_mode: opts.safetyMode || 'manual_safe_only',
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
: null;
|
|
221
|
+
|
|
222
|
+
if (nextState) {
|
|
223
|
+
safeWriteJson(join(root, STATE_PATH), nextState);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
emitRunEvent(root, 'state_reconciled_operator_commits', {
|
|
227
|
+
run_id: state?.run_id || null,
|
|
228
|
+
phase: state?.phase || state?.current_phase || null,
|
|
229
|
+
status: state?.status || null,
|
|
230
|
+
payload: {
|
|
231
|
+
previous_baseline: previousBaseline,
|
|
232
|
+
accepted_head: currentHead,
|
|
233
|
+
accepted_commits: commits.map((commit) => ({
|
|
234
|
+
sha: commit.sha,
|
|
235
|
+
subject: commit.subject,
|
|
236
|
+
paths_touched: commit.paths_touched,
|
|
237
|
+
})),
|
|
238
|
+
paths_touched: pathsTouched,
|
|
239
|
+
safety_checks: {
|
|
240
|
+
baseline_is_ancestor: true,
|
|
241
|
+
rejected_state_paths: ['.agentxchain/'],
|
|
242
|
+
rejected_deletions: [...CRITICAL_DELETION_PATHS],
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
writeSessionBaseline(root, nextState || state, previousBaseline, currentHead, commits);
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
ok: true,
|
|
251
|
+
previous_baseline: previousBaseline,
|
|
252
|
+
accepted_head: currentHead,
|
|
253
|
+
accepted_commits: commits.map((commit) => ({
|
|
254
|
+
sha: commit.sha,
|
|
255
|
+
subject: commit.subject,
|
|
256
|
+
paths_touched: commit.paths_touched,
|
|
257
|
+
})),
|
|
258
|
+
paths_touched: pathsTouched,
|
|
259
|
+
};
|
|
260
|
+
}
|