@volter/supercode-orchestrator 0.5.133 → 0.5.135

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/cli.mjs CHANGED
@@ -362,7 +362,7 @@ export async function runVerb(argv) {
362
362
  if (sid && !READS.has(verb) && boardExists(root, slug)) {
363
363
  // binding the caller's launch is a write: only the owner, answering the caller's own verb, makes it (a local read
364
364
  // never writes)
365
- await withBoardAsync(async (b) => { if (isCallersCall()) await reconcileCallerLaunch(b, sid); const own = ownCard(b.db, sid); if (own) authorize(b.db, verb, rest, args, own); });
365
+ await withBoardAsync(async (b) => { if (isCallersCall()) await reconcileCallerLaunch(b, sid); const own = ownCards(b.db, sid); if (own.worked.size) authorize(b.db, verb, rest, args, own); });
366
366
  }
367
367
  // the board's workflow (the IR's): what each verb does, and who may send it, is its
368
368
  // every verb reads the board through its workflow (a list settles cards too), so every verb loads it
@@ -1378,10 +1378,28 @@ function workerRun(db, id, sessionId) {
1378
1378
  return hit ? Number(hit.id) : null;
1379
1379
  }
1380
1380
 
1381
- function ownCard(db, sessionId) {
1382
- const hit = db.prepare('SELECT task_id FROM task_runs WHERE metadata LIKE ? ORDER BY id DESC LIMIT 1').get(`%"session_id":"${sessionId}"%`);
1383
- if (!hit) return null;
1384
- return engine.lastSession(db, hit.task_id)?.session_id === sessionId ? engine.getTask(db, hit.task_id) : null;
1381
+ /**
1382
+ * The cards the calling session works. A session in no pane can hold several cards at once (an adoption onto a second
1383
+ * card binds its run beside the first; only a pane's session is one run's alone, workflow.mjs retireSupersededRuns), so
1384
+ * this is a set: `worked`, every card whose current run names the session as
1385
+ * its worker, and the card of the newest run naming it while it is still that card's last worker (its card between
1386
+ * runs); `held`, those and every card whose current run names it in any slot (a card it reviews). A session with no
1387
+ * worked card is held to nothing here. The newest run alone named one card, and a session adopted into a second card
1388
+ * was refused every write on its first (volter-org t_9a9c2a9c: no comment, heartbeat or closed subtask there, read
1389
+ * by the board as no progress).
1390
+ */
1391
+ function ownCards(db, sessionId) {
1392
+ const worked = new Set(), held = new Set();
1393
+ const like = `%"session_id":"${sessionId}"%`;
1394
+ for (const row of db.prepare('SELECT t.id, r.metadata FROM tasks t JOIN task_runs r ON r.id = t.current_run_id WHERE r.metadata LIKE ?').all(like)) {
1395
+ const session = json(row.metadata, {})?.supercode;
1396
+ if (session?.session_id !== sessionId) continue;
1397
+ held.add(row.id);
1398
+ if ((session.slot ?? 'worker') === 'worker') worked.add(row.id);
1399
+ }
1400
+ const newest = db.prepare('SELECT task_id FROM task_runs WHERE metadata LIKE ? ORDER BY id DESC LIMIT 1').get(like);
1401
+ if (newest && engine.lastSession(db, newest.task_id)?.session_id === sessionId) { worked.add(newest.task_id); held.add(newest.task_id); }
1402
+ return { worked, held };
1385
1403
  }
1386
1404
 
1387
1405
  const READS = new Set(['list', 'ls', 'show', 'context', 'brief', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'notify-list', 'capacity', 'help', 'init']);
@@ -1432,19 +1450,21 @@ function rolesFor(db, cardId, sessionId, params = {}) {
1432
1450
  }
1433
1451
 
1434
1452
  function authorize(db, verb, rest, args, own) {
1435
- const refuse = (why) => { throw new Error(`the session working ${own.id} ${why}`); };
1453
+ const cards = [...own.worked];
1454
+ const named = cards.join(', ');
1455
+ const refuse = (why) => { throw new Error(`the session working ${named} ${why}`); };
1436
1456
  if (verb === 'create') {
1437
- if (args['subtask-of'] !== own.id) refuse(`files subtasks of its own card only (--subtask-of ${own.id})`);
1457
+ if (!own.worked.has(args['subtask-of'])) refuse(`files subtasks of its own card only (--subtask-of ${cards.join(' or ')})`);
1438
1458
  return;
1439
1459
  }
1440
1460
  if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
1441
1461
  // verbs that act on the board rather than on a card are the manager's and the board's, never a card's session's
1442
1462
  // (what a session may do to a card is its workflow's: each transition's `by`)
1443
1463
  if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'close', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
1444
- if (verb === 'set' && !(rest[0] === own.id && rest[1] === 'every')) refuse("sets only Every on its own auditor card; other properties are the manager's");
1464
+ if (verb === 'set' && !(own.worked.has(rest[0]) && rest[1] === 'every')) refuse("sets only Every on its own auditor card; other properties are the manager's");
1445
1465
  if (args.all) refuse(`does not ${verb} every card; only its own`);
1446
1466
  if (['notify-subscribe', 'notify-unsubscribe'].includes(verb) && args['for-card']) {
1447
- if (args['for-card'] !== own.id) refuse(`subscribes only its own arc (--for-card ${own.id})`);
1467
+ if (!own.worked.has(args['for-card'])) refuse(`subscribes only its own arc (--for-card ${cards.join(' or ')})`);
1448
1468
  return;
1449
1469
  }
1450
1470
  const targets = verb === 'request-state' ? [...rest, ...(args.ids ?? [])] : [rest[0], ...(args.ids ?? [])];
@@ -1453,7 +1473,8 @@ function authorize(db, verb, rest, args, own) {
1453
1473
  if (!card) continue;
1454
1474
  if (isCaller(completionOwnerOf(db, card.id))) continue;
1455
1475
  if (verb === 'comment' && isLatestReviewer(db, card.id, actorOf())) continue;
1456
- if (card.id !== own.id && card.subtask_of !== own.id) refuse(`writes only its own card and its subtasks, not ${card.id}`);
1476
+ // the card it names is the one it addresses: any card it holds, or a subtask of one it works
1477
+ if (!own.held.has(card.id) && !own.worked.has(card.subtask_of)) refuse(`writes only its own card${cards.length > 1 ? 's' : ''} and ${cards.length > 1 ? 'their' : 'its'} subtasks, not ${card.id}`);
1457
1478
  }
1458
1479
  }
1459
1480
 
@@ -383,6 +383,8 @@ const unavailable = message => Object.assign(new Error(message), { launchDeferre
383
383
  function progressedSince(db, task, runRow) {
384
384
  const started = Number(runRow?.started_at ?? 0);
385
385
  const progress = sessionProgress(db, task);
386
+ // an unread checkout is not a run without commits: it is never counted as one
387
+ if (progress.commit == null) return true;
386
388
  if (progress.commit >= started && progress.commit > 0) return true;
387
389
  const at = progress.event ? db.prepare('SELECT created_at FROM task_events WHERE id=?').get(progress.event)?.created_at : null;
388
390
  return at != null && Number(at) >= started;
@@ -500,7 +502,11 @@ async function supervise(board, fleet, settings, report, entry, effects) {
500
502
  return;
501
503
  }
502
504
  if (session.offline_since != null) {
503
- setRunSession(db,runRow.id,{...session,offline_since:null,offline_notified_at:null});
505
+ // What the board could not see is not the session's: the outage is no working time of the run (its runtime
506
+ // limit, its coding time), its idle and unknown-turn clocks start again from here (observeIdle), and a
507
+ // session read missing before the machine went is read again before it is missed.
508
+ setRunSession(db,runRow.id,{...session,offline_since:null,offline_notified_at:null,missing_since:null,unknown_since:null,watch_since:t,
509
+ stopped_seconds:Number(session.stopped_seconds??0)+Math.max(0,t-Number(session.offline_since))});
504
510
  observe('machine_online',{machine:session.machine,reason:'machine linked again'},null);
505
511
  session=currentRun(db,id)?.metadata?.supercode ?? session;
506
512
  }
@@ -1258,6 +1264,9 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1258
1264
  }
1259
1265
  }
1260
1266
 
1267
+ // When this process last read each run's turn (run id → seconds). Kept in memory on purpose: a board that restarted
1268
+ // has watched nothing, and its first reading of a run is where that run's watch begins.
1269
+ const idleWatch = new Map();
1261
1270
  /** Native idle intervals are independent of dispatch cadence and transcript writes.
1262
1271
  * Keep bounded readings on the run so an ordinary quiet stretch can be reviewed later. */
1263
1272
  async function observeIdle(board, fleet, settings, report, entry, effects) {
@@ -1296,15 +1305,18 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1296
1305
  if (addressOf(session)) cardsOf.set(addressOf(session), [...(cardsOf.get(addressOf(session)) ?? []), id]);
1297
1306
  }
1298
1307
  for (const { id } of running) {
1299
- let staged = [];
1308
+ let staged = [], watched = null;
1300
1309
  txRead(board, (db) => {
1301
- staged = [];
1310
+ staged = []; watched = null;
1302
1311
  const task = getTask(db, id);
1303
1312
  const status = statusOf(db, task);
1304
1313
  if (!(entry.workflow.statuses[status]?.on?.session_idle || entry.workflow.on?.session_idle || entry.workflow.run?.on?.session_idle)) return;
1305
1314
  const runRow = currentRun(db, id);
1306
- const session = runRow?.metadata?.supercode;
1315
+ let session = runRow?.metadata?.supercode;
1307
1316
  if (!session?.session_id || !['pane', 'native'].includes(session.surface) || session.offline_since != null) return;
1317
+ // a machine this round did not read as online (offline, or its door silent) shows nothing of its session: no
1318
+ // clock of the run moves on a round that could not see it
1319
+ if (session.machine && !fleet.online.has(session.machine)) return;
1308
1320
  if (session.surface === 'native' && sessionAlive(fleet, session) !== true) return;
1309
1321
  // a session in no pane has its turn on its machine's discovery row, which sessionAlive just read
1310
1322
  let nativeRow = null;
@@ -1312,10 +1324,23 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1312
1324
  const live = fleet.sessions.find((x) => x.address === addressOf(session)) ?? (nativeRow ? { address: addressOf(session), turn: nativeRow.activity?.turn ?? null, waiting_for: nativeRow.activity?.evidence?.waiting_for ?? null,
1313
1325
  turn_source: nativeRow.activity?.evidence?.source ?? null, turn_since_ms: nativeRow.activity?.evidence?.native_state_since_ms ?? null } : undefined);
1314
1326
  const observedAt = now();
1315
- const progress = sessionProgress(db, task);
1327
+ // Quiet time is time the board watched. A run this process has not read for a while (the board restarted, was
1328
+ // paused, could not read the fleet or the machine) starts its idle and unknown-turn clocks from this reading:
1329
+ // the gap is not idleness, and no reminder is spent on it (volter-org t_9a9c2a9c: reminder 1 in the second its
1330
+ // machine came back, the run reclaimed 45 minutes later).
1331
+ const lastWatched = idleWatch.get(runRow.id);
1332
+ if (lastWatched == null || observedAt - lastWatched > Math.max(3 * settings.interval, settings.idleQuiet / 2)) {
1333
+ setRunSession(db, runRow.id, { watch_since: observedAt, unknown_since: null });
1334
+ session = { ...session, watch_since: observedAt, unknown_since: null };
1335
+ }
1336
+ watched = [runRow.id, observedAt];
1337
+ const read = sessionProgress(db, task);
1316
1338
  const priorProgress = session.work_progress;
1317
- const worked = Boolean(priorProgress) && (progress.event > priorProgress.event || progress.commit > priorProgress.commit);
1318
- if (!priorProgress || worked) setRunSession(db, runRow.id, { work_progress: progress, ...(worked ? { goal_turns: 0 } : {}) });
1339
+ // The commit a run is measured from is the last one read. A checkout that could not be read (its machine silent,
1340
+ // its daemon without the door) keeps that baseline: its first reading after is where it stands, not work done.
1341
+ const progress = { event: read.event, commit: read.commit ?? priorProgress?.commit ?? null, ...(read.commit == null ? { commit_unread: read.commit_unread } : {}) };
1342
+ const worked = Boolean(priorProgress) && (progress.event > priorProgress.event || (read.commit != null && priorProgress.commit != null && read.commit > priorProgress.commit));
1343
+ if (!priorProgress || worked || (priorProgress.commit == null && read.commit != null)) setRunSession(db, runRow.id, { work_progress: { event: progress.event, commit: progress.commit }, ...(worked ? { goal_turns: 0 } : {}) });
1319
1344
  const lastAtMs = Number(live?.last_message_at_ms) || updatedAt(session);
1320
1345
  const lastAt = Math.floor(Number(lastAtMs) / 1000);
1321
1346
  const reading = {
@@ -1407,6 +1432,9 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1407
1432
  if (session.idle_since !== interval.since) setRunSession(db, runRow.id, { idle_since: interval.since });
1408
1433
  if (reading.quiet_seconds < settings.idleQuiet) { retain('below_threshold'); return; }
1409
1434
  if (session.goal_nudged_at && observedAt - session.goal_nudged_at < settings.idleQuiet) { retain('nudge_cooldown'); return; }
1435
+ // A reminder says the card shows no progress, and the last of them ends the run for it. With the card's checkout
1436
+ // unread that is not known: nothing is said or counted until it is read (the reading's `progress` says why).
1437
+ if (read.commit == null) { retain('progress_unread'); return; }
1410
1438
  try {
1411
1439
  const where = whereOf(board, task);
1412
1440
  // the ask names what the session may do, as its run's prompt did
@@ -1424,8 +1452,10 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1424
1452
  report.failed.push([id, `session_idle: ${String(error.message).slice(-200)}`]);
1425
1453
  }
1426
1454
  });
1455
+ if (watched) idleWatch.set(...watched);
1427
1456
  effects.push(...staged);
1428
1457
  }
1458
+ for (const [runId, at] of idleWatch) if (now() - at > 86_400) idleWatch.delete(runId);
1429
1459
  }
1430
1460
 
1431
1461
  function effectMessageKey(effect) {
@@ -2865,6 +2895,11 @@ function noCodeReviewDue(board, id) {
2865
2895
  });
2866
2896
  }
2867
2897
 
2898
+ /** Whether the arc's last review event is a pass: its review is over, and neither requested, returned nor voided since. */
2899
+ function passedReview(board, id) {
2900
+ return view(board, (db) => 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)?.kind === 'review_pass');
2901
+ }
2902
+
2868
2903
  /** Whether a card body ticks some outcome under its Acceptance heading. */
2869
2904
  function outcomeTicked(body) {
2870
2905
  let acceptance = false;
@@ -2917,7 +2952,10 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
2917
2952
  // requested the next run is the implementer's, until a new head is pushed.
2918
2953
  // An arc that closed its tasks having changed no code gets its one review of the act itself (ADR 0003).
2919
2954
  if (!payload && noCodeReviewDue(board, id)) payload = await noCodeChange(board, id);
2920
- if (!payload) { report.queued.push([id, 'review waits: no open or unjudged merged PR on its declared branches, and no completed disposition']); continue; }
2955
+ // An arc whose last verdict is a pass, with nothing unjudged since, waits on no review: what is left is its
2956
+ // session's done request and the manager's or creator's `complete`, and the round says that (volter-org
2957
+ // t_b499d0cf and t_a2bd2a5f were logged as waiting for a review every round for hours after theirs passed).
2958
+ if (!payload) { report.queued.push([id, passedReview(board, id) ? 'its review passed and no PR head is unjudged since: no review is due; the arc waits on `complete`' : 'review waits: no open or unjudged merged PR on its declared branches, and no completed disposition']); continue; }
2921
2959
  if (payload.waits) { report.queued.push([id, `review waits: ${payload.waits}`]); payload = null; continue; }
2922
2960
  if (!payload.no_code && !await reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
2923
2961
  report.queued.push([id, `review waits: every open PR head (${payload.pr.join(', ')}) has a verdict or predates the last one; push a commit that answers it`]);
@@ -40,10 +40,13 @@ export function delegatedWork(address, edges, sessions) {
40
40
  return working;
41
41
  }
42
42
 
43
- /** Idle time belongs to a native idle interval, never to transcript freshness. */
43
+ /** Idle time belongs to a native idle interval, never to transcript freshness, and only to time the board watched:
44
+ * it starts no earlier than `session.watch_since`, where the board's unbroken reading of the run began (its machine
45
+ * linked again, the board restarted or resumed). Quiet the board could not see is not idleness. */
44
46
  export function idleInterval(session, live, observedAt) {
45
47
  const nativeSince = Number(live?.turn_since_ms);
46
- const since = nativeSince > 0 && nativeSince <= observedAt * 1000
48
+ const read = nativeSince > 0 && nativeSince <= observedAt * 1000
47
49
  ? Math.floor(nativeSince / 1000) : Number(session.idle_since ?? observedAt);
50
+ const since = Math.min(observedAt, Math.max(read, Number(session.watch_since ?? 0)));
48
51
  return { since, quiet: Math.max(0, observedAt - since) };
49
52
  }
@@ -15,7 +15,7 @@ import { randomBytes } from 'node:crypto';
15
15
  import { actorOf, appendRunObservation, event as record, json, now } from './store.mjs';
16
16
  import { evaluate, holds, render } from './expr.mjs';
17
17
  import { completionEvidence, declaredWorkBranches, namedWorkBranch, reviewCandidate } from './ci.mjs';
18
- import { isWorkspacePath } from './workspace.mjs';
18
+ import { cardCommitsOn, isLocalWorkspace, isWorkspacePath } from './workspace.mjs';
19
19
  import { identifyNotice } from './notices.mjs';
20
20
 
21
21
  const loaded = new Map();
@@ -206,64 +206,92 @@ function pattern(p) {
206
206
  // and the default branch they merge into. Another ref naming the card (an evidence branch, a later card's work) is not
207
207
  // a change to its reviewed work. Merge commits are not the card's work: merging its reviewed PR, or main into its
208
208
  // branch, is not a newer change to review.
209
- const commitTimes = new Map();
210
- async function observeLastCommit(path, id, branches = []) {
211
- if (!path) return null;
212
- const key = `${path}:${id}`;
213
- const cached = commitTimes.get(key);
214
- if (cached && now() - cached.at < 15) return cached.value;
209
+ //
210
+ // A reading is known or it is not. A checkout on this machine is read here; one on another machine is read by that
211
+ // machine's daemon (workspace.mjs cardCommitsOn, the same reading: sdk/teams/workspaces.mjs cardCommitTime), never by
212
+ // running git here on a path of that machine (a Windows path read on a Mac answered no commits for as long as the card
213
+ // ran: volter-org t_9a9c2a9c). A folder that is not there, a git that did not answer, a machine that could not be asked:
214
+ // unknown, with why, and never "no commits".
215
+ const commitTimes = new Map(); // `${path}:${id}` → { at, known, value, error }
216
+ async function readCardCommit(path, id, branches) {
217
+ if (!existsSync(path)) return { known: false, value: null, error: `no folder ${path} on this machine` };
215
218
  // awaited, never a wait that holds the event loop: `status` 0 or the exit code, as a synchronous run answered
216
- const git = (repo, args) => execFileAsync('git', ['-C', repo, ...args], { encoding: 'utf8', timeout: 10_000 }).then(({ stdout }) => ({ status: 0, stdout }), (error) => ({ status: error.code ?? 1, stdout: error.stdout ?? '' }));
219
+ const git = (repo, args) => execFileAsync('git', ['-C', repo, ...args], { encoding: 'utf8', timeout: 10_000 }).then(({ stdout }) => ({ status: 0, stdout }), (error) => ({ status: error.code ?? 1, stdout: error.stdout ?? '', timedOut: Boolean(error.killed) }));
217
220
  const readCommit = async (repo) => {
218
221
  const listed = await git(repo, ['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes']);
219
- if (listed.status !== 0) return { repo: false, value: null };
222
+ if (listed.timedOut) return { repo: true, known: false, value: null, error: `git did not answer in ${repo} within 10s` };
223
+ if (listed.status !== 0) return { repo: false, known: true, value: null };
220
224
  const current = (await git(repo, ['branch', '--show-current'])).stdout?.trim();
221
225
  const fallback = (await git(repo, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])).stdout?.trim().replace(/^refs\/remotes\/origin\//, '');
222
226
  const names = new Set([...branches, current, fallback].filter(Boolean));
223
227
  const short = (ref) => ref.startsWith('refs/heads/') ? ref.slice(11) : ref.split('/').slice(3).join('/');
224
228
  const refs = listed.stdout.split('\n').filter((ref) => ref && !ref.endsWith('/HEAD') && (names.has(short(ref)) || namedWorkBranch(id, short(ref))));
225
- if (!refs.length) return { repo: true, value: null };
229
+ if (!refs.length) return { repo: true, known: true, value: null };
226
230
  const r = await git(repo, ['log', ...refs, '--no-merges', '-1', '--format=%ct', '--fixed-strings', `--grep=${id}`, '--']);
231
+ if (r.status !== 0) return { repo: true, known: false, value: null, error: `git log ${r.timedOut ? 'did not answer within 10s' : 'failed'} in ${repo}` };
227
232
  const value = Number(r.stdout?.trim());
228
- return { repo: r.status === 0, value: Number.isFinite(value) && value > 0 ? value : null };
233
+ return { repo: true, known: true, value: Number.isFinite(value) && value > 0 ? value : null };
229
234
  };
230
235
  const own = await readCommit(path);
231
- let value = own.value;
232
- if (!own.repo) {
233
- // A fleet card may span sibling repositories under its declared workspace.
234
- // Only direct children with a Git door participate; never scan arbitrary home trees.
235
- let entries = []; try { entries = readdirSync(path, { withFileTypes: true }); } catch {}
236
- for (const child of entries) {
237
- if (!child.isDirectory() && !child.isSymbolicLink()) continue;
238
- const repo = resolve(path, child.name);
239
- if (!existsSync(resolve(repo, '.git'))) continue;
240
- const t = (await readCommit(repo)).value;
241
- if (t != null) value = Math.max(value ?? 0, t);
242
- }
236
+ if (own.repo) return { known: own.known, value: own.value, ...(own.error ? { error: own.error } : {}) };
237
+ // A fleet card may span sibling repositories under its declared workspace.
238
+ // Only direct children with a Git door participate; never scan arbitrary home trees.
239
+ let value = null, unread = null;
240
+ let entries = []; try { entries = readdirSync(path, { withFileTypes: true }); } catch (error) { return { known: false, value: null, error: `${path} could not be listed: ${error.code ?? error.message}` }; }
241
+ for (const child of entries) {
242
+ if (!child.isDirectory() && !child.isSymbolicLink()) continue;
243
+ const repo = resolve(path, child.name);
244
+ if (!existsSync(resolve(repo, '.git'))) continue;
245
+ const read = await readCommit(repo);
246
+ if (!read.known) unread ??= read.error;
247
+ if (read.value != null) value = Math.max(value ?? 0, read.value);
243
248
  }
244
- commitTimes.set(key, { at: now(), value });
245
- return value;
249
+ // a commit that was read stands; with none read, a child that could not be read leaves the whole reading unknown
250
+ return value == null && unread ? { known: false, value: null, error: unread } : { known: true, value };
251
+ }
252
+ async function observeLastCommit(path, id, branches = [], machine = null) {
253
+ if (!path) return null;
254
+ const key = `${path}:${id}`;
255
+ const cached = commitTimes.get(key);
256
+ if (cached && now() - cached.at < 15) return cached.value;
257
+ let reading;
258
+ try {
259
+ if (isLocalWorkspace({ machine })) reading = await readCardCommit(path, id, branches);
260
+ else reading = { known: true, value: (await cardCommitsOn(machine, path, id, branches)).at };
261
+ } catch (error) { reading = { known: false, value: null, error: String(error?.message ?? error).slice(0, 300) }; }
262
+ commitTimes.set(key, { at: now(), ...reading });
263
+ return reading.value;
246
264
  }
247
265
 
248
266
  function lastCommitAt(path, id) { return path ? commitTimes.get(`${path}:${id}`)?.value ?? null : null; }
267
+ /** The checkout a card's commits are read in, and the machine it is on: its current run's session's machine, else the
268
+ * card's own. A scratch card has none, and a subtask's commits are its arc's (refreshCardInputs reads none for it). */
269
+ function commitSource(db, task) {
270
+ const path = task.workspace_kind !== 'scratch' && !task.subtask_of ? task.workspace_path ?? null : null;
271
+ if (!path) return { path: null, machine: null };
272
+ const session = task.current_run_id ? json(one(db, 'SELECT metadata FROM task_runs WHERE id=?', task.current_run_id)?.metadata, {})?.supercode : null;
273
+ return { path, machine: session?.machine ?? one(db, 'SELECT machine FROM supercode_cards WHERE task_id=?', task.id)?.machine ?? null };
274
+ }
249
275
  /** Material card work. Comments, reminder replies and timestamp writes are
250
- * deliberately absent; a heartbeat is a separately bounded waiting lease. */
276
+ * deliberately absent; a heartbeat is a separately bounded waiting lease.
277
+ * `commit` is the time of the card's latest commit, 0 for none, and null when its checkout could not be read
278
+ * (`commit_unread` says why): an unread checkout is not a card with no commits, and nothing may count it as one. */
251
279
  export function sessionProgress(db, task) {
252
280
  const event = one(db, `SELECT MAX(e.id) AS id FROM task_events e WHERE
253
281
  (e.task_id=? AND e.kind IN ('specified','review_requested','review_pass','changes_requested','audited')) OR
254
282
  (e.task_id IN (SELECT task_id FROM supercode_cards WHERE subtask_of=?) AND e.kind IN ('completed','cancelled'))`, task.id, task.id);
255
- return { event: Number(event?.id ?? 0), commit: Number(lastCommitAt(task.workspace_path, task.id) ?? 0) };
283
+ const { path } = commitSource(db, task);
284
+ const read = path ? commitTimes.get(`${path}:${task.id}`) ?? { known: false, error: 'its checkout has not been read yet' } : { known: true, value: null };
285
+ return { event: Number(event?.id ?? 0), commit: read.known ? Number(read.value ?? 0) : null, ...(read.known ? {} : { commit_unread: read.error ?? 'its checkout could not be read' }) };
256
286
  }
257
287
  /** Observe external facts before entering an optimistic board mutation. */
258
288
  export async function refreshCardInputs(board, id, { commits = false } = {}) {
259
289
  const task=board.db.prepare('SELECT t.*,c.subtask_of FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
260
290
  if (!task)return;
261
291
  if (commits && !task.subtask_of) {
262
- const own=task.workspace_kind!=='scratch'?task.workspace_path:null;
263
- const parent=!own && task.subtask_of?board.db.prepare('SELECT workspace_path FROM tasks WHERE id=?').get(task.subtask_of)?.workspace_path:null;
264
- const path=own??parent??null;
292
+ const { path, machine } = commitSource(board.db, task);
265
293
  if(path)commitTimes.delete(`${path}:${id}`);
266
- await observeLastCommit(path,id,declaredWorkBranches(board.db,id));
294
+ await observeLastCommit(path,id,declaredWorkBranches(board.db,id),machine);
267
295
  }
268
296
  }
269
297
 
@@ -33,6 +33,35 @@ async function workspaceDoor(machine, verb, value, options = {}) {
33
33
  if (r.code !== 0) throw new Error(`workspace on ${machine}: ${own ? `${own.message}; the machine daemon: ` : ''}${(r.stderr || r.stdout || r.error?.message).trim()}`);
34
34
  return JSON.parse(r.stdout);
35
35
  }
36
+ // A machine whose commit door did not answer is not asked again for a while: each call would wait out its bound, once
37
+ // per running card per round. One that has no such door (a daemon older than the door) is asked again in ten minutes.
38
+ const commitDoorHeld = new Map(); // machine → { until (ms), why }
39
+ /**
40
+ * When the latest commit naming `card` was made on its work in the checkout at `path` on another machine, as that
41
+ * machine's own daemon reads it (`harness.v1.workspaces.commits`: fixed git arguments, ten seconds a call there, ten
42
+ * seconds here): `{ at }`, seconds or null for none. Throws when it could not be read (the machine offline or silent,
43
+ * its daemon without the door, no such folder there): an unread checkout is unknown, never a card with no commits.
44
+ */
45
+ export async function cardCommitsOn(machine, path, card, branches = []) {
46
+ const name = normalMachine(machine);
47
+ if (presence?.known === true && !presence.online.has(name)) throw new Error(`machine ${name} is offline`);
48
+ const held = commitDoorHeld.get(name);
49
+ if (held && Date.now() < held.until) throw new Error(held.why);
50
+ const r = await runtimeDoor(machine, 'harness.v1.workspaces.commits', { path, card, branches }, { timeout: 10_000 });
51
+ if (r.code !== 0) {
52
+ let refusal = null; try { refusal = JSON.parse(String(r.stderr).trim().split('\n').pop()); } catch { /* said in its own words */ }
53
+ const missing = refusal?.code === -32601 || /unknown method/.test(refusal?.message ?? '');
54
+ const why = missing ? `machine ${name}'s daemon has no commit reading door (harness.v1.workspaces.commits); it answers once that machine runs a supercode that has it`
55
+ : `machine ${name}: ${String(refusal?.message ?? r.stderr ?? 'its door did not answer').trim().slice(0, 200)}`;
56
+ // the machine's own answer about one path says nothing of its other checkouts; a door that did not answer does
57
+ if (missing || refusal?.code == null || ['unavailable', 'machine_offline', 'ETIMEDOUT', 'ECONNRESET', 504, '504'].includes(refusal.code) || /timed out|timeout/i.test(refusal?.message ?? '')) commitDoorHeld.set(name, { until: Date.now() + (missing ? 600_000 : 60_000), why });
58
+ throw new Error(why);
59
+ }
60
+ commitDoorHeld.delete(name);
61
+ const answer = JSON.parse(r.stdout);
62
+ if (!answer || typeof answer !== 'object' || !('at' in answer) || (answer.at !== null && !(Number(answer.at) > 0))) throw new Error(`machine ${name} answered no commit reading`);
63
+ return { at: answer.at === null ? null : Number(answer.at) };
64
+ }
36
65
  /** A workspace declaration that asks no machine (scratch, or a repository named by id), else null: a path is declared
37
66
  * through its machine's door (declareWorkspace). */
38
67
  export function plainWorkspace(kind, value, branch = null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.133",
3
+ "version": "0.5.135",
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": {