@volter/supercode-orchestrator 0.4.146 → 0.4.148

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.
@@ -15,7 +15,7 @@
15
15
  // Every side effect follows a committed transition: a card is claimed before its session is launched, and a
16
16
  // subscriber's cursor moves only after its notice was delivered, so two dispatchers over one board never both
17
17
  // launch and a crash between the two steps is answered by the next tick.
18
- import { workflowRevision, retainWorkflowTick } from './ci.mjs';
18
+ import { inspectCompletionEvidence, openReviewPullRequest, withCompletionEvidence, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
19
19
  import { createRequire } from 'node:module';
20
20
  import { spawn as spawnChild, spawnSync } from 'node:child_process';
21
21
  import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
@@ -24,12 +24,14 @@ import { basename, dirname, join } from 'node:path';
24
24
  import { createHash, randomUUID } from 'node:crypto';
25
25
  import { fileURLToPath } from 'node:url';
26
26
  import { view, boardPath, claimer, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
27
- import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, templateValue, workflowFor } from './workflow.mjs';
27
+ import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, templateValue, workflowFor } from './workflow.mjs';
28
28
  import { checkWorkspace, assertWorkspaceOwner, resolveWorkspace } from './workspace.mjs';
29
29
  import { evaluate, holds, render } from './expr.mjs';
30
+ import { identifyNotice } from './notices.mjs';
31
+ import { pendingRequestMail } from './request-mail.mjs';
30
32
  import {
31
33
  cardContext, claimTtl, currentRun, DEFAULT_FAILURE_LIMIT, extendClaim, getTask, lastSession,
32
- recomputeReady, runEffects, setRunSession, subscribeIn, subscriptions,
34
+ recomputeReady, runEffects, setRunSession, subscribeIn, subscriptions, reconcileDependencySubscriptions,
33
35
  } from './engine.mjs';
34
36
 
35
37
  const WORKER = fileURLToPath(new URL('../bin/hermes-worker.mjs', import.meta.url));
@@ -589,7 +591,7 @@ export function launchRun(board, entry, id, lane, fleet, settings) {
589
591
  const reviewer = view(board, (db) => lastSession(db, id, slot));
590
592
  if (slot === 'worker' || (worker?.session_id && worker.session_id === reviewer?.session_id)) throw new Error('a reviewer must have a different session from the implementer');
591
593
  }
592
- const meta = { status, role: spec.role, slot };
594
+ const meta = { status, role: spec.role, slot, ...(spec.role === 'reviewer' ? { review_candidate: reviewCandidate(board, id) } : {}) };
593
595
  try {
594
596
  const result = surface === 'pane'
595
597
  ? launchPane(board, task, runId, lane, fleet, { input: prompt, slot, meta })
@@ -652,17 +654,26 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
652
654
  const panes = new Map();
653
655
  for (const row of rows) {
654
656
  let session = json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
655
- if (session?.launch_key && !session.pane && (row.ended_at || session.close_requested)) {
657
+ if (!session || session.close_transferred_to || session.close_confirmed_at) continue;
658
+ if (session.launch_key && (session.launch_pending || !session.pane) && (row.ended_at || session.close_requested)) {
656
659
  let receipt = null, receiptError = null;
657
- try { receipt = launchReceipt(board, session); } catch (error) { receiptError = error.message; }
658
- if (receipt?.pane) { bindLaunch(board, row, session, receipt.pane, session.session_id); session = { ...session, pane: receipt.pane }; }
660
+ try {
661
+ receipt = launchReceipt(board, session);
662
+ if (!receipt) receipt = cancelUnissuedLaunch(board, session);
663
+ } catch (error) { receiptError = error.message; }
664
+ if (unissuedLaunch(receipt, session) && retireUnissuedLaunch(board, row, session, receipt)) {
665
+ (report.closed ??= []).push({ task_id: row.task_id, ...session, receipt });
666
+ continue;
667
+ }
668
+ if (receipt?.pane && receipt.status !== 'issued' && (receipt.status || receipt.live || receipt.exit)) { bindLaunch(board, row, session, receipt.pane, session.session_id); session = { ...session, pane: receipt.pane }; }
659
669
  else {
660
- const error = `close deferred: ${receiptError ?? `awaiting launch receipt ${session.launch_key}`}`;
670
+ const error = `close deferred: ${receiptError ?? `awaiting terminal launch receipt ${session.launch_key} (${receipt?.status ?? 'unknown'})`}`;
661
671
  tx(board, (db) => {
662
672
  setRunSession(db, row.id, { close_error: error, close_requested: session.close_requested ?? now() });
663
673
  if (session.close_error !== error) event(db, row.task_id, 'session_close_failed', { error, retry: true }, row.id);
664
674
  });
665
675
  report.undelivered.push([row.task_id, addressOf(session), error]);
676
+ continue;
666
677
  }
667
678
  }
668
679
  if ((!session?.pane && session?.surface !== 'native') || session.close_transferred_to || session.close_confirmed_at || (!row.ended_at && !session.close_requested)) continue;
@@ -790,7 +801,10 @@ export async function sessionNotice(board, e) {
790
801
  const prior = view(board, (db) => json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId)?.metadata, {})?.supercode);
791
802
  if (prior?.close_confirmed_at) {
792
803
  tx(board, (db) => {
793
- event(db, e.id, 'session_notice_failed', { address: e.address, verb: e.verb, notice: e.durableId, error: 'session closure already confirmed; notice cannot be delivered', retry: false }, e.runId);
804
+ const unissued = unissuedLaunch(prior.launch_cancellation, prior);
805
+ event(db, e.id, unissued ? 'session_notice_not_required' : 'session_notice_failed', { address: e.address, verb: e.verb, notice: e.durableId,
806
+ error: unissued ? 'machine confirmed launch was not issued; no session exists to notify' : 'session closure already confirmed; notice cannot be delivered', retry: false,
807
+ ...(unissued ? { receipt: prior.launch_cancellation } : {}) }, e.runId);
794
808
  if (prior.lifecycle_notice_pending === e.durableId) setRunSession(db, e.runId, { lifecycle_notice_pending: null });
795
809
  });
796
810
  return true;
@@ -928,6 +942,45 @@ function launchReceipt(board, session) {
928
942
  return receipt;
929
943
  }
930
944
 
945
+ function cancelUnissuedLaunch(board, session) {
946
+ const result = sc(['teams', 'launches', 'cancel', session.launch_key, ...onMachine(session.machine)],
947
+ { timeout: 30_000, as: boardAddress(board.label) });
948
+ if (result.code !== 0) throw unavailable(`launch cancellation unavailable on ${session.machine}; retain ${session.launch_key}: ${(result.stderr || result.stdout).trim().slice(-300)}`);
949
+ const receipt = lastJson(result.stdout);
950
+ if (!receipt || receipt.key !== session.launch_key) throw unavailable(`invalid cancellation receipt for ${session.launch_key}`);
951
+ return receipt;
952
+ }
953
+
954
+ function unissuedLaunch(receipt, session) {
955
+ return receipt?.key === session?.launch_key && receipt?.status === 'cancelled' && receipt?.cancelled === true &&
956
+ receipt?.outcome === 'not_issued' && receipt?.live === false && receipt?.pane === null;
957
+ }
958
+
959
+ function retireUnissuedLaunch(board, row, session, receipt, { entry = null, effects = [] } = {}) {
960
+ if (!unissuedLaunch(receipt, session)) return false;
961
+ const result = tx(board, db => {
962
+ const current = db.prepare('SELECT ended_at, metadata FROM task_runs WHERE id=?').get(row.id);
963
+ const saved = json(current?.metadata, {})?.supercode;
964
+ if (!saved || saved.launch_key !== session.launch_key || saved.pane || saved.address || saved.pid) return { retired: false, effects: [] };
965
+ if (!current.ended_at && (!entry || getTask(db, row.task_id)?.current_run_id !== row.id)) return { retired: false, effects: [] };
966
+ if (saved.close_confirmed_at) return { retired: true, effects: [] };
967
+ const collected = [];
968
+ const at = now();
969
+ setRunSession(db, row.id, { launch_pending: false, launch_cancellation: receipt, launch_error: 'machine confirmed launch was not issued',
970
+ close_requested: saved.close_requested ?? at, closed_at: at, close_confirmed_at: at, close_error: null,
971
+ forgotten: saved.mode === 'fresh' ? true : Boolean(saved.forgotten) });
972
+ event(db, row.task_id, 'session_launch_cancelled', { key: session.launch_key, machine: session.machine, receipt }, row.id);
973
+ if (!current.ended_at) {
974
+ const moved = fire(db, board, entry, row.task_id, 'spawn_failed', { roles: ['platform'], payload: { error: `launch ${session.launch_key} was not issued; its machine retired the key` } });
975
+ collected.push(...moved.effects);
976
+ }
977
+ event(db, row.task_id, 'session_closed', { key: session.launch_key, machine: session.machine, reason: 'launch not issued', receipt }, row.id);
978
+ return { retired: true, effects: collected };
979
+ });
980
+ effects.push(...result.effects);
981
+ return result.retired;
982
+ }
983
+
931
984
  function bindLaunch(board, row, session, pane, sid) {
932
985
  return tx(board, (db) => {
933
986
  const current = db.prepare('SELECT ended_at, metadata FROM task_runs WHERE id=?').get(row.id);
@@ -989,13 +1042,22 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes
989
1042
  report.failed.push([id,error.message]);
990
1043
  }
991
1044
  }
992
- const rows = view(board, (db) => db.prepare(`SELECT id, task_id, ended_at, metadata FROM task_runs
1045
+ const rows = view(board, (db) => db.prepare(`SELECT id, task_id, started_at, ended_at, metadata FROM task_runs
993
1046
  WHERE json_extract(metadata, '$.supercode.launch_pending') = 1
994
1047
  AND json_extract(metadata, '$.supercode.close_confirmed_at') IS NULL`).all());
995
1048
  for (const row of rows) {
996
1049
  const s = json(row.metadata, {})?.supercode;
997
1050
  try {
998
- const receipt = launchReceipt(board, s);
1051
+ let receipt = launchReceipt(board, s);
1052
+ if (!receipt && (row.ended_at || now() - Number(s.launched_at ?? row.started_at) >= settings.launchWindow)) receipt = cancelUnissuedLaunch(board, s);
1053
+ if (unissuedLaunch(receipt, s)) {
1054
+ if (!retireUnissuedLaunch(board, row, s, receipt, { entry, effects })) report.failed.push([row.task_id, 'negative launch receipt conflicts with a bound session; retaining ownership']);
1055
+ continue;
1056
+ }
1057
+ if (receipt?.status === 'issued' || (receipt?.live === false && !receipt.status && !receipt.exit)) {
1058
+ report.queued.push([row.task_id, `launch ${s.launch_key} is issued or uncertain; retaining ownership`]);
1059
+ continue;
1060
+ }
999
1061
  if (!receipt?.pane) { report.queued.push([row.task_id, s.launch_error ?? `awaiting launch receipt ${s.launch_key}`]); continue; }
1000
1062
  const sid = s.session_id ?? fleet.sessions?.find((f) => f.pane === receipt.pane && String(f.address).startsWith(`sc:${s.machine}:${s.harness}:`))?.address.split(':')[3];
1001
1063
  bindLaunch(board, row, s, receipt.pane, receipt.live ? sid : null);
@@ -1350,23 +1412,31 @@ async function send(from, label, to, body, key, { queue = false, ask = false, su
1350
1412
  * (the id is the send's idempotency key). One that fails is tried again next tick.
1351
1413
  */
1352
1414
  export async function sendBoardMessages(board, report, taskId = null) {
1353
- const rows = view(board, (db) => db.prepare(`SELECT id, task_id, payload FROM task_events e WHERE kind = 'board_message' AND NOT EXISTS
1354
- (SELECT 1 FROM task_events s WHERE s.kind = 'board_message_sent' AND s.task_id = e.task_id AND json_extract(s.payload, '$.event') = e.id) ORDER BY id`).all());
1355
- for (const row of rows) {
1356
- if (taskId && row.task_id !== taskId) continue;
1357
- const m = json(row.payload, {}) ?? {};
1415
+ const pending = view(board, db => db.prepare(`SELECT 1 FROM task_events original WHERE kind='board_message'
1416
+ AND NOT EXISTS (SELECT 1 FROM task_events sent WHERE sent.kind='board_message_sent'
1417
+ AND sent.task_id=original.task_id AND json_extract(sent.payload,'$.event')=original.id) LIMIT 1`).get());
1418
+ if (!pending) return;
1419
+ const planned = tx(board, db => pendingRequestMail(db, taskId));
1420
+ if (Number.isFinite(planned.nextAt)) report.next_mail_at = Math.min(report.next_mail_at ?? Infinity, planned.nextAt);
1421
+ for (const mail of planned.ready) {
1422
+ const first = mail.members[0];
1358
1423
  try {
1359
- const task = view(board, db => getTask(db, row.task_id));
1360
- const receipt = await send(boardAddress(board.label), board.label, m.to, m.text, m.message_id ?? `board-message-${row.id}`, { ask: Boolean(m.message_id), subject: `${task?.title || row.task_id} · ${m.message_id ? 'Question' : 'Update'}` });
1424
+ const task = view(board, db => getTask(db, first.task_id));
1425
+ const subject = mail.requester ? `State requests · ${mail.members.length} request(s) · ${task?.title || first.task_id}` : `${task?.title || first.task_id} · ${mail.message_id ? 'Question' : 'Update'}`;
1426
+ const receipt = await send(boardAddress(board.label), board.label, mail.to, mail.text, mail.message_id ?? `board-message-${first.id}`, { ask: Boolean(mail.message_id), subject });
1361
1427
  if (!receipt.delivered) throw new Error(receipt.reason ?? 'queued; native transcript confirmation pending');
1362
- tx(board, (db) => event(db, row.task_id, 'board_message_sent', { event: row.id, to: m.to, receipt, ...(m.message_id ? { message_id: m.message_id } : {}) }));
1428
+ tx(board, db => {
1429
+ for (const member of mail.members) event(db, member.task_id, 'board_message_sent', { event: member.id, to: mail.to, receipt, ...(mail.message_id ? { message_id: mail.message_id, request_id: member.message_id ?? mail.message_id } : {}) });
1430
+ });
1363
1431
  } catch (error) {
1364
1432
  const reason = String(error.message).slice(-200);
1365
- report.undelivered.push([row.task_id, m.to, reason]);
1433
+ for (const member of mail.members) report.undelivered.push([member.task_id, mail.to, reason]);
1366
1434
  tx(board, db => {
1367
- const previous = db.prepare("SELECT payload,created_at FROM task_events WHERE task_id=? AND kind='board_message_failed' AND json_extract(payload,'$.event')=? ORDER BY id DESC LIMIT 1").get(row.task_id, row.id);
1368
- if (json(previous?.payload, {})?.reason !== reason || now() - (previous?.created_at ?? 0) >= 60)
1369
- event(db, row.task_id, 'board_message_failed', { event: row.id, to: m.to, message_id: m.message_id, reason });
1435
+ for (const member of mail.members) {
1436
+ const previous = db.prepare("SELECT payload,created_at FROM task_events WHERE task_id=? AND kind='board_message_failed' AND json_extract(payload,'$.event')=? ORDER BY id DESC LIMIT 1").get(member.task_id, member.id);
1437
+ if (json(previous?.payload, {})?.reason !== reason || now() - (previous?.created_at ?? 0) >= 60)
1438
+ event(db, member.task_id, 'board_message_failed', { event: member.id, to: mail.to, message_id: mail.message_id, reason });
1439
+ }
1370
1440
  });
1371
1441
  }
1372
1442
  }
@@ -1399,6 +1469,7 @@ function ensureSubscriptions(board, entry) {
1399
1469
  const managers = Array.isArray(entry.params.managers) ? entry.params.managers.map(String) : [];
1400
1470
  const toManagers = Object.keys(entry.workflow.notify?.manager ?? {}).length > 0;
1401
1471
  tx(board, (db) => {
1472
+ reconcileDependencySubscriptions(db);
1402
1473
  for (const t of db.prepare("SELECT id, current_run_id FROM tasks WHERE status NOT IN ('done', 'archived')").all()) {
1403
1474
  if (toManagers) for (const m of managers) subscribeIn(db, t.id, m, 'manager');
1404
1475
  if (!t.current_run_id) continue;
@@ -1418,6 +1489,7 @@ async function notify(board, fleet, report, entry) {
1418
1489
  const from = boardAddress(board.label);
1419
1490
  // each subscription's notices go in order; different subscribers are told side by side
1420
1491
  await pool(subscriptions(board.db).filter((sub) => sub.platform === 'supercode'), SEND_CONCURRENCY, async (sub) => {
1492
+ if (sub.delivery_metadata?.dependent_card && sub.delivery_metadata.waiting_recipient) return;
1421
1493
  const role = ['worker', 'dependency', 'manager'].includes(sub.delivery_metadata?.role) ? sub.delivery_metadata.role : 'creator';
1422
1494
  // a session the fleet does not list this tick is told next tick: one reading is not a loss (a loaded box drops
1423
1495
  // sessions from a read, and a dropped subscription never came back), and a session that is replaced on its card
@@ -1425,7 +1497,7 @@ async function notify(board, fleet, report, entry) {
1425
1497
  if (role !== 'creator' && fleet.ok && !fleet.alive.has(sub.chat_id)) return;
1426
1498
  const kinds = heardBy(entry, role);
1427
1499
  if (!kinds.length) return;
1428
- const events = board.db.prepare(`SELECT id, kind, payload, created_at FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id`)
1500
+ const events = board.db.prepare(`SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id`)
1429
1501
  .all(sub.task_id, sub.last_event_id, ...kinds);
1430
1502
  if (!events.length) return;
1431
1503
  // the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's)
@@ -1444,7 +1516,8 @@ async function notify(board, fleet, report, entry) {
1444
1516
  text = c ? noticeOf(entry, role, ev.kind, task, p, c) : null;
1445
1517
  } else text = noticeOf(entry, role, ev.kind, task, p);
1446
1518
  if (text) {
1447
- const receipt = await send(from, board.label, sub.chat_id, text, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { queue: !entry.workflow.run && sub.delivery_mode === 'notify', subject: `${task?.title || sub.task_id} · ${ev.kind.replaceAll('_', ' ')}` });
1519
+ const body = ['worker', 'dependency'].includes(role) ? identifyNotice(ev, text) : text;
1520
+ const receipt = await send(from, board.label, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { queue: !entry.workflow.run && sub.delivery_mode === 'notify', subject: `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}` });
1448
1521
  if (!receipt.delivered) { pending = Boolean(receipt.queued); throw new Error(receipt.reason ?? 'native delivery confirmation pending'); }
1449
1522
  tx(board, (db) => event(db, sub.task_id, 'notice_delivered', { source_event: ev.id, kind: ev.kind, address: sub.chat_id, receipt }));
1450
1523
  report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
@@ -1463,12 +1536,13 @@ async function notify(board, fleet, report, entry) {
1463
1536
  }
1464
1537
  // the cursor moves past what was delivered (a failed delivery is retried next tick; the mail id dedupes)
1465
1538
  tx(board, (db) => {
1466
- const where = "WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?";
1467
- db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND last_event_id < ?`).run(cursor, sub.task_id, sub.chat_id, cursor);
1539
+ const where = "WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?";
1540
+ const identity = [sub.task_id, sub.chat_id, sub.thread_id ?? ''];
1541
+ db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND last_event_id < ?`).run(cursor, ...identity, cursor);
1468
1542
  const failures = failed && !pending ? Number(sub.delivery_metadata?.failures ?? 0) + 1 : 0;
1469
- if (failures >= MAX_SEND_FAILURES) { db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(sub.task_id, sub.chat_id); return; }
1470
- if (failures !== Number(sub.delivery_metadata?.failures ?? 0)) db.prepare(`UPDATE kanban_notify_subs SET delivery_metadata = ? ${where}`).run(JSON.stringify({ ...sub.delivery_metadata, failures }), sub.task_id, sub.chat_id);
1471
- if (task?.status === 'archived' && cursor >= events.at(-1).id) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(sub.task_id, sub.chat_id);
1543
+ if (failures >= MAX_SEND_FAILURES) { db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity); return; }
1544
+ if (failures !== Number(sub.delivery_metadata?.failures ?? 0)) db.prepare(`UPDATE kanban_notify_subs SET delivery_metadata = ? ${where}`).run(JSON.stringify({ ...sub.delivery_metadata, failures }), ...identity);
1545
+ if (task?.status === 'archived' && cursor >= events.at(-1).id) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity);
1472
1546
  });
1473
1547
  });
1474
1548
  }
@@ -1703,6 +1777,42 @@ export async function plan({ root, lanes, settings, slugs = null, params = {} })
1703
1777
  return out;
1704
1778
  }
1705
1779
 
1780
+ /** An opened PR asks for the declared review transition once the arc's tasks are closed. */
1781
+ async function activatePullRequestReviews(board, entry, report, effects, { only = null } = {}) {
1782
+ const candidates = view(board, db => db.prepare(`SELECT t.id FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
1783
+ WHERE c.subtask_of IS NULL AND COALESCE(c.review_only,0)=0 AND c.every IS NULL
1784
+ AND t.workspace_kind!='scratch' ${only ? 'AND t.id=?' : ''}`).all(...(only ? [only] : [])));
1785
+ for (const { id } of candidates) {
1786
+ const eligible = db => {
1787
+ const task = getTask(db, id);
1788
+ if (!task) return false;
1789
+ const scope = scopeOf(db, board, task, { params: entry.params });
1790
+ return task && transitionsFor(entry.workflow, statusOf(db, task), 'request_review').some(t => !t.by || t.by.includes('platform')) &&
1791
+ !scope.card.dispatch_hold && !scope.card.paused && !scope.card.blocked_by.length &&
1792
+ scope.card.subtasks.every(t => ['done', 'cancelled'].includes(t.status));
1793
+ };
1794
+ if (!view(board, eligible)) continue;
1795
+ try {
1796
+ const payload = openReviewPullRequest(board, id);
1797
+ if (!payload) continue;
1798
+ const evidence = inspectCompletionEvidence(board, id, entry, payload, 'request_review');
1799
+ if (!evidence.ready) throw new Error(evidence.reason);
1800
+ refreshCardInputs(board, id, { commits: true });
1801
+ const moved = tx(board, db => {
1802
+ const task = getTask(db, id);
1803
+ if (!eligible(db)) return null;
1804
+ return withCompletionEvidence(board, id, evidence, () => fire(db, board, entry, id, 'request_review', {
1805
+ roles: ['platform'], payload: { ...payload, run_id: task.current_run_id, summary: `Opened PR ${payload.pr} at ${payload.pr_head}`, ci: evidence }
1806
+ }));
1807
+ });
1808
+ if (moved) {
1809
+ effects.push(...moved.effects);
1810
+ (report.review_requested ??= []).push({ id, pr: payload.pr, head: payload.pr_head });
1811
+ }
1812
+ } catch (error) { report.queued.push([id, `PR review waits: ${error.message}`]); }
1813
+ }
1814
+ }
1815
+
1706
1816
  /**
1707
1817
  * One tick over every board of the home at `root`. `lanes` from `lanesOf`, `settings` from `boardSettings`.
1708
1818
  * `only` limits starting to one card (a create that answers whether its card started). Each board is ticked under its
@@ -1750,6 +1860,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
1750
1860
  reconcileLaunches(board, fleet, report, entry, effects, settings, lanes); supervise(board, fleet, settings, report, entry, effects); await runEffects(board, effects.splice(0), { retryNotices: true }); closeRequested(board, report); phase('supervise');
1751
1861
  report.unsettled = [];
1752
1862
  report.promoted = recomputeReady(board, effects, report.unsettled);
1863
+ if (dispatch) await activatePullRequestReviews(board, entry, report, effects, { only });
1753
1864
  await sendBoardMessages(board, report);
1754
1865
  const before = report.started.length;
1755
1866
  if (dispatch) startCards(board, fleet, lanes, settings, report, entry, effects, { only, hostRunning });
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'];
@@ -343,11 +343,15 @@ export function setCaller(fn) { callerRoles = fn; }
343
343
 
344
344
  export async function act(board, id, name, { roles = null, payload = {}, extra = {} } = {}) {
345
345
  const entry = await loadWorkflow(board.root);
346
+ if (name === 'comment' && (!String(payload.body ?? '').trim() || !String(payload.author ?? '').trim())) throw new Error('a comment has an author and body');
346
347
  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) };
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.');
348
350
  const result = tx(board, (db) => {
349
351
  roles ??= callerRoles(db, id);
350
352
  const r = fire(db, board, entry, id, name, { roles, payload, extra });
353
+ // Authorization and the comment row share one transaction: a refusal stores nothing.
354
+ 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
355
  // the cards that wait on it, and its subtasks, see it change
352
356
  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
357
  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 +359,29 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
355
359
  return r;
356
360
  });
357
361
  await runEffects(board, result.effects);
358
- return { from: result.from, to: result.to, moved: result.moved };
362
+ return { from: result.from, to: result.to, moved: result.moved, ...(result.commentId ? { commentId: result.commentId } : {}) };
363
+ }
364
+
365
+ export async function requestStateMany(board, ids, payload = {}) {
366
+ const targets = [...new Set(ids.map(String))];
367
+ if (!targets.length) throw new Error('A state request names at least one card.');
368
+ const entry = await loadWorkflow(board.root);
369
+ const prepared = new Map(targets.map(id => {
370
+ const roles = callerRoles(board.db, id);
371
+ return [id, inspectCompletionEvidence(board, id, entry, payload, completionPhase(board, id, entry, 'request_state', payload, roles))];
372
+ }));
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
+ const result = tx(board, db => {
375
+ const effects = [];
376
+ const results = targets.map(id => withCompletionEvidence(board, id, prepared.get(id), () => {
377
+ const move = fire(db, board, entry, id, 'request_state', { roles: callerRoles(db, id), payload: { ...payload, ci: prepared.get(id) } });
378
+ effects.push(...move.effects);
379
+ return [id, move.to];
380
+ }));
381
+ return { results, effects };
382
+ });
383
+ await runEffects(board, result.effects);
384
+ return result.results;
359
385
  }
360
386
 
361
387
  /** Take card `id` to `status` by the event the workflow says goes there for this caller (`goto`). */
@@ -377,7 +403,7 @@ export async function goto(board, id, status, { roles = null, payload = {} } = {
377
403
  export async function runEffects(board, effects = [], { retryNotices = false } = {}) {
378
404
  const requested = new Set(effects.map((e) => e.durableId).filter(Boolean));
379
405
  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');
406
+ .filter((p) => retryNotices || requested.has(p.id));
381
407
  const durable = new Set(pending.map((p) => p.id));
382
408
  const combined = [...pending.map((p) => ({ ...json(p.effect, {}), durableId: p.id })), ...effects.filter((e) => !e.durableId || !durable.has(e.durableId))];
383
409
  combined.sort((a,b) => Number(b.kind === 'session_notice') - Number(a.kind === 'session_notice'));
@@ -453,14 +479,14 @@ function invalidateDescendants(db, board, id) {
453
479
 
454
480
  // each of Hermes's verbs is an event of the workflow; the words it takes are the event's payload
455
481
 
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 } });
482
+ 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 } = {}) =>
483
+ act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId, commit, ci_run, branch, pr, pr_head, reviewed_sha } });
458
484
  // 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
485
  export const block = (board, id, { reason = null, on = null, waitingOn = null, roles, runId = null } = {}) =>
460
486
  act(board, id, 'block', { roles, payload: { reason, on, waiting_on: waitingOn, run_id: runId } });
461
487
  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 } });
488
+ 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 } = {}) =>
489
+ act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId, commit, ci_run, branch, pr, pr_head } });
464
490
  export const requestChanges = (board, id, { reason = null, reviewer = null, roles } = {}) => act(board, id, 'request_changes', { roles, payload: { reason, reviewer } });
465
491
  export const reopen = (board, id, { author = 'user', reason = null, roles } = {}) => act(board, id, 'reopen', { roles, payload: { author, reason } });
466
492
  export const reopenReview = (board, id, { roles } = {}) => act(board, id, 'reopen_review', { roles });
@@ -470,11 +496,16 @@ export const reclaim = (board, id, reason = null, { roles } = {}) => act(board,
470
496
  export const archive = (board, id, { roles } = {}) => act(board, id, 'archive', { roles });
471
497
  export function specify(board, id, { title = null, body = null, workspace = null, branch = null, reviewOnly = null, author = 'user', roles } = {}) {
472
498
  let declaration = null;
473
- if (workspace != null) {
474
- const task = getTask(board.db, id), ws = parseWorkspace(workspace);
499
+ if (workspace != null || branch != null) {
500
+ const task = getTask(board.db, id);
501
+ const word = workspace ?? `${task.workspace_kind}${task.workspace_anchor || task.workspace_path ? `:${task.workspace_anchor ?? task.workspace_path}` : ''}`;
502
+ const ws = parseWorkspace(word);
475
503
  if (task.current_run_id) throw new Error('end the current run before changing its workspace');
504
+ if (branch != null && ws.kind === 'scratch') throw new Error('a scratch workspace has no repository branch');
476
505
  declaration = { kind: ws.kind, ...declareWorkspace(ws.kind, ws.path, task.machine, branch) };
506
+ if (declaration.repository === task.workspace_anchor) declaration.branch ??= task.branch_name;
477
507
  if (declaration.kind === 'worktree') declaration.branch ??= `wt/${id}`;
508
+ 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
509
  }
479
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.');
480
511
  return act(board, id, 'specify', { roles, payload: { title, body, author, ...(reviewOnly != null ? { review_only: reviewOnly } : {}), ...(declaration ? { workspace: declaration } : {}) } });
@@ -522,12 +553,8 @@ export async function unlink(board, parent, child, { roles } = {}) {
522
553
  export async function comment(board, id, author, body, { roles } = {}) {
523
554
  if (!String(body ?? '').trim()) throw new Error('a comment has a body');
524
555
  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;
556
+ const result = await act(board, id, 'comment', { roles, payload: { author: author.trim(), body } });
557
+ return result.commentId;
531
558
  }
532
559
 
533
560
  // ------------------------------------------------------------------ subscriptions
@@ -550,6 +577,47 @@ export function unsubscribe(board, id, address) {
550
577
  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
578
  }
552
579
 
580
+ function dependencyRecipient(db, id) {
581
+ const owner = must(db, id);
582
+ if (owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) return null;
583
+ const session = currentRun(db, id)?.metadata?.supercode ?? lastSession(db, id);
584
+ return session?.forgotten || session?.close_confirmed_at ? null : session?.address ?? null;
585
+ }
586
+
587
+ export function subscribeDependency(board, ownerId, sourceId, { remove = false } = {}) {
588
+ return tx(board, db => {
589
+ const roles = callerRoles(db, ownerId);
590
+ 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>.');
591
+ const owner = must(db, ownerId);
592
+ if (owner.subtask_of) throw new Error('The dependency subscription names its arc, not a subtask.');
593
+ must(db, sourceId);
594
+ const thread = `arc:${ownerId}`;
595
+ 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 };
596
+ const address = dependencyRecipient(db, ownerId);
597
+ if (!address) throw new Error('The subscribing arc has no current reachable session identity.');
598
+ 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);
599
+ const cursor = existing?.last_event_id ?? one(db, 'SELECT COALESCE(MAX(id),0) AS cursor FROM task_events WHERE task_id=?', sourceId).cursor;
600
+ 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);
601
+ 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',?,?,?)",
602
+ sourceId, address, thread, JSON.stringify({ role: 'dependency', dependent_card: ownerId }), now(), cursor);
603
+ event(db, ownerId, 'dependency_subscribed', { source: sourceId, address });
604
+ return { address, source: sourceId, owner: ownerId };
605
+ });
606
+ }
607
+
608
+ export function reconcileDependencySubscriptions(db) {
609
+ for (const row of all(db, "SELECT * FROM kanban_notify_subs WHERE platform='supercode' AND json_extract(delivery_metadata,'$.dependent_card') IS NOT NULL")) {
610
+ const metadata = json(row.delivery_metadata, {});
611
+ const owner = getTask(db, metadata.dependent_card);
612
+ const address = owner ? dependencyRecipient(db, owner.id) : null;
613
+ const where = "WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=?";
614
+ const identity = [row.task_id, row.chat_id, row.thread_id];
615
+ if (!owner || owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) run(db, `DELETE FROM kanban_notify_subs ${where}`, ...identity);
616
+ 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);
617
+ else if (!address && !metadata.waiting_recipient) run(db, `UPDATE kanban_notify_subs SET delivery_metadata=? ${where}`, JSON.stringify({ ...metadata, waiting_recipient: true }), ...identity);
618
+ }
619
+ }
620
+
553
621
  /** A child hears what its parents' subscribers hear (Hermes copies them at link time); a subtask its arc's session too. */
554
622
  function inheritSubscriptions(db, child, parents, { workers = false } = {}) {
555
623
  const cursor = one(db, 'SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?', child).c;
@@ -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/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
@@ -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': {