@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/ci.mjs +177 -108
- package/board/cli.mjs +58 -35
- package/board/dispatch.mjs +169 -43
- package/board/engine.mjs +130 -30
- package/board/notices.mjs +5 -0
- package/board/request-mail.mjs +59 -0
- package/board/store.mjs +6 -3
- package/board/tools.mjs +15 -9
- package/board/workflow.mjs +40 -32
- package/board/workspace.mjs +26 -0
- package/board/ztrack.mjs +781 -544
- package/hermes-locks.mjs +1 -20
- package/mcp/hermes-kanban-tools.json +76 -14
- package/package.json +2 -2
- package/board/rows.mjs +0 -132
package/board/dispatch.mjs
CHANGED
|
@@ -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 &&
|
|
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 &&
|
|
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
|
|
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 {
|
|
658
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
809
|
-
|
|
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
|
-
|
|
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
|
|
1354
|
-
(SELECT 1 FROM task_events
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
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,
|
|
1360
|
-
const
|
|
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,
|
|
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([
|
|
1441
|
+
for (const member of mail.members) report.undelivered.push([member.task_id, mail.to, reason]);
|
|
1366
1442
|
tx(board, db => {
|
|
1367
|
-
const
|
|
1368
|
-
|
|
1369
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 }),
|
|
1471
|
-
if (task?.status === 'archived' && cursor >= events.at(-1).id) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(
|
|
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 });
|