@volter/supercode-orchestrator 0.4.148 → 0.4.150

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
@@ -37,7 +37,7 @@ export function getTask(db, id) {
37
37
  const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
38
38
  if (!row) return null;
39
39
  const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
40
- return { ...row, workspace_anchor: ours?.workspace_anchor ?? null, review_only: Boolean(ours?.review_only), blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
40
+ return { ...row, workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
41
41
  }
42
42
 
43
43
  function must(db, id) {
@@ -152,10 +152,36 @@ function projectOf(root, idOrSlug) {
152
152
  } catch { return null; } finally { db.close(); }
153
153
  }
154
154
 
155
+ /**
156
+ * A card's Tasks are its subtasks (the document renders them as its Tasks section). Task lines written in an arc's
157
+ * body (`## Tasks` then `- [ ] step`, its indented lines the step's own text) are answered as the steps to file as
158
+ * its subtasks, and leave the body. A Tasks section with no task line is the body's own text.
159
+ */
160
+ export function bodyTasks(body) {
161
+ const lines = String(body ?? '').split('\n');
162
+ const at = lines.findIndex((line) => /^#{2,3}\s+Tasks\s*$/i.test(line));
163
+ if (at < 0) return { body, tasks: [] };
164
+ let end = lines.findIndex((line, i) => i > at && /^#{1,3}\s/.test(line));
165
+ if (end < 0) end = lines.length;
166
+ const section = lines.slice(at + 1, end);
167
+ if (!section.some((line) => /^[-*] \[[ xX]\]/.test(line))) return { body, tasks: [] };
168
+ const tasks = [];
169
+ for (const line of section) {
170
+ if (!line.trim()) continue;
171
+ const step = /^[-*] \[ \]\s+(\S.*)$/.exec(line);
172
+ if (step) tasks.push({ title: step[1].trim(), body: [] });
173
+ else if (/^\s+\S/.test(line) && tasks.length) tasks.at(-1).body.push(line.replace(/^\s{1,4}/, ''));
174
+ else throw new Error("A card's Tasks section holds open task lines ('- [ ] <step>', each filed as a subtask, with its own indented lines); record finished work as a comment and move other text out of the section");
175
+ }
176
+ const rest = [...lines.slice(0, at), ...lines.slice(end)].join('\n').replace(/\n{3,}/g, '\n\n').trim();
177
+ return { body: rest || null, tasks: tasks.map((t) => ({ title: t.title, body: t.body.join('\n') || null })) };
178
+ }
179
+
155
180
  export function createCard(board, spec) {
156
- if (spec.reviewOnly !== undefined && typeof spec.reviewOnly !== 'boolean') throw new Error('reviewOnly must be a boolean');
157
181
  const title = String(spec.title ?? '').trim();
158
182
  if (!title) throw new Error('a card has a title');
183
+ const steps = spec.subtaskOf ? [] : bodyTasks(spec.body).tasks;
184
+ if (steps.length) spec = { ...spec, body: bodyTasks(spec.body).body };
159
185
  const ws = parseWorkspace(spec.workspace ?? 'scratch');
160
186
  if (spec.branch && ws.kind === 'scratch') throw new Error('a scratch workspace has no repository branch');
161
187
  const parents = [...new Set((spec.parents ?? []).filter(Boolean))];
@@ -184,14 +210,15 @@ export function createCard(board, spec) {
184
210
  if (missing.length) throw new Error(`unknown parent card(s): ${missing.join(', ')}`);
185
211
  const arc = spec.subtaskOf ? getTask(db, spec.subtaskOf) : null;
186
212
  const entry = workflowFor(board.root);
187
- if (spec.reviewOnly) {
188
- const permitted = entry.params.review_only_by;
189
- if (arc || spec.every || initial || !Array.isArray(permitted) || !callerRoles(db, arc?.id ?? '').some(role => permitted.includes(role))) throw new Error('Only a permitted manager creates a review-only arc, without recurrence, subtasks or an initial-status override.');
190
- }
191
213
  if (spec.subtaskOf && !arc) throw new Error(`no card ${spec.subtaskOf} to be a subtask of`);
192
214
  if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; a subtask belongs to an arc`);
193
215
  let id = newTaskId();
194
216
  while (one(db, 'SELECT 1 FROM tasks WHERE id = ?', id)) id = newTaskId();
217
+ // where a card's blocks are relations, a card created blocked names what it waits on: one that names nothing is
218
+ // ready at once (board-ir-2 started six minutes after it was created "blocked")
219
+ if (initial === 'blocked' && !entry.workflow.statuses?.blocked && !parents.length) {
220
+ throw new Error('A card created blocked names what it waits on: --parent <card> (it starts when that card is done); or create it and block it with --waiting-on <message>.');
221
+ }
195
222
  const creationRoles = workflowFor(board.root).params.create_dependencies_by;
196
223
  if ((parents.length || initial === 'blocked') && creationRoles && !callerRoles(db, arc?.id ?? id).some(role => creationRoles.includes(role))) {
197
224
  throw new Error('Creating a dependency or initially blocked card requires a permitted manager role; file the task without a blocker and use request-state.');
@@ -216,13 +243,13 @@ export function createCard(board, spec) {
216
243
  spec.skills?.length ? JSON.stringify(spec.skills) : null, spec.maxRetries ?? null, spec.model ?? null, spec.provider ?? null,
217
244
  spec.reasoning ?? null, spec.sessionId ?? null, spec.project ?? null, spec.goal ? 1 : 0, spec.goal?.maxTurns ?? null);
218
245
  const machine = spec.machine ?? arc?.machine ?? null;
219
- run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root,review_only) VALUES (?,?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.mainRoot ?? null, spec.reviewOnly ? 1 : 0);
246
+ run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root) VALUES (?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.mainRoot ?? null);
220
247
  for (const p of parents) addDependency(db, entry, id, p);
221
248
  // where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
222
249
  // the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
223
250
  const status = startNew(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial,
224
- review_only: Boolean(spec.reviewOnly), every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
225
- event(db, id, 'created', { creator: actorOf(), assignee: spec.assignee || null, status, review_only: Boolean(spec.reviewOnly), parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
251
+ every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
252
+ event(db, id, 'created', { creator: actorOf(), assignee: spec.assignee || null, status, parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
226
253
  if ((spec.noStart || spec.adopt) && !arc) event(db, id, 'dispatch_held', { session: spec.adopt ?? null, reason: spec.adopt ? 'awaiting adoption' : 'created with --no-start' });
227
254
  if (parents.length) inheritSubscriptions(db, id, parents);
228
255
  if (arc) {
@@ -232,7 +259,9 @@ export function createCard(board, spec) {
232
259
  }
233
260
  if (initial === 'running' && arc && entry.workflow.run) setStatus(db, entry.workflow, id, 'running');
234
261
  else if (initial === 'running') claimIn(db, id, { ttl: claimTtl() });
235
- return { id, existing: false };
262
+ // the body's task lines, as subtasks the creator filed, in this same write
263
+ const subtasks = steps.map((step) => createCard(board, { ...step, subtaskOf: id, assignee: spec.assignee || null }).id);
264
+ return { id, existing: false, ...(subtasks.length ? { subtasks } : {}) };
236
265
  });
237
266
  }
238
267
 
@@ -255,8 +284,20 @@ function wouldCycle(db, parent, child) {
255
284
 
256
285
 
257
286
 
258
- /** The machine a card's session runs on. Moving it is a decision: a running card is reclaimed first. */
259
- export const setMachine = (board, id, machine, { roles } = {}) => act(board, id, 'move', { roles, payload: { machine: machine || null } });
287
+ /**
288
+ * Set one of a card's properties (`assignee`, `machine`, `model`, `every`) through the workflow's `set` verb, whose
289
+ * rules say who may and what follows: a change to how its session runs ends a live run and lets the dispatcher start
290
+ * the next one itself. A workflow without `set` (Hermes's own) has a verb per property.
291
+ */
292
+ export const SETTABLE = ['assignee', 'machine', 'model', 'every'];
293
+ export function set(board, id, field, value, { provider = null, roles } = {}) {
294
+ if (!SETTABLE.includes(field)) throw new Error(`set takes one of ${SETTABLE.join(', ')}, not ${field}`);
295
+ if (provider && (field !== 'model' || !value)) throw new Error('a provider goes with a model');
296
+ value = value || null;
297
+ if (workflowFor(board.root).workflow.verbs?.set) return act(board, id, 'set', { roles, payload: { field, value, provider } });
298
+ const verb = { assignee: ['assign', { assignee: value }], machine: ['move', { machine: value }], model: ['set_model', { model: value, provider }], every: ['set_every', { every: value }] }[field];
299
+ return act(board, id, verb[0], { roles, payload: verb[1] });
300
+ }
260
301
 
261
302
  // ------------------------------------------------------------------ lifecycle
262
303
 
@@ -345,8 +386,9 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
345
386
  const entry = await loadWorkflow(board.root);
346
387
  if (name === 'comment' && (!String(payload.body ?? '').trim() || !String(payload.author ?? '').trim())) throw new Error('a comment has an author and body');
347
388
  refreshCardInputs(board, id, { commits: ['complete', 'request_review'].includes(name) });
348
- if (['complete', 'request_review', 'request_state'].includes(name)) payload = { ...payload, ci: observeCompletionEvidence(board, id, entry, payload, completionPhase(board, id, entry, name, payload, roles ?? callerRoles(board.db, id))) };
349
- if (payload.ci?.required && !payload.ci.ready) throw new Error(payload.ci.reason ?? 'The PR candidate could not be verified.');
389
+ const evidencePhase = ['complete', 'request_review', 'request_state'].includes(name) ? completionPhase(board, id, entry, name, payload, roles ?? callerRoles(board.db, id)) : null;
390
+ if (evidencePhase) payload = { ...payload, ci: observeCompletionEvidence(board, id, entry, payload, evidencePhase) };
391
+ if (payload.ci?.required && !payload.ci.ready && (name !== 'request_state' || evidencePhase === 'review_verdict')) throw new Error(payload.ci.reason ?? 'The PR candidate could not be verified.');
350
392
  const result = tx(board, (db) => {
351
393
  roles ??= callerRoles(db, id);
352
394
  const r = fire(db, board, entry, id, name, { roles, payload, extra });
@@ -368,9 +410,14 @@ export async function requestStateMany(board, ids, payload = {}) {
368
410
  const entry = await loadWorkflow(board.root);
369
411
  const prepared = new Map(targets.map(id => {
370
412
  const roles = callerRoles(board.db, id);
371
- return [id, inspectCompletionEvidence(board, id, entry, payload, completionPhase(board, id, entry, 'request_state', payload, roles))];
413
+ const phase = completionPhase(board, id, entry, 'request_state', payload, roles);
414
+ const evidence = inspectCompletionEvidence(board, id, entry, payload, phase);
415
+ // An ordinary request mails a decision to the manager, not a completion.
416
+ // Keep a failed source observation in that mail. A reviewer recording an
417
+ // actual verdict still has to verify the requested PR head.
418
+ if (phase === 'review_verdict' && evidence.required && !evidence.ready) throw new Error(evidence.reason ?? 'The reviewed PR head could not be verified.');
419
+ return [id, evidence];
372
420
  }));
373
- for (const evidence of prepared.values()) if (evidence.required && !evidence.ready) throw new Error(evidence.reason ?? 'The reviewed PR head could not be verified.');
374
421
  const result = tx(board, db => {
375
422
  const effects = [];
376
423
  const results = targets.map(id => withCompletionEvidence(board, id, prepared.get(id), () => {
@@ -494,7 +541,7 @@ export const schedule = (board, id, reason = null, { roles } = {}) => act(board,
494
541
  export const promote = (board, id, { actor = 'user', reason = null, force = false, roles } = {}) => act(board, id, 'promote', { roles, payload: { actor, reason, force } });
495
542
  export const reclaim = (board, id, reason = null, { roles } = {}) => act(board, id, 'reclaim', { roles, payload: { reason } });
496
543
  export const archive = (board, id, { roles } = {}) => act(board, id, 'archive', { roles });
497
- export function specify(board, id, { title = null, body = null, workspace = null, branch = null, reviewOnly = null, author = 'user', roles } = {}) {
544
+ export async function specify(board, id, { title = null, body = null, workspace = null, branch = null, author = 'user', roles } = {}) {
498
545
  let declaration = null;
499
546
  if (workspace != null || branch != null) {
500
547
  const task = getTask(board.db, id);
@@ -507,17 +554,15 @@ export function specify(board, id, { title = null, body = null, workspace = null
507
554
  if (declaration.kind === 'worktree') declaration.branch ??= `wt/${id}`;
508
555
  if (declaration.kind === 'dir') declaration.path = checkWorkspace(board, { ...task, workspace_kind: declaration.kind, workspace_anchor: declaration.repository, workspace_path: declaration.path, branch_name: declaration.branch }).path;
509
556
  }
510
- if (reviewOnly != null && (typeof reviewOnly !== 'boolean' || !Array.isArray(workflowFor(board.root).params.review_only_by))) throw new Error('This workflow must declare review_only_by before a boolean review-only declaration is changed.');
511
- return act(board, id, 'specify', { roles, payload: { title, body, author, ...(reviewOnly != null ? { review_only: reviewOnly } : {}), ...(declaration ? { workspace: declaration } : {}) } });
557
+ // an arc's new task lines are filed as its subtasks once the specify itself is accepted
558
+ const steps = body != null && !one(board.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id = ?', id)?.subtask_of ? bodyTasks(body) : { body, tasks: [] };
559
+ const done = await act(board, id, 'specify', { roles, payload: { title, body: steps.body, author, ...(declaration ? { workspace: declaration } : {}) } });
560
+ for (const step of steps.tasks) createCard(board, { ...step, subtaskOf: id, assignee: getTask(board.db, id)?.assignee || null });
561
+ return done;
512
562
  }
513
563
  export const editResult = (board, id, { result, summary = null, metadata = undefined, roles } = {}) => act(board, id, 'edit', { roles, payload: { result, summary, metadata } });
514
564
  export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
515
- export const setModel = (board, id, model = null, provider = null, { roles } = {}) => {
516
- if (provider && !model) throw new Error('a provider needs a model');
517
- return act(board, id, 'set_model', { roles, payload: { model: model || null, provider: provider || null } });
518
- };
519
565
  export const setGoal = (board, id, { on = true, maxTurns = null, roles } = {}) => act(board, id, 'set_goal', { roles, payload: { goal: Boolean(on), max_turns: on ? maxTurns : null } });
520
- export const assign = (board, id, profile, { roles } = {}) => act(board, id, 'assign', { roles, payload: { assignee: profile || null } });
521
566
 
522
567
  /** A triage card fans out into children it waits on (Hermes's decomposer); answers their ids. */
523
568
  export async function decompose(board, id, children, { author = 'user', roles } = {}) {
@@ -665,7 +710,6 @@ export function cardContext(db, id) {
665
710
  const task = must(db, id);
666
711
  const out = [`# Card ${task.id}: ${task.title}`, ''];
667
712
  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}.` : ''}`);
668
- if (task.review_only) out.push('Purpose: review-only. Record the independent verdict with request-state --state done --reason <verdict> --evidence <artifact>; its creator or manager accepts this review job.');
669
713
  if (task.body) out.push('', cap(task.body, 8 * 1024));
670
714
  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);
671
715
  if (parents.length) {
package/board/store.mjs CHANGED
@@ -73,7 +73,7 @@ const OURS = `CREATE TABLE IF NOT EXISTS supercode_cards (task_id TEXT PRIMARY K
73
73
  CREATE TABLE IF NOT EXISTS supercode_effects (id INTEGER PRIMARY KEY AUTOINCREMENT, effect TEXT NOT NULL);`;
74
74
  // `stage`: the card's workflow status where kanban.db stores another (a `reviewing` card is `running` to Hermes)
75
75
  // `since`: when the card entered its workflow status; `after_fired`: which of the status's `after` moves have run since
76
- const OURS_COLUMNS = { workspace_anchor: 'TEXT', workspace_root: 'TEXT', subtask_of: 'TEXT', review_only: 'INTEGER DEFAULT 0', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT', blocked_by: 'TEXT', paused: 'TEXT', archived: 'INTEGER DEFAULT 0', every: 'TEXT', next_run_at: 'INTEGER' };
76
+ const OURS_COLUMNS = { workspace_anchor: 'TEXT', workspace_root: 'TEXT', subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT', blocked_by: 'TEXT', paused: 'TEXT', archived: 'INTEGER DEFAULT 0', every: 'TEXT', next_run_at: 'INTEGER' };
77
77
 
78
78
  // ------------------------------------------------------------------ backings
79
79
  //
@@ -172,18 +172,21 @@ export const tx = (board, fn) => {
172
172
  export const view = (board, fn) => fn(board.db);
173
173
 
174
174
  // A deferred native transaction, with the whole operation retried on an optimistic conflict.
175
- // There is no separate application writer lock or contention timeout.
175
+ // There is no separate application writer lock or contention timeout. A write inside one is part of it.
176
+ const inTx = new WeakSet();
176
177
  function sqliteTx(db, fn) {
178
+ if (inTx.has(db)) return fn(db);
177
179
  for (;;) {
178
180
  try {
179
181
  db.exec('BEGIN');
182
+ inTx.add(db);
180
183
  const out=fn(db);
181
184
  db.exec('COMMIT');
182
185
  return out;
183
186
  } catch(error) {
184
187
  try {db.exec('ROLLBACK');} catch {}
185
188
  if (!/locked|busy/i.test(error.message))throw error;
186
- }
189
+ } finally { inTx.delete(db); }
187
190
  }
188
191
  }
189
192
 
package/board/tools.mjs CHANGED
@@ -80,7 +80,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
80
80
  return JSON.stringify({
81
81
  task: { id: t.id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, tenant: t.tenant, priority: t.priority, workspace_kind: t.workspace_kind,
82
82
  workspace_path: t.workspace_path, created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
83
- current_run_id: t.current_run_id, model_override: t.model_override, provider_override: t.provider_override, review_only: Boolean(t.review_only) },
83
+ current_run_id: t.current_run_id, model_override: t.model_override, provider_override: t.provider_override },
84
84
  parents: card.parents.map((p) => p.id), children: card.children,
85
85
  comments: card.comments, events: card.events.slice(-50), runs: card.runs.map((r) => ({ id: r.id, profile: r.profile, status: r.status, outcome: r.outcome, summary: r.summary, error: r.error, metadata: r.metadata, started_at: r.started_at, ended_at: r.ended_at })),
86
86
  worker_context: card.context,
@@ -132,8 +132,11 @@ export async function boardTool(root, name, args = {}, env = {}) {
132
132
  }
133
133
  case 'kanban_request_changes': {
134
134
  const stale = ownRun(); if (stale) return error(stale);
135
+ // each finding is filed as a task on the arc first, so the arc stays open until a pushed head fixes it
136
+ const findings = [].concat(args.findings ?? []).map((f) => redact(String(f)).trim()).filter(Boolean);
137
+ const filed = findings.map((finding) => engine.createCard(board, { title: finding, subtaskOf: tid }).id);
135
138
  await engine.requestChanges(board, tid, { reason: args.reason ? redact(String(args.reason)) : null, reviewer: author });
136
- return ok({ task_id: tid });
139
+ return ok({ task_id: tid, findings: filed });
137
140
  }
138
141
  case 'kanban_heartbeat': {
139
142
  const stale = ownRun(); if (stale) return error(stale);
@@ -176,7 +179,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
176
179
  title: args.title, body: args.body ? redact(String(args.body)) : null, assignee: args.assignee ?? null, parents: [args.parents ?? []].flat(), tenant: args.tenant ?? null,
177
180
  priority: args.priority ?? 0, workspace, project: args.project ?? null, triage: Boolean(args.triage), idempotencyKey: args.idempotency_key ?? null,
178
181
  maxRuntime: args.max_runtime_seconds ?? null, initialStatus: args.initial_status ?? null, skills: args.skills ?? null, model: args.model ?? null, provider: args.provider ?? null,
179
- goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author, reviewOnly: args.review_only ?? false,
182
+ goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author,
180
183
  });
181
184
  return ok({ task_id: made.id, existing: made.existing, status: tx(board, (db) => engine.getTask(db, made.id)).status });
182
185
  }
@@ -104,7 +104,8 @@ function pattern(p) {
104
104
  }
105
105
 
106
106
  // the time of the latest commit that names `id` in its message, on any ref of the repository at `path` (a worktree's
107
- // pushes land on the shared remote-tracking refs), or null (asked only when an expression does)
107
+ // pushes land on the shared remote-tracking refs), or null (asked only when an expression does). Merge commits are
108
+ // not the card's work: merging its reviewed PR, or main into its branch, is not a newer change to review.
108
109
  const commitTimes = new Map();
109
110
  function observeLastCommit(path, id) {
110
111
  if (!path) return null;
@@ -112,7 +113,7 @@ function observeLastCommit(path, id) {
112
113
  const cached = commitTimes.get(key);
113
114
  if (cached && now() - cached.at < 15) return cached.value;
114
115
  const readCommit = (repo) => {
115
- const r = spawnSync('git', ['-C', repo, 'log', '--all', '-1', '--format=%ct', '--fixed-strings', `--grep=${id}`], { encoding: 'utf8', timeout: 10_000 });
116
+ const r = spawnSync('git', ['-C', repo, 'log', '--all', '--no-merges', '-1', '--format=%ct', '--fixed-strings', `--grep=${id}`], { encoding: 'utf8', timeout: 10_000 });
116
117
  const value = Number(r.stdout?.trim());
117
118
  return { repo: r.status === 0, value: Number.isFinite(value) && value > 0 ? value : null };
118
119
  };
@@ -201,11 +202,8 @@ function onlyTicks(was, body) {
201
202
 
202
203
  /** Seconds a card's runs spent by role, and the rest of its life as `waiting`. */
203
204
  export function runWorkingSeconds(row, at = now()) {
204
- const metadata = typeof row.metadata === 'string' ? json(row.metadata, {}) : row.metadata;
205
- const session = metadata?.supercode ?? {};
206
205
  const end = Number(row.ended_at ?? at);
207
- const waiting = Number(session.wait_seconds ?? 0) + (session.wait_started_at != null ? Math.max(0, end - Number(session.wait_started_at)) : 0);
208
- return Math.max(0, end - Number(row.started_at ?? end) - waiting);
206
+ return Math.max(0, end - Number(row.started_at ?? end));
209
207
  }
210
208
 
211
209
  function timeOf(db, task) {
@@ -250,7 +248,6 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
250
248
  ...task,
251
249
  creator_address: creatorOf(db, task.id),
252
250
  completion_owner_address: completionOwnerOf(db, task.id),
253
- review_only: Boolean(state.review_only),
254
251
  latest_review: latestReview(db, task.id) ?? {},
255
252
  review_candidate: reviewCandidate(board, task.id) ?? {},
256
253
  ci: completionEvidence(board, task.id),
@@ -363,6 +360,11 @@ function queueTransitionNotice(db, board, entry, task, from, name, payload, befo
363
360
  cancellation?.outcome === 'not_issued' && cancellation?.live === false && cancellation?.pane === null) return;
364
361
  const address = session?.address ?? (session?.session_id && session.machine ? `sc:${session.machine}:${session.harness ?? 'claude-code'}:${session.session_id}` : null);
365
362
  if (!address && !session?.launch_key && !session?.pane) return;
363
+ // The session that made this change learns it from its own command's answer; a notice about
364
+ // its own act would only hold its close until the notice is confirmed. A session whose close is
365
+ // confirmed is not told either: its next run resumes it with the card's state in its prompt.
366
+ if (address && address === actorOf()) return;
367
+ if (session?.close_confirmed_at) return;
366
368
  const scope = scopeOf(db, board, after, { params: entry.params, event: { ...payload, verb: name, from, to }, extra: { run: { id: beforeRun.id } } });
367
369
  const body = render(entry.workflow.notify?.worker?.run_changed ?? '{{card.id}}: {{event.verb}} changed run {{run.id}} ({{event.from}} → {{event.to}}). Stop work under the previous run; follow the board’s current state. {{event.reason ?? ""}}', scope);
368
370
  const source = one(db, 'SELECT MAX(id) AS id FROM task_events WHERE task_id = ?', task.id)?.id;
@@ -581,7 +583,7 @@ const FORMAT = {
581
583
  commented: ({ e }) => ({ author: String(e.author ?? 'user').trim(), len: String(e.body ?? '').length }),
582
584
  archived: () => null,
583
585
  status: ({ e, before, to }) => ({ status: to, reason: e.reason ?? 'reopened', previous_status: before.stored_as }),
584
- machine_set: ({ e, before }) => ({ machine: e.machine ?? null, was: before.machine }),
586
+ machine_set: ({ e, before }) => ({ machine: e.field === 'machine' ? e.value ?? null : e.machine ?? null, was: before.machine }),
585
587
  };
586
588
  const RETRY_KINDS = new Set(['crashed', 'timed_out', 'stale', 'rate_limited', 'session_lost', 'reclaimed', 'reconciled', 'spawn_failed']);
587
589
 
@@ -642,17 +644,6 @@ const ACTIONS = {
642
644
  const result = fire(ctx.db, ctx.board, ctx.entry, ctx.id, name, { roles: [].concat(as), payload, extra: { lanes: lanesIn(ctx.entry) } });
643
645
  ctx.effects.push(...result.effects);
644
646
  },
645
- wait_run(ctx, waiting) {
646
- if (typeof waiting !== 'boolean') throw new Error('wait_run requires a boolean');
647
- const at = now();
648
- runSession(ctx, session => {
649
- if (waiting) return { ...session, wait_started_at: session.wait_started_at ?? at };
650
- if (session.wait_started_at == null) return session;
651
- return { ...session, wait_started_at: null,
652
- wait_seconds: Number(session.wait_seconds ?? 0) + Math.max(0, at - Number(session.wait_started_at)),
653
- goal_nudged_at: at };
654
- });
655
- },
656
647
  record(ctx, args, scope) {
657
648
  const kind = typeof args === 'string' ? args : args?.kind;
658
649
  const given = typeof args === 'object' && args?.payload ? args.payload : null;
@@ -772,12 +763,6 @@ const ACTIONS = {
772
763
  run(ctx.db, 'DELETE FROM task_links WHERE parent_id = ? AND child_id = ?', parent, ctx.id);
773
764
  },
774
765
  specify(ctx, said) {
775
- if (said?.review_only != null) {
776
- const task = cardOf(ctx.db, ctx.id);
777
- if (typeof said.review_only !== 'boolean' || task.current_run_id || task.subtask_of || ctx.scope.card.every) throw new Error('A review-only declaration changes only an arc with no current run or recurrence.');
778
- cardField(ctx, 'review_only', said.review_only ? 1 : 0);
779
- record(ctx.db, ctx.id, 'review_mode_changed', { review_only: said.review_only }, null);
780
- }
781
766
  if (said?.workspace) {
782
767
  const task = cardOf(ctx.db, ctx.id), ws = said.workspace;
783
768
  if (task.current_run_id) throw new Error('end the current run before changing its workspace');
@@ -821,29 +806,15 @@ const ACTIONS = {
821
806
  const name = String(typeof slot === 'object' && slot ? slot.slot ?? 'worker' : slot ?? 'worker');
822
807
  const harness = typeof slot === 'object' && slot ? ctx.entry.profiles[slot.assignee]?.worker?.harness : null;
823
808
  if (typeof slot === 'object' && slot && !harness) return;
824
- const released = new Set();
825
809
  for (const row of all(ctx.db, 'SELECT id, metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC', ctx.id)) {
826
810
  const whole = json(row.metadata, {}) ?? {};
827
811
  const session = whole.supercode;
828
812
  if ((!session?.session_id && !session?.pane && !session?.launch_key) || (name !== '*' && (session.slot ?? 'worker') !== name) || (harness && (session.harness ?? 'claude-code') === harness)) continue;
829
813
  requestSessionClose(ctx.db, ctx.id, { runId: row.id });
830
814
  const latest = json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id=?', row.id)?.metadata, {}) ?? {};
831
- if (session.session_id) released.add(session.session_id);
832
815
  latest.supercode = { ...latest.supercode, forgotten: now(), resumable: false };
833
816
  run(ctx.db, "UPDATE task_runs SET metadata=?, ended_at=COALESCE(ended_at,?), status=CASE WHEN ended_at IS NULL THEN 'superseded' ELSE status END, outcome=CASE WHEN ended_at IS NULL THEN 'superseded' ELSE outcome END, claim_lock=NULL,claim_expires=NULL,worker_pid=NULL WHERE id=?", JSON.stringify(latest), now(), row.id);
834
817
  }
835
- const gaveUp = one(ctx.db, "SELECT created_at,payload FROM task_events WHERE task_id=? AND kind='gave_up' ORDER BY id DESC LIMIT 1", ctx.id);
836
- const failures = Number(json(gaveUp?.payload, {})?.failures ?? 0);
837
- if (!failures || !released.size) return;
838
- const recent = all(ctx.db, 'SELECT outcome,metadata FROM task_runs WHERE task_id=? AND ended_at<=? ORDER BY id DESC LIMIT ?', ctx.id, gaveUp.created_at, failures);
839
- if (recent.length !== failures || !recent.every(row => {
840
- const failure = json(row.metadata, {})?.last_events?.spawn_failed?.payload;
841
- return row.outcome === 'spawn_failed' && failure?.resume_failed === true && released.has(failure.session_id);
842
- })) return;
843
- const blocks = json(one(ctx.db, 'SELECT blocked_by FROM supercode_cards WHERE task_id=?', ctx.id)?.blocked_by, []);
844
- for (const block of blocks.filter(block => block.kind === 'message' && block.reason === 'failure limit reached' && block.since === gaveUp.created_at))
845
- ACTIONS.relation(ctx, { remove: block.id, reason: 'The manager released the session responsible for these stale continuation failures.' });
846
- run(ctx.db, 'UPDATE tasks SET consecutive_failures=0,last_failure_error=NULL WHERE id=?', ctx.id);
847
818
  },
848
819
  // an event sent to other cards (`to`: an expression naming their ids, e.g. a card's open subtasks), as `as` sends it;
849
820
  // a card it does not apply to now is passed over
@@ -867,6 +838,8 @@ const ACTIONS = {
867
838
  requester: actorOf() ?? ctx.scope.requester,
868
839
  window_seconds: Math.max(1, Math.min(60, Number(ctx.entry.params.request_batch_seconds) || 10)),
869
840
  } : null;
841
+ const observation = ctx.scope.event?.ci;
842
+ if (batch && observation?.required && !observation.ready) text = `${String(text ?? '')}\nSource evidence could not be verified: ${observation.reason ?? 'unavailable'}. The decision request is retained.`;
870
843
  record(ctx.db, ctx.id, 'board_message', { to: String(to), text: batch ? String(text ?? '') : `${String(text ?? '')}\n\nAnswer with reply_to ${id}.`, message_id: id, ...(batch ? { batch } : {}) }, null);
871
844
  ctx.effects.push({ kind: 'board_message', id: ctx.id });
872
845
  },
@@ -1025,15 +998,21 @@ function settleRelations(db, board, entry, id, effects) {
1025
998
  if (!json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []).some((b) => b.id === block.id)) continue;
1026
999
  const dependency = block.kind === 'card' ? one(db, 'SELECT * FROM tasks WHERE id = ?', block.id) : null;
1027
1000
  const status = dependency ? statusOf(db, dependency) : null;
1028
- const answer = block.kind === 'message' ? answerTo(block.id) : null;
1001
+ // an answer is handed to the workflow once; the block stays until a verb lifts it
1002
+ const answer = block.kind === 'message' && !block.answered ? answerTo(block.id) : null;
1029
1003
  const policy = entry.workflow.relations.blocked_by;
1030
1004
  const name = answer ? policy.answered : status === 'done' ? policy.completed : status === 'cancelled' ? policy.cancelled : null;
1005
+ if (answer) {
1006
+ const current = json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []);
1007
+ run(db, 'UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?', JSON.stringify(current.map((b) => b.id === block.id ? { ...b, answered: answer } : b)), id);
1008
+ }
1031
1009
  if (name) effects.push(...fire(db, board, entry, id, name, { roles: ['platform'], payload: { on: block.id, reason: answer ? `reply ${answer}` : `${block.id} ${status}` } }).effects);
1032
- else if (policy.aged && policy.age_seconds != null) {
1010
+ // a block that has aged is told once, not every age interval for as long as it lasts
1011
+ else if (policy.aged && policy.age_seconds != null && !block.aged_at) {
1033
1012
  const seconds = Number(evaluate(String(policy.age_seconds), { params: entry.params }));
1034
1013
  if (seconds > 0 && now() - Number(block.notified_at ?? block.since ?? now()) >= seconds) {
1035
1014
  const current = json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []);
1036
- run(db, 'UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?', JSON.stringify(current.map((b) => b.id === block.id ? { ...b, notified_at: now() } : b)), id);
1015
+ run(db, 'UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?', JSON.stringify(current.map((b) => b.id === block.id ? { ...b, aged_at: now() } : b)), id);
1037
1016
  effects.push(...fire(db, board, entry, id, policy.aged, { roles: ['platform'], payload: { on: block.id, reason: block.reason } }).effects);
1038
1017
  }
1039
1018
  }
@@ -1041,13 +1020,19 @@ function settleRelations(db, board, entry, id, effects) {
1041
1020
  }
1042
1021
 
1043
1022
 
1044
- export function projectStatus(db, board, entry, id) {
1045
- if (!entry.workflow.stored_as?.length) return;
1023
+ /** A card's workflow status and the status Hermes's `tasks.status` stores it as, read without writing. */
1024
+ export function storedStatus(db, board, entry, id) {
1046
1025
  const task = cardOf(db, id);
1047
1026
  const status = statusOf(db, task);
1027
+ if (!entry.workflow.stored_as?.length) return { status, stored: task.status };
1048
1028
  const scope = scopeOf(db, board, task, { params: entry.params });
1049
1029
  const rule = entry.workflow.stored_as.find((r) => holds(r.guard, scope));
1050
- const stored = rule?.status ?? storedAs(entry.workflow, status);
1030
+ return { status, stored: rule?.status ?? storedAs(entry.workflow, status) };
1031
+ }
1032
+
1033
+ export function projectStatus(db, board, entry, id) {
1034
+ if (!entry.workflow.stored_as?.length) return;
1035
+ const { status, stored } = storedStatus(db, board, entry, id);
1051
1036
  run(db, 'UPDATE tasks SET status = ? WHERE id = ?', stored, id);
1052
1037
  run(db, 'INSERT INTO supercode_cards (task_id, stage) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET stage = excluded.stage', id, status);
1053
1038
  }
@@ -1,9 +1,17 @@
1
1
  // Filesystem/RPC work happens before a board transaction; the ownership check is pure.
2
2
  import { spawnSync } from 'node:child_process';
3
+ import { existsSync } from 'node:fs';
4
+ import { basename, dirname } from 'node:path';
3
5
  import { Workspaces, inspectCheckout, repositoryId, teamsHome } from '@volter/supercode-teams/workspaces';
4
6
  import { localMachine, normalMachine, supercodeBin, commandEnv } from './dispatch.mjs';
5
7
  export const isWorkspacePath = value => /^(\/|~|[A-Za-z]:[\\/])/.test(value ?? '');
8
+ // The dispatcher's latest presence reading (observeFleet): a machine it reads as offline is not asked.
9
+ let presence = null;
10
+ export const notePresence = (reading) => { presence = reading; };
6
11
  function workspaceDoor(machine, verb, value, options = {}) {
12
+ if (machine && presence?.known === true && normalMachine(machine) !== localMachine() && !presence.online.has(normalMachine(machine))) {
13
+ throw new Error(`workspace on ${machine}: machine ${normalMachine(machine)} is offline`);
14
+ }
7
15
  if (!machine || normalMachine(machine) === localMachine()) {
8
16
  const map = new Workspaces(teamsHome());
9
17
  if (verb === 'inspect') return inspectCheckout(value);
@@ -28,6 +36,17 @@ export function declareWorkspace(kind, value, machine, branch = null) {
28
36
  const row = workspaceDoor(machine, 'register', value);
29
37
  return { repository: row.repository, branch: branch ?? (kind === 'dir' ? row.branch : null), path: kind === 'dir' ? row.path : null };
30
38
  }
39
+ /** The checkouts this machine keeps of any repository on `branch`: where an arc changing several repositories
40
+ * keeps its work in each of them (its own worktree aside). Another machine's map is not read from here. */
41
+ export function branchCheckouts(machine, branch) {
42
+ if (!branch || (machine && normalMachine(machine) !== localMachine())) return [];
43
+ return new Workspaces(teamsHome()).list().filter((row) => (branch === '*' || row.branch === branch) && existsSync(row.path));
44
+ }
45
+ /** The registered checkout of `repository` on `branch` on `machine`: where a card changing several repositories
46
+ * keeps its work in each of them. */
47
+ export function checkoutOf(machine, repository, branch) {
48
+ return workspaceDoor(machine, 'resolve', repositoryId(repository), { branch });
49
+ }
31
50
  export function resolveWorkspace(task, create = false) {
32
51
  const declared = task.workspace_anchor ?? task.workspace_path;
33
52
  if (isWorkspacePath(declared)) {
@@ -36,6 +55,19 @@ export function resolveWorkspace(task, create = false) {
36
55
  if (info.linked) throw new Error('worktree declaration is already a linked worktree; name its repository instead');
37
56
  throw new Error('legacy worktree path needs registration and a repository declaration before launch');
38
57
  }
58
+ // A primary checkout can change when another clone is registered. That does
59
+ // not move an already allocated card worktree. Machine moves and workspace
60
+ // retargets clear this runtime receipt; never borrow an arbitrary checkout.
61
+ if (task.workspace_kind === 'worktree' && task.workspace_path &&
62
+ basename(task.workspace_path) === task.id && basename(dirname(task.workspace_path)) === '.worktrees' &&
63
+ (!isLocalWorkspace(task) || existsSync(task.workspace_path))) {
64
+ const cached = workspaceDoor(task.machine, 'inspect', task.workspace_path);
65
+ if (cached.linked && basename(cached.path) === task.id && basename(dirname(cached.path)) === '.worktrees' &&
66
+ cached.repository === repositoryId(declared) && cached.branch === (task.branch_name ?? `wt/${task.id}`)) {
67
+ return { ...cached, root: dirname(dirname(cached.path)) };
68
+ }
69
+ throw new Error(`card worktree ${task.workspace_path} does not match ${declared}, branch ${task.branch_name ?? `wt/${task.id}`}; retarget its workspace explicitly`);
70
+ }
39
71
  return workspaceDoor(task.machine, 'resolve', declared, { branch: task.branch_name, solo: task.workspace_kind === 'worktree', card: task.id, create });
40
72
  }
41
73
  export function checkWorkspace(board, task) {