@volter/supercode-orchestrator 0.5.210 → 0.5.212
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 +15 -1
- package/agents.mjs +11 -2
- package/bin/orchestrator.mjs +5 -0
- package/board/agent-declarations.mjs +18 -1
- package/board/attachment-write.mjs +68 -0
- package/board/call.mjs +2 -1
- package/board/capacity.mjs +54 -41
- package/board/ci.mjs +58 -29
- package/board/cli.mjs +51 -6
- package/board/credential-guard.mjs +15 -2
- package/board/dispatch.mjs +122 -40
- package/board/engine.mjs +24 -13
- package/board/fleet-listeners.mjs +16 -5
- package/board/published.mjs +18 -17
- package/board/queries.mjs +30 -0
- package/board/store.mjs +18 -2
- package/board/workflow.mjs +42 -16
- package/board/workspace.mjs +21 -10
- package/board-input.d.ts +2 -0
- package/board-input.mjs +33 -1
- package/hermes-cli.mjs +7 -19
- package/index.d.ts +1 -1
- package/loop.mjs +11 -1
- package/mcp/mount.mjs +22 -14
- package/orchestration.mjs +29 -2
- package/package.json +4 -5
package/board/dispatch.mjs
CHANGED
|
@@ -27,11 +27,12 @@ import { readMachineFleet } from './machine-fleet.mjs';
|
|
|
27
27
|
import { homeFleetMachines } from './fleet-targets.mjs';
|
|
28
28
|
import { delegatedWork, idleInterval } from './session-work.mjs';
|
|
29
29
|
import { localSessionDelegations } from '../agents.mjs';
|
|
30
|
-
import { creatorOf, sessionProgress } from './workflow.mjs';
|
|
30
|
+
import { cardSectionHeading, creatorOf, sessionProgress } from './workflow.mjs';
|
|
31
31
|
import { boardLabel, boardName } from '@volter/teams/board-name';
|
|
32
32
|
let fleetRoot = null;
|
|
33
33
|
import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, withFreshReviewReads, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
|
|
34
34
|
import { setImmediate as yieldToOwner } from 'node:timers/promises';
|
|
35
|
+
import { ACTIVE_RUN_IDS, latestEvent, readRunPages } from './queries.mjs';
|
|
35
36
|
import { runtimeDoor } from './runtime-door.mjs';
|
|
36
37
|
import { agentScope, localCardAgent, localReaderContext, localAgentSessions, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, recordUsageUnread, agentPreamble, agentEnvironment, recordAgentRun, cardAgentAssignments,renameCardAgent,handOffAgents, agentRunForSession } from '../agents.mjs';
|
|
37
38
|
import { finishCardAgentAssignment, recordCardAgentAssignment } from './agent-declarations.mjs';
|
|
@@ -50,7 +51,7 @@ import { homedir, hostname, tmpdir, totalmem } from 'node:os';
|
|
|
50
51
|
import { basename, dirname, join } from 'node:path';
|
|
51
52
|
import { createHash, randomUUID } from 'node:crypto';
|
|
52
53
|
import { fileURLToPath } from 'node:url';
|
|
53
|
-
import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, openBoard, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
|
|
54
|
+
import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, NOTICE_RUN_IDS, openBoard, openBoardSnapshot, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
|
|
54
55
|
import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
|
|
55
56
|
import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
|
|
56
57
|
import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
|
|
@@ -829,7 +830,14 @@ async function homeHoldings(root, slugs, lanes, fleet, entry) {
|
|
|
829
830
|
const out = [];
|
|
830
831
|
for (const slug of slugs) {
|
|
831
832
|
try {
|
|
832
|
-
|
|
833
|
+
// Capacity spans awaits. It needs its own committed snapshot, not the shared writer's total_changes counter:
|
|
834
|
+
// unrelated owner calls and delivery writes used to invalidate all three attempts at this read.
|
|
835
|
+
let b;
|
|
836
|
+
try { b = openBoardSnapshot(root, slug); }
|
|
837
|
+
catch (error) {
|
|
838
|
+
if (error.code !== 'board_read_by_owner') throw error;
|
|
839
|
+
b = openBoard(root, slug); // files/non-WAL backings retain their guarded optimistic read
|
|
840
|
+
}
|
|
833
841
|
try {
|
|
834
842
|
b.slug ??= slug; b.label ??= boardLabel(root, slug);
|
|
835
843
|
// each run's session read before it is counted live or not (unknown stays unknown)
|
|
@@ -839,7 +847,7 @@ async function homeHoldings(root, slugs, lanes, fleet, entry) {
|
|
|
839
847
|
runLaneOf: row => runLane(b.db, b, entry, getTask(b.db, row.id), lanes) }));
|
|
840
848
|
}
|
|
841
849
|
finally { b.close(); }
|
|
842
|
-
} catch (error) {
|
|
850
|
+
} catch (error) { error.capacityUnread = true; throw error; }
|
|
843
851
|
}
|
|
844
852
|
out.defaults = harnessDefaults(out);
|
|
845
853
|
return out;
|
|
@@ -926,6 +934,9 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
|
|
|
926
934
|
if (task.subtask_of || task.current_run_id) continue;
|
|
927
935
|
const state = scopeFor(task).card;
|
|
928
936
|
if (state.archived || ['done', 'cancelled'].includes(state.status)) continue;
|
|
937
|
+
if (state.status === 'running' && completedWork(board.db, task.id)) {
|
|
938
|
+
waiting(task, 'work is complete; the board must select its independent review before another implementer run'); continue;
|
|
939
|
+
}
|
|
929
940
|
if (state.detached) { waiting(task, `session belongs to ${state.detached.person}; verified adoption is required`); continue; }
|
|
930
941
|
if (state.dispatch_hold) { waiting(task, 'dispatch held: an explicit start or verified adoption must release it'); continue; }
|
|
931
942
|
if (workflow.run && state.paused) { waiting(task, `paused: ${state.paused.reason ?? 'manager pause'}`); continue; }
|
|
@@ -990,7 +1001,7 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
|
|
|
990
1001
|
const refused = caps.admit(waitingEntries[i], scope, waitingEntries);
|
|
991
1002
|
if (refused) throw Object.assign(new Error(refused.why), { claimedElsewhere: true });
|
|
992
1003
|
assertWorkspaceOwner(db, task, checked);
|
|
993
|
-
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.
|
|
1004
|
+
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, task.id);
|
|
994
1005
|
if (!task.assignee && plan.profile) {
|
|
995
1006
|
db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(plan.profile, task.id);
|
|
996
1007
|
event(db, task.id, 'assigned', { assignee: plan.profile, source: 'workflow role' });
|
|
@@ -1075,7 +1086,7 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
|
|
|
1075
1086
|
const checked = await checkWorkspace(board, task);
|
|
1076
1087
|
if (checked) runTx(board, id, runId, (db) => {
|
|
1077
1088
|
assertWorkspaceOwner(db, task, checked);
|
|
1078
|
-
db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.
|
|
1089
|
+
db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, id);
|
|
1079
1090
|
});
|
|
1080
1091
|
const status = view(board, (db) => statusOf(db, task));
|
|
1081
1092
|
const spec = runSpec(entry, status, task);
|
|
@@ -1208,7 +1219,7 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
|
|
|
1208
1219
|
const moved = tx(board, (db) => {
|
|
1209
1220
|
if (detachedCard(db, id)) throw new Error(`${id} is detached to a person; request and verify adoption before starting it`);
|
|
1210
1221
|
assertWorkspaceOwner(db, before, checked);
|
|
1211
|
-
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.
|
|
1222
|
+
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, id);
|
|
1212
1223
|
if (!roles.some(role => ['manager', 'creator'].includes(role))) throw new Error('Only the manager or creator starts a held card; request its decision.');
|
|
1213
1224
|
event(db, id, 'dispatch_released', { reason: 'explicit start' });
|
|
1214
1225
|
requestSessionClose(db,id);
|
|
@@ -1398,8 +1409,7 @@ function recordInputClose(board, row, session, reading) {
|
|
|
1398
1409
|
/** The closes this process is making now, by run: each one's outcome, for a pass that meets it under way. */
|
|
1399
1410
|
const closesUnderway = new Map();
|
|
1400
1411
|
export async function closeRequested(board, report, { taskId = null, caller = null, ttl = null, fleet = null } = {}) {
|
|
1401
|
-
const rows =
|
|
1402
|
-
WHERE metadata IS NOT NULL ${taskId ? 'AND task_id = ?' : ''}`).all(...(taskId ? [taskId] : [])));
|
|
1412
|
+
const rows = await readRunPages(board.db, 'id, task_id, profile, ended_at, metadata', { task: taskId, where: 'metadata IS NOT NULL' });
|
|
1403
1413
|
// Repair pre-transfer history once: only identities with an older unended reference need a write.
|
|
1404
1414
|
const latest = new Map(), transfers = new Map();
|
|
1405
1415
|
for (const row of [...rows].sort((a,b) => b.id-a.id)) {
|
|
@@ -1413,10 +1423,15 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1413
1423
|
}
|
|
1414
1424
|
}
|
|
1415
1425
|
if (transfers.size) tx(board, (db) => {
|
|
1416
|
-
for (const row of transfers.values())
|
|
1426
|
+
for (const row of transfers.values()) {
|
|
1427
|
+
// The paged scan yielded. Transfer only from the successor's current retained identity.
|
|
1428
|
+
const current = db.prepare('SELECT task_id,metadata FROM task_runs WHERE id=?').get(row.id);
|
|
1429
|
+
if (current) retireSupersededRuns(db, current.task_id, row.id, json(current.metadata, {})?.supercode);
|
|
1430
|
+
}
|
|
1417
1431
|
});
|
|
1418
1432
|
const panes = new Map();
|
|
1419
1433
|
for (const row of rows) {
|
|
1434
|
+
await yieldToOwner();
|
|
1420
1435
|
let session = json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
|
|
1421
1436
|
if (!session || detachedSession(board.db, session) || session.close_transferred_to || session.close_confirmed_at) continue;
|
|
1422
1437
|
// A keyed launch is reconciled by its machine's own daemon (D137), never by a door the close waits on. A session
|
|
@@ -1445,7 +1460,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1445
1460
|
continue;
|
|
1446
1461
|
}
|
|
1447
1462
|
}
|
|
1448
|
-
if (session?.monitor_held && !session.close_requested) continue; // only an explicit close action ends this process
|
|
1463
|
+
if ((session?.monitor_held || session?.retain_after_run) && !session.close_requested) continue; // only an explicit close action ends this process
|
|
1449
1464
|
if ((!session?.pane && session?.surface !== 'native') || session.close_transferred_to || session.close_confirmed_at || (!row.ended_at && !session.close_requested)) continue;
|
|
1450
1465
|
if (caller && session.session_id === caller) continue; // let the command that ended its own run return
|
|
1451
1466
|
// The board closes only what it launched. An adopted session (a person's own thread, a hook session the card was
|
|
@@ -1474,12 +1489,12 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1474
1489
|
const observed = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
1475
1490
|
if (observed?.pane !== session.pane) continue;
|
|
1476
1491
|
if (observed.present === true && observed.yours === true) {
|
|
1477
|
-
if (view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.
|
|
1492
|
+
if (view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all())
|
|
1478
1493
|
.some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); })) continue;
|
|
1479
1494
|
finalFailure = false;
|
|
1480
1495
|
} else if (observed.present !== false) continue;
|
|
1481
1496
|
}
|
|
1482
|
-
const active = view(board, (db) => db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.
|
|
1497
|
+
const active = view(board, (db) => db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all());
|
|
1483
1498
|
// Persist retries even for historical ends that did not have a close_session action. The close is claimed in the
|
|
1484
1499
|
// same transaction, before the pane or process is touched: of two rounds (or a round and a verb) that read one close,
|
|
1485
1500
|
// only the one whose claim committed closes it, and the other never records the pane it finds gone as a failure.
|
|
@@ -1531,7 +1546,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1531
1546
|
const observed = panes.get(machine);
|
|
1532
1547
|
if (observed.error) throw new Error(`close deferred: ${observed.error}`); // an unread pane list is no verdict on the pane
|
|
1533
1548
|
if (observed.live.has(session.pane)) {
|
|
1534
|
-
const protectedNow = view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.
|
|
1549
|
+
const protectedNow = view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all())
|
|
1535
1550
|
.some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); });
|
|
1536
1551
|
if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
|
|
1537
1552
|
// An ended run's session is closed only between its turns, read last, after the claim and the pane list (a pane
|
|
@@ -1583,7 +1598,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1583
1598
|
// Removing only the card's own left every subtask notifying a closed session, each attempt a failed delivery,
|
|
1584
1599
|
// every round. A later run that resumes it by id subscribes it again at its launch (and each round while it is
|
|
1585
1600
|
// the card's current run).
|
|
1586
|
-
const subscribed = db.prepare(
|
|
1601
|
+
const subscribed = db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.id IN (${ACTIVE_RUN_IDS})`).all()
|
|
1587
1602
|
.some((r) => addressOf(json(r.metadata, {})?.supercode) === address && r.id !== row.id);
|
|
1588
1603
|
const unsubscribed = subscribed ? 0 : db.prepare("DELETE FROM kanban_notify_subs WHERE platform = 'supercode' AND chat_id = ? AND (task_id = ? OR json_extract(delivery_metadata, '$.role') = 'worker')").run(address, row.task_id).changes;
|
|
1589
1604
|
event(db, row.task_id, 'session_closed', { session_id: session.session_id, pane: session.pane, machine: session.machine, unsubscribed,
|
|
@@ -1612,7 +1627,18 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1612
1627
|
return Number(rows?.find((row) => row.locator?.session_id === session.session_id)?.updated_at_ms) || null;
|
|
1613
1628
|
};
|
|
1614
1629
|
const managers = [].concat(entry.params.managers ?? []).filter(Boolean);
|
|
1615
|
-
const
|
|
1630
|
+
const running = board.db.prepare(entry.workflow.run ? "SELECT id FROM tasks WHERE current_run_id IS NOT NULL" : "SELECT id FROM tasks WHERE status = 'running'").all();
|
|
1631
|
+
const edges = localSessionDelegations(board.root, localMachine());
|
|
1632
|
+
const reached = new Set(running.map(({ id }) => addressOf(currentRun(board.db, id)?.metadata?.supercode)).filter(Boolean));
|
|
1633
|
+
const delegations = [];
|
|
1634
|
+
const pending = [...reached];
|
|
1635
|
+
const byStarter = new Map();
|
|
1636
|
+
for (const edge of edges) { const children = byStarter.get(edge.starter) ?? []; children.push(edge); byStarter.set(edge.starter, children); }
|
|
1637
|
+
while (pending.length) for (const edge of byStarter.get(pending.pop()) ?? []) {
|
|
1638
|
+
delegations.push(edge);
|
|
1639
|
+
if (!reached.has(edge.session)) { reached.add(edge.session); pending.push(edge.session); }
|
|
1640
|
+
}
|
|
1641
|
+
report.idle_delegations = { recorded: edges.length, reachable: delegations.length };
|
|
1616
1642
|
const delegatedReadings = [...fleet.sessions];
|
|
1617
1643
|
// Forks and concurrent sessions need not have a pane. Read only the exact
|
|
1618
1644
|
// native identities this home's starter receipts name, before any write.
|
|
@@ -1628,7 +1654,6 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1628
1654
|
if (row?.activity) delegatedReadings.push({ address, state: row.activity.presence, turn: row.activity.turn });
|
|
1629
1655
|
} catch { /* an unread child is never called working */ }
|
|
1630
1656
|
}
|
|
1631
|
-
const running = board.db.prepare(entry.workflow.run ? "SELECT id FROM tasks WHERE current_run_id IS NOT NULL" : "SELECT id FROM tasks WHERE status = 'running'").all();
|
|
1632
1657
|
// every running card a session is on: a session waiting on its user is told once, naming them all
|
|
1633
1658
|
const cardsOf = new Map();
|
|
1634
1659
|
for (const { id } of running) {
|
|
@@ -1722,7 +1747,7 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1722
1747
|
const key = [address, live.waiting_for ?? '', live.turn_since_ms ?? ''].join('|');
|
|
1723
1748
|
if (session.held_notice?.key === key) return;
|
|
1724
1749
|
// every run a card works under: an ended run can still be its card's current one
|
|
1725
|
-
const told = db.prepare(
|
|
1750
|
+
const told = db.prepare(`SELECT r.metadata FROM task_runs r WHERE r.id != ? AND r.id IN (${ACTIVE_RUN_IDS})`).all(runRow.id).some((r) => json(r.metadata, {})?.supercode?.held_notice?.key === key);
|
|
1726
1751
|
setRunSession(db, runRow.id, { held_notice: { key, at: observedAt } });
|
|
1727
1752
|
if (told) return;
|
|
1728
1753
|
const observation = retain('waiting_on_user', { waiting_for: live.waiting_for ?? null, since_ms: live.turn_since_ms ?? null });
|
|
@@ -1752,7 +1777,7 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1752
1777
|
if (reading.quiet_seconds < settings.idleQuiet) { retain('turn_unknown'); return; }
|
|
1753
1778
|
if (session.unknown_notice) return;
|
|
1754
1779
|
const key = [address, live?.turn_since_ms ?? 'unknown'].join('|');
|
|
1755
|
-
const told = db.prepare(
|
|
1780
|
+
const told = db.prepare(`SELECT r.metadata FROM task_runs r WHERE r.id != ? AND r.id IN (${ACTIVE_RUN_IDS})`).all(runRow.id)
|
|
1756
1781
|
.some(r => json(r.metadata, {})?.supercode?.unknown_notice?.key === key);
|
|
1757
1782
|
setRunSession(db, runRow.id, { unknown_notice: { key, at: observedAt, since } });
|
|
1758
1783
|
const observation = retain('turn_unknown_reported', { waiting_for: live?.waiting_for ?? null, turn_source: live?.turn_source ?? null, since_ms: live?.turn_since_ms ?? null });
|
|
@@ -2115,9 +2140,12 @@ async function readRunUsage(root,org,row,endedAt){
|
|
|
2115
2140
|
async function synchronizeAgents(board, report) {
|
|
2116
2141
|
// A delayed launch receipt/catalogue event binds the same declared agent, never another identity.
|
|
2117
2142
|
// A resumed session is held by its newest run; an earlier run of the same session no longer owns it.
|
|
2118
|
-
const agentRuns=[...new Map(
|
|
2143
|
+
const agentRuns=[...new Map((await readRunPages(board.db, 'id,task_id,metadata,ended_at', { where: "json_extract(metadata,'$.supercode.agent_id') IS NOT NULL" }))
|
|
2119
2144
|
.map(row=>{const session=json(row.metadata,{})?.supercode;return [session?.session_id?addressOf(session):`run:${row.id}`,row];})).values()];
|
|
2120
|
-
for(const
|
|
2145
|
+
for(const scanned of agentRuns){
|
|
2146
|
+
await yieldToOwner();
|
|
2147
|
+
const row=view(board,db=>db.prepare('SELECT id,task_id,metadata,ended_at FROM task_runs WHERE id=?').get(scanned.id));
|
|
2148
|
+
if(!row)continue;
|
|
2121
2149
|
const session=json(row.metadata,{})?.supercode;
|
|
2122
2150
|
if(!session?.session_id||!session.agent_org||view(board,db=>detachedSession(db,session)))continue;
|
|
2123
2151
|
const ref=`${session.harness}:${session.session_id}`;
|
|
@@ -2276,6 +2304,10 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2276
2304
|
const pending = view(board, (db) => db.prepare('SELECT id, ended_at, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId))
|
|
2277
2305
|
.map((r) => ({ ...r, session: json(r.metadata, {})?.supercode }))
|
|
2278
2306
|
.find((r) => (r.session?.pane || r.session?.launch_key || r.session?.surface === 'native') && !r.session.close_transferred_to && (r.session.slot ?? 'worker') === slot && (r.ended_at || r.session.close_requested) && !r.session.close_confirmed_at
|
|
2307
|
+
// Deliberate retention owes no close. Only this slot's latest identity may reach the liveness/continuation
|
|
2308
|
+
// checks below; an explicit close or another unresolved identity still holds the slot.
|
|
2309
|
+
&& !(r.session.retain_after_run && !r.session.close_requested && !r.session.forgotten && last?.session_id
|
|
2310
|
+
&& r.session.session_id === last.session_id && r.session.pane === last.pane && r.session.machine === machine && (r.session.harness ?? 'claude-code') === harness)
|
|
2279
2311
|
&& !(closeRefused(r.session) && last?.session_id && r.session.session_id === last.session_id));
|
|
2280
2312
|
if (pending) throw unavailable(`slot ${slot} still owns pane ${pending.session.pane} from run ${pending.id}; closure must be confirmed before another session starts: ${pending.session.close_error ?? 'close pending'}`);
|
|
2281
2313
|
let prev = view(board, (db) => lastSession(db, task.id, slot));
|
|
@@ -2423,13 +2455,14 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2423
2455
|
if (next === 'refuse') throw new Error(`session ${prev.session_id} still has live pane ${prev.pane}; cannot resume or replace it`);
|
|
2424
2456
|
if (next === 'keep') {
|
|
2425
2457
|
// the session is kept; the heartbeat's count belongs to the run that ended, so the new run starts it again
|
|
2426
|
-
const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _is, idle_notice: _in, unknown_since: _us, unknown_notice: _un, work_progress: _wp, idle_observation: _io, idle_below_threshold: _ib, idle_eligible: _ie, close_requested: _q, closed_at: _c, close_confirmed_at: _cc, close_error: _ce, close_attempts: _ca, close_attempted_at: _ct, close_failed_at: _cf, close_unread: _cu, close_claim: _cl, monitor_held: _mh, released: _rl, review_held: _rh, ...kept } = prev;
|
|
2458
|
+
const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _is, idle_notice: _in, unknown_since: _us, unknown_notice: _un, work_progress: _wp, idle_observation: _io, idle_below_threshold: _ib, idle_eligible: _ie, close_requested: _q, closed_at: _c, close_confirmed_at: _cc, close_error: _ce, close_attempts: _ca, close_attempted_at: _ct, close_failed_at: _cf, close_unread: _cu, close_claim: _cl, monitor_held: _mh, released: _rl, review_held: _rh, retain_after_run: _rr, ...kept } = prev;
|
|
2427
2459
|
session = { ...kept, mode: 'adopt', launched_at: now(), seen_at: now() };
|
|
2428
2460
|
// a kept session is told what this run is for (its card came back to it, with what happened since), only while
|
|
2429
2461
|
// this run is still the card's current one (its pane and native reads were awaited)
|
|
2430
2462
|
tx(board, (db) => {
|
|
2431
2463
|
if (!runCurrent(db, task.id, runId)) throw Object.assign(new Error(`run ${runId} of ${task.id} ended or was replaced while its session was read; nothing was told to it`), { launchSuperseded: true });
|
|
2432
2464
|
setRunSession(db, runId, { ...session, ...meta });
|
|
2465
|
+
transferRetainedSession(db, task.id, runId, session);
|
|
2433
2466
|
// an earlier run's close of this pane is this run's now: the pane is the kept session's, not a close to retry
|
|
2434
2467
|
if (prev.pane) for (const r of db.prepare('SELECT id, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId)) {
|
|
2435
2468
|
const s = json(r.metadata, {})?.supercode;
|
|
@@ -2444,7 +2477,7 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2444
2477
|
} else if (prev.resumable !== false) {
|
|
2445
2478
|
const authorization = view(board, db => currentRun(db,task.id)?.metadata?.supercode?.restart_authorized);
|
|
2446
2479
|
const released = view(board,db=>{
|
|
2447
|
-
const last = db.
|
|
2480
|
+
const last = latestEvent(db, task.id, ['dispatch_held', 'dispatch_released', 'restart_authorization_consumed'], 'id,kind');
|
|
2448
2481
|
return last?.kind === 'dispatch_released';
|
|
2449
2482
|
});
|
|
2450
2483
|
// Only a session whose run ended in a monitored incident waits for its manager or creator; one a workflow
|
|
@@ -2498,6 +2531,7 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2498
2531
|
const saved = json(row?.metadata, {})?.supercode;
|
|
2499
2532
|
if (saved?.address === session.address && !saved.launch_pending) return true;
|
|
2500
2533
|
setRunSession(db, runId, session);
|
|
2534
|
+
transferRetainedSession(db, task.id, runId, session);
|
|
2501
2535
|
event(db, task.id, kind, { session_id: session.session_id, machine, pane: session.pane, address: session.address, mode: session.mode, ...(replaced ? { replaced } : {}) }, runId);
|
|
2502
2536
|
// the working session hears updates to its card; the slot's previous session no longer does (a reviewer starting
|
|
2503
2537
|
// leaves the implementer's, and a subtask's arc session's, subscription alone)
|
|
@@ -2512,6 +2546,19 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2512
2546
|
return session;
|
|
2513
2547
|
}
|
|
2514
2548
|
|
|
2549
|
+
/** A continued role identity now belongs to its new attempt. Preserve the completed receipt, and keep an old
|
|
2550
|
+
* retained reference from closing the same native process/pane later. Transfer only after a successful bind. */
|
|
2551
|
+
function transferRetainedSession(db, id, runId, session) {
|
|
2552
|
+
if (!session?.session_id) return;
|
|
2553
|
+
for (const row of db.prepare('SELECT id,metadata FROM task_runs WHERE task_id=? AND id<? AND ended_at IS NOT NULL').all(id, runId)) {
|
|
2554
|
+
const old = json(row.metadata, {})?.supercode;
|
|
2555
|
+
if (!old?.retain_after_run || old.close_requested || old.close_confirmed_at || old.close_transferred_to || old.forgotten
|
|
2556
|
+
|| old.session_id !== session.session_id || old.machine !== session.machine || (old.slot ?? 'worker') !== (session.slot ?? 'worker')) continue;
|
|
2557
|
+
setRunSession(db, row.id, { close_transferred_to: runId });
|
|
2558
|
+
event(db, id, 'session_transferred', { successor: runId, session_id: old.session_id, pane: old.pane ?? null }, row.id);
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2515
2562
|
/** The agent a slot's last session belongs to, when it is not the agent this run's `meta` names: the agent store's
|
|
2516
2563
|
* owner of the session where it records one in this run's org, else the agent its own run ran as. Null when it is
|
|
2517
2564
|
* this run's agent, or no agent is known for it (a session from before card agents). */
|
|
@@ -2808,8 +2855,10 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2808
2855
|
// agent store another, and every round after would trip on it.
|
|
2809
2856
|
const owner = agent ? agentRunForSession(board.root, [`${harness}:${sessionId}`]) : null;
|
|
2810
2857
|
if (owner && owner.agent_id !== agent.identity.id) throw new Error(`${found.address} already works for agent ${owner.agent_id} (run ${owner.run_id}); it cannot be adopted as ${agent.identity.id}`);
|
|
2811
|
-
//
|
|
2812
|
-
|
|
2858
|
+
// Each role owns its own slot. A retained worker does not prevent binding the independent reviewer; only this
|
|
2859
|
+
// adoption's slot can conflict with the incoming identity. Independence still reads worker history below.
|
|
2860
|
+
const adoptionSlot = beforeSession?.slot ?? runSpec(entry0, view(board, db => statusOf(db, card0)), card0)?.session ?? 'worker';
|
|
2861
|
+
await readBefore(fleet, view(board, (db) => lastSession(db, id, adoptionSlot)));
|
|
2813
2862
|
const adopted = tx(board, (db) => {
|
|
2814
2863
|
const task = getTask(db, id);
|
|
2815
2864
|
if (!task) throw new Error(`no card ${id}`);
|
|
@@ -2818,7 +2867,7 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2818
2867
|
if (changed) throw changed;
|
|
2819
2868
|
if (beforeRun && assignee && assignee !== task.assignee) throw new Error('rebinding a session does not reassign its active run');
|
|
2820
2869
|
if (task.machine && normalMachine(task.machine) !== host) throw new Error(`${id} runs on ${task.machine}, and ${found.address} is on ${host}; move the card first`);
|
|
2821
|
-
const was = lastSession(db, id);
|
|
2870
|
+
const was = lastSession(db, id, adoptionSlot);
|
|
2822
2871
|
// a session whose close the board confirmed is not live, whatever a fleet reading still lists (a released one was
|
|
2823
2872
|
// left running, and is read)
|
|
2824
2873
|
if (was && was.session_id !== sessionId && !(was.close_confirmed_at && !was.released) && sessionAlive(fleet, was)) throw new Error(`${id} already has a live session, ${was.address}`);
|
|
@@ -2956,8 +3005,11 @@ async function fileMail(board, to, body, key, { ask = false, wake = true, subjec
|
|
|
2956
3005
|
}
|
|
2957
3006
|
|
|
2958
3007
|
/** Fleet listener mail uses the owner's ordinary filing door, including board-copy refusal and sender custody. */
|
|
2959
|
-
export function fileFleetNotice(root, to, body, key) {
|
|
2960
|
-
|
|
3008
|
+
export async function fileFleetNotice(root, to, body, key) {
|
|
3009
|
+
const board = openBoard(root, 'default');
|
|
3010
|
+
board.label = boardLabel(root, 'default');
|
|
3011
|
+
try { return await fileMail(board, to, body, key); }
|
|
3012
|
+
finally { board.close(); }
|
|
2961
3013
|
}
|
|
2962
3014
|
|
|
2963
3015
|
/**
|
|
@@ -3102,9 +3154,13 @@ function noticeWait(board, sub, report, reason = null) {
|
|
|
3102
3154
|
* the latest run that names it. A Claude or Codex session resumed by id keeps its address, so a run that resumes it
|
|
3103
3155
|
* holds it again.
|
|
3104
3156
|
*/
|
|
3105
|
-
function sessionRecord(db) {
|
|
3157
|
+
function sessionRecord(db, address) {
|
|
3106
3158
|
const held = new Set(), latest = new Map();
|
|
3107
|
-
|
|
3159
|
+
const named = sessionAt(address);
|
|
3160
|
+
if (!named) return { held, latest };
|
|
3161
|
+
// Both arms seek one native identity; the join's current_run_id has its own index. Never read unrelated sessions.
|
|
3162
|
+
for (const r of db.prepare(`SELECT r.id,r.task_id,r.ended_at,r.metadata,t.id AS current FROM task_runs r
|
|
3163
|
+
LEFT JOIN tasks t ON t.current_run_id=r.id WHERE r.id IN (${NOTICE_RUN_IDS}) ORDER BY r.id`).all(named.session_id, address)) {
|
|
3108
3164
|
const session = json(r.metadata, {})?.supercode, address = addressOf(session);
|
|
3109
3165
|
if (!address) continue;
|
|
3110
3166
|
if (!r.ended_at || r.current) held.add(address);
|
|
@@ -3152,10 +3208,10 @@ async function endWorkerSubscriptions(board, fleet, report) {
|
|
|
3152
3208
|
if (aliveNow.get(address) === false && subscriberGone(fleet, address)) goneNow.set(address, now());
|
|
3153
3209
|
}
|
|
3154
3210
|
tx(board, (db) => {
|
|
3155
|
-
const { held, latest } = sessionRecord(db);
|
|
3156
3211
|
const remove = db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?");
|
|
3157
3212
|
const ended = new Map();
|
|
3158
3213
|
for (const row of db.prepare("SELECT task_id, chat_id, thread_id FROM kanban_notify_subs WHERE platform = 'supercode' AND json_extract(delivery_metadata, '$.role') = 'worker'").all()) {
|
|
3214
|
+
const { held, latest } = sessionRecord(db, row.chat_id);
|
|
3159
3215
|
const address = row.chat_id, record = latest.get(address);
|
|
3160
3216
|
// a session alive, or whose liveness could not be read (unknown, never taken as ended), keeps its subscription
|
|
3161
3217
|
if (!record || record.session.released || held.has(address) || (fleet.ok && aliveNow.get(address) !== false)) continue;
|
|
@@ -3185,7 +3241,6 @@ async function endWorkerSubscriptions(board, fleet, report) {
|
|
|
3185
3241
|
*/
|
|
3186
3242
|
async function notify(board, fleet, report, entry) {
|
|
3187
3243
|
await endWorkerSubscriptions(board, fleet, report);
|
|
3188
|
-
const record = view(board, (db) => sessionRecord(db));
|
|
3189
3244
|
for (const sub of subscriptions(board.db).filter((s) => s.platform === 'supercode')) {
|
|
3190
3245
|
await yieldToOwner();
|
|
3191
3246
|
if (stopping) break;
|
|
@@ -3198,6 +3253,7 @@ async function notify(board, fleet, report, entry) {
|
|
|
3198
3253
|
const events = board.db.prepare(`SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id LIMIT 64`)
|
|
3199
3254
|
.all(sub.task_id, sub.last_event_id, ...kinds);
|
|
3200
3255
|
if (!events.length) { noticeWait(board, sub, report); continue; }
|
|
3256
|
+
const record = view(board, db => sessionRecord(db, sub.chat_id));
|
|
3201
3257
|
// Events name the acting native session; subscriptions may name its agent.
|
|
3202
3258
|
// Use the daemon's retained binding, including former mains, for both
|
|
3203
3259
|
// coalescing and delivery. An unread binding proves no additional alias.
|
|
@@ -3511,14 +3567,37 @@ export async function plan({ root, lanes, settings, slugs = null, params = {} })
|
|
|
3511
3567
|
return out;
|
|
3512
3568
|
}
|
|
3513
3569
|
|
|
3570
|
+
/** An explicit completion of the latest attempt, after any previous review or reopening. A reclaimed or failed
|
|
3571
|
+
* attempt is not a completed disposition, and a new attempt or returned verdict invalidates the old receipt. */
|
|
3572
|
+
function completedWork(db, id) {
|
|
3573
|
+
const last = db.prepare(`SELECT id,kind,run_id FROM task_events WHERE task_id=?
|
|
3574
|
+
AND kind IN ('work_completed','review_requested','changes_requested','review_pass','review_voided','reopened')
|
|
3575
|
+
ORDER BY id DESC LIMIT 1`).get(id);
|
|
3576
|
+
if (last?.kind !== 'work_completed') return null;
|
|
3577
|
+
const attempt = db.prepare('SELECT id,outcome,ended_at FROM task_runs WHERE task_id=? ORDER BY id DESC LIMIT 1').get(id);
|
|
3578
|
+
return attempt?.id === last.run_id && attempt.outcome === 'completed' && attempt.ended_at != null ? last : null;
|
|
3579
|
+
}
|
|
3580
|
+
|
|
3581
|
+
/** Finish ends the attempt, not its caller's native turn. Keep observing that retained attempt until the board
|
|
3582
|
+
* requests review; the receipt's lifecycle/latest-attempt fence prevents an older session holding later work. */
|
|
3583
|
+
function reviewTurnRun(db, id) {
|
|
3584
|
+
const active = currentRun(db, id);
|
|
3585
|
+
if (active) return active;
|
|
3586
|
+
const completed = completedWork(db, id);
|
|
3587
|
+
if (!completed || detachedCard(db, id)) return null;
|
|
3588
|
+
const row = db.prepare('SELECT * FROM task_runs WHERE id=?').get(completed.run_id);
|
|
3589
|
+
const metadata = json(row?.metadata, {}) ?? {}, session = metadata.supercode;
|
|
3590
|
+
return row && session?.retain_after_run && !session.close_transferred_to && !detachedSession(db, session) ? { ...row, metadata } : null;
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3514
3593
|
/** Whether an arc with no open PR may be one that changed no code and is due its review: it has done work, by filing
|
|
3515
|
-
* tasks or
|
|
3594
|
+
* tasks, an explicit completed-work receipt, or a completed run with an Acceptance outcome ticked, and no
|
|
3516
3595
|
* review of it is pending or passed (after changes are requested, closing the tasks that own the findings makes it
|
|
3517
3596
|
* due again). */
|
|
3518
3597
|
function noCodeReviewDue(board, id) {
|
|
3519
3598
|
return view(board, (db) => {
|
|
3520
3599
|
const filed = db.prepare('SELECT 1 FROM supercode_cards WHERE subtask_of = ? LIMIT 1').get(id);
|
|
3521
|
-
if (!filed && !(db.prepare(
|
|
3600
|
+
if (!filed && !completedWork(db, id) && !(db.prepare("SELECT 1 FROM task_runs WHERE task_id = ? AND outcome = 'completed' AND ended_at IS NOT NULL LIMIT 1").get(id) &&
|
|
3522
3601
|
outcomeTicked(db.prepare('SELECT body FROM tasks WHERE id = ?').get(id)?.body))) return false;
|
|
3523
3602
|
const last = db.prepare("SELECT kind FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass','review_voided') ORDER BY id DESC LIMIT 1").get(id);
|
|
3524
3603
|
return !last || ['changes_requested','review_voided'].includes(last.kind);
|
|
@@ -3534,8 +3613,8 @@ function passedReview(board, id) {
|
|
|
3534
3613
|
function outcomeTicked(body) {
|
|
3535
3614
|
let acceptance = false;
|
|
3536
3615
|
return String(body ?? '').split('\n').some((line) => {
|
|
3537
|
-
const heading =
|
|
3538
|
-
if (heading) acceptance = heading
|
|
3616
|
+
const heading = cardSectionHeading(line);
|
|
3617
|
+
if (heading !== null) acceptance = heading === 'acceptance';
|
|
3539
3618
|
return acceptance && /^\s*[-*] \[[xX]\] \S/.test(line);
|
|
3540
3619
|
});
|
|
3541
3620
|
}
|
|
@@ -3556,7 +3635,7 @@ function outcomeTicked(body) {
|
|
|
3556
3635
|
* running a proof, still holds the review. */
|
|
3557
3636
|
async function turnInFlight(board, id, fleet, heldSince = null) {
|
|
3558
3637
|
if (!fleet?.ok) return null;
|
|
3559
|
-
const session =
|
|
3638
|
+
const session = reviewTurnRun(board.db, id)?.metadata?.supercode;
|
|
3560
3639
|
if (!session?.session_id || session.surface === 'headless' || session.close_requested) return null;
|
|
3561
3640
|
let live = fleet.sessions?.find((x) => x.address === addressOf(session)) ?? null;
|
|
3562
3641
|
if (live?.turn == null && session.surface === 'native') try {
|
|
@@ -3628,14 +3707,17 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
3628
3707
|
}
|
|
3629
3708
|
// A native turn holds the advisory proposal before authoritative source reads. Retain the proposal on the run
|
|
3630
3709
|
// (`review_held`) so a goal turn can be told to have started after that work was first observed.
|
|
3631
|
-
const heldRun =
|
|
3710
|
+
const heldRun = reviewTurnRun(board.db, id), dueKey = reviewDueKey(board, id, payload);
|
|
3632
3711
|
const held = heldRun?.metadata?.supercode?.review_held?.key === dueKey ? heldRun.metadata.supercode.review_held : null;
|
|
3633
3712
|
const inTurn = await turnInFlight(board, id, fleet, held ? Number(held.since) : null);
|
|
3634
3713
|
if (inTurn) {
|
|
3635
|
-
if (!held && !dry && heldRun) tx(board, (db) => { if (
|
|
3714
|
+
if (!held && !dry && heldRun) tx(board, (db) => { if (reviewTurnRun(db, id)?.id === heldRun.id) setRunSession(db, heldRun.id, { review_held: { key: dueKey, since: now() } }); });
|
|
3636
3715
|
report.queued.push([id, `review waits: ${inTurn.why}`]);
|
|
3637
3716
|
continue;
|
|
3638
3717
|
}
|
|
3718
|
+
// A stable local refusal (wrong HEAD or uncommitted work) must not spend a full fresh REST scan every
|
|
3719
|
+
// round. Advisory discovery is sufficient to refuse, never to authorize; the scan below still owns that.
|
|
3720
|
+
if (!payload.no_code) await openReviewPullRequests(board, id, { checkWorkspaces: true });
|
|
3639
3721
|
if (!dry) await refreshCardInputs(board, id, { commits: true });
|
|
3640
3722
|
// Cached draft/closed exclusions are useful while waiting, but cannot define a committed review's set.
|
|
3641
3723
|
const observed = await withFreshReviewReads(async () => {
|
|
@@ -3884,7 +3966,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
3884
3966
|
if (dispatch && !only && !report.paused) await observeIdle(board, fleet, settings, report, entry, effects);
|
|
3885
3967
|
// what the moves asked for is recorded with them; the delivery pass (`deliver`) carries it out, outside the round
|
|
3886
3968
|
queueEffects(board, effects);
|
|
3887
|
-
phase('
|
|
3969
|
+
phase('idle');
|
|
3888
3970
|
ensureSubscriptions(board, entry);
|
|
3889
3971
|
fireHooks(board, settings, report);
|
|
3890
3972
|
if (String(board.db.prepare('PRAGMA journal_mode').get()?.journal_mode).toLowerCase() === 'wal') board.db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get();
|
package/board/engine.mjs
CHANGED
|
@@ -292,7 +292,7 @@ export function createCard(board, spec) {
|
|
|
292
292
|
const handle = cardHandle(boardKey(board.root, board.slug), numberOf(db, id));
|
|
293
293
|
if (namedBranch) run(db, 'UPDATE tasks SET branch_name = ? WHERE id = ?', handle ? namedBranch(handle) : `wt/${id}`, id);
|
|
294
294
|
const machine = spec.machine ?? arc?.machine ?? null;
|
|
295
|
-
run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root,tags,requested_by_unverified) VALUES (?,?,?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.
|
|
295
|
+
run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root,tags,requested_by_unverified) VALUES (?,?,?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.workspaceRoot ?? null,
|
|
296
296
|
tags.length ? JSON.stringify(tags) : null, spec.requestedByUnverified ? JSON.stringify(spec.requestedByUnverified) : null);
|
|
297
297
|
for (const p of parents) addDependency(db, entry, id, p);
|
|
298
298
|
// where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
|
|
@@ -468,8 +468,9 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
|
|
|
468
468
|
// Authorization and the comment row share one transaction: a refusal stores nothing.
|
|
469
469
|
if (name === 'comment') r.commentId = Number(run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', id, payload.author.trim(), payload.body.trim(), now()).lastInsertRowid);
|
|
470
470
|
// the cards that wait on it, and its subtasks, see it change
|
|
471
|
-
|
|
472
|
-
|
|
471
|
+
// Card blockers are mirrored to task_links by relation writes and document imports. Its parent key is the reverse
|
|
472
|
+
// lookup; reading every card's blocked_by JSON on each public verb only rediscovers the same children.
|
|
473
|
+
const around = [...childrenOf(db, id), ...all(db, 'SELECT task_id AS id FROM supercode_cards WHERE subtask_of = ?', id).map((x) => x.id)];
|
|
473
474
|
for (const other of new Set(around)) settle(db, board, entry, other, r.effects);
|
|
474
475
|
return r;
|
|
475
476
|
});
|
|
@@ -532,7 +533,7 @@ export async function goto(board, id, status, { roles = null, payload = {} } = {
|
|
|
532
533
|
// it keeps trying at the capped wait, and at EFFECT_ATTEMPTS failed attempts its card records `effect_stalled` and the
|
|
533
534
|
// managers are mailed once, so a passed card whose merge keeps failing is seen, and still heals when the cause clears.
|
|
534
535
|
export const EFFECT_ATTEMPTS = 6;
|
|
535
|
-
const OWNED = new Set(['merge_reviewed', 'cleanup_workspace']);
|
|
536
|
+
const OWNED = new Set(['merge_reviewed', 'cleanup_workspace', 'rename_card_agents']);
|
|
536
537
|
const effectWait = (attempts) => Math.min(1800, 30 * 2 ** Math.max(0, attempts - 1));
|
|
537
538
|
// A delivery pass claims a row by compare-and-set on its next_at (set ahead only while the row is due) before trying it,
|
|
538
539
|
// so two passes (serve's, the hosted loop's, a person's `workflow deliver`, a verb's own) never try one effect at once.
|
|
@@ -620,6 +621,7 @@ async function runEffect(board, e) {
|
|
|
620
621
|
if (e.kind === 'session_notice') { const { sessionNotice } = await import('./dispatch.mjs'); return await sessionNotice(board, e) !== false; }
|
|
621
622
|
if (e.kind === 'cleanup_workspace') { await cleanupWorkspace(board, e.id); return true; }
|
|
622
623
|
if (e.kind === 'declare_card_agent') { const { declareCardAgentEffect } = await import('./agent-declarations.mjs'); return (await declareCardAgentEffect(board, e)) !== false; }
|
|
624
|
+
if (e.kind === 'rename_card_agents') { const { renameCardAgentsEffect } = await import('./agent-declarations.mjs'); return await renameCardAgentsEffect(board, e); }
|
|
623
625
|
// a verb's own ask files its card's mail at once (a local write; the mailbox delivers it)
|
|
624
626
|
if (e.kind === 'board_message') { const { fileBoardMail } = await import('./dispatch.mjs'); await fileBoardMail(board, { undelivered: [] }, e.id); return true; }
|
|
625
627
|
if (e.kind === 'merge_reviewed') { const { mergeReviewed } = await import('./dispatch.mjs'); return await mergeReviewed(board, e) !== false; }
|
|
@@ -738,17 +740,26 @@ export const editResult = (board, id, { result, summary = null, metadata = undef
|
|
|
738
740
|
export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
|
|
739
741
|
export const setGoal = (board, id, { on = true, maxTurns = null, roles } = {}) => act(board, id, 'set_goal', { roles, payload: { goal: Boolean(on), max_turns: on ? maxTurns : null } });
|
|
740
742
|
|
|
741
|
-
/**
|
|
743
|
+
/** File the named card's tasks only if its workflow accepts decompose; refusal leaves no cards behind. */
|
|
742
744
|
export async function decompose(board, id, children, { author = 'user', roles } = {}) {
|
|
743
745
|
if (!children.length) throw new Error('decompose names at least one child (--child "<title>[@<assignee>]")');
|
|
744
|
-
const
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
746
|
+
const entry = await loadWorkflow(board.root);
|
|
747
|
+
await refreshCardInputs(board, id);
|
|
748
|
+
const result = tx(board, db => {
|
|
749
|
+
const task = must(db, id), sender = roles ?? callerRoles(db, id);
|
|
750
|
+
if (task.subtask_of) throw new Error(`${id} is itself a subtask of ${task.subtask_of}; decompose its arc instead`);
|
|
751
|
+
// Creation and authorization share this transaction. Guards see the actual child ids, and any refusal
|
|
752
|
+
// (including a later child's invalid spec) rolls back every child, event, subscription and queued effect.
|
|
753
|
+
const ids = children.map(c => createCard(board, {
|
|
754
|
+
title: c.title, body: c.body ?? null, assignee: c.assignee ?? task.assignee, machine: task.machine,
|
|
755
|
+
subtaskOf: id, workspace: 'scratch', tenant: task.tenant, createdBy: author,
|
|
756
|
+
}).id);
|
|
757
|
+
const move = fire(db, board, entry, id, 'decompose', { roles: sender, payload: { children: ids, author } });
|
|
758
|
+
for (const child of ids) settle(db, board, entry, child, move.effects);
|
|
759
|
+
return { ids, effects: move.effects };
|
|
760
|
+
});
|
|
761
|
+
await runEffects(board, result.effects);
|
|
762
|
+
return result.ids;
|
|
752
763
|
}
|
|
753
764
|
|
|
754
765
|
/** `child` waits for `parent`. */
|
|
@@ -15,9 +15,9 @@ const refused = message => Object.assign(new Error(message), { code: 'fleet_list
|
|
|
15
15
|
/** A retained mail outbox. `file` is the board's existing admitted mailbox filing door, never a transport or
|
|
16
16
|
* provider selected from the observed payload. No runtime database is opened until the home opts into listeners. */
|
|
17
17
|
export class FleetListenerRouter {
|
|
18
|
-
constructor({ root, file, enrich = event => event, retry = () => {}, log = () => {} }) {
|
|
18
|
+
constructor({ root, file, enrich = event => event, work = fn => fn(), retry = () => {}, log = () => {} }) {
|
|
19
19
|
if (typeof file !== 'function') throw refused('fleet listener router needs the admitted mailbox filing door');
|
|
20
|
-
this.root = root; this.file = file; this.enrich = enrich; this.retry = retry; this.log = log; this.definitions = {}; this.closed = false; this.suspended = false;
|
|
20
|
+
this.root = root; this.file = file; this.enrich = enrich; this.work = work; this.retry = retry; this.log = log; this.definitions = {}; this.closed = false; this.suspended = false;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/** The workflow's decoded declarations. An invalid replacement refuses without discarding the prior outbox. */
|
|
@@ -37,7 +37,15 @@ export class FleetListenerRouter {
|
|
|
37
37
|
this.suspended = false;
|
|
38
38
|
if (Object.keys(next).length) {
|
|
39
39
|
this.store();
|
|
40
|
-
if (!this.timer) {
|
|
40
|
+
if (!this.timer) {
|
|
41
|
+
this.timer = setInterval(() => {
|
|
42
|
+
void Promise.resolve().then(() => {
|
|
43
|
+
if (!this.closed && !this.suspended) return this.retry();
|
|
44
|
+
}).catch(error => this.log(`fleet listener source: ${String(error.message).slice(0, 300)}`))
|
|
45
|
+
.finally(() => this.wake());
|
|
46
|
+
}, 1000);
|
|
47
|
+
this.timer.unref?.();
|
|
48
|
+
}
|
|
41
49
|
this.wake();
|
|
42
50
|
} else if (this.db) {
|
|
43
51
|
this.db.prepare('INSERT INTO fleet_listener_runtime(id,needs_baseline) VALUES(1,1) ON CONFLICT(id) DO UPDATE SET needs_baseline=1').run();
|
|
@@ -212,7 +220,10 @@ export class FleetListenerRouter {
|
|
|
212
220
|
flush() {
|
|
213
221
|
if (this.flushing) return this.flushing;
|
|
214
222
|
if (!this.db || this.closed || this.suspended) return Promise.resolve();
|
|
215
|
-
|
|
223
|
+
// Prepare owner work before replaying retained waits too. Enrichment and enqueue remain synchronous, so the
|
|
224
|
+
// source cursor cannot advance until its complete event body is committed to the outbox.
|
|
225
|
+
this.flushing = Promise.resolve().then(() => this.work(async () => {
|
|
226
|
+
if (this.closed || this.suspended) return;
|
|
216
227
|
// Even a prompt that has since cleared keeps an earlier refused observation. Do not block already queued mail
|
|
217
228
|
// on a full outbox: file it below to make room, then the retained prompt retries at the next wake.
|
|
218
229
|
for (const wait of this.db.prepare('SELECT address,event FROM fleet_listener_waits WHERE routed=0 LIMIT 16').all()) {
|
|
@@ -238,7 +249,7 @@ export class FleetListenerRouter {
|
|
|
238
249
|
this.db.prepare('UPDATE fleet_listener_mail SET attempts=?,next_at=?,error=? WHERE id=?').run(attempts, Date.now() + delay, String(error.message).slice(0, 500), row.id);
|
|
239
250
|
}
|
|
240
251
|
}
|
|
241
|
-
}).finally(() => { this.flushing = null; });
|
|
252
|
+
})).finally(() => { this.flushing = null; });
|
|
242
253
|
return this.flushing;
|
|
243
254
|
}
|
|
244
255
|
|