@volter/supercode-orchestrator 0.5.112 → 0.5.114

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/ci.mjs CHANGED
@@ -118,7 +118,7 @@ export function withCompletionEvidence(board, id, state, fn) {
118
118
  export function reviewCandidate(board, id) {
119
119
  const row = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='review_requested' ORDER BY id DESC LIMIT 1").get(id);
120
120
  const payload = row && JSON.parse(row.payload);
121
- return payload?.ci?.pr ? payload.ci : null;
121
+ return payload?.ci?.pr || payload?.ci?.no_code ? payload.ci : null;
122
122
  }
123
123
 
124
124
  /** The PRs an evidence record covers: every PR of one arc review (the first is also its top-level PR). */
@@ -215,17 +215,17 @@ export function declaredWorkBranches(db, id) {
215
215
  * answers `{ waits }` saying so. One review covers them all; the caller decides the transition. */
216
216
  export async function openReviewPullRequests(board, id) {
217
217
  const own = await repositoryWorkspace(board, id);
218
- if (!own) return null;
218
+ const registered = registeredBranches(board, id);
219
+ if (!own && !registered.length) return null;
219
220
  const task = board.db.prepare('SELECT branch_name FROM tasks WHERE id=?').get(id);
220
- const ownRepository = own.repository ?? (own.here ? await originOf(own.path) : null);
221
- if (!ownRepository) return null;
222
- const current = task?.branch_name ? null : own.branch ?? (own.here ? await run('git', ['branch', '--show-current'], own.path) : null);
221
+ const ownRepository = own?.repository ?? (own?.here ? await originOf(own.path) : null);
222
+ if (!ownRepository && !registered.length) return null;
223
+ const current = task?.branch_name ? null : own?.branch ?? (own?.here ? await run('git', ['branch', '--show-current'], own.path) : null);
223
224
  let branch = task?.branch_name ?? (current || null);
224
- const registered = registeredBranches(board, id);
225
225
  const mine = (repository, head) => (repository === ownRepository && head === branch) || namedWorkBranch(id, head)
226
226
  || registered.some((r) => r.repository === repository && r.branch === head);
227
227
  const prs = [];
228
- for (const repository of new Set([ownRepository, ...registered.map((r) => r.repository), ...(await knownRepositories())])) {
228
+ for (const repository of new Set([ownRepository, ...registered.map((r) => r.repository), ...(await knownRepositories())].filter(Boolean))) {
229
229
  // Discovery is shared per repository for one minute; each selected PR/head is re-read before it authorizes a move.
230
230
  let list = pullRequestLists.get(repository);
231
231
  if (!list || Date.now() - list.at >= 60_000) {
@@ -263,7 +263,19 @@ export async function openReviewPullRequests(board, id) {
263
263
  }
264
264
  }
265
265
  }
266
- if (!prs.length) return branch ? null : { waits: `the card has no work branch (${task?.branch_name ? `it declares ${task.branch_name}, its repository's default` : current ? `its checkout ${own.path} is on ${current}, its repository's default` : `its checkout ${own.path} is on no branch`}): give it one with --branch, or open its PR from a wt/${id} branch` };
266
+ // A source declaration (an implementer's or manager's card comment, engine.comment) adds merged or moved PRs that
267
+ // discovery cannot see; it never hides what discovery found. Only repositories the card's workspace or registered
268
+ // branches name count, and each declared PR is followed at its current head (GitHub's), never pinned to the
269
+ // declared one, so a push after the declaration is reviewed instead of failing every tick.
270
+ const declaration = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='commented' AND json_type(payload,'$.sources')='array' ORDER BY id DESC LIMIT 1").get(id);
271
+ const repositories = new Set([ownRepository, ...registered.map((r) => r.repository)].filter(Boolean).map((r) => r.toLowerCase()));
272
+ for (const source of declaration ? JSON.parse(declaration.payload).sources : []) {
273
+ if (!repositories.has(repositoryOf(source.url).toLowerCase()) || prs.some((known) => known.url === source.url)) continue;
274
+ const pr = await readPullRequest(source.url);
275
+ if (pr.isCrossRepository || !['OPEN', 'MERGED'].includes(pr.state) || (pr.state === 'OPEN' && pr.isDraft)) continue;
276
+ if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
277
+ }
278
+ if (!prs.length) return branch || !ownRepository ? null : { waits: `the card has no work branch (${task?.branch_name ? `it declares ${task.branch_name}, its repository's default` : current ? `its checkout ${own.path} is on ${current}, its repository's default` : `its checkout ${own.path} is on no branch`}): give it one with --branch, or open its PR from a wt/${id} branch` };
267
279
  return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch };
268
280
  }
269
281
 
@@ -271,6 +283,14 @@ export async function openReviewPullRequests(board, id) {
271
283
  * beyond its repository's default branch, locally or on origin, its checkout holds no uncommitted work, no PR was
272
284
  * ever opened from it, and it registered no other branch. Answers what its review reads instead of a diff, or null when the arc carries (or may carry) code. */
273
285
  export async function noCodeChange(board, id) {
286
+ const taskRow = board.db.prepare('SELECT workspace_kind FROM tasks WHERE id=?').get(id);
287
+ if (taskRow?.workspace_kind === 'scratch') {
288
+ if (board.db.prepare("SELECT 1 FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE c.subtask_of=? AND t.workspace_kind!='scratch' AND t.status!='cancelled' LIMIT 1").get(id)) return null;
289
+ // Scratch declares an operational disposition rather than repository code.
290
+ // Closed tasks/results and the retained card thread are the review source;
291
+ // this does not invent a verdict or assert that an external act succeeded.
292
+ return { pr: [], pr_head: [], branch: null, no_code: { kind: 'disposition' } };
293
+ }
274
294
  const checkout = await repositoryWorkspace(board, id);
275
295
  if (!checkout?.here) return null;
276
296
  const own = checkout.path;
@@ -357,6 +377,7 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
357
377
  const candidate = stage === 'acceptance' ? reviewCandidate(board, id) : null;
358
378
  if (candidate) {
359
379
  const row = board.db.prepare("SELECT id,payload FROM task_events WHERE task_id=? AND kind='review_pass' ORDER BY id DESC LIMIT 1").get(id);
380
+ if (!row) throw new Error('The current candidate needs its independent verdict before acceptance.');
360
381
  const voided = board.db.prepare("SELECT id FROM task_events WHERE task_id=? AND kind='review_voided' ORDER BY id DESC LIMIT 1").get(id);
361
382
  if (voided && (!row || voided.id > row.id)) throw new Error('The manager voided this verdict; the board must obtain a new review before acceptance.');
362
383
  const passed = candidatePullRequests(row && JSON.parse(row.payload)?.ci);
@@ -370,13 +391,19 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
370
391
  }
371
392
  return state;
372
393
  }
373
- const own = await repositoryWorkspace(board, id);
374
394
  const asked = stage === 'review_verdict' ? reviewCandidate(board, id) : null;
375
395
  const urls = [].concat(payload.pr ?? payload.metadata?.pr ?? candidatePullRequests(asked).map((c) => c.pr.url)).filter(Boolean);
376
- if (!own) {
377
- if (urls.length) throw new Error('A PR candidate needs a resolved repository workspace.');
396
+ if (!urls.length && asked?.no_code) return { ...state, no_code: asked.no_code };
397
+ if (urls.some(url => typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url))) throw new Error('Name each GitHub PR URL with --pr.');
398
+ // Merged source is immutable and needs no surviving workspace. Resolve an
399
+ // unmerged candidate's checkout as before; a broken legacy path never
400
+ // prevents a verdict on already merged commits.
401
+ const mergedOnly = urls.length && (await Promise.all(urls.map(readPullRequest))).every(pr => pr.state === 'MERGED');
402
+ const own = mergedOnly ? null : await repositoryWorkspace(board, id);
403
+ if (!own && !urls.length) {
378
404
  return state;
379
405
  }
406
+ if (!own && !mergedOnly) throw new Error('An unmerged PR candidate needs a resolved repository workspace.');
380
407
  // A verdict with no requested PR (a scratch arc) keeps its own contract. An arc's review
381
408
  // always names every opened PR it changed, independent of completion_ci.
382
409
  if (stage === 'review_verdict' && !asked && !urls.length) return state;
@@ -412,7 +439,7 @@ async function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
412
439
  // card's own checkout, and this machine's checkout of the PR's branch when it keeps one, must also hold that head
413
440
  // with nothing uncommitted. A card on another machine is reviewed at its pushed head: its checkout is not read from here.
414
441
  const observed = await readPullRequest(url);
415
- const path = !own.here ? null : await originOf(own.path) === repository && await run('git', ['branch', '--show-current'], own.path) === observed.headRefName ? own.path : await branchCheckout(board, id, repository, observed.headRefName);
442
+ const path = !own?.here || observed.state === 'MERGED' ? null : await originOf(own.path) === repository && await run('git', ['branch', '--show-current'], own.path) === observed.headRefName ? own.path : await branchCheckout(board, id, repository, observed.headRefName);
416
443
  // A PR merged before its review (an outage fix landed first) is still reviewed: its head is fixed for good, so the
417
444
  // verdict is recorded on that head, and nothing about a worktree or its base can move under it any more.
418
445
  const merged = observed.state === 'MERGED';
package/board/cli.mjs CHANGED
@@ -11,6 +11,7 @@ import { homeOf, parse } from './args.mjs';
11
11
  import { BOARD_OPERATION_VERBS, boardHome, boardOwnerSocket, CALLER_FACTS } from '@volter/teams/board-owner';
12
12
  import { current, isCallersCall, ownCall, within } from './call.mjs';
13
13
  import { taskStatusOf } from './published.mjs';
14
+ import { executionOf } from './session-work.mjs';
14
15
  import { createServer } from 'node:net';
15
16
  import { chmodSync, existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
16
17
  import { notRuntime, ownHome } from './owner-files.mjs';
@@ -93,7 +94,7 @@ The board's verbs, with their flags:
93
94
  set <id> <assignee|machine|model|every> <value|none> [--provider] [--ids ...] assign <id> <profile|none> set-model <id> [model] [--provider] set-goal <id>... [--goal-max-turns N] [--off]
94
95
  reassign <id> --agent AGENT [--role implementer] (moves a card role's assignment to another agent; sessions keep their identity)
95
96
  claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] [--until <time|30m>] link|unlink <parent> <child>
96
- comment <id> (<text> | --file PATH|-) [--author] [--max-len] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
97
+ comment <id> (<text> | --file PATH|-) [--author] [--max-len] [(--pr URL --pr-head SHA)...] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
97
98
  complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
98
99
  block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
99
100
  pause <id> --reason <why> resume <id> cancel <id> --reason <why>
@@ -135,6 +136,7 @@ And:
135
136
 
136
137
  specify <id> --workspace dir:<repository> --branch NAME: the manager retargets a stopped card without replacing its history.
137
138
  specify <id> --workspace … --machine M: declares the workspace on M and moves the card there, never asking the machine it leaves.
139
+ comment <id> TEXT (--pr URL --pr-head SHA)...: the card's implementer or the manager adds PRs (including merged ones) to the arc's discovered source set, in the card's own repositories, each followed at its current head; the latest declaration replaces the previous one. This records evidence; only the board starts its review.
138
140
  request-review <id> (--pr URL --pr-head SHA)...: hand over every open PR the arc changed, each at its pushed head; one review covers them all.
139
141
  request-state <id> --state done (--reviewed-sha SHA)...: record the verdict on every requested PR head.
140
142
  request-state <id>... --state STATE --reason REASON: request several decisions in one retained manager mail.
@@ -289,14 +291,14 @@ const ts = (t) => {
289
291
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
290
292
  };
291
293
  // a card's status is the workflow's (triaging, reviewing, …); kanban.db stores the Hermes status it maps to
292
- const line = (t) => `${ICONS[t.status] ?? '?'} ${t.id} ${(t.stage ?? t.status).padEnd(8)} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
294
+ const line = (t) => `${ICONS[t.status] ?? '?'} ${t.id} ${(t.stage ?? t.status).padEnd(8)}${['running','review'].includes(t.status) ? ` [session ${t.execution?.state ?? 'unknown'}]` : ''} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
293
295
  const cardDict = (t) => ({
294
296
  id: t.id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, priority: t.priority, tenant: t.tenant,
295
297
  workspace_kind: t.workspace_kind, repository: t.workspace_anchor ?? null, workspace_path: t.workspace_path, branch_name: t.branch_name, project_id: t.project_id,
296
298
  created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
297
299
  skills: t.skills ?? [], max_retries: t.max_retries, model_override: t.model_override, provider_override: t.provider_override,
298
300
  session_id: t.session_id, workflow_template_id: t.workflow_template_id, current_step_key: t.current_step_key,
299
- machine: t.machine ?? null, subtask_of: t.subtask_of ?? null,
301
+ machine: t.machine ?? null, subtask_of: t.subtask_of ?? null, execution: t.execution ?? null,
300
302
  });
301
303
 
302
304
  /** Hermes's durations: seconds, or 30s / 5m / 2h / 1d. */
@@ -523,7 +525,7 @@ export async function runVerb(argv) {
523
525
  return [t.id, {
524
526
  workflow_status: t.stage ?? t.status,
525
527
  parents: b.db.prepare('SELECT parent_id FROM task_links WHERE child_id = ? ORDER BY parent_id').all(t.id).map((r) => r.parent_id),
526
- run: run ? { id: run.id, status: run.status, started_at: run.started_at, session: session.address ?? session.session_id ?? null } : null,
528
+ run: run ? { id: run.id, status: run.status, started_at: run.started_at, session: session.address ?? session.session_id ?? null, execution: executionOf(run) } : null,
527
529
  // the message whose answer its standing block waits for (`block --waiting-on`)
528
530
  waiting_on: (() => {
529
531
  const last = b.db.prepare("SELECT kind, payload FROM task_events WHERE task_id = ? AND kind IN ('blocked', 'unblocked', 'dependency_wait') ORDER BY id DESC LIMIT 1").get(t.id);
@@ -546,6 +548,8 @@ export async function runVerb(argv) {
546
548
  const card = withBoard((b) => {
547
549
  const got = engine.showCard(b.db, id);
548
550
  got.runs = engine.runs(b.db, id, { stateType: args['state-type'] ?? null, stateName: args['state-name'] ?? null });
551
+ got.task.execution = executionOf(engine.currentRun(b.db, id));
552
+ got.runs = got.runs.map(run => ({ ...run, execution: executionOf(run) }));
549
553
  got.diagnostics = engine.diagnose(b.db, got.task, { failureLimit: settings.failureLimit, launchWindowSeconds: settings.launchWindow });
550
554
  got.stage = statusOf(b.db, got.task);
551
555
  got.time = scopeOf(b.db, b, got.task, { params: workflowFor(root).params }).card.time;
@@ -603,7 +607,7 @@ export async function runVerb(argv) {
603
607
  for (const r of card.runs) {
604
608
  const s = r.metadata?.supercode;
605
609
  out.push(` #${String(r.id).padEnd(3)} ${(r.outcome || r.status || 'active').padEnd(12)} @${r.profile || '-'} ${r.ended_at ? `${Math.max(0, r.ended_at - r.started_at)}s` : 'active'} ${ts(r.started_at)}`);
606
- if (s) out.push(` ⇢ ${s.session_id ? `session ${s.session_id}${s.pane ? ` in pane ${s.pane}` : ''}` : `worker pid ${s.pid}`} on ${s.machine} (${s.mode})`);
610
+ if (s) out.push(` ⇢ ${s.session_id ? `session ${s.session_id}${s.pane ? ` in pane ${s.pane}` : ''}` : `worker pid ${s.pid}`} on ${s.machine} (${s.mode}); observed ${executionOf(r).state}`);
607
611
  if (r.summary) out.push(` → ${r.summary.split('\n')[0].slice(0, 160)}`);
608
612
  if (r.error) out.push(` ! ${r.error.split('\n')[0].slice(0, 160)}`);
609
613
  }
@@ -717,7 +721,7 @@ export async function runVerb(argv) {
717
721
  need(args.file !== undefined ? 1 : 2, 'a card id and the text (or --file PATH, - for stdin)');
718
722
  let text = textFrom(args.file, rest.slice(1).join(' '));
719
723
  if (args['max-len']) text = text.slice(0, Number(args['max-len']));
720
- const n = await withBoardAsync((b) => engine.comment(b, id, isLatestReviewer(b.db, id, actorOf()) ? actorOf() : args.author ?? callerAddress() ?? callerName(), text));
724
+ const n = await withBoardAsync((b) => engine.comment(b, id, isLatestReviewer(b.db, id, actorOf()) ? actorOf() : args.author ?? callerAddress() ?? callerName(), text, { pr: args.pr ?? null, pr_head: args['pr-head'] ?? null }));
721
725
  say(args, `Comment added to ${id}`, { ok: true, comment: n }); return 0;
722
726
  }
723
727
  case 'attach': {
@@ -966,9 +970,9 @@ export async function runVerb(argv) {
966
970
  // planned through the workflow's own dispatch (its start event, eligible, order and limits), writing nothing
967
971
  const reports = await plan({ root, lanes, settings, params, slugs: [slug] });
968
972
  const would = reports.flatMap((r) => r.would), reviews = reports.flatMap((r) => r.would_review ?? []);
969
- const lines = [...reviews.map((r) => `would request review of ${r.id}: ${r.no_code ? `no code changed (${r.no_code.repository} ${r.no_code.branch} holds no commit beyond ${r.no_code.base}, no PR from it)` : r.prs.map((url, i) => `${url} at ${r.heads[i]}`).join(', ')}`),
973
+ const lines = [...reviews.map((r) => `would request review of ${r.id}: ${r.no_code ? r.no_code.kind === 'disposition' ? 'scratch operational disposition (card evidence and closed tasks)' : `no code changed (${r.no_code.repository} ${r.no_code.branch} holds no commit beyond ${r.no_code.base}, no PR from it)` : r.prs.map((url, i) => `${url} at ${r.heads[i]}`).join(', ')}`),
970
974
  ...would.map((w) => `would start ${w.id} (${w.status}${w.role ? `, its ${w.role}` : ''}) as ${w.surface} for ${w.assignee}${w.machine ? ` on ${w.machine}` : ''}`)];
971
- say(args, lines.length ? lines.join('\n') : 'nothing would start', args.json ? { would, would_review: reviews } : would);
975
+ say(args, lines.length ? lines.join('\n') : 'nothing would start', args.json ? { would, would_review: reviews, queued: reports.flatMap(r => r.queued ?? []) } : would);
972
976
  return 0;
973
977
  }
974
978
  // This process holds the home's lock for the round, so it is the board's owner for it: it registers its pid, which
@@ -340,6 +340,15 @@ const unavailable = message => Object.assign(new Error(message), { launchDeferre
340
340
 
341
341
  // ------------------------------------------------------------------ supervise
342
342
 
343
+ /** Material card work (workflow.mjs sessionProgress) recorded since the run began. */
344
+ function progressedSince(db, task, runRow) {
345
+ const started = Number(runRow?.started_at ?? 0);
346
+ const progress = sessionProgress(db, task);
347
+ if (progress.commit >= started && progress.commit > 0) return true;
348
+ const at = progress.event ? db.prepare('SELECT created_at FROM task_events WHERE id=?').get(progress.event)?.created_at : null;
349
+ return at != null && Number(at) >= started;
350
+ }
351
+
343
352
  async function supervise(board, fleet, settings, report, entry, effects) {
344
353
  const rows = board.db.prepare(entry.workflow.run ? "SELECT id FROM tasks WHERE current_run_id IS NOT NULL" : "SELECT id FROM tasks WHERE status = 'running'").all();
345
354
  // Published native observations are the only liveness input. Missing data remains unknown. A launch the
@@ -352,7 +361,14 @@ async function supervise(board, fleet, settings, report, entry, effects) {
352
361
  if (!session?.launch_key || !fleet.ok || sessionAlive(fleet, session) !== false) continue;
353
362
  // how a keyed launch ended, where its machine keeps that record: read when it can be, never waited on
354
363
  if (!fleet.capabilities?.get(normalMachine(session.machine))?.launches) continue;
355
- try { const receipt = await launchReceipt(board, session); if (receipt?.live === false && receipt.exit) exits.set(id, { ...receipt.exit, launch_key: session.launch_key }); }
364
+ try {
365
+ const receipt = await launchReceipt(board, session);
366
+ if (receipt?.live === false && receipt.exit) {
367
+ exits.set(id, { ...receipt.exit, launch_key: session.launch_key });
368
+ // a clean exit is judged against the card's commits, read before the board write
369
+ if (!receipt.exit.failed && recoversRuns(entry)) await refreshCardInputs(board, id, { commits: true });
370
+ }
371
+ }
356
372
  catch (error) { report.failed.push([id, `exit receipt unavailable; retaining liveness observation: ${error.message}`]); }
357
373
  }
358
374
  for (const { id } of rows) {
@@ -371,6 +387,21 @@ async function supervise(board, fleet, settings, report, entry, effects) {
371
387
  event(db,id,'session_rebound',{session_id:session.session_id,pane:session.pane},runRow.id);
372
388
  }
373
389
  const t = now();
390
+ if (runRow && session) {
391
+ const alive = session.surface === 'headless' ? pidAlive(session.pid) : sessionAlive(fleet, session);
392
+ let native = fleet.sessions?.find(item => item.address === addressOf(session));
393
+ if (!native && session.session_id) try {
394
+ const row = nativeReading(nativeSessions(session.machine, session.harness ?? 'claude-code', session.session_id), session).row;
395
+ native = { turn: row?.activity?.turn };
396
+ } catch { /* an unread turn stays unknown */ }
397
+ const reading = { alive: alive ?? null, turn: alive === true ? native?.turn ?? null : null, observed_at: t };
398
+ const prior = session.presence_observation;
399
+ // Keep state changes immediately and renew unchanged readings once a minute.
400
+ if (!prior || prior.alive !== reading.alive || prior.turn !== reading.turn || t - prior.observed_at >= 60) {
401
+ setRunSession(db, runRow.id, { presence_observation: reading });
402
+ session = { ...session, presence_observation: reading };
403
+ }
404
+ }
374
405
  // what the dispatcher saw becomes a platform event; the card's workflow says what it does
375
406
  const observe = (name, payload, bucket) => {
376
407
  try {
@@ -474,12 +505,23 @@ async function supervise(board, fleet, settings, report, entry, effects) {
474
505
  const ended = { code: exit.failed ? 1 : 0, error: `native session ${session.session_id} ended: ${exit.how}`, session_id: session.session_id, pane: session.pane };
475
506
  if (!exit.failed) {
476
507
  if (monitorsRuns(entry)) { observe('session_closed', {...ended,exit},'crashed'); return; }
508
+ // Under recover a clean exit continues the card. One with no material card progress since its run began
509
+ // is an unsuccessful continuation: it counts toward the failure limit like any failed run, so a session
510
+ // that always ends cleanly (fresh or resumed) stops at the limit and the workflow asks the manager.
511
+ if (recoversRuns(entry) && !progressedSince(db, task, runRow)) {
512
+ observe('exited', { ...ended, error: `${ended.error} with no card progress since run ${runRow.id} began` }, 'crashed');
513
+ return;
514
+ }
477
515
  try {
516
+ if (recoversRuns(entry)) retainRecovery(db, id, 'session_closed', { ...ended, exit });
478
517
  const r = fire(db, board, entry, id, 'session_closed', { roles: ['platform'], payload: ended });
479
518
  staged.push(...r.effects); (report.closed_by_hand ??= []).push(id);
480
519
  return;
481
520
  } catch (error) { if (!error.noTransition) { report.failed.push([id, `session_closed: ${String(error.message).slice(-200)}`]); return; } }
482
521
  }
522
+ if (exit.failed && session.mode === 'resume' && !session.seen_at) {
523
+ setRunSession(db, runRow.id, { resumable: false, resume_failed_before_live: true });
524
+ }
483
525
  observe('exited', ended, 'crashed');
484
526
  return;
485
527
  }
@@ -1797,7 +1839,8 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
1797
1839
  const launchKey = `board-${task.id}-r${runId}-resume`;
1798
1840
  session = await openOwned(launchKey, prev.session_id, 'resume', ['open', '--new', its.program, ...base.slice(3),
1799
1841
  '--key', launchKey, '--input', told, '--', ...its.resume(prev.session_id), ...tail]);
1800
- } else replaced = { session_id: prev.session_id, why: 'its resume never came up' };
1842
+ } else replaced = { session_id: prev.session_id, why: 'its resume never came up',
1843
+ letGo: recoversRuns(workflowFor(board.root)) && Boolean(prev.resume_failed_before_live) && Boolean(prev.close_confirmed_at) };
1801
1844
  } else if (prev) replaced = { session_id: prev.session_id, why: `the card moved from ${prev.machine} to ${machine}` };
1802
1845
  // a card keeps its context: a slot's session that cannot be kept or resumed stops the launch, and only a session the
1803
1846
  // workflow let go (`forget_session`) or its manager did (a reassignment to another harness's lane) is replaced
@@ -2631,17 +2674,30 @@ export async function plan({ root, lanes, settings, slugs = null, params = {} })
2631
2674
  return out;
2632
2675
  }
2633
2676
 
2634
- /** Whether an arc with no open PR may be one that changed no code and is due its review: it filed and closed tasks
2635
- * (a fresh arc with none has not started), and no review of it is pending or passed (after changes are requested,
2636
- * closing the tasks that own the findings makes it due again). */
2677
+ /** Whether an arc with no open PR may be one that changed no code and is due its review: it has done work, by filing
2678
+ * tasks or by a run that ended with an Acceptance outcome ticked (a fresh arc with neither has not started), and no
2679
+ * review of it is pending or passed (after changes are requested, closing the tasks that own the findings makes it
2680
+ * due again). */
2637
2681
  function noCodeReviewDue(board, id) {
2638
2682
  return view(board, (db) => {
2639
- if (!db.prepare('SELECT 1 FROM supercode_cards WHERE subtask_of = ? LIMIT 1').get(id)) return false;
2683
+ const filed = db.prepare('SELECT 1 FROM supercode_cards WHERE subtask_of = ? LIMIT 1').get(id);
2684
+ if (!filed && !(db.prepare('SELECT 1 FROM task_runs WHERE task_id = ? AND ended_at IS NOT NULL LIMIT 1').get(id) &&
2685
+ outcomeTicked(db.prepare('SELECT body FROM tasks WHERE id = ?').get(id)?.body))) return false;
2640
2686
  const last = db.prepare("SELECT kind FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass','review_voided') ORDER BY id DESC LIMIT 1").get(id);
2641
2687
  return !last || ['changes_requested','review_voided'].includes(last.kind);
2642
2688
  });
2643
2689
  }
2644
2690
 
2691
+ /** Whether a card body ticks some outcome under its Acceptance heading. */
2692
+ function outcomeTicked(body) {
2693
+ let acceptance = false;
2694
+ return String(body ?? '').split('\n').some((line) => {
2695
+ const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line);
2696
+ if (heading) acceptance = heading[1].toLowerCase() === 'acceptance';
2697
+ return acceptance && /^\s*[-*] \[[xX]\] \S/.test(line);
2698
+ });
2699
+ }
2700
+
2645
2701
  /** An opened PR asks for the declared review transition once the arc's tasks are closed. */
2646
2702
  // A task written in the card's own Tasks section that did not become a subtask holds its review too.
2647
2703
  /** The arc's session still in a turn: its review, and the close of that session the request brings (end_run), wait for
@@ -2660,7 +2716,7 @@ async function turnInFlight(board, id, fleet) {
2660
2716
  async function activatePullRequestReviews(board, entry, report, effects, { only = null, dry = false, fleet = null } = {}) {
2661
2717
  const candidates = view(board, db => db.prepare(`SELECT t.id FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
2662
2718
  WHERE c.subtask_of IS NULL AND c.every IS NULL
2663
- AND t.workspace_kind!='scratch' ${only ? 'AND t.id=?' : ''}`).all(...(only ? [only] : [])));
2719
+ ${only ? 'AND t.id=?' : ''}`).all(...(only ? [only] : [])));
2664
2720
  for (const { id } of candidates) {
2665
2721
  const eligible = db => {
2666
2722
  const task = getTask(db, id);
@@ -2684,7 +2740,7 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
2684
2740
  // requested the next run is the implementer's, until a new head is pushed.
2685
2741
  // An arc that closed its tasks having changed no code gets its one review of the act itself (ADR 0003).
2686
2742
  if (!payload && noCodeReviewDue(board, id)) payload = await noCodeChange(board, id);
2687
- if (!payload) { report.queued.push([id, 'review waits: no open PR on its branch, a wt/<card> branch or a registered checkout']); continue; }
2743
+ if (!payload) { report.queued.push([id, 'review waits: no open or unjudged merged PR on its declared branches, and no completed disposition']); continue; }
2688
2744
  if (payload.waits) { report.queued.push([id, `review waits: ${payload.waits}`]); payload = null; continue; }
2689
2745
  if (!payload.no_code && !await reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
2690
2746
  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`]);
@@ -2700,8 +2756,8 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
2700
2756
  if (eligible(db) !== true) return null;
2701
2757
  return withCompletionEvidence(board, id, evidence, () => fire(db, board, entry, id, 'request_review', {
2702
2758
  roles: ['platform'], payload: { ...payload, run_id: task.current_run_id, ci: evidence, summary: payload.no_code
2703
- ? `No code changed: ${payload.no_code.repository} ${payload.branch} holds no commit beyond ${payload.no_code.base} and no PR was opened from it. Review the arc's act from the card: its comments, its tasks' results and what they point at.`
2704
- : `Opened ${payload.pr.map((url, index) => `${url} at ${payload.pr_head[index]}`).join(', ')}` }
2759
+ ? `${payload.no_code.kind === 'disposition' ? 'Scratch operational disposition.' : `No code changed: ${payload.no_code.repository} ${payload.branch} holds no commit beyond ${payload.no_code.base} and no PR was opened from it.`} Review the arc's act from the card: its comments, its tasks' results and what they point at.`
2760
+ : `Source ${payload.pr.map((url, index) => `${url} at ${payload.pr_head[index]}`).join(', ')}` }
2705
2761
  }));
2706
2762
  });
2707
2763
  if (moved) {
package/board/engine.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  // Each function takes an open board (`store.mjs`) and runs in one write transaction. Nothing here launches,
9
9
  // observes or sends: the dispatcher (`dispatch.mjs`) does, and records what it did through these.
10
10
  import { current } from './call.mjs';
11
+ import { executionOf } from './session-work.mjs';
11
12
  import { spawn, execFile } from 'node:child_process';
12
13
  import { existsSync, rmSync } from 'node:fs';
13
14
  import { join, resolve } from 'node:path';
@@ -721,10 +722,22 @@ export async function unlink(board, parent, child, { roles } = {}) {
721
722
  }
722
723
 
723
724
  /** A comment on the card's thread; answers its id. */
724
- export async function comment(board, id, author, body, { roles } = {}) {
725
+ export async function comment(board, id, author, body, { roles, pr = null, pr_head = null } = {}) {
725
726
  if (!String(body ?? '').trim()) throw new Error('a comment has a body');
726
727
  if (!String(author ?? '').trim()) throw new Error('a comment has an author');
727
- const result = await act(board, id, 'comment', { roles, payload: { author: author.trim(), body } });
728
+ let sources;
729
+ if (pr != null || pr_head != null) {
730
+ const urls = [].concat(pr ?? []), heads = [].concat(pr_head ?? []);
731
+ if (!urls.length || urls.length !== heads.length) throw new Error('Record every PR with --pr URL --pr-head SHA, one full head per URL.');
732
+ if (new Set(urls).size !== urls.length) throw new Error('Record each PR once.');
733
+ sources = urls.map((url, i) => {
734
+ if (!/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url) || !/^[a-f0-9]{40}$/.test(heads[i])) throw new Error('A source declaration needs a GitHub PR URL and its full head SHA.');
735
+ return { url: url.replace(/\/$/, ''), head: heads[i] };
736
+ });
737
+ // Only the card's own implementer or the board's manager declares its sources; the author text is not identity.
738
+ if (!(roles ?? callerRoles(board.db, id)).some((role) => role === 'implementer' || role === 'manager')) throw new Error(`Only ${id}'s implementer or the manager records its PR sources.`);
739
+ }
740
+ const result = await act(board, id, 'comment', { roles, payload: { author: author.trim(), body, ...(sources ? { sources } : {}) } });
728
741
  return result.commentId;
729
742
  }
730
743
 
@@ -812,7 +825,7 @@ export function listCards(db, { status = null, assignee = null, machine = null,
812
825
  const rows = all(db, `SELECT t.*, c.machine, c.subtask_of, c.stage, c.tags, c.sponsor FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id
813
826
  WHERE (? IS NULL OR t.status = ? OR c.stage = ?) AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? OR t.status != 'archived')
814
827
  ORDER BY t.priority DESC, t.created_at ASC`, status, status, status, assignee, assignee, machine, machine, includeArchived ? 1 : 0);
815
- return rows.map((r) => ({ ...r, skills: json(r.skills, []) ?? [] }));
828
+ return rows.map((r) => ({ ...r, skills: json(r.skills, []) ?? [], execution: executionOf(r.current_run_id ? one(db, 'SELECT metadata,ended_at FROM task_runs WHERE id=?', r.current_run_id) : null) }));
816
829
  }
817
830
 
818
831
  export function showCard(db, id) {
@@ -62,6 +62,33 @@ export function surfaceIncident(db, board, entry, id, name, evidence = {}, runId
62
62
  /** Older ended runs are audited before eligibility: a completed run is not permission to repeat. */
63
63
  export function auditEndedRuns(db, board, entry) {
64
64
  if (recoversRuns(entry)) {
65
+ // Retained positive exit receipts from an earlier version qualify the same
66
+ // failed-before-live fallback as a newly supervised resume. An unknown
67
+ // reading, a mere ended row or a once-live resume does not qualify.
68
+ for (const row of db.prepare(`SELECT r.id,r.metadata FROM task_runs r JOIN tasks t ON t.id=r.task_id
69
+ WHERE r.id=(SELECT MAX(id) FROM task_runs WHERE task_id=t.id) AND r.ended_at IS NOT NULL
70
+ AND t.current_run_id IS NULL AND t.status NOT IN ('done','cancelled','archived')`).all()) {
71
+ const metadata = json(row.metadata, {}), session = metadata.supercode;
72
+ if (session?.mode !== 'resume' || session.seen_at || session.resume_failed_before_live) continue;
73
+ const exit = db.prepare("SELECT 1 FROM task_events WHERE run_id=? AND kind='run_incident' AND json_extract(payload,'$.observation')='exited' AND json_extract(payload,'$.evidence.code')=1").get(row.id);
74
+ if (!exit) continue;
75
+ metadata.supercode = { ...session, resumable: false, resume_failed_before_live: true };
76
+ db.prepare('UPDATE task_runs SET metadata=? WHERE id=?').run(JSON.stringify(metadata), row.id);
77
+ }
78
+ // The older organization workflow automatically paused a clean native exit.
79
+ // Release only that exact platform outcome, never a person's later pause.
80
+ for (const task of db.prepare(`SELECT t.id,c.paused FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
81
+ WHERE t.current_run_id IS NULL AND t.status NOT IN ('done','cancelled','archived') AND c.paused IS NOT NULL AND NOT COALESCE(c.archived,0)`).all()) {
82
+ const pause = json(task.paused, null);
83
+ const last = db.prepare("SELECT kind,payload FROM task_events WHERE task_id=? AND kind IN ('closed_by_hand','paused','resumed') ORDER BY id DESC LIMIT 1").get(task.id);
84
+ const ended = json(last?.payload, {});
85
+ // The machine may have moved since this run. Match the retained board
86
+ // actor's exact label, never a user's actor or just the reason text.
87
+ if (last?.kind !== 'closed_by_hand' || !pause?.by?.endsWith(`:board:${board.label}`) || !pause.by.startsWith('sc:')) continue;
88
+ if (pause.reason !== `its session ended cleanly (${ended.error}); resume the card to start it again`) continue;
89
+ db.prepare('UPDATE supercode_cards SET paused=NULL WHERE task_id=? AND paused=?').run(task.id, task.paused);
90
+ event(db, task.id, 'automatic_close_pause_released', { reason: 'workflow selected native continuation after clean exit', prior_pause: pause });
91
+ }
65
92
  // Changing the explicit policy releases only incident holds. A person's
66
93
  // pause, dependency, dispatch switch or unrelated hold remains their act.
67
94
  for (const task of db.prepare(`SELECT t.*,r.id run_id,r.metadata run_metadata FROM tasks t JOIN task_runs r
@@ -5,6 +5,7 @@
5
5
  // supercode_publication_dirty, whoever writes (this process, Hermes's native writer, a document import). A transaction
6
6
  // publishes the marked cards and clears their marks; one that marked nothing reads one row and publishes nothing.
7
7
  import { createHash, randomUUID } from 'node:crypto';
8
+ import { executionOf } from './session-work.mjs';
8
9
  export const PUBLICATION_SCHEMA = `
9
10
  CREATE TABLE IF NOT EXISTS supercode_publications (
10
11
  sequence INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, resource_id TEXT NOT NULL,
@@ -95,14 +96,16 @@ export function cardSnapshot(db,row,rows=perCard(db)) {
95
96
  const comments=rows.comments(row.id).map(comment=>({...comment,created_at:at(comment.created_at)}));
96
97
  const verdicts={review_requested:'requested',approved:'approved',review_approved:'approved',changes_requested:'changes_requested',escalated:'escalated'};
97
98
  const reviews=rows.reviews(row.id).map(event=>{const payload=parse(event.payload,{});return {id:event.id,verdict:verdicts[event.kind],by:payload.reviewer??payload.by??payload.profile,reason:payload.reason,at:at(event.created_at)};});
98
- const attempts=rows.attempts(row.id).map(run=>{
99
+ const runs=rows.attempts(row.id);
100
+ const attempts=runs.map(run=>{
99
101
  const native=parse(run.metadata,{})?.supercode??null;
100
102
  const session=native?{machine:native.machine,harness:native.harness,session_id:native.session_id,address:native.address,pane:native.pane,surface:native.surface,state:native.state,closed_at:native.closed_at,agent:native.agent_id??null,counted:native.counted??null,profile_differences:native.agent_profile_mismatch?.differences??null}:null;
101
103
  const metadata=parse(run.metadata,{});delete metadata.supercode;
102
- return {id:String(run.id),profile:run.profile,step:run.step_key,status:run.status,started_at:at(run.started_at),ended_at:at(run.ended_at),outcome:run.outcome,error:run.error,session,handoff:run.summary||Object.keys(metadata).length?{summary:run.summary,metadata}:null};
104
+ return {id:String(run.id),profile:run.profile,step:run.step_key,status:run.status,started_at:at(run.started_at),ended_at:at(run.ended_at),outcome:run.outcome,error:run.error,execution:executionOf(run),session,handoff:run.summary||Object.keys(metadata).length?{summary:run.summary,metadata}:null};
103
105
  });
104
106
  const lane=own.stage??row.status;
105
107
  return {id:row.id,title:row.title,body:row.body??'',assignee:row.assignee,status:taskStatusOf(lane,row.completed_at),lane,
108
+ execution:executionOf(runs.find(run=>run.id===row.current_run_id)),
106
109
  priority:row.priority,created_by:row.created_by,created_at:at(row.created_at),started_at:at(row.started_at),completed_at:at(row.completed_at),result:row.result,skills:parse(row.skills,[]),model:row.model_override,provider:row.provider_override,
107
110
  tags:parse(own.tags,null)??(row.tenant?[row.tenant]:[]),sponsor:own.sponsor??null,sponsor_evidence:parse(own.sponsor_evidence),requested_by_unverified:parse(own.requested_by_unverified),machine:own.machine??null,subtask_of:own.subtask_of??null,archived:!!own.archived,
108
111
  blocked_by:parse(own.blocked_by,[]),paused:parse(own.paused),every:own.every??null,
@@ -1,6 +1,25 @@
1
1
  // The dispatcher observes native turns and explicit session delegation. A
2
2
  // transcript write, a reminder reply, or sharing a profile proves no progress.
3
3
 
4
+ /** A retained reading, separate from the card's workflow lane and launch receipt.
5
+ * An ended attempt is never a live session; an unread live attempt is unknown. */
6
+ export function executionOf(run) {
7
+ if (!run) return { state: 'awaiting_session', alive: false, observed_at: null };
8
+ let metadata = run.metadata;
9
+ if (typeof metadata === 'string') { try { metadata = JSON.parse(metadata); } catch { metadata = null; } }
10
+ const session = metadata?.supercode;
11
+ if (run.ended_at != null || session?.close_confirmed_at || session?.released) return { state: 'ended', alive: false, observed_at: session?.presence_observation?.observed_at ?? null };
12
+ const reading = session?.presence_observation;
13
+ if (!reading) return { state: 'unknown', alive: null, observed_at: null };
14
+ const freshUntil = Number(reading.observed_at) + 90;
15
+ // A stopped dispatcher cannot keep advertising its last positive reading.
16
+ // Cached snapshot consumers use the same expiry without another publication.
17
+ if (Date.now() / 1000 > freshUntil) return { state: 'unknown', alive: null, observed_at: reading.observed_at, fresh_until: freshUntil };
18
+ const delegated = session?.idle_observation?.decision === 'delegated_work' && session.idle_observation.turn === reading.turn;
19
+ return { ...reading, fresh_until: freshUntil, state: reading.alive === false ? 'ended' : reading.alive == null ? 'unknown'
20
+ : delegated ? 'delegating' : reading.turn ?? 'live' };
21
+ }
22
+
4
23
  /** Sessions this session started, transitively. Cycles never imply work. */
5
24
  export function delegatedWork(address, edges, sessions) {
6
25
  const reached = new Set([address]), pending = [address], working = [];
@@ -760,7 +760,7 @@ const FORMAT = {
760
760
  model_override_set: ({ e }) => ({ model: (e.field === 'model' ? e.value : e.model) || null, provider: e.provider || null }),
761
761
  goal_set: ({ after }) => ({ goal_mode: Boolean(after.goal_mode), goal_max_turns: after.goal_mode ? after.goal_max_turns ?? null : null }),
762
762
  heartbeat: ({ e }) => (e.note || e.until ? { ...(e.note ? { note: e.note } : {}), ...(e.until ? { until: Number(e.until) } : {}) } : null),
763
- commented: ({ e }) => ({ author: String(e.author ?? 'user').trim(), len: String(e.body ?? '').length }),
763
+ commented: ({ e }) => ({ author: String(e.author ?? 'user').trim(), len: String(e.body ?? '').length, ...(e.sources ? { sources: e.sources } : {}) }),
764
764
  archived: () => null,
765
765
  status: ({ e, before, to }) => ({ status: to, reason: e.reason ?? 'reopened', previous_status: before.stored_as }),
766
766
  machine_set: ({ e, before }) => ({ machine: e.field === 'machine' ? e.value ?? null : e.machine ?? null, was: before.machine }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.112",
3
+ "version": "0.5.114",
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": {