agentxchain 2.22.0 → 2.24.1
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/README.md +1 -1
- package/bin/agentxchain.js +32 -0
- package/package.json +1 -1
- package/scripts/release-bump.sh +55 -8
- package/scripts/release-postflight.sh +50 -9
- package/src/commands/proposal.js +144 -0
- package/src/commands/resume.js +9 -3
- package/src/commands/step.js +10 -5
- package/src/lib/adapters/api-proxy-adapter.js +39 -17
- package/src/lib/blocked-state.js +56 -14
- package/src/lib/config.js +2 -2
- package/src/lib/dispatch-bundle.js +176 -1
- package/src/lib/governed-state.js +215 -20
- package/src/lib/normalized-config.js +3 -3
- package/src/lib/proposal-ops.js +451 -0
- package/src/lib/repo-observer.js +1 -0
- package/src/lib/schemas/turn-result.schema.json +28 -0
- package/src/lib/turn-result-validator.js +35 -0
package/src/lib/blocked-state.js
CHANGED
|
@@ -1,27 +1,63 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
deriveConflictLoopRecoveryAction,
|
|
3
|
+
deriveDispatchRecoveryAction,
|
|
4
|
+
deriveEscalationRecoveryAction,
|
|
5
|
+
deriveHookTamperRecoveryAction,
|
|
6
|
+
deriveNeedsHumanRecoveryAction,
|
|
7
|
+
getActiveTurnCount,
|
|
8
|
+
} from './governed-state.js';
|
|
2
9
|
|
|
3
10
|
function isLegacyEscalationRecoveryAction(action) {
|
|
4
11
|
return action === 'Resolve the escalation, then run agentxchain step --resume'
|
|
5
12
|
|| action === 'Resolve the escalation, then run agentxchain step';
|
|
6
13
|
}
|
|
7
14
|
|
|
8
|
-
function
|
|
15
|
+
function isLegacyNeedsHumanRecoveryAction(action) {
|
|
16
|
+
return action === 'Resolve the stated issue, then run agentxchain step --resume';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isLegacyHookTamperRecoveryAction(action) {
|
|
20
|
+
return action === 'Disable or fix the hook, verify protected files, then run agentxchain step --resume';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isLegacyConflictLoopRecoveryAction(action) {
|
|
24
|
+
return typeof action === 'string' && action.startsWith('Serialize the conflicting work, then run agentxchain step --resume');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function maybeRefreshRecoveryAction(state, config, persistedRecovery, turnRetained) {
|
|
9
28
|
if (!config || !persistedRecovery || typeof persistedRecovery !== 'object') {
|
|
10
29
|
return null;
|
|
11
30
|
}
|
|
12
31
|
|
|
13
32
|
const typedReason = persistedRecovery.typed_reason;
|
|
14
33
|
const currentAction = persistedRecovery.recovery_action || null;
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
34
|
+
const turnId = state?.blocked_reason?.turn_id ?? state?.escalation?.from_turn_id ?? null;
|
|
35
|
+
if (typedReason === 'retries_exhausted' || ((typedReason === 'operator_escalation') && isLegacyEscalationRecoveryAction(currentAction))) {
|
|
36
|
+
return deriveEscalationRecoveryAction(state, config, {
|
|
37
|
+
turnRetained,
|
|
38
|
+
turnId,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (typedReason === 'needs_human' && isLegacyNeedsHumanRecoveryAction(currentAction)) {
|
|
43
|
+
return deriveNeedsHumanRecoveryAction(state, config, {
|
|
44
|
+
turnRetained,
|
|
45
|
+
turnId,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (typedReason === 'hook_tamper' && isLegacyHookTamperRecoveryAction(currentAction)) {
|
|
50
|
+
return deriveHookTamperRecoveryAction(state, config, {
|
|
51
|
+
turnRetained,
|
|
52
|
+
turnId,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (typedReason === 'conflict_loop' && isLegacyConflictLoopRecoveryAction(currentAction)) {
|
|
57
|
+
return deriveConflictLoopRecoveryAction(turnId);
|
|
19
58
|
}
|
|
20
59
|
|
|
21
|
-
return
|
|
22
|
-
turnRetained,
|
|
23
|
-
turnId: state?.blocked_reason?.turn_id ?? state?.escalation?.from_turn_id ?? null,
|
|
24
|
-
});
|
|
60
|
+
return null;
|
|
25
61
|
}
|
|
26
62
|
|
|
27
63
|
export function deriveRecoveryDescriptor(state, config = null) {
|
|
@@ -53,11 +89,11 @@ export function deriveRecoveryDescriptor(state, config = null) {
|
|
|
53
89
|
|
|
54
90
|
const persistedRecovery = state.blocked_reason?.recovery;
|
|
55
91
|
if (persistedRecovery && typeof persistedRecovery === 'object') {
|
|
56
|
-
const
|
|
92
|
+
const refreshedRecoveryAction = maybeRefreshRecoveryAction(state, config, persistedRecovery, turnRetained);
|
|
57
93
|
return {
|
|
58
94
|
typed_reason: persistedRecovery.typed_reason || 'unknown_block',
|
|
59
95
|
owner: persistedRecovery.owner || 'human',
|
|
60
|
-
recovery_action:
|
|
96
|
+
recovery_action: refreshedRecoveryAction
|
|
61
97
|
|| persistedRecovery.recovery_action
|
|
62
98
|
|| 'Inspect state.json and resolve manually before rerunning agentxchain step',
|
|
63
99
|
turn_retained: typeof persistedRecovery.turn_retained === 'boolean'
|
|
@@ -75,7 +111,10 @@ export function deriveRecoveryDescriptor(state, config = null) {
|
|
|
75
111
|
return {
|
|
76
112
|
typed_reason: 'needs_human',
|
|
77
113
|
owner: 'human',
|
|
78
|
-
recovery_action:
|
|
114
|
+
recovery_action: deriveNeedsHumanRecoveryAction(state, config, {
|
|
115
|
+
turnRetained,
|
|
116
|
+
turnId: state.blocked_reason?.turn_id ?? null,
|
|
117
|
+
}),
|
|
79
118
|
turn_retained: turnRetained,
|
|
80
119
|
detail: state.blocked_on.slice('human:'.length) || null,
|
|
81
120
|
};
|
|
@@ -110,7 +149,10 @@ export function deriveRecoveryDescriptor(state, config = null) {
|
|
|
110
149
|
return {
|
|
111
150
|
typed_reason: 'dispatch_error',
|
|
112
151
|
owner: 'human',
|
|
113
|
-
recovery_action:
|
|
152
|
+
recovery_action: deriveDispatchRecoveryAction(state, config, {
|
|
153
|
+
turnRetained,
|
|
154
|
+
turnId: state.blocked_reason?.turn_id ?? null,
|
|
155
|
+
}),
|
|
114
156
|
turn_retained: turnRetained,
|
|
115
157
|
detail: state.blocked_on.slice('dispatch:'.length) || state.blocked_on,
|
|
116
158
|
};
|
package/src/lib/config.js
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
normalizeGovernedStateShape,
|
|
8
8
|
getActiveTurn,
|
|
9
9
|
reconcileBudgetStatusWithConfig,
|
|
10
|
-
|
|
10
|
+
reconcileRecoveryActionsWithConfig,
|
|
11
11
|
} from './governed-state.js';
|
|
12
12
|
|
|
13
13
|
function attachLegacyCurrentTurnAlias(state) {
|
|
@@ -155,7 +155,7 @@ export function loadProjectState(root, config) {
|
|
|
155
155
|
stateData = normalized.state;
|
|
156
156
|
const reconciledBudget = reconcileBudgetStatusWithConfig(stateData, config);
|
|
157
157
|
stateData = reconciledBudget.state;
|
|
158
|
-
const reconciledRecovery =
|
|
158
|
+
const reconciledRecovery = reconcileRecoveryActionsWithConfig(stateData, config);
|
|
159
159
|
stateData = reconciledRecovery.state;
|
|
160
160
|
if (normalized.changed || reconciledBudget.changed || reconciledRecovery.changed) {
|
|
161
161
|
safeWriteJson(filePath, stateData);
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* orchestrator turn loop call it after assignGovernedTurn().
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from 'fs';
|
|
17
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync } from 'fs';
|
|
18
18
|
import { join } from 'path';
|
|
19
19
|
import { getActiveTurn, getActiveTurns } from './governed-state.js';
|
|
20
20
|
import {
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
const HISTORY_PATH = '.agentxchain/history.jsonl';
|
|
32
32
|
const FILE_PREVIEW_MAX_FILES = 5;
|
|
33
33
|
const FILE_PREVIEW_MAX_LINES = 120;
|
|
34
|
+
const PROPOSAL_SUMMARY_MAX_LINES = 80;
|
|
34
35
|
const GATE_FILE_PREVIEW_MAX_LINES = 60;
|
|
35
36
|
const DISPATCH_LOG_MAX_LINES = 50;
|
|
36
37
|
const DISPATCH_LOG_MAX_LINE_BYTES = 8192;
|
|
@@ -229,6 +230,21 @@ function renderPrompt(role, roleId, turn, state, config, root) {
|
|
|
229
230
|
lines.push('');
|
|
230
231
|
lines.push('- You may propose changes as patches but cannot directly commit.');
|
|
231
232
|
lines.push('- Your artifact type should be `patch`.');
|
|
233
|
+
if (runtimeType === 'api_proxy') {
|
|
234
|
+
lines.push('- **This runtime cannot write repo files directly.** When doing work, you MUST return proposed changes as structured JSON.');
|
|
235
|
+
lines.push('- Include a `proposed_changes` array in your turn result with each file change (omit or set to `[]` on completion-only turns):');
|
|
236
|
+
lines.push(' ```json');
|
|
237
|
+
lines.push(' "proposed_changes": [');
|
|
238
|
+
lines.push(' { "path": "src/lib/foo.js", "action": "create", "content": "// full file..." },');
|
|
239
|
+
lines.push(' { "path": "src/lib/bar.js", "action": "modify", "content": "// full new content..." },');
|
|
240
|
+
lines.push(' { "path": "src/old.js", "action": "delete" }');
|
|
241
|
+
lines.push(' ]');
|
|
242
|
+
lines.push(' ```');
|
|
243
|
+
lines.push('- Valid actions: `create` (new file), `modify` (replace content), `delete` (remove file).');
|
|
244
|
+
lines.push('- `content` is required for `create` and `modify` actions.');
|
|
245
|
+
lines.push('- The orchestrator will materialize your proposal to `.agentxchain/proposed/<turn_id>/` for review.');
|
|
246
|
+
lines.push('- List all proposed file paths in `files_changed`.');
|
|
247
|
+
}
|
|
232
248
|
lines.push('');
|
|
233
249
|
}
|
|
234
250
|
|
|
@@ -366,6 +382,22 @@ function renderPrompt(role, roleId, turn, state, config, root) {
|
|
|
366
382
|
lines.push(`- **You are in the \`${currentPhase}\` phase (final phase).** When ready to ship, set \`run_completion_request: true\` and \`phase_transition_request: null\`.`);
|
|
367
383
|
}
|
|
368
384
|
}
|
|
385
|
+
// Phase-specific guidance for proposed roles
|
|
386
|
+
if (role.write_authority === 'proposed' && phaseNames.length > 0) {
|
|
387
|
+
const currentPhase = state?.phase;
|
|
388
|
+
const phaseIdx = currentPhase ? phaseNames.indexOf(currentPhase) : -1;
|
|
389
|
+
if (phaseIdx >= 0 && phaseIdx < phaseNames.length - 1) {
|
|
390
|
+
const nextPhase = phaseNames[phaseIdx + 1];
|
|
391
|
+
const currentGate = config.routing?.[currentPhase]?.exit_gate;
|
|
392
|
+
const gateClause = currentGate ? ` and the exit gate (\`${currentGate}\`) is satisfied` : '';
|
|
393
|
+
lines.push(`- **You are in the \`${currentPhase}\` phase (not final phase).** When your work is complete${gateClause}, set \`phase_transition_request: "${nextPhase}"\`.`);
|
|
394
|
+
} else if (phaseIdx >= 0 && phaseIdx === phaseNames.length - 1) {
|
|
395
|
+
lines.push(`- **You are in the \`${currentPhase}\` phase (final phase).** When ready to ship, set \`run_completion_request: true\` and \`phase_transition_request: null\`.`);
|
|
396
|
+
if (runtimeType === 'api_proxy') {
|
|
397
|
+
lines.push('- **Completion turns must be no-op:** set `proposed_changes` to `[]` or omit it, set `files_changed` to `[]`, and set `artifact.type` to `"review"`. Do NOT propose file changes on a completion turn.');
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
369
401
|
// Phase-specific guidance for review_only roles (terminal phase ship readiness)
|
|
370
402
|
if (role.write_authority === 'review_only' && phaseNames.length > 0) {
|
|
371
403
|
const currentPhase = state?.phase;
|
|
@@ -467,6 +499,42 @@ function renderContext(state, config, root, turn, role) {
|
|
|
467
499
|
}
|
|
468
500
|
}
|
|
469
501
|
|
|
502
|
+
const proposalPreview = role?.write_authority === 'review_only'
|
|
503
|
+
? buildProposalArtifactPreview(root, lastTurn)
|
|
504
|
+
: null;
|
|
505
|
+
if (proposalPreview) {
|
|
506
|
+
lines.push('### Proposed Artifact');
|
|
507
|
+
lines.push('');
|
|
508
|
+
lines.push(`- **Artifact ref:** \`${proposalPreview.artifactRef}\``);
|
|
509
|
+
lines.push(`- **Proposed files:** ${proposalPreview.changeCount}`);
|
|
510
|
+
lines.push('');
|
|
511
|
+
lines.push('```');
|
|
512
|
+
lines.push(proposalPreview.summary);
|
|
513
|
+
lines.push('```');
|
|
514
|
+
if (proposalPreview.summaryTruncated) {
|
|
515
|
+
lines.push('');
|
|
516
|
+
lines.push(`_Preview truncated after ${PROPOSAL_SUMMARY_MAX_LINES} lines._`);
|
|
517
|
+
}
|
|
518
|
+
lines.push('');
|
|
519
|
+
|
|
520
|
+
if (proposalPreview.filePreviews.length > 0) {
|
|
521
|
+
lines.push('### Proposed File Previews');
|
|
522
|
+
lines.push('');
|
|
523
|
+
for (const preview of proposalPreview.filePreviews) {
|
|
524
|
+
lines.push(`#### \`${preview.path}\` (${preview.action})`);
|
|
525
|
+
lines.push('');
|
|
526
|
+
lines.push('```');
|
|
527
|
+
lines.push(preview.content);
|
|
528
|
+
lines.push('```');
|
|
529
|
+
if (preview.truncated) {
|
|
530
|
+
lines.push('');
|
|
531
|
+
lines.push(`_Preview truncated after ${FILE_PREVIEW_MAX_LINES} lines._`);
|
|
532
|
+
}
|
|
533
|
+
lines.push('');
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
470
538
|
// Verification evidence from the previous turn
|
|
471
539
|
// Use raw verification (has commands, machine_evidence, evidence_summary)
|
|
472
540
|
// and supplement with normalized_verification status when available
|
|
@@ -683,6 +751,113 @@ function buildChangedFilePreviews(root, filesChanged) {
|
|
|
683
751
|
return previews;
|
|
684
752
|
}
|
|
685
753
|
|
|
754
|
+
function buildProposalArtifactPreview(root, lastTurn) {
|
|
755
|
+
const artifactRef = lastTurn?.artifact?.ref;
|
|
756
|
+
if (typeof artifactRef !== 'string' || !artifactRef.startsWith('.agentxchain/proposed/')) {
|
|
757
|
+
return null;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const absProposalDir = join(root, artifactRef);
|
|
761
|
+
const summaryPath = join(absProposalDir, 'PROPOSAL.md');
|
|
762
|
+
|
|
763
|
+
let summary = '';
|
|
764
|
+
let summaryTruncated = false;
|
|
765
|
+
|
|
766
|
+
if (existsSync(summaryPath)) {
|
|
767
|
+
try {
|
|
768
|
+
const raw = readFileSync(summaryPath, 'utf8');
|
|
769
|
+
const lines = raw.replace(/\r\n/g, '\n').split('\n');
|
|
770
|
+
summaryTruncated = lines.length > PROPOSAL_SUMMARY_MAX_LINES;
|
|
771
|
+
summary = (summaryTruncated ? lines.slice(0, PROPOSAL_SUMMARY_MAX_LINES) : lines)
|
|
772
|
+
.join('\n')
|
|
773
|
+
.trimEnd();
|
|
774
|
+
} catch {
|
|
775
|
+
summary = '';
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const changeActions = extractProposalActions(summary);
|
|
780
|
+
const materializedFiles = collectProposalFiles(absProposalDir);
|
|
781
|
+
|
|
782
|
+
if (!summary) {
|
|
783
|
+
const fallbackLines = [
|
|
784
|
+
`# Proposed Changes — ${lastTurn.turn_id || '(unknown)'}`,
|
|
785
|
+
'',
|
|
786
|
+
lastTurn.summary || '(no summary)',
|
|
787
|
+
'',
|
|
788
|
+
];
|
|
789
|
+
for (const filePath of materializedFiles) {
|
|
790
|
+
fallbackLines.push(`- \`${filePath}\` — ${changeActions.get(filePath) || 'create'}`);
|
|
791
|
+
}
|
|
792
|
+
summary = fallbackLines.join('\n');
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const filePreviews = [];
|
|
796
|
+
for (const relPath of materializedFiles.slice(0, FILE_PREVIEW_MAX_FILES)) {
|
|
797
|
+
const absPath = join(absProposalDir, relPath);
|
|
798
|
+
|
|
799
|
+
let raw;
|
|
800
|
+
try {
|
|
801
|
+
raw = readFileSync(absPath, 'utf8');
|
|
802
|
+
} catch {
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const lines = raw.replace(/\r\n/g, '\n').split('\n');
|
|
807
|
+
const truncated = lines.length > FILE_PREVIEW_MAX_LINES;
|
|
808
|
+
const previewLines = truncated ? lines.slice(0, FILE_PREVIEW_MAX_LINES) : lines;
|
|
809
|
+
filePreviews.push({
|
|
810
|
+
path: relPath,
|
|
811
|
+
action: changeActions.get(relPath) || 'create',
|
|
812
|
+
content: previewLines.join('\n').trimEnd(),
|
|
813
|
+
truncated,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
return {
|
|
818
|
+
artifactRef,
|
|
819
|
+
summary,
|
|
820
|
+
summaryTruncated,
|
|
821
|
+
filePreviews,
|
|
822
|
+
changeCount: changeActions.size || materializedFiles.length,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function extractProposalActions(summary) {
|
|
827
|
+
const actions = new Map();
|
|
828
|
+
if (!summary) {
|
|
829
|
+
return actions;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const matches = summary.matchAll(/^- `([^`]+)` — ([a-z]+)/gm);
|
|
833
|
+
for (const match of matches) {
|
|
834
|
+
actions.set(match[1], match[2]);
|
|
835
|
+
}
|
|
836
|
+
return actions;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function collectProposalFiles(absProposalDir, relPrefix = '') {
|
|
840
|
+
if (!existsSync(absProposalDir)) {
|
|
841
|
+
return [];
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const files = [];
|
|
845
|
+
for (const entry of readdirSync(absProposalDir, { withFileTypes: true })) {
|
|
846
|
+
if (entry.name === 'PROPOSAL.md') {
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const relPath = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
|
|
851
|
+
const absPath = join(absProposalDir, entry.name);
|
|
852
|
+
if (entry.isDirectory()) {
|
|
853
|
+
files.push(...collectProposalFiles(absPath, relPath));
|
|
854
|
+
} else if (entry.isFile()) {
|
|
855
|
+
files.push(relPath);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return files.sort();
|
|
859
|
+
}
|
|
860
|
+
|
|
686
861
|
function buildDispatchLogExcerpt(root, turnId) {
|
|
687
862
|
const logPath = join(root, getDispatchLogPath(turnId));
|
|
688
863
|
if (!existsSync(logPath)) {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync, readdirSync, rmSync } from 'fs';
|
|
19
19
|
import { join, dirname } from 'path';
|
|
20
|
-
import { randomBytes } from 'crypto';
|
|
20
|
+
import { randomBytes, createHash } from 'crypto';
|
|
21
21
|
import { safeWriteJson } from './safe-write.js';
|
|
22
22
|
import { validateStagedTurnResult } from './turn-result-validator.js';
|
|
23
23
|
import { evaluatePhaseExit, evaluateRunCompletion } from './gate-evaluator.js';
|
|
@@ -155,6 +155,87 @@ function materializeDerivedReviewArtifact(root, turnResult, state, runtimeType,
|
|
|
155
155
|
return reviewPath;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
function materializeDerivedProposalArtifact(root, turnResult, state, runtimeType) {
|
|
159
|
+
if (turnResult?.artifact?.type !== 'patch' || runtimeType !== 'api_proxy') {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
if (!Array.isArray(turnResult.proposed_changes) || turnResult.proposed_changes.length === 0) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const proposalDir = `.agentxchain/proposed/${turnResult.turn_id}`;
|
|
167
|
+
const absProposalDir = join(root, proposalDir);
|
|
168
|
+
mkdirSync(absProposalDir, { recursive: true });
|
|
169
|
+
|
|
170
|
+
// Write PROPOSAL.md summary
|
|
171
|
+
const summaryLines = [
|
|
172
|
+
`# Proposed Changes — ${turnResult.turn_id}`,
|
|
173
|
+
'',
|
|
174
|
+
`**Role:** ${turnResult.role}`,
|
|
175
|
+
`**Runtime:** ${turnResult.runtime_id}`,
|
|
176
|
+
`**Status:** ${turnResult.status}`,
|
|
177
|
+
'',
|
|
178
|
+
`## Summary`,
|
|
179
|
+
'',
|
|
180
|
+
turnResult.summary || '(no summary)',
|
|
181
|
+
'',
|
|
182
|
+
`## Files`,
|
|
183
|
+
'',
|
|
184
|
+
];
|
|
185
|
+
for (const change of turnResult.proposed_changes) {
|
|
186
|
+
summaryLines.push(`- \`${change.path}\` — ${change.action}`);
|
|
187
|
+
}
|
|
188
|
+
if (turnResult.decisions?.length > 0) {
|
|
189
|
+
summaryLines.push('', '## Decisions', '');
|
|
190
|
+
for (const dec of turnResult.decisions) {
|
|
191
|
+
summaryLines.push(`- **${dec.id}** (${dec.category}): ${dec.statement}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
summaryLines.push('');
|
|
195
|
+
writeFileSync(join(absProposalDir, 'PROPOSAL.md'), summaryLines.join('\n'));
|
|
196
|
+
writeFileSync(
|
|
197
|
+
join(absProposalDir, 'SOURCE_SNAPSHOT.json'),
|
|
198
|
+
JSON.stringify(captureProposalSourceSnapshot(root, turnResult.proposed_changes), null, 2) + '\n',
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// Materialize each proposed file (create/modify only; delete just listed)
|
|
202
|
+
for (const change of turnResult.proposed_changes) {
|
|
203
|
+
if (change.action === 'delete') continue;
|
|
204
|
+
if (typeof change.content !== 'string') continue;
|
|
205
|
+
const absFilePath = join(absProposalDir, change.path);
|
|
206
|
+
mkdirSync(dirname(absFilePath), { recursive: true });
|
|
207
|
+
writeFileSync(absFilePath, change.content);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
turnResult.artifact = { ...(turnResult.artifact || {}), ref: proposalDir };
|
|
211
|
+
return proposalDir;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function captureProposalSourceSnapshot(root, proposedChanges) {
|
|
215
|
+
return {
|
|
216
|
+
captured_at: new Date().toISOString(),
|
|
217
|
+
files: proposedChanges.map((change) => {
|
|
218
|
+
const absFilePath = join(root, change.path);
|
|
219
|
+
if (!existsSync(absFilePath)) {
|
|
220
|
+
return {
|
|
221
|
+
path: change.path,
|
|
222
|
+
action: change.action,
|
|
223
|
+
existed: false,
|
|
224
|
+
sha256: null,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const content = readFileSync(absFilePath);
|
|
229
|
+
return {
|
|
230
|
+
path: change.path,
|
|
231
|
+
action: change.action,
|
|
232
|
+
existed: true,
|
|
233
|
+
sha256: `sha256:${createHash('sha256').update(content).digest('hex')}`,
|
|
234
|
+
};
|
|
235
|
+
}),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
158
239
|
function normalizeActiveTurns(activeTurns) {
|
|
159
240
|
if (!activeTurns || typeof activeTurns !== 'object' || Array.isArray(activeTurns)) {
|
|
160
241
|
return {};
|
|
@@ -229,6 +310,60 @@ export function deriveRetainedTurnRecoveryCommand(state, config, options = {}) {
|
|
|
229
310
|
return command;
|
|
230
311
|
}
|
|
231
312
|
|
|
313
|
+
export function deriveBlockedRecoveryCommand(state, config, options = {}) {
|
|
314
|
+
const turnRetained = typeof options.turnRetained === 'boolean'
|
|
315
|
+
? options.turnRetained
|
|
316
|
+
: getActiveTurnCount(state) > 0;
|
|
317
|
+
if (!turnRetained) {
|
|
318
|
+
return options.clearedCommand || 'agentxchain resume';
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return deriveRetainedTurnRecoveryCommand(state, config, {
|
|
322
|
+
turnId: options.turnId,
|
|
323
|
+
fallbackCommand: options.fallbackCommand || 'agentxchain step --resume',
|
|
324
|
+
manualCommand: options.manualCommand || 'agentxchain resume',
|
|
325
|
+
automatedCommand: options.automatedCommand || 'agentxchain step --resume',
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function deriveNeedsHumanRecoveryAction(state, config, options = {}) {
|
|
330
|
+
const command = deriveBlockedRecoveryCommand(state, config, {
|
|
331
|
+
turnRetained: options.turnRetained,
|
|
332
|
+
turnId: options.turnId,
|
|
333
|
+
});
|
|
334
|
+
return `Resolve the stated issue, then run ${command}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function deriveDispatchRecoveryAction(state, config, options = {}) {
|
|
338
|
+
const command = deriveBlockedRecoveryCommand(state, config, {
|
|
339
|
+
turnRetained: options.turnRetained,
|
|
340
|
+
turnId: options.turnId,
|
|
341
|
+
});
|
|
342
|
+
return `Resolve the dispatch issue, then run ${command}`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function deriveHookTamperRecoveryAction(state, config, options = {}) {
|
|
346
|
+
const command = deriveBlockedRecoveryCommand(state, config, {
|
|
347
|
+
turnRetained: options.turnRetained,
|
|
348
|
+
turnId: options.turnId,
|
|
349
|
+
});
|
|
350
|
+
return `Disable or fix the hook, verify protected files, then run ${command}`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function deriveAfterDispatchHookRecoveryAction(state, config, options = {}) {
|
|
354
|
+
const command = deriveBlockedRecoveryCommand(state, config, {
|
|
355
|
+
turnRetained: options.turnRetained,
|
|
356
|
+
turnId: options.turnId,
|
|
357
|
+
});
|
|
358
|
+
return `Fix or reconfigure the hook, then rerun ${command}`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function deriveConflictLoopRecoveryAction(turnId) {
|
|
362
|
+
return turnId
|
|
363
|
+
? `Serialize the conflicting work, then run agentxchain reject-turn --turn ${turnId} --reassign`
|
|
364
|
+
: 'Serialize the conflicting work, then run agentxchain reject-turn --reassign';
|
|
365
|
+
}
|
|
366
|
+
|
|
232
367
|
function isLegacyEscalationRecoveryAction(action) {
|
|
233
368
|
return action === 'Resolve the escalation, then run agentxchain step --resume'
|
|
234
369
|
|| action === 'Resolve the escalation, then run agentxchain step';
|
|
@@ -857,7 +992,7 @@ function deriveHookRecovery(state, { phase, hookName, detail, errorCode, turnId,
|
|
|
857
992
|
typed_reason: isTamper ? 'hook_tamper' : 'hook_block',
|
|
858
993
|
owner: 'human',
|
|
859
994
|
recovery_action: isTamper
|
|
860
|
-
?
|
|
995
|
+
? deriveHookTamperRecoveryAction(state, null, { turnRetained, turnId })
|
|
861
996
|
: `Fix or reconfigure hook "${hookName}", then rerun agentxchain accept-turn${turnId ? ` --turn ${turnId}` : ''}`,
|
|
862
997
|
turn_retained: Boolean(turnRetained),
|
|
863
998
|
detail: detail || hookName || phase,
|
|
@@ -936,30 +1071,80 @@ function normalizeRecoveryDescriptor(recovery, turnRetained, detail) {
|
|
|
936
1071
|
};
|
|
937
1072
|
}
|
|
938
1073
|
|
|
939
|
-
|
|
1074
|
+
function isLegacyNeedsHumanRecoveryAction(action) {
|
|
1075
|
+
return action === 'Resolve the stated issue, then run agentxchain step --resume';
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function isLegacyHookTamperRecoveryAction(action) {
|
|
1079
|
+
return action === 'Disable or fix the hook, verify protected files, then run agentxchain step --resume';
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function isLegacyAfterDispatchHookBlockRecoveryAction(action) {
|
|
1083
|
+
return action === 'Fix or reconfigure the hook, then rerun agentxchain resume';
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function isLegacyConflictLoopRecoveryAction(action) {
|
|
1087
|
+
return typeof action === 'string' && action.startsWith('Serialize the conflicting work, then run agentxchain step --resume');
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
export function reconcileRecoveryActionsWithConfig(state, config) {
|
|
940
1091
|
if (!state || typeof state !== 'object' || state.status !== 'blocked' || !config) {
|
|
941
1092
|
return { state, changed: false };
|
|
942
1093
|
}
|
|
943
1094
|
|
|
944
1095
|
const recovery = state.blocked_reason?.recovery;
|
|
945
1096
|
const typedReason = recovery?.typed_reason;
|
|
946
|
-
if (typedReason !== 'operator_escalation' && typedReason !== 'retries_exhausted') {
|
|
947
|
-
return { state, changed: false };
|
|
948
|
-
}
|
|
949
|
-
|
|
950
1097
|
const currentAction = recovery?.recovery_action || null;
|
|
951
|
-
const shouldRefresh = typedReason === 'retries_exhausted' || isLegacyEscalationRecoveryAction(currentAction);
|
|
952
|
-
if (!shouldRefresh) {
|
|
953
|
-
return { state, changed: false };
|
|
954
|
-
}
|
|
955
|
-
|
|
956
1098
|
const turnRetained = typeof recovery?.turn_retained === 'boolean'
|
|
957
1099
|
? recovery.turn_retained
|
|
958
1100
|
: getActiveTurnCount(state) > 0;
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1101
|
+
const turnId = state.blocked_reason?.turn_id ?? state.escalation?.from_turn_id ?? null;
|
|
1102
|
+
|
|
1103
|
+
let shouldRefresh = false;
|
|
1104
|
+
let nextAction = null;
|
|
1105
|
+
|
|
1106
|
+
if (typedReason === 'operator_escalation' || typedReason === 'retries_exhausted') {
|
|
1107
|
+
shouldRefresh = typedReason === 'retries_exhausted' || isLegacyEscalationRecoveryAction(currentAction);
|
|
1108
|
+
if (shouldRefresh) {
|
|
1109
|
+
nextAction = deriveEscalationRecoveryAction(state, config, {
|
|
1110
|
+
turnRetained,
|
|
1111
|
+
turnId,
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
} else if (typedReason === 'needs_human') {
|
|
1115
|
+
shouldRefresh = isLegacyNeedsHumanRecoveryAction(currentAction);
|
|
1116
|
+
if (shouldRefresh) {
|
|
1117
|
+
nextAction = deriveNeedsHumanRecoveryAction(state, config, {
|
|
1118
|
+
turnRetained,
|
|
1119
|
+
turnId,
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
} else if (typedReason === 'hook_tamper') {
|
|
1123
|
+
shouldRefresh = isLegacyHookTamperRecoveryAction(currentAction);
|
|
1124
|
+
if (shouldRefresh) {
|
|
1125
|
+
nextAction = deriveHookTamperRecoveryAction(state, config, {
|
|
1126
|
+
turnRetained,
|
|
1127
|
+
turnId,
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
} else if (typedReason === 'hook_block') {
|
|
1131
|
+
shouldRefresh = isLegacyAfterDispatchHookBlockRecoveryAction(currentAction);
|
|
1132
|
+
if (shouldRefresh) {
|
|
1133
|
+
nextAction = deriveAfterDispatchHookRecoveryAction(state, config, {
|
|
1134
|
+
turnRetained,
|
|
1135
|
+
turnId,
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
} else if (typedReason === 'conflict_loop') {
|
|
1139
|
+
shouldRefresh = isLegacyConflictLoopRecoveryAction(currentAction);
|
|
1140
|
+
if (shouldRefresh) {
|
|
1141
|
+
nextAction = deriveConflictLoopRecoveryAction(turnId);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
if (!shouldRefresh || !nextAction) {
|
|
1146
|
+
return { state, changed: false };
|
|
1147
|
+
}
|
|
963
1148
|
|
|
964
1149
|
let nextState = state;
|
|
965
1150
|
let changed = false;
|
|
@@ -1015,7 +1200,10 @@ function inferBlockedReasonFromState(state) {
|
|
|
1015
1200
|
recovery: {
|
|
1016
1201
|
typed_reason: 'needs_human',
|
|
1017
1202
|
owner: 'human',
|
|
1018
|
-
recovery_action:
|
|
1203
|
+
recovery_action: deriveNeedsHumanRecoveryAction(state, null, {
|
|
1204
|
+
turnRetained,
|
|
1205
|
+
turnId: activeTurn?.turn_id ?? state.blocked_reason?.turn_id ?? null,
|
|
1206
|
+
}),
|
|
1019
1207
|
turn_retained: turnRetained,
|
|
1020
1208
|
detail,
|
|
1021
1209
|
},
|
|
@@ -1049,7 +1237,10 @@ function inferBlockedReasonFromState(state) {
|
|
|
1049
1237
|
recovery: {
|
|
1050
1238
|
typed_reason: 'dispatch_error',
|
|
1051
1239
|
owner: 'human',
|
|
1052
|
-
recovery_action:
|
|
1240
|
+
recovery_action: deriveDispatchRecoveryAction(state, null, {
|
|
1241
|
+
turnRetained,
|
|
1242
|
+
turnId: activeTurn?.turn_id ?? state.blocked_reason?.turn_id ?? null,
|
|
1243
|
+
}),
|
|
1053
1244
|
turn_retained: turnRetained,
|
|
1054
1245
|
detail,
|
|
1055
1246
|
},
|
|
@@ -1802,6 +1993,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
1802
1993
|
const runtime = config.runtimes?.[runtimeId];
|
|
1803
1994
|
const runtimeType = runtime?.type || 'manual';
|
|
1804
1995
|
materializeDerivedReviewArtifact(root, turnResult, state, runtimeType, baseline);
|
|
1996
|
+
materializeDerivedProposalArtifact(root, turnResult, state, runtimeType);
|
|
1805
1997
|
const writeAuthority = role?.write_authority || 'review_only';
|
|
1806
1998
|
const diffComparison = compareDeclaredVsObserved(
|
|
1807
1999
|
turnResult.files_changed || [],
|
|
@@ -1859,7 +2051,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
1859
2051
|
recovery: {
|
|
1860
2052
|
typed_reason: 'conflict_loop',
|
|
1861
2053
|
owner: 'human',
|
|
1862
|
-
recovery_action:
|
|
2054
|
+
recovery_action: deriveConflictLoopRecoveryAction(currentTurn.turn_id),
|
|
1863
2055
|
turn_retained: true,
|
|
1864
2056
|
detail: buildConflictDetail(conflict),
|
|
1865
2057
|
},
|
|
@@ -2018,7 +2210,10 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2018
2210
|
recovery: {
|
|
2019
2211
|
typed_reason: 'needs_human',
|
|
2020
2212
|
owner: 'human',
|
|
2021
|
-
recovery_action:
|
|
2213
|
+
recovery_action: deriveNeedsHumanRecoveryAction(updatedState, config, {
|
|
2214
|
+
turnRetained: false,
|
|
2215
|
+
turnId: turnResult.turn_id,
|
|
2216
|
+
}),
|
|
2022
2217
|
turn_retained: false,
|
|
2023
2218
|
detail: turnResult.needs_human_reason || 'unspecified',
|
|
2024
2219
|
},
|
|
@@ -389,11 +389,11 @@ export function validateV4Config(data, projectRoot) {
|
|
|
389
389
|
errors.push(`Role "${id}" is review_only but uses local_cli runtime "${role.runtime}" — review_only roles should not have authoritative write access`);
|
|
390
390
|
}
|
|
391
391
|
}
|
|
392
|
-
//
|
|
392
|
+
// api_proxy restriction: only review_only and proposed roles may bind to api_proxy runtimes
|
|
393
393
|
if (role.runtime && data.runtimes[role.runtime]) {
|
|
394
394
|
const rt = data.runtimes[role.runtime];
|
|
395
|
-
if (rt.type === 'api_proxy' && role.write_authority !== 'review_only') {
|
|
396
|
-
errors.push(`Role "${id}" has write_authority "${role.write_authority}" but uses api_proxy runtime "${role.runtime}" —
|
|
395
|
+
if (rt.type === 'api_proxy' && role.write_authority !== 'review_only' && role.write_authority !== 'proposed') {
|
|
396
|
+
errors.push(`Role "${id}" has write_authority "${role.write_authority}" but uses api_proxy runtime "${role.runtime}" — api_proxy only supports review_only and proposed roles`);
|
|
397
397
|
}
|
|
398
398
|
}
|
|
399
399
|
}
|