agentxchain 2.90.0 → 2.92.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 +2 -1
- package/dashboard/components/initiative.js +17 -0
- package/package.json +1 -1
- package/src/commands/dashboard.js +132 -4
- package/src/commands/doctor.js +19 -1
- package/src/commands/status.js +10 -0
- package/src/commands/stop.js +28 -0
- package/src/lib/adapters/local-cli-adapter.js +1 -1
- package/src/lib/export.js +43 -0
- package/src/lib/governed-state.js +84 -2
- package/src/lib/repo-observer.js +39 -5
- package/src/lib/report.js +87 -8
- package/src/lib/run-loop.js +16 -0
package/bin/agentxchain.js
CHANGED
|
@@ -199,7 +199,7 @@ program
|
|
|
199
199
|
|
|
200
200
|
program
|
|
201
201
|
.command('stop')
|
|
202
|
-
.description('Stop watch
|
|
202
|
+
.description('Stop dashboard/watch daemons and Claude Code sessions; close Cursor/VS Code chats manually')
|
|
203
203
|
.action(stopCommand);
|
|
204
204
|
|
|
205
205
|
program
|
|
@@ -477,6 +477,7 @@ program
|
|
|
477
477
|
.command('dashboard')
|
|
478
478
|
.description('Open the read-only governance dashboard in your browser')
|
|
479
479
|
.option('--port <port>', 'Server port', '3847')
|
|
480
|
+
.option('--daemon', 'Run the dashboard in background mode')
|
|
480
481
|
.option('--no-open', 'Do not auto-open the browser')
|
|
481
482
|
.action(dashboardCommand);
|
|
482
483
|
|
|
@@ -207,6 +207,23 @@ export function render({
|
|
|
207
207
|
if (Array.isArray(barrier.satisfied_repos) && barrier.satisfied_repos.length > 0) {
|
|
208
208
|
html += `<div class="turn-detail"><span class="detail-label">Satisfied Repos:</span> ${esc(barrier.satisfied_repos.join(', '))}</div>`;
|
|
209
209
|
}
|
|
210
|
+
const decisionIds = barrier.required_decision_ids_by_repo || barrier.alignment_decision_ids || null;
|
|
211
|
+
if (decisionIds && typeof decisionIds === 'object' && !Array.isArray(decisionIds)) {
|
|
212
|
+
const satisfiedSet = new Set(Array.isArray(barrier.satisfied_repos) ? barrier.satisfied_repos : []);
|
|
213
|
+
html += `<div class="turn-detail"><span class="detail-label">Decision Requirements:</span></div>`;
|
|
214
|
+
html += `<div class="decision-req-list" style="margin-left:1.2em;margin-top:0.3em">`;
|
|
215
|
+
for (const [repo, ids] of Object.entries(decisionIds)) {
|
|
216
|
+
if (!Array.isArray(ids) || ids.length === 0) continue;
|
|
217
|
+
const repoSatisfied = satisfiedSet.has(repo);
|
|
218
|
+
const idBadges = ids.map((id) =>
|
|
219
|
+
repoSatisfied
|
|
220
|
+
? badge(`${id} ✓`, 'var(--green)')
|
|
221
|
+
: badge(id, 'var(--text-dim)')
|
|
222
|
+
).join(' ');
|
|
223
|
+
html += `<div style="margin-bottom:0.2em"><span class="mono" style="margin-right:0.5em">${esc(repo)}:</span>${idBadges}</div>`;
|
|
224
|
+
}
|
|
225
|
+
html += `</div>`;
|
|
226
|
+
}
|
|
210
227
|
html += `</div>`;
|
|
211
228
|
}
|
|
212
229
|
html += `</div>`;
|
package/package.json
CHANGED
|
@@ -5,13 +5,17 @@
|
|
|
5
5
|
* The dashboard remains mostly observational, but can approve pending gates.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync } from 'fs';
|
|
8
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
|
|
9
|
+
import { spawn } from 'child_process';
|
|
9
10
|
import { join, dirname } from 'path';
|
|
10
11
|
import { fileURLToPath } from 'url';
|
|
11
12
|
import { createBridgeServer } from '../lib/dashboard/bridge-server.js';
|
|
12
13
|
|
|
13
14
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
15
|
const DEFAULT_PORT = 3847;
|
|
16
|
+
const DASHBOARD_PID_FILE = '.agentxchain-dashboard.pid';
|
|
17
|
+
const DASHBOARD_SESSION_FILE = '.agentxchain-dashboard.json';
|
|
18
|
+
const DASHBOARD_DAEMON_CHILD_ENV = 'AGENTXCHAIN_DASHBOARD_DAEMON_CHILD';
|
|
15
19
|
|
|
16
20
|
export async function dashboardCommand(options) {
|
|
17
21
|
const cwd = process.cwd();
|
|
@@ -29,12 +33,25 @@ export async function dashboardCommand(options) {
|
|
|
29
33
|
process.exit(1);
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
if (options.daemon && process.env[DASHBOARD_DAEMON_CHILD_ENV] !== '1') {
|
|
37
|
+
await startDashboardDaemon({ cwd, port: parseDashboardPort(options.port) });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
cleanupDashboardFiles(cwd);
|
|
42
|
+
|
|
43
|
+
const port = parseDashboardPort(options.port);
|
|
33
44
|
const bridge = createBridgeServer({ agentxchainDir, dashboardDir, port });
|
|
34
45
|
|
|
35
46
|
try {
|
|
36
47
|
const { port: actualPort } = await bridge.start();
|
|
37
48
|
const url = `http://localhost:${actualPort}`;
|
|
49
|
+
writeDashboardSession(cwd, {
|
|
50
|
+
pid: process.pid,
|
|
51
|
+
port: actualPort,
|
|
52
|
+
url,
|
|
53
|
+
started_at: new Date().toISOString(),
|
|
54
|
+
});
|
|
38
55
|
|
|
39
56
|
console.log(`Dashboard running at ${url}`);
|
|
40
57
|
console.log('Press Ctrl+C to stop.\n');
|
|
@@ -51,16 +68,19 @@ export async function dashboardCommand(options) {
|
|
|
51
68
|
}
|
|
52
69
|
}
|
|
53
70
|
|
|
54
|
-
|
|
71
|
+
let shuttingDown = false;
|
|
55
72
|
const shutdown = async () => {
|
|
73
|
+
if (shuttingDown) return;
|
|
74
|
+
shuttingDown = true;
|
|
56
75
|
console.log('\nShutting down dashboard...');
|
|
76
|
+
cleanupDashboardFiles(cwd);
|
|
57
77
|
await bridge.stop();
|
|
58
78
|
process.exit(0);
|
|
59
79
|
};
|
|
60
80
|
process.on('SIGINT', shutdown);
|
|
61
81
|
process.on('SIGTERM', shutdown);
|
|
62
|
-
|
|
63
82
|
} catch (err) {
|
|
83
|
+
cleanupDashboardFiles(cwd);
|
|
64
84
|
if (err.code === 'EADDRINUSE') {
|
|
65
85
|
console.error(`Error: Port ${port} is already in use. Try --port <number>.`);
|
|
66
86
|
process.exit(1);
|
|
@@ -68,3 +88,111 @@ export async function dashboardCommand(options) {
|
|
|
68
88
|
throw err;
|
|
69
89
|
}
|
|
70
90
|
}
|
|
91
|
+
|
|
92
|
+
export function getDashboardPid(root) {
|
|
93
|
+
const pidPath = join(root, DASHBOARD_PID_FILE);
|
|
94
|
+
if (!existsSync(pidPath)) return null;
|
|
95
|
+
try {
|
|
96
|
+
const pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
|
|
97
|
+
if (!Number.isFinite(pid)) return null;
|
|
98
|
+
process.kill(pid, 0);
|
|
99
|
+
return pid;
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err?.code === 'ESRCH') {
|
|
102
|
+
cleanupDashboardFiles(root);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function getDashboardSession(root) {
|
|
110
|
+
const sessionPath = join(root, DASHBOARD_SESSION_FILE);
|
|
111
|
+
if (!existsSync(sessionPath)) return null;
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(readFileSync(sessionPath, 'utf8'));
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function cleanupDashboardFiles(root) {
|
|
120
|
+
const paths = [
|
|
121
|
+
join(root, DASHBOARD_PID_FILE),
|
|
122
|
+
join(root, DASHBOARD_SESSION_FILE),
|
|
123
|
+
];
|
|
124
|
+
for (const path of paths) {
|
|
125
|
+
if (!existsSync(path)) continue;
|
|
126
|
+
try { unlinkSync(path); } catch {}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseDashboardPort(value) {
|
|
131
|
+
const parsed = parseInt(value, 10);
|
|
132
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PORT;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function writeDashboardSession(root, session) {
|
|
136
|
+
writeFileSync(join(root, DASHBOARD_PID_FILE), `${session.pid}\n`);
|
|
137
|
+
writeFileSync(join(root, DASHBOARD_SESSION_FILE), `${JSON.stringify(session, null, 2)}\n`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function startDashboardDaemon({ cwd, port }) {
|
|
141
|
+
const existingPid = getDashboardPid(cwd);
|
|
142
|
+
if (existingPid) {
|
|
143
|
+
const existingSession = getDashboardSession(cwd);
|
|
144
|
+
const existingUrl = existingSession?.url || `http://localhost:${existingSession?.port || port}`;
|
|
145
|
+
console.error(`Error: Dashboard already running at ${existingUrl} (PID: ${existingPid}). Stop it first with "agentxchain stop".`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
cleanupDashboardFiles(cwd);
|
|
150
|
+
|
|
151
|
+
const cliBin = join(__dirname, '..', '..', 'bin', 'agentxchain.js');
|
|
152
|
+
const child = spawn(process.execPath, [cliBin, 'dashboard', '--port', String(port), '--no-open'], {
|
|
153
|
+
cwd,
|
|
154
|
+
env: {
|
|
155
|
+
...process.env,
|
|
156
|
+
[DASHBOARD_DAEMON_CHILD_ENV]: '1',
|
|
157
|
+
},
|
|
158
|
+
detached: true,
|
|
159
|
+
stdio: 'ignore',
|
|
160
|
+
});
|
|
161
|
+
child.unref();
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const session = await waitForDashboardSession(cwd, child.pid);
|
|
165
|
+
console.log(`Dashboard started in daemon mode at ${session.url}`);
|
|
166
|
+
console.log(`PID: ${session.pid}`);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
try { process.kill(child.pid, 'SIGTERM'); } catch {}
|
|
169
|
+
cleanupDashboardFiles(cwd);
|
|
170
|
+
console.error(`Error: ${err.message}`);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function waitForDashboardSession(root, expectedPid, timeoutMs = 8000) {
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const startedAt = Date.now();
|
|
178
|
+
|
|
179
|
+
const poll = () => {
|
|
180
|
+
const pid = getDashboardPid(root);
|
|
181
|
+
const session = getDashboardSession(root);
|
|
182
|
+
|
|
183
|
+
if (pid === expectedPid && session?.pid === expectedPid && typeof session?.url === 'string') {
|
|
184
|
+
resolve(session);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
189
|
+
reject(new Error('Dashboard daemon did not report a live session within 8s.'));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
setTimeout(poll, 100);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
poll();
|
|
197
|
+
});
|
|
198
|
+
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -5,6 +5,7 @@ import chalk from 'chalk';
|
|
|
5
5
|
import { loadConfig, loadLock, findProjectRoot } from '../lib/config.js';
|
|
6
6
|
import { validateProject } from '../lib/validation.js';
|
|
7
7
|
import { getWatchPid } from './watch.js';
|
|
8
|
+
import { getDashboardPid, getDashboardSession } from './dashboard.js';
|
|
8
9
|
import { loadNormalizedConfig, detectConfigVersion } from '../lib/normalized-config.js';
|
|
9
10
|
import { readDaemonState, evaluateDaemonStatus } from '../lib/run-schedule.js';
|
|
10
11
|
import { getGovernedVersionSurface, formatGovernedVersionLabel } from '../lib/protocol-version.js';
|
|
@@ -219,6 +220,21 @@ function governedDoctor(root, rawConfig, opts) {
|
|
|
219
220
|
}
|
|
220
221
|
}
|
|
221
222
|
|
|
223
|
+
// 9. Dashboard session health (unconditional — dashboard is a general operator surface)
|
|
224
|
+
{
|
|
225
|
+
const dashPid = getDashboardPid(root);
|
|
226
|
+
const dashSession = getDashboardSession(root);
|
|
227
|
+
if (dashPid && dashSession) {
|
|
228
|
+
checks.push({ id: 'dashboard_session', name: 'Dashboard session', level: 'pass', detail: `Dashboard running at ${dashSession.url} (PID: ${dashPid})` });
|
|
229
|
+
} else if (dashPid && !dashSession) {
|
|
230
|
+
checks.push({ id: 'dashboard_session', name: 'Dashboard session', level: 'warn', detail: `Dashboard PID ${dashPid} alive but session file missing` });
|
|
231
|
+
} else if (!dashPid && dashSession) {
|
|
232
|
+
checks.push({ id: 'dashboard_session', name: 'Dashboard session', level: 'warn', detail: `Stale dashboard session files (PID ${dashSession.pid || '?'} not running)` });
|
|
233
|
+
} else {
|
|
234
|
+
checks.push({ id: 'dashboard_session', name: 'Dashboard session', level: 'info', detail: 'No dashboard session' });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
222
238
|
// Compute summary
|
|
223
239
|
const failCount = checks.filter(c => c.level === 'fail').length;
|
|
224
240
|
const warnCount = checks.filter(c => c.level === 'warn').length;
|
|
@@ -253,7 +269,9 @@ function governedDoctor(root, rawConfig, opts) {
|
|
|
253
269
|
? chalk.green('PASS')
|
|
254
270
|
: c.level === 'warn'
|
|
255
271
|
? chalk.yellow('WARN')
|
|
256
|
-
:
|
|
272
|
+
: c.level === 'info'
|
|
273
|
+
? chalk.dim('INFO')
|
|
274
|
+
: chalk.red('FAIL');
|
|
257
275
|
console.log(` ${badge} ${c.name.padEnd(24)} ${chalk.dim(c.detail)}`);
|
|
258
276
|
}
|
|
259
277
|
|
package/src/commands/status.js
CHANGED
|
@@ -9,6 +9,7 @@ import { getConnectorHealth } from '../lib/connector-health.js';
|
|
|
9
9
|
import { deriveWorkflowKitArtifacts } from '../lib/workflow-kit-artifacts.js';
|
|
10
10
|
import { evaluateTimeouts } from '../lib/timeout-evaluator.js';
|
|
11
11
|
import { summarizeRunProvenance } from '../lib/run-provenance.js';
|
|
12
|
+
import { getDashboardPid, getDashboardSession } from './dashboard.js';
|
|
12
13
|
|
|
13
14
|
export async function statusCommand(opts) {
|
|
14
15
|
const context = loadProjectContext();
|
|
@@ -87,6 +88,14 @@ function renderGovernedStatus(context, opts) {
|
|
|
87
88
|
const workflowKitArtifacts = deriveWorkflowKitArtifacts(root, config, state);
|
|
88
89
|
|
|
89
90
|
if (opts.json) {
|
|
91
|
+
const dashPid = getDashboardPid(root);
|
|
92
|
+
const dashSession = getDashboardSession(root);
|
|
93
|
+
const dashboardSessionObj = dashPid
|
|
94
|
+
? { status: 'running', pid: dashPid, url: dashSession?.url || null, started_at: dashSession?.started_at || null }
|
|
95
|
+
: dashSession
|
|
96
|
+
? { status: 'stale', pid: dashSession.pid || null, url: dashSession.url || null, started_at: dashSession.started_at || null }
|
|
97
|
+
: { status: 'not_running', pid: null, url: null, started_at: null };
|
|
98
|
+
|
|
90
99
|
console.log(JSON.stringify({
|
|
91
100
|
version,
|
|
92
101
|
protocol_mode: config.protocol_mode,
|
|
@@ -99,6 +108,7 @@ function renderGovernedStatus(context, opts) {
|
|
|
99
108
|
continuity,
|
|
100
109
|
connector_health: connectorHealth,
|
|
101
110
|
workflow_kit_artifacts: workflowKitArtifacts,
|
|
111
|
+
dashboard_session: dashboardSessionObj,
|
|
102
112
|
}, null, 2));
|
|
103
113
|
return;
|
|
104
114
|
}
|
package/src/commands/stop.js
CHANGED
|
@@ -3,9 +3,12 @@ import { join } from 'path';
|
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import { loadConfig } from '../lib/config.js';
|
|
5
5
|
import { getWatchPid } from './watch.js';
|
|
6
|
+
import { cleanupDashboardFiles, getDashboardPid, getDashboardSession } from './dashboard.js';
|
|
6
7
|
|
|
7
8
|
const SESSION_FILE = '.agentxchain-session.json';
|
|
8
9
|
const WATCH_PID_FILE = '.agentxchain-watch.pid';
|
|
10
|
+
const DASHBOARD_PID_FILE = '.agentxchain-dashboard.pid';
|
|
11
|
+
const DASHBOARD_SESSION_FILE = '.agentxchain-dashboard.json';
|
|
9
12
|
|
|
10
13
|
export async function stopCommand() {
|
|
11
14
|
const result = loadConfig();
|
|
@@ -14,7 +17,12 @@ export async function stopCommand() {
|
|
|
14
17
|
const { root } = result;
|
|
15
18
|
const sessionPath = join(root, SESSION_FILE);
|
|
16
19
|
const watchPidPath = join(root, WATCH_PID_FILE);
|
|
20
|
+
const dashboardPidPath = join(root, DASHBOARD_PID_FILE);
|
|
21
|
+
const dashboardSessionPath = join(root, DASHBOARD_SESSION_FILE);
|
|
17
22
|
const watchPid = getWatchPid(root);
|
|
23
|
+
const hadDashboardArtifacts = existsSync(dashboardPidPath) || existsSync(dashboardSessionPath);
|
|
24
|
+
const dashboardPid = getDashboardPid(root);
|
|
25
|
+
const dashboardSession = getDashboardSession(root);
|
|
18
26
|
let didStopAnything = false;
|
|
19
27
|
|
|
20
28
|
if (watchPid) {
|
|
@@ -41,6 +49,26 @@ export async function stopCommand() {
|
|
|
41
49
|
} catch {}
|
|
42
50
|
}
|
|
43
51
|
|
|
52
|
+
if (dashboardPid) {
|
|
53
|
+
try {
|
|
54
|
+
process.kill(dashboardPid, 'SIGTERM');
|
|
55
|
+
cleanupDashboardFiles(root);
|
|
56
|
+
didStopAnything = true;
|
|
57
|
+
console.log('');
|
|
58
|
+
console.log(chalk.green(` ✓ Stopped dashboard process (PID: ${dashboardPid})${dashboardSession?.url ? ` at ${dashboardSession.url}` : ''}`));
|
|
59
|
+
console.log('');
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (err.code === 'ESRCH') {
|
|
62
|
+
cleanupDashboardFiles(root);
|
|
63
|
+
} else {
|
|
64
|
+
console.log(chalk.red(` ✗ Could not stop dashboard process (PID: ${dashboardPid}): ${err.message}`));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} else if (hadDashboardArtifacts) {
|
|
68
|
+
cleanupDashboardFiles(root);
|
|
69
|
+
console.log(chalk.dim(' Removed stale dashboard session files.'));
|
|
70
|
+
}
|
|
71
|
+
|
|
44
72
|
if (existsSync(sessionPath)) {
|
|
45
73
|
let session;
|
|
46
74
|
try {
|
|
@@ -115,7 +115,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
115
115
|
child = spawn(command, args, {
|
|
116
116
|
cwd: runtime.cwd ? join(root, runtime.cwd) : root,
|
|
117
117
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
118
|
-
env: { ...process.env },
|
|
118
|
+
env: { ...process.env, AGENTXCHAIN_TURN_ID: turn.turn_id },
|
|
119
119
|
});
|
|
120
120
|
} catch (err) {
|
|
121
121
|
resolve({ ok: false, error: `Failed to spawn "${command}": ${err.message}`, logs });
|
package/src/lib/export.js
CHANGED
|
@@ -7,6 +7,7 @@ import { loadProjectContext, loadProjectState } from './config.js';
|
|
|
7
7
|
import { loadCoordinatorConfig, COORDINATOR_CONFIG_FILE } from './coordinator-config.js';
|
|
8
8
|
import { loadCoordinatorState } from './coordinator-state.js';
|
|
9
9
|
import { normalizeRunProvenance } from './run-provenance.js';
|
|
10
|
+
import { getDashboardPid, getDashboardSession } from '../commands/dashboard.js';
|
|
10
11
|
|
|
11
12
|
const EXPORT_SCHEMA_VERSION = '0.3';
|
|
12
13
|
|
|
@@ -23,6 +24,8 @@ const COORDINATOR_INCLUDED_ROOTS = [
|
|
|
23
24
|
export const RUN_EXPORT_INCLUDED_ROOTS = [
|
|
24
25
|
'agentxchain.json',
|
|
25
26
|
'TALK.md',
|
|
27
|
+
'.agentxchain-dashboard.pid',
|
|
28
|
+
'.agentxchain-dashboard.json',
|
|
26
29
|
'.agentxchain/state.json',
|
|
27
30
|
'.agentxchain/session.json',
|
|
28
31
|
'.agentxchain/history.jsonl',
|
|
@@ -166,6 +169,45 @@ function countDirectoryFiles(files, prefix) {
|
|
|
166
169
|
return Object.keys(files).filter((path) => path.startsWith(`${prefix}/`)).length;
|
|
167
170
|
}
|
|
168
171
|
|
|
172
|
+
function buildDashboardSessionSummary(root) {
|
|
173
|
+
const dashPid = getDashboardPid(root);
|
|
174
|
+
const dashSession = getDashboardSession(root);
|
|
175
|
+
|
|
176
|
+
if (dashPid && dashSession) {
|
|
177
|
+
return {
|
|
178
|
+
status: 'running',
|
|
179
|
+
pid: dashPid,
|
|
180
|
+
url: dashSession.url || null,
|
|
181
|
+
started_at: dashSession.started_at || null,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (dashPid && !dashSession) {
|
|
186
|
+
return {
|
|
187
|
+
status: 'pid_only',
|
|
188
|
+
pid: dashPid,
|
|
189
|
+
url: null,
|
|
190
|
+
started_at: null,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!dashPid && dashSession) {
|
|
195
|
+
return {
|
|
196
|
+
status: 'stale',
|
|
197
|
+
pid: dashSession.pid || null,
|
|
198
|
+
url: dashSession.url || null,
|
|
199
|
+
started_at: dashSession.started_at || null,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
status: 'not_running',
|
|
205
|
+
pid: null,
|
|
206
|
+
url: null,
|
|
207
|
+
started_at: null,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
169
211
|
export function buildDelegationSummary(files) {
|
|
170
212
|
const historyData = files['.agentxchain/history.jsonl']?.data;
|
|
171
213
|
if (!Array.isArray(historyData)) {
|
|
@@ -404,6 +446,7 @@ export function buildRunExport(startDir = process.cwd()) {
|
|
|
404
446
|
staging_artifact_files: countDirectoryFiles(files, '.agentxchain/staging'),
|
|
405
447
|
intake_present: Object.keys(files).some((path) => path.startsWith('.agentxchain/intake/')),
|
|
406
448
|
coordinator_present: Object.keys(files).some((path) => path.startsWith('.agentxchain/multirepo/')),
|
|
449
|
+
dashboard_session: buildDashboardSessionSummary(root),
|
|
407
450
|
delegation_summary: buildDelegationSummary(files),
|
|
408
451
|
},
|
|
409
452
|
workspace: buildRunWorkspaceMetadata(root),
|
|
@@ -799,6 +799,61 @@ function readJsonlEntries(root, relPath) {
|
|
|
799
799
|
.filter(Boolean);
|
|
800
800
|
}
|
|
801
801
|
|
|
802
|
+
function collectPendingConcurrentSiblingDeclarations(root, state, currentTurn, historyEntries = []) {
|
|
803
|
+
const concurrentIds = new Set(
|
|
804
|
+
Array.isArray(currentTurn?.concurrent_with)
|
|
805
|
+
? currentTurn.concurrent_with.filter((id) => typeof id === 'string' && id.length > 0)
|
|
806
|
+
: [],
|
|
807
|
+
);
|
|
808
|
+
const activeTurns = getActiveTurns(state);
|
|
809
|
+
for (const turn of Object.values(activeTurns)) {
|
|
810
|
+
if (
|
|
811
|
+
turn?.turn_id !== currentTurn?.turn_id
|
|
812
|
+
&& Array.isArray(turn?.concurrent_with)
|
|
813
|
+
&& turn.concurrent_with.includes(currentTurn?.turn_id)
|
|
814
|
+
) {
|
|
815
|
+
concurrentIds.add(turn.turn_id);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
if (concurrentIds.size === 0) {
|
|
819
|
+
return [];
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const acceptedIds = new Set(
|
|
823
|
+
(Array.isArray(historyEntries) ? historyEntries : [])
|
|
824
|
+
.map((entry) => entry?.turn_id)
|
|
825
|
+
.filter((turnId) => typeof turnId === 'string' && turnId.length > 0),
|
|
826
|
+
);
|
|
827
|
+
const declarations = [];
|
|
828
|
+
|
|
829
|
+
for (const siblingTurnId of concurrentIds) {
|
|
830
|
+
if (siblingTurnId === currentTurn?.turn_id || acceptedIds.has(siblingTurnId) || !activeTurns[siblingTurnId]) {
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const stagedSibling = loadHookStagedTurn(root, getTurnStagingResultPath(siblingTurnId));
|
|
835
|
+
const siblingResult = stagedSibling.turnResult;
|
|
836
|
+
if (!siblingResult || siblingResult.turn_id !== siblingTurnId) {
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const siblingFiles = [...new Set(
|
|
841
|
+
(Array.isArray(siblingResult.files_changed) ? siblingResult.files_changed : [])
|
|
842
|
+
.filter((filePath) => typeof filePath === 'string' && filePath.length > 0),
|
|
843
|
+
)];
|
|
844
|
+
if (siblingFiles.length === 0) {
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
declarations.push({
|
|
849
|
+
turn_id: siblingTurnId,
|
|
850
|
+
files_changed: siblingFiles,
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return declarations;
|
|
855
|
+
}
|
|
856
|
+
|
|
802
857
|
function getObservedFiles(entry) {
|
|
803
858
|
if (Array.isArray(entry?.observed_artifact?.files_changed)) {
|
|
804
859
|
return entry.observed_artifact.files_changed;
|
|
@@ -2373,7 +2428,17 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2373
2428
|
const baseline = currentTurn.baseline || null;
|
|
2374
2429
|
const rawObservation = observeChanges(root, baseline);
|
|
2375
2430
|
const historyEntries = readJsonlEntries(root, HISTORY_PATH);
|
|
2376
|
-
const
|
|
2431
|
+
const pendingConcurrentSiblingDeclarations = collectPendingConcurrentSiblingDeclarations(
|
|
2432
|
+
root,
|
|
2433
|
+
state,
|
|
2434
|
+
currentTurn,
|
|
2435
|
+
historyEntries,
|
|
2436
|
+
);
|
|
2437
|
+
const observation = attributeObservedChangesToTurn(rawObservation, currentTurn, historyEntries, {
|
|
2438
|
+
currentDeclaredFiles: turnResult.files_changed || [],
|
|
2439
|
+
concurrentSiblingIds: pendingConcurrentSiblingDeclarations.map((entry) => entry.turn_id),
|
|
2440
|
+
pendingConcurrentSiblingDeclarations,
|
|
2441
|
+
});
|
|
2377
2442
|
const role = config.roles?.[turnResult.role];
|
|
2378
2443
|
const runtimeId = turnResult.runtime_id;
|
|
2379
2444
|
const runtime = config.runtimes?.[runtimeId];
|
|
@@ -2381,11 +2446,28 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2381
2446
|
materializeDerivedReviewArtifact(root, turnResult, state, runtimeType, baseline);
|
|
2382
2447
|
materializeDerivedProposalArtifact(root, turnResult, state, runtimeType);
|
|
2383
2448
|
const writeAuthority = role?.write_authority || 'review_only';
|
|
2449
|
+
|
|
2450
|
+
// When concurrent siblings exist but have not yet been accepted, the
|
|
2451
|
+
// observation includes their file changes too. The attribution system
|
|
2452
|
+
// (attributeObservedChangesToTurn) only removes sibling files for
|
|
2453
|
+
// *later*-accepted turns. For the *first*-accepted concurrent turn,
|
|
2454
|
+
// undeclared files are expected noise from concurrency — downgrade to
|
|
2455
|
+
// warnings so the governance contract is not broken by turn-acceptance
|
|
2456
|
+
// ordering.
|
|
2457
|
+
const concurrentIds = new Set(
|
|
2458
|
+
Array.isArray(currentTurn.concurrent_with) ? currentTurn.concurrent_with : [],
|
|
2459
|
+
);
|
|
2460
|
+
const acceptedTurnIds = new Set(historyEntries.map(h => h.turn_id));
|
|
2461
|
+
const hasUnacceptedConcurrentSiblings = [...concurrentIds].some(id => !acceptedTurnIds.has(id));
|
|
2462
|
+
|
|
2384
2463
|
const diffComparison = compareDeclaredVsObserved(
|
|
2385
2464
|
turnResult.files_changed || [],
|
|
2386
2465
|
observation.files_changed,
|
|
2387
2466
|
writeAuthority,
|
|
2388
|
-
{
|
|
2467
|
+
{
|
|
2468
|
+
observation_available: observation.observation_available,
|
|
2469
|
+
has_unaccepted_concurrent_siblings: hasUnacceptedConcurrentSiblings,
|
|
2470
|
+
},
|
|
2389
2471
|
);
|
|
2390
2472
|
if (diffComparison.errors.length > 0) {
|
|
2391
2473
|
return {
|
package/src/lib/repo-observer.js
CHANGED
|
@@ -175,7 +175,7 @@ export function observeChanges(root, baseline) {
|
|
|
175
175
|
};
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
export function attributeObservedChangesToTurn(observation, currentTurn, historyEntries = []) {
|
|
178
|
+
export function attributeObservedChangesToTurn(observation, currentTurn, historyEntries = [], options = {}) {
|
|
179
179
|
const observedFiles = Array.isArray(observation?.files_changed) ? observation.files_changed : [];
|
|
180
180
|
if (observedFiles.length === 0) {
|
|
181
181
|
return observation;
|
|
@@ -184,6 +184,11 @@ export function attributeObservedChangesToTurn(observation, currentTurn, history
|
|
|
184
184
|
const concurrentIds = new Set(
|
|
185
185
|
Array.isArray(currentTurn?.concurrent_with) ? currentTurn.concurrent_with : [],
|
|
186
186
|
);
|
|
187
|
+
for (const siblingId of Array.isArray(options.concurrentSiblingIds) ? options.concurrentSiblingIds : []) {
|
|
188
|
+
if (typeof siblingId === 'string' && siblingId.length > 0) {
|
|
189
|
+
concurrentIds.add(siblingId);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
187
192
|
if (concurrentIds.size === 0) {
|
|
188
193
|
return observation;
|
|
189
194
|
}
|
|
@@ -195,11 +200,17 @@ export function attributeObservedChangesToTurn(observation, currentTurn, history
|
|
|
195
200
|
.filter((entry) => (
|
|
196
201
|
Number.isInteger(entry?.accepted_sequence)
|
|
197
202
|
&& entry.accepted_sequence > assignedSequence
|
|
198
|
-
&&
|
|
203
|
+
&& (
|
|
204
|
+
concurrentIds.has(entry.turn_id)
|
|
205
|
+
|| (Array.isArray(entry?.concurrent_with) && entry.concurrent_with.includes(currentTurn?.turn_id))
|
|
206
|
+
)
|
|
199
207
|
))
|
|
200
208
|
.sort((left, right) => left.accepted_sequence - right.accepted_sequence);
|
|
201
209
|
|
|
202
|
-
|
|
210
|
+
const pendingConcurrentSiblingDeclarations = Array.isArray(options.pendingConcurrentSiblingDeclarations)
|
|
211
|
+
? options.pendingConcurrentSiblingDeclarations
|
|
212
|
+
: [];
|
|
213
|
+
if (acceptedConcurrentSiblings.length === 0 && pendingConcurrentSiblingDeclarations.length === 0) {
|
|
203
214
|
return observation;
|
|
204
215
|
}
|
|
205
216
|
|
|
@@ -221,7 +232,20 @@ export function attributeObservedChangesToTurn(observation, currentTurn, history
|
|
|
221
232
|
}
|
|
222
233
|
}
|
|
223
234
|
|
|
224
|
-
|
|
235
|
+
const currentDeclaredFiles = new Set(
|
|
236
|
+
Array.isArray(options.currentDeclaredFiles) ? options.currentDeclaredFiles : [],
|
|
237
|
+
);
|
|
238
|
+
const pendingConcurrentSiblingFiles = new Set();
|
|
239
|
+
for (const declaration of pendingConcurrentSiblingDeclarations) {
|
|
240
|
+
const siblingFiles = Array.isArray(declaration?.files_changed) ? declaration.files_changed : [];
|
|
241
|
+
for (const filePath of siblingFiles) {
|
|
242
|
+
if (typeof filePath === 'string' && filePath.length > 0 && !currentDeclaredFiles.has(filePath)) {
|
|
243
|
+
pendingConcurrentSiblingFiles.add(filePath);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (siblingMarkersByFile.size === 0 && pendingConcurrentSiblingFiles.size === 0) {
|
|
225
249
|
return observation;
|
|
226
250
|
}
|
|
227
251
|
|
|
@@ -239,6 +263,10 @@ export function attributeObservedChangesToTurn(observation, currentTurn, history
|
|
|
239
263
|
attributedToConcurrentSiblings.push(filePath);
|
|
240
264
|
continue;
|
|
241
265
|
}
|
|
266
|
+
if (pendingConcurrentSiblingFiles.has(filePath)) {
|
|
267
|
+
attributedToConcurrentSiblings.push(filePath);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
242
270
|
nextFiles.push(filePath);
|
|
243
271
|
if (typeof currentMarker === 'string') {
|
|
244
272
|
nextMarkers[filePath] = currentMarker;
|
|
@@ -503,7 +531,13 @@ export function compareDeclaredVsObserved(declared, observed, writeAuthority, op
|
|
|
503
531
|
|
|
504
532
|
if (writeAuthority === 'authoritative') {
|
|
505
533
|
if (undeclared.length > 0) {
|
|
506
|
-
|
|
534
|
+
if (options.has_unaccepted_concurrent_siblings) {
|
|
535
|
+
// Concurrent siblings may have written these files; downgrade to warning.
|
|
536
|
+
// The attribution system will handle later-accepted siblings correctly.
|
|
537
|
+
warnings.push(`Undeclared file changes detected (likely from concurrent sibling turns): ${undeclared.join(', ')}`);
|
|
538
|
+
} else {
|
|
539
|
+
errors.push(`Undeclared file changes detected (observed but not in files_changed): ${undeclared.join(', ')}`);
|
|
540
|
+
}
|
|
507
541
|
}
|
|
508
542
|
if (phantom.length > 0) {
|
|
509
543
|
warnings.push(`Declared files not observed in actual diff: ${phantom.join(', ')}`);
|
package/src/lib/report.js
CHANGED
|
@@ -5,6 +5,7 @@ import { normalizeRunProvenance, summarizeRunProvenance } from './run-provenance
|
|
|
5
5
|
export const GOVERNANCE_REPORT_VERSION = '0.1';
|
|
6
6
|
|
|
7
7
|
const VALID_DELEGATION_OUTCOMES = new Set(['completed', 'failed', 'mixed', 'pending']);
|
|
8
|
+
const VALID_DASHBOARD_SESSION_STATUSES = new Set(['running', 'pid_only', 'stale', 'not_running']);
|
|
8
9
|
|
|
9
10
|
function normalizeDelegationSummary(summary) {
|
|
10
11
|
if (!summary || typeof summary !== 'object' || Array.isArray(summary)) return null;
|
|
@@ -58,6 +59,37 @@ function extractDelegationSummary(artifact) {
|
|
|
58
59
|
return normalizeDelegationSummary(buildDelegationSummary(artifact.files || {}));
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
function normalizeDashboardSessionSummary(summary) {
|
|
63
|
+
if (!summary || typeof summary !== 'object' || Array.isArray(summary)) return null;
|
|
64
|
+
if (!VALID_DASHBOARD_SESSION_STATUSES.has(summary.status)) return null;
|
|
65
|
+
return {
|
|
66
|
+
status: summary.status,
|
|
67
|
+
pid: Number.isInteger(summary.pid) ? summary.pid : null,
|
|
68
|
+
url: typeof summary.url === 'string' && summary.url.length > 0 ? summary.url : null,
|
|
69
|
+
started_at: typeof summary.started_at === 'string' && summary.started_at.length > 0 ? summary.started_at : null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function extractDashboardSessionSummary(artifact) {
|
|
74
|
+
return normalizeDashboardSessionSummary(artifact.summary?.dashboard_session);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatDashboardSessionLine(session) {
|
|
78
|
+
if (!session) return null;
|
|
79
|
+
switch (session.status) {
|
|
80
|
+
case 'running':
|
|
81
|
+
return `running at ${session.url || 'unknown url'} (PID: ${session.pid || '?'})`;
|
|
82
|
+
case 'pid_only':
|
|
83
|
+
return `pid_only (PID: ${session.pid || '?'}, session metadata missing)`;
|
|
84
|
+
case 'stale':
|
|
85
|
+
return `stale session files${session.pid ? ` (PID: ${session.pid})` : ''}${session.url ? ` at ${session.url}` : ''}`;
|
|
86
|
+
case 'not_running':
|
|
87
|
+
return 'not_running';
|
|
88
|
+
default:
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
61
93
|
function yesNo(value) {
|
|
62
94
|
return value ? 'yes' : 'no';
|
|
63
95
|
}
|
|
@@ -767,14 +799,28 @@ function extractBarrierSummary(artifact) {
|
|
|
767
799
|
return Object.entries(data)
|
|
768
800
|
.filter(([, b]) => b && typeof b === 'object' && !Array.isArray(b))
|
|
769
801
|
.sort(([a], [b]) => a.localeCompare(b, 'en'))
|
|
770
|
-
.map(([barrierId, b]) =>
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
802
|
+
.map(([barrierId, b]) => {
|
|
803
|
+
const entry = {
|
|
804
|
+
barrier_id: barrierId,
|
|
805
|
+
workstream_id: b.workstream_id || null,
|
|
806
|
+
type: b.type || 'unknown',
|
|
807
|
+
status: b.status || 'unknown',
|
|
808
|
+
required_repos: Array.isArray(b.required_repos) ? b.required_repos : [],
|
|
809
|
+
satisfied_repos: Array.isArray(b.satisfied_repos) ? b.satisfied_repos : [],
|
|
810
|
+
};
|
|
811
|
+
const decisionIds =
|
|
812
|
+
b.required_decision_ids_by_repo || b.alignment_decision_ids || null;
|
|
813
|
+
if (decisionIds && typeof decisionIds === 'object' && !Array.isArray(decisionIds)) {
|
|
814
|
+
entry.required_decision_ids_by_repo = decisionIds;
|
|
815
|
+
const satisfiedSet = new Set(entry.satisfied_repos);
|
|
816
|
+
const satisfiedByRepo = {};
|
|
817
|
+
for (const [repo, ids] of Object.entries(decisionIds)) {
|
|
818
|
+
satisfiedByRepo[repo] = satisfiedSet.has(repo) ? [...ids] : [];
|
|
819
|
+
}
|
|
820
|
+
entry.satisfied_decision_ids_by_repo = satisfiedByRepo;
|
|
821
|
+
}
|
|
822
|
+
return entry;
|
|
823
|
+
});
|
|
778
824
|
}
|
|
779
825
|
|
|
780
826
|
function summarizeBarrierTransition(entry) {
|
|
@@ -902,6 +948,7 @@ function buildRunSubject(artifact) {
|
|
|
902
948
|
const continuity = extractContinuityMetadata(artifact);
|
|
903
949
|
const governanceEvents = extractGovernanceEventDigest(artifact);
|
|
904
950
|
const delegationSummary = extractDelegationSummary(artifact);
|
|
951
|
+
const dashboardSession = extractDashboardSessionSummary(artifact);
|
|
905
952
|
|
|
906
953
|
return {
|
|
907
954
|
kind: 'governed_run',
|
|
@@ -928,6 +975,7 @@ function buildRunSubject(artifact) {
|
|
|
928
975
|
active_roles: activeRoles,
|
|
929
976
|
budget_status: normalizeBudgetStatus(artifact.state?.budget_status),
|
|
930
977
|
cost_summary: computeCostSummary(turns),
|
|
978
|
+
dashboard_session: dashboardSession,
|
|
931
979
|
created_at: timing.created_at,
|
|
932
980
|
completed_at: timing.completed_at,
|
|
933
981
|
duration_seconds: timing.duration_seconds,
|
|
@@ -1195,6 +1243,9 @@ export function formatGovernanceReportText(report) {
|
|
|
1195
1243
|
if (run.inherited_context?.parent_run_id) {
|
|
1196
1244
|
lines.push(`Inherited from: ${run.inherited_context.parent_run_id} (${run.inherited_context.parent_status || 'unknown'})`);
|
|
1197
1245
|
}
|
|
1246
|
+
if (run.dashboard_session) {
|
|
1247
|
+
lines.push(`Dashboard session: ${formatDashboardSessionLine(run.dashboard_session)}`);
|
|
1248
|
+
}
|
|
1198
1249
|
|
|
1199
1250
|
lines.push(
|
|
1200
1251
|
`History entries: ${artifacts.history_entries}`,
|
|
@@ -1442,6 +1493,17 @@ export function formatGovernanceReportText(report) {
|
|
|
1442
1493
|
const satisfied = b.satisfied_repos.length;
|
|
1443
1494
|
const required = b.required_repos.length;
|
|
1444
1495
|
lines.push(` - ${b.barrier_id}: ${b.status} (${b.type}, ${satisfied}/${required} repos satisfied, workstream ${b.workstream_id || 'unknown'})`);
|
|
1496
|
+
if (b.required_decision_ids_by_repo) {
|
|
1497
|
+
lines.push(' Decision requirements:');
|
|
1498
|
+
for (const [repo, ids] of Object.entries(b.required_decision_ids_by_repo)) {
|
|
1499
|
+
if (!Array.isArray(ids) || ids.length === 0) continue;
|
|
1500
|
+
const satisfiedIds = new Set(
|
|
1501
|
+
Array.isArray(b.satisfied_decision_ids_by_repo?.[repo]) ? b.satisfied_decision_ids_by_repo[repo] : []
|
|
1502
|
+
);
|
|
1503
|
+
const labels = ids.map((id) => `${id} (${satisfiedIds.has(id) ? 'satisfied' : 'pending'})`);
|
|
1504
|
+
lines.push(` ${repo}: ${labels.join(', ')}`);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1445
1507
|
}
|
|
1446
1508
|
}
|
|
1447
1509
|
|
|
@@ -1663,6 +1725,9 @@ export function formatGovernanceReportMarkdown(report) {
|
|
|
1663
1725
|
if (run.inherited_context?.parent_run_id) {
|
|
1664
1726
|
lines.push(`- Inherited from: \`${run.inherited_context.parent_run_id}\` (${run.inherited_context.parent_status || 'unknown'})`);
|
|
1665
1727
|
}
|
|
1728
|
+
if (run.dashboard_session) {
|
|
1729
|
+
lines.push(`- Dashboard session: \`${formatDashboardSessionLine(run.dashboard_session)}\``);
|
|
1730
|
+
}
|
|
1666
1731
|
|
|
1667
1732
|
lines.push(
|
|
1668
1733
|
`- History entries: ${artifacts.history_entries}`,
|
|
@@ -1917,6 +1982,20 @@ export function formatGovernanceReportMarkdown(report) {
|
|
|
1917
1982
|
for (const b of barrier_summary) {
|
|
1918
1983
|
mdLines.push(`| \`${b.barrier_id}\` | \`${b.workstream_id || 'unknown'}\` | \`${b.type}\` | \`${b.status}\` | ${b.satisfied_repos.length}/${b.required_repos.length} repos |`);
|
|
1919
1984
|
}
|
|
1985
|
+
const barriersWithDecisions = barrier_summary.filter((b) => b.required_decision_ids_by_repo);
|
|
1986
|
+
if (barriersWithDecisions.length > 0) {
|
|
1987
|
+
mdLines.push('', '### Decision Requirements');
|
|
1988
|
+
for (const b of barriersWithDecisions) {
|
|
1989
|
+
mdLines.push('', `**\`${b.barrier_id}\`** decision requirements:`, '', '| Repo | Required | Satisfied |', '|------|----------|-----------|');
|
|
1990
|
+
for (const [repo, ids] of Object.entries(b.required_decision_ids_by_repo)) {
|
|
1991
|
+
if (!Array.isArray(ids) || ids.length === 0) continue;
|
|
1992
|
+
const satisfiedIds = Array.isArray(b.satisfied_decision_ids_by_repo?.[repo]) ? b.satisfied_decision_ids_by_repo[repo] : [];
|
|
1993
|
+
const reqStr = ids.map((id) => `\`${id}\``).join(', ');
|
|
1994
|
+
const satStr = satisfiedIds.length > 0 ? satisfiedIds.map((id) => `\`${id}\``).join(', ') : '—';
|
|
1995
|
+
mdLines.push(`| \`${repo}\` | ${reqStr} | ${satStr} |`);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1920
1999
|
}
|
|
1921
2000
|
|
|
1922
2001
|
if (barrier_ledger_timeline && barrier_ledger_timeline.length > 0) {
|
package/src/lib/run-loop.js
CHANGED
|
@@ -217,7 +217,16 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
|
|
|
217
217
|
|
|
218
218
|
// If selectRole returns a role we already tried (or assigned), try
|
|
219
219
|
// other eligible roles from the routing before giving up.
|
|
220
|
+
// Exception: when delegation queue is driving resolution, do not fill
|
|
221
|
+
// extra slots with non-delegation roles via the fallback — those roles
|
|
222
|
+
// would execute without delegation context and corrupt the lifecycle.
|
|
220
223
|
if (triedRoles.has(roleId)) {
|
|
224
|
+
const hasPendingDelegations = Array.isArray(state?.delegation_queue) &&
|
|
225
|
+
state.delegation_queue.some(d => d.status === 'pending' || d.status === 'active');
|
|
226
|
+
const hasPendingReview = !!state?.pending_delegation_review;
|
|
227
|
+
if (hasPendingDelegations || hasPendingReview) {
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
221
230
|
const phase = state.phase;
|
|
222
231
|
const allowed = config?.routing?.[phase]?.allowed_next_roles || [];
|
|
223
232
|
const alternateFound = allowed.some((alt) => {
|
|
@@ -248,6 +257,13 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
|
|
|
248
257
|
turnsToDispatch.push({ turn: assignResult.turn, state: assignResult.state });
|
|
249
258
|
emit({ type: 'turn_assigned', turn: assignResult.turn, role: roleId, state: assignResult.state });
|
|
250
259
|
|
|
260
|
+
// Delegation review is a coordination checkpoint — do not fill additional
|
|
261
|
+
// slots alongside it. The review must execute alone so it can assess all
|
|
262
|
+
// delegation results before the run continues.
|
|
263
|
+
if (assignResult.turn.delegation_review) {
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
|
|
251
267
|
// Reload state after assignment to get accurate active count
|
|
252
268
|
state = loadState(root, config);
|
|
253
269
|
activeCount = getActiveTurnCount(state);
|