@volter/supercode-orchestrator 0.5.28 → 0.5.30
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/board/dispatch.mjs +135 -25
- package/board/store.mjs +22 -0
- package/board/ztrack.mjs +6 -1
- package/package.json +1 -1
package/board/dispatch.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { homedir, hostname, tmpdir, totalmem } from 'node:os';
|
|
|
30
30
|
import { basename, dirname, join } from 'node:path';
|
|
31
31
|
import { createHash, randomUUID } from 'node:crypto';
|
|
32
32
|
import { fileURLToPath } from 'node:url';
|
|
33
|
-
import { view, boardCopyOf, boardPath, claimer, copyRefusal, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
|
|
33
|
+
import { view, boardCopyOf, boardPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, openBoard, setActor, tx } from './store.mjs';
|
|
34
34
|
import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
|
|
35
35
|
import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
|
|
36
36
|
import { capacity, harnessDefaults, holdings } from './capacity.mjs';
|
|
@@ -780,7 +780,7 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
|
|
|
780
780
|
|
|
781
781
|
/** Reconcile durable close intent and ended runs, including old unverified closed_at records.
|
|
782
782
|
* Reads and process I/O stay outside the writer. An active successor owns its pane until it ends. */
|
|
783
|
-
export function closeRequested(board, report, { taskId = null, caller = null } = {}) {
|
|
783
|
+
export function closeRequested(board, report, { taskId = null, caller = null, ttl = null } = {}) {
|
|
784
784
|
const rows = view(board, (db) => db.prepare(`SELECT id, task_id, ended_at, metadata FROM task_runs
|
|
785
785
|
WHERE metadata IS NOT NULL ${taskId ? 'AND task_id = ?' : ''}`).all(...(taskId ? [taskId] : [])));
|
|
786
786
|
// Repair pre-transfer history once: only identities with an older unended reference need a write.
|
|
@@ -849,8 +849,19 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
|
|
|
849
849
|
// every round (each attempt is a process). A deferred close (an offline machine, a run that still owns the pane) waits.
|
|
850
850
|
if (session.close_failed_at && session.close_error && !/^close deferred/.test(session.close_error)) continue;
|
|
851
851
|
const active = view(board, (db) => db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)`).all());
|
|
852
|
-
// Persist retries even for historical ends that did not have a close_session action.
|
|
853
|
-
|
|
852
|
+
// Persist retries even for historical ends that did not have a close_session action. The close is claimed in the
|
|
853
|
+
// same transaction, before the pane or process is touched: of two rounds (or a round and a verb) that read one close,
|
|
854
|
+
// only the one whose claim committed closes it, and the other never records the pane it finds gone as a failure.
|
|
855
|
+
const claimed = tx(board, (db) => {
|
|
856
|
+
const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
|
|
857
|
+
if (!current || current.close_confirmed_at || current.close_transferred_to || current.pane !== session.pane || current.session_id !== session.session_id || claimHolds(current.close_claim)) return false;
|
|
858
|
+
requestSessionClose(db, row.task_id, { runId: row.id });
|
|
859
|
+
// a close this process already claims, with a minute of its claim left, is tried again without a new claim
|
|
860
|
+
const own = current.close_claim?.lock === claimer() && Number(current.close_claim.expires) > now() + 60;
|
|
861
|
+
if (!own) setRunSession(db, row.id, { close_claim: newClaim(ttl ?? boardSettings(workflowFor(board.root).params).ttl) });
|
|
862
|
+
return true;
|
|
863
|
+
});
|
|
864
|
+
if (!claimed) continue;
|
|
854
865
|
let error = null;
|
|
855
866
|
try {
|
|
856
867
|
const machine = session.machine ?? localMachine();
|
|
@@ -887,6 +898,8 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
|
|
|
887
898
|
const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
|
|
888
899
|
if (!current || current.pane !== session.pane || current.session_id !== session.session_id) return;
|
|
889
900
|
const at = now();
|
|
901
|
+
// a close confirmed meanwhile (a claim that expired under a slow close) is not undone by this attempt's failure
|
|
902
|
+
if (error && current.close_confirmed_at) return;
|
|
890
903
|
if (error) {
|
|
891
904
|
const final = !/^close deferred/.test(error);
|
|
892
905
|
setRunSession(db, row.id, { ...current, close_attempted_at: at, close_attempts: Number(current.close_attempts ?? 0) + 1, close_error: error, ...(final ? { close_failed_at: at } : {}) });
|
|
@@ -1304,8 +1317,21 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes
|
|
|
1304
1317
|
event(db,row.task_id,mismatch.differences.length?'agent_profile_drift':'agent_profile_recorded',{agent:session.agent_id,session:session.session_id,comparison:mismatch},row.id);
|
|
1305
1318
|
});
|
|
1306
1319
|
}
|
|
1320
|
+
const ref=`${session.harness}:${session.session_id}`;
|
|
1321
|
+
// An ended run whose session the agent store already holds, published, for another agent lost that claim when it
|
|
1322
|
+
// ran: no round can change an ended run or a published owner, so the disagreement is history. It is said once on
|
|
1323
|
+
// its card (agent_ownership_conflict) and the run is not recorded again. A live run is never history: it is
|
|
1324
|
+
// recorded below every round, and a conflict there is refused and said each round as before.
|
|
1325
|
+
if(row.ended_at){
|
|
1326
|
+
let owner=null;try{owner=agentRunForSession(board.root,[ref]);}catch{/* conflicting owners: recorded below, refused there */}
|
|
1327
|
+
if(owner?.published===1&&owner.org===session.agent_org&&owner.agent_id!==session.agent_id){
|
|
1328
|
+
const said=db=>db.prepare("SELECT 1 FROM task_events WHERE task_id=? AND run_id=? AND kind='agent_ownership_conflict' LIMIT 1").get(row.task_id,row.id);
|
|
1329
|
+
if(!view(board,said))tx(board,db=>{if(!said(db))event(db,row.task_id,'agent_ownership_conflict',{session:ref,run_agent:session.agent_id,owner:owner.agent_id,owner_run:owner.run_id},row.id);});
|
|
1330
|
+
continue;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1307
1333
|
// One run whose session another agent already owns is that card's problem, said on it; the round goes on for the rest.
|
|
1308
|
-
try{recordAgentRun(board.root,session.agent_org,session.agent_id,{session
|
|
1334
|
+
try{recordAgentRun(board.root,session.agent_org,session.agent_id,{session:ref,machine:session.machine,run:row.id});}
|
|
1309
1335
|
catch(error){report.queued.push([row.task_id,`run ${row.id}: ${error.message}`]);continue;}
|
|
1310
1336
|
orgs.set(session.agent_org,session.agent_context);
|
|
1311
1337
|
}
|
|
@@ -1674,30 +1700,65 @@ export function requestAdoption(board, id, who, { workspace = null, assignee = n
|
|
|
1674
1700
|
/** How long a requested adoption waits for its session to appear in the fleet before it fails. */
|
|
1675
1701
|
const ADOPTION_LISTING_WINDOW_S = 180;
|
|
1676
1702
|
|
|
1677
|
-
/**
|
|
1703
|
+
/**
|
|
1704
|
+
* The events that answer an adoption request, or hold it: a request is open while it is the card's latest of these, and
|
|
1705
|
+
* a round that acts on it first records `adoption_claimed` (the request, its claim lock and when the claim expires).
|
|
1706
|
+
*/
|
|
1707
|
+
const ADOPTION_ANSWERS = "('dispatch_held','dispatch_released','session_adopted','adoption_failed','adoption_claimed')";
|
|
1708
|
+
const latestAnswer = (db, id) => db.prepare(`SELECT id, kind, payload FROM task_events WHERE task_id = ? AND kind IN ${ADOPTION_ANSWERS} ORDER BY id DESC LIMIT 1`).get(id) ?? null;
|
|
1709
|
+
|
|
1710
|
+
/**
|
|
1711
|
+
* Adoptions the manager requested and no tick has answered yet, completed from this tick's fleet reading. A round claims
|
|
1712
|
+
* a request in a transaction that finds it still open, before anything it does outside the board (`adoptSession`), so
|
|
1713
|
+
* of two rounds that read one request, one binds it and the other leaves it; the bind and the failure are each written
|
|
1714
|
+
* only while that claim is the card's latest.
|
|
1715
|
+
*/
|
|
1678
1716
|
export function completeAdoptions(board, fleet, settings, report, lanes = null) {
|
|
1679
|
-
const pending = view(board, db => db.prepare(`SELECT e.task_id, e.payload, e.created_at FROM task_events e
|
|
1680
|
-
AND json_extract(e.payload, '$.reason') = 'adoption requested'
|
|
1681
|
-
AND e.id = (SELECT MAX(id) FROM task_events l WHERE l.task_id = e.task_id AND l.kind IN
|
|
1717
|
+
const pending = view(board, db => db.prepare(`SELECT e.id, e.task_id, e.kind, e.payload, e.created_at FROM task_events e
|
|
1718
|
+
WHERE ((e.kind = 'dispatch_held' AND json_extract(e.payload, '$.reason') = 'adoption requested') OR e.kind = 'adoption_claimed')
|
|
1719
|
+
AND e.id = (SELECT MAX(id) FROM task_events l WHERE l.task_id = e.task_id AND l.kind IN ${ADOPTION_ANSWERS})`).all());
|
|
1682
1720
|
for (const row of pending) {
|
|
1683
|
-
|
|
1721
|
+
// a request another round has claimed is that round's while its claim holds; a claim whose round died is open again
|
|
1722
|
+
let request = row;
|
|
1723
|
+
if (row.kind === 'adoption_claimed') {
|
|
1724
|
+
const claim = json(row.payload, {}) ?? {};
|
|
1725
|
+
if (claimHolds(claim)) { (report.adoption_skipped ??= []).push([row.task_id, `claimed by ${claim.lock}`]); continue; }
|
|
1726
|
+
request = view(board, db => db.prepare('SELECT id, payload, created_at FROM task_events WHERE id = ?').get(claim.request));
|
|
1727
|
+
if (!request) continue;
|
|
1728
|
+
}
|
|
1729
|
+
const asked = json(request.payload, {}) ?? {};
|
|
1730
|
+
const progress = {};
|
|
1684
1731
|
try {
|
|
1685
|
-
const got = adoptSession(board, row.task_id, asked.session, { workspace: asked.workspace ?? null, assignee: asked.assignee ?? null, role: asked.role ?? null, ttl: settings.ttl, fleet: fleet?.ok ? fleet : null , lanes});
|
|
1732
|
+
const got = adoptSession(board, row.task_id, asked.session, { request: request.id, open: row.id, progress, workspace: asked.workspace ?? null, assignee: asked.assignee ?? null, role: asked.role ?? null, ttl: settings.ttl, fleet: fleet?.ok ? fleet : null , lanes});
|
|
1686
1733
|
(report.adopted ??= []).push([row.task_id, got.address]);
|
|
1687
1734
|
} catch (error) {
|
|
1688
1735
|
const message = String(error.message).slice(-400);
|
|
1689
1736
|
// A session opened moments before its adoption is asked may not be in the fleet's published set yet: the
|
|
1690
1737
|
// card stays held and each tick looks again, until the window closes
|
|
1691
|
-
if (error.notListed && Date.now() / 1000 - Number(
|
|
1738
|
+
if (error.notListed && Date.now() / 1000 - Number(request.created_at) < ADOPTION_LISTING_WINDOW_S) {
|
|
1692
1739
|
report.queued.push([row.task_id, `adoption of ${asked.session} waits: ${message}`]);
|
|
1693
1740
|
continue;
|
|
1694
1741
|
}
|
|
1695
|
-
|
|
1696
|
-
|
|
1742
|
+
// the failure answers the request only while it is still open, or claimed by this round: an adoption that bound and
|
|
1743
|
+
// then failed after (its agent run not recorded) is bound, and a request another round claimed is that round's
|
|
1744
|
+
const answered = !error.superseded && tx(board, db => adoptionOpenTo(db, row.task_id, request.id, row.id) && (event(db, row.task_id, 'adoption_failed', { session: asked.session, error: message }), true));
|
|
1745
|
+
// A request another round answered or claimed first is that round's, not a failure of this one: it is noted where
|
|
1746
|
+
// no round prints it. A failure after this round's own claim (bound, then its agent run not recorded) is one.
|
|
1747
|
+
if (!answered && !progress.claimed) { (report.adoption_skipped ??= []).push([row.task_id, message]); continue; }
|
|
1748
|
+
report.failed.push([row.task_id, `adoption of ${asked.session}${answered ? '' : ' (bound by this round, then failed)'}: ${message}`]);
|
|
1697
1749
|
}
|
|
1698
1750
|
}
|
|
1699
1751
|
}
|
|
1700
1752
|
|
|
1753
|
+
/** Whether request `request` is still this round's to answer: the card's latest answer is `open`, or this process's claim of it. */
|
|
1754
|
+
function adoptionOpenTo(db, id, request, open) {
|
|
1755
|
+
const last = latestAnswer(db, id);
|
|
1756
|
+
if (!last) return false;
|
|
1757
|
+
if (last.id === open) return true;
|
|
1758
|
+
const claim = last.kind === 'adoption_claimed' ? json(last.payload, {}) ?? {} : null;
|
|
1759
|
+
return Boolean(claim) && claim.request === request && claim.lock === claimer();
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1701
1762
|
/**
|
|
1702
1763
|
* A session on this machine that Teams never published (one outside every synced root: a profile's own directory) is
|
|
1703
1764
|
* found through this machine's own doors: its panes, and the native session each pane's process runs. Only a full
|
|
@@ -1719,7 +1780,23 @@ function localListing(who) {
|
|
|
1719
1780
|
return { address: `sc:${localMachine()}:${harness}:${sessionId}`, machine: localMachine(), harness, session_id: sessionId, pane, name: null };
|
|
1720
1781
|
}
|
|
1721
1782
|
|
|
1722
|
-
|
|
1783
|
+
/** The verb an adoption with no current run fires: the workflow's own `adopt` where it declares one, else its dispatch start. */
|
|
1784
|
+
const adoptionVerb = (entry) => (entry.workflow.verbs?.adopt || !entry.workflow.run ? 'adopt' : entry.workflow.dispatch.start);
|
|
1785
|
+
|
|
1786
|
+
/**
|
|
1787
|
+
* The role an adopted session works on its card: a rebound run's own, else the role of the status the adoption's verb
|
|
1788
|
+
* moves the card to, as its claim names it. A card in review adopts its reviewer, never its implementer.
|
|
1789
|
+
*/
|
|
1790
|
+
function adoptedRole(db, board, entry, task, run) {
|
|
1791
|
+
const kept = run?.metadata?.supercode?.role;
|
|
1792
|
+
if (kept) return kept;
|
|
1793
|
+
const verb = adoptionVerb(entry), by = verb === 'adopt' ? 'manager' : 'platform';
|
|
1794
|
+
const status = statusOf(db, task), scope = scopeOf(db, board, task, { params: entry.params });
|
|
1795
|
+
const t = transitionsFor(entry.workflow, status, verb).find((x) => !x.by?.length || x.by.includes(by));
|
|
1796
|
+
return runSpec(entry, t?.target ? String(templateValue(t.target, scope)) : status, task)?.role ?? null;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
function adoptSession(board, id, who, { request = null, open = request, progress = {}, workspace = null, ttl, assignee = null, role = null, fleet = null, lanes = null } = {}) {
|
|
1723
1800
|
fleet ??= observeFleet();
|
|
1724
1801
|
if (!fleet.ok) throw new Error(`supercode could not read the fleet: ${fleet.error}`);
|
|
1725
1802
|
const found = fleet.sessions.find((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who) ?? localListing(who);
|
|
@@ -1745,12 +1822,26 @@ function adoptSession(board, id, who, { workspace = null, ttl, assignee = null,
|
|
|
1745
1822
|
// Rule 2: a session working a card role is that role's agent, however it started; adopted, it joins the agent as a
|
|
1746
1823
|
// launch does, and its recorded profile is compared with the role's intent (rule 4).
|
|
1747
1824
|
const entry0 = workflowFor(board.root), card0 = view(board, (db) => getTask(db, id));
|
|
1748
|
-
const
|
|
1749
|
-
const laneName = view(board, (db) => evaluate(entry0.workflow.roles?.[
|
|
1825
|
+
const agentRole = view(board, (db) => adoptedRole(db, board, entry0, card0, beforeRun)) ?? 'implementer';
|
|
1826
|
+
const laneName = view(board, (db) => evaluate(entry0.workflow.roles?.[agentRole] ?? 'card.assignee', scopeOf(db, board, card0, { params: entry0.params }))) ?? assignee ?? card0?.assignee;
|
|
1750
1827
|
const lane = laneName ? lanes?.[laneName] : null, context = process.env.SUPERCODE_CONTEXT, team = agentScope(context);
|
|
1828
|
+
// The request is claimed before this adoption's first write outside the board (the agent's Teams record, below), in
|
|
1829
|
+
// a transaction that finds it still open and the card's run the one read: a request answered or claimed meanwhile, or
|
|
1830
|
+
// a run that changed, ends it here with nothing written anywhere, and no other round acts on a request claimed here.
|
|
1831
|
+
const stale = (db) => {
|
|
1832
|
+
if (request !== null && !adoptionOpenTo(db, id, request, open)) return Object.assign(new Error(`${id}'s adoption request was answered or claimed meanwhile`), { superseded: true });
|
|
1833
|
+
if ((getTask(db, id)?.current_run_id ?? null) !== (beforeRun?.id ?? null)) return new Error(`${id} changed runs during adoption; retry against its current run`);
|
|
1834
|
+
return null;
|
|
1835
|
+
};
|
|
1836
|
+
if (request !== null) tx(board, (db) => {
|
|
1837
|
+
const refused = stale(db);
|
|
1838
|
+
if (refused) throw refused;
|
|
1839
|
+
event(db, id, 'adoption_claimed', { request, session: who, ...newClaim(ttl) });
|
|
1840
|
+
});
|
|
1841
|
+
progress.claimed = request !== null;
|
|
1751
1842
|
let agent = null;
|
|
1752
1843
|
if (lane && !team.offline) {
|
|
1753
|
-
try { agent = ensureCardAgent({ root: board.root, org: team.org, server: team.server, profiles: entry0.profiles, profile: lane.name, card: id, role:
|
|
1844
|
+
try { agent = ensureCardAgent({ root: board.root, org: team.org, server: team.server, profiles: entry0.profiles, profile: lane.name, card: id, role: agentRole, sponsor: card0?.sponsor ?? null, machine: normalMachine(host) === localMachine() ? null : host, context }); }
|
|
1754
1845
|
catch (error) { if (!teamsUnavailable(error)) throw error; }
|
|
1755
1846
|
}
|
|
1756
1847
|
// A session another agent already owns is refused before anything is recorded: the run would name one agent and the
|
|
@@ -1760,7 +1851,9 @@ function adoptSession(board, id, who, { workspace = null, ttl, assignee = null,
|
|
|
1760
1851
|
const adopted = tx(board, (db) => {
|
|
1761
1852
|
const task = getTask(db, id);
|
|
1762
1853
|
if (!task) throw new Error(`no card ${id}`);
|
|
1763
|
-
|
|
1854
|
+
// the bind is made only while this round's claim is the card's latest and the run is the one read
|
|
1855
|
+
const changed = stale(db);
|
|
1856
|
+
if (changed) throw changed;
|
|
1764
1857
|
if (beforeRun && assignee && assignee !== task.assignee) throw new Error('rebinding a session does not reassign its active run');
|
|
1765
1858
|
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`);
|
|
1766
1859
|
const was = lastSession(db, id);
|
|
@@ -1782,10 +1875,13 @@ function adoptSession(board, id, who, { workspace = null, ttl, assignee = null,
|
|
|
1782
1875
|
event(db, id, 'dispatch_released', { reason: 'verified adoption', session: found.address });
|
|
1783
1876
|
// the manager's adoption is the workflow's own `adopt` verb where it declares one, sent as the manager; else its
|
|
1784
1877
|
// dispatch start, which the platform sends for a verified adoption
|
|
1785
|
-
if (!beforeRun) { const verb = entry
|
|
1878
|
+
if (!beforeRun) { const verb = adoptionVerb(entry);
|
|
1786
1879
|
fire(db, board, entry, id, verb, { roles: [verb === 'adopt' ? 'manager' : 'platform'], payload: { lock: claimer() } }); }
|
|
1787
1880
|
const runId = getTask(db, id).current_run_id;
|
|
1788
1881
|
if (!runId) throw new Error(`${id} waits on parents that are not done`);
|
|
1882
|
+
// the agent joined above is the one of the run's own role; a run of another role would name the wrong agent
|
|
1883
|
+
const runRole = currentRun(db, id)?.metadata?.supercode?.role;
|
|
1884
|
+
if (agent && runRole && runRole !== agentRole) throw new Error(`${id}'s run is its ${runRole}'s, and ${found.address} was joined as its ${agentRole}; adopt again`);
|
|
1789
1885
|
const session = { surface: found.pane ? 'pane' : 'native', session_id: sessionId, harness, machine: host, pane: found.pane ?? null, address: found.address, mode: 'adopt', launched_at: now(), seen_at: now(),
|
|
1790
1886
|
...(agent ? { agent_id: agent.identity.id, agent_org: team.org, agent_context: context ?? null,
|
|
1791
1887
|
agent_profile_intent: { ...structuredClone(agent.intended.resolved), worker: { ...agent.intended.resolved?.worker, harness: lane.harness, model: lane.model ?? null, cwd: dir ?? task.workspace_path ?? null } } } : {}) };
|
|
@@ -2161,17 +2257,22 @@ function fireHooks(board, settings, report) {
|
|
|
2161
2257
|
child.unref();
|
|
2162
2258
|
}
|
|
2163
2259
|
};
|
|
2164
|
-
|
|
2260
|
+
// the events are taken (the cursor moved past them) in one transaction and their hooks run once it commits: a
|
|
2261
|
+
// transaction run again on contention, or a second round, never runs a hook twice
|
|
2262
|
+
const due = tx(board, (db) => {
|
|
2165
2263
|
db.exec('CREATE TABLE IF NOT EXISTS supercode_board_state (key TEXT PRIMARY KEY, value TEXT)');
|
|
2166
2264
|
const max = Number(db.prepare('SELECT COALESCE(MAX(id), 0) AS m FROM task_events').get().m);
|
|
2167
2265
|
const row = db.prepare("SELECT value FROM supercode_board_state WHERE key = 'hooks_cursor'").get();
|
|
2168
2266
|
const cursor = row ? Number(row.value) : max; // the first tick starts at the present, never replays history
|
|
2267
|
+
const taken = [];
|
|
2169
2268
|
for (const ev of db.prepare('SELECT e.id, e.task_id, e.run_id, e.kind, e.payload, e.created_at, t.assignee FROM task_events e LEFT JOIN tasks t ON t.id = e.task_id WHERE e.id > ? ORDER BY e.id').all(cursor)) {
|
|
2170
2269
|
const hook = HOOK_OF[ev.kind];
|
|
2171
|
-
if (hook && hooks[hook])
|
|
2270
|
+
if (hook && hooks[hook]) taken.push([hook, { task_id: ev.task_id, run_id: ev.run_id, event: ev.kind, assignee: ev.assignee, payload: json(ev.payload, null), at: ev.created_at }]);
|
|
2172
2271
|
}
|
|
2173
2272
|
db.prepare("INSERT INTO supercode_board_state (key, value) VALUES ('hooks_cursor', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(String(max));
|
|
2273
|
+
return taken;
|
|
2174
2274
|
});
|
|
2275
|
+
for (const [hook, payload] of due) run(hook, payload);
|
|
2175
2276
|
if (hooks.on_kanban_dispatch_tick) run('on_kanban_dispatch_tick', { started: report.started.length, reclaimed: report.reclaimed.length, crashed: report.crashed.length, timed_out: report.timed_out.length, promoted: report.promoted });
|
|
2176
2277
|
}
|
|
2177
2278
|
|
|
@@ -2207,7 +2308,13 @@ function readMailbox(board, address, report) {
|
|
|
2207
2308
|
try { renameSync(path, join(box, 'cur', name)); } catch { continue; } // another reader took it
|
|
2208
2309
|
// an answer to a question the board asked is what a card waits on (read by the card's workflow), not a verb
|
|
2209
2310
|
if (envelope.in_reply_to) continue;
|
|
2210
|
-
|
|
2311
|
+
// A notice is news for the board, never a card update: a machine's event, or Claude Code's delivery notice about a
|
|
2312
|
+
// message the board sent (filed by the relay from `sc:<machine>:claude-code:notice`, which is no session). Its sender
|
|
2313
|
+
// asked for no reply (reply_via none), so it is filed as read and never answered.
|
|
2314
|
+
if (envelope.kind === 'notice' || envelope.reply_via?.mode === 'none') {
|
|
2315
|
+
report.mailed.push([envelope.from, envelope.from === `sc:${localMachine()}:operator:teams-events` ? 'machine_event' : 'notice']);
|
|
2316
|
+
continue;
|
|
2317
|
+
}
|
|
2211
2318
|
const argv = json(envelope.body, {})?.workflow;
|
|
2212
2319
|
let answer;
|
|
2213
2320
|
if (!Array.isArray(argv) || !argv.length || NOT_BY_MAIL.has(argv[0])) answer = `not applied: a mailed card update is one \`supercode workflow\` verb other than ${[...NOT_BY_MAIL].join(', ')}`;
|
|
@@ -2372,7 +2479,10 @@ function capMax(cap, group, entry, lanes) {
|
|
|
2372
2479
|
/**
|
|
2373
2480
|
* One tick over every board of the home at `root`. `lanes` from `lanesOf`, `settings` from `boardSettings`.
|
|
2374
2481
|
* `only` limits starting to one card (a create that answers whether its card started). Each board is ticked under its
|
|
2375
|
-
* version-checked card claims; no dispatcher file lock.
|
|
2482
|
+
* version-checked card claims; no dispatcher file lock. Two rounds may run at once (the dispatcher's and a one-shot
|
|
2483
|
+
* `workflow dispatch`). An adoption request and a session close are claimed in a transaction (`newClaim`) before
|
|
2484
|
+
* anything is done for them outside the board; every other act relies on its own per-card transaction. Answers one
|
|
2485
|
+
* report per board.
|
|
2376
2486
|
*/
|
|
2377
2487
|
export async function tick({ root, lanes, settings, only = null, slugs = null, dispatch = true, params = {}, deploymentReceipt = false }) {
|
|
2378
2488
|
eventFleetRoot = root;
|
|
@@ -2413,7 +2523,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
2413
2523
|
readMailbox(board, boardAddress(board.label), report); phase('mailbox');
|
|
2414
2524
|
await refreshRelationAnswers(board); phase('answers');
|
|
2415
2525
|
const effects = [];
|
|
2416
|
-
completeAdoptions(board, fleet, settings, report, lanes); reconnectContinuedSessions(board, fleet, entry, report); reconcileLaunches(board, fleet, report, entry, effects, settings, lanes); supervise(board, fleet, settings, report, entry, effects); await runEffects(board, effects.splice(0), { retryNotices: true }); closeRequested(board, report); phase('supervise');
|
|
2526
|
+
completeAdoptions(board, fleet, settings, report, lanes); reconnectContinuedSessions(board, fleet, entry, report); reconcileLaunches(board, fleet, report, entry, effects, settings, lanes); supervise(board, fleet, settings, report, entry, effects); await runEffects(board, effects.splice(0), { retryNotices: true }); closeRequested(board, report, { ttl: settings.ttl }); phase('supervise');
|
|
2417
2527
|
report.unsettled = [];
|
|
2418
2528
|
report.promoted = recomputeReady(board, effects, report.unsettled);
|
|
2419
2529
|
tx(board,db=>auditEndedRuns(db,board,entry));
|
package/board/store.mjs
CHANGED
|
@@ -259,6 +259,28 @@ export const newTaskId = () => `t_${randomBytes(4).toString('hex')}`;
|
|
|
259
259
|
/** The claim lock this process writes, in Hermes's `host:pid` form. */
|
|
260
260
|
export const claimer = () => `${hostname()}:${process.pid}`;
|
|
261
261
|
|
|
262
|
+
/**
|
|
263
|
+
* A claim on one request a round acts on outside the board (an adoption request, a session close): written in the
|
|
264
|
+
* transaction that finds the request still open, before anything outside the board is done for it, so of two rounds
|
|
265
|
+
* that read one request only the one whose claim committed acts on it. It is this process's claim lock and an expiry
|
|
266
|
+
* one claim TTL out, as a card's claim is.
|
|
267
|
+
*/
|
|
268
|
+
export const newClaim = (ttl) => ({ lock: claimer(), expires: now() + (Number(ttl) > 0 ? Number(ttl) : 15 * 60) });
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Whether a claim keeps the caller off its request: another process's, unexpired, and that process alive where its pid
|
|
272
|
+
* can be read (this machine). A claim whose process died is open again at once here, and at its expiry from another
|
|
273
|
+
* machine; the caller's own claim never keeps it off (its rounds run one after another).
|
|
274
|
+
*/
|
|
275
|
+
export function claimHolds(claim) {
|
|
276
|
+
if (!claim?.lock || claim.lock === claimer() || Number(claim.expires) <= now()) return false;
|
|
277
|
+
const lock = String(claim.lock), at = lock.lastIndexOf(':');
|
|
278
|
+
if (lock.slice(0, at) !== hostname()) return true;
|
|
279
|
+
const pid = Number(lock.slice(at + 1));
|
|
280
|
+
if (!(pid > 0)) return false;
|
|
281
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
|
|
282
|
+
}
|
|
283
|
+
|
|
262
284
|
export function json(text, fallback = null) {
|
|
263
285
|
if (text === null || text === undefined || text === '') return fallback;
|
|
264
286
|
try { return JSON.parse(text); } catch { return fallback; }
|
package/board/ztrack.mjs
CHANGED
|
@@ -344,7 +344,12 @@ class ZtrackBoard {
|
|
|
344
344
|
staleDocuments({ pending }) {
|
|
345
345
|
const published = this.get('published', {});
|
|
346
346
|
const inFlight = pending ? this.get('pending_hashes', null) : null;
|
|
347
|
-
|
|
347
|
+
// the repair is said, so a span length written wrong stays visible
|
|
348
|
+
const measured = (doc) => {
|
|
349
|
+
const gap = lf(doc.raw).length - this.entries(doc.role).reduce((sum, e) => sum + e.len, 0);
|
|
350
|
+
if (gap) process.stderr.write(`board: warning: ${doc.path}: its index spans measure ${Math.abs(gap)} character${Math.abs(gap) === 1 ? '' : 's'} ${gap > 0 ? 'short of' : 'past'} its text; this write reads it again\n`);
|
|
351
|
+
return !gap;
|
|
352
|
+
};
|
|
348
353
|
return this.docs.map((doc) => ({ ...doc, ...this.fileHash(doc.path) }))
|
|
349
354
|
.filter((doc) => (doc.hash !== published[doc.role] && !(inFlight?.[doc.role] && doc.hash === inFlight[doc.role].before)) || (!pending && !measured(doc)))
|
|
350
355
|
.map((doc) => ({ ...doc, text: lf(doc.raw) }));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.30",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
|
|
6
6
|
"exports": {
|