@volter/supercode-orchestrator 0.4.147 → 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.
- package/board/ci.mjs +104 -108
- package/board/cli.mjs +42 -17
- package/board/dispatch.mjs +141 -30
- package/board/engine.mjs +84 -16
- package/board/notices.mjs +5 -0
- package/board/request-mail.mjs +59 -0
- package/board/tools.mjs +13 -7
- package/board/workflow.mjs +20 -5
- package/board/ztrack.mjs +2 -2
- package/mcp/hermes-kanban-tools.json +50 -10
- package/package.json +2 -2
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, 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
|
|
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;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
1354
|
-
(SELECT 1 FROM task_events
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
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,
|
|
1360
|
-
const
|
|
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,
|
|
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([
|
|
1433
|
+
for (const member of mail.members) report.undelivered.push([member.task_id, mail.to, reason]);
|
|
1366
1434
|
tx(board, db => {
|
|
1367
|
-
const
|
|
1368
|
-
|
|
1369
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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)
|
|
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)
|
|
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
|
|
526
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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.
|
|
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': {
|