@volter/supercode-orchestrator 0.4.29 → 0.4.31

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
@@ -12,6 +12,7 @@ import { existsSync, rmSync } from 'node:fs';
12
12
  import { join, resolve } from 'node:path';
13
13
  import { DatabaseSync } from 'node:sqlite';
14
14
  import { claimer, event, json, newTaskId, now, tx } from './store.mjs';
15
+ import { fire, initialStatus, loadWorkflow, setStatus, settle, statusOf, workflowFor } from './workflow.mjs';
15
16
 
16
17
  export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived'];
17
18
  export const BLOCK_KINDS = ['dependency', 'needs_input', 'capability', 'transient'];
@@ -111,10 +112,11 @@ export function setRunSession(db, runId, session) {
111
112
  }
112
113
 
113
114
  /** The card's latest session the dispatcher recorded, newest run first; null when it never had one. */
114
- export function lastSession(db, taskId) {
115
+ export function lastSession(db, taskId, slot = 'worker') {
116
+ // a session slot's own last session (runs recorded before slots existed are the worker's)
115
117
  for (const row of all(db, 'SELECT metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC', taskId)) {
116
118
  const s = json(row.metadata, {})?.supercode;
117
- if (s?.session_id) return s;
119
+ if (s?.session_id && (s.slot ?? 'worker') === slot) return s;
118
120
  }
119
121
  return null;
120
122
  }
@@ -174,10 +176,6 @@ export function createCard(board, spec) {
174
176
  const arc = spec.subtaskOf ? getTask(db, spec.subtaskOf) : null;
175
177
  if (spec.subtaskOf && !arc) throw new Error(`no card ${spec.subtaskOf} to be a subtask of`);
176
178
  if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; a subtask belongs to an arc`);
177
- let status = 'ready';
178
- if (initial === 'blocked') status = 'blocked';
179
- else if (spec.triage) status = 'triage';
180
- else if (parents.some((p) => !['done', 'archived'].includes(one(db, 'SELECT status FROM tasks WHERE id = ?', p).status))) status = 'todo';
181
179
  let id = newTaskId();
182
180
  while (one(db, 'SELECT 1 FROM tasks WHERE id = ?', id)) id = newTaskId();
183
181
  // a project (Hermes's projects registry, `projects.db` in the home) anchors the card's worktree in its repository
@@ -196,13 +194,17 @@ export function createCard(board, spec) {
196
194
  tenant, idempotency_key, max_runtime_seconds, skills, max_retries, model_override, provider_override, reasoning_effort, session_id,
197
195
  project_id, goal_mode, goal_max_turns)
198
196
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
199
- id, title, spec.body ?? null, spec.assignee || null, status, Number(spec.priority) || 0, spec.createdBy ?? 'user', t, ws.kind, ws.path,
197
+ id, title, spec.body ?? null, spec.assignee || null, 'todo', Number(spec.priority) || 0, spec.createdBy ?? 'user', t, ws.kind, ws.path,
200
198
  spec.branch ?? null, spec.tenant ?? null, spec.idempotencyKey ?? null, spec.maxRuntime ?? null,
201
199
  spec.skills?.length ? JSON.stringify(spec.skills) : null, spec.maxRetries ?? null, spec.model ?? null, spec.provider ?? null,
202
200
  spec.reasoning ?? null, spec.sessionId ?? null, spec.project ?? null, spec.goal ? 1 : 0, spec.goal?.maxTurns ?? null);
203
201
  const machine = spec.machine ?? arc?.machine ?? null;
204
202
  if (machine || arc) run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id, machine, subtask_of) VALUES (?, ?, ?)', id, machine, arc?.id ?? null);
205
203
  for (const p of parents) run(db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', p, id);
204
+ // where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
205
+ const entry = workflowFor(board.root);
206
+ const status = initialStatus(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial });
207
+ setStatus(db, entry.workflow, id, status);
206
208
  event(db, id, 'created', { assignee: spec.assignee || null, status, parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
207
209
  if (parents.length) inheritSubscriptions(db, id, parents);
208
210
  if (arc) {
@@ -228,58 +230,18 @@ function wouldCycle(db, parent, child) {
228
230
  return false;
229
231
  }
230
232
 
231
- /** `child` waits for `parent`: a ready child goes back to todo while the parent is not done. */
232
- export function link(board, parent, child) {
233
- if (parent === child) throw new Error('a card cannot depend on itself');
234
- return tx(board, (db) => {
235
- must(db, parent); must(db, child);
236
- if (wouldCycle(db, parent, child)) throw new Error(`linking ${parent} -> ${child} would make a cycle`);
237
- run(db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', parent, child);
238
- if (one(db, 'SELECT status FROM tasks WHERE id = ?', parent).status !== 'done') run(db, "UPDATE tasks SET status = 'todo' WHERE id = ? AND status = 'ready'", child);
239
- event(db, child, 'linked', { parent, child });
240
- inheritSubscriptions(db, child, [parent]);
241
- });
242
- }
243
233
 
244
- export function unlink(board, parent, child) {
245
- return tx(board, (db) => {
246
- const r = run(db, 'DELETE FROM task_links WHERE parent_id = ? AND child_id = ?', parent, child);
247
- if (!r.changes) return false;
248
- event(db, child, 'unlinked', { parent, child });
249
- recomputeIn(db);
250
- return true;
251
- });
252
- }
253
234
 
254
235
  // ------------------------------------------------------------------ the thread and the card's fields
255
236
 
256
- export function comment(board, id, author, body) {
257
- if (!String(body ?? '').trim()) throw new Error('a comment has a body');
258
- if (!String(author ?? '').trim()) throw new Error('a comment has an author');
259
- return tx(board, (db) => {
260
- must(db, id);
261
- const r = run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', id, author.trim(), body.trim(), now());
262
- event(db, id, 'commented', { author: author.trim(), len: body.length });
263
- return Number(r.lastInsertRowid);
264
- });
265
- }
266
237
 
267
- /** A new assignee (profile). A running card is reclaimed first, on purpose, never moved under its session. */
268
- export function assign(board, id, profile) {
269
- return tx(board, (db) => {
270
- const task = must(db, id);
271
- if (task.status === 'running' && task.claim_lock) throw new Error(`${id} is running; reclaim it before reassigning`);
272
- run(db, `UPDATE tasks SET assignee = ?${task.assignee !== (profile || null) ? ', consecutive_failures = 0, last_failure_error = NULL' : ''} WHERE id = ?`, profile || null, id);
273
- event(db, id, 'assigned', { assignee: profile || null });
274
- });
275
- }
276
238
 
277
239
  /** The machine a card's session runs on. Moving it is a decision: a running card is reclaimed first. */
278
240
  export function setMachine(board, id, machine) {
279
241
  return tx(board, (db) => {
280
242
  const task = must(db, id);
281
243
  if (task.status === 'running' && task.claim_lock) throw new Error(`${id} is running on ${task.machine}; reclaim it before moving it`);
282
- run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id, machine) VALUES (?, ?)', id, machine || null);
244
+ run(db, 'INSERT INTO supercode_cards (task_id, machine) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET machine = excluded.machine', id, machine || null);
283
245
  event(db, id, 'machine_set', { machine: machine || null, was: task.machine });
284
246
  });
285
247
  }
@@ -355,10 +317,6 @@ export function recordFailure(db, id, error, { failureLimit = DEFAULT_FAILURE_LI
355
317
  return true;
356
318
  }
357
319
 
358
- export function complete(board, id, opts = {}) {
359
- completeIn(board, id, opts);
360
- cleanupWorkspace(board, id);
361
- }
362
320
 
363
321
  /**
364
322
  * A completed card's workspace goes, as Hermes cleans up on completion: a scratch directory under the board's own
@@ -374,197 +332,171 @@ function cleanupWorkspace(board, id) {
374
332
  if (resolve(task.workspace_path).startsWith(`${managed}/`)) rmSync(task.workspace_path, { recursive: true, force: true });
375
333
  }
376
334
 
377
- function completeIn(board, id, { result = null, summary = null, metadata = null } = {}) {
378
- return tx(board, (db) => {
379
- const task = must(db, id);
380
- if (!parentsDone(db, id)) throw new Error(`${id} waits on parents that are not done`);
381
- if (!['running', 'ready', 'blocked', 'review'].includes(task.status)) throw new Error(`${id} is ${task.status}; only a running, ready, blocked or review card completes`);
382
- run(db, "UPDATE tasks SET status = 'done', result = ?, completed_at = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, block_kind = NULL, block_recurrences = 0, consecutive_failures = 0, last_failure_error = NULL WHERE id = ?",
383
- result, now(), id);
384
- const said = summary ?? result;
385
- let runId = endRun(db, id, { outcome: 'completed', status: 'done', summary: said, metadata: metadata ?? undefined });
386
- if (runId === null && (said || metadata || task.status === 'review')) runId = synthesizeRun(db, id, { outcome: 'completed', summary: said ?? (task.status === 'review' ? 'Review approved without additional evidence.' : null), metadata });
387
- const first = String(said ?? '').trim().split('\n')[0].slice(0, 400);
388
- const artifacts = Array.isArray(metadata?.artifacts) ? metadata.artifacts.filter((a) => typeof a === 'string' && a.trim()) : [];
389
- event(db, id, 'completed', { result_len: result ? result.length : 0, summary: first || null, ...(artifacts.length ? { artifacts } : {}) }, runId);
390
- // card ids the handoff names that no card has (Hermes flags them the same way)
391
- const named = [...new Set(`${summary ?? ''} ${result ?? ''}`.match(/\bt_[a-f0-9]{8,}\b/g) ?? [])];
392
- const phantom = named.filter((t) => !one(db, 'SELECT 1 FROM tasks WHERE id = ?', t));
393
- if (phantom.length) event(db, id, 'suspected_hallucinated_references', { phantom_refs: phantom, source: 'completion_summary' }, runId);
394
- recomputeIn(db);
395
- });
396
- }
397
335
 
398
- export function block(board, id, { reason = null, kind = null } = {}) {
399
- return tx(board, (db) => blockIn(db, id, { reason, kind }));
400
- }
401
336
 
402
- /** `block` inside the caller's transaction (the dispatcher's own blocks keep the same recurrence accounting). */
403
- export function blockIn(db, id, { reason = null, kind = null } = {}) {
404
- if (kind && !BLOCK_KINDS.includes(kind)) throw new Error(`block kind is one of ${BLOCK_KINDS.join(', ')}`);
405
- const task = must(db, id);
406
- if (!['running', 'ready'].includes(task.status)) throw new Error(`${id} is ${task.status}; only a running or ready card blocks`);
407
- const source = task.status === 'running' ? retryStatus(db, id) : 'ready';
408
- if (kind === 'dependency') {
409
- run(db, "UPDATE tasks SET status = 'todo', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, block_kind = ? WHERE id = ?", kind, id);
410
- const runId = endRun(db, id, { outcome: 'blocked', summary: reason }) ?? (reason ? synthesizeRun(db, id, { outcome: 'blocked', summary: reason }) : null);
411
- event(db, id, 'dependency_wait', { reason, kind, source_status: source }, runId);
412
- return 'todo';
413
- }
414
- const recurrences = task.block_kind === kind ? Number(task.block_recurrences || 0) + 1 : 1;
415
- const loop = recurrences >= BLOCK_RECURRENCE_LIMIT;
416
- const status = loop ? 'triage' : 'blocked';
417
- run(db, 'UPDATE tasks SET status = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, block_kind = ?, block_recurrences = ? WHERE id = ?', status, kind, recurrences, id);
418
- const runId = endRun(db, id, { outcome: 'blocked', summary: reason }) ?? (reason ? synthesizeRun(db, id, { outcome: 'blocked', summary: reason }) : null);
419
- event(db, id, loop ? 'block_loop_detected' : 'blocked', { reason, kind, recurrences, ...(loop ? { limit: BLOCK_RECURRENCE_LIMIT } : {}), source_status: source }, runId);
420
- return status;
421
- }
422
337
 
423
- export function unblock(board, id) {
424
- return tx(board, (db) => {
425
- const task = must(db, id);
426
- if (!['blocked', 'scheduled'].includes(task.status)) throw new Error(`${id} is ${task.status}; only a blocked or scheduled card unblocks`);
427
- const resume = task.status === 'blocked' ? resumeStatus(db, id) : 'ready';
428
- if (task.current_run_id) run(db, "UPDATE task_runs SET status = 'reclaimed', outcome = 'reclaimed', summary = COALESCE(summary, 'invariant recovery on unblock'), ended_at = ? WHERE id = ? AND ended_at IS NULL", now(), task.current_run_id);
429
- const landing = parentsDone(db, id) ? 'ready' : 'todo';
430
- const status = landing === 'ready' && resume === 'review' ? 'review' : landing;
431
- run(db, 'UPDATE tasks SET status = ?, current_run_id = NULL, consecutive_failures = 0, last_failure_error = NULL WHERE id = ?', status, id);
432
- event(db, id, 'unblocked', status !== 'ready' || resume !== 'ready' ? { status, resume_status: resume } : null);
433
- return status;
434
- });
435
- }
436
338
 
437
- /** Implementation done, awaiting review (not a block): the review lane, optionally under a reviewer profile. */
438
- export function requestReview(board, id, { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null } = {}) {
439
- return tx(board, (db) => {
440
- const task = must(db, id);
441
- if (!parentsDone(db, id)) throw new Error(`${id} waits on parents that are not done`);
442
- if (!['running', 'ready'].includes(task.status)) throw new Error(`${id} is ${task.status}; only a running or ready card goes to review`);
443
- // a live claim is its worker's to hand off; anyone else says so on purpose (Hermes's guard)
444
- // (Hermes's expected_run_id: the worker names the run it holds; a superseded run changes nothing)
445
- if (expectedRunId !== null && Number(task.current_run_id) !== Number(expectedRunId)) throw new Error(`run ${expectedRunId} of ${id} is no longer its current run; nothing was changed`);
446
- if (task.status === 'running' && task.claim_lock && !force && expectedRunId === null) throw new Error(`${id} is running under a live claim; its worker requests review, or pass --force to override on purpose`);
447
- // a re-review goes back to the reviewer who asked for the changes
448
- if (!reviewer) {
449
- const changed = one(db, "SELECT id FROM task_runs WHERE task_id = ? AND outcome = 'changes_requested' ORDER BY id DESC LIMIT 1", id);
450
- if (changed) {
451
- const asked = json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND run_id = ? AND kind = 'changes_requested' ORDER BY id DESC LIMIT 1", id, changed.id)?.payload, {}) ?? {};
452
- if (!asked.reviewer) throw new Error(`${id}'s last changes request names no reviewer; pass --reviewer`);
453
- reviewer = asked.reviewer;
454
- }
455
- }
456
- run(db, `UPDATE tasks SET status = 'review', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL${reviewer ? ', assignee = ?' : ''} WHERE id = ?`, ...(reviewer ? [reviewer, id] : [id]));
457
- const runId = endRun(db, id, { outcome: 'review_requested', status: 'review', summary, metadata: metadata ?? undefined }) ?? (summary || metadata ? synthesizeRun(db, id, { outcome: 'review_requested', summary, metadata }) : null);
458
- event(db, id, 'review_requested', { summary: String(summary ?? '').trim().split('\n')[0].slice(0, 400) || null, implementer: task.assignee, reviewer }, runId);
459
- });
460
- }
461
339
 
462
- /** A reviewer's verdict sending the card back to its implementer. */
463
- export function requestChanges(board, id, { reason = null, reviewer = null, implementer = null } = {}) {
464
- return tx(board, (db) => {
465
- const task = must(db, id);
466
- if (task.status !== 'review' && !(task.status === 'running' && retryStatus(db, id) === 'review')) throw new Error(`${id} is not in review`);
467
- // back to the implementer the review request recorded, not to the reviewer now holding it
468
- const asked = json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'review_requested' ORDER BY id DESC LIMIT 1", id)?.payload, {}) ?? {};
469
- const back = implementer ?? asked.implementer ?? task.assignee;
470
- run(db, "UPDATE tasks SET status = ?, assignee = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?", parentsDone(db, id) ? 'ready' : 'todo', back, id);
471
- const runId = endRun(db, id, { outcome: 'changes_requested', summary: reason });
472
- event(db, id, 'changes_requested', { reason, reviewer, implementer: back }, runId);
473
- });
474
- }
340
+
341
+
342
+
343
+
344
+
345
+
346
+
347
+ // ------------------------------------------------------------------ events through the workflow
475
348
 
476
349
  /**
477
- * A done (or archived) card reopened: back to ready (todo while a parent is open), and every descendant that had moved on
478
- * because of it pulled back to todo (Hermes's dashboard reopen, `invalidate_descendants_for_parent_reopen`).
350
+ * Send card `id` the event `name` (the board's workflow decides what it does: `workflow.mjs`), then settle the cards
351
+ * that wait on it (Hermes's recompute_ready) and run what the move asked for once it is committed. `roles` are who
352
+ * sends it: `manager` for a person or the fleet's manager at the CLI, the card's own role for its session,
353
+ * `platform` for the dispatcher. Answers `{ from, to, moved }`; throws the workflow's refusal.
479
354
  */
480
- export function reopen(board, id, { author = 'user', reason = null } = {}) {
481
- return tx(board, (db) => {
482
- const task = must(db, id);
483
- if (!['done', 'archived'].includes(task.status)) throw new Error(`${id} is ${task.status}; only a done or archived card is reopened`);
484
- const status = parentsDone(db, id) ? 'ready' : 'todo';
485
- run(db, 'UPDATE tasks SET status = ?, completed_at = NULL WHERE id = ?', status, id);
486
- event(db, id, 'status', { status, reason: reason ?? 'reopened', previous_status: task.status });
487
- const invalidated = [];
488
- const t = now();
489
- const rows = all(db, `WITH RECURSIVE d(id) AS (SELECT child_id FROM task_links WHERE parent_id = ? UNION SELECT l.child_id FROM task_links l JOIN d ON d.id = l.parent_id)
490
- SELECT t.id, t.status FROM d JOIN tasks t ON t.id = d.id ORDER BY t.id`, id);
491
- for (const row of rows) {
492
- if (!['ready', 'review', 'running', 'done'].includes(row.status)) continue;
493
- const resume = row.status === 'review' ? 'review' : row.status === 'running' ? retryStatus(db, row.id) : 'ready';
494
- const runId = row.status === 'running' ? endRun(db, row.id, { outcome: 'reclaimed', status: 'todo', summary: `ancestor ${id} reopened` }) : null;
495
- run(db, "UPDATE tasks SET status = 'todo', completed_at = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, current_run_id = NULL, consecutive_failures = 0 WHERE id = ?", row.id);
496
- event(db, row.id, 'descendant_invalidated', { ancestor: id, prior_status: row.status, new_status: 'todo', resume_status: resume }, runId);
497
- event(db, row.id, 'status', { status: 'todo', reason: 'ancestor_reopened', parent: id, previous_status: row.status, resume_status: resume }, runId);
498
- run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', row.id, author, `Invalidated: ancestor ${id} was reopened; retracted from '${row.status}' to 'todo' (will resume via '${resume}').`, t);
499
- invalidated.push({ id: row.id, prior_status: row.status });
500
- }
501
- return { status, invalidated };
355
+ // who is sending, when a caller does not say: the CLI answers with the caller's role on the card (setCaller)
356
+ let callerRoles = () => ['manager'];
357
+ /** How the engine learns who a caller is on a card: `fn(db, cardId)` answers the caller's roles there. */
358
+ export function setCaller(fn) { callerRoles = fn; }
359
+
360
+ export async function act(board, id, name, { roles = null, payload = {}, extra = {} } = {}) {
361
+ const entry = await loadWorkflow(board.root);
362
+ const result = tx(board, (db) => {
363
+ roles ??= callerRoles(db, id);
364
+ const r = fire(db, board, entry, id, name, { roles, payload, extra });
365
+ // the cards that wait on it, and its subtasks, see it change
366
+ const around = [...childrenOf(db, id), ...all(db, 'SELECT task_id AS id FROM supercode_cards WHERE subtask_of = ?', id).map((x) => x.id)];
367
+ for (const other of new Set(around)) settle(db, board, entry, other, r.effects);
368
+ return r;
502
369
  });
370
+ await runEffects(board, result.effects);
371
+ return { from: result.from, to: result.to, moved: result.moved };
372
+ }
373
+
374
+ /** The I/O a committed move asked for. */
375
+ export async function runEffects(board, effects = []) {
376
+ for (const e of effects) {
377
+ if (e.kind === 'cleanup_workspace') cleanupWorkspace(board, e.id);
378
+ else if (e.kind === 'invalidate_descendants') tx(board, (db) => invalidateDescendants(db, board, e.id));
379
+ else if ((e.kind === 'run_ended' && e.outcome === 'reclaimed') || e.kind === 'stop_run') stopWorker(board, e.runId);
380
+ else if (e.kind === 'close_session' || e.kind === 'send_to_actor') {
381
+ const { sessionEffect } = await import('./dispatch.mjs');
382
+ await sessionEffect(board, e);
383
+ }
384
+ }
503
385
  }
504
386
 
505
- export function schedule(board, id, reason = null) {
506
- return tx(board, (db) => {
507
- const task = must(db, id);
508
- if (!['todo', 'ready', 'running', 'blocked'].includes(task.status)) throw new Error(`${id} is ${task.status}; it cannot be scheduled`);
509
- run(db, "UPDATE tasks SET status = 'scheduled', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?", id);
510
- const runId = endRun(db, id, { outcome: 'scheduled', summary: reason }) ?? (reason ? synthesizeRun(db, id, { outcome: 'scheduled', summary: reason }) : null);
511
- event(db, id, 'scheduled', { reason }, runId);
512
- });
387
+ // a run taken back stops its one-shot worker on this host (Hermes's _terminate_reclaimed_worker); a pane session is
388
+ // a person-visible session and is never killed from the board
389
+ function stopWorker(board, runId) {
390
+ if (!runId) return;
391
+ const row = tx(board, (db) => one(db, 'SELECT metadata, claim_lock FROM task_runs WHERE id = ?', runId));
392
+ const worker = json(row?.metadata, {})?.supercode;
393
+ if (worker?.surface === 'headless' && worker.pid) terminate(Number(worker.pid));
513
394
  }
514
395
 
515
- export function promote(board, id, { actor = 'user', reason = null, force = false } = {}) {
396
+ /** Every card whose workflow status has an `always` move that holds takes it (the tick's recompute). Answers how many moved. */
397
+ export function recomputeReady(board) {
398
+ const entry = workflowFor(board.root);
516
399
  return tx(board, (db) => {
517
- const task = must(db, id);
518
- if (!['todo', 'blocked'].includes(task.status)) throw new Error(`${id} is ${task.status}; promote moves a todo or blocked card`);
519
- const open = parentsOf(db, id).filter((p) => !['done', 'archived'].includes(p.status)).map((p) => p.id);
520
- if (open.length && !force) throw new Error(`parents not done: ${open.join(', ')} (force to override)`);
521
- run(db, "UPDATE tasks SET status = 'ready' WHERE id = ?", id);
522
- event(db, id, 'promoted_manual', { actor, reason, forced: force });
400
+ let moved = 0;
401
+ for (const { id } of all(db, "SELECT id FROM tasks WHERE status NOT IN ('done', 'archived')")) {
402
+ const before = statusOf(db, getTask(db, id));
403
+ settle(db, board, entry, id);
404
+ if (statusOf(db, getTask(db, id)) !== before) moved += 1;
405
+ }
406
+ return moved;
523
407
  });
524
408
  }
525
409
 
526
- /** Release a running card's claim by hand (its session is left alone); failures are forgiven. */
527
- export function reclaim(board, id, reason = null) {
528
- return tx(board, (db) => {
529
- const task = must(db, id);
530
- if (task.status !== 'running') throw new Error(`${id} is ${task.status}, not running`);
531
- release(db, id, { outcome: 'reclaimed', error: reason ? `manual_reclaim: ${reason}` : 'manual_reclaim', payload: { manual: true, reason, prev_lock: task.claim_lock } });
532
- run(db, 'UPDATE tasks SET consecutive_failures = 0, last_failure_error = NULL WHERE id = ?', id);
533
- });
410
+ // Hermes's invalidate_descendants_for_parent_reopen: every descendant that moved on because of the reopened card
411
+ // is pulled back to todo, its run ended, a comment saying why
412
+ function invalidateDescendants(db, board, id) {
413
+ const { workflow } = workflowFor(board.root);
414
+ const t = now();
415
+ const rows = all(db, `WITH RECURSIVE d(id) AS (SELECT child_id FROM task_links WHERE parent_id = ? UNION SELECT l.child_id FROM task_links l JOIN d ON d.id = l.parent_id)
416
+ SELECT t.id, t.status FROM d JOIN tasks t ON t.id = d.id ORDER BY t.id`, id);
417
+ const out = [];
418
+ for (const row of rows) {
419
+ if (!['ready', 'review', 'running', 'done'].includes(row.status)) continue;
420
+ const resume = row.status === 'review' ? 'review' : row.status === 'running' ? retryStatus(db, row.id) : 'ready';
421
+ const runId = row.status === 'running' ? endRun(db, row.id, { outcome: 'reclaimed', status: 'todo', summary: `ancestor ${id} reopened` }) : null;
422
+ run(db, 'UPDATE tasks SET completed_at = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, current_run_id = NULL, consecutive_failures = 0 WHERE id = ?', row.id);
423
+ setStatus(db, workflow, row.id, 'todo');
424
+ event(db, row.id, 'descendant_invalidated', { ancestor: id, prior_status: row.status, new_status: 'todo', resume_status: resume }, runId);
425
+ event(db, row.id, 'status', { status: 'todo', reason: 'ancestor_reopened', parent: id, previous_status: row.status, resume_status: resume }, runId);
426
+ run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', row.id, 'workflow', `Invalidated: ancestor ${id} was reopened; retracted from '${row.status}' to 'todo' (will resume via '${resume}').`, t);
427
+ out.push({ id: row.id, prior_status: row.status });
428
+ }
429
+ return out;
534
430
  }
535
431
 
536
- export function archive(board, id) {
537
- return tx(board, (db) => {
538
- const task = must(db, id);
539
- if (task.status === 'archived') return false;
540
- run(db, "UPDATE tasks SET status = 'archived', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?", id);
541
- const runId = endRun(db, id, { outcome: 'reclaimed', summary: 'task archived with run still active' });
542
- event(db, id, 'archived', null, runId);
543
- recomputeIn(db);
544
- return true;
432
+ // each of Hermes's verbs is an event of the workflow; the words it takes are the event's payload
433
+
434
+ export const complete = (board, id, { result = null, summary = null, metadata = null, roles, runId = null } = {}) =>
435
+ act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId } });
436
+ export const block = (board, id, { reason = null, kind = null, roles, runId = null } = {}) => {
437
+ if (kind && !BLOCK_KINDS.includes(kind)) throw new Error(`block kind is one of ${BLOCK_KINDS.join(', ')}`);
438
+ return act(board, id, 'block', { roles, payload: { reason, kind, run_id: runId } });
439
+ };
440
+ export const unblock = (board, id, { reason = null, roles } = {}) => act(board, id, 'unblock', { roles, payload: { reason } });
441
+ export const requestReview = (board, id, { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles } = {}) =>
442
+ act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId } });
443
+ export const requestChanges = (board, id, { reason = null, reviewer = null, roles } = {}) => act(board, id, 'request_changes', { roles, payload: { reason, reviewer } });
444
+ export const reopen = (board, id, { author = 'user', reason = null, roles } = {}) => act(board, id, 'reopen', { roles, payload: { author, reason } });
445
+ export const reopenReview = (board, id, { roles } = {}) => act(board, id, 'reopen_review', { roles });
446
+ export const schedule = (board, id, reason = null, { roles } = {}) => act(board, id, 'schedule', { roles, payload: { reason } });
447
+ export const promote = (board, id, { actor = 'user', reason = null, force = false, roles } = {}) => act(board, id, 'promote', { roles, payload: { actor, reason, force } });
448
+ export const reclaim = (board, id, reason = null, { roles } = {}) => act(board, id, 'reclaim', { roles, payload: { reason } });
449
+ export const archive = (board, id, { roles } = {}) => act(board, id, 'archive', { roles });
450
+ export const specify = (board, id, { title = null, body = null, author = 'user', roles } = {}) => act(board, id, 'specify', { roles, payload: { title, body, author } });
451
+ export const editResult = (board, id, { result, summary = null, metadata = undefined, roles } = {}) => act(board, id, 'edit', { roles, payload: { result, summary, metadata } });
452
+ export const heartbeat = (board, id, note = null, { roles } = {}) => act(board, id, 'heartbeat', { roles, payload: { note } });
453
+ export const setModel = (board, id, model = null, provider = null, { roles } = {}) => {
454
+ if (provider && !model) throw new Error('a provider needs a model');
455
+ return act(board, id, 'set_model', { roles, payload: { model: model || null, provider: provider || null } });
456
+ };
457
+ 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 } });
458
+ export const assign = (board, id, profile, { roles } = {}) => act(board, id, 'assign', { roles, payload: { assignee: profile || null } });
459
+
460
+ /** A triage card fans out into children it waits on (Hermes's decomposer); answers their ids. */
461
+ export async function decompose(board, id, children, { author = 'user', roles } = {}) {
462
+ if (!children.length) throw new Error('decompose names at least one child (--child "<title>[@<assignee>]")');
463
+ const task = tx(board, (db) => must(db, id));
464
+ const ids = children.map((c) => createCard(board, {
465
+ title: c.title, body: c.body ?? null, assignee: c.assignee ?? task.assignee, machine: task.machine,
466
+ workspace: task.workspace_path ? `${task.workspace_kind}:${task.workspace_path}` : task.workspace_kind, tenant: task.tenant, createdBy: author,
467
+ }).id);
468
+ await act(board, id, 'decompose', { roles, payload: { children: ids, author } });
469
+ return ids;
470
+ }
471
+
472
+ /** `child` waits for `parent`. */
473
+ export function link(board, parent, child, { roles } = {}) {
474
+ if (parent === child) throw new Error('a card cannot depend on itself');
475
+ tx(board, (db) => {
476
+ must(db, parent); must(db, child);
477
+ if (wouldCycle(db, parent, child)) throw new Error(`linking ${parent} -> ${child} would make a cycle`);
478
+ inheritSubscriptions(db, child, [parent]);
545
479
  });
480
+ return act(board, child, 'link', { roles, payload: { parent, child } });
546
481
  }
547
482
 
548
- /** Every todo (and non-sticky blocked) card whose parents are all done becomes ready. Answers how many. */
549
- export function recomputeReady(board, failureLimit = DEFAULT_FAILURE_LIMIT) {
550
- return tx(board, (db) => recomputeIn(db, failureLimit));
483
+ export async function unlink(board, parent, child, { roles } = {}) {
484
+ const linked = tx(board, (db) => one(db, 'SELECT 1 FROM task_links WHERE parent_id = ? AND child_id = ?', parent, child));
485
+ if (!linked) return false;
486
+ await act(board, child, 'unlink', { roles, payload: { parent, child } });
487
+ return true;
551
488
  }
552
489
 
553
- function recomputeIn(db, failureLimit = DEFAULT_FAILURE_LIMIT) {
554
- let promoted = 0;
555
- for (const row of all(db, "SELECT id, status, consecutive_failures, max_retries FROM tasks WHERE status IN ('todo', 'blocked')")) {
556
- if (row.status === 'blocked') {
557
- const last = one(db, "SELECT kind FROM task_events WHERE task_id = ? AND kind IN ('blocked', 'unblocked') ORDER BY id DESC LIMIT 1", row.id);
558
- if (last?.kind === 'blocked') continue; // a person's block stays until a person unblocks it
559
- if (Number(row.consecutive_failures || 0) >= (row.max_retries ?? failureLimit)) continue;
560
- }
561
- if (!parentsDone(db, row.id)) continue;
562
- const status = resumeStatus(db, row.id);
563
- run(db, 'UPDATE tasks SET status = ? WHERE id = ? AND status = ?', status, row.id, row.status);
564
- event(db, row.id, 'promoted', status !== 'ready' ? { status } : null);
565
- promoted += 1;
566
- }
567
- return promoted;
490
+ /** A comment on the card's thread; answers its id. */
491
+ export async function comment(board, id, author, body, { roles } = {}) {
492
+ if (!String(body ?? '').trim()) throw new Error('a comment has a body');
493
+ if (!String(author ?? '').trim()) throw new Error('a comment has an author');
494
+ const commentId = tx(board, (db) => {
495
+ must(db, id);
496
+ return Number(run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', id, author.trim(), body.trim(), now()).lastInsertRowid);
497
+ });
498
+ await act(board, id, 'comment', { roles, payload: { author: author.trim(), body } });
499
+ return commentId;
568
500
  }
569
501
 
570
502
  // ------------------------------------------------------------------ subscriptions
@@ -663,40 +595,7 @@ export function cardContext(db, id) {
663
595
 
664
596
  // ------------------------------------------------------------------ the rest of Hermes's verbs
665
597
 
666
- /** A triage card gets its spec (a tightened title and a body) and waits in todo for its parents, as Hermes's specifier leaves it. */
667
- export function specify(board, id, { title = null, body = null, author = 'user' } = {}) {
668
- return tx(board, (db) => {
669
- const task = must(db, id);
670
- if (task.status !== 'triage' && task.status !== 'running') throw new Error(`${id} is ${task.status}; only a triage card is specified`);
671
- if (title && String(title).trim()) run(db, 'UPDATE tasks SET title = ? WHERE id = ?', String(title).trim(), id);
672
- if (body !== null) run(db, 'UPDATE tasks SET body = ? WHERE id = ?', body, id);
673
- run(db, "UPDATE tasks SET status = 'todo', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?", id);
674
- const runId = endRun(db, id, { outcome: 'specified', summary: 'specified' });
675
- event(db, id, 'specified', { author, title_changed: Boolean(title), body_len: body ? body.length : 0 }, runId);
676
- recomputeIn(db);
677
- });
678
- }
679
598
 
680
- /**
681
- * A triage card fans out into children it waits on (Hermes's decomposer: the root stays and is the child of every
682
- * leaf, so it wakes when the graph is done). `children` are `{ title, body?, assignee? }`; answers their ids.
683
- */
684
- export function decompose(board, id, children, { author = 'user' } = {}) {
685
- if (!children.length) throw new Error('decompose names at least one child (--child "<title>[@<assignee>]")');
686
- const task = tx(board, (db) => must(db, id));
687
- if (task.status !== 'triage' && task.status !== 'running') throw new Error(`${id} is ${task.status}; only a triage card is decomposed`);
688
- const ids = children.map((c) => createCard(board, {
689
- title: c.title, body: c.body ?? null, assignee: c.assignee ?? task.assignee, machine: task.machine,
690
- workspace: task.workspace_path ? `${task.workspace_kind}:${task.workspace_path}` : task.workspace_kind, tenant: task.tenant, createdBy: author,
691
- }).id);
692
- tx(board, (db) => {
693
- for (const child of ids) run(db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', child, id);
694
- run(db, "UPDATE tasks SET status = 'todo', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?", id);
695
- const runId = endRun(db, id, { outcome: 'decomposed', summary: `decomposed into ${ids.join(', ')}` });
696
- event(db, id, 'decomposed', { children: ids, author }, runId);
697
- });
698
- return ids;
699
- }
700
599
 
701
600
  /** Hermes's swarm: a completed root as the shared blackboard, workers after it, a verifier after them all, a synthesizer last. */
702
601
  export function swarm(board, { goal, workers, verifier, synthesizer, tenant = null, createdBy = 'swarm-orchestrator', priority = 0, idempotencyKey = null }) {
@@ -717,68 +616,10 @@ export function swarm(board, { goal, workers, verifier, synthesizer, tenant = nu
717
616
  return { root_id: root.id, worker_ids: workerIds, verifier_id: verifierId, synthesizer_id: synthesizerId };
718
617
  }
719
618
 
720
- /** Hermes's `edit`: correct a done card's result (and its completed run's summary and metadata). */
721
- export function editResult(board, id, { result, summary = null, metadata = undefined }) {
722
- return tx(board, (db) => {
723
- const task = must(db, id);
724
- if (task.status !== 'done') throw new Error(`${id} is ${task.status}; edit corrects a done card's result`);
725
- run(db, 'UPDATE tasks SET result = ? WHERE id = ?', result, id);
726
- const said = summary ?? result;
727
- const last = one(db, "SELECT id FROM task_runs WHERE task_id = ? AND outcome = 'completed' ORDER BY COALESCE(ended_at, started_at, 0) DESC, id DESC LIMIT 1", id);
728
- let runId = last ? Number(last.id) : null;
729
- if (runId === null) runId = synthesizeRun(db, id, { outcome: 'completed', summary: said, metadata: metadata ?? null });
730
- else {
731
- run(db, 'UPDATE task_runs SET summary = ? WHERE id = ?', said, runId);
732
- if (metadata !== undefined) run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(metadata), runId);
733
- }
734
- event(db, id, 'edited', { fields: ['result', 'summary', ...(metadata !== undefined ? ['metadata'] : [])], result_len: result ? result.length : 0, summary: String(said ?? '').split('\n')[0].slice(0, 400) || null }, runId);
735
- });
736
- }
737
619
 
738
- /** Send a card in review back to its implementer (ready, or todo while parents are open). */
739
- export function reopenReview(board, id) {
740
- return tx(board, (db) => {
741
- const task = must(db, id);
742
- if (task.status !== 'review') throw new Error(`${id} is ${task.status}, not in review`);
743
- const handoff = json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'review_requested' ORDER BY id DESC LIMIT 1", id)?.payload, {}) ?? {};
744
- const status = parentsDone(db, id) ? 'ready' : 'todo';
745
- const implementer = typeof handoff.implementer === 'string' && handoff.implementer.trim() ? handoff.implementer : null;
746
- run(db, `UPDATE tasks SET status = ?, current_run_id = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL${implementer ? ', assignee = ?' : ''} WHERE id = ?`, ...(implementer ? [status, implementer, id] : [status, id]));
747
- event(db, id, 'review_reopened', { status, ...(implementer ? { implementer } : {}) });
748
- return status;
749
- });
750
- }
751
620
 
752
- /** A running card's worker says it is alive (Hermes's `heartbeat`); the dispatcher does not rely on it. */
753
- export function heartbeat(board, id, note = null) {
754
- return tx(board, (db) => {
755
- const task = must(db, id);
756
- if (task.status !== 'running') throw new Error(`${id} is ${task.status}, not running`);
757
- const t = now();
758
- run(db, 'UPDATE tasks SET last_heartbeat_at = ? WHERE id = ?', t, id);
759
- if (task.current_run_id) run(db, 'UPDATE task_runs SET last_heartbeat_at = ? WHERE id = ?', t, task.current_run_id);
760
- event(db, id, 'heartbeat', note ? { note } : null, task.current_run_id);
761
- });
762
- }
763
621
 
764
- /** A card's model (and provider) for its next start, or cleared. */
765
- export function setModel(board, id, model = null, provider = null) {
766
- if (provider && !model) throw new Error('a provider needs a model');
767
- return tx(board, (db) => {
768
- must(db, id);
769
- run(db, 'UPDATE tasks SET model_override = ?, provider_override = ? WHERE id = ?', model || null, provider || null, id);
770
- event(db, id, 'model_override_set', { model: model || null, provider: provider || null });
771
- });
772
- }
773
622
 
774
- /** Goal mode on a card (Hermes's goal loop, which Hermes sets only at creation): on or off, with its turn budget. */
775
- export function setGoal(board, id, { on = true, maxTurns = null } = {}) {
776
- return tx(board, (db) => {
777
- must(db, id);
778
- run(db, 'UPDATE tasks SET goal_mode = ?, goal_max_turns = ? WHERE id = ?', on ? 1 : 0, on ? maxTurns : null, id);
779
- event(db, id, 'goal_set', { goal_mode: Boolean(on), goal_max_turns: on ? maxTurns : null });
780
- });
781
- }
782
623
 
783
624
  export function addAttachment(board, id, { file, name, contentType = null, author = 'user', dir }) {
784
625
  return tx(board, (db) => {