@volter/supercode-orchestrator 0.5.133 → 0.5.134
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 +31 -10
- package/board/dispatch.mjs +37 -7
- package/board/session-work.mjs +5 -2
- package/board/workflow.mjs +59 -31
- package/board/workspace.mjs +29 -0
- package/package.json +1 -1
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 =
|
|
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
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
|
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']
|
|
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]
|
|
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']
|
|
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
|
-
|
|
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
|
|
package/board/dispatch.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1318
|
-
|
|
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) {
|
package/board/session-work.mjs
CHANGED
|
@@ -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
|
|
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
|
}
|
package/board/workflow.mjs
CHANGED
|
@@ -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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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.
|
|
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:
|
|
233
|
+
return { repo: true, known: true, value: Number.isFinite(value) && value > 0 ? value : null };
|
|
229
234
|
};
|
|
230
235
|
const own = await readCommit(path);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
package/board/workspace.mjs
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.5.134",
|
|
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": {
|