@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.
@@ -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, openReviewPullRequests, reviewDue, 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));
@@ -331,13 +333,13 @@ function supervise(board, fleet, settings, report, entry, effects) {
331
333
  return;
332
334
  }
333
335
  // a run past its runtime limit (the status's `run.limits.runtime`), as Hermes times its workers out
334
- if (limits.runtime && runRow && session?.wait_started_at == null && runWorkingSeconds(runRow, t) > Number(limits.runtime)) {
336
+ if (limits.runtime && runRow && runWorkingSeconds(runRow, t) > Number(limits.runtime)) {
335
337
  observe('timed_out', { limit_seconds: limits.runtime, error: `ran past max_runtime ${limits.runtime}s` }, 'timed_out');
336
338
  return;
337
339
  }
338
340
  if (session?.surface === 'headless') {
339
341
  // Hermes's stale check: a worker running past the limit with no heartbeat in the last hour is stopped
340
- if (limits.stale && runRow && session?.wait_started_at == null && runWorkingSeconds(runRow, t) >= Number(limits.stale) && !(task.last_heartbeat_at && t - Number(task.last_heartbeat_at) < 3600)) {
342
+ if (limits.stale && runRow && runWorkingSeconds(runRow, t) >= Number(limits.stale) && !(task.last_heartbeat_at && t - Number(task.last_heartbeat_at) < 3600)) {
341
343
  observe('stale', { timeout_seconds: limits.stale, pid: session.pid, error: `no heartbeat after ${t - Number(runRow.started_at)}s running` }, 'reclaimed');
342
344
  return;
343
345
  }
@@ -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;
@@ -679,7 +690,7 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
679
690
  if (reading.alive) {
680
691
  const protectedNow = active.some(r => addressOf(json(r.metadata, {})?.supercode) === addressOf(session));
681
692
  if (protectedNow) throw new Error('close deferred: native session is owned by an unended run');
682
- if (session.lifecycle_notice_pending) throw new Error(`close deferred: lifecycle notice ${session.lifecycle_notice_pending} has not been delivered`);
693
+ if (noticeHoldsClose(session, null)) throw new Error(`close deferred: lifecycle notice ${session.lifecycle_notice_pending} has not been filed`);
683
694
  stopSession(board, session);
684
695
  }
685
696
  } else {
@@ -692,7 +703,7 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
692
703
  if (observed.error) throw new Error(observed.error);
693
704
  if (observed.live.has(session.pane)) {
694
705
  const pendingNotice = view(board, (db) => db.prepare("SELECT id FROM supercode_effects WHERE json_extract(effect, '$.kind')='session_notice' AND json_extract(effect, '$.runId')=? LIMIT 1").get(row.id));
695
- if (pendingNotice || session.lifecycle_notice_pending) throw new Error(`close deferred: lifecycle notice ${pendingNotice?.id ?? session.lifecycle_notice_pending} has not been delivered: ${session.lifecycle_notice_error ?? 'pending'}`);
706
+ if (noticeHoldsClose(session, pendingNotice)) throw new Error(`close deferred: lifecycle notice ${pendingNotice?.id ?? session.lifecycle_notice_pending} has not been filed: ${session.lifecycle_notice_error ?? 'pending'}`);
696
707
  const protectedNow = view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)`).all())
697
708
  .some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); });
698
709
  if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
@@ -733,7 +744,6 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
733
744
  if (!(entry.workflow.statuses[status]?.on?.session_idle || entry.workflow.on?.session_idle || entry.workflow.run?.on?.session_idle)) return;
734
745
  const runRow = currentRun(db, id);
735
746
  const session = runRow?.metadata?.supercode;
736
- if (session?.wait_started_at != null) return;
737
747
  if (!session?.session_id || !['pane', 'native'].includes(session.surface) || session.offline_since != null) return;
738
748
  if (session.surface === 'native' && fleet.native?.get(addressOf(session))?.alive !== true) return;
739
749
  const live = fleet.sessions.find((x) => x.address === addressOf(session));
@@ -790,7 +800,10 @@ export async function sessionNotice(board, e) {
790
800
  const prior = view(board, (db) => json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId)?.metadata, {})?.supercode);
791
801
  if (prior?.close_confirmed_at) {
792
802
  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);
803
+ const unissued = unissuedLaunch(prior.launch_cancellation, prior);
804
+ event(db, e.id, unissued ? 'session_notice_not_required' : 'session_notice_failed', { address: e.address, verb: e.verb, notice: e.durableId,
805
+ error: unissued ? 'machine confirmed launch was not issued; no session exists to notify' : 'session closure already confirmed; notice cannot be delivered', retry: false,
806
+ ...(unissued ? { receipt: prior.launch_cancellation } : {}) }, e.runId);
794
807
  if (prior.lifecycle_notice_pending === e.durableId) setRunSession(db, e.runId, { lifecycle_notice_pending: null });
795
808
  });
796
809
  return true;
@@ -800,26 +813,31 @@ export async function sessionNotice(board, e) {
800
813
  try {
801
814
  if (!e.address) throw new Error('lifecycle notice awaits the owned launch session identity');
802
815
  receipt = await send(boardAddress(board.label), board.label, e.address, e.text, effectMessageKey(e));
803
- if (!receipt.delivered) error = receipt.reason ?? 'notice queued; native transcript confirmation pending'; }
816
+ // A notice filed in the session's mailbox is done: the session reads its mail at its next
817
+ // turn, and a resumed session first. Only a notice no mailbox took is retried.
818
+ if (!receipt.delivered && !receipt.queued) error = receipt.reason ?? 'notice was not filed'; }
804
819
  catch (failure) { error = String(failure.message).slice(-500); }
805
820
  tx(board, (db) => {
806
821
  const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
807
822
  if (session) setRunSession(db, e.runId, { ...session,
808
- ...(error ? { lifecycle_notice_error: error } : { lifecycle_notice_error: null, ...(session.lifecycle_notice_pending === e.durableId ? { lifecycle_notice_pending: null } : {}) }) });
809
- if (!error || session?.lifecycle_notice_error !== error) event(db, e.id, error ? (receipt?.queued ? 'session_notice_queued' : 'session_notice_failed') : 'session_notice_delivered', { address: e.address, verb: e.verb, source_event: e.sourceEvent, notice: e.durableId, receipt, ...(error ? { error, retry: true } : {}) }, e.runId);
823
+ ...(error ? { lifecycle_notice_error: error, lifecycle_notice_failures: Number(session.lifecycle_notice_failures ?? 0) + 1 }
824
+ : { lifecycle_notice_error: null, lifecycle_notice_failures: 0, ...(session.lifecycle_notice_pending === e.durableId ? { lifecycle_notice_pending: null } : {}) }) });
825
+ if (!error || session?.lifecycle_notice_error !== error) event(db, e.id, error ? 'session_notice_failed' : receipt?.delivered ? 'session_notice_delivered' : 'session_notice_queued', { address: e.address, verb: e.verb, source_event: e.sourceEvent, notice: e.durableId, receipt, ...(error ? { error, retry: true } : {}) }, e.runId);
810
826
  });
811
827
  return !error;
812
828
  }
813
829
 
830
+ // A close waits for its run's lifecycle notice to be filed, but not past this many failed
831
+ // attempts: an unreachable session must not hold its pane, and its slot's next launch, forever.
832
+ const NOTICE_ATTEMPTS_BEFORE_CLOSE = 3;
833
+ const noticeHoldsClose = (session, pending) => Boolean(pending || session.lifecycle_notice_pending)
834
+ && Number(session.lifecycle_notice_failures ?? 0) < NOTICE_ATTEMPTS_BEFORE_CLOSE;
835
+
814
836
  /** A session effect a committed move asked for: a message to the card's current actor, or closing a slot's pane. */
815
837
  export async function sessionEffect(board, e) {
816
838
  if (e.kind === 'send_to_actor') {
817
839
  const row = view(board, db => e.runId ? db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId) : null);
818
840
  const session = json(row?.metadata, {})?.supercode;
819
- if (session?.wait_started_at != null) {
820
- tx(board, db => event(db, e.id, 'actor_notice_suppressed', { notice: e.durableId, reason: 'run is waiting', run_id: e.runId }, e.runId));
821
- return true;
822
- }
823
841
  const address = addressOf(session);
824
842
  let receipt;
825
843
  try {
@@ -928,6 +946,45 @@ function launchReceipt(board, session) {
928
946
  return receipt;
929
947
  }
930
948
 
949
+ function cancelUnissuedLaunch(board, session) {
950
+ const result = sc(['teams', 'launches', 'cancel', session.launch_key, ...onMachine(session.machine)],
951
+ { timeout: 30_000, as: boardAddress(board.label) });
952
+ if (result.code !== 0) throw unavailable(`launch cancellation unavailable on ${session.machine}; retain ${session.launch_key}: ${(result.stderr || result.stdout).trim().slice(-300)}`);
953
+ const receipt = lastJson(result.stdout);
954
+ if (!receipt || receipt.key !== session.launch_key) throw unavailable(`invalid cancellation receipt for ${session.launch_key}`);
955
+ return receipt;
956
+ }
957
+
958
+ function unissuedLaunch(receipt, session) {
959
+ return receipt?.key === session?.launch_key && receipt?.status === 'cancelled' && receipt?.cancelled === true &&
960
+ receipt?.outcome === 'not_issued' && receipt?.live === false && receipt?.pane === null;
961
+ }
962
+
963
+ function retireUnissuedLaunch(board, row, session, receipt, { entry = null, effects = [] } = {}) {
964
+ if (!unissuedLaunch(receipt, session)) return false;
965
+ const result = tx(board, db => {
966
+ const current = db.prepare('SELECT ended_at, metadata FROM task_runs WHERE id=?').get(row.id);
967
+ const saved = json(current?.metadata, {})?.supercode;
968
+ if (!saved || saved.launch_key !== session.launch_key || saved.pane || saved.address || saved.pid) return { retired: false, effects: [] };
969
+ if (!current.ended_at && (!entry || getTask(db, row.task_id)?.current_run_id !== row.id)) return { retired: false, effects: [] };
970
+ if (saved.close_confirmed_at) return { retired: true, effects: [] };
971
+ const collected = [];
972
+ const at = now();
973
+ setRunSession(db, row.id, { launch_pending: false, launch_cancellation: receipt, launch_error: 'machine confirmed launch was not issued',
974
+ close_requested: saved.close_requested ?? at, closed_at: at, close_confirmed_at: at, close_error: null,
975
+ forgotten: saved.mode === 'fresh' ? true : Boolean(saved.forgotten) });
976
+ event(db, row.task_id, 'session_launch_cancelled', { key: session.launch_key, machine: session.machine, receipt }, row.id);
977
+ if (!current.ended_at) {
978
+ 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` } });
979
+ collected.push(...moved.effects);
980
+ }
981
+ event(db, row.task_id, 'session_closed', { key: session.launch_key, machine: session.machine, reason: 'launch not issued', receipt }, row.id);
982
+ return { retired: true, effects: collected };
983
+ });
984
+ effects.push(...result.effects);
985
+ return result.retired;
986
+ }
987
+
931
988
  function bindLaunch(board, row, session, pane, sid) {
932
989
  return tx(board, (db) => {
933
990
  const current = db.prepare('SELECT ended_at, metadata FROM task_runs WHERE id=?').get(row.id);
@@ -989,13 +1046,22 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes
989
1046
  report.failed.push([id,error.message]);
990
1047
  }
991
1048
  }
992
- const rows = view(board, (db) => db.prepare(`SELECT id, task_id, ended_at, metadata FROM task_runs
1049
+ const rows = view(board, (db) => db.prepare(`SELECT id, task_id, started_at, ended_at, metadata FROM task_runs
993
1050
  WHERE json_extract(metadata, '$.supercode.launch_pending') = 1
994
1051
  AND json_extract(metadata, '$.supercode.close_confirmed_at') IS NULL`).all());
995
1052
  for (const row of rows) {
996
1053
  const s = json(row.metadata, {})?.supercode;
997
1054
  try {
998
- const receipt = launchReceipt(board, s);
1055
+ let receipt = launchReceipt(board, s);
1056
+ if (!receipt && (row.ended_at || now() - Number(s.launched_at ?? row.started_at) >= settings.launchWindow)) receipt = cancelUnissuedLaunch(board, s);
1057
+ if (unissuedLaunch(receipt, s)) {
1058
+ if (!retireUnissuedLaunch(board, row, s, receipt, { entry, effects })) report.failed.push([row.task_id, 'negative launch receipt conflicts with a bound session; retaining ownership']);
1059
+ continue;
1060
+ }
1061
+ if (receipt?.status === 'issued' || (receipt?.live === false && !receipt.status && !receipt.exit)) {
1062
+ report.queued.push([row.task_id, `launch ${s.launch_key} is issued or uncertain; retaining ownership`]);
1063
+ continue;
1064
+ }
999
1065
  if (!receipt?.pane) { report.queued.push([row.task_id, s.launch_error ?? `awaiting launch receipt ${s.launch_key}`]); continue; }
1000
1066
  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
1067
  bindLaunch(board, row, s, receipt.pane, receipt.live ? sid : null);
@@ -1016,6 +1082,10 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes
1016
1082
 
1017
1083
  function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'worker', meta = {} } = {}) {
1018
1084
  const machine = normalMachine(task.machine);
1085
+ // Whether a board session opens a terminal window on its machine is the workflow's choice, stated on every launch;
1086
+ // no machine default decides it.
1087
+ const window = workflowFor(board.root).params.session_window;
1088
+ if (typeof window !== 'boolean') throw unavailable('the workflow declares no params.session_window (true or false): whether a board session opens a window on its machine is the workflow\'s choice');
1019
1089
  {
1020
1090
  const made = resolveLocalWorkspace(board, task);
1021
1091
  tx(board, (db) => {
@@ -1028,7 +1098,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
1028
1098
  if (lane.harness === 'codex' && lane.reasoning_effort) tail.push('-c', `model_reasoning_effort=${JSON.stringify(lane.reasoning_effort)}`);
1029
1099
  const harness = HARNESSES[lane.harness] ? lane.harness : 'claude-code';
1030
1100
  const how = HARNESSES[harness];
1031
- const base = ['open', '--new', how.program, '--detach', '--on', task.machine, '--cwd', task.workspace_path];
1101
+ const base = ['open', '--new', how.program, '--detach', `--window=${window}`, '--on', task.machine, '--cwd', task.workspace_path];
1032
1102
  // the slot's last session: kept when live, resumed when lost, never another slot's (a reviewer's is its own)
1033
1103
  // a close its session's own command asked for is carried out first, so its pane is gone before the slot's next
1034
1104
  // session starts (never two processes on one session)
@@ -1350,23 +1420,31 @@ async function send(from, label, to, body, key, { queue = false, ask = false, su
1350
1420
  * (the id is the send's idempotency key). One that fails is tried again next tick.
1351
1421
  */
1352
1422
  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, {}) ?? {};
1423
+ const pending = view(board, db => db.prepare(`SELECT 1 FROM task_events original WHERE kind='board_message'
1424
+ AND NOT EXISTS (SELECT 1 FROM task_events sent WHERE sent.kind='board_message_sent'
1425
+ AND sent.task_id=original.task_id AND json_extract(sent.payload,'$.event')=original.id) LIMIT 1`).get());
1426
+ if (!pending) return;
1427
+ const planned = tx(board, db => pendingRequestMail(db, taskId));
1428
+ if (Number.isFinite(planned.nextAt)) report.next_mail_at = Math.min(report.next_mail_at ?? Infinity, planned.nextAt);
1429
+ for (const mail of planned.ready) {
1430
+ const first = mail.members[0];
1358
1431
  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'}` });
1432
+ const task = view(board, db => getTask(db, first.task_id));
1433
+ 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'}`;
1434
+ 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
1435
  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 } : {}) }));
1436
+ tx(board, db => {
1437
+ 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 } : {}) });
1438
+ });
1363
1439
  } catch (error) {
1364
1440
  const reason = String(error.message).slice(-200);
1365
- report.undelivered.push([row.task_id, m.to, reason]);
1441
+ for (const member of mail.members) report.undelivered.push([member.task_id, mail.to, reason]);
1366
1442
  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 });
1443
+ for (const member of mail.members) {
1444
+ 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);
1445
+ if (json(previous?.payload, {})?.reason !== reason || now() - (previous?.created_at ?? 0) >= 60)
1446
+ event(db, member.task_id, 'board_message_failed', { event: member.id, to: mail.to, message_id: mail.message_id, reason });
1447
+ }
1370
1448
  });
1371
1449
  }
1372
1450
  }
@@ -1399,6 +1477,7 @@ function ensureSubscriptions(board, entry) {
1399
1477
  const managers = Array.isArray(entry.params.managers) ? entry.params.managers.map(String) : [];
1400
1478
  const toManagers = Object.keys(entry.workflow.notify?.manager ?? {}).length > 0;
1401
1479
  tx(board, (db) => {
1480
+ reconcileDependencySubscriptions(db);
1402
1481
  for (const t of db.prepare("SELECT id, current_run_id FROM tasks WHERE status NOT IN ('done', 'archived')").all()) {
1403
1482
  if (toManagers) for (const m of managers) subscribeIn(db, t.id, m, 'manager');
1404
1483
  if (!t.current_run_id) continue;
@@ -1418,6 +1497,7 @@ async function notify(board, fleet, report, entry) {
1418
1497
  const from = boardAddress(board.label);
1419
1498
  // each subscription's notices go in order; different subscribers are told side by side
1420
1499
  await pool(subscriptions(board.db).filter((sub) => sub.platform === 'supercode'), SEND_CONCURRENCY, async (sub) => {
1500
+ if (sub.delivery_metadata?.dependent_card && sub.delivery_metadata.waiting_recipient) return;
1421
1501
  const role = ['worker', 'dependency', 'manager'].includes(sub.delivery_metadata?.role) ? sub.delivery_metadata.role : 'creator';
1422
1502
  // a session the fleet does not list this tick is told next tick: one reading is not a loss (a loaded box drops
1423
1503
  // sessions from a read, and a dropped subscription never came back), and a session that is replaced on its card
@@ -1425,7 +1505,7 @@ async function notify(board, fleet, report, entry) {
1425
1505
  if (role !== 'creator' && fleet.ok && !fleet.alive.has(sub.chat_id)) return;
1426
1506
  const kinds = heardBy(entry, role);
1427
1507
  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`)
1508
+ 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
1509
  .all(sub.task_id, sub.last_event_id, ...kinds);
1430
1510
  if (!events.length) return;
1431
1511
  // the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's)
@@ -1444,7 +1524,8 @@ async function notify(board, fleet, report, entry) {
1444
1524
  text = c ? noticeOf(entry, role, ev.kind, task, p, c) : null;
1445
1525
  } else text = noticeOf(entry, role, ev.kind, task, p);
1446
1526
  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('_', ' ')}` });
1527
+ const body = ['worker', 'dependency'].includes(role) ? identifyNotice(ev, text) : text;
1528
+ 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
1529
  if (!receipt.delivered) { pending = Boolean(receipt.queued); throw new Error(receipt.reason ?? 'native delivery confirmation pending'); }
1449
1530
  tx(board, (db) => event(db, sub.task_id, 'notice_delivered', { source_event: ev.id, kind: ev.kind, address: sub.chat_id, receipt }));
1450
1531
  report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
@@ -1463,12 +1544,13 @@ async function notify(board, fleet, report, entry) {
1463
1544
  }
1464
1545
  // the cursor moves past what was delivered (a failed delivery is retried next tick; the mail id dedupes)
1465
1546
  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);
1547
+ const where = "WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?";
1548
+ const identity = [sub.task_id, sub.chat_id, sub.thread_id ?? ''];
1549
+ db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND last_event_id < ?`).run(cursor, ...identity, cursor);
1468
1550
  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);
1551
+ if (failures >= MAX_SEND_FAILURES) { db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity); return; }
1552
+ 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);
1553
+ if (task?.status === 'archived' && cursor >= events.at(-1).id) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity);
1472
1554
  });
1473
1555
  });
1474
1556
  }
@@ -1703,6 +1785,49 @@ export async function plan({ root, lanes, settings, slugs = null, params = {} })
1703
1785
  return out;
1704
1786
  }
1705
1787
 
1788
+ /** An opened PR asks for the declared review transition once the arc's tasks are closed. */
1789
+ // A task written in the card's own Tasks section that did not become a subtask holds its review too.
1790
+ async function activatePullRequestReviews(board, entry, report, effects, { only = null } = {}) {
1791
+ const candidates = view(board, db => db.prepare(`SELECT t.id FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
1792
+ WHERE c.subtask_of IS NULL AND c.every IS NULL
1793
+ AND t.workspace_kind!='scratch' ${only ? 'AND t.id=?' : ''}`).all(...(only ? [only] : [])));
1794
+ for (const { id } of candidates) {
1795
+ const eligible = db => {
1796
+ const task = getTask(db, id);
1797
+ if (!task) return false;
1798
+ const scope = scopeOf(db, board, task, { params: entry.params });
1799
+ if (!transitionsFor(entry.workflow, statusOf(db, task), 'request_review').some(t => !t.by || t.by.includes('platform')) ||
1800
+ scope.card.dispatch_hold || scope.card.paused || scope.card.blocked_by.length) return false;
1801
+ // The card's Tasks are its subtasks; an open one holds the review, and says so.
1802
+ const open = scope.card.subtasks.filter(t => !['done', 'cancelled'].includes(t.status));
1803
+ return open.length ? open : true;
1804
+ };
1805
+ const ready = view(board, eligible);
1806
+ if (!ready) continue;
1807
+ if (ready !== true) { report.queued.push([id, `review waits: ${ready.length} open task(s) in the card's Tasks: ${ready[0].id} ${ready[0].title}`]); continue; }
1808
+ try {
1809
+ const payload = openReviewPullRequests(board, id);
1810
+ // The arc's open PRs ask for one review only while some head has no verdict: after changes are
1811
+ // requested the next run is the implementer's, until a new head is pushed.
1812
+ if (!payload || !reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) continue;
1813
+ const evidence = inspectCompletionEvidence(board, id, entry, payload, 'request_review');
1814
+ if (!evidence.ready) throw new Error(evidence.reason);
1815
+ refreshCardInputs(board, id, { commits: true });
1816
+ const moved = tx(board, db => {
1817
+ const task = getTask(db, id);
1818
+ if (eligible(db) !== true) return null;
1819
+ return withCompletionEvidence(board, id, evidence, () => fire(db, board, entry, id, 'request_review', {
1820
+ roles: ['platform'], payload: { ...payload, run_id: task.current_run_id, summary: `Opened ${payload.pr.map((url, index) => `${url} at ${payload.pr_head[index]}`).join(', ')}`, ci: evidence }
1821
+ }));
1822
+ });
1823
+ if (moved) {
1824
+ effects.push(...moved.effects);
1825
+ (report.review_requested ??= []).push({ id, prs: payload.pr, heads: payload.pr_head });
1826
+ }
1827
+ } catch (error) { report.queued.push([id, `PR review waits: ${error.message}`]); }
1828
+ }
1829
+ }
1830
+
1706
1831
  /**
1707
1832
  * One tick over every board of the home at `root`. `lanes` from `lanesOf`, `settings` from `boardSettings`.
1708
1833
  * `only` limits starting to one card (a create that answers whether its card started). Each board is ticked under its
@@ -1750,6 +1875,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
1750
1875
  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
1876
  report.unsettled = [];
1752
1877
  report.promoted = recomputeReady(board, effects, report.unsettled);
1878
+ if (dispatch) await activatePullRequestReviews(board, entry, report, effects, { only });
1753
1879
  await sendBoardMessages(board, report);
1754
1880
  const before = report.started.length;
1755
1881
  if (dispatch) startCards(board, fleet, lanes, settings, report, entry, effects, { only, hostRunning });