@volter/supercode-orchestrator 0.4.147 → 0.4.149

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
@@ -13,7 +13,7 @@ import { join, resolve } from 'node:path';
13
13
  import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace } from './workspace.mjs';
14
14
  import { DatabaseSync } from 'node:sqlite';
15
15
  import { view, actorOf, claimer, event, json, newTaskId, now, tx } from './store.mjs';
16
- import { observeCompletionEvidence } from './ci.mjs';
16
+ import { completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
17
17
  import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
18
18
 
19
19
  export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived'];
@@ -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,10 +210,6 @@ 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();
@@ -216,13 +238,13 @@ export function createCard(board, spec) {
216
238
  spec.skills?.length ? JSON.stringify(spec.skills) : null, spec.maxRetries ?? null, spec.model ?? null, spec.provider ?? null,
217
239
  spec.reasoning ?? null, spec.sessionId ?? null, spec.project ?? null, spec.goal ? 1 : 0, spec.goal?.maxTurns ?? null);
218
240
  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);
241
+ 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
242
  for (const p of parents) addDependency(db, entry, id, p);
221
243
  // where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
222
244
  // the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
223
245
  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 } : {}) });
246
+ every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
247
+ event(db, id, 'created', { creator: actorOf(), assignee: spec.assignee || null, status, parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
226
248
  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
249
  if (parents.length) inheritSubscriptions(db, id, parents);
228
250
  if (arc) {
@@ -232,7 +254,9 @@ export function createCard(board, spec) {
232
254
  }
233
255
  if (initial === 'running' && arc && entry.workflow.run) setStatus(db, entry.workflow, id, 'running');
234
256
  else if (initial === 'running') claimIn(db, id, { ttl: claimTtl() });
235
- return { id, existing: false };
257
+ // the body's task lines, as subtasks the creator filed, in this same write
258
+ const subtasks = steps.map((step) => createCard(board, { ...step, subtaskOf: id, assignee: spec.assignee || null }).id);
259
+ return { id, existing: false, ...(subtasks.length ? { subtasks } : {}) };
236
260
  });
237
261
  }
238
262
 
@@ -343,11 +367,16 @@ export function setCaller(fn) { callerRoles = fn; }
343
367
 
344
368
  export async function act(board, id, name, { roles = null, payload = {}, extra = {} } = {}) {
345
369
  const entry = await loadWorkflow(board.root);
370
+ if (name === 'comment' && (!String(payload.body ?? '').trim() || !String(payload.author ?? '').trim())) throw new Error('a comment has an author and body');
346
371
  refreshCardInputs(board, id, { commits: ['complete', 'request_review'].includes(name) });
347
- if (['complete', 'request_review', 'request_state'].includes(name)) payload = { ...payload, ci: observeCompletionEvidence(board, id, entry, payload) };
372
+ const evidencePhase = ['complete', 'request_review', 'request_state'].includes(name) ? completionPhase(board, id, entry, name, payload, roles ?? callerRoles(board.db, id)) : null;
373
+ if (evidencePhase) payload = { ...payload, ci: observeCompletionEvidence(board, id, entry, payload, evidencePhase) };
374
+ 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.');
348
375
  const result = tx(board, (db) => {
349
376
  roles ??= callerRoles(db, id);
350
377
  const r = fire(db, board, entry, id, name, { roles, payload, extra });
378
+ // Authorization and the comment row share one transaction: a refusal stores nothing.
379
+ if (name === 'comment') r.commentId = Number(run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', id, payload.author.trim(), payload.body.trim(), now()).lastInsertRowid);
351
380
  // the cards that wait on it, and its subtasks, see it change
352
381
  const related = all(db, 'SELECT task_id, blocked_by FROM supercode_cards').filter((c) => json(c.blocked_by, []).some((b) => b.id === id)).map((c) => c.task_id);
353
382
  const around = [...related, ...childrenOf(db, id), ...all(db, 'SELECT task_id AS id FROM supercode_cards WHERE subtask_of = ?', id).map((x) => x.id)];
@@ -355,7 +384,34 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
355
384
  return r;
356
385
  });
357
386
  await runEffects(board, result.effects);
358
- return { from: result.from, to: result.to, moved: result.moved };
387
+ return { from: result.from, to: result.to, moved: result.moved, ...(result.commentId ? { commentId: result.commentId } : {}) };
388
+ }
389
+
390
+ export async function requestStateMany(board, ids, payload = {}) {
391
+ const targets = [...new Set(ids.map(String))];
392
+ if (!targets.length) throw new Error('A state request names at least one card.');
393
+ const entry = await loadWorkflow(board.root);
394
+ const prepared = new Map(targets.map(id => {
395
+ const roles = callerRoles(board.db, id);
396
+ const phase = completionPhase(board, id, entry, 'request_state', payload, roles);
397
+ const evidence = inspectCompletionEvidence(board, id, entry, payload, phase);
398
+ // An ordinary request mails a decision to the manager, not a completion.
399
+ // Keep a failed source observation in that mail. A reviewer recording an
400
+ // actual verdict still has to verify the requested PR head.
401
+ if (phase === 'review_verdict' && evidence.required && !evidence.ready) throw new Error(evidence.reason ?? 'The reviewed PR head could not be verified.');
402
+ return [id, evidence];
403
+ }));
404
+ const result = tx(board, db => {
405
+ const effects = [];
406
+ const results = targets.map(id => withCompletionEvidence(board, id, prepared.get(id), () => {
407
+ const move = fire(db, board, entry, id, 'request_state', { roles: callerRoles(db, id), payload: { ...payload, ci: prepared.get(id) } });
408
+ effects.push(...move.effects);
409
+ return [id, move.to];
410
+ }));
411
+ return { results, effects };
412
+ });
413
+ await runEffects(board, result.effects);
414
+ return result.results;
359
415
  }
360
416
 
361
417
  /** Take card `id` to `status` by the event the workflow says goes there for this caller (`goto`). */
@@ -377,7 +433,7 @@ export async function goto(board, id, status, { roles = null, payload = {} } = {
377
433
  export async function runEffects(board, effects = [], { retryNotices = false } = {}) {
378
434
  const requested = new Set(effects.map((e) => e.durableId).filter(Boolean));
379
435
  const pending = board.db.prepare('SELECT id, effect FROM supercode_effects ORDER BY id').all()
380
- .filter((p) => retryNotices || requested.has(p.id) || json(p.effect, {})?.kind !== 'session_notice');
436
+ .filter((p) => retryNotices || requested.has(p.id));
381
437
  const durable = new Set(pending.map((p) => p.id));
382
438
  const combined = [...pending.map((p) => ({ ...json(p.effect, {}), durableId: p.id })), ...effects.filter((e) => !e.durableId || !durable.has(e.durableId))];
383
439
  combined.sort((a,b) => Number(b.kind === 'session_notice') - Number(a.kind === 'session_notice'));
@@ -453,14 +509,14 @@ function invalidateDescendants(db, board, id) {
453
509
 
454
510
  // each of Hermes's verbs is an event of the workflow; the words it takes are the event's payload
455
511
 
456
- export const complete = (board, id, { result = null, summary = null, metadata = null, roles, runId = null, commit = null, ci_run = null } = {}) =>
457
- act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId, commit, ci_run } });
512
+ export const complete = (board, id, { result = null, summary = null, metadata = null, roles, runId = null, commit = null, ci_run = null, branch = null, pr = null, pr_head = null, reviewed_sha = null } = {}) =>
513
+ act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId, commit, ci_run, branch, pr, pr_head, reviewed_sha } });
458
514
  // 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`)
459
515
  export const block = (board, id, { reason = null, on = null, waitingOn = null, roles, runId = null } = {}) =>
460
516
  act(board, id, 'block', { roles, payload: { reason, on, waiting_on: waitingOn, run_id: runId } });
461
517
  export const unblock = (board, id, { reason = null, on = null, roles } = {}) => act(board, id, 'unblock', { roles, payload: { reason, on } });
462
- export const requestReview = (board, id, { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, ci_run = null } = {}) =>
463
- act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId, commit, ci_run } });
518
+ export const requestReview = (board, id, { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, ci_run = null, branch = null, pr = null, pr_head = null } = {}) =>
519
+ act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId, commit, ci_run, branch, pr, pr_head } });
464
520
  export const requestChanges = (board, id, { reason = null, reviewer = null, roles } = {}) => act(board, id, 'request_changes', { roles, payload: { reason, reviewer } });
465
521
  export const reopen = (board, id, { author = 'user', reason = null, roles } = {}) => act(board, id, 'reopen', { roles, payload: { author, reason } });
466
522
  export const reopenReview = (board, id, { roles } = {}) => act(board, id, 'reopen_review', { roles });
@@ -468,16 +524,24 @@ export const schedule = (board, id, reason = null, { roles } = {}) => act(board,
468
524
  export const promote = (board, id, { actor = 'user', reason = null, force = false, roles } = {}) => act(board, id, 'promote', { roles, payload: { actor, reason, force } });
469
525
  export const reclaim = (board, id, reason = null, { roles } = {}) => act(board, id, 'reclaim', { roles, payload: { reason } });
470
526
  export const archive = (board, id, { roles } = {}) => act(board, id, 'archive', { roles });
471
- export function specify(board, id, { title = null, body = null, workspace = null, branch = null, reviewOnly = null, author = 'user', roles } = {}) {
527
+ export async function specify(board, id, { title = null, body = null, workspace = null, branch = null, author = 'user', roles } = {}) {
472
528
  let declaration = null;
473
- if (workspace != null) {
474
- const task = getTask(board.db, id), ws = parseWorkspace(workspace);
529
+ if (workspace != null || branch != null) {
530
+ const task = getTask(board.db, id);
531
+ const word = workspace ?? `${task.workspace_kind}${task.workspace_anchor || task.workspace_path ? `:${task.workspace_anchor ?? task.workspace_path}` : ''}`;
532
+ const ws = parseWorkspace(word);
475
533
  if (task.current_run_id) throw new Error('end the current run before changing its workspace');
534
+ if (branch != null && ws.kind === 'scratch') throw new Error('a scratch workspace has no repository branch');
476
535
  declaration = { kind: ws.kind, ...declareWorkspace(ws.kind, ws.path, task.machine, branch) };
536
+ if (declaration.repository === task.workspace_anchor) declaration.branch ??= task.branch_name;
477
537
  if (declaration.kind === 'worktree') declaration.branch ??= `wt/${id}`;
538
+ 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;
478
539
  }
479
- 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.');
480
- return act(board, id, 'specify', { roles, payload: { title, body, author, ...(reviewOnly != null ? { review_only: reviewOnly } : {}), ...(declaration ? { workspace: declaration } : {}) } });
540
+ // an arc's new task lines are filed as its subtasks once the specify itself is accepted
541
+ const steps = body != null && !one(board.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id = ?', id)?.subtask_of ? bodyTasks(body) : { body, tasks: [] };
542
+ const done = await act(board, id, 'specify', { roles, payload: { title, body: steps.body, author, ...(declaration ? { workspace: declaration } : {}) } });
543
+ for (const step of steps.tasks) createCard(board, { ...step, subtaskOf: id, assignee: getTask(board.db, id)?.assignee || null });
544
+ return done;
481
545
  }
482
546
  export const editResult = (board, id, { result, summary = null, metadata = undefined, roles } = {}) => act(board, id, 'edit', { roles, payload: { result, summary, metadata } });
483
547
  export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
@@ -522,12 +586,8 @@ export async function unlink(board, parent, child, { roles } = {}) {
522
586
  export async function comment(board, id, author, body, { roles } = {}) {
523
587
  if (!String(body ?? '').trim()) throw new Error('a comment has a body');
524
588
  if (!String(author ?? '').trim()) throw new Error('a comment has an author');
525
- const commentId = tx(board, (db) => {
526
- must(db, id);
527
- return Number(run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', id, author.trim(), body.trim(), now()).lastInsertRowid);
528
- });
529
- await act(board, id, 'comment', { roles, payload: { author: author.trim(), body } });
530
- return commentId;
589
+ const result = await act(board, id, 'comment', { roles, payload: { author: author.trim(), body } });
590
+ return result.commentId;
531
591
  }
532
592
 
533
593
  // ------------------------------------------------------------------ subscriptions
@@ -550,6 +610,47 @@ export function unsubscribe(board, id, address) {
550
610
  return tx(board, (db) => run(db, "DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?", id, address).changes > 0);
551
611
  }
552
612
 
613
+ function dependencyRecipient(db, id) {
614
+ const owner = must(db, id);
615
+ if (owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) return null;
616
+ const session = currentRun(db, id)?.metadata?.supercode ?? lastSession(db, id);
617
+ return session?.forgotten || session?.close_confirmed_at ? null : session?.address ?? null;
618
+ }
619
+
620
+ export function subscribeDependency(board, ownerId, sourceId, { remove = false } = {}) {
621
+ return tx(board, db => {
622
+ const roles = callerRoles(db, ownerId);
623
+ if (!roles.some(role => ['implementer', 'reviewer', 'auditor', 'manager', 'owner'].includes(role))) throw new Error('A dependency subscription belongs to your working arc; use --for-card <your-card>.');
624
+ const owner = must(db, ownerId);
625
+ if (owner.subtask_of) throw new Error('The dependency subscription names its arc, not a subtask.');
626
+ must(db, sourceId);
627
+ const thread = `arc:${ownerId}`;
628
+ if (remove) return { removed: run(db, "DELETE FROM kanban_notify_subs WHERE task_id=? AND platform='supercode' AND thread_id=? AND json_extract(delivery_metadata,'$.dependent_card')=?", sourceId, thread, ownerId).changes > 0 };
629
+ const address = dependencyRecipient(db, ownerId);
630
+ if (!address) throw new Error('The subscribing arc has no current reachable session identity.');
631
+ const existing = one(db, "SELECT * FROM kanban_notify_subs WHERE task_id=? AND platform='supercode' AND thread_id=? AND json_extract(delivery_metadata,'$.dependent_card')=?", sourceId, thread, ownerId);
632
+ const cursor = existing?.last_event_id ?? one(db, 'SELECT COALESCE(MAX(id),0) AS cursor FROM task_events WHERE task_id=?', sourceId).cursor;
633
+ run(db, "DELETE FROM kanban_notify_subs WHERE task_id=? AND platform='supercode' AND thread_id=? AND json_extract(delivery_metadata,'$.dependent_card')=?", sourceId, thread, ownerId);
634
+ run(db, "INSERT INTO kanban_notify_subs(task_id,platform,chat_id,thread_id,chat_type,delivery_mode,delivery_metadata,created_at,last_event_id) VALUES (?,'supercode',?,?,'dm','notify+wake',?,?,?)",
635
+ sourceId, address, thread, JSON.stringify({ role: 'dependency', dependent_card: ownerId }), now(), cursor);
636
+ event(db, ownerId, 'dependency_subscribed', { source: sourceId, address });
637
+ return { address, source: sourceId, owner: ownerId };
638
+ });
639
+ }
640
+
641
+ export function reconcileDependencySubscriptions(db) {
642
+ for (const row of all(db, "SELECT * FROM kanban_notify_subs WHERE platform='supercode' AND json_extract(delivery_metadata,'$.dependent_card') IS NOT NULL")) {
643
+ const metadata = json(row.delivery_metadata, {});
644
+ const owner = getTask(db, metadata.dependent_card);
645
+ const address = owner ? dependencyRecipient(db, owner.id) : null;
646
+ const where = "WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=?";
647
+ const identity = [row.task_id, row.chat_id, row.thread_id];
648
+ if (!owner || owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) run(db, `DELETE FROM kanban_notify_subs ${where}`, ...identity);
649
+ 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);
650
+ else if (!address && !metadata.waiting_recipient) run(db, `UPDATE kanban_notify_subs SET delivery_metadata=? ${where}`, JSON.stringify({ ...metadata, waiting_recipient: true }), ...identity);
651
+ }
652
+ }
653
+
553
654
  /** A child hears what its parents' subscribers hear (Hermes copies them at link time); a subtask its arc's session too. */
554
655
  function inheritSubscriptions(db, child, parents, { workers = false } = {}) {
555
656
  const cursor = one(db, 'SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?', child).c;
@@ -597,7 +698,6 @@ export function cardContext(db, id) {
597
698
  const task = must(db, id);
598
699
  const out = [`# Card ${task.id}: ${task.title}`, ''];
599
700
  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}.` : ''}`);
600
- 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.');
601
701
  if (task.body) out.push('', cap(task.body, 8 * 1024));
602
702
  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);
603
703
  if (parents.length) {
@@ -0,0 +1,5 @@
1
+ export function identifyNotice(event, text) {
2
+ const time = new Date(Number(event.created_at) * 1000).toISOString();
3
+ const kind = String(event.kind).replaceAll('_', ' ');
4
+ return `New board update: ${kind}; run ${event.run_id ?? 'none'}; event ${event.id}; ${time}.\n${text}`;
5
+ }
@@ -0,0 +1,59 @@
1
+ import { event, json, now } from './store.mjs';
2
+
3
+ export function pendingRequestMail(db, taskId = null) {
4
+ const pending = db.prepare(`SELECT id, task_id, payload, created_at FROM task_events original
5
+ WHERE kind='board_message' AND NOT EXISTS (SELECT 1 FROM task_events sent WHERE sent.kind='board_message_sent'
6
+ AND sent.task_id=original.task_id AND json_extract(sent.payload,'$.event')=original.id) ORDER BY id`).all()
7
+ .map(row => ({ ...row, mail: json(row.payload, {}) ?? {} }));
8
+ if (!pending.length) return { ready: [], nextAt: Infinity };
9
+ const batches = db.prepare("SELECT payload FROM task_events WHERE kind='board_message_batch' AND id>=? ORDER BY id").all(pending[0].id)
10
+ .map(row => json(row.payload, {}));
11
+ const assigned = new Set();
12
+ const ready = [];
13
+ let nextAt = Infinity;
14
+ for (const envelope of batches) {
15
+ const remaining = pending.filter(row => envelope.members?.some(member => member.id === row.id));
16
+ if (!remaining.length) continue;
17
+ for (const row of remaining) assigned.add(row.id);
18
+ if (!taskId || envelope.members.some(member => member.task_id === taskId)) ready.push(envelope);
19
+ }
20
+ const groups = new Map();
21
+ for (const row of pending) {
22
+ if (assigned.has(row.id)) continue;
23
+ if (!row.mail.batch) {
24
+ if (!taskId || row.task_id === taskId) ready.push({ ...row.mail, members: [{ id: row.id, task_id: row.task_id }] });
25
+ continue;
26
+ }
27
+ const key = JSON.stringify([row.mail.to, row.mail.batch.requester]);
28
+ const groupsForRequester = groups.get(key) ?? [];
29
+ let group = groupsForRequester.at(-1);
30
+ if (!group || row.created_at > group[0].created_at + 60) {
31
+ group = [];
32
+ groupsForRequester.push(group);
33
+ groups.set(key, groupsForRequester);
34
+ }
35
+ group.push(row);
36
+ }
37
+ for (const groupsForRequester of groups.values()) for (const group of groupsForRequester) {
38
+ if (taskId && !group.some(row => row.task_id === taskId)) continue;
39
+ const first = group[0];
40
+ const last = group.at(-1);
41
+ const due = Math.min(first.created_at + 60, last.created_at + last.mail.batch.window_seconds);
42
+ if (now() < due) {
43
+ nextAt = Math.min(nextAt, due * 1000);
44
+ continue;
45
+ }
46
+ const envelope = {
47
+ to: first.mail.to,
48
+ message_id: first.mail.message_id,
49
+ requester: first.mail.batch.requester,
50
+ members: group.map(row => ({ id: row.id, task_id: row.task_id, message_id: row.mail.message_id })),
51
+ text: `State requests from ${first.mail.batch.requester}: ${group.length} request(s) for ${new Set(group.map(row => row.task_id)).size} card(s).\n\n` +
52
+ group.map(row => `${row.task_id} [${row.mail.message_id}]\n${row.mail.text}`).join('\n\n---\n\n') +
53
+ `\n\nAnswer with reply_to ${first.mail.message_id}.`,
54
+ };
55
+ event(db, first.task_id, 'board_message_batch', envelope);
56
+ ready.push(envelope);
57
+ }
58
+ return { ready, nextAt };
59
+ }
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
@@ -34,7 +34,8 @@ export async function boardTool(root, name, args = {}, env = {}) {
34
34
  const runId = env.HERMES_KANBAN_RUN_ID ? Number(env.HERMES_KANBAN_RUN_ID) : null;
35
35
  const author = env.HERMES_PROFILE || 'worker';
36
36
  if (own && ORCHESTRATOR_ONLY.has(name)) return error(`${name} is the orchestrator's; read kanban_show for your task's declared doors, or use kanban_request_state, kanban_heartbeat or kanban_comment`);
37
- const tid = args.task_id || own;
37
+ if (args.task_ids !== undefined && (!Array.isArray(args.task_ids) || !args.task_ids.length || args.task_ids.some(id => typeof id !== 'string' || !id.trim()))) return error('task_ids is a nonempty array of card ids.');
38
+ const tid = args.task_id || args.task_ids?.[0] || own;
38
39
  if (!['kanban_list', 'kanban_create', 'kanban_link'].includes(name) && !tid) return error('task_id is required (or HERMES_KANBAN_TASK in the environment)');
39
40
  const slug = args.board || env.HERMES_KANBAN_BOARD || DEFAULT_BOARD;
40
41
  // the board's workflow decides what each tool's event does; the worker is its run's role on its own task, and
@@ -44,6 +45,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
44
45
  if (!own || !runId) return own ? [] : ['manager'];
45
46
  const meta = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(runId)?.metadata, {})?.supercode ?? {};
46
47
  const roles = cardId === own ? [meta.role ?? 'implementer'] : [];
48
+ if (engine.getTask(db, cardId)?.subtask_of === own) roles.push('arc');
47
49
  if (name === 'kanban_comment' && isLatestReviewer(db, cardId, actorOf()) && !roles.includes('reviewer')) roles.push('reviewer');
48
50
  if (actorOf() && completionOwnerOf(db, cardId) === actorOf()) roles.push('creator');
49
51
  return roles;
@@ -53,8 +55,12 @@ export async function boardTool(root, name, args = {}, env = {}) {
53
55
  board.label = slug;
54
56
  const callingRun = runId ? json(board.db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(runId)?.metadata, {})?.supercode : null;
55
57
  setActor(own && runId ? callingRun?.address ?? `hermes-run:${slug}:${runId}` : null);
56
- if (own && LIFECYCLE.has(name) && tid !== own && completionOwnerOf(board.db, tid) !== actorOf()) {
57
- board.close(); return error(`worker is scoped to task ${own}; refusing to change ${tid}. Use kanban_comment to hand off to other tasks.`);
58
+ const targets = name === 'kanban_request_state' ? [...new Set([tid, ...(args.task_ids ?? [])])] : [tid];
59
+ if (own && LIFECYCLE.has(name)) for (const target of targets) {
60
+ const arcTask = ['kanban_complete', 'kanban_request_state'].includes(name) && engine.getTask(board.db, target)?.subtask_of === own;
61
+ if (target !== own && !arcTask && completionOwnerOf(board.db, target) !== actorOf()) {
62
+ board.close(); return error(`worker is scoped to task ${own}; refusing to change ${target}. Use kanban_comment to hand off to other tasks.`);
63
+ }
58
64
  }
59
65
  const current = () => tx(board, (db) => engine.getTask(db, tid));
60
66
  // a worker whose run was superseded (reclaimed, re-dispatched) no longer speaks for the card
@@ -74,7 +80,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
74
80
  return JSON.stringify({
75
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,
76
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,
77
- 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 },
78
84
  parents: card.parents.map((p) => p.id), children: card.children,
79
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 })),
80
86
  worker_context: card.context,
@@ -106,7 +112,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
106
112
  }
107
113
  }
108
114
  if (env.HERMES_SESSION_ID && own === tid) metadata = { ...(metadata ?? {}), worker_session_id: env.HERMES_SESSION_ID };
109
- await engine.complete(board, tid, { summary, result, metadata, commit: args.commit ?? null, ci_run: args.ci_run ?? null });
115
+ await engine.complete(board, tid, { summary, result, metadata, commit: args.commit ?? null, ci_run: args.ci_run ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? null, reviewed_sha: args.reviewed_sha ?? null });
110
116
  return ok({ task_id: tid, status: 'done' });
111
117
  }
112
118
  case 'kanban_block': {
@@ -116,12 +122,12 @@ export async function boardTool(root, name, args = {}, env = {}) {
116
122
  }
117
123
  case 'kanban_request_state': {
118
124
  const stale = ownRun(); if (stale) return error(stale);
119
- await engine.act(board, tid, 'request_state', { payload: { state: args.state, reason: args.reason, on: args.on ?? null, waiting_on: args.waiting_on ?? null, evidence: args.evidence ?? null, commit: args.commit ?? null, ci_run: args.ci_run ?? null, deployed_sha: args.deployed_sha ?? null } });
120
- return ok({ task_id: tid, requested: args.state, changed: false });
125
+ await engine.requestStateMany(board, targets, { state: args.state, reason: args.reason, on: args.on ?? null, waiting_on: args.waiting_on ?? null, evidence: args.evidence ?? null, commit: args.commit ?? null, ci_run: args.ci_run ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? null, reviewed_sha: args.reviewed_sha ?? null, deployed_sha: args.deployed_sha ?? null });
126
+ return ok({ task_id: tid, task_ids: targets, requested: args.state, changed: false });
121
127
  }
122
128
  case 'kanban_request_review': {
123
129
  const stale = ownRun(); if (stale) return error(stale);
124
- await engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, commit: args.commit ?? null, ci_run: args.ci_run ?? null, reviewer: args.reviewer ?? null, expectedRunId: own === tid && runId ? runId : null });
130
+ await engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, commit: args.commit ?? null, ci_run: args.ci_run ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? args.commit ?? null, reviewer: args.reviewer ?? null, expectedRunId: own === tid && runId ? runId : null });
125
131
  return ok({ task_id: tid, status: 'review' });
126
132
  }
127
133
  case 'kanban_request_changes': {
@@ -170,7 +176,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
170
176
  title: args.title, body: args.body ? redact(String(args.body)) : null, assignee: args.assignee ?? null, parents: [args.parents ?? []].flat(), tenant: args.tenant ?? null,
171
177
  priority: args.priority ?? 0, workspace, project: args.project ?? null, triage: Boolean(args.triage), idempotencyKey: args.idempotency_key ?? null,
172
178
  maxRuntime: args.max_runtime_seconds ?? null, initialStatus: args.initial_status ?? null, skills: args.skills ?? null, model: args.model ?? null, provider: args.provider ?? null,
173
- goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author, reviewOnly: args.review_only ?? false,
179
+ goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author,
174
180
  });
175
181
  return ok({ task_id: made.id, existing: made.existing, status: tx(board, (db) => engine.getTask(db, made.id)).status });
176
182
  }