agentxchain 2.43.0 → 2.44.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 +10 -0
- package/dashboard/app.js +3 -0
- package/dashboard/components/run-history.js +144 -0
- package/dashboard/index.html +1 -0
- package/package.json +1 -1
- package/src/commands/history.js +120 -0
- package/src/commands/restart.js +2 -1
- package/src/lib/continuity-status.js +10 -1
- package/src/lib/dashboard/bridge-server.js +9 -0
- package/src/lib/dashboard/state-reader.js +1 -0
- package/src/lib/export.js +2 -0
- package/src/lib/governed-state.js +23 -0
- package/src/lib/intake.js +37 -19
- package/src/lib/repo-observer.js +1 -0
- package/src/lib/run-history.js +156 -0
- package/src/lib/schema.js +2 -0
package/bin/agentxchain.js
CHANGED
|
@@ -105,6 +105,7 @@ import { intakeScanCommand } from '../src/commands/intake-scan.js';
|
|
|
105
105
|
import { intakeResolveCommand } from '../src/commands/intake-resolve.js';
|
|
106
106
|
import { intakeStatusCommand } from '../src/commands/intake-status.js';
|
|
107
107
|
import { demoCommand } from '../src/commands/demo.js';
|
|
108
|
+
import { historyCommand } from '../src/commands/history.js';
|
|
108
109
|
|
|
109
110
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
110
111
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -256,6 +257,15 @@ program
|
|
|
256
257
|
.option('-v, --verbose', 'Show stack traces on failure')
|
|
257
258
|
.action(demoCommand);
|
|
258
259
|
|
|
260
|
+
program
|
|
261
|
+
.command('history')
|
|
262
|
+
.description('Show cross-run history of governed runs in this project')
|
|
263
|
+
.option('-j, --json', 'Output as JSON')
|
|
264
|
+
.option('-l, --limit <n>', 'Number of recent runs to show (default: 20)')
|
|
265
|
+
.option('-s, --status <status>', 'Filter by status: completed, blocked, failed')
|
|
266
|
+
.option('-d, --dir <path>', 'Project directory')
|
|
267
|
+
.action(historyCommand);
|
|
268
|
+
|
|
259
269
|
program
|
|
260
270
|
.command('validate')
|
|
261
271
|
.description('Validate project protocol artifacts')
|
package/dashboard/app.js
CHANGED
|
@@ -14,6 +14,7 @@ import { render as renderInitiative } from './components/initiative.js';
|
|
|
14
14
|
import { render as renderCrossRepo } from './components/cross-repo.js';
|
|
15
15
|
import { render as renderBlockers } from './components/blockers.js';
|
|
16
16
|
import { render as renderArtifacts } from './components/artifacts.js';
|
|
17
|
+
import { render as renderRunHistory } from './components/run-history.js';
|
|
17
18
|
|
|
18
19
|
const VIEWS = {
|
|
19
20
|
timeline: { fetch: ['state', 'continuity', 'history', 'audit', 'annotations', 'connectors'], render: renderTimeline },
|
|
@@ -25,6 +26,7 @@ const VIEWS = {
|
|
|
25
26
|
'cross-repo': { fetch: ['coordinatorState', 'coordinatorHistory'], render: renderCrossRepo },
|
|
26
27
|
blockers: { fetch: ['coordinatorBlockers'], render: renderBlockers },
|
|
27
28
|
artifacts: { fetch: ['workflowKitArtifacts'], render: renderArtifacts },
|
|
29
|
+
'run-history': { fetch: ['runHistory'], render: renderRunHistory },
|
|
28
30
|
};
|
|
29
31
|
|
|
30
32
|
const API_MAP = {
|
|
@@ -42,6 +44,7 @@ const API_MAP = {
|
|
|
42
44
|
coordinatorBlockers: '/api/coordinator/blockers',
|
|
43
45
|
workflowKitArtifacts: '/api/workflow-kit-artifacts',
|
|
44
46
|
connectors: '/api/connectors',
|
|
47
|
+
runHistory: '/api/run-history',
|
|
45
48
|
};
|
|
46
49
|
|
|
47
50
|
const viewState = {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run History view — renders cross-run history from /api/run-history.
|
|
3
|
+
*
|
|
4
|
+
* Pure render function: takes data from the bridge server, returns HTML.
|
|
5
|
+
* No business logic — just table rendering with status-colored rows.
|
|
6
|
+
*
|
|
7
|
+
* See: RUN_HISTORY_TERMINAL_RECORDING_SPEC.md
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function esc(str) {
|
|
11
|
+
if (!str) return '';
|
|
12
|
+
return String(str)
|
|
13
|
+
.replace(/&/g, '&')
|
|
14
|
+
.replace(/</g, '<')
|
|
15
|
+
.replace(/>/g, '>')
|
|
16
|
+
.replace(/"/g, '"')
|
|
17
|
+
.replace(/'/g, ''');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function badge(label, color = 'var(--text-dim)') {
|
|
21
|
+
return `<span class="badge" style="color:${color};border-color:${color}">${esc(label)}</span>`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function statusBadge(status) {
|
|
25
|
+
switch (status) {
|
|
26
|
+
case 'completed':
|
|
27
|
+
return badge('completed', 'var(--green)');
|
|
28
|
+
case 'blocked':
|
|
29
|
+
return badge('blocked', 'var(--yellow)');
|
|
30
|
+
case 'failed':
|
|
31
|
+
return badge('failed', 'var(--red)');
|
|
32
|
+
default:
|
|
33
|
+
return badge(status || 'unknown', 'var(--text-dim)');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function formatDuration(ms) {
|
|
38
|
+
if (ms == null) return '—';
|
|
39
|
+
if (ms < 1000) return `${ms}ms`;
|
|
40
|
+
const seconds = Math.floor(ms / 1000);
|
|
41
|
+
if (seconds < 60) return `${seconds}s`;
|
|
42
|
+
const minutes = Math.floor(seconds / 60);
|
|
43
|
+
const remainingSeconds = seconds % 60;
|
|
44
|
+
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
|
|
45
|
+
const hours = Math.floor(minutes / 60);
|
|
46
|
+
const remainingMinutes = minutes % 60;
|
|
47
|
+
return `${hours}h ${remainingMinutes}m`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatCost(usd) {
|
|
51
|
+
if (usd == null) return '—';
|
|
52
|
+
return `$${Number(usd).toFixed(2)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatDate(iso) {
|
|
56
|
+
if (!iso) return '—';
|
|
57
|
+
try {
|
|
58
|
+
const d = new Date(iso);
|
|
59
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
60
|
+
+ ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
61
|
+
} catch {
|
|
62
|
+
return esc(iso);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function truncateId(id, len = 12) {
|
|
67
|
+
if (!id) return '—';
|
|
68
|
+
return id.length > len ? id.slice(0, len) + '…' : id;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderRow(entry, index) {
|
|
72
|
+
const rowClass = entry.status === 'blocked'
|
|
73
|
+
? ' style="border-left:3px solid var(--yellow)"'
|
|
74
|
+
: entry.status === 'failed'
|
|
75
|
+
? ' style="border-left:3px solid var(--red)"'
|
|
76
|
+
: '';
|
|
77
|
+
|
|
78
|
+
const phases = Array.isArray(entry.phases_completed) && entry.phases_completed.length > 0
|
|
79
|
+
? entry.phases_completed.map(p => esc(p)).join(' → ')
|
|
80
|
+
: '—';
|
|
81
|
+
|
|
82
|
+
const blockedInfo = entry.status === 'blocked' && entry.blocked_reason
|
|
83
|
+
? `<div class="blocked-hint" style="font-size:0.85em;color:var(--yellow);margin-top:2px">${esc(typeof entry.blocked_reason === 'string' ? entry.blocked_reason : entry.blocked_reason?.detail || entry.blocked_reason?.category || '')}</div>`
|
|
84
|
+
: '';
|
|
85
|
+
|
|
86
|
+
return `<tr${rowClass}>
|
|
87
|
+
<td style="color:var(--text-dim)">${index + 1}</td>
|
|
88
|
+
<td class="mono" title="${esc(entry.run_id)}">${esc(truncateId(entry.run_id))}</td>
|
|
89
|
+
<td>${statusBadge(entry.status)}${blockedInfo}</td>
|
|
90
|
+
<td>${phases}</td>
|
|
91
|
+
<td>${entry.total_turns ?? '—'}</td>
|
|
92
|
+
<td>${formatCost(entry.total_cost_usd)}</td>
|
|
93
|
+
<td>${formatDuration(entry.duration_ms)}</td>
|
|
94
|
+
<td>${formatDate(entry.recorded_at || entry.completed_at)}</td>
|
|
95
|
+
</tr>`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function render({ runHistory }) {
|
|
99
|
+
if (!runHistory) {
|
|
100
|
+
return `<div class="placeholder"><h2>Run History</h2><p>No run history data available. Complete a governed run to see cross-run history.</p></div>`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!Array.isArray(runHistory) || runHistory.length === 0) {
|
|
104
|
+
return `<div class="placeholder"><h2>Run History</h2><p>No runs recorded yet. Run history is populated when governed runs reach a terminal state (completed or blocked).</p></div>`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const total = runHistory.length;
|
|
108
|
+
const completed = runHistory.filter(e => e.status === 'completed').length;
|
|
109
|
+
const blocked = runHistory.filter(e => e.status === 'blocked').length;
|
|
110
|
+
|
|
111
|
+
let html = `<div class="run-history-view">`;
|
|
112
|
+
|
|
113
|
+
// Header summary
|
|
114
|
+
html += `<div class="run-header"><div class="run-meta">`;
|
|
115
|
+
html += `<span class="turn-count">${total} run${total !== 1 ? 's' : ''} recorded</span>`;
|
|
116
|
+
if (completed > 0) html += badge(`${completed} completed`, 'var(--green)');
|
|
117
|
+
if (blocked > 0) html += badge(`${blocked} blocked`, 'var(--yellow)');
|
|
118
|
+
html += `</div></div>`;
|
|
119
|
+
|
|
120
|
+
// Table
|
|
121
|
+
html += `<div class="section"><h3>Cross-Run History</h3>
|
|
122
|
+
<table class="data-table">
|
|
123
|
+
<thead>
|
|
124
|
+
<tr>
|
|
125
|
+
<th>#</th>
|
|
126
|
+
<th>Run ID</th>
|
|
127
|
+
<th>Status</th>
|
|
128
|
+
<th>Phases</th>
|
|
129
|
+
<th>Turns</th>
|
|
130
|
+
<th>Cost</th>
|
|
131
|
+
<th>Duration</th>
|
|
132
|
+
<th>Date</th>
|
|
133
|
+
</tr>
|
|
134
|
+
</thead>
|
|
135
|
+
<tbody>`;
|
|
136
|
+
|
|
137
|
+
runHistory.forEach((entry, index) => {
|
|
138
|
+
html += renderRow(entry, index);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
html += `</tbody></table></div>`;
|
|
142
|
+
html += `</div>`;
|
|
143
|
+
return html;
|
|
144
|
+
}
|
package/dashboard/index.html
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentxchain history — cross-run operator observability.
|
|
3
|
+
*
|
|
4
|
+
* Shows a persistent history of governed runs in the current project.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { resolve } from 'path';
|
|
8
|
+
import { existsSync, readFileSync } from 'fs';
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import { queryRunHistory } from '../lib/run-history.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {object} opts - { json?: boolean, limit?: number, status?: string, dir?: string }
|
|
14
|
+
*/
|
|
15
|
+
export async function historyCommand(opts) {
|
|
16
|
+
const root = findProjectRoot(opts.dir || process.cwd());
|
|
17
|
+
if (!root) {
|
|
18
|
+
console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const limit = opts.limit ? parseInt(opts.limit, 10) : 20;
|
|
23
|
+
const entries = queryRunHistory(root, {
|
|
24
|
+
limit,
|
|
25
|
+
status: opts.status || undefined,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (opts.json) {
|
|
29
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (entries.length === 0) {
|
|
34
|
+
console.log(chalk.dim('No run history found.'));
|
|
35
|
+
if (opts.status) {
|
|
36
|
+
console.log(chalk.dim(` (filtered by status: ${opts.status})`));
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Table header
|
|
42
|
+
const header = [
|
|
43
|
+
pad('#', 4),
|
|
44
|
+
pad('Run ID', 14),
|
|
45
|
+
pad('Status', 11),
|
|
46
|
+
pad('Phases', 8),
|
|
47
|
+
pad('Turns', 6),
|
|
48
|
+
pad('Cost', 10),
|
|
49
|
+
pad('Duration', 10),
|
|
50
|
+
pad('Date', 20),
|
|
51
|
+
].join(' ');
|
|
52
|
+
|
|
53
|
+
console.log(chalk.bold(header));
|
|
54
|
+
console.log(chalk.dim('─'.repeat(header.length)));
|
|
55
|
+
|
|
56
|
+
entries.forEach((entry, i) => {
|
|
57
|
+
const idx = String(i + 1);
|
|
58
|
+
const runId = (entry.run_id || '—').slice(0, 12);
|
|
59
|
+
const status = formatStatus(entry.status);
|
|
60
|
+
const phases = String(entry.phases_completed?.length || 0);
|
|
61
|
+
const turns = String(entry.total_turns || 0);
|
|
62
|
+
const cost = entry.total_cost_usd != null
|
|
63
|
+
? `$${entry.total_cost_usd.toFixed(4)}`
|
|
64
|
+
: '—';
|
|
65
|
+
const duration = entry.duration_ms != null
|
|
66
|
+
? formatDuration(entry.duration_ms)
|
|
67
|
+
: '—';
|
|
68
|
+
const date = entry.recorded_at
|
|
69
|
+
? new Date(entry.recorded_at).toLocaleString()
|
|
70
|
+
: '—';
|
|
71
|
+
|
|
72
|
+
console.log([
|
|
73
|
+
pad(idx, 4),
|
|
74
|
+
pad(runId, 14),
|
|
75
|
+
pad(status, 11),
|
|
76
|
+
pad(phases, 8),
|
|
77
|
+
pad(turns, 6),
|
|
78
|
+
pad(cost, 10),
|
|
79
|
+
pad(duration, 10),
|
|
80
|
+
pad(date, 20),
|
|
81
|
+
].join(' '));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
console.log(chalk.dim(`\n${entries.length} run(s) shown`));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
function findProjectRoot(startDir) {
|
|
90
|
+
let dir = resolve(startDir);
|
|
91
|
+
while (true) {
|
|
92
|
+
if (existsSync(resolve(dir, 'agentxchain.json'))) return dir;
|
|
93
|
+
const parent = resolve(dir, '..');
|
|
94
|
+
if (parent === dir) return null;
|
|
95
|
+
dir = parent;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function pad(str, width) {
|
|
100
|
+
return String(str).padEnd(width);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function formatStatus(status) {
|
|
104
|
+
if (status === 'completed') return chalk.green('completed');
|
|
105
|
+
if (status === 'blocked') return chalk.yellow('blocked');
|
|
106
|
+
if (status === 'failed') return chalk.red('failed');
|
|
107
|
+
return status || '—';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function formatDuration(ms) {
|
|
111
|
+
if (ms < 1000) return `${ms}ms`;
|
|
112
|
+
const secs = Math.floor(ms / 1000);
|
|
113
|
+
if (secs < 60) return `${secs}s`;
|
|
114
|
+
const mins = Math.floor(secs / 60);
|
|
115
|
+
const remainSecs = secs % 60;
|
|
116
|
+
if (mins < 60) return `${mins}m ${remainSecs}s`;
|
|
117
|
+
const hrs = Math.floor(mins / 60);
|
|
118
|
+
const remainMins = mins % 60;
|
|
119
|
+
return `${hrs}h ${remainMins}m`;
|
|
120
|
+
}
|
package/src/commands/restart.js
CHANGED
|
@@ -190,7 +190,8 @@ export async function restartCommand(opts) {
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
if (state.status === 'failed') {
|
|
193
|
-
console.log(chalk.red('Run
|
|
193
|
+
console.log(chalk.red('Run uses reserved status: failed.'));
|
|
194
|
+
console.log(chalk.dim('Current governed writers do not emit run-level failed. Inspect state.json and recover manually.'));
|
|
194
195
|
process.exit(1);
|
|
195
196
|
}
|
|
196
197
|
|
|
@@ -34,7 +34,16 @@ function deriveRecommendedContinuityAction(state) {
|
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
if (
|
|
37
|
+
if (state.status === 'failed') {
|
|
38
|
+
return {
|
|
39
|
+
recommended_command: null,
|
|
40
|
+
recommended_reason: 'reserved_terminal_state',
|
|
41
|
+
recommended_detail: 'run-level failed is reserved and not emitted by current governed writers',
|
|
42
|
+
restart_recommended: false,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!['blocked', 'completed'].includes(state.status)) {
|
|
38
47
|
return {
|
|
39
48
|
recommended_command: 'agentxchain restart',
|
|
40
49
|
recommended_reason: 'restart_available',
|
|
@@ -20,6 +20,7 @@ import { approvePendingDashboardGate } from './actions.js';
|
|
|
20
20
|
import { readCoordinatorBlockerSnapshot } from './coordinator-blockers.js';
|
|
21
21
|
import { readWorkflowKitArtifacts } from './workflow-kit-artifacts.js';
|
|
22
22
|
import { readConnectorHealthSnapshot } from './connectors.js';
|
|
23
|
+
import { queryRunHistory } from '../run-history.js';
|
|
23
24
|
|
|
24
25
|
const MIME_TYPES = {
|
|
25
26
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -293,6 +294,14 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
|
|
|
293
294
|
return;
|
|
294
295
|
}
|
|
295
296
|
|
|
297
|
+
if (pathname === '/api/run-history') {
|
|
298
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
299
|
+
const limit = url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit'), 10) : undefined;
|
|
300
|
+
const entries = queryRunHistory(workspacePath, { limit });
|
|
301
|
+
writeJson(res, 200, entries);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
296
305
|
// API routes
|
|
297
306
|
if (pathname.startsWith('/api/')) {
|
|
298
307
|
const result = readResource(agentxchainDir, pathname);
|
|
@@ -47,6 +47,7 @@ export const FILE_TO_RESOURCE = Object.fromEntries(
|
|
|
47
47
|
Object.entries(RESOURCE_MAP).map(([resource, file]) => [normalizeRelativePath(file), resource])
|
|
48
48
|
);
|
|
49
49
|
FILE_TO_RESOURCE[normalizeRelativePath(SESSION_RECOVERY_FILE)] = '/api/continuity';
|
|
50
|
+
FILE_TO_RESOURCE[normalizeRelativePath('run-history.jsonl')] = '/api/run-history';
|
|
50
51
|
|
|
51
52
|
export const WATCH_DIRECTORIES = [
|
|
52
53
|
'',
|
package/src/lib/export.js
CHANGED
|
@@ -29,6 +29,7 @@ export const RUN_EXPORT_INCLUDED_ROOTS = [
|
|
|
29
29
|
'.agentxchain/hook-audit.jsonl',
|
|
30
30
|
'.agentxchain/hook-annotations.jsonl',
|
|
31
31
|
'.agentxchain/notification-audit.jsonl',
|
|
32
|
+
'.agentxchain/run-history.jsonl',
|
|
32
33
|
'.agentxchain/dispatch',
|
|
33
34
|
'.agentxchain/staging',
|
|
34
35
|
'.agentxchain/transactions/accept',
|
|
@@ -50,6 +51,7 @@ export const RUN_RESTORE_ROOTS = [
|
|
|
50
51
|
'.agentxchain/hook-audit.jsonl',
|
|
51
52
|
'.agentxchain/hook-annotations.jsonl',
|
|
52
53
|
'.agentxchain/notification-audit.jsonl',
|
|
54
|
+
'.agentxchain/run-history.jsonl',
|
|
53
55
|
'.agentxchain/dispatch',
|
|
54
56
|
'.agentxchain/staging',
|
|
55
57
|
'.agentxchain/transactions/accept',
|
|
@@ -36,6 +36,7 @@ import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir, getRev
|
|
|
36
36
|
import { runHooks } from './hook-runner.js';
|
|
37
37
|
import { emitNotifications } from './notification-runner.js';
|
|
38
38
|
import { writeSessionCheckpoint } from './session-checkpoint.js';
|
|
39
|
+
import { recordRunHistory } from './run-history.js';
|
|
39
40
|
|
|
40
41
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
41
42
|
|
|
@@ -1029,6 +1030,12 @@ function blockRunForHookIssue(root, state, { phase, turnId, hookName, detail, er
|
|
|
1029
1030
|
}),
|
|
1030
1031
|
};
|
|
1031
1032
|
writeState(root, blockedState);
|
|
1033
|
+
|
|
1034
|
+
// DEC-RHTR-SPEC: Record blocked outcome in cross-run history (non-fatal)
|
|
1035
|
+
if (notificationConfig) {
|
|
1036
|
+
recordRunHistory(root, blockedState, notificationConfig, 'blocked');
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1032
1039
|
emitBlockedNotification(root, notificationConfig, blockedState, {
|
|
1033
1040
|
category: typedReason,
|
|
1034
1041
|
blockedOn: blockedState.blocked_on,
|
|
@@ -2094,6 +2101,12 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2094
2101
|
});
|
|
2095
2102
|
|
|
2096
2103
|
writeState(root, updatedState);
|
|
2104
|
+
|
|
2105
|
+
// DEC-RHTR-SPEC: Record conflict_loop blocked outcome in cross-run history (non-fatal)
|
|
2106
|
+
if (updatedState.status === 'blocked') {
|
|
2107
|
+
recordRunHistory(root, updatedState, config, 'blocked');
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2097
2110
|
return {
|
|
2098
2111
|
ok: false,
|
|
2099
2112
|
error: `Acceptance conflict detected for turn ${currentTurn.turn_id}`,
|
|
@@ -2464,6 +2477,10 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2464
2477
|
}
|
|
2465
2478
|
|
|
2466
2479
|
if (updatedState.status === 'blocked') {
|
|
2480
|
+
// DEC-RHTR-SPEC: Record blocked outcome in cross-run history (non-fatal)
|
|
2481
|
+
// Covers needs_human, budget:exhausted, and any other non-hook blocked states
|
|
2482
|
+
recordRunHistory(root, updatedState, config, 'blocked');
|
|
2483
|
+
|
|
2467
2484
|
emitBlockedNotification(root, config, updatedState, {
|
|
2468
2485
|
category: updatedState.blocked_reason?.category || 'needs_human',
|
|
2469
2486
|
blockedOn: updatedState.blocked_on,
|
|
@@ -2694,6 +2711,9 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
2694
2711
|
|
|
2695
2712
|
writeState(root, updatedState);
|
|
2696
2713
|
|
|
2714
|
+
// DEC-RHTR-SPEC: Record retries-exhausted blocked outcome in cross-run history (non-fatal)
|
|
2715
|
+
recordRunHistory(root, updatedState, config, 'blocked');
|
|
2716
|
+
|
|
2697
2717
|
emitBlockedNotification(root, config, updatedState, {
|
|
2698
2718
|
category: 'retries_exhausted',
|
|
2699
2719
|
blockedOn: updatedState.blocked_on,
|
|
@@ -2913,6 +2933,9 @@ export function approveRunCompletion(root, config) {
|
|
|
2913
2933
|
// Session checkpoint — non-fatal
|
|
2914
2934
|
writeSessionCheckpoint(root, updatedState, 'run_completed');
|
|
2915
2935
|
|
|
2936
|
+
// Run history — non-fatal
|
|
2937
|
+
recordRunHistory(root, updatedState, config, 'completed');
|
|
2938
|
+
|
|
2916
2939
|
return {
|
|
2917
2940
|
ok: true,
|
|
2918
2941
|
state: attachLegacyCurrentTurnAlias(updatedState),
|
package/src/lib/intake.js
CHANGED
|
@@ -23,7 +23,8 @@ const VALID_PRIORITIES = ['p0', 'p1', 'p2', 'p3'];
|
|
|
23
23
|
const EVENT_ID_RE = /^evt_\d+_[0-9a-f]{4}$/;
|
|
24
24
|
const INTENT_ID_RE = /^intent_\d+_[0-9a-f]{4}$/;
|
|
25
25
|
|
|
26
|
-
// V3-S1 through S5 states
|
|
26
|
+
// V3-S1 through S5 states. `failed` remains read-tolerant for historical/manual
|
|
27
|
+
// intent files, but current first-party intake writers do not transition into it.
|
|
27
28
|
const S1_STATES = new Set(['detected', 'triaged', 'approved', 'planned', 'executing', 'blocked', 'completed', 'failed', 'suppressed', 'rejected']);
|
|
28
29
|
const TERMINAL_STATES = new Set(['suppressed', 'rejected', 'completed', 'failed']);
|
|
29
30
|
|
|
@@ -32,7 +33,7 @@ const VALID_TRANSITIONS = {
|
|
|
32
33
|
triaged: ['approved', 'rejected'],
|
|
33
34
|
approved: ['planned'],
|
|
34
35
|
planned: ['executing'],
|
|
35
|
-
executing: ['blocked', 'completed'
|
|
36
|
+
executing: ['blocked', 'completed'],
|
|
36
37
|
blocked: ['approved'],
|
|
37
38
|
};
|
|
38
39
|
|
|
@@ -864,23 +865,29 @@ function resolveRepoBackedIntent(root, intentPath, dirs, intent) {
|
|
|
864
865
|
const now = nowISO();
|
|
865
866
|
const previousStatus = intent.status;
|
|
866
867
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
868
|
+
// Run-level 'failed' is reserved/unreached in current governed writers (DEC-RUN-STATUS-001).
|
|
869
|
+
// Fail closed if encountered — operator must investigate manually.
|
|
870
|
+
if (state.status === 'failed') {
|
|
871
|
+
return {
|
|
872
|
+
ok: false,
|
|
873
|
+
error: 'governed run has reserved status "failed" which is not emitted by current governed writers. Manual inspection required. See DEC-RUN-STATUS-001.',
|
|
874
|
+
exitCode: 1,
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (state.status === 'blocked') {
|
|
879
|
+
intent.status = 'blocked';
|
|
870
880
|
intent.run_blocked_on = state.blocked_on || null;
|
|
871
881
|
intent.run_blocked_reason = state.blocked_reason?.category || null;
|
|
872
882
|
intent.run_blocked_recovery = state.blocked_reason?.recovery?.recovery_action || null;
|
|
873
|
-
if (newStatus === 'failed') {
|
|
874
|
-
intent.run_failed_at = now;
|
|
875
|
-
}
|
|
876
883
|
intent.updated_at = now;
|
|
877
884
|
intent.history.push({
|
|
878
885
|
from: previousStatus,
|
|
879
|
-
to:
|
|
886
|
+
to: 'blocked',
|
|
880
887
|
at: now,
|
|
881
|
-
reason: `governed run ${intent.target_run} reached status
|
|
888
|
+
reason: `governed run ${intent.target_run} reached status blocked`,
|
|
882
889
|
run_id: intent.target_run,
|
|
883
|
-
run_status:
|
|
890
|
+
run_status: 'blocked',
|
|
884
891
|
});
|
|
885
892
|
|
|
886
893
|
safeWriteJson(intentPath, intent);
|
|
@@ -888,8 +895,8 @@ function resolveRepoBackedIntent(root, intentPath, dirs, intent) {
|
|
|
888
895
|
ok: true,
|
|
889
896
|
intent,
|
|
890
897
|
previous_status: previousStatus,
|
|
891
|
-
new_status:
|
|
892
|
-
run_outcome:
|
|
898
|
+
new_status: 'blocked',
|
|
899
|
+
run_outcome: 'blocked',
|
|
893
900
|
no_change: false,
|
|
894
901
|
exitCode: 0,
|
|
895
902
|
};
|
|
@@ -1025,15 +1032,26 @@ function resolveCoordinatorBackedIntent(root, intentPath, dirs, intent) {
|
|
|
1025
1032
|
};
|
|
1026
1033
|
}
|
|
1027
1034
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1035
|
+
// Coordinator run-level 'failed' is reserved/unreached (DEC-RUN-STATUS-001). Fail closed.
|
|
1036
|
+
if (coordinatorState.status === 'failed') {
|
|
1037
|
+
return {
|
|
1038
|
+
ok: false,
|
|
1039
|
+
error: `coordinator run ${expectedSuperRunId} has reserved status "failed" which is not emitted by current governed writers. Manual inspection required. See DEC-RUN-STATUS-001.`,
|
|
1040
|
+
exitCode: 1,
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
if (coordinatorState.status === 'completed') {
|
|
1045
|
+
intent.status = 'blocked';
|
|
1046
|
+
intent.run_blocked_on = `coordinator:completed_without_workstream:${workstreamId}`;
|
|
1047
|
+
intent.run_blocked_reason = 'coordinator_completed_without_workstream';
|
|
1048
|
+
intent.run_blocked_recovery = `Coordinator run ${expectedSuperRunId} completed without satisfying workstream ${workstreamId}. Re-approve the intent and start a new run.`;
|
|
1031
1049
|
intent.updated_at = now;
|
|
1032
1050
|
intent.history.push({
|
|
1033
1051
|
from: previousStatus,
|
|
1034
|
-
to: '
|
|
1052
|
+
to: 'blocked',
|
|
1035
1053
|
at: now,
|
|
1036
|
-
reason: `coordinator run ${expectedSuperRunId}
|
|
1054
|
+
reason: `coordinator run ${expectedSuperRunId} completed without satisfying workstream ${workstreamId}`,
|
|
1037
1055
|
super_run_id: expectedSuperRunId,
|
|
1038
1056
|
run_status: coordinatorState.status,
|
|
1039
1057
|
});
|
|
@@ -1043,7 +1061,7 @@ function resolveCoordinatorBackedIntent(root, intentPath, dirs, intent) {
|
|
|
1043
1061
|
ok: true,
|
|
1044
1062
|
intent,
|
|
1045
1063
|
previous_status: previousStatus,
|
|
1046
|
-
new_status: '
|
|
1064
|
+
new_status: 'blocked',
|
|
1047
1065
|
run_outcome: coordinatorState.status,
|
|
1048
1066
|
no_change: false,
|
|
1049
1067
|
exitCode: 0,
|
package/src/lib/repo-observer.js
CHANGED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run History — cross-run operator observability.
|
|
3
|
+
*
|
|
4
|
+
* Append-only JSONL ledger persisting summary metadata from each governed run.
|
|
5
|
+
* Survives across runs (not reset by initializeGovernedRun).
|
|
6
|
+
*
|
|
7
|
+
* DEC-RH-SPEC: .planning/RUN_HISTORY_SPEC.md
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, appendFileSync, existsSync, mkdirSync } from 'fs';
|
|
11
|
+
import { join, dirname } from 'path';
|
|
12
|
+
|
|
13
|
+
const RUN_HISTORY_PATH = '.agentxchain/run-history.jsonl';
|
|
14
|
+
const HISTORY_PATH = '.agentxchain/history.jsonl';
|
|
15
|
+
const LEDGER_PATH = '.agentxchain/decision-ledger.jsonl';
|
|
16
|
+
const SCHEMA_VERSION = '0.1';
|
|
17
|
+
const WRITABLE_TERMINAL_STATUSES = new Set(['completed', 'blocked']);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Record a run's summary into the persistent run-history ledger.
|
|
21
|
+
* Non-fatal: catches and returns { ok: false, error } on failure.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} root - project root directory
|
|
24
|
+
* @param {object} state - final governed state
|
|
25
|
+
* @param {object} config - normalized config
|
|
26
|
+
* @param {'completed'|'blocked'} status - terminal status produced by current governed writers
|
|
27
|
+
* @returns {{ ok: boolean, error?: string }}
|
|
28
|
+
*/
|
|
29
|
+
export function recordRunHistory(root, state, config, status) {
|
|
30
|
+
try {
|
|
31
|
+
if (!WRITABLE_TERMINAL_STATUSES.has(status)) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
error: `Unsupported run-history terminal status: ${status}. Current governed writers emit completed or blocked only.`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const filePath = join(root, RUN_HISTORY_PATH);
|
|
39
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
40
|
+
|
|
41
|
+
const historyEntries = readJsonlSafe(root, HISTORY_PATH);
|
|
42
|
+
const ledgerEntries = readJsonlSafe(root, LEDGER_PATH);
|
|
43
|
+
|
|
44
|
+
// Extract unique phases and roles from turn history
|
|
45
|
+
const phasesCompleted = [...new Set(historyEntries.map(e => e.phase).filter(Boolean))];
|
|
46
|
+
const rolesUsed = [...new Set(historyEntries.map(e => e.role).filter(Boolean))];
|
|
47
|
+
|
|
48
|
+
// Derive connector and model from config
|
|
49
|
+
const firstRole = Object.values(config.roles || {})[0];
|
|
50
|
+
const connectorUsed = firstRole?.runtime_id || firstRole?.runtime || null;
|
|
51
|
+
const modelUsed = firstRole?.model || config.adapter?.model || null;
|
|
52
|
+
|
|
53
|
+
// Derive run start time from first history entry or state
|
|
54
|
+
const startedAt = historyEntries[0]?.accepted_at
|
|
55
|
+
|| state?.created_at
|
|
56
|
+
|| null;
|
|
57
|
+
const completedAt = state?.completed_at || null;
|
|
58
|
+
const durationMs = (startedAt && completedAt)
|
|
59
|
+
? new Date(completedAt).getTime() - new Date(startedAt).getTime()
|
|
60
|
+
: null;
|
|
61
|
+
|
|
62
|
+
const record = {
|
|
63
|
+
schema_version: SCHEMA_VERSION,
|
|
64
|
+
run_id: state?.run_id || null,
|
|
65
|
+
project_id: config.project?.id || null,
|
|
66
|
+
project_name: config.project?.name || null,
|
|
67
|
+
template: config.template || null,
|
|
68
|
+
status,
|
|
69
|
+
started_at: startedAt,
|
|
70
|
+
completed_at: completedAt,
|
|
71
|
+
duration_ms: durationMs,
|
|
72
|
+
phases_completed: phasesCompleted,
|
|
73
|
+
total_turns: historyEntries.length,
|
|
74
|
+
roles_used: rolesUsed,
|
|
75
|
+
decisions_count: ledgerEntries.length,
|
|
76
|
+
total_cost_usd: state?.budget_status?.spent_usd ?? null,
|
|
77
|
+
budget_limit_usd: config.budget?.per_run_max_usd ?? null,
|
|
78
|
+
blocked_reason: status === 'blocked' ? (state?.blocked_reason?.detail || state?.blocked_on || null) : null,
|
|
79
|
+
gate_results: state?.phase_gate_status || {},
|
|
80
|
+
connector_used: connectorUsed,
|
|
81
|
+
model_used: modelUsed,
|
|
82
|
+
recorded_at: new Date().toISOString(),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
appendFileSync(filePath, JSON.stringify(record) + '\n');
|
|
86
|
+
return { ok: true };
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return { ok: false, error: err.message || String(err) };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Query the run-history ledger.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} root - project root directory
|
|
96
|
+
* @param {object} [opts] - { limit?: number, status?: string }
|
|
97
|
+
* @returns {Array<object>} most-recent-first
|
|
98
|
+
*/
|
|
99
|
+
export function queryRunHistory(root, opts = {}) {
|
|
100
|
+
const filePath = join(root, RUN_HISTORY_PATH);
|
|
101
|
+
if (!existsSync(filePath)) return [];
|
|
102
|
+
|
|
103
|
+
let content;
|
|
104
|
+
try {
|
|
105
|
+
content = readFileSync(filePath, 'utf8').trim();
|
|
106
|
+
} catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
if (!content) return [];
|
|
110
|
+
|
|
111
|
+
let entries = content
|
|
112
|
+
.split('\n')
|
|
113
|
+
.filter(Boolean)
|
|
114
|
+
.map(line => {
|
|
115
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
116
|
+
})
|
|
117
|
+
.filter(Boolean);
|
|
118
|
+
|
|
119
|
+
// Filter by status if requested
|
|
120
|
+
if (opts.status) {
|
|
121
|
+
entries = entries.filter(e => e.status === opts.status);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Most recent first
|
|
125
|
+
entries.reverse();
|
|
126
|
+
|
|
127
|
+
// Limit
|
|
128
|
+
if (opts.limit && opts.limit > 0) {
|
|
129
|
+
entries = entries.slice(0, opts.limit);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return entries;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get the path to the run-history file.
|
|
137
|
+
*/
|
|
138
|
+
export function getRunHistoryPath(root) {
|
|
139
|
+
return join(root, RUN_HISTORY_PATH);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Internal ────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
function readJsonlSafe(root, relPath) {
|
|
145
|
+
const filePath = join(root, relPath);
|
|
146
|
+
if (!existsSync(filePath)) return [];
|
|
147
|
+
try {
|
|
148
|
+
const content = readFileSync(filePath, 'utf8').trim();
|
|
149
|
+
if (!content) return [];
|
|
150
|
+
return content.split('\n').filter(Boolean).map(line => {
|
|
151
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
152
|
+
}).filter(Boolean);
|
|
153
|
+
} catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
}
|
package/src/lib/schema.js
CHANGED
|
@@ -31,6 +31,8 @@ export function validateStateSchema(data) {
|
|
|
31
31
|
|
|
32
32
|
export function validateGovernedStateSchema(data) {
|
|
33
33
|
const errors = [];
|
|
34
|
+
// Keep `failed` for compatibility. Current governed writers do not emit it,
|
|
35
|
+
// but validators and read-only surfaces still tolerate reserved/manual states.
|
|
34
36
|
const VALID_RUN_STATUSES = ['idle', 'active', 'paused', 'blocked', 'completed', 'failed'];
|
|
35
37
|
const isV1_1 = data?.schema_version === '1.1';
|
|
36
38
|
const hasLegacyCurrentTurn = Object.prototype.hasOwnProperty.call(data || {}, 'current_turn');
|