@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.
@@ -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
- tx(board, (db) => requestSessionClose(db, row.task_id, { runId: row.id }));
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:`${session.harness}:${session.session_id}`,machine:session.machine,run:row.id});}
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
- /** Adoptions the manager requested and no tick has answered yet, completed from this tick's fleet reading. */
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 WHERE e.kind = 'dispatch_held'
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 ('dispatch_held','dispatch_released','session_adopted','adoption_failed'))`).all());
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
- const asked = json(row.payload, {}) ?? {};
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(row.created_at) < ADOPTION_LISTING_WINDOW_S) {
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
- tx(board, db => event(db, row.task_id, 'adoption_failed', { session: asked.session, error: message }));
1696
- report.failed.push([row.task_id, `adoption of ${asked.session}: ${message}`]);
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
- function adoptSession(board, id, who, { workspace = null, ttl, assignee = null, role = null, fleet = null, lanes = null } = {}) {
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 spec0 = view(board, (db) => runSpec(entry0, entry0.workflow.verbs?.adopt?.[0]?.target ?? 'running', card0));
1749
- const laneName = view(board, (db) => evaluate(entry0.workflow.roles?.[spec0?.role] ?? 'card.assignee', scopeOf(db, board, card0, { params: entry0.params }))) ?? assignee ?? card0?.assignee;
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: spec0?.role ?? 'implementer', sponsor: card0?.sponsor ?? null, machine: normalMachine(host) === localMachine() ? null : host, context }); }
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
- if ((task.current_run_id ?? null) !== (beforeRun?.id ?? null)) throw new Error(`${id} changed runs during adoption; retry against its current run`);
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.workflow.verbs?.adopt || !entry.workflow.run ? 'adopt' : entry.workflow.dispatch.start;
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
- tx(board, (db) => {
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]) run(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 });
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
- if (envelope.from === `sc:${localMachine()}:operator:teams-events` && envelope.kind === 'notice') { report.mailed.push([envelope.from, 'machine_event']); continue; }
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. Answers one report per board.
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
- const measured = (doc) => this.entries(doc.role).reduce((sum, e) => sum + e.len, 0) === lf(doc.raw).length;
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.28",
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": {