agentxchain 2.127.0 → 2.129.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 -2
- package/bin/agentxchain.js +44 -4
- package/package.json +1 -1
- package/scripts/verify-post-publish.sh +55 -5
- package/src/commands/connector.js +17 -2
- package/src/commands/doctor.js +122 -1
- package/src/commands/events.js +7 -1
- package/src/commands/init.js +55 -14
- package/src/commands/inject.js +1 -1
- package/src/commands/mission.js +142 -0
- package/src/commands/reissue-turn.js +122 -0
- package/src/commands/reject-turn.js +24 -4
- package/src/commands/restart.js +9 -2
- package/src/commands/resume.js +20 -9
- package/src/commands/run.js +13 -0
- package/src/commands/status.js +46 -4
- package/src/commands/step.js +49 -10
- package/src/commands/validate.js +78 -20
- package/src/lib/adapters/local-cli-adapter.js +7 -1
- package/src/lib/cli-version.js +106 -0
- package/src/lib/connector-probe.js +149 -6
- package/src/lib/continuous-run.js +14 -86
- package/src/lib/dispatch-bundle.js +39 -0
- package/src/lib/governed-state.js +474 -10
- package/src/lib/governed-templates.js +1 -0
- package/src/lib/intake.js +221 -77
- package/src/lib/missions.js +56 -4
- package/src/lib/normalized-config.js +50 -15
- package/src/lib/repo-observer.js +7 -2
- package/src/lib/run-events.js +4 -0
- package/src/lib/run-loop.js +5 -0
- package/src/lib/runner-interface.js +2 -0
- package/src/lib/session-checkpoint.js +18 -2
- package/src/templates/governed/full-local-cli.json +71 -0
package/src/lib/intake.js
CHANGED
|
@@ -27,6 +27,8 @@ const INTENT_ID_RE = /^intent_\d+_[0-9a-f]{4}$/;
|
|
|
27
27
|
// intent files, but current first-party intake writers do not transition into it.
|
|
28
28
|
const S1_STATES = new Set(['detected', 'triaged', 'approved', 'planned', 'executing', 'blocked', 'completed', 'failed', 'suppressed', 'rejected']);
|
|
29
29
|
const TERMINAL_STATES = new Set(['suppressed', 'rejected', 'completed', 'failed']);
|
|
30
|
+
const DISPATCHABLE_STATUSES = new Set(['planned', 'approved']);
|
|
31
|
+
const PRIORITY_RANK = { p0: 0, p1: 1, p2: 2, p3: 3 };
|
|
30
32
|
|
|
31
33
|
const VALID_TRANSITIONS = {
|
|
32
34
|
detected: ['triaged', 'suppressed'],
|
|
@@ -502,6 +504,196 @@ export function intakeStatus(root, intentId) {
|
|
|
502
504
|
return { ok: true, summary, exitCode: 0 };
|
|
503
505
|
}
|
|
504
506
|
|
|
507
|
+
export function findNextDispatchableIntent(root) {
|
|
508
|
+
const dirs = intakeDirs(root);
|
|
509
|
+
if (!existsSync(dirs.intents)) {
|
|
510
|
+
return { ok: false, error: 'no intents directory' };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const intents = readJsonDir(dirs.intents)
|
|
514
|
+
.filter((intent) => intent && DISPATCHABLE_STATUSES.has(intent.status));
|
|
515
|
+
|
|
516
|
+
if (intents.length === 0) {
|
|
517
|
+
return { ok: false, error: 'no dispatchable intents' };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const approved = intents.filter((intent) => intent.status === 'approved');
|
|
521
|
+
const candidates = approved.length > 0 ? approved : intents;
|
|
522
|
+
|
|
523
|
+
candidates.sort((a, b) => {
|
|
524
|
+
const aPriority = PRIORITY_RANK[a.priority] ?? Number.MAX_SAFE_INTEGER;
|
|
525
|
+
const bPriority = PRIORITY_RANK[b.priority] ?? Number.MAX_SAFE_INTEGER;
|
|
526
|
+
if (aPriority !== bPriority) return aPriority - bPriority;
|
|
527
|
+
|
|
528
|
+
const aTime = Date.parse(a.approved_at || a.planned_at || a.created_at || a.updated_at || 0);
|
|
529
|
+
const bTime = Date.parse(b.approved_at || b.planned_at || b.created_at || b.updated_at || 0);
|
|
530
|
+
if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) {
|
|
531
|
+
return aTime - bTime;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return String(a.intent_id || '').localeCompare(String(b.intent_id || ''));
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
const intent = candidates[0];
|
|
538
|
+
return {
|
|
539
|
+
ok: true,
|
|
540
|
+
intentId: intent.intent_id,
|
|
541
|
+
status: intent.status,
|
|
542
|
+
priority: intent.priority || null,
|
|
543
|
+
charter: intent.charter || null,
|
|
544
|
+
acceptance_count: Array.isArray(intent.acceptance_contract) ? intent.acceptance_contract.length : 0,
|
|
545
|
+
intent,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Return all approved-but-unconsumed intents sorted by priority (BUG-15).
|
|
551
|
+
* Used by `status` to surface the pending intent queue.
|
|
552
|
+
*/
|
|
553
|
+
export function findPendingApprovedIntents(root) {
|
|
554
|
+
const dirs = intakeDirs(root);
|
|
555
|
+
if (!existsSync(dirs.intents)) return [];
|
|
556
|
+
|
|
557
|
+
return readJsonDir(dirs.intents)
|
|
558
|
+
.filter((intent) => intent && intent.status === 'approved')
|
|
559
|
+
.sort((a, b) => {
|
|
560
|
+
const aPriority = PRIORITY_RANK[a.priority] ?? Number.MAX_SAFE_INTEGER;
|
|
561
|
+
const bPriority = PRIORITY_RANK[b.priority] ?? Number.MAX_SAFE_INTEGER;
|
|
562
|
+
if (aPriority !== bPriority) return aPriority - bPriority;
|
|
563
|
+
const aTime = Date.parse(a.approved_at || a.created_at || 0);
|
|
564
|
+
const bTime = Date.parse(b.approved_at || b.created_at || 0);
|
|
565
|
+
if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return aTime - bTime;
|
|
566
|
+
return String(a.intent_id || '').localeCompare(String(b.intent_id || ''));
|
|
567
|
+
})
|
|
568
|
+
.map((intent) => ({
|
|
569
|
+
intent_id: intent.intent_id,
|
|
570
|
+
priority: intent.priority || 'p0',
|
|
571
|
+
charter: intent.charter || intent.description || null,
|
|
572
|
+
acceptance_count: Array.isArray(intent.acceptance_contract) ? intent.acceptance_contract.length : 0,
|
|
573
|
+
approved_at: intent.approved_at || null,
|
|
574
|
+
}));
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Unified intent consumption entry point (BUG-16).
|
|
579
|
+
* Both manual (resume/step --resume) and continuous/scheduler paths should call
|
|
580
|
+
* this single function to consume the next approved intent.
|
|
581
|
+
*
|
|
582
|
+
* @param {string} root
|
|
583
|
+
* @param {{ role?: string, writeDispatchBundle?: boolean, allowTerminalRestart?: boolean, provenance?: object }} options
|
|
584
|
+
* @returns {{ ok: boolean, intentId?: string, intent?: object, error?: string }}
|
|
585
|
+
*/
|
|
586
|
+
export function consumeNextApprovedIntent(root, options = {}) {
|
|
587
|
+
const queued = findNextDispatchableIntent(root);
|
|
588
|
+
if (!queued.ok) {
|
|
589
|
+
return { ok: false, error: queued.error || 'no dispatchable intents' };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const prepared = prepareIntentForDispatch(root, queued.intentId, {
|
|
593
|
+
role: options.role,
|
|
594
|
+
writeDispatchBundle: options.writeDispatchBundle ?? false,
|
|
595
|
+
allowTerminalRestart: options.allowTerminalRestart ?? false,
|
|
596
|
+
provenance: options.provenance,
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
if (!prepared.ok) {
|
|
600
|
+
return { ok: false, error: prepared.error, intentId: queued.intentId };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return {
|
|
604
|
+
ok: true,
|
|
605
|
+
intentId: queued.intentId,
|
|
606
|
+
status: queued.status,
|
|
607
|
+
priority: queued.priority,
|
|
608
|
+
charter: queued.charter,
|
|
609
|
+
intent: prepared.intent,
|
|
610
|
+
run_id: prepared.run_id,
|
|
611
|
+
turn_id: prepared.turn_id,
|
|
612
|
+
role: prepared.role,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export function prepareIntentForDispatch(root, intentId, options = {}) {
|
|
617
|
+
const loadedIntent = readIntent(root, intentId);
|
|
618
|
+
if (!loadedIntent.ok) {
|
|
619
|
+
return loadedIntent;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const startingStatus = loadedIntent.intent.status;
|
|
623
|
+
let intent = loadedIntent.intent;
|
|
624
|
+
let planned = false;
|
|
625
|
+
|
|
626
|
+
if (intent.status === 'approved') {
|
|
627
|
+
const plannedResult = planIntent(root, intent.intent_id, {
|
|
628
|
+
projectName: options.projectName,
|
|
629
|
+
force: options.forcePlan === true,
|
|
630
|
+
});
|
|
631
|
+
if (!plannedResult.ok) {
|
|
632
|
+
return {
|
|
633
|
+
ok: false,
|
|
634
|
+
error: `plan failed: ${plannedResult.error}`,
|
|
635
|
+
intent_status: intent.status,
|
|
636
|
+
exitCode: plannedResult.exitCode || 1,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
intent = plannedResult.intent;
|
|
640
|
+
planned = true;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (intent.status === 'planned') {
|
|
644
|
+
const startResult = startIntent(root, intent.intent_id, {
|
|
645
|
+
allowTerminalRestart: options.allowTerminalRestart === true,
|
|
646
|
+
provenance: options.provenance,
|
|
647
|
+
role: options.role || undefined,
|
|
648
|
+
writeDispatchBundle: options.writeDispatchBundle,
|
|
649
|
+
});
|
|
650
|
+
if (!startResult.ok) {
|
|
651
|
+
return {
|
|
652
|
+
ok: false,
|
|
653
|
+
error: `start failed: ${startResult.error}`,
|
|
654
|
+
intent_status: intent.status,
|
|
655
|
+
exitCode: startResult.exitCode || 1,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
return {
|
|
659
|
+
ok: true,
|
|
660
|
+
intent_id: intent.intent_id,
|
|
661
|
+
starting_status: startingStatus,
|
|
662
|
+
final_status: startResult.intent.status,
|
|
663
|
+
planned,
|
|
664
|
+
started: true,
|
|
665
|
+
run_id: startResult.run_id,
|
|
666
|
+
turn_id: startResult.turn_id,
|
|
667
|
+
role: startResult.role,
|
|
668
|
+
intent: startResult.intent,
|
|
669
|
+
exitCode: 0,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (intent.status === 'executing') {
|
|
674
|
+
return {
|
|
675
|
+
ok: true,
|
|
676
|
+
intent_id: intent.intent_id,
|
|
677
|
+
starting_status: startingStatus,
|
|
678
|
+
final_status: intent.status,
|
|
679
|
+
planned,
|
|
680
|
+
started: false,
|
|
681
|
+
run_id: intent.target_run || null,
|
|
682
|
+
turn_id: intent.target_turn || null,
|
|
683
|
+
role: options.role || null,
|
|
684
|
+
intent,
|
|
685
|
+
exitCode: 0,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return {
|
|
690
|
+
ok: false,
|
|
691
|
+
error: `intent ${intentId} is in unsupported status "${intent.status}" for dispatch preparation`,
|
|
692
|
+
intent_status: intent.status,
|
|
693
|
+
exitCode: 1,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
505
697
|
// ---------------------------------------------------------------------------
|
|
506
698
|
// Approve
|
|
507
699
|
// ---------------------------------------------------------------------------
|
|
@@ -750,7 +942,9 @@ export function startIntent(root, intentId, options = {}) {
|
|
|
750
942
|
}
|
|
751
943
|
|
|
752
944
|
// Assign governed turn
|
|
753
|
-
const assignResult = assignGovernedTurn(root, config, roleId.role
|
|
945
|
+
const assignResult = assignGovernedTurn(root, config, roleId.role, {
|
|
946
|
+
intakeContext,
|
|
947
|
+
});
|
|
754
948
|
if (!assignResult.ok) {
|
|
755
949
|
return { ok: false, error: `turn assignment failed: ${assignResult.error}`, exitCode: 1 };
|
|
756
950
|
}
|
|
@@ -763,24 +957,18 @@ export function startIntent(root, intentId, options = {}) {
|
|
|
763
957
|
return { ok: false, error: 'turn assignment succeeded but turn not found in state', exitCode: 1 };
|
|
764
958
|
}
|
|
765
959
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
960
|
+
if (options.writeDispatchBundle !== false) {
|
|
961
|
+
const bundleResult = writeDispatchBundle(root, state, config);
|
|
962
|
+
if (!bundleResult.ok) {
|
|
963
|
+
return { ok: false, error: `dispatch bundle failed: ${bundleResult.error}`, exitCode: 1 };
|
|
964
|
+
}
|
|
771
965
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
966
|
+
finalizeDispatchManifest(root, assignedTurn.turn_id, {
|
|
967
|
+
run_id: state.run_id,
|
|
968
|
+
role: assignedTurn.assigned_role,
|
|
969
|
+
});
|
|
776
970
|
}
|
|
777
971
|
|
|
778
|
-
// Finalize dispatch manifest
|
|
779
|
-
finalizeDispatchManifest(root, assignedTurn.turn_id, {
|
|
780
|
-
run_id: state.run_id,
|
|
781
|
-
role: assignedTurn.assigned_role,
|
|
782
|
-
});
|
|
783
|
-
|
|
784
972
|
// Update intent: planned → executing
|
|
785
973
|
const now = nowISO();
|
|
786
974
|
intent.status = 'executing';
|
|
@@ -1426,83 +1614,39 @@ export function consumePreemptionMarker(root, options = {}) {
|
|
|
1426
1614
|
};
|
|
1427
1615
|
}
|
|
1428
1616
|
|
|
1429
|
-
const
|
|
1430
|
-
|
|
1431
|
-
let planned = false;
|
|
1432
|
-
let started = false;
|
|
1433
|
-
|
|
1434
|
-
if (intent.status === 'approved') {
|
|
1435
|
-
const plannedResult = planIntent(root, intent.intent_id, {
|
|
1436
|
-
projectName: options.projectName,
|
|
1437
|
-
force: options.forcePlan === true,
|
|
1438
|
-
});
|
|
1439
|
-
if (!plannedResult.ok) {
|
|
1440
|
-
return {
|
|
1441
|
-
ok: false,
|
|
1442
|
-
error: `failed to plan injected intent ${intent.intent_id}: ${plannedResult.error}`,
|
|
1443
|
-
marker,
|
|
1444
|
-
intent_status: intent.status,
|
|
1445
|
-
exitCode: plannedResult.exitCode || 1,
|
|
1446
|
-
};
|
|
1447
|
-
}
|
|
1448
|
-
intent = plannedResult.intent;
|
|
1449
|
-
planned = true;
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
if (intent.status === 'planned') {
|
|
1453
|
-
const startResult = startIntent(root, intent.intent_id, {
|
|
1454
|
-
role: options.role || undefined,
|
|
1455
|
-
});
|
|
1456
|
-
if (!startResult.ok) {
|
|
1457
|
-
return {
|
|
1458
|
-
ok: false,
|
|
1459
|
-
error: `failed to start injected intent ${intent.intent_id}: ${startResult.error}`,
|
|
1460
|
-
marker,
|
|
1461
|
-
intent_status: intent.status,
|
|
1462
|
-
exitCode: startResult.exitCode || 1,
|
|
1463
|
-
};
|
|
1464
|
-
}
|
|
1465
|
-
clearPreemptionMarker(root);
|
|
1617
|
+
const prepared = prepareIntentForDispatch(root, marker.intent_id, options);
|
|
1618
|
+
if (!prepared.ok) {
|
|
1466
1619
|
return {
|
|
1467
|
-
|
|
1620
|
+
...prepared,
|
|
1621
|
+
error: `failed to prepare injected intent ${marker.intent_id}: ${prepared.error}`,
|
|
1468
1622
|
marker,
|
|
1469
|
-
intent_id: intent.intent_id,
|
|
1470
|
-
starting_status: startingStatus,
|
|
1471
|
-
final_status: startResult.intent.status,
|
|
1472
|
-
planned,
|
|
1473
|
-
started: true,
|
|
1474
|
-
run_id: startResult.run_id,
|
|
1475
|
-
turn_id: startResult.turn_id,
|
|
1476
|
-
role: startResult.role,
|
|
1477
|
-
intent: startResult.intent,
|
|
1478
|
-
exitCode: 0,
|
|
1479
1623
|
};
|
|
1480
1624
|
}
|
|
1481
1625
|
|
|
1482
|
-
if (
|
|
1626
|
+
if (prepared.final_status === 'executing') {
|
|
1483
1627
|
clearPreemptionMarker(root);
|
|
1484
1628
|
return {
|
|
1485
1629
|
ok: true,
|
|
1486
1630
|
marker,
|
|
1487
|
-
intent_id:
|
|
1488
|
-
starting_status:
|
|
1489
|
-
final_status:
|
|
1490
|
-
planned,
|
|
1491
|
-
started:
|
|
1492
|
-
run_id:
|
|
1493
|
-
turn_id:
|
|
1494
|
-
role:
|
|
1495
|
-
intent,
|
|
1631
|
+
intent_id: prepared.intent_id,
|
|
1632
|
+
starting_status: prepared.starting_status,
|
|
1633
|
+
final_status: prepared.final_status,
|
|
1634
|
+
planned: prepared.planned,
|
|
1635
|
+
started: prepared.started,
|
|
1636
|
+
run_id: prepared.run_id,
|
|
1637
|
+
turn_id: prepared.turn_id,
|
|
1638
|
+
role: prepared.role,
|
|
1639
|
+
intent: prepared.intent,
|
|
1496
1640
|
exitCode: 0,
|
|
1497
1641
|
};
|
|
1498
1642
|
}
|
|
1499
1643
|
|
|
1500
1644
|
return {
|
|
1501
1645
|
ok: false,
|
|
1502
|
-
error: `cannot consume preemption marker from intent status "${
|
|
1646
|
+
error: `cannot consume preemption marker from intent status "${prepared.final_status}"`,
|
|
1503
1647
|
marker,
|
|
1504
|
-
intent_id:
|
|
1505
|
-
intent_status:
|
|
1648
|
+
intent_id: prepared.intent_id,
|
|
1649
|
+
intent_status: prepared.final_status,
|
|
1506
1650
|
exitCode: 1,
|
|
1507
1651
|
};
|
|
1508
1652
|
}
|
package/src/lib/missions.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from 'path';
|
|
|
3
3
|
import { loadAllChainReports, loadChainReport, loadLatestChainReport } from './chain-reports.js';
|
|
4
4
|
import { buildPlanProgressSummary, loadLatestPlan } from './mission-plans.js';
|
|
5
5
|
import { getActiveRepoDecisions } from './repo-decisions.js';
|
|
6
|
+
import { getCoordinatorStatus } from './coordinator-state.js';
|
|
6
7
|
|
|
7
8
|
const MISSION_ATTENTION_TERMINALS = new Set(['operator_abort', 'parent_validation_failed']);
|
|
8
9
|
const MISSION_ATTENTION_RUN_STATUSES = new Set(['blocked', 'failed']);
|
|
@@ -143,9 +144,21 @@ export function buildMissionSnapshot(root, missionArtifact) {
|
|
|
143
144
|
const latestPlan = loadLatestPlan(root, missionArtifact.mission_id);
|
|
144
145
|
const activeRepoDecisions = getActiveRepoDecisions(root);
|
|
145
146
|
|
|
147
|
+
// Load coordinator status if mission is bound to a multi-repo coordinator
|
|
148
|
+
let coordinatorStatus = null;
|
|
149
|
+
if (missionArtifact.coordinator && missionArtifact.coordinator.super_run_id) {
|
|
150
|
+
const workspacePath = missionArtifact.coordinator.workspace_path || root;
|
|
151
|
+
try {
|
|
152
|
+
const cs = getCoordinatorStatus(workspacePath);
|
|
153
|
+
coordinatorStatus = cs || { unreachable: true, super_run_id: missionArtifact.coordinator.super_run_id };
|
|
154
|
+
} catch {
|
|
155
|
+
coordinatorStatus = { unreachable: true, super_run_id: missionArtifact.coordinator.super_run_id };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
146
159
|
return {
|
|
147
160
|
...missionArtifact,
|
|
148
|
-
derived_status: deriveMissionStatus(missionArtifact, chains, missingChainIds),
|
|
161
|
+
derived_status: deriveMissionStatus(missionArtifact, chains, missingChainIds, coordinatorStatus),
|
|
149
162
|
chain_count: chainIds.length,
|
|
150
163
|
attached_chain_count: chains.length,
|
|
151
164
|
missing_chain_ids: missingChainIds,
|
|
@@ -155,6 +168,7 @@ export function buildMissionSnapshot(root, missionArtifact) {
|
|
|
155
168
|
latest_terminal_reason: latestChain?.terminal_reason || null,
|
|
156
169
|
latest_plan: buildPlanProgressSummary(latestPlan),
|
|
157
170
|
active_repo_decisions_count: activeRepoDecisions.length,
|
|
171
|
+
coordinator_status: coordinatorStatus,
|
|
158
172
|
chains,
|
|
159
173
|
};
|
|
160
174
|
}
|
|
@@ -163,19 +177,24 @@ export function loadAllMissionSnapshots(root) {
|
|
|
163
177
|
return loadAllMissionArtifacts(root).map((mission) => buildMissionSnapshot(root, mission));
|
|
164
178
|
}
|
|
165
179
|
|
|
166
|
-
function deriveMissionStatus(missionArtifact, chains, missingChainIds) {
|
|
180
|
+
function deriveMissionStatus(missionArtifact, chains, missingChainIds, coordinatorStatus) {
|
|
167
181
|
if (missionArtifact.status && missionArtifact.status !== 'active') {
|
|
168
182
|
return missionArtifact.status;
|
|
169
183
|
}
|
|
184
|
+
// Coordinator-bound missions: check coordinator health
|
|
185
|
+
if (coordinatorStatus && !coordinatorStatus.unreachable && coordinatorStatus.status === 'blocked') {
|
|
186
|
+
return 'needs_attention';
|
|
187
|
+
}
|
|
170
188
|
if (missingChainIds.length > 0) return 'degraded';
|
|
171
|
-
if (chains.length === 0) return 'planned';
|
|
189
|
+
if (chains.length === 0 && !coordinatorStatus) return 'planned';
|
|
172
190
|
if (chains.some((chain) => (
|
|
173
191
|
MISSION_ATTENTION_TERMINALS.has(chain.terminal_reason)
|
|
174
192
|
|| (chain.runs || []).some((run) => MISSION_ATTENTION_RUN_STATUSES.has(run.status))
|
|
175
193
|
))) {
|
|
176
194
|
return 'needs_attention';
|
|
177
195
|
}
|
|
178
|
-
return 'progressing';
|
|
196
|
+
if (coordinatorStatus && !coordinatorStatus.unreachable) return 'progressing';
|
|
197
|
+
return chains.length > 0 ? 'progressing' : 'planned';
|
|
179
198
|
}
|
|
180
199
|
|
|
181
200
|
export function loadLatestMissionSnapshot(root) {
|
|
@@ -196,3 +215,36 @@ export function loadMissionAttachmentTarget(root, missionId) {
|
|
|
196
215
|
if (missionId) return loadMissionArtifact(root, missionId);
|
|
197
216
|
return loadLatestMissionArtifact(root);
|
|
198
217
|
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Bind a coordinator super_run_id to a mission artifact.
|
|
221
|
+
*
|
|
222
|
+
* @param {string} root - project root
|
|
223
|
+
* @param {string} missionId - mission to bind
|
|
224
|
+
* @param {{ super_run_id: string, config_path: string, workspace_path?: string }} coordinatorRef
|
|
225
|
+
* @returns {{ ok: boolean, mission?: object, error?: string }}
|
|
226
|
+
*/
|
|
227
|
+
export function bindCoordinatorToMission(root, missionId, coordinatorRef) {
|
|
228
|
+
const mission = loadMissionArtifact(root, missionId);
|
|
229
|
+
if (!mission) {
|
|
230
|
+
return { ok: false, error: `Mission not found: ${missionId}` };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!coordinatorRef || typeof coordinatorRef.super_run_id !== 'string') {
|
|
234
|
+
return { ok: false, error: 'coordinator super_run_id is required' };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const updated = {
|
|
238
|
+
...mission,
|
|
239
|
+
coordinator: {
|
|
240
|
+
super_run_id: coordinatorRef.super_run_id,
|
|
241
|
+
config_path: coordinatorRef.config_path || null,
|
|
242
|
+
workspace_path: coordinatorRef.workspace_path || '.',
|
|
243
|
+
},
|
|
244
|
+
updated_at: new Date().toISOString(),
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
mkdirSync(getMissionsDir(root), { recursive: true });
|
|
248
|
+
writeFileSync(join(getMissionsDir(root), `${updated.mission_id}.json`), JSON.stringify(updated, null, 2));
|
|
249
|
+
return { ok: true, mission: updated };
|
|
250
|
+
}
|
|
@@ -330,6 +330,53 @@ export function detectConfigVersion(raw) {
|
|
|
330
330
|
return null;
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
+
function formatInvalidReviewOnlyLocalCliBindingError(roleId, runtimeId) {
|
|
334
|
+
return `Role "${roleId}" uses invalid review_only + local_cli binding on runtime "${runtimeId}" — change write_authority to "authoritative" for local CLI automation, or move the role to "manual", "api_proxy", "mcp", or "remote_agent"`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function formatInvalidAuthoritativeBindingError(roleId, runtimeId, runtimeType, writeAuthority) {
|
|
338
|
+
return `Role "${roleId}" has write_authority "${writeAuthority}" but uses ${runtimeType} runtime "${runtimeId}" — ${runtimeType} only supports review_only and proposed roles`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function findAuthorityRuntimeBindingIssues(data) {
|
|
342
|
+
const issues = [];
|
|
343
|
+
|
|
344
|
+
if (!data?.roles || !data?.runtimes) {
|
|
345
|
+
return issues;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
for (const [roleId, role] of Object.entries(data.roles)) {
|
|
349
|
+
if (!role?.runtime || !data.runtimes[role.runtime]) {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const runtime = data.runtimes[role.runtime];
|
|
354
|
+
const contract = getRoleRuntimeCapabilityContract(roleId, role, runtime);
|
|
355
|
+
|
|
356
|
+
if (contract.effective_write_path === 'invalid_review_only_binding') {
|
|
357
|
+
issues.push({
|
|
358
|
+
role_id: roleId,
|
|
359
|
+
runtime_id: role.runtime,
|
|
360
|
+
runtime_type: runtime.type,
|
|
361
|
+
write_authority: role.write_authority,
|
|
362
|
+
effective_write_path: contract.effective_write_path,
|
|
363
|
+
message: formatInvalidReviewOnlyLocalCliBindingError(roleId, role.runtime),
|
|
364
|
+
});
|
|
365
|
+
} else if (contract.effective_write_path === 'invalid_authoritative_binding') {
|
|
366
|
+
issues.push({
|
|
367
|
+
role_id: roleId,
|
|
368
|
+
runtime_id: role.runtime,
|
|
369
|
+
runtime_type: runtime.type,
|
|
370
|
+
write_authority: role.write_authority,
|
|
371
|
+
effective_write_path: contract.effective_write_path,
|
|
372
|
+
message: formatInvalidAuthoritativeBindingError(roleId, role.runtime, runtime.type, role.write_authority),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return issues;
|
|
378
|
+
}
|
|
379
|
+
|
|
333
380
|
/**
|
|
334
381
|
* Validate a governed config.
|
|
335
382
|
* Returns { ok, errors }.
|
|
@@ -458,21 +505,9 @@ export function validateV4Config(data, projectRoot) {
|
|
|
458
505
|
}
|
|
459
506
|
}
|
|
460
507
|
|
|
461
|
-
// Cross-reference:
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
if (role.runtime && data.runtimes[role.runtime]) {
|
|
465
|
-
const rt = data.runtimes[role.runtime];
|
|
466
|
-
const contract = getRoleRuntimeCapabilityContract(id, role, rt);
|
|
467
|
-
if (contract.effective_write_path === 'invalid_review_only_binding') {
|
|
468
|
-
errors.push(`Role "${id}" is review_only but uses local_cli runtime "${role.runtime}" — review_only roles should not have authoritative write access`);
|
|
469
|
-
} else if (contract.effective_write_path === 'invalid_authoritative_binding') {
|
|
470
|
-
errors.push(
|
|
471
|
-
`Role "${id}" has write_authority "${role.write_authority}" but uses ${rt.type} runtime "${role.runtime}" — ${rt.type} only supports review_only and proposed roles`
|
|
472
|
-
);
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
}
|
|
508
|
+
// Cross-reference: role authority must match the runtime's write contract
|
|
509
|
+
for (const issue of findAuthorityRuntimeBindingIssues(data)) {
|
|
510
|
+
errors.push(issue.message);
|
|
476
511
|
}
|
|
477
512
|
|
|
478
513
|
// Routing (optional but validated if present)
|
package/src/lib/repo-observer.js
CHANGED
|
@@ -540,7 +540,7 @@ export function compareDeclaredVsObserved(declared, observed, writeAuthority, op
|
|
|
540
540
|
// The attribution system will handle later-accepted siblings correctly.
|
|
541
541
|
warnings.push(`Undeclared file changes detected (likely from concurrent sibling turns): ${undeclared.join(', ')}`);
|
|
542
542
|
} else {
|
|
543
|
-
errors.push(`Undeclared file changes detected (observed but not in files_changed): ${undeclared.join(', ')}
|
|
543
|
+
errors.push(`Undeclared file changes detected (observed but not in files_changed): ${undeclared.join(', ')}. If these files were changed by the operator (not the turn), add them to the dispatch baseline by committing or stashing them before dispatch.`);
|
|
544
544
|
}
|
|
545
545
|
}
|
|
546
546
|
if (phantom.length > 0) {
|
|
@@ -701,7 +701,12 @@ function getWorkingTreeChanges(root) {
|
|
|
701
701
|
}
|
|
702
702
|
}
|
|
703
703
|
|
|
704
|
-
|
|
704
|
+
/**
|
|
705
|
+
* Capture hashes of all non-operational dirty files in the workspace.
|
|
706
|
+
* Used at baseline time AND at dispatch time to snapshot pre-existing dirt
|
|
707
|
+
* so acceptance can filter unchanged files (BUG-1 fix).
|
|
708
|
+
*/
|
|
709
|
+
export function captureDirtyWorkspaceSnapshot(root) {
|
|
705
710
|
const snapshot = {};
|
|
706
711
|
for (const filePath of getWorkingTreeChanges(root).filter((filePath) => !isOperationalPath(filePath))) {
|
|
707
712
|
snapshot[filePath] = getWorkspaceFileMarker(root, filePath);
|
package/src/lib/run-events.js
CHANGED
|
@@ -18,6 +18,8 @@ export const VALID_RUN_EVENTS = [
|
|
|
18
18
|
'turn_accepted',
|
|
19
19
|
'turn_rejected',
|
|
20
20
|
'turn_conflicted',
|
|
21
|
+
'acceptance_failed',
|
|
22
|
+
'turn_reissued',
|
|
21
23
|
'run_blocked',
|
|
22
24
|
'run_completed',
|
|
23
25
|
'escalation_raised',
|
|
@@ -41,6 +43,7 @@ export const VALID_RUN_EVENTS = [
|
|
|
41
43
|
* @param {string} [details.phase] - Current phase
|
|
42
44
|
* @param {string} [details.status] - Current run status
|
|
43
45
|
* @param {object} [details.turn] - Turn context (turn_id, role_id, etc.)
|
|
46
|
+
* @param {string} [details.intent_id] - Intake intent id when the event services queued intake work
|
|
44
47
|
* @param {object} [details.payload] - Additional event-specific data
|
|
45
48
|
* @returns {{ ok: boolean, event_id: string }}
|
|
46
49
|
*/
|
|
@@ -54,6 +57,7 @@ export function emitRunEvent(root, eventType, details = {}) {
|
|
|
54
57
|
phase: details.phase || null,
|
|
55
58
|
status: details.status || null,
|
|
56
59
|
turn: details.turn || null,
|
|
60
|
+
intent_id: details.intent_id || null,
|
|
57
61
|
payload: details.payload || {},
|
|
58
62
|
};
|
|
59
63
|
|
package/src/lib/run-loop.js
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
rejectTurn,
|
|
24
24
|
markRunBlocked,
|
|
25
25
|
writeDispatchBundle,
|
|
26
|
+
refreshTurnBaselineSnapshot,
|
|
26
27
|
getTurnStagingResultPath,
|
|
27
28
|
approvePhaseGate,
|
|
28
29
|
approveCompletionGate,
|
|
@@ -308,6 +309,8 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
|
|
|
308
309
|
// ── Build dispatch contexts ──────────────────────────────────────────
|
|
309
310
|
const contexts = [];
|
|
310
311
|
for (const { turn, state: turnState } of turnsToDispatch) {
|
|
312
|
+
// BUG-1 fix: refresh baseline to capture files dirtied between assignment and dispatch
|
|
313
|
+
refreshTurnBaselineSnapshot(root, turn.turn_id);
|
|
311
314
|
const bundleResult = writeDispatchBundle(root, turnState, config, { turnId: turn.turn_id });
|
|
312
315
|
if (!bundleResult.ok) {
|
|
313
316
|
errors.push(`writeDispatchBundle(${turn.assigned_role}): ${bundleResult.error}`);
|
|
@@ -442,6 +445,8 @@ async function dispatchAndProcess(root, config, turn, assignState, callbacks, em
|
|
|
442
445
|
const roleId = turn.assigned_role;
|
|
443
446
|
const history = [];
|
|
444
447
|
|
|
448
|
+
// BUG-1 fix: refresh baseline to capture files dirtied between assignment and dispatch
|
|
449
|
+
refreshTurnBaselineSnapshot(root, turn.turn_id);
|
|
445
450
|
const bundleResult = writeDispatchBundle(root, assignState, config);
|
|
446
451
|
if (!bundleResult.ok) {
|
|
447
452
|
errors.push(`writeDispatchBundle(${roleId}): ${bundleResult.error}`);
|
|
@@ -39,6 +39,8 @@ export {
|
|
|
39
39
|
getActiveTurn,
|
|
40
40
|
acquireAcceptanceLock as acquireLock,
|
|
41
41
|
releaseAcceptanceLock as releaseLock,
|
|
42
|
+
refreshTurnBaselineSnapshot,
|
|
43
|
+
reissueTurn,
|
|
42
44
|
} from './governed-state.js';
|
|
43
45
|
|
|
44
46
|
// ── Dispatch ────────────────────────────────────────────────────────────────
|
|
@@ -103,8 +103,24 @@ export function writeSessionCheckpoint(root, state, reason, extra = {}) {
|
|
|
103
103
|
const pendingGate = state.pending_phase_transition?.gate || state.pending_transition?.gate || null;
|
|
104
104
|
const pendingRunCompletion = state.pending_run_completion?.gate || null;
|
|
105
105
|
|
|
106
|
-
// Capture git baseline for repo-drift detection
|
|
107
|
-
|
|
106
|
+
// Capture git baseline for repo-drift detection.
|
|
107
|
+
// When a turn_baseline from captureBaseline() is provided, derive
|
|
108
|
+
// baseline_ref from it so session.json and state.json always agree
|
|
109
|
+
// on workspace-dirty status (BUG-2 fix).
|
|
110
|
+
let baselineRef;
|
|
111
|
+
if (extra.turn_baseline) {
|
|
112
|
+
baselineRef = {
|
|
113
|
+
git_head: extra.turn_baseline.head_ref || null,
|
|
114
|
+
git_branch: null,
|
|
115
|
+
workspace_dirty: !extra.turn_baseline.clean,
|
|
116
|
+
};
|
|
117
|
+
// Fill in git_branch if available
|
|
118
|
+
try {
|
|
119
|
+
baselineRef.git_branch = shellExec('git rev-parse --abbrev-ref HEAD', { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
120
|
+
} catch { /* non-fatal */ }
|
|
121
|
+
} else {
|
|
122
|
+
baselineRef = extra.baseline_ref || captureBaselineRef(root);
|
|
123
|
+
}
|
|
108
124
|
|
|
109
125
|
const checkpoint = {
|
|
110
126
|
session_id: sessionId,
|