agentxchain 2.14.0 → 2.15.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 +7 -3
- package/bin/agentxchain.js +19 -2
- package/package.json +1 -1
- package/scripts/release-downstream-truth.sh +15 -14
- package/src/commands/intake-approve.js +2 -10
- package/src/commands/intake-handoff.js +58 -0
- package/src/commands/intake-plan.js +2 -11
- package/src/commands/intake-record.js +2 -10
- package/src/commands/intake-resolve.js +2 -10
- package/src/commands/intake-scan.js +2 -10
- package/src/commands/intake-start.js +2 -10
- package/src/commands/intake-status.js +6 -10
- package/src/commands/intake-triage.js +2 -10
- package/src/commands/intake-workspace.js +58 -0
- package/src/commands/multi.js +58 -2
- package/src/lib/coordinator-acceptance.js +24 -98
- package/src/lib/coordinator-barriers.js +116 -0
- package/src/lib/coordinator-config.js +93 -0
- package/src/lib/coordinator-recovery.js +123 -68
- package/src/lib/coordinator-state.js +1 -0
- package/src/lib/cross-repo-context.js +68 -1
- package/src/lib/intake-handoff.js +58 -0
- package/src/lib/intake.js +300 -11
|
@@ -17,6 +17,11 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } fr
|
|
|
17
17
|
import { isAbsolute, join, dirname, relative, resolve } from 'node:path';
|
|
18
18
|
import { randomBytes } from 'node:crypto';
|
|
19
19
|
import { readBarriers, readCoordinatorHistory, saveCoordinatorState } from './coordinator-state.js';
|
|
20
|
+
import {
|
|
21
|
+
computeBarrierStatus as computeCoordinatorBarrierStatus,
|
|
22
|
+
getAcceptedReposForWorkstream,
|
|
23
|
+
getAlignedReposForBarrier,
|
|
24
|
+
} from './coordinator-barriers.js';
|
|
20
25
|
|
|
21
26
|
// ── Paths ───────────────────────────────────────────────────────────────────
|
|
22
27
|
|
|
@@ -238,100 +243,8 @@ export function recordBarrierTransition(workspacePath, barrierId, previousStatus
|
|
|
238
243
|
});
|
|
239
244
|
}
|
|
240
245
|
|
|
241
|
-
// ── Internal barrier logic ──────────────────────────────────────────────────
|
|
242
|
-
|
|
243
|
-
function getAcceptedReposForWorkstream(history, workstreamId, requiredRepos) {
|
|
244
|
-
const accepted = new Set();
|
|
245
|
-
for (const entry of history) {
|
|
246
|
-
if (entry?.type === 'acceptance_projection' && entry.workstream_id === workstreamId) {
|
|
247
|
-
if (requiredRepos.includes(entry.repo_id)) {
|
|
248
|
-
accepted.add(entry.repo_id);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
return [...accepted];
|
|
253
|
-
}
|
|
254
|
-
|
|
255
246
|
function computeBarrierStatus(barrier, history, config) {
|
|
256
|
-
|
|
257
|
-
case 'all_repos_accepted':
|
|
258
|
-
return computeAllReposAccepted(barrier, history);
|
|
259
|
-
|
|
260
|
-
case 'ordered_repo_sequence':
|
|
261
|
-
return computeOrderedRepoSequence(barrier, history, config);
|
|
262
|
-
|
|
263
|
-
case 'interface_alignment':
|
|
264
|
-
return computeInterfaceAlignment(barrier, history);
|
|
265
|
-
|
|
266
|
-
case 'shared_human_gate':
|
|
267
|
-
// Never auto-satisfied — requires explicit human approval
|
|
268
|
-
return barrier.status;
|
|
269
|
-
|
|
270
|
-
default:
|
|
271
|
-
return barrier.status;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function computeAllReposAccepted(barrier, history) {
|
|
276
|
-
const required = new Set(barrier.required_repos);
|
|
277
|
-
const satisfied = new Set();
|
|
278
|
-
|
|
279
|
-
for (const entry of history) {
|
|
280
|
-
if (entry?.type === 'acceptance_projection' && entry.workstream_id === barrier.workstream_id) {
|
|
281
|
-
if (required.has(entry.repo_id)) {
|
|
282
|
-
satisfied.add(entry.repo_id);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
if (satisfied.size === required.size) return 'satisfied';
|
|
288
|
-
if (satisfied.size > 0) return 'partially_satisfied';
|
|
289
|
-
return 'pending';
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function computeOrderedRepoSequence(barrier, history, config) {
|
|
293
|
-
const workstream = config.workstreams?.[barrier.workstream_id];
|
|
294
|
-
if (!workstream) return barrier.status;
|
|
295
|
-
|
|
296
|
-
// Upstream is entry_repo. The barrier is satisfied when entry_repo has an accepted turn.
|
|
297
|
-
const entryRepo = workstream.entry_repo;
|
|
298
|
-
const hasUpstreamAcceptance = history.some(
|
|
299
|
-
entry => entry?.type === 'acceptance_projection'
|
|
300
|
-
&& entry.workstream_id === barrier.workstream_id
|
|
301
|
-
&& entry.repo_id === entryRepo
|
|
302
|
-
);
|
|
303
|
-
|
|
304
|
-
if (hasUpstreamAcceptance) return 'satisfied';
|
|
305
|
-
|
|
306
|
-
// Check if any non-entry repos have been accepted (partial)
|
|
307
|
-
const anyDownstreamAccepted = history.some(
|
|
308
|
-
entry => entry?.type === 'acceptance_projection'
|
|
309
|
-
&& entry.workstream_id === barrier.workstream_id
|
|
310
|
-
&& entry.repo_id !== entryRepo
|
|
311
|
-
);
|
|
312
|
-
|
|
313
|
-
if (anyDownstreamAccepted) return 'partially_satisfied';
|
|
314
|
-
return 'pending';
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function computeInterfaceAlignment(barrier, history) {
|
|
318
|
-
const required = new Set(barrier.required_repos);
|
|
319
|
-
const repoDecisions = {};
|
|
320
|
-
|
|
321
|
-
for (const entry of history) {
|
|
322
|
-
if (entry?.type === 'acceptance_projection' && entry.workstream_id === barrier.workstream_id) {
|
|
323
|
-
if (required.has(entry.repo_id)) {
|
|
324
|
-
repoDecisions[entry.repo_id] = entry.decisions ?? [];
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
const acceptedRepos = Object.keys(repoDecisions);
|
|
330
|
-
if (acceptedRepos.length === 0) return 'pending';
|
|
331
|
-
if (acceptedRepos.length < required.size) return 'partially_satisfied';
|
|
332
|
-
|
|
333
|
-
// All repos accepted — check for DEC-* alignment (heuristic)
|
|
334
|
-
return 'satisfied';
|
|
247
|
+
return computeCoordinatorBarrierStatus(barrier, history, config);
|
|
335
248
|
}
|
|
336
249
|
|
|
337
250
|
// ── Barrier effects from a single acceptance ────────────────────────────────
|
|
@@ -340,19 +253,32 @@ function evaluateBarrierEffects(workspacePath, state, config, repoId, workstream
|
|
|
340
253
|
const barriers = readBarriers(workspacePath);
|
|
341
254
|
const history = readCoordinatorHistory(workspacePath);
|
|
342
255
|
const effects = [];
|
|
256
|
+
let snapshotChanged = false;
|
|
343
257
|
|
|
344
258
|
for (const [barrierId, barrier] of Object.entries(barriers)) {
|
|
345
259
|
if (barrier.status === 'satisfied') continue;
|
|
346
260
|
if (barrier.workstream_id !== workstreamId) continue;
|
|
347
261
|
|
|
348
262
|
const newStatus = computeBarrierStatus(barrier, history, config);
|
|
263
|
+
if (barrier.type === 'all_repos_accepted') {
|
|
264
|
+
const satisfiedRepos = getAcceptedReposForWorkstream(history, workstreamId, barrier.required_repos);
|
|
265
|
+
if (JSON.stringify(barrier.satisfied_repos || []) !== JSON.stringify(satisfiedRepos)) {
|
|
266
|
+
barrier.satisfied_repos = satisfiedRepos;
|
|
267
|
+
snapshotChanged = true;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (barrier.type === 'interface_alignment') {
|
|
271
|
+
const satisfiedRepos = getAlignedReposForBarrier(barrier, history);
|
|
272
|
+
if (JSON.stringify(barrier.satisfied_repos || []) !== JSON.stringify(satisfiedRepos)) {
|
|
273
|
+
barrier.satisfied_repos = satisfiedRepos;
|
|
274
|
+
snapshotChanged = true;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
349
278
|
if (newStatus !== barrier.status) {
|
|
350
279
|
const previousStatus = barrier.status;
|
|
351
280
|
barrier.status = newStatus;
|
|
352
|
-
|
|
353
|
-
if (barrier.type === 'all_repos_accepted') {
|
|
354
|
-
barrier.satisfied_repos = getAcceptedReposForWorkstream(history, workstreamId, barrier.required_repos);
|
|
355
|
-
}
|
|
281
|
+
snapshotChanged = true;
|
|
356
282
|
|
|
357
283
|
effects.push({
|
|
358
284
|
barrier_id: barrierId,
|
|
@@ -370,7 +296,7 @@ function evaluateBarrierEffects(workspacePath, state, config, repoId, workstream
|
|
|
370
296
|
}
|
|
371
297
|
|
|
372
298
|
// Persist updated barrier snapshot
|
|
373
|
-
if (
|
|
299
|
+
if (snapshotChanged) {
|
|
374
300
|
writeFileSync(barriersPath(workspacePath), JSON.stringify(barriers, null, 2) + '\n');
|
|
375
301
|
}
|
|
376
302
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
function isProjectionEntry(entry, workstreamId) {
|
|
2
|
+
return entry?.type === 'acceptance_projection' && entry.workstream_id === workstreamId;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function normalizeDecisionId(decision) {
|
|
6
|
+
if (typeof decision === 'string') return decision;
|
|
7
|
+
if (decision && typeof decision === 'object' && typeof decision.id === 'string') return decision.id;
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function collectAcceptedDecisionIds(history, workstreamId, requiredRepos = []) {
|
|
12
|
+
const required = new Set(requiredRepos);
|
|
13
|
+
const repoDecisionIds = {};
|
|
14
|
+
const acceptedRepos = new Set();
|
|
15
|
+
|
|
16
|
+
for (const entry of history) {
|
|
17
|
+
if (!isProjectionEntry(entry, workstreamId)) continue;
|
|
18
|
+
if (required.size > 0 && !required.has(entry.repo_id)) continue;
|
|
19
|
+
|
|
20
|
+
acceptedRepos.add(entry.repo_id);
|
|
21
|
+
if (!repoDecisionIds[entry.repo_id]) {
|
|
22
|
+
repoDecisionIds[entry.repo_id] = new Set();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const decision of Array.isArray(entry.decisions) ? entry.decisions : []) {
|
|
26
|
+
const id = normalizeDecisionId(decision);
|
|
27
|
+
if (id) {
|
|
28
|
+
repoDecisionIds[entry.repo_id].add(id);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { acceptedRepos, repoDecisionIds };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getAcceptedReposForWorkstream(history, workstreamId, requiredRepos = []) {
|
|
37
|
+
return [
|
|
38
|
+
...collectAcceptedDecisionIds(history, workstreamId, requiredRepos).acceptedRepos,
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getAlignedReposForBarrier(barrier, history) {
|
|
43
|
+
const requiredRepos = Array.isArray(barrier.required_repos) ? barrier.required_repos : [];
|
|
44
|
+
const alignmentDecisionIds = barrier.alignment_decision_ids || {};
|
|
45
|
+
const { repoDecisionIds } = collectAcceptedDecisionIds(history, barrier.workstream_id, requiredRepos);
|
|
46
|
+
const alignedRepos = [];
|
|
47
|
+
|
|
48
|
+
for (const repoId of requiredRepos) {
|
|
49
|
+
const requiredIds = Array.isArray(alignmentDecisionIds[repoId]) ? alignmentDecisionIds[repoId] : [];
|
|
50
|
+
if (requiredIds.length === 0) continue;
|
|
51
|
+
|
|
52
|
+
const acceptedIds = repoDecisionIds[repoId] || new Set();
|
|
53
|
+
if (requiredIds.every((decisionId) => acceptedIds.has(decisionId))) {
|
|
54
|
+
alignedRepos.push(repoId);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return alignedRepos;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function computeAllReposAcceptedStatus(barrier, history) {
|
|
62
|
+
const requiredRepos = Array.isArray(barrier.required_repos) ? barrier.required_repos : [];
|
|
63
|
+
const acceptedRepos = getAcceptedReposForWorkstream(history, barrier.workstream_id, requiredRepos);
|
|
64
|
+
|
|
65
|
+
if (acceptedRepos.length === requiredRepos.length && requiredRepos.length > 0) return 'satisfied';
|
|
66
|
+
if (acceptedRepos.length > 0) return 'partially_satisfied';
|
|
67
|
+
return 'pending';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function computeOrderedRepoSequenceStatus(barrier, history, config) {
|
|
71
|
+
const workstream = config.workstreams?.[barrier.workstream_id];
|
|
72
|
+
if (!workstream) return barrier.status;
|
|
73
|
+
|
|
74
|
+
const entryRepo = workstream.entry_repo;
|
|
75
|
+
const hasUpstreamAcceptance = history.some(
|
|
76
|
+
(entry) => isProjectionEntry(entry, barrier.workstream_id) && entry.repo_id === entryRepo,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
if (hasUpstreamAcceptance) return 'satisfied';
|
|
80
|
+
|
|
81
|
+
const anyDownstreamAccepted = history.some(
|
|
82
|
+
(entry) => isProjectionEntry(entry, barrier.workstream_id) && entry.repo_id !== entryRepo,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (anyDownstreamAccepted) return 'partially_satisfied';
|
|
86
|
+
return 'pending';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function computeInterfaceAlignmentStatus(barrier, history) {
|
|
90
|
+
const requiredRepos = Array.isArray(barrier.required_repos) ? barrier.required_repos : [];
|
|
91
|
+
const alignedRepos = getAlignedReposForBarrier(barrier, history);
|
|
92
|
+
const acceptedRepos = getAcceptedReposForWorkstream(history, barrier.workstream_id, requiredRepos);
|
|
93
|
+
|
|
94
|
+
if (alignedRepos.length === requiredRepos.length && requiredRepos.length > 0) return 'satisfied';
|
|
95
|
+
if (acceptedRepos.length > 0) return 'partially_satisfied';
|
|
96
|
+
return 'pending';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function computeBarrierStatus(barrier, history, config) {
|
|
100
|
+
switch (barrier.type) {
|
|
101
|
+
case 'all_repos_accepted':
|
|
102
|
+
return computeAllReposAcceptedStatus(barrier, history);
|
|
103
|
+
|
|
104
|
+
case 'ordered_repo_sequence':
|
|
105
|
+
return computeOrderedRepoSequenceStatus(barrier, history, config);
|
|
106
|
+
|
|
107
|
+
case 'interface_alignment':
|
|
108
|
+
return computeInterfaceAlignmentStatus(barrier, history);
|
|
109
|
+
|
|
110
|
+
case 'shared_human_gate':
|
|
111
|
+
return barrier.status;
|
|
112
|
+
|
|
113
|
+
default:
|
|
114
|
+
return barrier.status;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -146,12 +146,95 @@ function validateWorkstreams(raw, repoIds, errors) {
|
|
|
146
146
|
`workstream "${workstreamId}" completion_barrier must be one of: ${Array.from(VALID_BARRIER_TYPES).join(', ')}`,
|
|
147
147
|
);
|
|
148
148
|
}
|
|
149
|
+
|
|
150
|
+
validateInterfaceAlignment(workstreamId, workstream, errors);
|
|
149
151
|
}
|
|
150
152
|
|
|
151
153
|
detectWorkstreamCycles(raw.workstreams, errors);
|
|
152
154
|
return workstreamIds;
|
|
153
155
|
}
|
|
154
156
|
|
|
157
|
+
function validateInterfaceAlignment(workstreamId, workstream, errors) {
|
|
158
|
+
if (workstream.completion_barrier !== 'interface_alignment') {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const alignment = workstream.interface_alignment;
|
|
163
|
+
if (!alignment || typeof alignment !== 'object' || Array.isArray(alignment)) {
|
|
164
|
+
pushError(
|
|
165
|
+
errors,
|
|
166
|
+
'workstream_interface_alignment_invalid',
|
|
167
|
+
`workstream "${workstreamId}" with completion_barrier "interface_alignment" must declare interface_alignment.decision_ids_by_repo`,
|
|
168
|
+
);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const byRepo = alignment.decision_ids_by_repo;
|
|
173
|
+
if (!byRepo || typeof byRepo !== 'object' || Array.isArray(byRepo)) {
|
|
174
|
+
pushError(
|
|
175
|
+
errors,
|
|
176
|
+
'workstream_interface_alignment_decisions_invalid',
|
|
177
|
+
`workstream "${workstreamId}" interface_alignment.decision_ids_by_repo must be an object`,
|
|
178
|
+
);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const repoIds = Array.isArray(workstream.repos) ? workstream.repos : [];
|
|
183
|
+
const repoIdSet = new Set(repoIds);
|
|
184
|
+
|
|
185
|
+
for (const repoId of repoIds) {
|
|
186
|
+
if (!(repoId in byRepo)) {
|
|
187
|
+
pushError(
|
|
188
|
+
errors,
|
|
189
|
+
'workstream_interface_alignment_repo_missing',
|
|
190
|
+
`workstream "${workstreamId}" must declare interface_alignment.decision_ids_by_repo["${repoId}"]`,
|
|
191
|
+
);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const decisionIds = byRepo[repoId];
|
|
196
|
+
if (!Array.isArray(decisionIds) || decisionIds.length === 0) {
|
|
197
|
+
pushError(
|
|
198
|
+
errors,
|
|
199
|
+
'workstream_interface_alignment_repo_invalid',
|
|
200
|
+
`workstream "${workstreamId}" interface_alignment.decision_ids_by_repo["${repoId}"] must be a non-empty array`,
|
|
201
|
+
);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
for (const decisionId of decisionIds) {
|
|
207
|
+
if (typeof decisionId !== 'string' || !/^DEC-\d+$/.test(decisionId)) {
|
|
208
|
+
pushError(
|
|
209
|
+
errors,
|
|
210
|
+
'workstream_interface_alignment_decision_invalid',
|
|
211
|
+
`workstream "${workstreamId}" interface_alignment decision "${decisionId}" for repo "${repoId}" must match DEC-NNN`,
|
|
212
|
+
);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (seen.has(decisionId)) {
|
|
216
|
+
pushError(
|
|
217
|
+
errors,
|
|
218
|
+
'workstream_interface_alignment_decision_duplicate',
|
|
219
|
+
`workstream "${workstreamId}" interface_alignment decision "${decisionId}" is duplicated for repo "${repoId}"`,
|
|
220
|
+
);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
seen.add(decisionId);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const repoId of Object.keys(byRepo)) {
|
|
228
|
+
if (!repoIdSet.has(repoId)) {
|
|
229
|
+
pushError(
|
|
230
|
+
errors,
|
|
231
|
+
'workstream_interface_alignment_repo_unknown',
|
|
232
|
+
`workstream "${workstreamId}" interface_alignment references undeclared repo "${repoId}"`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
155
238
|
function detectWorkstreamCycles(workstreams, errors) {
|
|
156
239
|
const visiting = new Set();
|
|
157
240
|
const visited = new Set();
|
|
@@ -344,6 +427,16 @@ export function normalizeCoordinatorConfig(raw) {
|
|
|
344
427
|
entry_repo: workstream.entry_repo,
|
|
345
428
|
depends_on: Array.isArray(workstream.depends_on) ? [...new Set(workstream.depends_on)] : [],
|
|
346
429
|
completion_barrier: workstream.completion_barrier,
|
|
430
|
+
interface_alignment: workstream.interface_alignment?.decision_ids_by_repo
|
|
431
|
+
? {
|
|
432
|
+
decision_ids_by_repo: Object.fromEntries(
|
|
433
|
+
Object.entries(workstream.interface_alignment.decision_ids_by_repo).map(([repoId, decisionIds]) => [
|
|
434
|
+
repoId,
|
|
435
|
+
Array.isArray(decisionIds) ? [...new Set(decisionIds)] : [],
|
|
436
|
+
]),
|
|
437
|
+
),
|
|
438
|
+
}
|
|
439
|
+
: null,
|
|
347
440
|
},
|
|
348
441
|
]),
|
|
349
442
|
),
|
|
@@ -21,6 +21,11 @@ import {
|
|
|
21
21
|
readBarriers,
|
|
22
22
|
} from './coordinator-state.js';
|
|
23
23
|
import { safeWriteJson } from './safe-write.js';
|
|
24
|
+
import {
|
|
25
|
+
computeBarrierStatus as computeCoordinatorBarrierStatus,
|
|
26
|
+
getAcceptedReposForWorkstream,
|
|
27
|
+
getAlignedReposForBarrier,
|
|
28
|
+
} from './coordinator-barriers.js';
|
|
24
29
|
|
|
25
30
|
// ── Paths ───────────────────────────────────────────────────────────────────
|
|
26
31
|
|
|
@@ -335,6 +340,23 @@ export function resyncFromRepoAuthority(workspacePath, state, config) {
|
|
|
335
340
|
if (barrier.type === 'shared_human_gate') continue; // Never auto-transition
|
|
336
341
|
|
|
337
342
|
const newStatus = recomputeBarrierStatus(barrier, fullHistory, config);
|
|
343
|
+
if (barrier.type === 'all_repos_accepted') {
|
|
344
|
+
const satisfiedRepos = getAcceptedReposForWorkstream(
|
|
345
|
+
fullHistory, barrier.workstream_id, barrier.required_repos
|
|
346
|
+
);
|
|
347
|
+
if (JSON.stringify(barrier.satisfied_repos || []) !== JSON.stringify(satisfiedRepos)) {
|
|
348
|
+
barrier.satisfied_repos = satisfiedRepos;
|
|
349
|
+
barriersChanged = true;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (barrier.type === 'interface_alignment') {
|
|
353
|
+
const satisfiedRepos = getAlignedReposForBarrier(barrier, fullHistory);
|
|
354
|
+
if (JSON.stringify(barrier.satisfied_repos || []) !== JSON.stringify(satisfiedRepos)) {
|
|
355
|
+
barrier.satisfied_repos = satisfiedRepos;
|
|
356
|
+
barriersChanged = true;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
338
360
|
if (newStatus !== barrier.status) {
|
|
339
361
|
const previousStatus = barrier.status;
|
|
340
362
|
barrierChanges.push({
|
|
@@ -347,13 +369,6 @@ export function resyncFromRepoAuthority(workspacePath, state, config) {
|
|
|
347
369
|
|
|
348
370
|
barrier.status = newStatus;
|
|
349
371
|
|
|
350
|
-
// Update satisfied_repos for tracking
|
|
351
|
-
if (barrier.type === 'all_repos_accepted') {
|
|
352
|
-
barrier.satisfied_repos = getAcceptedReposForWorkstream(
|
|
353
|
-
fullHistory, barrier.workstream_id, barrier.required_repos
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
372
|
barriersChanged = true;
|
|
358
373
|
|
|
359
374
|
appendJsonl(barrierLedgerPath(workspacePath), {
|
|
@@ -421,75 +436,115 @@ export function resyncFromRepoAuthority(workspacePath, state, config) {
|
|
|
421
436
|
};
|
|
422
437
|
}
|
|
423
438
|
|
|
424
|
-
|
|
439
|
+
/**
|
|
440
|
+
* Clear a coordinator blocked state after the operator resolves the cause.
|
|
441
|
+
*
|
|
442
|
+
* Recovery always begins with repo-authority resync, then restores the
|
|
443
|
+
* coordinator to `active` or `paused` based on whether a pending gate still
|
|
444
|
+
* exists. It never mutates repo-local governed state.
|
|
445
|
+
*
|
|
446
|
+
* @param {string} workspacePath
|
|
447
|
+
* @param {object} state
|
|
448
|
+
* @param {object} config
|
|
449
|
+
* @returns {{ ok: boolean, state?: object, resumed_status?: string, blocked_reason?: string, blocked_repos?: string[], resync?: object, error?: string }}
|
|
450
|
+
*/
|
|
451
|
+
export function resumeCoordinatorFromBlockedState(workspacePath, state, config) {
|
|
452
|
+
if (!state || state.status !== 'blocked') {
|
|
453
|
+
return {
|
|
454
|
+
ok: false,
|
|
455
|
+
error: `Cannot resume coordinator: status is "${state?.status || 'missing'}", expected "blocked"`,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
425
458
|
|
|
426
|
-
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
459
|
+
const previousBlockedReason = state.blocked_reason || 'unknown blocked reason';
|
|
460
|
+
const expectedSuperRunId = state.super_run_id;
|
|
461
|
+
const resync = resyncFromRepoAuthority(workspacePath, state, config);
|
|
462
|
+
const refreshedState = loadCoordinatorState(workspacePath);
|
|
463
|
+
|
|
464
|
+
if (!refreshedState) {
|
|
465
|
+
return {
|
|
466
|
+
ok: false,
|
|
467
|
+
error: 'Coordinator state could not be reloaded after resync',
|
|
468
|
+
blocked_reason: previousBlockedReason,
|
|
469
|
+
resync,
|
|
470
|
+
};
|
|
434
471
|
}
|
|
435
|
-
return [...accepted];
|
|
436
|
-
}
|
|
437
472
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
satisfied.add(entry.repo_id);
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
if (satisfied.size === required.size) return 'satisfied';
|
|
451
|
-
if (satisfied.size > 0) return 'partially_satisfied';
|
|
452
|
-
return 'pending';
|
|
453
|
-
}
|
|
473
|
+
if (refreshedState.super_run_id !== expectedSuperRunId) {
|
|
474
|
+
return {
|
|
475
|
+
ok: false,
|
|
476
|
+
error: `Cannot resume coordinator: super_run_id changed from "${expectedSuperRunId}" to "${refreshedState.super_run_id}"`,
|
|
477
|
+
blocked_reason: previousBlockedReason,
|
|
478
|
+
resync,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
454
481
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
482
|
+
if (!resync.ok) {
|
|
483
|
+
return {
|
|
484
|
+
ok: false,
|
|
485
|
+
error: `Coordinator remains blocked: ${resync.blocked_reason || refreshedState.blocked_reason || previousBlockedReason}`,
|
|
486
|
+
blocked_reason: resync.blocked_reason || refreshedState.blocked_reason || previousBlockedReason,
|
|
487
|
+
resync,
|
|
488
|
+
state: refreshedState,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const blockedRepos = Object.entries(refreshedState.repo_runs || {})
|
|
493
|
+
.filter(([, repoRun]) => repoRun?.status === 'blocked')
|
|
494
|
+
.map(([repoId]) => repoId);
|
|
495
|
+
|
|
496
|
+
if (blockedRepos.length > 0) {
|
|
497
|
+
const blockedReason = `child repos remain blocked: ${blockedRepos.join(', ')}`;
|
|
498
|
+
const blockedState = {
|
|
499
|
+
...refreshedState,
|
|
500
|
+
status: 'blocked',
|
|
501
|
+
blocked_reason: blockedReason,
|
|
502
|
+
};
|
|
503
|
+
saveCoordinatorState(workspacePath, blockedState);
|
|
504
|
+
return {
|
|
505
|
+
ok: false,
|
|
506
|
+
error: `Cannot resume coordinator: ${blockedReason}`,
|
|
507
|
+
blocked_reason: blockedReason,
|
|
508
|
+
blocked_repos: blockedRepos,
|
|
509
|
+
resync,
|
|
510
|
+
state: blockedState,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const resumedStatus = refreshedState.pending_gate ? 'paused' : 'active';
|
|
515
|
+
const resumedState = {
|
|
516
|
+
...refreshedState,
|
|
517
|
+
status: resumedStatus,
|
|
518
|
+
};
|
|
519
|
+
delete resumedState.blocked_reason;
|
|
473
520
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
521
|
+
saveCoordinatorState(workspacePath, resumedState);
|
|
522
|
+
appendJsonl(historyPath(workspacePath), {
|
|
523
|
+
type: 'blocked_resolved',
|
|
524
|
+
timestamp: new Date().toISOString(),
|
|
525
|
+
super_run_id: refreshedState.super_run_id,
|
|
526
|
+
from: 'blocked',
|
|
527
|
+
to: resumedStatus,
|
|
528
|
+
blocked_reason: previousBlockedReason,
|
|
529
|
+
pending_gate: refreshedState.pending_gate
|
|
530
|
+
? {
|
|
531
|
+
gate: refreshedState.pending_gate.gate,
|
|
532
|
+
gate_type: refreshedState.pending_gate.gate_type,
|
|
481
533
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
return 'satisfied';
|
|
485
|
-
}
|
|
534
|
+
: null,
|
|
535
|
+
});
|
|
486
536
|
|
|
487
|
-
|
|
488
|
-
|
|
537
|
+
return {
|
|
538
|
+
ok: true,
|
|
539
|
+
state: resumedState,
|
|
540
|
+
resumed_status: resumedStatus,
|
|
541
|
+
blocked_reason: previousBlockedReason,
|
|
542
|
+
resync,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
489
545
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
}
|
|
546
|
+
function recomputeBarrierStatus(barrier, history, config) {
|
|
547
|
+
return computeCoordinatorBarrierStatus(barrier, history, config);
|
|
493
548
|
}
|
|
494
549
|
|
|
495
550
|
/**
|
|
@@ -97,6 +97,7 @@ function bootstrapBarriers(config) {
|
|
|
97
97
|
status: 'pending',
|
|
98
98
|
required_repos: [...workstream.repos],
|
|
99
99
|
satisfied_repos: [],
|
|
100
|
+
alignment_decision_ids: workstream.interface_alignment?.decision_ids_by_repo || null,
|
|
100
101
|
created_at: new Date().toISOString(),
|
|
101
102
|
};
|
|
102
103
|
}
|