agentxchain 2.140.0 → 2.142.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/package.json +1 -1
- package/scripts/release-preflight.sh +1 -1
- package/src/lib/governed-state.js +239 -4
- package/src/lib/repo-observer.js +51 -1
- package/src/lib/schemas/turn-result.schema.json +14 -0
- package/src/lib/turn-checkpoint.js +2 -18
- package/src/lib/turn-result-validator.js +34 -0
- package/src/lib/verification-replay.js +286 -5
package/package.json
CHANGED
|
@@ -153,7 +153,7 @@ if [[ "$PUBLISH_GATE" -eq 1 ]]; then
|
|
|
153
153
|
test/release-notes-gate.test.js
|
|
154
154
|
test/release-identity-hardening.test.js
|
|
155
155
|
test/normalized-config.test.js
|
|
156
|
-
test/conformance.test.js
|
|
156
|
+
test/protocol-conformance.test.js
|
|
157
157
|
test/beta-scenario-emission-guard.test.js
|
|
158
158
|
test/claim-reality-preflight.test.js
|
|
159
159
|
test/beta-tester-scenarios/*.test.js
|
|
@@ -36,7 +36,10 @@ import {
|
|
|
36
36
|
compareDeclaredVsObserved,
|
|
37
37
|
deriveAcceptedRef,
|
|
38
38
|
checkCleanBaseline,
|
|
39
|
+
detectDirtyFilesOutsideAllowed,
|
|
39
40
|
isOperationalPath,
|
|
41
|
+
isBaselineExemptPath,
|
|
42
|
+
normalizeCheckpointableFiles,
|
|
40
43
|
} from './repo-observer.js';
|
|
41
44
|
import { getMaxConcurrentTurns } from './normalized-config.js';
|
|
42
45
|
import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir, getReviewArtifactPath } from './turn-paths.js';
|
|
@@ -64,6 +67,8 @@ import {
|
|
|
64
67
|
} from './repo-decisions.js';
|
|
65
68
|
import {
|
|
66
69
|
replayVerificationMachineEvidence,
|
|
70
|
+
normalizeVerificationProducedFiles,
|
|
71
|
+
cleanupIgnoredVerificationFiles,
|
|
67
72
|
summarizeVerificationReplay,
|
|
68
73
|
} from './verification-replay.js';
|
|
69
74
|
import { executeGateActions } from './gate-actions.js';
|
|
@@ -106,6 +111,34 @@ function buildInitialPhaseGateStatus(config) {
|
|
|
106
111
|
);
|
|
107
112
|
}
|
|
108
113
|
|
|
114
|
+
function buildObservationDrift(beforeObservation, afterObservation) {
|
|
115
|
+
const beforeFiles = (Array.isArray(beforeObservation?.files_changed) ? beforeObservation.files_changed : [])
|
|
116
|
+
.filter((filePath) => !isBaselineExemptPath(filePath));
|
|
117
|
+
const afterFiles = (Array.isArray(afterObservation?.files_changed) ? afterObservation.files_changed : [])
|
|
118
|
+
.filter((filePath) => !isBaselineExemptPath(filePath));
|
|
119
|
+
const beforeSet = new Set(beforeFiles);
|
|
120
|
+
const afterSet = new Set(afterFiles);
|
|
121
|
+
const beforeMarkers = beforeObservation?.file_markers && typeof beforeObservation.file_markers === 'object'
|
|
122
|
+
? beforeObservation.file_markers
|
|
123
|
+
: {};
|
|
124
|
+
const afterMarkers = afterObservation?.file_markers && typeof afterObservation.file_markers === 'object'
|
|
125
|
+
? afterObservation.file_markers
|
|
126
|
+
: {};
|
|
127
|
+
|
|
128
|
+
const added = afterFiles.filter((filePath) => !beforeSet.has(filePath));
|
|
129
|
+
const removed = beforeFiles.filter((filePath) => !afterSet.has(filePath));
|
|
130
|
+
const markerChanged = beforeFiles.filter((filePath) => (
|
|
131
|
+
afterSet.has(filePath) && beforeMarkers[filePath] !== afterMarkers[filePath]
|
|
132
|
+
));
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
added,
|
|
136
|
+
removed,
|
|
137
|
+
marker_changed: markerChanged,
|
|
138
|
+
drifted_files: [...new Set([...added, ...removed, ...markerChanged])].sort(),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
109
142
|
function listIntakeIntentFiles(root) {
|
|
110
143
|
const intentsDir = join(root, INTAKE_INTENTS_DIR);
|
|
111
144
|
if (!existsSync(intentsDir)) return [];
|
|
@@ -3176,7 +3209,16 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3176
3209
|
};
|
|
3177
3210
|
}
|
|
3178
3211
|
|
|
3179
|
-
const
|
|
3212
|
+
const rawTurnResult = validation.turnResult;
|
|
3213
|
+
const verificationProducedFiles = normalizeVerificationProducedFiles(rawTurnResult.verification);
|
|
3214
|
+
const effectiveFilesChanged = normalizeCheckpointableFiles([
|
|
3215
|
+
...(rawTurnResult.files_changed || []),
|
|
3216
|
+
...verificationProducedFiles.artifact_files,
|
|
3217
|
+
]);
|
|
3218
|
+
const turnResult = {
|
|
3219
|
+
...rawTurnResult,
|
|
3220
|
+
files_changed: effectiveFilesChanged,
|
|
3221
|
+
};
|
|
3180
3222
|
|
|
3181
3223
|
// Validate cross-run decision overrides against repo-decisions.jsonl
|
|
3182
3224
|
if (turnResult.decisions && turnResult.decisions.length > 0) {
|
|
@@ -3202,6 +3244,35 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3202
3244
|
const stagingFile = join(root, resolvedStagingPath);
|
|
3203
3245
|
const now = new Date().toISOString();
|
|
3204
3246
|
const baseline = currentTurn.baseline || null;
|
|
3247
|
+
const ignoredVerificationCleanup = cleanupIgnoredVerificationFiles({
|
|
3248
|
+
root,
|
|
3249
|
+
baseline,
|
|
3250
|
+
ignoredFiles: verificationProducedFiles.ignored_files,
|
|
3251
|
+
});
|
|
3252
|
+
if (ignoredVerificationCleanup.ok === false) {
|
|
3253
|
+
const cleanupError = ignoredVerificationCleanup.cleanup_error
|
|
3254
|
+
|| 'Ignored verification-produced files could not be restored safely.';
|
|
3255
|
+
transitionToFailedAcceptance(root, state, currentTurn, cleanupError, {
|
|
3256
|
+
error_code: 'verification_produced_files_cleanup',
|
|
3257
|
+
stage: 'artifact_observation',
|
|
3258
|
+
extra: {
|
|
3259
|
+
ignored_files: verificationProducedFiles.ignored_files,
|
|
3260
|
+
restored_files: ignoredVerificationCleanup.restored_files || [],
|
|
3261
|
+
},
|
|
3262
|
+
});
|
|
3263
|
+
return {
|
|
3264
|
+
ok: false,
|
|
3265
|
+
error: cleanupError,
|
|
3266
|
+
validation: {
|
|
3267
|
+
...validation,
|
|
3268
|
+
ok: false,
|
|
3269
|
+
stage: 'artifact_observation',
|
|
3270
|
+
error_class: 'artifact_error',
|
|
3271
|
+
errors: [cleanupError],
|
|
3272
|
+
warnings: validation.warnings,
|
|
3273
|
+
},
|
|
3274
|
+
};
|
|
3275
|
+
}
|
|
3205
3276
|
const rawObservation = observeChanges(root, baseline);
|
|
3206
3277
|
const historyEntries = readJsonlEntries(root, HISTORY_PATH);
|
|
3207
3278
|
const pendingConcurrentSiblingDeclarations = collectPendingConcurrentSiblingDeclarations(
|
|
@@ -3210,11 +3281,17 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3210
3281
|
currentTurn,
|
|
3211
3282
|
historyEntries,
|
|
3212
3283
|
);
|
|
3213
|
-
const
|
|
3284
|
+
const observationAttributionOptions = {
|
|
3214
3285
|
currentDeclaredFiles: turnResult.files_changed || [],
|
|
3215
3286
|
concurrentSiblingIds: pendingConcurrentSiblingDeclarations.map((entry) => entry.turn_id),
|
|
3216
3287
|
pendingConcurrentSiblingDeclarations,
|
|
3217
|
-
}
|
|
3288
|
+
};
|
|
3289
|
+
const observation = attributeObservedChangesToTurn(
|
|
3290
|
+
rawObservation,
|
|
3291
|
+
currentTurn,
|
|
3292
|
+
historyEntries,
|
|
3293
|
+
observationAttributionOptions,
|
|
3294
|
+
);
|
|
3218
3295
|
const role = config.roles?.[turnResult.role];
|
|
3219
3296
|
const runtimeId = turnResult.runtime_id;
|
|
3220
3297
|
const runtime = config.runtimes?.[runtimeId];
|
|
@@ -3233,9 +3310,76 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3233
3310
|
const concurrentIds = new Set(
|
|
3234
3311
|
Array.isArray(currentTurn.concurrent_with) ? currentTurn.concurrent_with : [],
|
|
3235
3312
|
);
|
|
3313
|
+
const concurrentAllowedDirtyFiles = [];
|
|
3314
|
+
for (const declaration of pendingConcurrentSiblingDeclarations) {
|
|
3315
|
+
concurrentIds.add(declaration?.turn_id);
|
|
3316
|
+
for (const filePath of Array.isArray(declaration?.files_changed) ? declaration.files_changed : []) {
|
|
3317
|
+
concurrentAllowedDirtyFiles.push(filePath);
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
for (const historyEntry of historyEntries) {
|
|
3321
|
+
const isConcurrentSibling = concurrentIds.has(historyEntry?.turn_id)
|
|
3322
|
+
|| (Array.isArray(historyEntry?.concurrent_with) && historyEntry.concurrent_with.includes(currentTurn.turn_id));
|
|
3323
|
+
if (!isConcurrentSibling) {
|
|
3324
|
+
continue;
|
|
3325
|
+
}
|
|
3326
|
+
const siblingFiles = Array.isArray(historyEntry?.observed_artifact?.files_changed)
|
|
3327
|
+
? historyEntry.observed_artifact.files_changed
|
|
3328
|
+
: historyEntry?.files_changed;
|
|
3329
|
+
for (const filePath of Array.isArray(siblingFiles) ? siblingFiles : []) {
|
|
3330
|
+
concurrentAllowedDirtyFiles.push(filePath);
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3236
3333
|
const acceptedTurnIds = new Set(historyEntries.map(h => h.turn_id));
|
|
3237
3334
|
const hasUnacceptedConcurrentSiblings = [...concurrentIds].some(id => !acceptedTurnIds.has(id));
|
|
3238
3335
|
|
|
3336
|
+
// Files from accepted-but-uncheckpointed prior turns are expected to be
|
|
3337
|
+
// dirty in the working tree until `checkpoint-turn` commits them. These
|
|
3338
|
+
// must not block the current turn's acceptance — they are known accepted
|
|
3339
|
+
// mutations, not undeclared agent writes.
|
|
3340
|
+
const uncheckpointedPriorFiles = [];
|
|
3341
|
+
for (const historyEntry of historyEntries) {
|
|
3342
|
+
if (historyEntry.checkpoint_sha) continue; // already committed
|
|
3343
|
+
const priorFiles = Array.isArray(historyEntry?.files_changed)
|
|
3344
|
+
? historyEntry.files_changed
|
|
3345
|
+
: [];
|
|
3346
|
+
for (const filePath of priorFiles) {
|
|
3347
|
+
uncheckpointedPriorFiles.push(filePath);
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
const dirtyParity = detectDirtyFilesOutsideAllowed(
|
|
3352
|
+
root,
|
|
3353
|
+
writeAuthority,
|
|
3354
|
+
[
|
|
3355
|
+
...(turnResult.files_changed || []),
|
|
3356
|
+
...concurrentAllowedDirtyFiles,
|
|
3357
|
+
...uncheckpointedPriorFiles,
|
|
3358
|
+
],
|
|
3359
|
+
);
|
|
3360
|
+
if (!dirtyParity.clean) {
|
|
3361
|
+
transitionToFailedAcceptance(root, state, currentTurn, dirtyParity.reason, {
|
|
3362
|
+
error_code: 'artifact_dirty_tree_mismatch',
|
|
3363
|
+
stage: 'artifact_observation',
|
|
3364
|
+
extra: {
|
|
3365
|
+
unexpected_dirty_files: dirtyParity.unexpected_dirty_files,
|
|
3366
|
+
dirty_files: dirtyParity.dirty_files,
|
|
3367
|
+
},
|
|
3368
|
+
});
|
|
3369
|
+
return {
|
|
3370
|
+
ok: false,
|
|
3371
|
+
error: dirtyParity.reason,
|
|
3372
|
+
validation: {
|
|
3373
|
+
...validation,
|
|
3374
|
+
ok: false,
|
|
3375
|
+
stage: 'artifact_observation',
|
|
3376
|
+
error_class: 'artifact_error',
|
|
3377
|
+
errors: [dirtyParity.reason],
|
|
3378
|
+
warnings: validation.warnings,
|
|
3379
|
+
},
|
|
3380
|
+
};
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3239
3383
|
const diffComparison = compareDeclaredVsObserved(
|
|
3240
3384
|
turnResult.files_changed || [],
|
|
3241
3385
|
observation.files_changed,
|
|
@@ -3398,11 +3542,70 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3398
3542
|
const observedArtifact = buildObservedArtifact(observation, baseline);
|
|
3399
3543
|
const normalizedVerification = normalizeVerification(turnResult.verification, runtimeType);
|
|
3400
3544
|
const artifactType = turnResult.artifact?.type || 'review';
|
|
3545
|
+
|
|
3546
|
+
// BUG-46 fix requirement #6: workspace artifact with empty files_changed is a
|
|
3547
|
+
// protocol violation for authoritative completed turns. A workspace artifact
|
|
3548
|
+
// declares "I mutated repo files" — if files_changed is empty, the declaration
|
|
3549
|
+
// is incoherent and must be rejected before replay or history persistence.
|
|
3550
|
+
if (artifactType === 'workspace'
|
|
3551
|
+
&& writeAuthority === 'authoritative'
|
|
3552
|
+
&& turnResult.status === 'completed'
|
|
3553
|
+
&& (turnResult.files_changed || []).length === 0
|
|
3554
|
+
&& (observation.files_changed || []).length === 0) {
|
|
3555
|
+
const emptyWsError = 'Turn declared artifact.type: "workspace" but files_changed is empty. '
|
|
3556
|
+
+ 'Either declare the files modified, or set artifact.type: "review" if no repo mutations were intended.';
|
|
3557
|
+
transitionToFailedAcceptance(root, state, currentTurn, emptyWsError, {
|
|
3558
|
+
error_code: 'empty_workspace_artifact',
|
|
3559
|
+
stage: 'artifact_validation',
|
|
3560
|
+
extra: {
|
|
3561
|
+
artifact_type: artifactType,
|
|
3562
|
+
write_authority: writeAuthority,
|
|
3563
|
+
turn_status: turnResult.status,
|
|
3564
|
+
},
|
|
3565
|
+
});
|
|
3566
|
+
return {
|
|
3567
|
+
ok: false,
|
|
3568
|
+
error: emptyWsError,
|
|
3569
|
+
validation: {
|
|
3570
|
+
...validation,
|
|
3571
|
+
ok: false,
|
|
3572
|
+
stage: 'artifact_validation',
|
|
3573
|
+
error_class: 'artifact_error',
|
|
3574
|
+
errors: [emptyWsError],
|
|
3575
|
+
warnings: validation.warnings,
|
|
3576
|
+
},
|
|
3577
|
+
};
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3401
3580
|
const derivedRef = deriveAcceptedRef(observation, artifactType, state.accepted_integration_ref);
|
|
3402
3581
|
const verificationReplay = (config.policies || []).some((policy) => policy?.rule === 'require_reproducible_verification')
|
|
3403
3582
|
? replayVerificationMachineEvidence({ root, verification: turnResult.verification })
|
|
3404
3583
|
: null;
|
|
3405
3584
|
|
|
3585
|
+
if (verificationReplay?.workspace_guard?.ok === false) {
|
|
3586
|
+
const replayError = verificationReplay.workspace_guard.cleanup_error
|
|
3587
|
+
|| 'Verification replay mutated actor-owned workspace files and cleanup failed.';
|
|
3588
|
+
transitionToFailedAcceptance(root, state, currentTurn, replayError, {
|
|
3589
|
+
error_code: 'verification_replay_drift',
|
|
3590
|
+
stage: 'verification_replay',
|
|
3591
|
+
extra: {
|
|
3592
|
+
replay_side_effect_files: verificationReplay.workspace_guard.restored_files || [],
|
|
3593
|
+
},
|
|
3594
|
+
});
|
|
3595
|
+
return {
|
|
3596
|
+
ok: false,
|
|
3597
|
+
error: replayError,
|
|
3598
|
+
validation: {
|
|
3599
|
+
...validation,
|
|
3600
|
+
ok: false,
|
|
3601
|
+
stage: 'verification_replay',
|
|
3602
|
+
error_class: 'verification_replay_error',
|
|
3603
|
+
errors: [replayError],
|
|
3604
|
+
warnings: validation.warnings,
|
|
3605
|
+
},
|
|
3606
|
+
};
|
|
3607
|
+
}
|
|
3608
|
+
|
|
3406
3609
|
// Policy evaluation — declarative governance rules (spec: POLICY_ENGINE_SPEC.md)
|
|
3407
3610
|
const policyResult = evaluatePolicies(config.policies || [], {
|
|
3408
3611
|
currentPhase: state.phase,
|
|
@@ -3638,6 +3841,38 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3638
3841
|
}
|
|
3639
3842
|
}
|
|
3640
3843
|
|
|
3844
|
+
const finalObservation = attributeObservedChangesToTurn(
|
|
3845
|
+
observeChanges(root, baseline),
|
|
3846
|
+
currentTurn,
|
|
3847
|
+
historyEntries,
|
|
3848
|
+
observationAttributionOptions,
|
|
3849
|
+
);
|
|
3850
|
+
const observationDrift = buildObservationDrift(observation, finalObservation);
|
|
3851
|
+
if (observationDrift.drifted_files.length > 0) {
|
|
3852
|
+
const driftError = `Acceptance mutated actor-owned workspace after artifact observation: ${observationDrift.drifted_files.join(', ')}`;
|
|
3853
|
+
transitionToFailedAcceptance(root, state, currentTurn, driftError, {
|
|
3854
|
+
error_code: 'acceptance_drift',
|
|
3855
|
+
stage: 'artifact_observation',
|
|
3856
|
+
extra: {
|
|
3857
|
+
added_files: observationDrift.added,
|
|
3858
|
+
removed_files: observationDrift.removed,
|
|
3859
|
+
marker_changed_files: observationDrift.marker_changed,
|
|
3860
|
+
},
|
|
3861
|
+
});
|
|
3862
|
+
return {
|
|
3863
|
+
ok: false,
|
|
3864
|
+
error: driftError,
|
|
3865
|
+
validation: {
|
|
3866
|
+
...validation,
|
|
3867
|
+
ok: false,
|
|
3868
|
+
stage: 'artifact_observation',
|
|
3869
|
+
error_class: 'artifact_error',
|
|
3870
|
+
errors: [driftError],
|
|
3871
|
+
warnings: validation.warnings,
|
|
3872
|
+
},
|
|
3873
|
+
};
|
|
3874
|
+
}
|
|
3875
|
+
|
|
3641
3876
|
const acceptedSequence = (state.turn_sequence || 0) + 1;
|
|
3642
3877
|
const historyEntry = {
|
|
3643
3878
|
turn_id: turnResult.turn_id,
|
|
@@ -3649,7 +3884,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3649
3884
|
summary: turnResult.summary,
|
|
3650
3885
|
decisions: turnResult.decisions || [],
|
|
3651
3886
|
objections: turnResult.objections || [],
|
|
3652
|
-
files_changed: turnResult.files_changed
|
|
3887
|
+
files_changed: normalizeCheckpointableFiles(turnResult.files_changed),
|
|
3653
3888
|
artifacts_created: turnResult.artifacts_created || [],
|
|
3654
3889
|
verification: turnResult.verification || {},
|
|
3655
3890
|
normalized_verification: normalizedVerification,
|
package/src/lib/repo-observer.js
CHANGED
|
@@ -29,6 +29,8 @@ const OPERATIONAL_PATH_PREFIXES = [
|
|
|
29
29
|
'.agentxchain/intake/',
|
|
30
30
|
'.agentxchain/locks/',
|
|
31
31
|
'.agentxchain/transactions/',
|
|
32
|
+
'.agentxchain/missions/',
|
|
33
|
+
'.agentxchain/multirepo/',
|
|
32
34
|
];
|
|
33
35
|
|
|
34
36
|
// Orchestrator-owned state files that agents must never be blamed for modifying.
|
|
@@ -38,6 +40,7 @@ const ORCHESTRATOR_STATE_FILES = [
|
|
|
38
40
|
'.agentxchain/session.json',
|
|
39
41
|
'.agentxchain/history.jsonl',
|
|
40
42
|
'.agentxchain/decision-ledger.jsonl',
|
|
43
|
+
'.agentxchain/repo-decisions.jsonl',
|
|
41
44
|
'.agentxchain/lock.json',
|
|
42
45
|
'.agentxchain/hook-audit.jsonl',
|
|
43
46
|
'.agentxchain/hook-annotations.jsonl',
|
|
@@ -71,11 +74,21 @@ export function isOperationalPath(filePath) {
|
|
|
71
74
|
|| ORCHESTRATOR_STATE_FILES.includes(filePath);
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
function isBaselineExemptPath(filePath) {
|
|
77
|
+
export function isBaselineExemptPath(filePath) {
|
|
75
78
|
return isOperationalPath(filePath)
|
|
76
79
|
|| BASELINE_EXEMPT_PATH_PREFIXES.some(prefix => filePath.startsWith(prefix));
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
export function normalizeCheckpointableFiles(filesChanged) {
|
|
83
|
+
return [...new Set(
|
|
84
|
+
(Array.isArray(filesChanged) ? filesChanged : [])
|
|
85
|
+
.filter((value) => typeof value === 'string')
|
|
86
|
+
.map((value) => value.trim())
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.filter((value) => !isOperationalPath(value)),
|
|
89
|
+
)];
|
|
90
|
+
}
|
|
91
|
+
|
|
79
92
|
// ── Baseline Capture ────────────────────────────────────────────────────────
|
|
80
93
|
|
|
81
94
|
/**
|
|
@@ -628,6 +641,43 @@ export function checkCleanBaseline(root, writeAuthority) {
|
|
|
628
641
|
};
|
|
629
642
|
}
|
|
630
643
|
|
|
644
|
+
export function detectDirtyFilesOutsideAllowed(root, writeAuthority, allowedFiles = []) {
|
|
645
|
+
const cleanCheck = checkCleanBaseline(root, writeAuthority);
|
|
646
|
+
if (cleanCheck.clean) {
|
|
647
|
+
return {
|
|
648
|
+
clean: true,
|
|
649
|
+
dirty_files: [],
|
|
650
|
+
unexpected_dirty_files: [],
|
|
651
|
+
reason: null,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const allowedSet = new Set(
|
|
656
|
+
(Array.isArray(allowedFiles) ? allowedFiles : [])
|
|
657
|
+
.filter((value) => typeof value === 'string')
|
|
658
|
+
.map((value) => value.trim())
|
|
659
|
+
.filter(Boolean),
|
|
660
|
+
);
|
|
661
|
+
const dirtyFiles = Array.isArray(cleanCheck.dirty_files) ? cleanCheck.dirty_files : [];
|
|
662
|
+
const unexpectedDirtyFiles = dirtyFiles.filter((filePath) => !allowedSet.has(filePath));
|
|
663
|
+
|
|
664
|
+
if (unexpectedDirtyFiles.length === 0) {
|
|
665
|
+
return {
|
|
666
|
+
clean: true,
|
|
667
|
+
dirty_files: dirtyFiles,
|
|
668
|
+
unexpected_dirty_files: [],
|
|
669
|
+
reason: null,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return {
|
|
674
|
+
clean: false,
|
|
675
|
+
dirty_files: dirtyFiles,
|
|
676
|
+
unexpected_dirty_files: unexpectedDirtyFiles,
|
|
677
|
+
reason: `Working tree has uncommitted changes in actor-owned files outside the accepted turn contract: ${unexpectedDirtyFiles.slice(0, 5).join(', ')}${unexpectedDirtyFiles.length > 5 ? '...' : ''}. Resume would block on the same files. Declare them in files_changed, classify verification outputs under verification.produced_files, or clean them before acceptance.`,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
631
681
|
// ── Git Primitives ──────────────────────────────────────────────────────────
|
|
632
682
|
|
|
633
683
|
function isGitRepo(root) {
|
|
@@ -165,6 +165,20 @@
|
|
|
165
165
|
"exit_code": { "type": "integer" }
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
|
+
},
|
|
169
|
+
"produced_files": {
|
|
170
|
+
"type": "array",
|
|
171
|
+
"items": {
|
|
172
|
+
"type": "object",
|
|
173
|
+
"required": ["path"],
|
|
174
|
+
"additionalProperties": false,
|
|
175
|
+
"properties": {
|
|
176
|
+
"path": { "type": "string" },
|
|
177
|
+
"disposition": {
|
|
178
|
+
"enum": ["artifact", "ignore"]
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
168
182
|
}
|
|
169
183
|
}
|
|
170
184
|
},
|
|
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { resolveAcceptedTurnHistoryReference } from './accepted-turn-history.js';
|
|
5
5
|
import { emitRunEvent } from './run-events.js';
|
|
6
6
|
import { safeWriteJson } from './safe-write.js';
|
|
7
|
+
import { normalizeCheckpointableFiles } from './repo-observer.js';
|
|
7
8
|
|
|
8
9
|
const STATE_PATH = '.agentxchain/state.json';
|
|
9
10
|
const HISTORY_PATH = '.agentxchain/history.jsonl';
|
|
@@ -52,25 +53,8 @@ function isGitRepo(root) {
|
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
// BUG-43: Staging and dispatch dirs are ephemeral — cleaned up after acceptance.
|
|
56
|
-
// They must never appear in checkpoint git-add paths.
|
|
57
|
-
const EPHEMERAL_PATH_PREFIXES = [
|
|
58
|
-
'.agentxchain/staging/',
|
|
59
|
-
'.agentxchain/dispatch/',
|
|
60
|
-
];
|
|
61
|
-
|
|
62
|
-
function isEphemeralPath(filePath) {
|
|
63
|
-
return EPHEMERAL_PATH_PREFIXES.some((prefix) => filePath.startsWith(prefix));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
56
|
function normalizeFilesChanged(filesChanged) {
|
|
67
|
-
return
|
|
68
|
-
(Array.isArray(filesChanged) ? filesChanged : [])
|
|
69
|
-
.filter((value) => typeof value === 'string')
|
|
70
|
-
.map((value) => value.trim())
|
|
71
|
-
.filter(Boolean)
|
|
72
|
-
.filter((value) => !isEphemeralPath(value)),
|
|
73
|
-
)];
|
|
57
|
+
return normalizeCheckpointableFiles(filesChanged);
|
|
74
58
|
}
|
|
75
59
|
|
|
76
60
|
function extractGitError(err) {
|
|
@@ -624,6 +624,40 @@ function validateVerification(tr) {
|
|
|
624
624
|
}
|
|
625
625
|
}
|
|
626
626
|
|
|
627
|
+
if (Array.isArray(v.produced_files)) {
|
|
628
|
+
const seenProducedPaths = new Map();
|
|
629
|
+
const declaredFiles = new Set(Array.isArray(tr.files_changed) ? tr.files_changed : []);
|
|
630
|
+
for (let i = 0; i < v.produced_files.length; i++) {
|
|
631
|
+
const entry = v.produced_files[i];
|
|
632
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
633
|
+
errors.push(`verification.produced_files[${i}] must be an object.`);
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const rawPath = typeof entry.path === 'string' ? entry.path : '';
|
|
638
|
+
const path = rawPath.trim();
|
|
639
|
+
if (!path) {
|
|
640
|
+
errors.push(`verification.produced_files[${i}].path must be a non-empty string.`);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const disposition = entry.disposition == null ? 'artifact' : entry.disposition;
|
|
644
|
+
if (!['artifact', 'ignore'].includes(disposition)) {
|
|
645
|
+
errors.push(`verification.produced_files[${i}].disposition must be "artifact" or "ignore".`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (path) {
|
|
649
|
+
if (seenProducedPaths.has(path)) {
|
|
650
|
+
errors.push(`verification.produced_files[${i}].path duplicates "${path}" from verification.produced_files[${seenProducedPaths.get(path)}].`);
|
|
651
|
+
} else {
|
|
652
|
+
seenProducedPaths.set(path, i);
|
|
653
|
+
}
|
|
654
|
+
if (disposition === 'ignore' && declaredFiles.has(path)) {
|
|
655
|
+
errors.push(`verification.produced_files[${i}] marks "${path}" as ignore, but it is also listed in files_changed.`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
627
661
|
return { errors, warnings };
|
|
628
662
|
}
|
|
629
663
|
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { isOperationalPath } from './repo-observer.js';
|
|
2
8
|
|
|
3
9
|
export const DEFAULT_VERIFICATION_REPLAY_TIMEOUT_MS = 30_000;
|
|
4
10
|
|
|
@@ -22,10 +28,15 @@ export function replayVerificationMachineEvidence({ root, verification, timeoutM
|
|
|
22
28
|
return payload;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
const workspaceGuard = createReplayWorkspaceGuard(root);
|
|
32
|
+
try {
|
|
33
|
+
payload.commands = machineEvidence.map((entry, index) => replayEvidenceCommand(root, entry, index, timeoutMs));
|
|
34
|
+
payload.replayed_commands = payload.commands.length;
|
|
35
|
+
payload.matched_commands = payload.commands.filter((entry) => entry.matched).length;
|
|
36
|
+
payload.overall = payload.commands.every((entry) => entry.matched) ? 'match' : 'mismatch';
|
|
37
|
+
} finally {
|
|
38
|
+
payload.workspace_guard = workspaceGuard.cleanup();
|
|
39
|
+
}
|
|
29
40
|
|
|
30
41
|
return payload;
|
|
31
42
|
}
|
|
@@ -69,3 +80,273 @@ export function summarizeVerificationReplay(payload) {
|
|
|
69
80
|
...(payload.reason ? { reason: payload.reason } : {}),
|
|
70
81
|
};
|
|
71
82
|
}
|
|
83
|
+
|
|
84
|
+
export function normalizeVerificationProducedFiles(verification) {
|
|
85
|
+
const artifactFiles = new Set();
|
|
86
|
+
const ignoredFiles = new Set();
|
|
87
|
+
const producedFiles = Array.isArray(verification?.produced_files)
|
|
88
|
+
? verification.produced_files
|
|
89
|
+
: [];
|
|
90
|
+
|
|
91
|
+
for (const entry of producedFiles) {
|
|
92
|
+
if (!entry || typeof entry !== 'object') {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const path = typeof entry.path === 'string' ? entry.path.trim() : '';
|
|
96
|
+
if (!path || isOperationalPath(path)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (entry.disposition === 'ignore') {
|
|
100
|
+
ignoredFiles.add(path);
|
|
101
|
+
} else {
|
|
102
|
+
artifactFiles.add(path);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
artifact_files: [...artifactFiles].sort(),
|
|
108
|
+
ignored_files: [...ignoredFiles].sort(),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function cleanupIgnoredVerificationFiles({ root, baseline, ignoredFiles }) {
|
|
113
|
+
const targetFiles = [...new Set(Array.isArray(ignoredFiles) ? ignoredFiles : [])]
|
|
114
|
+
.filter((filePath) => typeof filePath === 'string' && filePath && !isOperationalPath(filePath))
|
|
115
|
+
.sort();
|
|
116
|
+
|
|
117
|
+
if (targetFiles.length === 0 || !isGitRepo(root)) {
|
|
118
|
+
return { ok: true, restored_files: [], cleanup_error: null };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const restoredFiles = [];
|
|
122
|
+
const baselineSnapshot = baseline?.dirty_snapshot && typeof baseline.dirty_snapshot === 'object' && !Array.isArray(baseline.dirty_snapshot)
|
|
123
|
+
? baseline.dirty_snapshot
|
|
124
|
+
: {};
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
for (const filePath of targetFiles) {
|
|
128
|
+
const baselineMarker = baselineSnapshot[filePath];
|
|
129
|
+
const currentMarker = getWorkspaceFileMarker(root, filePath);
|
|
130
|
+
|
|
131
|
+
if (baselineMarker && currentMarker !== baselineMarker) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
restored_files: restoredFiles.sort(),
|
|
135
|
+
cleanup_error: `verification.produced_files declared "${filePath}" as ignore, but that path was already dirty at dispatch and cannot be restored safely.`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (baselineMarker) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
restoreCleanPath(root, filePath);
|
|
144
|
+
restoredFiles.push(filePath);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const remainingDirty = new Set(collectDirtyActorFiles(root));
|
|
148
|
+
const stranded = targetFiles.filter((filePath) => remainingDirty.has(filePath));
|
|
149
|
+
if (stranded.length > 0) {
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
restored_files: restoredFiles.sort(),
|
|
153
|
+
cleanup_error: `Ignored verification-produced files remain dirty after cleanup: ${stranded.join(', ')}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
restored_files: restoredFiles.sort(),
|
|
160
|
+
cleanup_error: null,
|
|
161
|
+
};
|
|
162
|
+
} catch (err) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
restored_files: restoredFiles.sort(),
|
|
166
|
+
cleanup_error: err?.message || String(err),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function createReplayWorkspaceGuard(root) {
|
|
172
|
+
if (!isGitRepo(root)) {
|
|
173
|
+
return {
|
|
174
|
+
cleanup() {
|
|
175
|
+
return { ok: true, restored_files: [], cleanup_error: null };
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const backupDir = mkdtempSync(join(tmpdir(), 'axc-verification-replay-'));
|
|
181
|
+
const beforeState = captureDirtyWorkspaceState(root, backupDir);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
cleanup() {
|
|
185
|
+
const restoredFiles = new Set();
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const afterFiles = collectDirtyActorFiles(root);
|
|
189
|
+
const candidateFiles = new Set([...beforeState.files.keys(), ...afterFiles]);
|
|
190
|
+
|
|
191
|
+
for (const filePath of candidateFiles) {
|
|
192
|
+
const beforeInfo = beforeState.files.get(filePath);
|
|
193
|
+
const currentMarker = getWorkspaceFileMarker(root, filePath);
|
|
194
|
+
|
|
195
|
+
if (beforeInfo) {
|
|
196
|
+
if (currentMarker !== beforeInfo.marker) {
|
|
197
|
+
restorePreReplayPath(root, filePath, beforeInfo);
|
|
198
|
+
restoredFiles.add(filePath);
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
restoreCleanPath(root, filePath);
|
|
204
|
+
restoredFiles.add(filePath);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const remainingDrift = detectWorkspaceDrift(root, beforeState.files);
|
|
208
|
+
if (remainingDrift.length > 0) {
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
restored_files: [...restoredFiles].sort(),
|
|
212
|
+
cleanup_error: `Verification replay left actor-owned workspace drift after cleanup: ${remainingDrift.join(', ')}`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
ok: true,
|
|
218
|
+
restored_files: [...restoredFiles].sort(),
|
|
219
|
+
cleanup_error: null,
|
|
220
|
+
};
|
|
221
|
+
} catch (err) {
|
|
222
|
+
return {
|
|
223
|
+
ok: false,
|
|
224
|
+
restored_files: [...restoredFiles].sort(),
|
|
225
|
+
cleanup_error: err?.message || String(err),
|
|
226
|
+
};
|
|
227
|
+
} finally {
|
|
228
|
+
rmSync(backupDir, { recursive: true, force: true });
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function captureDirtyWorkspaceState(root, backupDir) {
|
|
235
|
+
const files = new Map();
|
|
236
|
+
for (const filePath of collectDirtyActorFiles(root)) {
|
|
237
|
+
const absPath = join(root, filePath);
|
|
238
|
+
const backupPath = join(backupDir, filePath);
|
|
239
|
+
const existed = existsSync(absPath);
|
|
240
|
+
const marker = getWorkspaceFileMarker(root, filePath);
|
|
241
|
+
|
|
242
|
+
if (existed) {
|
|
243
|
+
mkdirSync(dirname(backupPath), { recursive: true });
|
|
244
|
+
cpSync(absPath, backupPath, { force: true, recursive: true });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
files.set(filePath, { existed, marker, backupPath });
|
|
248
|
+
}
|
|
249
|
+
return { files };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function detectWorkspaceDrift(root, beforeFiles) {
|
|
253
|
+
const afterFiles = collectDirtyActorFiles(root);
|
|
254
|
+
const candidates = new Set([...beforeFiles.keys(), ...afterFiles]);
|
|
255
|
+
const drift = [];
|
|
256
|
+
|
|
257
|
+
for (const filePath of candidates) {
|
|
258
|
+
const beforeInfo = beforeFiles.get(filePath);
|
|
259
|
+
const currentMarker = getWorkspaceFileMarker(root, filePath);
|
|
260
|
+
if (beforeInfo) {
|
|
261
|
+
if (currentMarker !== beforeInfo.marker) {
|
|
262
|
+
drift.push(filePath);
|
|
263
|
+
}
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
drift.push(filePath);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return drift.sort();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function restorePreReplayPath(root, filePath, beforeInfo) {
|
|
273
|
+
const absPath = join(root, filePath);
|
|
274
|
+
if (!beforeInfo.existed) {
|
|
275
|
+
rmSync(absPath, { recursive: true, force: true });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
rmSync(absPath, { recursive: true, force: true });
|
|
280
|
+
mkdirSync(dirname(absPath), { recursive: true });
|
|
281
|
+
cpSync(beforeInfo.backupPath, absPath, { force: true, recursive: true });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function restoreCleanPath(root, filePath) {
|
|
285
|
+
const absPath = join(root, filePath);
|
|
286
|
+
if (isTrackedPath(root, filePath)) {
|
|
287
|
+
execFileSync('git', ['restore', '--source=HEAD', '--staged', '--worktree', '--', filePath], {
|
|
288
|
+
cwd: root,
|
|
289
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
290
|
+
});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
rmSync(absPath, { recursive: true, force: true });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function collectDirtyActorFiles(root) {
|
|
298
|
+
return [...new Set([
|
|
299
|
+
...readGitLines(root, ['diff', '--name-only', 'HEAD']),
|
|
300
|
+
...readGitLines(root, ['diff', '--name-only', '--cached']),
|
|
301
|
+
...readGitLines(root, ['ls-files', '--others', '--exclude-standard']),
|
|
302
|
+
])]
|
|
303
|
+
.filter((filePath) => filePath && !isOperationalPath(filePath))
|
|
304
|
+
.sort();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function readGitLines(root, args) {
|
|
308
|
+
try {
|
|
309
|
+
const output = execFileSync('git', args, {
|
|
310
|
+
cwd: root,
|
|
311
|
+
encoding: 'utf8',
|
|
312
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
313
|
+
}).trim();
|
|
314
|
+
return output ? output.split('\n').filter(Boolean) : [];
|
|
315
|
+
} catch {
|
|
316
|
+
return [];
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function isTrackedPath(root, filePath) {
|
|
321
|
+
try {
|
|
322
|
+
execFileSync('git', ['ls-files', '--error-unmatch', '--', filePath], {
|
|
323
|
+
cwd: root,
|
|
324
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
325
|
+
});
|
|
326
|
+
return true;
|
|
327
|
+
} catch {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function isGitRepo(root) {
|
|
333
|
+
try {
|
|
334
|
+
execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
335
|
+
cwd: root,
|
|
336
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
337
|
+
});
|
|
338
|
+
return true;
|
|
339
|
+
} catch {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function getWorkspaceFileMarker(root, filePath) {
|
|
345
|
+
const absPath = join(root, filePath);
|
|
346
|
+
if (!existsSync(absPath)) {
|
|
347
|
+
return '__deleted__';
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const content = readFileSync(absPath);
|
|
351
|
+
return createHash('sha1').update(content).digest('hex');
|
|
352
|
+
}
|