@volter/supercode-orchestrator 0.5.206 → 0.5.208

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/engine.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { allocateReadableId } from './readable-id.mjs';
1
2
  // The board's transitions (docs/architecture/orchestrator.md §2.9): what a card may do, written for supercode and
2
3
  // kept to Hermes Kanban's lifecycle so the two read each other's boards. Statuses: triage · todo · scheduled ·
3
4
  // ready · running · blocked · review · done · archived. A card with a parent that is not done waits in `todo`
@@ -10,18 +11,20 @@
10
11
  import { current } from './call.mjs';
11
12
  import { detachedCard, detachedSession, personCustody, sessionAddress, sessionOperation } from './custody.mjs';
12
13
  import { executionOf } from './session-work.mjs';
14
+ import { cardState, cardStateSql, holdDescription } from './card-state.mjs';
13
15
  import { spawn, execFile } from 'node:child_process';
14
16
  import { existsSync, rmSync } from 'node:fs';
15
17
  import { join, resolve } from 'node:path';
16
18
  import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace, plainWorkspace } from './workspace.mjs';
17
19
  import { DatabaseSync } from 'node:sqlite';
18
- import { view, actorOf, principalOf, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
20
+ import { CARD_VISIBILITY_SQL } from './visibility.mjs';
21
+ import { view, actorOf, principalOf, boardKey, cardBranch, cardHandle, CARD_NUMBERS, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
19
22
  import { assertSourceReceipt, completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
20
23
  import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, reviewerOf, eventTo, fire, validateEvent, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
21
24
  import { refuseCredentialDump, withheldIfDump } from './credential-guard.mjs';
22
25
  import { AGENT_KIND_LIST, compactCardAgentEvents } from './published.mjs';
23
26
 
24
- export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived'];
27
+ export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'paused', 'review', 'done', 'archived'];
25
28
  export const WORKSPACE_KINDS = ['scratch', 'dir', 'worktree'];
26
29
  /** A card blocked this many times in a row for the same kind goes to triage for a person (Hermes's limit). */
27
30
  export const BLOCK_RECURRENCE_LIMIT = 2;
@@ -38,12 +41,24 @@ const one = (db, sql, ...args) => db.prepare(sql).get(...args);
38
41
  const all = (db, sql, ...args) => db.prepare(sql).all(...args);
39
42
  const run = (db, sql, ...args) => db.prepare(sql).run(...args);
40
43
 
44
+ // A card's number: what the store gave it when it was made (store.mjs layOutNumbers), read with its board's key
45
+ // (`VO-42`, store.mjs boardKey). A person names a card by it wherever its id is taken (`VO-42`, `vo-42`, `#42`, `42`),
46
+ // and every view of a card shows it first, before the id.
47
+ /** Card `n`'s id on this board, or null when no card here has that number. */
48
+ export const cardByNumber = (db, n) => one(db, `SELECT t.id FROM ${CARD_NUMBERS} n JOIN tasks t ON t.id = n.task_id WHERE n.number = ?`, Number(n))?.id ?? null;
49
+ /** The number of card `id`, or null. */
50
+ export const numberOf = (db, id) => one(db, `SELECT number FROM ${CARD_NUMBERS} WHERE task_id = ?`, id)?.number ?? null;
51
+ /** The numbers of cards `ids`: id → number. */
52
+ export const numbersOf = (db, ids) => new Map(ids.length ? all(db, `SELECT task_id, number FROM ${CARD_NUMBERS} WHERE task_id IN (SELECT value FROM json_each(?))`, JSON.stringify([...new Set(ids)])).map((r) => [r.task_id, r.number]) : []);
53
+ /** A card as a person reads it: its keyed number first (store.mjs boardKey), then its id (`VO-42 t_4406c88a`). */
54
+ export const cardLabel = (key, number, id, readableId = null) => `${number ? `${key}-${number} ${id}` : String(id)}${readableId ? ` (${readableId})` : ''}`;
55
+
41
56
  export function getTask(db, id) {
42
57
  const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
43
58
  if (!row) return null;
44
59
  const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
45
- return { ...row, workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), detached: json(ours?.detached, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null,
46
- tags: json(ours?.tags, null) ?? (row.tenant ? [row.tenant] : []), sponsor: ours?.sponsor ?? null, sponsor_evidence: json(ours?.sponsor_evidence, null), requested_by_unverified: json(ours?.requested_by_unverified, null) };
60
+ return { ...row, number: numberOf(db, id), stage: ours?.stage ?? row.status, workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), detached: json(ours?.detached, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null,
61
+ tags: json(ours?.tags, null) ?? (row.tenant ? [row.tenant] : []), requested_by_unverified: json(ours?.requested_by_unverified, null) };
47
62
  }
48
63
 
49
64
  function must(db, id) {
@@ -232,8 +247,6 @@ export function createCard(board, spec) {
232
247
  if (spec.provider && !spec.model) throw new Error('a provider needs a model');
233
248
  // Several ordered tags (C4); Hermes's single `tenant` is the first of them, as its export lowers them.
234
249
  const tags = [...new Set([spec.tenant, ...(spec.tags ?? [])].filter((tag) => typeof tag === 'string' && tag.trim()).map((tag) => tag.trim()))];
235
- // A sponsor is a person, never an agent or an automation (Teams refuses any principal that is not a person).
236
- if (spec.sponsor && /^(agent_|svc_|service_)/.test(spec.sponsor)) throw new Error('a sponsor is a person; an agent or an automation cannot sponsor a card');
237
250
  return tx(board, (db) => {
238
251
  if (spec.idempotencyKey) {
239
252
  const got = one(db, "SELECT id FROM tasks WHERE idempotency_key = ? AND status != 'archived' ORDER BY created_at DESC LIMIT 1", spec.idempotencyKey);
@@ -261,14 +274,10 @@ export function createCard(board, spec) {
261
274
  }
262
275
  assertWorkspaceOwner(db, workspaceTask, checked);
263
276
  // The repository declaration stays separate from the resolved worktree path.
264
- if (project) {
265
- if (ws.kind === 'scratch') ws.kind = 'worktree';
266
- if (ws.kind === 'worktree' && !spec.branch) {
267
- const t = title.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '');
268
- spec = { ...spec, branch: `${project.slug}/${id}${t ? `-${t}` : ''}` };
269
- }
270
- }
271
- spec = { ...spec, project: project?.id ?? null, branch: spec.branch ?? (ws.kind === 'worktree' ? `wt/${id}` : null) };
277
+ if (project && ws.kind === 'scratch') ws.kind = 'worktree';
278
+ // A worktree card's branch is named for it once the store has numbered it (below), unless its creator named one.
279
+ const namedBranch = ws.kind === 'worktree' && !spec.branch ? (handle) => `${project ? `${project.slug}/` : ''}${cardBranch(handle, readableId)}` : null;
280
+ spec = { ...spec, project: project?.id ?? null };
272
281
  const t = now();
273
282
  run(db, `INSERT INTO tasks (id, title, body, assignee, status, priority, created_by, created_at, workspace_kind, workspace_path, branch_name,
274
283
  tenant, idempotency_key, max_runtime_seconds, skills, max_retries, model_override, provider_override, reasoning_effort, session_id,
@@ -278,16 +287,20 @@ export function createCard(board, spec) {
278
287
  spec.branch ?? null, tags[0] ?? null, spec.idempotencyKey ?? null, spec.maxRuntime ?? null,
279
288
  spec.skills?.length ? JSON.stringify(spec.skills) : null, spec.maxRetries ?? null, spec.model ?? null, spec.provider ?? null,
280
289
  spec.reasoning ?? null, spec.sessionId ?? null, spec.project ?? null, spec.goal ? 1 : 0, spec.goal?.maxTurns ?? null);
290
+ const readableId = allocateReadableId(db, title, spec.readableId ?? null, id);
291
+ run(db, 'UPDATE tasks SET readable_id=? WHERE id=?', readableId, id);
292
+ const handle = cardHandle(boardKey(board.root, board.slug), numberOf(db, id));
293
+ if (namedBranch) run(db, 'UPDATE tasks SET branch_name = ? WHERE id = ?', handle ? namedBranch(handle) : `wt/${id}`, id);
281
294
  const machine = spec.machine ?? arc?.machine ?? null;
282
- run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root,tags,sponsor,sponsor_evidence,requested_by_unverified) VALUES (?,?,?,?,?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.mainRoot ?? null,
283
- tags.length ? JSON.stringify(tags) : null, spec.sponsor ?? null, spec.sponsorEvidence ? JSON.stringify(spec.sponsorEvidence) : null, spec.requestedByUnverified ? JSON.stringify(spec.requestedByUnverified) : null);
295
+ run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root,tags,requested_by_unverified) VALUES (?,?,?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.mainRoot ?? null,
296
+ tags.length ? JSON.stringify(tags) : null, spec.requestedByUnverified ? JSON.stringify(spec.requestedByUnverified) : null);
284
297
  for (const p of parents) addDependency(db, entry, id, p);
285
298
  // where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
286
299
  // the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
287
300
  const status = startNew(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial,
288
301
  every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
289
302
  event(db, id, 'created', { creator: actorOf(), created_by: one(db, 'SELECT created_by FROM tasks WHERE id=?', id)?.created_by ?? null, assignee: spec.assignee || null,
290
- ...(spec.reviewer != null ? { reviewer: spec.reviewer } : {}), status, parents, tags, ...(spec.sponsor ? { sponsor: spec.sponsor } : {}), ...(spec.requestedByUnverified ? { requested_by_unverified: spec.requestedByUnverified } : {}), ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
303
+ ...(spec.reviewer != null ? { reviewer: spec.reviewer } : {}), status, parents, tags, ...(spec.requestedByUnverified ? { requested_by_unverified: spec.requestedByUnverified } : {}), ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
291
304
  if ((spec.noStart || spec.adopt) && !arc) event(db, id, 'dispatch_held', { session: spec.adopt ?? null, reason: spec.adopt ? 'awaiting adoption' : 'created with --no-start' });
292
305
  if (parents.length) inheritSubscriptions(db, id, parents);
293
306
  if (arc) {
@@ -682,8 +695,8 @@ export const complete = async (board, id, options = {}) => {
682
695
  return await act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId, commit, branch, pr, pr_head, reviewed_sha } });
683
696
  };
684
697
  // a block names the one thing that ends it: a card it waits on (`on`), or the message whose answer it waits for (`waiting_on`)
685
- export const block = async (board, id, { reason = null, on = null, waitingOn = null, roles, runId = null } = {}) =>
686
- await act(board, id, 'block', { roles, payload: { reason, on, waiting_on: waitingOn, run_id: runId } });
698
+ export const block = async (board, id, { reason = null, on = null, waitingOn = null, kind = 'needs_input', roles, runId = null } = {}) =>
699
+ await act(board, id, 'block', { roles, payload: { reason, on, kind, waiting_on: waitingOn, run_id: runId } });
687
700
  export const unblock = (board, id, { reason = null, on = null, roles } = {}) => act(board, id, 'unblock', { roles, payload: { reason, on } });
688
701
  export const requestReview = async (board, id, options = {}) => {
689
702
  assertSourceReceipt(options);
@@ -697,7 +710,7 @@ export const schedule = (board, id, reason = null, { roles } = {}) => act(board,
697
710
  export const promote = (board, id, { actor = 'user', reason = null, force = false, roles } = {}) => act(board, id, 'promote', { roles, payload: { actor, reason, force } });
698
711
  export const reclaim = (board, id, reason = null, { roles } = {}) => act(board, id, 'reclaim', { roles, payload: { reason } });
699
712
  export const archive = (board, id, { roles, reason } = {}) => act(board, id, 'archive', { roles, payload: { reason } });
700
- export async function specify(board, id, { title = null, body = null, workspace = null, branch = null, machine = null, author = 'user', roles } = {}) {
713
+ export async function specify(board, id, { title = null, body = null, readableId = null, workspace = null, branch = null, machine = null, author = 'user', roles } = {}) {
701
714
  refuseCredentialDump(`${id}'s specification`, { title, body });
702
715
  let declaration = null;
703
716
  if (workspace != null || branch != null || machine != null) {
@@ -711,12 +724,13 @@ export async function specify(board, id, { title = null, body = null, workspace
711
724
  if (branch != null && ws.kind === 'scratch') throw new Error('a scratch workspace has no repository branch');
712
725
  declaration = { kind: ws.kind, ...(await declareWorkspace(ws.kind, ws.path, target, branch)), ...(machine != null && target !== task.machine ? { machine: target } : {}) };
713
726
  if (declaration.repository === task.workspace_anchor) declaration.branch ??= task.branch_name;
714
- if (declaration.kind === 'worktree') declaration.branch ??= `wt/${id}`;
727
+ const handle = cardHandle(boardKey(board.root, board.slug), task.number);
728
+ if (declaration.kind === 'worktree') declaration.branch ??= handle ? cardBranch(handle, task.readable_id) : `wt/${id}`;
715
729
  if (declaration.kind === 'dir') declaration.path = (await checkWorkspace(board, { ...task, machine: target, workspace_kind: declaration.kind, workspace_anchor: declaration.repository, workspace_path: declaration.path, branch_name: declaration.branch })).path;
716
730
  }
717
731
  // an arc's new task lines are filed as its subtasks once the specify itself is accepted
718
732
  const steps = body != null && !one(board.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id = ?', id)?.subtask_of ? bodyTasks(body) : { body, tasks: [] };
719
- const done = await act(board, id, 'specify', { roles, payload: { title, body: steps.body, author, ...(declaration ? { workspace: declaration } : {}) } });
733
+ const done = await act(board, id, 'specify', { roles, payload: { title, body: steps.body, readable_id: readableId, author, ...(declaration ? { workspace: declaration } : {}) } });
720
734
  for (const step of steps.tasks) createCard(board, { ...step, subtaskOf: id, assignee: getTask(board.db, id)?.assignee || null });
721
735
  return done;
722
736
  }
@@ -756,10 +770,26 @@ export async function unlink(board, parent, child, { roles } = {}) {
756
770
  }
757
771
 
758
772
  /** A comment on the card's thread; answers its id. */
759
- export async function comment(board, id, author, body, { roles, pr = null, pr_head = null } = {}) {
773
+ export async function comment(board, id, author, body, { roles, pr = null, pr_head = null, source = null } = {}) {
760
774
  if (!String(body ?? '').trim()) throw new Error('a comment has a body');
761
775
  if (!String(author ?? '').trim()) throw new Error('a comment has an author');
762
- let sources;
776
+ let sources, branches;
777
+ // The repositories and branches an arc changes are the arc's own: declared on it (`--source owner/repo:branch`, each
778
+ // repository the arc changes), never registered as a subtask's workspace to be reviewed (VO-2858 had to register
779
+ // volter-org and runhuman-2 on its tasks, VO-2860). The latest declaration is the arc's complete set.
780
+ if (source != null) {
781
+ const named = [].concat(source).map((item) => String(item).trim());
782
+ branches = named.map((item) => {
783
+ const found = /^(?:https:\/\/github\.com\/|github\.com\/)?([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?:([^\s:]+)$/.exec(item);
784
+ if (!found) throw new Error(`Declare each source as owner/repository:branch, not ${JSON.stringify(item)}.`);
785
+ return { repository: found[1], branch: found[2] };
786
+ });
787
+ if (!branches.length) throw new Error('Declare at least one source as owner/repository:branch.');
788
+ if (new Set(branches.map((b) => `${b.repository.toLowerCase()}:${b.branch}`)).size !== branches.length) throw new Error('Declare each source once.');
789
+ const arc = one(board.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id = ?', id)?.subtask_of;
790
+ if (arc) throw new Error(`${id} is a task of ${arc}: an arc's sources are declared on the arc (comment ${arc} … --source owner/repository:branch).`);
791
+ if (!(roles ?? callerRoles(board.db, id)).some((role) => role === 'implementer' || role === 'manager')) throw new Error(`Only ${id}'s implementer or the manager declares its sources.`);
792
+ }
763
793
  if (pr != null || pr_head != null) {
764
794
  const urls = [].concat(pr ?? []), heads = [].concat(pr_head ?? []);
765
795
  if (!urls.length || urls.length !== heads.length) throw new Error('Record every PR with --pr URL --pr-head SHA, one full head per URL.');
@@ -771,7 +801,7 @@ export async function comment(board, id, author, body, { roles, pr = null, pr_he
771
801
  // Only the card's own implementer or the board's manager declares its sources; the author text is not identity.
772
802
  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.`);
773
803
  }
774
- const result = await act(board, id, 'comment', { roles, payload: { author: author.trim(), body, ...(sources ? { sources } : {}) } });
804
+ const result = await act(board, id, 'comment', { roles, payload: { author: author.trim(), body, ...(sources ? { sources } : {}), ...(branches ? { branches } : {}) } });
775
805
  return result.commentId;
776
806
  }
777
807
 
@@ -855,7 +885,7 @@ export function reconcileDependencySubscriptions(db) {
855
885
  const where = "WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=?";
856
886
  const identity = [row.task_id, row.chat_id, row.thread_id];
857
887
  if (!owner || owner.detached || owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) run(db, `DELETE FROM kanban_notify_subs ${where}`, ...identity);
858
- else if (address && (address !== row.chat_id || metadata.waiting_recipient)) run(db, `UPDATE kanban_notify_subs SET chat_id=?,delivery_metadata=? ${where}`, address, JSON.stringify({ ...metadata, waiting_recipient: false, failures: 0 }), ...identity);
888
+ else if (address && (address !== row.chat_id || metadata.waiting_recipient)) run(db, `UPDATE kanban_notify_subs SET chat_id=?,delivery_metadata=? ${where}`, address, JSON.stringify({ ...metadata, notice_wait: undefined, waiting_recipient: false, failures: 0 }), ...identity);
859
889
  else if (!address && !metadata.waiting_recipient) run(db, `UPDATE kanban_notify_subs SET delivery_metadata=? ${where}`, JSON.stringify({ ...metadata, waiting_recipient: true }), ...identity);
860
890
  }
861
891
  }
@@ -865,11 +895,13 @@ function inheritSubscriptions(db, child, parents, { workers = false } = {}) {
865
895
  const cursor = one(db, 'SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?', child).c;
866
896
  for (const p of parents) {
867
897
  for (const s of all(db, 'SELECT * FROM kanban_notify_subs WHERE task_id = ?', p)) {
868
- const role = json(s.delivery_metadata, {})?.role;
898
+ const metadata = json(s.delivery_metadata, {}) ?? {}, role = metadata.role;
869
899
  if ((role === 'worker' && !workers) || role === 'dependency') continue; // a parent's session does not work the child
900
+ // A child's subscription is a new wait, not the parent's retained delivery state.
901
+ delete metadata.notice_wait;
870
902
  run(db, `INSERT OR IGNORE INTO kanban_notify_subs (task_id, platform, chat_id, thread_id, user_id, user_id_alt, chat_type, notifier_profile, delivery_mode, delivery_metadata, created_at, last_event_id)
871
903
  VALUES (?, ?, ?, ?, ?, ?, COALESCE(?, 'dm'), ?, COALESCE(?, 'notify'), ?, ?, ?)`,
872
- child, s.platform, s.chat_id, s.thread_id, s.user_id, s.user_id_alt, s.chat_type, s.notifier_profile, s.delivery_mode, s.delivery_metadata, now(), cursor);
904
+ child, s.platform, s.chat_id, s.thread_id, s.user_id, s.user_id_alt, s.chat_type, s.notifier_profile, s.delivery_mode, JSON.stringify(metadata), now(), cursor);
873
905
  }
874
906
  }
875
907
  }
@@ -879,20 +911,21 @@ export const subscriptions = (db, id = null) => (id ? all(db, 'SELECT * FROM kan
879
911
 
880
912
  // ------------------------------------------------------------------ reading
881
913
 
882
- export function listCards(db, { status = null, assignee = null, machine = null, includeArchived = false } = {}) {
883
- 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
884
- 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')
885
- ORDER BY t.priority DESC, t.created_at ASC`, status, status, status, assignee, assignee, machine, machine, includeArchived ? 1 : 0);
886
- 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) }));
914
+ export function listCards(db, { status = null, assignee = null, machine = null, includeArchived = false, includeSubtasks = true, nativeHolds = false } = {}) {
915
+ const stateSql = cardStateSql({ nativeHolds });
916
+ const rows = all(db, `SELECT t.*, n.number, c.machine, c.subtask_of, c.stage, c.tags, c.paused, c.blocked_by FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id
917
+ WHERE (? IS NULL OR ${stateSql} = ? OR (? NOT IN ('paused','blocked') AND c.stage = ?)) AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND ${CARD_VISIBILITY_SQL}
918
+ ORDER BY t.priority DESC, t.created_at ASC`, status, status, status, status, assignee, assignee, machine, machine, includeSubtasks ? 1 : 0, includeArchived ? 1 : 0);
919
+ return rows.map((r) => cardState({ ...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) }, { nativeHolds }));
887
920
  }
888
921
 
889
- export function showCard(db, id) {
890
- const task = { ...must(db, id), reviewer: reviewerOf(db, id) };
922
+ export function showCard(db, id, options = {}) {
923
+ const task = cardState({ ...must(db, id), reviewer: reviewerOf(db, id) }, options);
891
924
  return {
892
925
  task,
893
926
  parents: parentsOf(db, id),
894
927
  children: childrenOf(db, id),
895
- subtasks: all(db, 'SELECT t.id, t.status, t.title FROM tasks t JOIN supercode_cards c ON c.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at', id),
928
+ subtasks: all(db, `SELECT t.id, n.number, t.status, t.title, c.stage, c.paused, c.blocked_by FROM tasks t JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at`, id).map(row => cardState(row, options)),
896
929
  comments: all(db, 'SELECT author, body, created_at FROM task_comments WHERE task_id = ? ORDER BY id', id),
897
930
  runs: all(db, 'SELECT * FROM task_runs WHERE task_id = ? ORDER BY id', id).map((r) => ({ ...r, metadata: json(r.metadata, null) })),
898
931
  events: all(db, 'SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? ORDER BY id', id).map((e) => ({ ...e, payload: json(e.payload, null) })),
@@ -905,9 +938,10 @@ export function showCard(db, id) {
905
938
  const cap = (text, n) => { text = text ? withheldIfDump(text) : text; return text && text.length > n ? `${text.slice(0, n)}… (${text.length - n} more characters)` : text; };
906
939
 
907
940
  /** What a session starting on a card reads first: the card, its parents' results, earlier attempts and the thread. */
908
- export function cardContext(db, id) {
909
- const task = must(db, id);
941
+ export function cardContext(db, id, options = {}) {
942
+ const task = cardState(must(db, id), options);
910
943
  const out = [`# Card ${task.id}: ${task.title}`, ''];
944
+ out.push(`Readable id: ${task.readable_id}.`);
911
945
  out.push(`Status: ${task.status}. Machine: ${task.machine ?? 'unset'}. Workspace: ${task.workspace_kind}${task.workspace_anchor ? ` ${task.workspace_anchor}` : task.workspace_path ? ` ${task.workspace_path}` : ''}${task.branch_name ? ` (branch ${task.branch_name})` : ''}.${task.subtask_of ? ` A subtask of ${task.subtask_of}.` : ''}`);
912
946
  if (task.body) out.push('', cap(task.body, 8 * 1024));
913
947
  const parents = all(db, 'SELECT t.id, t.title, t.result FROM tasks t JOIN task_links l ON l.parent_id = t.id WHERE l.child_id = ?', id);
@@ -987,14 +1021,16 @@ export function removeAttachment(board, attachmentId) {
987
1021
  }
988
1022
 
989
1023
  /** Per status, per assignee, and the oldest ready card's age (Hermes's `stats`). */
990
- export function stats(db) {
1024
+ export function stats(db, options = {}) {
1025
+ const stateSql = cardStateSql(options);
1026
+ const rows = `FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id`;
991
1027
  const byStatus = {};
992
- for (const r of all(db, "SELECT status, COUNT(*) AS n FROM tasks WHERE status != 'archived' GROUP BY status")) byStatus[r.status] = Number(r.n);
1028
+ for (const r of all(db, `SELECT ${stateSql} AS board_status, COUNT(*) AS n ${rows} WHERE t.status != 'archived' GROUP BY board_status`)) byStatus[r.board_status] = Number(r.n);
993
1029
  const byAssignee = {};
994
- for (const r of all(db, "SELECT assignee, status, COUNT(*) AS n FROM tasks WHERE status != 'archived' AND assignee IS NOT NULL GROUP BY assignee, status")) (byAssignee[r.assignee] ??= {})[r.status] = Number(r.n);
1030
+ for (const r of all(db, `SELECT t.assignee, ${stateSql} AS board_status, COUNT(*) AS n ${rows} WHERE t.status != 'archived' AND t.assignee IS NOT NULL GROUP BY t.assignee, board_status`)) (byAssignee[r.assignee] ??= {})[r.board_status] = Number(r.n);
995
1031
  const byMachine = {};
996
- for (const r of all(db, "SELECT c.machine, t.status, COUNT(*) AS n FROM tasks t JOIN supercode_cards c ON c.task_id = t.id WHERE t.status != 'archived' AND c.machine IS NOT NULL GROUP BY c.machine, t.status")) (byMachine[r.machine] ??= {})[r.status] = Number(r.n);
997
- const oldest = one(db, "SELECT MIN(created_at) AS ts FROM tasks WHERE status = 'ready'")?.ts;
1032
+ for (const r of all(db, `SELECT c.machine, ${stateSql} AS board_status, COUNT(*) AS n ${rows} WHERE t.status != 'archived' AND c.machine IS NOT NULL GROUP BY c.machine, board_status`)) (byMachine[r.machine] ??= {})[r.board_status] = Number(r.n);
1033
+ const oldest = one(db, `SELECT MIN(t.created_at) AS ts ${rows} WHERE ${stateSql} = 'ready'`)?.ts;
998
1034
  const t = now();
999
1035
  return { by_status: byStatus, by_assignee: byAssignee, by_machine: byMachine, oldest_ready_age_seconds: oldest ? t - Number(oldest) : null, now: t };
1000
1036
  }
@@ -1032,7 +1068,8 @@ export function diagnosePublishing(publishing) {
1032
1068
  * same thing can go wrong, and supercode's own where only its sessions can): each `{ kind, severity, title, data,
1033
1069
  * actions }`, worst first.
1034
1070
  */
1035
- export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, strandedSeconds = 30 * 60, blockedHours = 24, fleet = null, launchWindowSeconds = 300, publishing = null, expiredMail = null, waitingMail = null } = {}) {
1071
+ export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, strandedSeconds = 30 * 60, blockedHours = 24, fleet = null, launchWindowSeconds = 300, publishing = null, expiredMail = null, waitingMail = null, nativeHolds = false } = {}) {
1072
+ task = cardState(task, { nativeHolds });
1036
1073
  const out = [];
1037
1074
  const t = now();
1038
1075
  const events = all(db, 'SELECT kind, payload, created_at FROM task_events WHERE task_id = ? ORDER BY id', task.id);
@@ -1059,9 +1096,10 @@ export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, stran
1059
1096
  if (lost >= 2) out.push({ kind: 'repeated_session_loss', severity: 'warning', title: `its session was lost ${lost}x in 24h`, data: { lost }, actions: [`supercode workflow set ${task.id} machine <machine>`] });
1060
1097
  const crashes = events.filter((e) => e.kind === 'crashed' && t - e.created_at < 24 * 3600).length;
1061
1098
  if (crashes >= 2) out.push({ kind: 'repeated_crashes', severity: 'error', title: `its worker crashed ${crashes}x in 24h`, data: { crashes }, actions: [`supercode workflow log ${task.id}`] });
1062
- if (task.status === 'blocked') {
1099
+ if (task.status === 'paused') out.push({ kind: 'paused', severity: 'info', title: holdDescription(task), data: { paused: task.paused }, actions: [`supercode workflow resume ${task.id}`] });
1100
+ if (task.status === 'blocked' && (!nativeHolds || task.blocked_by.length)) {
1063
1101
  const since = [...events].reverse().find((e) => e.kind === 'blocked' || e.kind === 'gave_up')?.created_at ?? task.created_at;
1064
- if (hours(t - since) >= blockedHours) out.push({ kind: 'stuck_in_blocked', severity: 'warning', title: `blocked for ${Math.floor(hours(t - since))}h`, data: { hours: Math.floor(hours(t - since)) }, actions: [`supercode workflow unblock ${task.id}`] });
1102
+ if (hours(t - since) >= blockedHours) out.push({ kind: 'stuck_in_blocked', severity: 'warning', title: `blocked for ${Math.floor(hours(t - since))}h${holdDescription(task) ? `; ${holdDescription(task)}` : ''}`, data: { hours: Math.floor(hours(t - since)), blocked_by: task.blocked_by }, actions: [`supercode workflow unblock ${task.id}`] });
1065
1103
  const cycles = events.filter((e) => e.kind === 'unblocked' && t - e.created_at < 24 * 3600).length;
1066
1104
  if (cycles >= 3) out.push({ kind: 'block_unblock_cycling', severity: 'warning', title: `block→unblock cycled ${cycles}x in 24h`, data: { cycles }, actions: [] });
1067
1105
  }
@@ -92,7 +92,12 @@ export async function* followBoardPublications({root,after,signal}) {
92
92
  // A pruned floor above the cursor means changes it never read are gone; a new log id means a new store.
93
93
  if(!position||position.log!==boundary.log||!Number.isSafeInteger(position.sequence)||position.sequence<boundary.floor||position.sequence>boundary.high)throw resync(`Workflow publication log changed: ${slug}`);
94
94
  for(;;){
95
- const rows=board.db.prepare('SELECT * FROM supercode_publications WHERE sequence>? ORDER BY sequence LIMIT 32').all(positions[slug].sequence);if(!rows.length)break;
95
+ const rows=board.tx(db=>{
96
+ const current=db.prepare('SELECT id,pruned_through FROM supercode_publication_log').get();
97
+ if(!current||current.id!==position.log||positions[slug].sequence<Number(current.pruned_through))throw resync(`Workflow publication log changed: ${slug}`);
98
+ return db.prepare('SELECT * FROM supercode_publications WHERE sequence>? AND sequence<=? ORDER BY sequence LIMIT 32').all(positions[slug].sequence,boundary.high);
99
+ });
100
+ if(!rows.length)break;
96
101
  for(const row of rows){positions[slug].sequence=row.sequence;yield {type:'event',id:row.id,kind:row.kind,sequence:row.sequence,cursor:cursorOf(home,positions),data:{board:slug,card:JSON.parse(row.snapshot)}};}
97
102
  }
98
103
  }
package/board/files.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { fillReadableIds } from './readable-id.mjs';
1
2
  // The `files` backing of a board (docs/architecture/orchestrator.md §2.9, "Backings"): the same tables as a
2
3
  // Hermes `kanban.db`, kept as a directory of JSON files instead of one SQLite file. `<id>.json` holds everything of
3
4
  // one card (its `tasks` row, its `supercode_cards` row, the links to its parents, its comments, events, runs,
@@ -11,6 +12,7 @@
11
12
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
12
13
  import { fenced } from './fence.mjs';
13
14
  import { markRemovedHeads } from './published.mjs';
15
+ import { CARD_NUMBERS } from './store.mjs';
14
16
  import { createHash } from 'node:crypto';
15
17
  import { join } from 'node:path';
16
18
  import { DatabaseSync } from 'node:sqlite';
@@ -44,7 +46,7 @@ export function openFilesBoard(dir, { create = false, init, held = false }) {
44
46
  // events, and on every read while it keeps no watch on them; a write still compares the files it read (persist)
45
47
  board.held = held;
46
48
  if (!isFilesBoard(dir)) board.tx(() => {}, { force: true });
47
- return { db: board.db, tx: (fn) => board.tx(fn), close: () => board.close(),
49
+ return { db: board.db, snapshot: (path) => board.read(() => board.mem.prepare('VACUUM INTO ?').run(path)), tx: (fn) => board.tx(fn), close: () => board.close(),
48
50
  documentsChanged: () => { board.changed = true; }, documentsUnwatched: (unwatched) => { board.unwatched = unwatched; } };
49
51
  }
50
52
 
@@ -98,6 +100,9 @@ class FilesBoard {
98
100
  const state = this.stateText === null ? null : JSON.parse(this.stateText);
99
101
  for (const sql of state?.schema ?? []) mem.exec(sql);
100
102
  this.init(mem, { fresh: !state?.schema?.length });
103
+ // the schema as the directory keeps it: a layout this load added (a table, a column, the card numbers) is written to
104
+ // every card's file by the next write (persist)
105
+ this.keptSchema = state?.schema ?? [];
101
106
  const columns = (table) => new Set(mem.prepare(`PRAGMA table_info(${quote(table)})`).all().map((c) => c.name));
102
107
  const insert = (table, rows) => {
103
108
  const have = columns(table);
@@ -108,17 +113,26 @@ class FilesBoard {
108
113
  };
109
114
  const previous = this.texts;
110
115
  this.texts = new Map();
116
+ const cards = [];
111
117
  for (const name of readdirSync(this.dir)) {
112
118
  if (!name.endsWith('.json') || name.startsWith('_')) continue;
113
119
  const text = readFileSync(join(this.dir, name), 'utf8');
114
120
  const card = JSON.parse(text);
115
121
  this.texts.set(card.id, text);
116
- for (const [table, rows] of Object.entries(card.tables ?? {})) insert(table, rows);
122
+ cards.push(card);
117
123
  }
118
- for (const [table, rows] of Object.entries(state?.tables ?? {})) insert(table, rows);
124
+ // The id counters first: a number handed out before stays handed out.
119
125
  for (const { name, seq } of state?.sequence ?? []) {
120
126
  if (mem.prepare('UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = ?').run(seq, name).changes === 0) mem.prepare('INSERT INTO sqlite_sequence (name, seq) VALUES (?, ?)').run(name, seq);
121
127
  }
128
+ // Inserting a card's row numbers it when it has no number yet (store.mjs layOutNumbers): every number a card file
129
+ // holds loads first, so each card keeps its own and a new one is past them all, and then the cards load in the
130
+ // order they were made.
131
+ for (const card of cards) if (card.tables?.[CARD_NUMBERS]) insert(CARD_NUMBERS, card.tables[CARD_NUMBERS]);
132
+ const madeAt = (card) => Number(card.tables?.tasks?.[0]?.created_at ?? 0);
133
+ cards.sort((a, b) => madeAt(a) - madeAt(b) || (String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0));
134
+ for (const card of cards) for (const [table, rows] of Object.entries(card.tables ?? {})) if (table !== CARD_NUMBERS) insert(table, rows);
135
+ for (const [table, rows] of Object.entries(state?.tables ?? {})) insert(table, rows);
122
136
  // Loading inserts every card, which marks every card changed (published.mjs). The first load of this projection keeps
123
137
  // those marks: its next write compares each card with its published head once (a named limit: each projection opened
124
138
  // compares every card once, as each load already reads every card's file). A reload keeps the marks not yet
@@ -134,6 +148,7 @@ class FilesBoard {
134
148
  if (mem.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'supercode_publication_heads'").get()) markRemovedHeads(mem);
135
149
  this.mem = mem;
136
150
  this.watch();
151
+ fillReadableIds(mem);
137
152
  this.stamp = stamp;
138
153
  }
139
154
 
@@ -172,7 +187,8 @@ class FilesBoard {
172
187
  /** Write the files of the cards the transaction touched, then `_board.json`; writes compare the content hash. */
173
188
  persist(schemaBefore) {
174
189
  const schema = this.schema();
175
- const reshaped = schema.join('\n') !== schemaBefore.join('\n');
190
+ // reshaped by this transaction, or by the load it runs on (a layout the directory does not keep yet)
191
+ const reshaped = schema.join('\n') !== schemaBefore.join('\n') || schema.join('\n') !== (this.keptSchema ?? []).join('\n');
176
192
  const touched = new Set(this.mem.prepare('SELECT DISTINCT id FROM _touched').all().map((r) => r.id).filter((id) => id !== null));
177
193
  if (reshaped) this.watch(); // a table made in the transaction gets its triggers; every card is written again
178
194
  const keys = this.keys;
package/board/listing.mjs CHANGED
@@ -3,6 +3,8 @@
3
3
  import { setImmediate as nextTurn } from 'node:timers/promises';
4
4
  import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
5
5
  import { executionOf } from './session-work.mjs';
6
+ import { CARD_VISIBILITY_SQL } from './visibility.mjs';
7
+ import { cardState, cardStateSql } from './card-state.mjs';
6
8
 
7
9
  export const LIST_BATCH = 64;
8
10
  // A listing on the owner's read door (owner-door.mjs) that can read no committed state without waiting on a writer (a
@@ -45,9 +47,9 @@ const sameRevision = (a, b) => Object.keys(a).every(key => a[key] === b[key]);
45
47
 
46
48
  // Only the fields cardDict, human lines, filters and sorters use. A human listing never reads
47
49
  // body/result/skills or run history. A JSON listing retains its existing full-text contract.
48
- const HUMAN_FIELDS = 'id,title,assignee,status,priority,tenant,created_at,started_at,completed_at';
50
+ const HUMAN_FIELDS = 'id,readable_id,title,assignee,status,priority,tenant,created_at,started_at,completed_at';
49
51
  const JSON_FIELDS = `${HUMAN_FIELDS},body,result,workspace_kind,workspace_path,branch_name,project_id,created_by,skills,max_retries,model_override,provider_override,session_id,workflow_template_id,current_step_key`;
50
- export const TASK_SUMMARY_INDEX = 'idx_tasks_list_summary';
52
+ export const TASK_SUMMARY_INDEX = 'idx_tasks_list_summary_readable';
51
53
  export const TASK_SUMMARY_INDEX_SQL = `CREATE INDEX IF NOT EXISTS ${TASK_SUMMARY_INDEX} ON tasks(${HUMAN_FIELDS},current_run_id,session_id,workflow_template_id,current_step_key)`;
52
54
  const RUN_METADATA = `CASE WHEN json_valid(r.metadata) THEN json_object('supercode', json_object(
53
55
  'address',json_extract(r.metadata,'$.supercode.address'),'session_id',json_extract(r.metadata,'$.supercode.session_id'),
@@ -66,15 +68,16 @@ export const LAST_BLOCK_SQL = `SELECT CASE WHEN kind = 'blocked' THEN payload EN
66
68
  UNION ALL SELECT (SELECT id FROM task_events WHERE task_id = t.id AND kind = 'dependency_wait' ORDER BY id DESC LIMIT 1)
67
69
  ))`;
68
70
 
69
- export function listPageSql(detailed, tagCount = 0) {
71
+ export function listPageSql(detailed, tagCount = 0, options = {}) {
70
72
  return `SELECT t.rowid AS list_order,${(detailed ? JSON_FIELDS : HUMAN_FIELDS).split(',').map(name => `t.${name}`).join(',')},
71
- c.machine,c.stage${detailed ? ',c.subtask_of' : ''},
73
+ n.number,c.machine,c.stage,c.paused,c.blocked_by,c.detached${detailed ? ',c.subtask_of' : ''},
72
74
  r.id AS run_id,r.status AS run_status,r.started_at AS run_started_at,r.ended_at AS run_ended_at,${RUN_METADATA} AS run_metadata
73
75
  ${detailed ? `,(SELECT json_group_array(parent_id) FROM (SELECT parent_id FROM task_links WHERE child_id = t.id ORDER BY parent_id)) AS parents_json,
74
76
  (${LAST_BLOCK_SQL}) AS block_payload` : ''}
75
- FROM tasks t ${detailed ? '' : `INDEXED BY ${TASK_SUMMARY_INDEX}`} LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN task_runs r ON r.id = t.current_run_id
76
- WHERE t.id IN (SELECT value FROM json_each(?)) AND (? IS NULL OR t.status = ? OR c.stage = ?)
77
- AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? OR t.status != 'archived')
77
+ FROM tasks t ${detailed ? '' : `INDEXED BY ${TASK_SUMMARY_INDEX}`} LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN supercode_card_numbers n ON n.task_id = t.id
78
+ LEFT JOIN task_runs r ON r.id = t.current_run_id
79
+ WHERE t.id IN (SELECT value FROM json_each(?)) AND (? IS NULL OR ${cardStateSql(options)} = ? OR (? NOT IN ('paused','blocked') AND c.stage = ?))
80
+ AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND ${CARD_VISIBILITY_SQL}
78
81
  AND (? IS NULL OR t.tenant = ?) AND (? IS NULL OR t.session_id = ?)
79
82
  AND (? IS NULL OR t.workflow_template_id = ?) AND (? IS NULL OR t.current_step_key = ?)
80
83
  ${Array.from({ length: tagCount }, () => `AND CASE WHEN json_valid(c.tags) THEN
@@ -91,10 +94,10 @@ export async function listCardsInTurns(db, options = {}) {
91
94
  throw Object.assign(new Error('board changed during both listing reads; run list again'), { code: 'board_listing_changed' });
92
95
  }
93
96
 
94
- async function readCardsInTurns(db, { detailed = false, status = null, assignee = null, machine = null, includeArchived = false,
95
- tenant = null, session = null, workflowTemplateId = null, stepKey = null, tags = [] }, revision) {
97
+ async function readCardsInTurns(db, { detailed = false, status = null, assignee = null, machine = null, includeArchived = false, includeSubtasks = false,
98
+ tenant = null, session = null, workflowTemplateId = null, stepKey = null, tags = [], nativeHolds = false }, revision) {
96
99
  const out = [];
97
- const page = prepared(db, listPageSql(detailed, tags.length)), candidates = prepared(db, LIST_CANDIDATES_SQL);
100
+ const page = prepared(db, listPageSql(detailed, tags.length, { nativeHolds })), candidates = prepared(db, LIST_CANDIDATES_SQL);
98
101
  const ceiling = measured('last candidate', () => prepared(db, "SELECT COALESCE(MAX(id), '') AS cursor FROM tasks").get().cursor);
99
102
  let cursor = '';
100
103
  while (cursor < ceiling) {
@@ -102,7 +105,7 @@ async function readCardsInTurns(db, { detailed = false, status = null, assignee
102
105
  const range = measured('candidate range', () => candidates.get(cursor, ceiling));
103
106
  if (range.last == null) break;
104
107
  const rows = measured(detailed ? 'JSON task rows and latest blocks' : 'human task rows', () => page.all(range.ids,
105
- status, status, status, assignee, assignee, machine, machine, includeArchived ? 1 : 0,
108
+ status, status, status, status, assignee, assignee, machine, machine, includeSubtasks ? 1 : 0, includeArchived ? 1 : 0,
106
109
  tenant, tenant, session, session, workflowTemplateId, workflowTemplateId, stepKey, stepKey, ...tags.flatMap(tag => [tag, tag, tag])));
107
110
  measured('row projection', () => {
108
111
  for (const { run_id, run_status, run_started_at, run_ended_at, run_metadata, parents_json, block_payload, ...task } of rows) {
@@ -116,7 +119,7 @@ async function readCardsInTurns(db, { detailed = false, status = null, assignee
116
119
  task.waiting_on = parse(block_payload, {})?.waiting_on ?? null;
117
120
  task.run = run ? { id: run.id, status: run.status, started_at: run.started_at, session: metadata.supercode?.address ?? metadata.supercode?.session_id ?? null, execution: task.execution } : null;
118
121
  }
119
- out.push(task);
122
+ out.push(cardState(task, { nativeHolds }));
120
123
  }
121
124
  });
122
125
  cursor = range.last;
@@ -82,12 +82,14 @@ export async function readMachineFleet(machines, local) {
82
82
  noteStep(`fleet reading (${names.length} machine${names.length === 1 ? '' : 's'}, held records)`, performance.now() - started);
83
83
  // `panes`: each answering machine's live pane ids, whether or not a session was read in them
84
84
  // `ended`: the panes each machine saw end (its tmux's exit log, or a pane its host keeps dead), with how each ended
85
- const online = new Set(), unknown = new Set(), alive = new Set(), native = new Map(), sessions = [], capabilities = new Map(), panes = new Map(), ended = new Map();
85
+ const online = new Set(), offline = new Set(), unknown = new Set(), errors = new Map(), alive = new Set(), native = new Map(), sessions = [], capabilities = new Map(), panes = new Map(), ended = new Map();
86
86
  let localError = null;
87
87
  for (const row of rows) {
88
88
  if (row.error) {
89
+ errors.set(row.machine, row.error);
89
90
  if (row.machine === local) localError = row.error;
90
- else if (row.code !== 'machine_offline') unknown.add(row.machine);
91
+ else if (row.code === 'machine_offline') offline.add(row.machine);
92
+ else unknown.add(row.machine);
91
93
  continue;
92
94
  }
93
95
  online.add(row.machine);
@@ -98,13 +100,13 @@ export async function readMachineFleet(machines, local) {
98
100
  ...(row.ended ?? []).filter((exit) => exit?.pane && !panes.get(row.machine).has(exit.pane)).map((exit) => [exit.pane, { failed: exit.failed ?? null, how: exit.how ?? null }]),
99
101
  ...(row.panes ?? []).filter((pane) => pane.dead).map((pane) => [pane.pane, { failed: null, how: 'its program ended (the pane is kept for its last screen)' }]),
100
102
  ]));
101
- for (const { pid, activity } of row.activities ?? []) {
103
+ for (const { pid, activity, locator } of row.activities ?? []) {
102
104
  const pane = live.find((item) => item.pid === pid);
103
105
  if (!pane || !activity?.session_id || !activity.harness) continue;
104
106
  const address = `sc:${row.machine}:${activity.harness}:${activity.session_id}`, running = activity.presence === 'running';
105
107
  native.set(address, { alive: running ? true : null, states: [running ? true : null] });
106
108
  if (running) alive.add(address);
107
- sessions.push({ address, machine: row.machine, harness: activity.harness, session_id: activity.session_id, pane: pane.pane, name: pane.title ?? pane.label ?? null,
109
+ sessions.push({ address, machine: row.machine, harness: activity.harness, session_id: activity.session_id, locator: locator ?? null, pane: pane.pane, name: pane.title ?? pane.label ?? null,
108
110
  starter_session: pane.starter_session ?? null,
109
111
  state: running ? 'running' : 'unknown', turn: activity.turn ?? null, native_state: activity.evidence?.native_state ?? null,
110
112
  // what a session that needs input says it waits for, what started its open turn (Codex: `goal` for a thread
@@ -114,5 +116,5 @@ export async function readMachineFleet(machines, local) {
114
116
  }
115
117
  }
116
118
  const ok = online.has(local);
117
- return { ok, presenceKnown: ok, online, unknown, alive, native, sessions, capabilities, panes, ended, error: ok ? null : `machine ${local}'s door did not answer: ${localError ?? 'no row'}` };
119
+ return { ok, presenceKnown: ok, online, offline, unknown, errors, alive, native, sessions, capabilities, panes, ended, error: ok ? null : `machine ${local}'s door did not answer: ${localError ?? 'no row'}` };
118
120
  }
package/board/notices.mjs CHANGED
@@ -1,3 +1,8 @@
1
+ /** A notice's text or a mail's subject about a card, with the card's keyed number first (`VO-42 …`; store.mjs boardKey). */
2
+ export const numbered = (key, number, text) => (number ? `${key}-${number} ${text}` : text);
3
+ /** The number of card `id` in the board's store, or null. */
4
+ export const cardNumber = (db, id) => db.prepare('SELECT number FROM supercode_card_numbers WHERE task_id = ?').get(id)?.number ?? null;
5
+
1
6
  export function identifyNotice(event, text) {
2
7
  const time = new Date(Number(event.created_at) * 1000).toISOString();
3
8
  const kind = String(event.kind).replaceAll('_', ' ');