agentxchain 0.8.8 → 2.2.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/README.md +136 -136
- package/bin/agentxchain.js +186 -5
- package/dashboard/app.js +305 -0
- package/dashboard/components/blocked.js +145 -0
- package/dashboard/components/cross-repo.js +126 -0
- package/dashboard/components/gate.js +311 -0
- package/dashboard/components/hooks.js +177 -0
- package/dashboard/components/initiative.js +147 -0
- package/dashboard/components/ledger.js +165 -0
- package/dashboard/components/timeline.js +222 -0
- package/dashboard/index.html +352 -0
- package/package.json +14 -6
- package/scripts/live-api-proxy-preflight-smoke.sh +531 -0
- package/scripts/publish-from-tag.sh +88 -0
- package/scripts/release-postflight.sh +231 -0
- package/scripts/release-preflight.sh +167 -0
- package/src/commands/accept-turn.js +160 -0
- package/src/commands/approve-completion.js +80 -0
- package/src/commands/approve-transition.js +85 -0
- package/src/commands/dashboard.js +70 -0
- package/src/commands/init.js +516 -0
- package/src/commands/migrate.js +348 -0
- package/src/commands/multi.js +549 -0
- package/src/commands/plugin.js +157 -0
- package/src/commands/reject-turn.js +204 -0
- package/src/commands/resume.js +389 -0
- package/src/commands/status.js +196 -3
- package/src/commands/step.js +947 -0
- package/src/commands/template-list.js +33 -0
- package/src/commands/template-set.js +279 -0
- package/src/commands/validate.js +20 -11
- package/src/commands/verify.js +71 -0
- package/src/lib/adapters/api-proxy-adapter.js +1076 -0
- package/src/lib/adapters/local-cli-adapter.js +337 -0
- package/src/lib/adapters/manual-adapter.js +169 -0
- package/src/lib/blocked-state.js +94 -0
- package/src/lib/config.js +97 -1
- package/src/lib/context-compressor.js +121 -0
- package/src/lib/context-section-parser.js +220 -0
- package/src/lib/coordinator-acceptance.js +428 -0
- package/src/lib/coordinator-config.js +461 -0
- package/src/lib/coordinator-dispatch.js +276 -0
- package/src/lib/coordinator-gates.js +487 -0
- package/src/lib/coordinator-hooks.js +239 -0
- package/src/lib/coordinator-recovery.js +523 -0
- package/src/lib/coordinator-state.js +365 -0
- package/src/lib/cross-repo-context.js +247 -0
- package/src/lib/dashboard/bridge-server.js +284 -0
- package/src/lib/dashboard/file-watcher.js +93 -0
- package/src/lib/dashboard/state-reader.js +96 -0
- package/src/lib/dispatch-bundle.js +568 -0
- package/src/lib/dispatch-manifest.js +252 -0
- package/src/lib/gate-evaluator.js +285 -0
- package/src/lib/governed-state.js +2139 -0
- package/src/lib/governed-templates.js +145 -0
- package/src/lib/hook-runner.js +788 -0
- package/src/lib/normalized-config.js +539 -0
- package/src/lib/plugin-config-schema.js +192 -0
- package/src/lib/plugins.js +692 -0
- package/src/lib/protocol-conformance.js +291 -0
- package/src/lib/reference-conformance-adapter.js +858 -0
- package/src/lib/repo-observer.js +597 -0
- package/src/lib/repo.js +0 -31
- package/src/lib/schema.js +121 -0
- package/src/lib/schemas/turn-result.schema.json +205 -0
- package/src/lib/token-budget.js +206 -0
- package/src/lib/token-counter.js +27 -0
- package/src/lib/turn-paths.js +67 -0
- package/src/lib/turn-result-validator.js +496 -0
- package/src/lib/validation.js +137 -0
- package/src/templates/governed/api-service.json +31 -0
- package/src/templates/governed/cli-tool.json +30 -0
- package/src/templates/governed/generic.json +10 -0
- package/src/templates/governed/web-app.json +30 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coordinator divergence detection and state recovery.
|
|
3
|
+
*
|
|
4
|
+
* Compares coordinator expectations against repo-local authority and
|
|
5
|
+
* rebuilds coordinator state from explicit event contracts only.
|
|
6
|
+
*
|
|
7
|
+
* Design rules:
|
|
8
|
+
* - Repo-local state is always authoritative (DEC-MR-IMPL-004)
|
|
9
|
+
* - Recovery NEVER writes to repo-local .agentxchain/
|
|
10
|
+
* - Rebuild uses only explicit coordinator history event types
|
|
11
|
+
* - Ambiguous state forces blocked status, not silent repair
|
|
12
|
+
* - Pending gates survive safe resync; ambiguous gates force blocked
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, writeFileSync, existsSync, appendFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import {
|
|
18
|
+
loadCoordinatorState,
|
|
19
|
+
saveCoordinatorState,
|
|
20
|
+
readCoordinatorHistory,
|
|
21
|
+
readBarriers,
|
|
22
|
+
} from './coordinator-state.js';
|
|
23
|
+
import { safeWriteJson } from './safe-write.js';
|
|
24
|
+
|
|
25
|
+
// ── Paths ───────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const MULTIREPO_DIR = '.agentxchain/multirepo';
|
|
28
|
+
|
|
29
|
+
function barriersPath(ws) {
|
|
30
|
+
return join(ws, MULTIREPO_DIR, 'barriers.json');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function historyPath(ws) {
|
|
34
|
+
return join(ws, MULTIREPO_DIR, 'history.jsonl');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function barrierLedgerPath(ws) {
|
|
38
|
+
return join(ws, MULTIREPO_DIR, 'barrier-ledger.jsonl');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
function appendJsonl(filePath, entry) {
|
|
44
|
+
appendFileSync(filePath, JSON.stringify(entry) + '\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readRepoLocalState(repoPath) {
|
|
48
|
+
const stateFile = join(repoPath, '.agentxchain/state.json');
|
|
49
|
+
if (!existsSync(stateFile)) return null;
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(readFileSync(stateFile, 'utf8'));
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readRepoLocalHistory(repoPath) {
|
|
58
|
+
const file = join(repoPath, '.agentxchain/history.jsonl');
|
|
59
|
+
if (!existsSync(file)) return [];
|
|
60
|
+
try {
|
|
61
|
+
const content = readFileSync(file, 'utf8').trim();
|
|
62
|
+
if (!content) return [];
|
|
63
|
+
return content.split('\n').map(line => JSON.parse(line));
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Divergence Detection ────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Detect divergence between coordinator expectations and repo-local authority.
|
|
73
|
+
*
|
|
74
|
+
* Compares:
|
|
75
|
+
* - Coordinator repo_runs status vs actual repo-local state
|
|
76
|
+
* - Dispatched turns expected active vs repo-local acceptance/rejection
|
|
77
|
+
* - Coordinator phase expectations vs repo-local phase
|
|
78
|
+
*
|
|
79
|
+
* @param {string} workspacePath
|
|
80
|
+
* @param {object} state - current coordinator state
|
|
81
|
+
* @param {object} config - normalized coordinator config
|
|
82
|
+
* @returns {{ diverged: boolean, mismatches: object[] }}
|
|
83
|
+
*/
|
|
84
|
+
export function detectDivergence(workspacePath, state, config) {
|
|
85
|
+
const mismatches = [];
|
|
86
|
+
const history = readCoordinatorHistory(workspacePath);
|
|
87
|
+
|
|
88
|
+
for (const [repoId, repoRun] of Object.entries(state.repo_runs || {})) {
|
|
89
|
+
const repo = config.repos?.[repoId];
|
|
90
|
+
if (!repo?.resolved_path) {
|
|
91
|
+
mismatches.push({
|
|
92
|
+
type: 'repo_config_missing',
|
|
93
|
+
repo_id: repoId,
|
|
94
|
+
detail: `Repo "${repoId}" has no resolved_path in config`,
|
|
95
|
+
});
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const repoState = readRepoLocalState(repo.resolved_path);
|
|
100
|
+
if (!repoState) {
|
|
101
|
+
mismatches.push({
|
|
102
|
+
type: 'repo_state_missing',
|
|
103
|
+
repo_id: repoId,
|
|
104
|
+
detail: `Repo "${repoId}" has no .agentxchain/state.json`,
|
|
105
|
+
});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Run ID mismatch
|
|
110
|
+
if (repoRun.run_id && repoState.run_id && repoRun.run_id !== repoState.run_id) {
|
|
111
|
+
mismatches.push({
|
|
112
|
+
type: 'run_id_mismatch',
|
|
113
|
+
repo_id: repoId,
|
|
114
|
+
coordinator_run_id: repoRun.run_id,
|
|
115
|
+
repo_run_id: repoState.run_id,
|
|
116
|
+
detail: `Coordinator expects run "${repoRun.run_id}" but repo has "${repoState.run_id}"`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Repo completed but coordinator thinks it's active
|
|
121
|
+
if (repoState.status === 'completed' && repoRun.status !== 'completed') {
|
|
122
|
+
mismatches.push({
|
|
123
|
+
type: 'repo_completed_unexpectedly',
|
|
124
|
+
repo_id: repoId,
|
|
125
|
+
coordinator_status: repoRun.status,
|
|
126
|
+
repo_status: repoState.status,
|
|
127
|
+
detail: `Coordinator expects repo "${repoId}" as "${repoRun.status}" but repo is completed`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Repo blocked but coordinator doesn't know
|
|
132
|
+
if (repoState.status === 'blocked' && repoRun.status !== 'blocked') {
|
|
133
|
+
mismatches.push({
|
|
134
|
+
type: 'repo_blocked_unexpectedly',
|
|
135
|
+
repo_id: repoId,
|
|
136
|
+
coordinator_status: repoRun.status,
|
|
137
|
+
repo_status: repoState.status,
|
|
138
|
+
detail: `Coordinator expects repo "${repoId}" as "${repoRun.status}" but repo is blocked`,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check dispatched turns that coordinator thinks are active but repo has already accepted/rejected
|
|
144
|
+
const dispatchedTurns = history.filter(e => e?.type === 'turn_dispatched');
|
|
145
|
+
const projectedTurnIds = new Set(
|
|
146
|
+
history
|
|
147
|
+
.filter(e => e?.type === 'acceptance_projection')
|
|
148
|
+
.map(e => e.repo_turn_id)
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
for (const dispatch of dispatchedTurns) {
|
|
152
|
+
if (projectedTurnIds.has(dispatch.repo_turn_id)) continue; // Already projected
|
|
153
|
+
|
|
154
|
+
const repo = config.repos?.[dispatch.repo_id];
|
|
155
|
+
if (!repo?.resolved_path) continue;
|
|
156
|
+
|
|
157
|
+
const repoState = readRepoLocalState(repo.resolved_path);
|
|
158
|
+
if (!repoState) continue;
|
|
159
|
+
|
|
160
|
+
// Check if the turn is still active in the repo
|
|
161
|
+
const activeTurns = repoState.active_turns || {};
|
|
162
|
+
const turnStillActive = Object.keys(activeTurns).includes(dispatch.repo_turn_id);
|
|
163
|
+
|
|
164
|
+
if (!turnStillActive) {
|
|
165
|
+
// Turn is no longer active but coordinator hasn't projected it
|
|
166
|
+
// Read repo-local history to determine what happened
|
|
167
|
+
const repoHistory = readRepoLocalHistory(repo.resolved_path);
|
|
168
|
+
const turnAccepted = repoHistory.some(
|
|
169
|
+
e => e?.turn_id === dispatch.repo_turn_id && e?.status === 'accepted'
|
|
170
|
+
);
|
|
171
|
+
const turnRejected = repoHistory.some(
|
|
172
|
+
e => e?.turn_id === dispatch.repo_turn_id && e?.status === 'rejected'
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
mismatches.push({
|
|
176
|
+
type: turnAccepted ? 'turn_accepted_unprojected' : turnRejected ? 'turn_rejected_unprojected' : 'turn_disappeared',
|
|
177
|
+
repo_id: dispatch.repo_id,
|
|
178
|
+
turn_id: dispatch.repo_turn_id,
|
|
179
|
+
workstream_id: dispatch.workstream_id,
|
|
180
|
+
detail: turnAccepted
|
|
181
|
+
? `Turn "${dispatch.repo_turn_id}" accepted in repo "${dispatch.repo_id}" but not projected in coordinator`
|
|
182
|
+
: turnRejected
|
|
183
|
+
? `Turn "${dispatch.repo_turn_id}" rejected in repo "${dispatch.repo_id}" but coordinator has no record`
|
|
184
|
+
: `Turn "${dispatch.repo_turn_id}" no longer active in repo "${dispatch.repo_id}" for unknown reason`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
diverged: mismatches.length > 0,
|
|
191
|
+
mismatches,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Resync ──────────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Rebuild coordinator state from repo-local authority.
|
|
199
|
+
*
|
|
200
|
+
* Reads repo-local state and history, rebuilds coordinator projections
|
|
201
|
+
* and barriers from explicit event contracts only. Preserves pending
|
|
202
|
+
* gates when they are still coherent with repo state.
|
|
203
|
+
*
|
|
204
|
+
* Event contracts used for rebuild:
|
|
205
|
+
* - acceptance_projection (from coordinator history — kept if repo confirms)
|
|
206
|
+
* - turn_dispatched (from coordinator history — kept as-is)
|
|
207
|
+
* - phase_transition_requested / phase_transition_approved
|
|
208
|
+
* - run_completion_requested / run_completed
|
|
209
|
+
* - barrier_transition (from barrier-ledger.jsonl)
|
|
210
|
+
*
|
|
211
|
+
* @param {string} workspacePath
|
|
212
|
+
* @param {object} state - current coordinator state
|
|
213
|
+
* @param {object} config - normalized coordinator config
|
|
214
|
+
* @returns {{ ok: boolean, resynced_repos: string[], projected_acceptances: object[], barrier_changes: object[], errors: string[], blocked_reason?: string }}
|
|
215
|
+
*/
|
|
216
|
+
export function resyncFromRepoAuthority(workspacePath, state, config) {
|
|
217
|
+
const errors = [];
|
|
218
|
+
const resyncedRepos = [];
|
|
219
|
+
const projectedAcceptances = [];
|
|
220
|
+
const barrierChanges = [];
|
|
221
|
+
|
|
222
|
+
// Step 1: Refresh repo_runs from repo-local authority
|
|
223
|
+
const updatedRepoRuns = { ...state.repo_runs };
|
|
224
|
+
|
|
225
|
+
for (const [repoId, repoRun] of Object.entries(state.repo_runs || {})) {
|
|
226
|
+
const repo = config.repos?.[repoId];
|
|
227
|
+
if (!repo?.resolved_path) {
|
|
228
|
+
errors.push(`Repo "${repoId}" has no resolved_path in config`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const repoState = readRepoLocalState(repo.resolved_path);
|
|
233
|
+
if (!repoState) {
|
|
234
|
+
errors.push(`Repo "${repoId}" state unreadable`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const changes = {};
|
|
239
|
+
|
|
240
|
+
// Update run_id if it changed (e.g., repo was re-initialized outside coordinator)
|
|
241
|
+
if (repoState.run_id && repoState.run_id !== repoRun.run_id) {
|
|
242
|
+
changes.run_id = repoState.run_id;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Update status from repo-local authority
|
|
246
|
+
if (repoState.status === 'completed' && repoRun.status !== 'completed') {
|
|
247
|
+
changes.status = 'completed';
|
|
248
|
+
} else if (repoState.status === 'blocked') {
|
|
249
|
+
changes.status = 'blocked';
|
|
250
|
+
} else if (repoState.status === 'active') {
|
|
251
|
+
changes.status = repoRun.initialized_by_coordinator ? 'initialized' : 'linked';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Update phase
|
|
255
|
+
if (repoState.phase && repoState.phase !== repoRun.phase) {
|
|
256
|
+
changes.phase = repoState.phase;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (Object.keys(changes).length > 0) {
|
|
260
|
+
updatedRepoRuns[repoId] = { ...repoRun, ...changes };
|
|
261
|
+
resyncedRepos.push(repoId);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Step 2: Rebuild projections for unprojected accepted turns
|
|
266
|
+
const history = readCoordinatorHistory(workspacePath);
|
|
267
|
+
const projectedTurnIds = new Set(
|
|
268
|
+
history
|
|
269
|
+
.filter(e => e?.type === 'acceptance_projection')
|
|
270
|
+
.map(e => e.repo_turn_id)
|
|
271
|
+
);
|
|
272
|
+
const dispatchedTurns = history.filter(e => e?.type === 'turn_dispatched');
|
|
273
|
+
|
|
274
|
+
for (const dispatch of dispatchedTurns) {
|
|
275
|
+
if (projectedTurnIds.has(dispatch.repo_turn_id)) continue;
|
|
276
|
+
|
|
277
|
+
const repo = config.repos?.[dispatch.repo_id];
|
|
278
|
+
if (!repo?.resolved_path) continue;
|
|
279
|
+
|
|
280
|
+
const repoHistory = readRepoLocalHistory(repo.resolved_path);
|
|
281
|
+
const acceptedEntry = repoHistory.find(
|
|
282
|
+
e => e?.turn_id === dispatch.repo_turn_id && e?.status === 'accepted'
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
if (acceptedEntry) {
|
|
286
|
+
// Create a recovery projection
|
|
287
|
+
const projection = {
|
|
288
|
+
type: 'acceptance_projection',
|
|
289
|
+
timestamp: new Date().toISOString(),
|
|
290
|
+
super_run_id: state.super_run_id,
|
|
291
|
+
projection_ref: `proj_recovery_${dispatch.repo_id}_${Date.now()}`,
|
|
292
|
+
workstream_id: dispatch.workstream_id,
|
|
293
|
+
repo_id: dispatch.repo_id,
|
|
294
|
+
repo_run_id: dispatch.repo_run_id,
|
|
295
|
+
repo_turn_id: dispatch.repo_turn_id,
|
|
296
|
+
summary: acceptedEntry.summary ?? '',
|
|
297
|
+
files_changed: acceptedEntry.files_changed ?? [],
|
|
298
|
+
decisions: acceptedEntry.decisions ?? [],
|
|
299
|
+
verification: acceptedEntry.verification ?? null,
|
|
300
|
+
recovery: true,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
appendJsonl(historyPath(workspacePath), projection);
|
|
304
|
+
resyncedRepos.push(dispatch.repo_id);
|
|
305
|
+
projectedAcceptances.push({
|
|
306
|
+
repo_id: dispatch.repo_id,
|
|
307
|
+
repo_turn_id: dispatch.repo_turn_id,
|
|
308
|
+
workstream_id: dispatch.workstream_id,
|
|
309
|
+
projection_ref: projection.projection_ref,
|
|
310
|
+
summary: acceptedEntry.summary ?? '',
|
|
311
|
+
files_changed: acceptedEntry.files_changed ?? [],
|
|
312
|
+
decisions: acceptedEntry.decisions ?? [],
|
|
313
|
+
verification: acceptedEntry.verification ?? null,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Step 3: Rebuild barrier snapshot from coordinator history (including new recovery projections)
|
|
319
|
+
const fullHistory = readCoordinatorHistory(workspacePath); // Re-read after appending
|
|
320
|
+
const barriers = readBarriers(workspacePath);
|
|
321
|
+
let barriersChanged = false;
|
|
322
|
+
|
|
323
|
+
for (const [barrierId, barrier] of Object.entries(barriers)) {
|
|
324
|
+
if (barrier.status === 'satisfied') continue;
|
|
325
|
+
if (barrier.type === 'shared_human_gate') continue; // Never auto-transition
|
|
326
|
+
|
|
327
|
+
const newStatus = recomputeBarrierStatus(barrier, fullHistory, config);
|
|
328
|
+
if (newStatus !== barrier.status) {
|
|
329
|
+
const previousStatus = barrier.status;
|
|
330
|
+
barrierChanges.push({
|
|
331
|
+
barrier_id: barrierId,
|
|
332
|
+
previous_status: previousStatus,
|
|
333
|
+
new_status: newStatus,
|
|
334
|
+
workstream_id: barrier.workstream_id,
|
|
335
|
+
type: barrier.type,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
barrier.status = newStatus;
|
|
339
|
+
|
|
340
|
+
// Update satisfied_repos for tracking
|
|
341
|
+
if (barrier.type === 'all_repos_accepted') {
|
|
342
|
+
barrier.satisfied_repos = getAcceptedReposForWorkstream(
|
|
343
|
+
fullHistory, barrier.workstream_id, barrier.required_repos
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
barriersChanged = true;
|
|
348
|
+
|
|
349
|
+
appendJsonl(barrierLedgerPath(workspacePath), {
|
|
350
|
+
type: 'barrier_transition',
|
|
351
|
+
timestamp: new Date().toISOString(),
|
|
352
|
+
barrier_id: barrierId,
|
|
353
|
+
previous_status: previousStatus,
|
|
354
|
+
new_status: newStatus,
|
|
355
|
+
causation: {
|
|
356
|
+
super_run_id: state.super_run_id,
|
|
357
|
+
trigger: 'resync',
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (barriersChanged) {
|
|
364
|
+
safeWriteJson(barriersPath(workspacePath), barriers);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Step 4: Validate pending_gate coherence
|
|
368
|
+
let blockedReason = null;
|
|
369
|
+
const pendingGate = state.pending_gate;
|
|
370
|
+
|
|
371
|
+
if (pendingGate) {
|
|
372
|
+
const gateCoherent = validatePendingGateCoherence(pendingGate, updatedRepoRuns, config);
|
|
373
|
+
if (!gateCoherent.ok) {
|
|
374
|
+
blockedReason = gateCoherent.reason;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Step 5: Update coordinator state
|
|
379
|
+
const newStatus = blockedReason ? 'blocked' : state.status;
|
|
380
|
+
const updatedState = {
|
|
381
|
+
...state,
|
|
382
|
+
repo_runs: updatedRepoRuns,
|
|
383
|
+
status: newStatus,
|
|
384
|
+
blocked_reason: blockedReason || undefined,
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
// Clean up blocked_reason if no longer blocked
|
|
388
|
+
if (!blockedReason && updatedState.blocked_reason) {
|
|
389
|
+
delete updatedState.blocked_reason;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
saveCoordinatorState(workspacePath, updatedState);
|
|
393
|
+
|
|
394
|
+
// Step 6: Append resync event to history
|
|
395
|
+
appendJsonl(historyPath(workspacePath), {
|
|
396
|
+
type: 'state_resynced',
|
|
397
|
+
timestamp: new Date().toISOString(),
|
|
398
|
+
super_run_id: state.super_run_id,
|
|
399
|
+
resynced_repos: resyncedRepos,
|
|
400
|
+
barrier_changes: barrierChanges,
|
|
401
|
+
blocked_reason: blockedReason || null,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
ok: !blockedReason,
|
|
406
|
+
resynced_repos: [...new Set(resyncedRepos)],
|
|
407
|
+
projected_acceptances: projectedAcceptances,
|
|
408
|
+
barrier_changes: barrierChanges,
|
|
409
|
+
errors,
|
|
410
|
+
blocked_reason: blockedReason || undefined,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ── Internal helpers ────────────────────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
function getAcceptedReposForWorkstream(history, workstreamId, requiredRepos) {
|
|
417
|
+
const accepted = new Set();
|
|
418
|
+
for (const entry of history) {
|
|
419
|
+
if (entry?.type === 'acceptance_projection' && entry.workstream_id === workstreamId) {
|
|
420
|
+
if (requiredRepos.includes(entry.repo_id)) {
|
|
421
|
+
accepted.add(entry.repo_id);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return [...accepted];
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function recomputeBarrierStatus(barrier, history, config) {
|
|
429
|
+
switch (barrier.type) {
|
|
430
|
+
case 'all_repos_accepted': {
|
|
431
|
+
const required = new Set(barrier.required_repos);
|
|
432
|
+
const satisfied = new Set();
|
|
433
|
+
for (const entry of history) {
|
|
434
|
+
if (entry?.type === 'acceptance_projection' && entry.workstream_id === barrier.workstream_id) {
|
|
435
|
+
if (required.has(entry.repo_id)) {
|
|
436
|
+
satisfied.add(entry.repo_id);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (satisfied.size === required.size) return 'satisfied';
|
|
441
|
+
if (satisfied.size > 0) return 'partially_satisfied';
|
|
442
|
+
return 'pending';
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
case 'ordered_repo_sequence': {
|
|
446
|
+
const workstream = config.workstreams?.[barrier.workstream_id];
|
|
447
|
+
if (!workstream) return barrier.status;
|
|
448
|
+
const entryRepo = workstream.entry_repo;
|
|
449
|
+
const hasUpstream = history.some(
|
|
450
|
+
e => e?.type === 'acceptance_projection'
|
|
451
|
+
&& e.workstream_id === barrier.workstream_id
|
|
452
|
+
&& e.repo_id === entryRepo
|
|
453
|
+
);
|
|
454
|
+
if (hasUpstream) return 'satisfied';
|
|
455
|
+
const anyDownstream = history.some(
|
|
456
|
+
e => e?.type === 'acceptance_projection'
|
|
457
|
+
&& e.workstream_id === barrier.workstream_id
|
|
458
|
+
&& e.repo_id !== entryRepo
|
|
459
|
+
);
|
|
460
|
+
if (anyDownstream) return 'partially_satisfied';
|
|
461
|
+
return 'pending';
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
case 'interface_alignment': {
|
|
465
|
+
const required = new Set(barrier.required_repos);
|
|
466
|
+
const acceptedRepos = new Set();
|
|
467
|
+
for (const entry of history) {
|
|
468
|
+
if (entry?.type === 'acceptance_projection' && entry.workstream_id === barrier.workstream_id) {
|
|
469
|
+
if (required.has(entry.repo_id)) acceptedRepos.add(entry.repo_id);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (acceptedRepos.size === 0) return 'pending';
|
|
473
|
+
if (acceptedRepos.size < required.size) return 'partially_satisfied';
|
|
474
|
+
return 'satisfied';
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
case 'shared_human_gate':
|
|
478
|
+
return barrier.status; // Never auto-transition
|
|
479
|
+
|
|
480
|
+
default:
|
|
481
|
+
return barrier.status;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Validate that a pending gate is still coherent with repo state.
|
|
487
|
+
*
|
|
488
|
+
* For phase transitions: all required repos must still exist and not have
|
|
489
|
+
* moved to an unexpected state.
|
|
490
|
+
*
|
|
491
|
+
* For completion: all required repos must still be completion-ready.
|
|
492
|
+
*/
|
|
493
|
+
function validatePendingGateCoherence(pendingGate, repoRuns, config) {
|
|
494
|
+
if (pendingGate.gate_type === 'phase_transition') {
|
|
495
|
+
// Check that required repos haven't entered a state that invalidates the gate
|
|
496
|
+
for (const repoId of pendingGate.required_repos || []) {
|
|
497
|
+
const repoRun = repoRuns[repoId];
|
|
498
|
+
if (!repoRun) {
|
|
499
|
+
return { ok: false, reason: `Phase gate "${pendingGate.gate}" invalid: repo "${repoId}" missing from coordinator state` };
|
|
500
|
+
}
|
|
501
|
+
// A repo going blocked during a pending gate makes the gate ambiguous
|
|
502
|
+
if (repoRun.status === 'blocked') {
|
|
503
|
+
return { ok: false, reason: `Phase gate "${pendingGate.gate}" ambiguous: repo "${repoId}" is now blocked` };
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return { ok: true };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (pendingGate.gate_type === 'run_completion') {
|
|
510
|
+
for (const repoId of pendingGate.required_repos || []) {
|
|
511
|
+
const repoRun = repoRuns[repoId];
|
|
512
|
+
if (!repoRun) {
|
|
513
|
+
return { ok: false, reason: `Completion gate invalid: repo "${repoId}" missing from coordinator state` };
|
|
514
|
+
}
|
|
515
|
+
if (repoRun.status === 'blocked') {
|
|
516
|
+
return { ok: false, reason: `Completion gate ambiguous: repo "${repoId}" is now blocked` };
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return { ok: true };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return { ok: true };
|
|
523
|
+
}
|