@volter/supercode-orchestrator 0.5.228 → 0.5.229
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/README.md +18 -2
- package/activation.mjs +1 -1
- package/adapters/agent-mailbox.mjs +10 -0
- package/adapters/api-server.mjs +6 -0
- package/agents.mjs +149 -40
- package/apply/doors.mjs +3 -1
- package/bin/agent-channel.mjs +7 -2
- package/bin/connection-runner.mjs +60 -19
- package/bin/hermes-worker.mjs +2 -1
- package/bin/orchestrator.mjs +3 -3
- package/bin/runtime.mjs +3 -2
- package/board/agent-current.mjs +82 -0
- package/board/agent-declarations.mjs +10 -3
- package/board/args.mjs +137 -6
- package/board/call.mjs +16 -1
- package/board/caller-session.mjs +3 -1
- package/board/capacity.mjs +59 -45
- package/board/card-state.mjs +14 -5
- package/board/ci.mjs +521 -178
- package/board/cli.mjs +614 -209
- package/board/client.mjs +64 -21
- package/board/close-current.mjs +36 -0
- package/board/current-records.mjs +44 -0
- package/board/custody.mjs +2 -1
- package/board/dispatch.mjs +1973 -846
- package/board/doors.mjs +2 -2
- package/board/engine.mjs +623 -85
- package/board/event-stream.mjs +10 -5
- package/board/fence.mjs +2 -0
- package/board/files.mjs +93 -62
- package/board/generation-owner.mjs +188 -53
- package/board/git-document.mjs +3 -1
- package/board/github-rest.mjs +29 -7
- package/board/incidents.mjs +5 -17
- package/board/listing.mjs +14 -8
- package/board/local-admission.mjs +59 -0
- package/board/lock-holder.mjs +18 -6
- package/board/machine-fleet.mjs +66 -13
- package/board/owner-door-worker.mjs +3 -3
- package/board/owner-door.mjs +17 -7
- package/board/owner-health.mjs +12 -15
- package/board/owner-interface.mjs +6 -0
- package/board/publication-reader.mjs +50 -0
- package/board/published.mjs +40 -12
- package/board/queries.mjs +24 -4
- package/board/record-view.mjs +30 -3
- package/board/request-mail.mjs +37 -6
- package/board/resting-admission.mjs +318 -0
- package/board/resting-codec-qualifications.json +33 -0
- package/board/resting-protocol.mjs +71 -0
- package/board/resting-state-protocol.json +120 -0
- package/board/resting-state.mjs +113 -0
- package/board/runtime-door.mjs +2 -2
- package/board/session-continuity.mjs +127 -0
- package/board/session-work.mjs +25 -13
- package/board/standalone-declaration.mjs +24 -0
- package/board/storage-contract.mjs +129 -7
- package/board/store.mjs +73 -11
- package/board/tools.mjs +56 -13
- package/board/wake.mjs +18 -5
- package/board/workflow.mjs +284 -82
- package/board/workspace.mjs +86 -9
- package/board/ztrack.mjs +344 -91
- package/connection.mjs +72 -17
- package/gates.mjs +2 -1
- package/hand-off.mjs +4 -3
- package/hermes-cli.mjs +5 -3
- package/hermes-locks.mjs +5 -2
- package/hosted.mjs +2 -1
- package/index.d.ts +2 -0
- package/index.mjs +1 -1
- package/kanban-worker.mjs +63 -13
- package/loop.mjs +33 -0
- package/mcp/hermes-kanban-tools.json +109 -50
- package/mcp/server.mjs +7 -1
- package/ontology.d.ts +26 -0
- package/orchestration.mjs +15 -4
- package/package.json +19 -12
- package/session-recovery.mjs +2 -1
- package/worker-route.mjs +37 -19
package/board/engine.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { latestEvent, latestEventIdSql } from './queries.mjs';
|
|
2
|
+
import { stopWorkChild } from '@volter/supercode-harness-sdk/work-ledger';
|
|
1
3
|
import { allocateReadableId, cardReadableId, requireReadableId, validateReadableId } from './readable-id.mjs';
|
|
2
4
|
// The board's transitions (docs/architecture/orchestrator.md §2.9): what a card may do, written for supercode and
|
|
3
5
|
// kept to Hermes Kanban's lifecycle so the two read each other's boards. Statuses: triage · todo · scheduled ·
|
|
@@ -8,22 +10,28 @@ import { allocateReadableId, cardReadableId, requireReadableId, validateReadable
|
|
|
8
10
|
//
|
|
9
11
|
// Each function takes an open board (`store.mjs`) and runs in one write transaction. Nothing here launches,
|
|
10
12
|
// observes or sends: the dispatcher (`dispatch.mjs`) does, and records what it did through these.
|
|
11
|
-
import { current } from './call.mjs';
|
|
13
|
+
import { current, refuseUnreadManager } from './call.mjs';
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
import { redactValue } from '@volter/teams/redact';
|
|
16
|
+
import { backlogQualification, deadlineUntil } from './resting-state.mjs';
|
|
12
17
|
import { detachedCard, detachedSession, personCustody, personOf, sessionAddress, sessionOperation } from './custody.mjs';
|
|
13
18
|
import { executionOf, readingContext } from './session-work.mjs';
|
|
14
19
|
import { cardState, cardStateSql, holdDescription } from './card-state.mjs';
|
|
15
20
|
import { spawn, execFile } from 'node:child_process';
|
|
16
21
|
import { existsSync, rmSync } from 'node:fs';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
17
23
|
import { join, resolve } from 'node:path';
|
|
18
|
-
import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace, plainWorkspace } from './workspace.mjs';
|
|
24
|
+
import { checkWorkspace, assertWorkspaceOwner, cardWorktrees, declareWorkspace, isLocalWorkspace, plainWorkspace } from './workspace.mjs';
|
|
19
25
|
import { DatabaseSync } from 'node:sqlite';
|
|
20
26
|
import { CARD_VISIBILITY_SQL } from './visibility.mjs';
|
|
21
|
-
import { view, actorOf, principalOf, cardBranch, CARD_NUMBERS, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
|
|
22
|
-
import { arcRepositories, arcSources, assertSourceReceipt, completionPhase, outsideArcRepositories, inspectCompletionEvidence, observeCompletionEvidence, recordedArcRepositories, withCompletionEvidence } from './ci.mjs';
|
|
23
|
-
import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, reviewerOf, eventTo, fire, validateEvent, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, transitionsFor, workflowFor } from './workflow.mjs';
|
|
27
|
+
import { view, actorOf, setActor, principalOf, cardBranch, CARD_NUMBERS, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
|
|
28
|
+
import { preparePrEvidence, prDisposition, redeclaredSince, releaseReadback, arcRepositories, arcSources, assertSourceReceipt, completionPhase, outsideArcRepositories, inspectCompletionEvidence, observeCompletionEvidence, recordedArcRepositories, withCompletionEvidence, declaredPullRequests, candidatePullRequests, pendingReviewRequests, latestReviewRequest, openPrReviewFindings, prIdentity } from './ci.mjs';
|
|
29
|
+
import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, reviewerOf, eventTo, fire, validateEvent, loadWorkflow, setStatus, settle, settleExpiringFlags, startNew, statusOf, runEvent, transitionsFor, workflowFor, scopeOf, ownerRule, requestDetail } from './workflow.mjs';
|
|
24
30
|
import { refuseCredentialDump, withheldIfDump } from './credential-guard.mjs';
|
|
25
31
|
import { AGENT_KIND_LIST, compactCardAgentEvents } from './published.mjs';
|
|
26
32
|
|
|
33
|
+
const { toMdast } = createRequire(import.meta.url)('@volter/ztrack/preset-kit');
|
|
34
|
+
|
|
27
35
|
export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'paused', 'review', 'done', 'archived'];
|
|
28
36
|
export const WORKSPACE_KINDS = ['scratch', 'dir', 'worktree'];
|
|
29
37
|
/** A card blocked this many times in a row for the same kind goes to triage for a person (Hermes's limit). */
|
|
@@ -70,7 +78,7 @@ export function getTask(db, id) {
|
|
|
70
78
|
const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
|
|
71
79
|
if (!row) return null;
|
|
72
80
|
const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
|
|
73
|
-
return { ...row, number: numberOf(db, id), stage: ours?.stage ?? row.status, workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), detached: json(ours?.detached, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null,
|
|
81
|
+
return { ...row, number: numberOf(db, id), stage: ours?.stage ?? row.status, workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), backlog: json(ours?.backlog, null), detached: json(ours?.detached, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null,
|
|
74
82
|
tags: json(ours?.tags, null) ?? (row.tenant ? [row.tenant] : []), requested_by_unverified: json(ours?.requested_by_unverified, null) };
|
|
75
83
|
}
|
|
76
84
|
|
|
@@ -100,8 +108,23 @@ export function parseWorkspace(word = 'scratch') {
|
|
|
100
108
|
* seconds later. The wait runs in a detached shell, so the board's write lock is never held through it.
|
|
101
109
|
*/
|
|
102
110
|
function terminate(pid) {
|
|
103
|
-
try { process.kill(-pid, 'SIGTERM'); } catch { try { process.kill(pid, 'SIGTERM'); } catch { return; } }
|
|
104
|
-
|
|
111
|
+
try { stopWorkChild(pid, () => process.kill(-pid, 'SIGTERM'), { signal: 'SIGTERM' }); } catch { try { stopWorkChild(pid, () => process.kill(pid, 'SIGTERM'), { signal: 'SIGTERM' }); } catch { return; } }
|
|
112
|
+
stopWorkChild(pid, () => new Promise((accepted) => {
|
|
113
|
+
// Starting the delayed shell is not acceptance of its eventual signal. It
|
|
114
|
+
// reports only a successful kill; disappearing during the wait is a no-op.
|
|
115
|
+
const escalation = spawn('/bin/sh', ['-c', 'for i in 1 2 3 4 5 6 7 8 9 10; do kill -0 "$0" 2>/dev/null || exit 0; sleep 0.5; done; if kill -KILL -"$0" 2>/dev/null || kill -KILL "$0" 2>/dev/null; then printf "stopped\\n" >&3; else exit 1; fi', String(pid)], { detached: true, stdio: ['ignore', 'ignore', 'ignore', 'pipe'] });
|
|
116
|
+
let result = '';
|
|
117
|
+
escalation.stdio[3].on('data', (chunk) => {
|
|
118
|
+
if (result.length + chunk.length > 8) { accepted(false); return; }
|
|
119
|
+
result += chunk;
|
|
120
|
+
if (result === 'stopped\n') accepted(true);
|
|
121
|
+
});
|
|
122
|
+
escalation.stdio[3].once('error', () => accepted(false));
|
|
123
|
+
escalation.once('error', () => accepted(false));
|
|
124
|
+
escalation.once('close', () => accepted(false));
|
|
125
|
+
escalation.stdio[3].unref();
|
|
126
|
+
escalation.unref();
|
|
127
|
+
}), { signal: 'SIGKILL' });
|
|
105
128
|
}
|
|
106
129
|
|
|
107
130
|
/** Close the card's open run; answers its id, or null when none was open. */
|
|
@@ -155,7 +178,7 @@ export function lastSession(db, taskId, slot = 'worker') {
|
|
|
155
178
|
// a session slot's own last session (runs recorded before slots existed are the worker's)
|
|
156
179
|
for (const row of all(db, 'SELECT metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC', taskId)) {
|
|
157
180
|
const s = json(row.metadata, {})?.supercode;
|
|
158
|
-
if (s?.session_id && (s.slot ?? 'worker') === slot) return detachedSession(db, s) ? null : s;
|
|
181
|
+
if (s?.session_id && !(s.launch_not_issued && s.mode === 'fresh' && s.close_confirmed_at) && (s.slot ?? 'worker') === slot) return detachedSession(db, s) ? null : s;
|
|
159
182
|
}
|
|
160
183
|
return null;
|
|
161
184
|
}
|
|
@@ -194,17 +217,27 @@ function projectOf(root, idOrSlug) {
|
|
|
194
217
|
* its subtasks, and leave the body. A Tasks section with no task line is the body's own text.
|
|
195
218
|
*/
|
|
196
219
|
export function bodyTasks(body) {
|
|
197
|
-
const
|
|
198
|
-
|
|
220
|
+
const text = String(body ?? ''), lines = text.split('\n'), literal = new Set();
|
|
221
|
+
// Section/filed-task syntax belongs to prose. Rent CommonMark's literal ranges so fenced or indented examples
|
|
222
|
+
// and HTML blocks keep their text, including headings and checkbox lines that only look like filing instructions.
|
|
223
|
+
const walk = node => {
|
|
224
|
+
if ((node.type === 'code' || node.type === 'html') && node.position) {
|
|
225
|
+
for (let i = node.position.start.line - 1; i < node.position.end.line; i++) literal.add(i);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
for (const child of node.children ?? []) walk(child);
|
|
229
|
+
};
|
|
230
|
+
walk(toMdast(text));
|
|
231
|
+
const at = lines.findIndex((line, i) => !literal.has(i) && /^#{2,3}\s+Tasks\s*$/i.test(line));
|
|
199
232
|
if (at < 0) return { body, tasks: [] };
|
|
200
|
-
let end = lines.findIndex((line, i) => i > at && /^#{1,3}\s/.test(line));
|
|
233
|
+
let end = lines.findIndex((line, i) => i > at && !literal.has(i) && /^#{1,3}\s/.test(line));
|
|
201
234
|
if (end < 0) end = lines.length;
|
|
202
235
|
const section = lines.slice(at + 1, end);
|
|
203
|
-
if (!section.some((line) => /^[-*] \[[ xX]\]/.test(line))) return { body, tasks: [] };
|
|
236
|
+
if (!section.some((line, i) => !literal.has(at + 1 + i) && /^[-*] \[[ xX]\]/.test(line))) return { body, tasks: [] };
|
|
204
237
|
const tasks = [];
|
|
205
|
-
for (const line of section) {
|
|
238
|
+
for (const [i, line] of section.entries()) {
|
|
206
239
|
if (!line.trim()) continue;
|
|
207
|
-
const step = /^[-*] \[ \]\s+(\S.*)$/.exec(line);
|
|
240
|
+
const step = !literal.has(at + 1 + i) && /^[-*] \[ \]\s+(\S.*)$/.exec(line);
|
|
208
241
|
if (step) tasks.push({ title: step[1].trim(), body: [] });
|
|
209
242
|
else if (/^\s+\S/.test(line) && tasks.length) tasks.at(-1).body.push(line.replace(/^\s{1,4}/, ''));
|
|
210
243
|
else throw new Error("A card's Tasks section holds open task lines ('- [ ] <step>', each filed as a subtask, with its own indented lines); record finished work as a comment and move other text out of the section");
|
|
@@ -276,7 +309,7 @@ export function createCard(board, spec) {
|
|
|
276
309
|
throw new Error('A card reviewer names a runnable profile in this home.');
|
|
277
310
|
}
|
|
278
311
|
if (spec.subtaskOf && !arc) throw new Error(`no card ${spec.subtaskOf} to be a subtask of`);
|
|
279
|
-
if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of};
|
|
312
|
+
if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; subtasks are one level deep, so file it with --subtask-of ${arc.subtask_of}`);
|
|
280
313
|
let id = newTaskId();
|
|
281
314
|
while (one(db, 'SELECT 1 FROM tasks WHERE id = ?', id)) id = newTaskId();
|
|
282
315
|
// where a card's blocks are relations, a card created blocked names what it waits on: one that names nothing is
|
|
@@ -441,22 +474,96 @@ export function extendClaim(db, id, { ttl, reason, extra = {} }) {
|
|
|
441
474
|
}
|
|
442
475
|
|
|
443
476
|
/**
|
|
444
|
-
* A
|
|
445
|
-
*
|
|
446
|
-
* it, never a `dir`.
|
|
477
|
+
* A closed card's workspace goes, as Hermes cleans up on completion: the solo worktree the board made for it (its
|
|
478
|
+
* branch stays), and with `scratch` a scratch directory under the board's own workspaces; never while a child still
|
|
479
|
+
* waits on it or another open card works in it, never a `dir`. A close the board queues on its own (retire_workspace)
|
|
480
|
+
* retires only the worktree; a scratch folder goes where a workflow declares cleanup_workspace, or at gc. The worktree
|
|
481
|
+
* goes only through its machine's git, and only when that loses nothing (retireWorktree): its branch merged into origin's
|
|
482
|
+
* default branch (by ancestry, `git cherry`, a squash reading, or within a head the card's or its arc's
|
|
483
|
+
* `reviewed_merged` merged), its tree clean apart from ignored files, and no repository nested inside it. One that is
|
|
484
|
+
* not is kept, and the card says once which path holds what. Answers whether a worktree was removed.
|
|
447
485
|
*/
|
|
448
|
-
async function cleanupWorkspace(board, id) {
|
|
486
|
+
export async function cleanupWorkspace(board, id, { scratch = true } = {}) {
|
|
449
487
|
const task = view(board, (db) => getTask(db, id));
|
|
450
|
-
if (!task
|
|
451
|
-
|
|
488
|
+
if (!task || !(scratch ? ['scratch', 'worktree'] : ['worktree']).includes(task.workspace_kind)) return;
|
|
489
|
+
// a card reopened before its cleanup ran keeps its workspace
|
|
490
|
+
if (!['done', 'cancelled', 'archived'].includes(task.status)) return;
|
|
491
|
+
const waiting = view(board, (db) => one(db, "SELECT 1 FROM task_links l JOIN tasks t ON t.id = l.child_id WHERE l.parent_id = ? AND t.status NOT IN ('done', 'archived', 'cancelled') LIMIT 1", id)
|
|
452
492
|
?? one(db, "SELECT 1 FROM tasks WHERE workspace_path = ? AND id <> ? AND status NOT IN ('done', 'archived', 'cancelled') LIMIT 1", task.workspace_path, id));
|
|
453
|
-
if (waiting
|
|
454
|
-
|
|
455
|
-
|
|
493
|
+
if (waiting) return;
|
|
494
|
+
if (task.workspace_kind === 'worktree') {
|
|
495
|
+
let removed = false;
|
|
496
|
+
for (const allocation of view(board, db => cardWorktrees(db, task))) {
|
|
497
|
+
const shared = view(board, db => one(db, "SELECT 1 FROM tasks WHERE workspace_path=? AND id<>? AND status NOT IN ('done','archived','cancelled') LIMIT 1", allocation.path, id));
|
|
498
|
+
if (shared) continue;
|
|
499
|
+
removed = await retireCardWorktreeOf(board, { ...task, machine: allocation.machine, workspace_path: allocation.path, branch_name: allocation.branch }) || removed;
|
|
500
|
+
}
|
|
501
|
+
return removed;
|
|
502
|
+
}
|
|
503
|
+
if (!task.workspace_path) return;
|
|
504
|
+
if (!isLocalWorkspace(task) || !existsSync(task.workspace_path)) return;
|
|
456
505
|
const managed = resolve(board.slug === 'default' ? join(board.root, 'kanban', 'workspaces') : join(board.root, 'kanban', 'boards', board.slug, 'workspaces'));
|
|
457
506
|
if (resolve(task.workspace_path).startsWith(`${managed}/`)) rmSync(task.workspace_path, { recursive: true, force: true });
|
|
458
507
|
}
|
|
459
508
|
|
|
509
|
+
/** Card `task`'s worktree retired on its machine (cleanupWorkspace), once the card's sessions are closed and no live
|
|
510
|
+
* pane there works in it: `worktree_removed` when it went, else the card's comment naming the path, its branch's
|
|
511
|
+
* unmerged commits by subject and its changed files by count (or why the folder is never removed), written once for each
|
|
512
|
+
* such reading. A session is closed when its record says so or its machine reads its pane and process gone (one held
|
|
513
|
+
* after its run, monitored, or closed by hand). Throws while a session may still work there or the machine could not
|
|
514
|
+
* answer, so the effect is tried again. A recorded merge counts only where it merged (`merge`), never a release-only
|
|
515
|
+
* settlement. Answers whether it removed the worktree. */
|
|
516
|
+
async function retireCardWorktreeOf(board, task) {
|
|
517
|
+
const { panesUnder, sessionGone } = await import('./dispatch.mjs');
|
|
518
|
+
const { retireWorktree, worktreePresent } = await import('./workspace.mjs');
|
|
519
|
+
// a folder already gone is nothing to read panes or sessions for; it is recorded so, once, and gc reads it no more
|
|
520
|
+
const gone = () => tx(board, (db) => {
|
|
521
|
+
const last = latestEvent(db, task.id, ['worktree_kept', 'worktree_removed'], 'kind', "json_extract(payload,'$.path')=?", [task.workspace_path]);
|
|
522
|
+
if (last?.kind !== 'worktree_removed') event(db, task.id, 'worktree_removed', { path: task.workspace_path, gone: true });
|
|
523
|
+
return false;
|
|
524
|
+
});
|
|
525
|
+
const resolved = await worktreePresent(task.machine, task.workspace_path);
|
|
526
|
+
if (!resolved) return gone();
|
|
527
|
+
const recorded = view(board, (db) => {
|
|
528
|
+
if (getTask(db, task.id)?.current_run_id) return { current: true, open: [] };
|
|
529
|
+
const latest = new Map();
|
|
530
|
+
for (const row of db.prepare('SELECT metadata FROM task_runs WHERE task_id = ? ORDER BY id DESC').all(task.id)) {
|
|
531
|
+
const s = json(row.metadata, {})?.supercode;
|
|
532
|
+
if (s && !latest.has(s.slot ?? 'worker')) latest.set(s.slot ?? 'worker', s);
|
|
533
|
+
}
|
|
534
|
+
return { current: false, open: [...latest.values()].filter((s) => (s.pane || s.session_id || s.pid || s.launch_key) && !(s.close_confirmed_at || s.close_transferred_to || s.forgotten || s.detached_at)) };
|
|
535
|
+
});
|
|
536
|
+
if (recorded.current) throw new Error(`${task.id}: worktree ${task.workspace_path} waits: its run is still current`);
|
|
537
|
+
const live = [];
|
|
538
|
+
for (const s of recorded.open) if (!(await sessionGone(s))) live.push(s.address ?? s.pane ?? s.session_id ?? s.pid);
|
|
539
|
+
if (live.length) throw new Error(`${task.id}: worktree ${task.workspace_path} waits: its session ${live.join(', ')} is not closed`);
|
|
540
|
+
const panes = await panesUnder(task.machine, task.workspace_path, [resolved]);
|
|
541
|
+
if (panes.length) throw new Error(`${task.id}: worktree ${task.workspace_path} waits: live pane ${panes.join(', ')} works in it`);
|
|
542
|
+
const merged = view(board, (db) => db.prepare("SELECT payload FROM task_events WHERE task_id IN (?, ?) AND kind = 'reviewed_merged'").all(task.id, task.subtask_of ?? task.id))
|
|
543
|
+
.flatMap((row) => json(row.payload, {})?.merges ?? []).filter((m) => m?.merge && !m.release_only).map((m) => m.reviewed)
|
|
544
|
+
.filter((sha) => typeof sha === 'string' && /^[0-9a-f]{40}$/.test(sha));
|
|
545
|
+
// reopened while its machine was read: it keeps its worktree
|
|
546
|
+
if (!['done', 'cancelled', 'archived'].includes(view(board, (db) => getTask(db, task.id))?.status)) return false;
|
|
547
|
+
const answer = await retireWorktree(task.machine, task.workspace_path, { merged: [...new Set(merged)], branch: task.branch_name ?? null });
|
|
548
|
+
if (answer.gone) return gone();
|
|
549
|
+
const held = { path: answer.path, branch: answer.branch ?? null, base: answer.base ?? null, unmerged: answer.unmerged ?? [], modified: answer.modified ?? 0, untracked: answer.untracked ?? 0, reason: answer.reason ?? null, refused: Boolean(answer.refused) };
|
|
550
|
+
tx(board, (db) => {
|
|
551
|
+
if (answer.removed) { event(db, task.id, 'worktree_removed', { path: held.path, branch: held.branch, base: held.base }); return; }
|
|
552
|
+
const previous = latestEvent(db, task.id, ['worktree_kept', 'worktree_removed'], 'kind,payload', "json_extract(payload,'$.path')=?", [task.workspace_path]);
|
|
553
|
+
const last = json(previous?.payload, null);
|
|
554
|
+
if (previous?.kind === 'worktree_kept' && last && Object.keys(held).every((key) => JSON.stringify(last[key]) === JSON.stringify(held[key]))) return;
|
|
555
|
+
event(db, task.id, 'worktree_kept', held);
|
|
556
|
+
const shown = held.unmerged.slice(0, 20).map((subject) => `\n- ${subject}`).join('') + (held.unmerged.length > 20 ? `\n- … and ${held.unmerged.length - 20} more` : '');
|
|
557
|
+
const body = `Worktree ${held.path} is kept${task.machine ? ` on ${task.machine}` : ''}: ${held.reason}.`
|
|
558
|
+
+ (held.branch ? ` Branch ${held.branch}${held.base ? ` against origin/${held.base}` : ''}.` : '')
|
|
559
|
+
+ (held.unmerged.length ? ` Unmerged commits:${shown}` : '')
|
|
560
|
+
+ (held.modified || held.untracked ? `${held.unmerged.length ? '\n' : ' '}Files: ${held.modified} tracked change(s), ${held.untracked} untracked.` : '');
|
|
561
|
+
event(db, task.id, 'commented', { author: 'board', len: body.length, by: 'board' });
|
|
562
|
+
run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', task.id, 'board', body, now());
|
|
563
|
+
});
|
|
564
|
+
return Boolean(answer.removed);
|
|
565
|
+
}
|
|
566
|
+
|
|
460
567
|
|
|
461
568
|
|
|
462
569
|
|
|
@@ -475,40 +582,239 @@ async function cleanupWorkspace(board, id) {
|
|
|
475
582
|
* Send card `id` the event `name` (the board's workflow decides what it does: `workflow.mjs`), then settle the cards
|
|
476
583
|
* that wait on it (Hermes's recompute_ready) and run what the move asked for once it is committed. `roles` are who
|
|
477
584
|
* sends it: `manager` for a person or the fleet's manager at the CLI, the card's own role for its session,
|
|
478
|
-
* `platform` for the dispatcher. Answers `{ from, to, moved }
|
|
585
|
+
* `platform` for the dispatcher. Answers `{ from, to, moved }` or a retained manager request.
|
|
479
586
|
*/
|
|
480
587
|
// who is sending, when a caller does not say: the CLI answers with the caller's role on the card (setCaller)
|
|
481
588
|
/** How the engine learns who a caller is on a card: `fn(db, cardId)` answers the caller's roles there (the call's own). */
|
|
589
|
+
const preparedPrEvidence = new WeakSet();
|
|
590
|
+
const completionEvidenceRefusal = (evidence, fallback) => Object.assign(new Error(evidence.reason ?? fallback),
|
|
591
|
+
{ boardDetail: true }, Object.fromEntries(['code', 'retryable', 'deferred', 'deferred_code', 'retry_at', 'refusals']
|
|
592
|
+
.filter(key => Object.hasOwn(evidence, key)).map(key => [key, evidence[key]])));
|
|
482
593
|
export function setCaller(fn) { current().roles = fn; }
|
|
483
594
|
const callerRoles = (db, cardId) => current().roles(db, cardId);
|
|
595
|
+
const withhold = value => typeof value === 'string' ? withheldIfDump(value)
|
|
596
|
+
: Array.isArray(value) ? value.map(withhold)
|
|
597
|
+
: value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, withhold(item)])) : value;
|
|
598
|
+
|
|
599
|
+
/** Record an incomplete or already satisfied ordinary act without claiming a transition or asking for permission. */
|
|
600
|
+
export async function acceptRequest(board, { id = null, name, payload = {}, reason = null } = {}) {
|
|
601
|
+
const review = ['request_review', 'request-review', 'kanban_request_review'].includes(name);
|
|
602
|
+
const safe = redactValue({ requested: name, payload: withhold(payload), reason: String(reason?.message ?? reason ?? 'Recorded with the detail supplied.') });
|
|
603
|
+
const receipt = tx(board, db => {
|
|
604
|
+
const task = id ? getTask(db, id) : null, from = task ? statusOf(db, task) : null;
|
|
605
|
+
const queued = payload.queued_event ?? payload.queuedEvent;
|
|
606
|
+
if (review && queued != null) {
|
|
607
|
+
const pending = pendingReviewRequests(db, id).find(request => request.id === Number(queued));
|
|
608
|
+
if (pending) return { queued: true, disposition: 'pending', requested: name, id, pr: pending.pr, pr_head: pending.pr_head,
|
|
609
|
+
from, to: from, moved: false, pending: true, already: true, event: pending.id, queue_event: pending.id, reason: safe.reason,
|
|
610
|
+
...(Array.isArray(reason?.refusals) ? { refusals: reason.refusals } : {}), ...(reason?.retry_at ? { retry_at: reason.retry_at } : {}) };
|
|
611
|
+
}
|
|
612
|
+
if (review) {
|
|
613
|
+
const detail = { ...safe, reason: `Nothing was queued. ${safe.reason}` };
|
|
614
|
+
const recorded = event(db, id == null ? '' : String(id), 'review_not_queued', detail, null);
|
|
615
|
+
return { ok: false, queued: false, disposition: 'not_queued', requested: name, from, to: from, moved: false, event: recorded, reason: detail.reason };
|
|
616
|
+
}
|
|
617
|
+
const recorded = queued ?? event(db, id == null ? '' : String(id), 'request_accepted', safe, null);
|
|
618
|
+
return { accepted: true, disposition: queued != null ? 'pending' : 'accepted', requested: name, from, to: from, moved: false, needs_detail: true, ...(queued != null ? { pending: true } : {}), event: recorded, reason: safe.reason };
|
|
619
|
+
});
|
|
620
|
+
(receipt.queued ? current().queuedRequests ??= [] : review ? current().unqueuedReviews ??= [] : current().acceptedRequests ??= []).push(receipt);
|
|
621
|
+
return receipt;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Retain the requested act for the configured manager without borrowing that manager's authority. A caller that
|
|
625
|
+
* might be that manager, unread this call (call.mjs refuseUnreadManager), gets the identity error instead. */
|
|
626
|
+
export async function routeRequest(board, { id = null, name, payload = {}, reason = null } = {}) {
|
|
627
|
+
refuseUnreadManager();
|
|
628
|
+
const entry = await loadWorkflow(board.root);
|
|
629
|
+
const manager = [].concat(entry.params?.managers ?? []).find(Boolean) ?? 'manager';
|
|
630
|
+
const requester = actorOf() ?? current().caller ?? principalOf() ?? 'board caller';
|
|
631
|
+
const messageId = `m-${randomBytes(12).toString('hex')}`;
|
|
632
|
+
const safe = redactValue({ requested: name, payload: withhold(payload), reason: String(reason?.message ?? reason ?? 'This act needs the manager.'), requester });
|
|
633
|
+
const verb = String(name ?? '').replace(/^kanban_/, '').replaceAll('-', '_');
|
|
634
|
+
const state = ({ complete: 'done', cancel: 'cancelled', archive: 'cancelled' })[verb] ?? payload.state ?? verb;
|
|
635
|
+
const rule = entry.params?.request_rules?.[state];
|
|
636
|
+
const denial = entry.params?.request_denial;
|
|
637
|
+
const receipt = tx(board, db => {
|
|
638
|
+
const task = id ? getTask(db, id) : null;
|
|
639
|
+
const from = task ? statusOf(db, task) : null;
|
|
640
|
+
const taskId = id == null ? '' : String(id);
|
|
641
|
+
const queued = payload.queued_event ?? payload.queuedEvent;
|
|
642
|
+
if (queued != null) {
|
|
643
|
+
const previous = one(db, "SELECT payload FROM task_events WHERE task_id=? AND kind='request_routed' AND COALESCE(json_extract(payload,'$.payload.queued_event'),json_extract(payload,'$.payload.queuedEvent'))=? ORDER BY id DESC LIMIT 1", taskId, queued);
|
|
644
|
+
const prior = json(previous?.payload, null);
|
|
645
|
+
if (prior) return { routed: true, disposition: 'routed', requested: name, manager: prior.manager, message_id: prior.message_id, from, to: from, moved: false, reason: prior.reason, already: true };
|
|
646
|
+
}
|
|
647
|
+
event(db, taskId, 'request_routed', { ...safe, manager, message_id: messageId }, null);
|
|
648
|
+
if (manager) event(db, taskId, 'board_message', { to: manager, message_id: messageId,
|
|
649
|
+
text: `${taskId || 'Board'}: ${requester} requested ${String(name ?? 'an act').replaceAll('_', '-')}.\n${safe.reason}\nOriginal request: ${JSON.stringify(safe.payload)}\nCarry out the request through its ordinary board door, applying any decision it needs.\n${rule ? `\n${rule}\n${denial ?? ''}\n` : ''}\nAnswer with reply_to ${messageId}.` }, null);
|
|
650
|
+
return { routed: true, disposition: 'routed', requested: name, manager, message_id: messageId, from, to: from, moved: false, reason: safe.reason };
|
|
651
|
+
});
|
|
652
|
+
(current().routedRequests ??= []).push(receipt);
|
|
653
|
+
// board_message is the existing durable mail queue. Its delivery pass retries a failed filing.
|
|
654
|
+
return receipt;
|
|
655
|
+
}
|
|
484
656
|
|
|
485
657
|
/**
|
|
486
|
-
*
|
|
487
|
-
* `instead(db, error)`, when
|
|
488
|
-
* name to fire in its place (read and decided against the same state the refusal was), or null to keep the refusal.
|
|
658
|
+
* Apply `name` as the real caller, supplying ordinary missing defaults. A request that needs another decision is
|
|
659
|
+
* retained for the manager. `instead(db, error)`, when present, selects a workflow alternative in the same write.
|
|
489
660
|
*/
|
|
490
661
|
export async function act(board, id, name, { roles = null, payload = {}, extra = {}, instead = null } = {}) {
|
|
662
|
+
const original = payload;
|
|
663
|
+
try {
|
|
664
|
+
const sender = roles ?? callerRoles(board.db, id);
|
|
665
|
+
let backlogEvent = null;
|
|
666
|
+
if ((Object.hasOwn(payload, 'reason') || name === 'backlog') && !String(payload.reason ?? '').trim()) payload = { ...payload, reason: 'none given' };
|
|
667
|
+
if (name === 'pause' && payload.until == null) payload = { ...payload, until: '1h' };
|
|
668
|
+
if (name === 'unblock' && !payload.on) {
|
|
669
|
+
// A bare unblock means the dependencies the manager can see now, never a wildcard over later additions.
|
|
670
|
+
const blockers = getTask(board.db, id)?.blocked_by ?? [];
|
|
671
|
+
if (!blockers.length) {
|
|
672
|
+
const task = getTask(board.db, id), from = task ? statusOf(board.db, task) : null;
|
|
673
|
+
if (task) return { from, to: from, moved: false, already: true };
|
|
674
|
+
}
|
|
675
|
+
let result;
|
|
676
|
+
for (const blocker of blockers) result = await applyAct(board, id, name, { roles, payload: { ...payload, on: blocker.id }, extra, instead });
|
|
677
|
+
if (result) return result;
|
|
678
|
+
}
|
|
679
|
+
if (name === 'backlog') {
|
|
680
|
+
const task = getTask(board.db, id);
|
|
681
|
+
if (task?.backlog) return { from: statusOf(board.db, task), to: statusOf(board.db, task), moved: false, already: true };
|
|
682
|
+
if (task?.paused && payload.pause_revision == null) payload = { ...payload,
|
|
683
|
+
pause_revision: task.paused.generation ?? one(board.db, "SELECT id FROM task_events WHERE task_id=? AND kind='paused' ORDER BY id DESC LIMIT 1", id)?.id };
|
|
684
|
+
const entry = await loadWorkflow(board.root);
|
|
685
|
+
view(board, db => validateEvent(db, board, entry, id, name, { roles: sender, payload, extra, localOnly: true }));
|
|
686
|
+
const pendingHold = latestEvent(board.db, id, ['dispatch_held', 'dispatch_released', 'session_adopted'], 'id,kind,payload');
|
|
687
|
+
if (pendingHold?.kind === 'dispatch_held' && json(pendingHold.payload, {})?.backlog_request) backlogEvent = pendingHold.id;
|
|
688
|
+
if (task && !['done', 'archived', 'cancelled'].includes(task.status) && !backlogQualification(board.db, id).qualified) {
|
|
689
|
+
backlogEvent = tx(board, db => {
|
|
690
|
+
const hold = latestEvent(db, id, ['dispatch_held', 'dispatch_released', 'session_adopted'], 'id,kind,payload');
|
|
691
|
+
const held = json(hold?.payload, {}) ?? {};
|
|
692
|
+
const marker = event(db, id, 'dispatch_held', { reason: 'backlog requested; ending its execution before placement', backlog_request: true,
|
|
693
|
+
requester: actorOf() ?? current().caller ?? principalOf(), sender, backlog_payload: payload, backlog_extra: extra,
|
|
694
|
+
previous_hold: hold?.kind === 'dispatch_held' ? held.backlog_request ? held.previous_hold ?? null : { id: hold.id, payload: held } : null });
|
|
695
|
+
if (getTask(db, id)?.current_run_id) endRun(db, id, { outcome: 'backlogged', summary: payload.reason });
|
|
696
|
+
requestSessionClose(db, id);
|
|
697
|
+
return marker;
|
|
698
|
+
});
|
|
699
|
+
const { closeRequested } = await import('./dispatch.mjs');
|
|
700
|
+
await closeRequested(board, { undelivered: [] }, { taskId: id });
|
|
701
|
+
// The close pass can finish this exact request once it has the real stop receipt.
|
|
702
|
+
if (getTask(board.db, id)?.backlog) return { from: statusOf(board.db, task), to: statusOf(board.db, getTask(board.db, id)), moved: false };
|
|
703
|
+
const qualification = backlogQualification(board.db, id);
|
|
704
|
+
if (!qualification.qualified) throw requestDetail(qualification.reason);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return await applyAct(board, id, name, { roles, payload, extra, instead, backlogEvent });
|
|
708
|
+
} catch (error) {
|
|
709
|
+
if (error.ownerRule) return await routeRequest(board, { id, name, payload: original, reason: error });
|
|
710
|
+
if (error.boardDetail || error.noTransition || error.refusal) return await acceptRequest(board, { id, name, payload: original, reason: error });
|
|
711
|
+
throw error;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** Finish retained backlog requests only after the native close pass has made them qualified. */
|
|
716
|
+
export async function completeBacklogRequests(board, { taskId = null } = {}) {
|
|
717
|
+
const pending = view(board, db => all(db, `SELECT e.id,e.task_id,e.payload FROM tasks t JOIN task_events e ON e.id=${latestEventIdSql('t.id', ['dispatch_held', 'dispatch_released', 'session_adopted'])}
|
|
718
|
+
WHERE e.kind='dispatch_held' AND json_extract(e.payload,'$.backlog_request')=1 ${taskId ? 'AND t.id=?' : ''}
|
|
719
|
+
ORDER BY e.id`, ...(taskId ? [taskId] : [])));
|
|
720
|
+
const completed = [];
|
|
721
|
+
for (const request of pending) {
|
|
722
|
+
if (!backlogQualification(board.db, request.task_id).qualified) continue;
|
|
723
|
+
const held = json(request.payload, {}) ?? {};
|
|
724
|
+
// Complete only with the original sender's roles.
|
|
725
|
+
if (!Array.isArray(held.sender) || !held.backlog_payload) continue;
|
|
726
|
+
const previousActor = actorOf();
|
|
727
|
+
try {
|
|
728
|
+
setActor(held.requester ?? held.by ?? null);
|
|
729
|
+
const result = await applyAct(board, request.task_id, 'backlog', { roles: held.sender, payload: held.backlog_payload,
|
|
730
|
+
extra: held.backlog_extra ?? {}, backlogEvent: request.id });
|
|
731
|
+
if (result.backlog_event === request.id) completed.push(request.task_id);
|
|
732
|
+
} catch (error) {
|
|
733
|
+
// A changed workflow or card retains its request; a later close pass can reconsider it without duplicate mail.
|
|
734
|
+
if (!error.ownerRule && !error.boardDetail && !error.noTransition && !error.refusal) throw error;
|
|
735
|
+
} finally { setActor(previousActor); }
|
|
736
|
+
}
|
|
737
|
+
return completed;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function applyAct(board, id, name, { roles = null, payload = {}, extra = {}, instead = null, backlogEvent = null } = {}) {
|
|
491
741
|
assertSourceReceipt(payload);
|
|
492
742
|
const entry = await loadWorkflow(board.root);
|
|
493
|
-
|
|
743
|
+
const sender = roles ?? callerRoles(board.db, id), task = getTask(board.db, id);
|
|
744
|
+
if (task && !task.subtask_of && sender.includes('implementer') && !sender.includes('manager') && ['complete', 'cancel', 'archive'].includes(name))
|
|
745
|
+
throw ownerRule('The manager closes an arc; its own implementer requests that decision.');
|
|
746
|
+
if (task && name === 'request_review') {
|
|
747
|
+
const card = scopeOf(board.db, board, task, { params: entry.params, lazy: true, deferSources: true }).card;
|
|
748
|
+
if (card.reviewer_profile != null && (card.reviewer_profile === card.implementer || card.reviewer_profile === entry.params?.org_layer))
|
|
749
|
+
throw ownerRule('A review uses an independent reviewer profile; the manager selects it.');
|
|
750
|
+
}
|
|
751
|
+
if (task && name === 'set' && payload.field === 'reviewer' && payload.value != null
|
|
752
|
+
&& [task.assignee, entry.params?.org_layer].includes(payload.value)) throw ownerRule('A review uses an independent reviewer profile; the manager selects it.');
|
|
494
753
|
refuseCredentialDump(name === 'comment' ? `the comment on ${id}` : `${name.replace(/_/g, '-')} on ${id}`, payload);
|
|
495
754
|
await refreshCardInputs(board, id, { commits: ['complete', 'request_review'].includes(name) });
|
|
496
755
|
if (['complete', 'request_review', 'request_state'].includes(name)) view(board, db => validateEvent(db, board, entry, id, name,
|
|
497
756
|
{ roles: roles ?? callerRoles(db, id), payload, extra, localOnly: true }));
|
|
498
757
|
const evidencePhase = ['complete', 'request_review', 'request_state'].includes(name) ? completionPhase(board, id, entry, name, payload, roles ?? callerRoles(board.db, id)) : null;
|
|
499
758
|
if (evidencePhase) payload = { ...payload, ci: await observeCompletionEvidence(board, id, entry, payload, evidencePhase) };
|
|
500
|
-
|
|
759
|
+
// act retains missing review detail without firing the transition or consuming a queued request.
|
|
760
|
+
if (payload.ci?.required && !payload.ci.ready && (name !== 'request_state' || evidencePhase === 'review_verdict')) throw completionEvidenceRefusal(payload.ci, 'The PR candidate could not be verified.');
|
|
501
761
|
const result = tx(board, (db) => {
|
|
502
762
|
roles ??= callerRoles(db, id);
|
|
763
|
+
let backlogHold = null;
|
|
764
|
+
if (name === 'backlog' && backlogEvent != null) {
|
|
765
|
+
backlogHold = latestEvent(db, id, ['dispatch_held', 'dispatch_released', 'session_adopted'], 'id,kind,payload');
|
|
766
|
+
if (backlogHold?.id !== backlogEvent || backlogHold.kind !== 'dispatch_held' || !json(backlogHold.payload, {})?.backlog_request
|
|
767
|
+
|| !backlogQualification(db, id).qualified) {
|
|
768
|
+
const from = statusOf(db, getTask(db, id));
|
|
769
|
+
return { from, to: from, moved: false, effects: [] };
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (name === 'request_review' && payload.queued_event != null) {
|
|
773
|
+
const queued = one(db, "SELECT payload FROM task_events WHERE task_id=? AND id=? AND kind='review_queued'", id, payload.queued_event);
|
|
774
|
+
const taken = one(db, "SELECT 1 FROM task_events WHERE task_id=? AND kind IN ('review_queue_started','review_queue_settled') AND json_extract(payload,'$.queued_event')=?", id, payload.queued_event);
|
|
775
|
+
if (!queued || taken) throw requestDetail('This queued review was already handled.');
|
|
776
|
+
}
|
|
503
777
|
let r;
|
|
504
778
|
try { r = fire(db, board, entry, id, name, { roles, payload, extra }); }
|
|
505
779
|
catch (error) {
|
|
506
780
|
const other = (error.refusal || error.noTransition) && instead ? instead(db, error, entry) : null;
|
|
507
781
|
if (!other) throw error;
|
|
508
|
-
r = { ...fire(db, board, entry, id, other.name, { roles, payload, extra }), instead: other };
|
|
782
|
+
r = { ...fire(db, board, entry, id, other.name, { roles, payload: other.payload ?? payload, extra }), instead: other };
|
|
783
|
+
}
|
|
784
|
+
if (backlogHold && getTask(db, id)?.backlog) {
|
|
785
|
+
const previous = json(backlogHold.payload, {})?.previous_hold;
|
|
786
|
+
event(db, id, 'dispatch_released', { reason: 'backlog placement completed', backlog_request: backlogEvent });
|
|
787
|
+
if (previous?.payload) event(db, id, 'dispatch_held', { ...previous.payload, restored_after_backlog_request: backlogEvent, restored_by: actorOf() });
|
|
788
|
+
r.backlogEvent = backlogEvent;
|
|
509
789
|
}
|
|
510
790
|
// Authorization and the comment row share one transaction: a refusal stores nothing.
|
|
791
|
+
if (name === 'request_review' && payload.queued_event != null && r.moved && r.to === 'review') event(db, id, 'review_queue_started', { queued_event: payload.queued_event });
|
|
792
|
+
// The review now running replaces every other pending request for its PR (ADR 0252).
|
|
793
|
+
if (name === 'request_review' && r.moved && r.to === 'review' && payload.review === 'pr' && [].concat(payload.pr ?? []).length === 1) {
|
|
794
|
+
const active = one(db, "SELECT id FROM task_events WHERE task_id=? AND kind='review_requested' ORDER BY id DESC LIMIT 1", id)?.id ?? null;
|
|
795
|
+
for (const row of pendingReviewRequests(db, id)) {
|
|
796
|
+
const named = [].concat(row.pr ?? []);
|
|
797
|
+
if (row.id === Number(payload.queued_event) || named.length !== 1 || prIdentity(named[0]) !== prIdentity([].concat(payload.pr)[0])) continue;
|
|
798
|
+
event(db, id, 'review_queue_settled', { queued_event: row.id, disposition: 'superseded', active_review_event: active,
|
|
799
|
+
queue_order: row.queue_order, review: 'pr', requested_pr: payload.pr, requested_pr_head: payload.pr_head });
|
|
800
|
+
}
|
|
801
|
+
}
|
|
511
802
|
if (name === 'comment') {
|
|
803
|
+
if (payload.pr_evidence) {
|
|
804
|
+
const proof = payload.pr_evidence;
|
|
805
|
+
if (!preparedPrEvidence.has(proof)) throw new Error('PR evidence must enter through the verified comment door.');
|
|
806
|
+
const sender = roles ?? callerRoles(db, id);
|
|
807
|
+
if (!sender.some(role => ['implementer', 'manager'].includes(role))) throw ownerRule('PR evidence authority changed before commit.');
|
|
808
|
+
if (proof.kind === 'pr_release_readback') {
|
|
809
|
+
if (proof.verification?.kind === 'manager-attestation' && !sender.includes('manager')) throw ownerRule('Manager attestation authority changed.');
|
|
810
|
+
const disposition = prDisposition(board, id, proof.url, proof.head);
|
|
811
|
+
if (disposition.event !== proof.disposition_event || disposition.disposition !== 'release-only') throw new Error('PR disposition changed during readback.');
|
|
812
|
+
// Re-check the current verdict with the same binding validation used by readers.
|
|
813
|
+
const probe = { ...proof };
|
|
814
|
+
event(db, id, proof.kind, probe);
|
|
815
|
+
if (!releaseReadback(board, id, proof.url, proof.head)) throw new Error('The PR pass changed during readback.');
|
|
816
|
+
} else event(db, id, proof.kind, proof);
|
|
817
|
+
}
|
|
512
818
|
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);
|
|
513
819
|
joinThread(db, entry, id, payload.author.trim(), payload.body);
|
|
514
820
|
}
|
|
@@ -522,25 +828,38 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
|
|
|
522
828
|
await runEffects(board, result.effects);
|
|
523
829
|
// A card this verb closed ends its agents' assignments; the verb hands the ends on itself (agent-declarations.mjs).
|
|
524
830
|
if (result.moved) { const { endFinishedAgents } = await import('./agent-declarations.mjs'); await endFinishedAgents(board, [id]); }
|
|
525
|
-
return { from: result.from, to: result.to, moved: result.moved, ...(result.commentId ? { commentId: result.commentId } : {}), ...(result.instead ? { instead: result.instead } : {}) };
|
|
831
|
+
return { from: result.from, to: result.to, moved: result.moved, ...(result.commentId ? { commentId: result.commentId } : {}), ...(result.instead ? { instead: result.instead } : {}), ...(result.backlogEvent != null ? { backlog_event: result.backlogEvent } : {}) };
|
|
526
832
|
}
|
|
527
833
|
|
|
528
834
|
export async function requestStateMany(board, ids, payload = {}) {
|
|
835
|
+
try { return await applyStateMany(board, ids, payload); }
|
|
836
|
+
catch (error) {
|
|
837
|
+
const results = [];
|
|
838
|
+
for (const id of [...new Set(ids.map(String))]) {
|
|
839
|
+
if (!error.ownerRule && !error.boardDetail && !error.noTransition && !error.refusal) throw error;
|
|
840
|
+
const receipt = await (error.ownerRule ? routeRequest : acceptRequest)(board, { id, name: 'request_state', payload, reason: error });
|
|
841
|
+
results.push([id, receipt.to]);
|
|
842
|
+
}
|
|
843
|
+
if (!results.length) await acceptRequest(board, { name: 'request_state', payload, reason: error });
|
|
844
|
+
return results;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function applyStateMany(board, ids, payload = {}) {
|
|
529
849
|
assertSourceReceipt(payload);
|
|
850
|
+
if (!String(payload.reason ?? '').trim()) payload = { ...payload, reason: 'none given' };
|
|
530
851
|
const targets = [...new Set(ids.map(String))];
|
|
531
852
|
if (!targets.length) throw new Error('A state request names at least one card.');
|
|
532
853
|
const entry = await loadWorkflow(board.root);
|
|
533
854
|
const prepared = new Map(await Promise.all(targets.map(async id => {
|
|
534
855
|
const roles = callerRoles(board.db, id);
|
|
535
856
|
const phase = completionPhase(board, id, entry, 'request_state', payload, roles);
|
|
536
|
-
// As in complete: only a reviewer's verdict reads PRs from a state request; anyone else's would be dropped unread.
|
|
537
|
-
if (phase !== 'review_verdict' && [].concat(payload.pr ?? [], payload.pr_head ?? []).length) throw new Error(`A state request records no PRs: declare the arc's PRs on it with comment <arc> "<why>" --pr URL --pr-head SHA (one pair per PR), then request the state without them. Nothing was requested.`);
|
|
538
857
|
view(board, db => validateEvent(db, board, entry, id, 'request_state', { roles, payload, localOnly: true }));
|
|
539
858
|
const evidence = await inspectCompletionEvidence(board, id, entry, payload, phase);
|
|
540
859
|
// An ordinary request mails a decision to the manager, not a completion.
|
|
541
860
|
// Keep a failed source observation in that mail. A reviewer recording an
|
|
542
861
|
// actual verdict still has to verify the requested PR head.
|
|
543
|
-
if (phase === 'review_verdict' && evidence.required && !evidence.ready) throw
|
|
862
|
+
if (phase === 'review_verdict' && evidence.required && !evidence.ready) throw completionEvidenceRefusal(evidence, 'The reviewed PR head could not be verified.');
|
|
544
863
|
return [id, evidence];
|
|
545
864
|
})));
|
|
546
865
|
const result = tx(board, db => {
|
|
@@ -569,7 +888,10 @@ export async function goto(board, id, status, { roles = null, payload = {} } = {
|
|
|
569
888
|
return { ...eventTo(db, board, entry, id, status, { roles, payload }), from: statusOf(db, getTask(db, id)) };
|
|
570
889
|
})();
|
|
571
890
|
if (from === status) return { from, to: status, moved: false, event: null };
|
|
572
|
-
if (!name)
|
|
891
|
+
if (!name) {
|
|
892
|
+
refuseUnreadManager();
|
|
893
|
+
return await acceptRequest(board, { id, name: 'goto', payload: { ...payload, status }, reason: refusal ?? `no workflow event admits moving ${id} from ${from} to ${status} for ${roles.length ? roles.join(', ') : 'this caller'}.` });
|
|
894
|
+
}
|
|
573
895
|
return { ...(await act(board, id, name, { roles, payload })), event: name };
|
|
574
896
|
}
|
|
575
897
|
|
|
@@ -580,7 +902,7 @@ export async function goto(board, id, status, { roles = null, payload = {} } = {
|
|
|
580
902
|
// it keeps trying at the capped wait, and at EFFECT_ATTEMPTS failed attempts its card records `effect_stalled` and the
|
|
581
903
|
// managers are mailed once, so a passed card whose merge keeps failing is seen, and still heals when the cause clears.
|
|
582
904
|
export const EFFECT_ATTEMPTS = 6;
|
|
583
|
-
const OWNED = new Set(['merge_reviewed', 'cleanup_workspace', 'rename_card_agents']);
|
|
905
|
+
const OWNED = new Set(['merge_reviewed', 'retire_workspace', 'cleanup_workspace', 'rename_card_agents']);
|
|
584
906
|
const effectWait = (attempts) => Math.min(1800, 30 * 2 ** Math.max(0, attempts - 1));
|
|
585
907
|
// A delivery pass claims a row by compare-and-set on its next_at (set ahead only while the row is due) before trying it,
|
|
586
908
|
// so two passes (serve's, the hosted loop's, a person's `workflow deliver`, a verb's own) never try one effect at once.
|
|
@@ -666,7 +988,8 @@ export async function runEffects(board, effects = [], { pending: due = false, el
|
|
|
666
988
|
/** One effect's I/O: true when it is done, false when it is not done yet (the caller counts the attempt). */
|
|
667
989
|
async function runEffect(board, e) {
|
|
668
990
|
if (e.kind === 'session_notice') { const { sessionNotice } = await import('./dispatch.mjs'); return await sessionNotice(board, e) !== false; }
|
|
669
|
-
|
|
991
|
+
// retire_workspace is what this release queues; cleanup_workspace rows are a predecessor's, retired by the same rule
|
|
992
|
+
if (e.kind === 'retire_workspace' || e.kind === 'cleanup_workspace') { await cleanupWorkspace(board, e.id, { scratch: e.kind === 'cleanup_workspace' || e.scratch === true }); return true; }
|
|
670
993
|
if (e.kind === 'declare_card_agent') { const { declareCardAgentEffect } = await import('./agent-declarations.mjs'); return (await declareCardAgentEffect(board, e)) !== false; }
|
|
671
994
|
if (e.kind === 'rename_card_agents') { const { renameCardAgentsEffect } = await import('./agent-declarations.mjs'); return await renameCardAgentsEffect(board, e); }
|
|
672
995
|
// a verb's own ask files its card's mail at once (a local write; the mailbox delivers it)
|
|
@@ -701,7 +1024,7 @@ export function recomputeReady(board, effects = [], errors = []) {
|
|
|
701
1024
|
// what the moves ask for is handed on only once the write commits: a retried write starts its lists again
|
|
702
1025
|
const { moved, asked, failed } = tx(board, (db) => {
|
|
703
1026
|
const asked = [], failed = [];
|
|
704
|
-
let moved =
|
|
1027
|
+
let moved = settleExpiringFlags(db, board, entry, asked);
|
|
705
1028
|
for (const { id } of all(db, "SELECT id FROM tasks WHERE status NOT IN ('done', 'archived')")) {
|
|
706
1029
|
const before = statusOf(db, getTask(db, id));
|
|
707
1030
|
// another writer's commit is the whole write's to retry, never one card's failure
|
|
@@ -747,11 +1070,7 @@ export const complete = async (board, id, options = {}) => {
|
|
|
747
1070
|
// A person's card closes only on that person's completion (personDone, as the board's platform event): no session,
|
|
748
1071
|
// creator or manager completes it through this verb.
|
|
749
1072
|
const person = view(board, (db) => personOf(getTask(db, id)?.assignee));
|
|
750
|
-
if (person)
|
|
751
|
-
if ([].concat(pr ?? [], pr_head ?? []).length && !view(board, (db) => roles ?? callerRoles(db, id)).includes('reviewer')) {
|
|
752
|
-
const arc = view(board, (db) => one(db, 'SELECT subtask_of FROM supercode_cards WHERE task_id = ?', id))?.subtask_of ?? id;
|
|
753
|
-
throw new Error(`complete records no PRs: declare the arc's PRs on it with comment ${arc} "<why>" --pr URL --pr-head SHA (one pair per PR), then complete ${id} without them. Nothing was completed.`);
|
|
754
|
-
}
|
|
1073
|
+
if (person) return await routeRequest(board, { id, name: 'complete', payload: options, reason: `${id} is assigned to ${person}; obtain their completion through the Workplace person-done door.` });
|
|
755
1074
|
return await act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId, commit, branch, pr, pr_head, reviewed_sha } });
|
|
756
1075
|
};
|
|
757
1076
|
/**
|
|
@@ -780,14 +1099,129 @@ export const block = async (board, id, { reason = null, on = null, waitingOn = n
|
|
|
780
1099
|
await act(board, id, 'block', { roles, payload: { reason, on, kind, waiting_on: waitingOn, run_id: runId } });
|
|
781
1100
|
export const unblock = (board, id, { reason = null, on = null, roles } = {}) => act(board, id, 'unblock', { roles, payload: { reason, on } });
|
|
782
1101
|
export const requestReview = async (board, id, options = {}) => {
|
|
1102
|
+
try { return await applyRequestReview(board, id, options); }
|
|
1103
|
+
catch (error) {
|
|
1104
|
+
if (error.ownerRule) return await routeRequest(board, { id, name: 'request_review', payload: options, reason: error });
|
|
1105
|
+
if (error.boardDetail || error.noTransition || error.refusal) return await acceptRequest(board, { id, name: 'request_review', payload: options, reason: error });
|
|
1106
|
+
throw error;
|
|
1107
|
+
}
|
|
1108
|
+
};
|
|
1109
|
+
const applyRequestReview = async (board, id, options = {}) => {
|
|
783
1110
|
assertSourceReceipt(options);
|
|
784
|
-
const { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, branch = null,
|
|
1111
|
+
const { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, branch = null, plan = false, queuedEvent = null } = options;
|
|
1112
|
+
const sender = roles ?? callerRoles(board.db, id), entry = await loadWorkflow(board.root);
|
|
1113
|
+
const declared = declaredPullRequests(board.db, id);
|
|
1114
|
+
const previous = one(board.db, "SELECT id,payload FROM task_events WHERE task_id=? AND kind='review_requested' ORDER BY id DESC LIMIT 1", id);
|
|
1115
|
+
const previousPayload = json(previous?.payload, {});
|
|
1116
|
+
const requestedUrls = [].concat(previousPayload.requested_pr ?? []), requestedHeads = [].concat(previousPayload.requested_pr_head ?? []);
|
|
1117
|
+
const reviewed = [...candidatePullRequests(previousPayload.ci).map(source => ({ url: source.pr.url, head: source.commit })),
|
|
1118
|
+
...requestedUrls.map((url, i) => ({ url, head: requestedHeads[i] })).filter(source => source.url && source.head)];
|
|
1119
|
+
const declaration = one(board.db, "SELECT MAX(id) AS id FROM task_events WHERE task_id=? AND kind='commented' AND json_type(payload,'$.sources')='array'", id)?.id ?? 0;
|
|
1120
|
+
const known = [...new Map((declaration > (previous?.id ?? 0) ? [...reviewed, ...declared] : [...declared, ...reviewed]).map(source => [source.url, source])).values()];
|
|
1121
|
+
const pr = options.pr ?? (known.length === 1 ? known[0].url : null);
|
|
1122
|
+
const named = [].concat(pr ?? []);
|
|
1123
|
+
const pr_head = options.pr_head ?? (named.length && named.every(url => known.some(source => source.url === url && source.head))
|
|
1124
|
+
? named.map(url => known.find(source => source.url === url).head) : null);
|
|
785
1125
|
// What a session's request asks for is what it says, never its summary's words: `--plan` asks the plan review of the
|
|
786
1126
|
// proposed ADR's PR it names, and one PR named with its head (--pr, --pr-head) is that PR's review. The workflow's
|
|
787
1127
|
// clauses read `review`; a request naming several PRs asks neither.
|
|
788
|
-
const
|
|
789
|
-
const review = plan ? 'plan' :
|
|
790
|
-
|
|
1128
|
+
const singlePr = [].concat(pr ?? []).length === 1;
|
|
1129
|
+
const review = plan ? 'plan' : singlePr || !sender.includes('platform') ? 'pr' : null;
|
|
1130
|
+
const payload = { summary, metadata, reviewer, force, run_id: expectedRunId, commit, branch, pr, pr_head, review, plan: Boolean(plan),
|
|
1131
|
+
...(queuedEvent != null ? { queued_event: queuedEvent, ...(options.requester ? { requester: options.requester } : {}) } : {}) };
|
|
1132
|
+
const candidateUrls = urls => [].concat(urls ?? []).map(url => typeof url === 'string' ? url.replace(/\/$/, '').toLowerCase() : url);
|
|
1133
|
+
const samePr = urls => [].concat(urls ?? []).length === 1 && candidateUrls(urls)[0] === candidateUrls(pr)[0];
|
|
1134
|
+
const sameCandidate = (urls, heads) => JSON.stringify(candidateUrls(urls)) === JSON.stringify(candidateUrls(pr)) && JSON.stringify([].concat(heads ?? [])) === JSON.stringify([].concat(pr_head ?? []));
|
|
1135
|
+
const activeCandidate = db => {
|
|
1136
|
+
const active = latestReviewRequest(db, id);
|
|
1137
|
+
const candidate = json(active?.payload, {}), sources = candidatePullRequests(candidate.ci);
|
|
1138
|
+
return active && active.kind === (review ?? 'final') && sameCandidate(candidate.requested_pr ?? sources.map(source => source.pr.url), candidate.requested_pr_head ?? sources.map(source => source.commit)) ? active.id : null;
|
|
1139
|
+
};
|
|
1140
|
+
// Recovery is explicit and names one unsettled receipt on this card. Inspect B without replacing the active
|
|
1141
|
+
// review's observation, then append B and settle A together. A failed fresh admission leaves A pending.
|
|
1142
|
+
if (options.supersedes != null && queuedEvent == null) {
|
|
1143
|
+
const supersedes = Number(options.supersedes);
|
|
1144
|
+
if (!Number.isSafeInteger(supersedes) || supersedes <= 0) throw requestDetail('--supersedes names a positive queued review event ID.');
|
|
1145
|
+
if (!singlePr) throw requestDetail('A queued PR replacement names one PR and its head: --pr URL --pr-head SHA.');
|
|
1146
|
+
const authorize = db => {
|
|
1147
|
+
const task = getTask(db, id);
|
|
1148
|
+
if (!task) throw requestDetail(`No card ${id} holds that queued review.`);
|
|
1149
|
+
const currentSender = roles ?? callerRoles(db, id);
|
|
1150
|
+
if (!currentSender.some(role => ['implementer', 'reviewer', 'auditor', 'arc', 'creator', 'manager'].includes(role))) throw ownerRule('A card\'s own role or manager supersedes its queued PR review.');
|
|
1151
|
+
const from = statusOf(db, task);
|
|
1152
|
+
const assignee = from === 'review' ? scopeOf(db, board, task, { params: entry.params, lazy: true, deferSources: true }).card.implementer : task.assignee;
|
|
1153
|
+
const pending = pendingReviewRequests(db, id), original = pending.find(row => row.id === supersedes);
|
|
1154
|
+
if (!original) {
|
|
1155
|
+
const settled = one(db, "SELECT id,payload FROM task_events WHERE task_id=? AND kind='review_queue_settled' AND json_extract(payload,'$.queued_event')=? ORDER BY id DESC LIMIT 1", id, supersedes);
|
|
1156
|
+
const prior = json(settled?.payload, {});
|
|
1157
|
+
if (prior.disposition === 'superseded' && prior.review === review
|
|
1158
|
+
&& sameCandidate(prior.requested_pr, prior.requested_pr_head)) return { retry: {
|
|
1159
|
+
disposition: 'already', requested: 'request_review', id, pr, pr_head, supersedes,
|
|
1160
|
+
settlement_event: settled.id, ...(prior.replacement_event != null ? { event: prior.replacement_event, queue_event: prior.replacement_event, queue_order: prior.queue_order } : {}),
|
|
1161
|
+
...(prior.active_review_event != null ? { active_review_event: prior.active_review_event, in_review: from === 'review' && activeCandidate(db) === prior.active_review_event } : {}),
|
|
1162
|
+
from, to: from, moved: false, already: true, reason: 'This correction was already recorded; nothing new was queued. Its original replacement receipt is retained.' } };
|
|
1163
|
+
throw requestDetail(`Queued review ${supersedes} is not pending on this card; its original receipt is retained.`);
|
|
1164
|
+
}
|
|
1165
|
+
return { task, from, assignee, currentSender, pending, original };
|
|
1166
|
+
};
|
|
1167
|
+
const checked = view(board, authorize);
|
|
1168
|
+
if (checked.retry) { (current().acceptedRequests ??= []).push(checked.retry); return checked.retry; }
|
|
1169
|
+
const evidence = await inspectCompletionEvidence(board, id, entry, payload, 'request_review');
|
|
1170
|
+
if (!evidence.ready || !candidatePullRequests(evidence).length) throw completionEvidenceRefusal(evidence, 'The replacement PR candidate could not be verified.');
|
|
1171
|
+
const receipt = tx(board, db => {
|
|
1172
|
+
const authorized = authorize(db);
|
|
1173
|
+
if (authorized.retry) return authorized.retry;
|
|
1174
|
+
const { from, assignee, currentSender, pending, original } = authorized;
|
|
1175
|
+
const requester = actorOf() ?? current().caller;
|
|
1176
|
+
const active = from === 'review' ? activeCandidate(db) : null;
|
|
1177
|
+
if (active) {
|
|
1178
|
+
const settlement = event(db, id, 'review_queue_settled', { queued_event: supersedes, disposition: 'superseded',
|
|
1179
|
+
active_review_event: active, queue_order: original.queue_order, requester, sender: currentSender, assignee, review,
|
|
1180
|
+
requested_pr: pr, requested_pr_head: pr_head, ci: evidence });
|
|
1181
|
+
return { accepted: true, disposition: 'already', requested: 'request_review', id, pr, pr_head, supersedes,
|
|
1182
|
+
active_review_event: active, settlement_event: settlement, in_review: true, from, to: from, moved: false, already: true,
|
|
1183
|
+
reason: `Queued review ${supersedes} is superseded by the candidate already under review.` };
|
|
1184
|
+
}
|
|
1185
|
+
const same = pending.find(row => row.review === review && sameCandidate(row.pr, row.pr_head));
|
|
1186
|
+
if (same?.id === supersedes) return { queued: true, disposition: 'queued', requested: 'request_review', id, pr, pr_head,
|
|
1187
|
+
event: same.id, queue_event: same.id, queue_order: same.queue_order, from, to: from, moved: false, already: true };
|
|
1188
|
+
const queued = same?.id ?? event(db, id, 'review_queued', { ...payload, superseded_event: supersedes, queue_order: original.queue_order,
|
|
1189
|
+
sender: currentSender, requester, assignee });
|
|
1190
|
+
const queueOrder = Math.min(original.queue_order, same?.queue_order ?? original.queue_order);
|
|
1191
|
+
const settlement = event(db, id, 'review_queue_settled', { queued_event: supersedes, disposition: 'superseded',
|
|
1192
|
+
replacement_event: queued, queue_order: queueOrder, requester, sender: currentSender, assignee, review,
|
|
1193
|
+
requested_pr: pr, requested_pr_head: pr_head, ci: evidence });
|
|
1194
|
+
return { queued: true, disposition: 'queued', requested: 'request_review', id, pr, pr_head, supersedes,
|
|
1195
|
+
event: queued, queue_event: queued, settlement_event: settlement, queue_order: queueOrder,
|
|
1196
|
+
from, to: from, moved: false, already: Boolean(same) };
|
|
1197
|
+
});
|
|
1198
|
+
(receipt.queued ? current().queuedRequests ??= [] : current().acceptedRequests ??= []).push(receipt);
|
|
1199
|
+
return receipt;
|
|
1200
|
+
}
|
|
1201
|
+
const receipt = tx(board, db => {
|
|
1202
|
+
const task = getTask(db, id);
|
|
1203
|
+
if (!task || statusOf(db, task) !== 'review') return null;
|
|
1204
|
+
if (!sender.some(role => ['implementer', 'reviewer', 'auditor', 'arc', 'creator', 'manager'].includes(role))) throw ownerRule('A card\'s own role or manager requests its PR review.');
|
|
1205
|
+
const assignee = scopeOf(db, board, task, { params: entry.params, lazy: true, deferSources: true }).card.implementer;
|
|
1206
|
+
if (activeCandidate(db))
|
|
1207
|
+
return { accepted: true, already: true, disposition: 'already', requested: 'request_review', id, from: 'review', to: 'review', moved: false, reason: 'This candidate is already under review.' };
|
|
1208
|
+
const pending = pendingReviewRequests(db, id);
|
|
1209
|
+
const same = pending.find(row => row.review === review && sameCandidate(row.pr, row.pr_head));
|
|
1210
|
+
if (same) return { queued: true, disposition: 'queued', requested: 'request_review', id, pr, pr_head, event: same.id, queue_event: same.id, queue_order: same.queue_order, from: 'review', to: 'review', moved: false, already: true };
|
|
1211
|
+
// A newer request for the same PR replaces its pending ones at the earliest of their positions (ADR 0252).
|
|
1212
|
+
const replaced = review === 'pr' && singlePr ? pending.filter(row => samePr(row.pr)) : [];
|
|
1213
|
+
const queueOrder = replaced.length ? Math.min(...replaced.map(row => row.queue_order)) : null;
|
|
1214
|
+
const requester = actorOf() ?? current().caller;
|
|
1215
|
+
const queued = event(db, id, 'review_queued', { ...payload, ...(queueOrder != null ? { queue_order: queueOrder } : {}), sender, requester, assignee });
|
|
1216
|
+
let settlement = null;
|
|
1217
|
+
for (const row of replaced) settlement = event(db, id, 'review_queue_settled', { queued_event: row.id, disposition: 'superseded',
|
|
1218
|
+
replacement_event: queued, queue_order: queueOrder, requester, sender, assignee, review, requested_pr: pr, requested_pr_head: pr_head });
|
|
1219
|
+
return { queued: true, disposition: 'queued', requested: 'request_review', id, pr, pr_head, event: queued, queue_event: queued,
|
|
1220
|
+
queue_order: queueOrder ?? queued, ...(settlement != null ? { settlement_event: settlement, superseded: replaced.map(row => row.id) } : {}),
|
|
1221
|
+
from: 'review', to: 'review', moved: false, already: false };
|
|
1222
|
+
});
|
|
1223
|
+
if (receipt) { (receipt.queued ? current().queuedRequests ??= [] : current().acceptedRequests ??= []).push(receipt); return receipt; }
|
|
1224
|
+
return await act(board, id, 'request_review', { roles, payload });
|
|
791
1225
|
};
|
|
792
1226
|
export const requestChanges = (board, id, { reason = null, reviewer = null, roles } = {}) => act(board, id, 'request_changes', { roles, payload: { reason, reviewer } });
|
|
793
1227
|
export const reopen = (board, id, { author = 'user', reason = null, roles } = {}) => act(board, id, 'reopen', { roles, payload: { author, reason } });
|
|
@@ -805,11 +1239,20 @@ export async function reopenReview(board, id, { roles, reason = null } = {}) {
|
|
|
805
1239
|
// the refusal, the check that the open run is a reviewer's and the restart are one write: no verdict lands between
|
|
806
1240
|
const moved = await act(board, id, 'reopen_review', { roles, payload: { reason }, instead: (db, _error, entry) => {
|
|
807
1241
|
const run = currentRun(db, id);
|
|
1242
|
+
if (!run || run.ended_at != null || run.metadata?.supercode?.slot !== 'reviewer') return null;
|
|
1243
|
+
// An arc re-declared since its review request (redeclaredSince) is not restarted on that request's frozen candidate:
|
|
1244
|
+
// the review is returned to its coder with no verdict, naming the declaration (`redeclared`), so the return holds
|
|
1245
|
+
// no returned set (returnedReview): the next review is requested from the declared set when the ordinary rules make
|
|
1246
|
+
// one due (reviewDue, settledReviewDue), as a new review_requested its reviewer binds (VO-3775).
|
|
1247
|
+
const asked = db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id=? AND kind='review_requested'").get(id)?.id ?? null;
|
|
1248
|
+
const redeclared = asked ? redeclaredSince(db, id, asked) : null;
|
|
1249
|
+
if (redeclared && transitionsFor(entry.workflow, 'review', 'return_review').length)
|
|
1250
|
+
return { name: 'return_review', run: run.id, returned: true, payload: { reason: reason ?? 'the arc re-declared its sources after its review request', redeclared } };
|
|
808
1251
|
const stays = transitionsFor(entry.workflow, 'review', 'reclaim').some((t) => !t.target || t.target === 'review');
|
|
809
|
-
return
|
|
1252
|
+
return stays ? { name: 'reclaim', run: run.id } : null;
|
|
810
1253
|
} });
|
|
811
1254
|
const { instead, ...rest } = moved;
|
|
812
|
-
return instead ? { ...rest, restarted_run: instead.run } : rest;
|
|
1255
|
+
return instead ? { ...rest, ...(instead.returned ? { returned_run: instead.run } : { restarted_run: instead.run }) } : rest;
|
|
813
1256
|
}
|
|
814
1257
|
export const schedule = (board, id, reason = null, { roles } = {}) => act(board, id, 'schedule', { roles, payload: { reason } });
|
|
815
1258
|
export const promote = (board, id, { actor = 'user', reason = null, force = false, roles } = {}) => act(board, id, 'promote', { roles, payload: { actor, reason, force } });
|
|
@@ -841,11 +1284,19 @@ export async function specify(board, id, { title = null, body = null, readableId
|
|
|
841
1284
|
// an arc's new task lines are filed as its subtasks once the specify itself is accepted
|
|
842
1285
|
const steps = body != null && !one(board.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id = ?', id)?.subtask_of ? bodyTasks(body) : { body, tasks: [] };
|
|
843
1286
|
const done = await act(board, id, 'specify', { roles, payload: { title, body: steps.body, readable_id: readableId, author, ...(declaration ? { workspace: declaration } : {}) } });
|
|
1287
|
+
if (done.routed || done.accepted) return done;
|
|
844
1288
|
for (const step of steps.tasks) createCard(board, { ...step, subtaskOf: id, assignee: getTask(board.db, id)?.assignee || null });
|
|
845
1289
|
return done;
|
|
846
1290
|
}
|
|
847
1291
|
export const editResult = (board, id, { result, summary = null, metadata = undefined, roles } = {}) => act(board, id, 'edit', { roles, payload: { result, summary, metadata } });
|
|
848
|
-
export const heartbeat = (board, id, note = null, { roles, until = null } = {}) =>
|
|
1292
|
+
export const heartbeat = async (board, id, note = null, { roles, until = null } = {}) => {
|
|
1293
|
+
const deadline = deadlineUntil(until, now(), 'heartbeat');
|
|
1294
|
+
if (Number.isNaN(deadline)) {
|
|
1295
|
+
await act(board, id, 'heartbeat', { roles, payload: { note, until: null } });
|
|
1296
|
+
return await acceptRequest(board, { id, name: 'heartbeat', payload: { note, until }, reason: 'Heartbeat recorded; its wait deadline was left blank because the supplied time was unreadable or outside the supported date range.' });
|
|
1297
|
+
}
|
|
1298
|
+
return await act(board, id, 'heartbeat', { roles, payload: { note, until: deadline } });
|
|
1299
|
+
};
|
|
849
1300
|
export const setGoal = (board, id, { on = true, maxTurns = null, roles } = {}) => act(board, id, 'set_goal', { roles, payload: { goal: Boolean(on), max_turns: on ? maxTurns : null } });
|
|
850
1301
|
|
|
851
1302
|
/** File the named card's tasks only if its workflow accepts decompose; refusal leaves no cards behind. */
|
|
@@ -884,14 +1335,14 @@ export function link(board, parent, child, { roles } = {}) {
|
|
|
884
1335
|
export async function unlink(board, parent, child, { roles } = {}) {
|
|
885
1336
|
const linked = view(board, (db) => one(db, 'SELECT 1 FROM task_links WHERE parent_id = ? AND child_id = ?', parent, child));
|
|
886
1337
|
if (!linked) return false;
|
|
887
|
-
await act(board, child, 'unlink', { roles, payload: { parent, child } });
|
|
888
|
-
return true;
|
|
1338
|
+
const result = await act(board, child, 'unlink', { roles, payload: { parent, child } });
|
|
1339
|
+
return result.routed || result.accepted ? result : true;
|
|
889
1340
|
}
|
|
890
1341
|
|
|
891
1342
|
/** A comment on the card's thread; answers its id. */
|
|
892
|
-
export async function comment(board, id, author, body, { roles, pr = null, pr_head = null, source = null } = {}) {
|
|
893
|
-
|
|
894
|
-
|
|
1343
|
+
export async function comment(board, id, author, body, { roles, pr = null, pr_head = null, source = null, disposition = null, release_target = null, readback = null, reason = null } = {}) {
|
|
1344
|
+
body = String(body ?? '');
|
|
1345
|
+
author = String(author ?? actorOf() ?? current().caller ?? 'board caller');
|
|
895
1346
|
let sources, branches;
|
|
896
1347
|
// The repositories and branches an arc changes are the arc's own: declared on it (`--source owner/repo:branch`, each
|
|
897
1348
|
// repository the arc changes), never registered as a subtask's workspace to be reviewed (VO-2858 had to register
|
|
@@ -910,7 +1361,7 @@ export async function comment(board, id, author, body, { roles, pr = null, pr_he
|
|
|
910
1361
|
if (new Set(branches.map((b) => `${b.repository.toLowerCase()}:${b.branch}`)).size !== branches.length) throw new Error('Declare each source once.');
|
|
911
1362
|
const arc = one(board.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id = ?', id)?.subtask_of;
|
|
912
1363
|
if (arc) throw new Error(`${id} is a task of ${arc}: an arc's sources are declared on the arc (comment ${arc} … --source owner/repository:branch).`);
|
|
913
|
-
if (!(roles ?? callerRoles(board.db, id)).some((role) => role === 'implementer' || role === 'manager')) throw
|
|
1364
|
+
if (!(roles ?? callerRoles(board.db, id)).some((role) => role === 'implementer' || role === 'manager')) throw ownerRule(`Only ${id}'s implementer or the manager declares its sources.`);
|
|
914
1365
|
}
|
|
915
1366
|
if (pr != null || pr_head != null) {
|
|
916
1367
|
const urls = [].concat(pr ?? []), heads = [].concat(pr_head ?? []);
|
|
@@ -925,7 +1376,7 @@ export async function comment(board, id, author, body, { roles, pr = null, pr_he
|
|
|
925
1376
|
return { url: url.replace(/\/$/, ''), head: heads[i] };
|
|
926
1377
|
});
|
|
927
1378
|
// Only the card's own implementer or the board's manager declares its sources; the author text is not identity.
|
|
928
|
-
if (!(roles ?? callerRoles(board.db, id)).some((role) => role === 'implementer' || role === 'manager')) throw
|
|
1379
|
+
if (!(roles ?? callerRoles(board.db, id)).some((role) => role === 'implementer' || role === 'manager')) throw ownerRule(`Only ${id}'s implementer or the manager records its PR sources.`);
|
|
929
1380
|
// A declared PR is in one of the arc's repositories (its workspace, its declared sources, its tasks'; a `--source`
|
|
930
1381
|
// in this same comment counts with the arc's declared sources): the review reads no other, so one outside them is
|
|
931
1382
|
// refused here and nothing is stored, never kept to be dropped from every review request (t_ef2aa6ce). The board's
|
|
@@ -941,8 +1392,19 @@ export async function comment(board, id, author, body, { roles, pr = null, pr_he
|
|
|
941
1392
|
}
|
|
942
1393
|
if (outside.length) throw new Error(outside.map(({ url, repository }) => `${url} is in ${repository}, which is not one of ${id}'s repositories (its workspace, its declared sources, its tasks'): declare ${repository} first with comment ${id} "<why>" --source ${repository}:<branch>, then record its PRs. Nothing was recorded.`).join(' '));
|
|
943
1394
|
}
|
|
944
|
-
|
|
945
|
-
|
|
1395
|
+
let prEvidence;
|
|
1396
|
+
if (disposition != null || readback != null) {
|
|
1397
|
+
const sender = roles ?? callerRoles(board.db, id);
|
|
1398
|
+
if (!sender.some(role => ['implementer', 'manager'].includes(role))) throw ownerRule('Only the implementer or manager records PR disposition/readback.');
|
|
1399
|
+
if (sources?.length !== 1) throw new Error('PR disposition/readback names one --pr and --pr-head.');
|
|
1400
|
+
if (disposition != null && readback != null) throw new Error('Declare the disposition before recording its readback.');
|
|
1401
|
+
prEvidence = await preparePrEvidence(board, id, sources[0], { disposition, target: release_target, readback, reason }, { manager: sender.includes('manager'), actor: actorOf() });
|
|
1402
|
+
preparedPrEvidence.add(prEvidence);
|
|
1403
|
+
}
|
|
1404
|
+
try {
|
|
1405
|
+
const result = await act(board, id, 'comment', { roles, payload: { author: author.trim(), body, ...(sources ? { sources } : {}), ...(branches ? { branches } : {}), ...(prEvidence ? { pr_evidence: prEvidence } : {}) } });
|
|
1406
|
+
return result.routed || result.accepted ? result : result.commentId;
|
|
1407
|
+
} finally { if (prEvidence) preparedPrEvidence.delete(prEvidence); }
|
|
946
1408
|
}
|
|
947
1409
|
|
|
948
1410
|
// ------------------------------------------------------------------ subscriptions
|
|
@@ -951,10 +1413,19 @@ export async function comment(board, id, author, body, { roles, pr = null, pr_he
|
|
|
951
1413
|
* Subscribe `address` (a supercode session address) to a card's events from now on. `role` is `creator` (told of
|
|
952
1414
|
* starts, completion and blocks) or `worker` (told of updates to the card it works).
|
|
953
1415
|
*/
|
|
954
|
-
export function subscribeIn(db, id, address, role = 'creator', { explicit = false, own = false } = {}) {
|
|
1416
|
+
export function subscribeIn(db, id, address, role = 'creator', { explicit = false, own = false, check = false } = {}) {
|
|
955
1417
|
// a session that is an agent's main follows a card under its agent's address: one follower, one subscription
|
|
956
1418
|
if (!['worker', 'dependency'].includes(role)) address = followerAddress(address);
|
|
957
1419
|
if (personCustody(db, address)) return;
|
|
1420
|
+
const outranked = FOLLOWER_RANKS.includes(role) ? FOLLOWER_RANKS.slice(FOLLOWER_RANKS.indexOf(role) + 1) : ['participant'];
|
|
1421
|
+
if (check) {
|
|
1422
|
+
const held = one(db, "SELECT delivery_metadata FROM kanban_notify_subs WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=''", id, address);
|
|
1423
|
+
if (!held) return true;
|
|
1424
|
+
const metadata = json(held.delivery_metadata, {}) ?? {};
|
|
1425
|
+
return Boolean(role === 'worker' && metadata.role !== 'worker' && ![].concat(metadata.also ?? []).includes('worker')
|
|
1426
|
+
|| (own || explicit) && metadata.own !== true || explicit && metadata.unsubscribed
|
|
1427
|
+
|| outranked.includes(metadata.role));
|
|
1428
|
+
}
|
|
958
1429
|
const cursor = one(db, 'SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?', id).c;
|
|
959
1430
|
run(db, `INSERT OR IGNORE INTO kanban_notify_subs (task_id, platform, chat_id, thread_id, chat_type, delivery_mode, delivery_metadata, created_at, last_event_id)
|
|
960
1431
|
VALUES (?, 'supercode', ?, '', 'dm', 'notify+wake', ?, ?, ?)`, id, address, JSON.stringify({ role, ...(own ? { own: true } : {}) }), now(), cursor);
|
|
@@ -970,7 +1441,6 @@ export function subscribeIn(db, id, address, role = 'creator', { explicit = fals
|
|
|
970
1441
|
// participant or creator that is the board's manager hears it as the manager, never as both. What the new role hears
|
|
971
1442
|
// is told from now on, never the card's history (the cursor moves to the card's last event).
|
|
972
1443
|
// A worker or a dependency follower takes over a participant's subscription, as before.
|
|
973
|
-
const outranked = FOLLOWER_RANKS.includes(role) ? FOLLOWER_RANKS.slice(FOLLOWER_RANKS.indexOf(role) + 1) : ['participant'];
|
|
974
1444
|
// the role it held is kept beside it (`also`): what that role heard, the follower still hears (a manager that created
|
|
975
1445
|
// a card is told its audit rounds as its creator was)
|
|
976
1446
|
// (only a follower's role is kept so, appended to what it kept before; a worker or dependency hears its own kinds)
|
|
@@ -1068,7 +1538,7 @@ export function unsubscribe(board, id, address) {
|
|
|
1068
1538
|
}
|
|
1069
1539
|
|
|
1070
1540
|
/** Reconcile only the current worker's audience, inside the binding transaction. */
|
|
1071
|
-
export function replaceWorkerSubscriptions(db, id, address) {
|
|
1541
|
+
export function replaceWorkerSubscriptions(db, id, address, { check = false } = {}) {
|
|
1072
1542
|
const active = currentRun(db, id), session = active?.metadata?.supercode;
|
|
1073
1543
|
const bound = session?.address ?? (session?.session_id && session.machine ? `sc:${session.machine}:${session.harness ?? 'claude-code'}:${session.session_id}` : null);
|
|
1074
1544
|
if (!address || bound !== address || active.ended_at) return false;
|
|
@@ -1079,15 +1549,17 @@ export function replaceWorkerSubscriptions(db, id, address) {
|
|
|
1079
1549
|
const removed = [];
|
|
1080
1550
|
for (const { task_id: card } of family) {
|
|
1081
1551
|
for (const old of replaces ? all(db, "SELECT chat_id,thread_id FROM kanban_notify_subs WHERE task_id=? AND platform='supercode' AND json_extract(delivery_metadata,'$.role')='worker' AND chat_id!=?", card, address) : []) {
|
|
1552
|
+
if (check) return true;
|
|
1082
1553
|
run(db, "DELETE FROM kanban_notify_subs WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=? AND json_extract(delivery_metadata,'$.role')='worker'", card, old.chat_id, old.thread_id);
|
|
1083
1554
|
removed.push({ card, address: old.chat_id });
|
|
1084
1555
|
}
|
|
1085
1556
|
// INSERT OR IGNORE preserves an independent creator/manager subscription
|
|
1086
1557
|
// and the current worker's existing cursor on repeated reconciliation.
|
|
1087
|
-
subscribeIn(db, card, address, 'worker');
|
|
1558
|
+
const needed = subscribeIn(db, card, address, 'worker', { check });
|
|
1559
|
+
if (check && needed) return true;
|
|
1088
1560
|
}
|
|
1089
1561
|
if (removed.length) event(db, id, 'notice_subscriptions_replaced', { address, role: 'worker', superseded: removed, reason: 'the current worker binding replaced its audience' }, active.id);
|
|
1090
|
-
return
|
|
1562
|
+
return !check;
|
|
1091
1563
|
}
|
|
1092
1564
|
|
|
1093
1565
|
export function subscribe(board, id, address, role = 'creator', { own = false } = {}) {
|
|
@@ -1122,21 +1594,30 @@ export function subscribeDependency(board, ownerId, sourceId, { remove = false }
|
|
|
1122
1594
|
});
|
|
1123
1595
|
}
|
|
1124
1596
|
|
|
1125
|
-
export function reconcileDependencySubscriptions(db) {
|
|
1126
|
-
for (const row of all(db, "SELECT * FROM kanban_notify_subs WHERE platform='supercode' AND json_extract(delivery_metadata,'$.dependent_card') IS NOT NULL")) {
|
|
1597
|
+
export function reconcileDependencySubscriptions(db, id, { check = false } = {}) {
|
|
1598
|
+
for (const row of all(db, "SELECT * FROM kanban_notify_subs WHERE task_id=? AND platform='supercode' AND json_extract(delivery_metadata,'$.dependent_card') IS NOT NULL", id)) {
|
|
1127
1599
|
const metadata = json(row.delivery_metadata, {});
|
|
1128
1600
|
const owner = getTask(db, metadata.dependent_card);
|
|
1129
1601
|
const address = owner ? dependencyRecipient(db, owner.id) : null;
|
|
1130
1602
|
const where = "WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=?";
|
|
1131
1603
|
const identity = [row.task_id, row.chat_id, row.thread_id];
|
|
1132
|
-
if (!owner || owner.detached || owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status))
|
|
1604
|
+
if (!owner || owner.detached || owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) {
|
|
1605
|
+
if (check) return true;
|
|
1606
|
+
run(db, `DELETE FROM kanban_notify_subs ${where}`, ...identity);
|
|
1607
|
+
}
|
|
1133
1608
|
// a subscription moved to its arc's new session starts from the card's last event: the new session is told what
|
|
1134
1609
|
// happens from now on, never the card's history the old one was told or missed
|
|
1135
1610
|
// (a subscription that waited for its arc to have a session keeps its cursor: what it waited to tell is still told)
|
|
1136
|
-
else if (address && (address !== row.chat_id || metadata.waiting_recipient))
|
|
1137
|
-
|
|
1138
|
-
|
|
1611
|
+
else if (address && (address !== row.chat_id || metadata.waiting_recipient)) {
|
|
1612
|
+
if (check) return true;
|
|
1613
|
+
run(db, `UPDATE kanban_notify_subs SET chat_id=?,delivery_metadata=?,last_event_id=CASE WHEN ? THEN MAX(last_event_id,(SELECT COALESCE(MAX(id),0) FROM task_events WHERE task_id=?)) ELSE last_event_id END ${where}`,
|
|
1614
|
+
address, JSON.stringify({ ...metadata, notice_wait: undefined, waiting_recipient: false, failures: 0 }), address !== row.chat_id ? 1 : 0, row.task_id, ...identity);
|
|
1615
|
+
} else if (!address && !metadata.waiting_recipient) {
|
|
1616
|
+
if (check) return true;
|
|
1617
|
+
run(db, `UPDATE kanban_notify_subs SET delivery_metadata=? ${where}`, JSON.stringify({ ...metadata, waiting_recipient: true }), ...identity);
|
|
1618
|
+
}
|
|
1139
1619
|
}
|
|
1620
|
+
return false;
|
|
1140
1621
|
}
|
|
1141
1622
|
|
|
1142
1623
|
/** A child hears what its parents' subscribers hear (Hermes copies them at link time); a subtask only its arc's session. */
|
|
@@ -1163,21 +1644,29 @@ export const subscriptions = (db, id = null) => (id ? all(db, 'SELECT * FROM kan
|
|
|
1163
1644
|
|
|
1164
1645
|
// ------------------------------------------------------------------ reading
|
|
1165
1646
|
|
|
1166
|
-
export function listCards(db, { status = null, assignee = null, machine = null, includeArchived = false, includeSubtasks = true, nativeHolds = false } = {}) {
|
|
1647
|
+
export function listCards(db, { status = null, assignee = null, machine = null, includeArchived = false, includeSubtasks = true, nativeHolds = false, backlog = null } = {}) {
|
|
1167
1648
|
const stateSql = cardStateSql({ nativeHolds });
|
|
1168
|
-
const rows = all(db, `SELECT t.*, n.number, c.machine, c.subtask_of, c.stage, c.tags, c.paused, c.blocked_by FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id
|
|
1169
|
-
WHERE (? IS NULL OR ${stateSql} = ? OR (? NOT IN ('paused','blocked') AND c.stage = ?)) AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND ${CARD_VISIBILITY_SQL}
|
|
1170
|
-
ORDER BY t.priority DESC, t.created_at ASC`, status, status, status, status, assignee, assignee, machine, machine, includeSubtasks ? 1 : 0, includeArchived ? 1 : 0);
|
|
1649
|
+
const rows = all(db, `SELECT t.*, n.number, c.machine, c.subtask_of, c.stage, c.tags, c.paused, c.backlog, c.blocked_by FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id
|
|
1650
|
+
WHERE (? IS NULL OR ${stateSql} = ? OR (? NOT IN ('paused','blocked') AND c.stage = ?)) AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? IS NULL OR (c.backlog IS NOT NULL AND c.backlog != 'null') = ?) AND ${CARD_VISIBILITY_SQL}
|
|
1651
|
+
ORDER BY t.priority DESC, t.created_at ASC`, status, status, status, status, assignee, assignee, machine, machine, backlog == null ? null : Number(Boolean(backlog)), backlog == null ? null : Number(Boolean(backlog)), includeSubtasks ? 1 : 0, includeArchived ? 1 : 0);
|
|
1171
1652
|
return rows.map((r) => cardState({ ...r, skills: json(r.skills, []) ?? [], execution: executionOf(r.current_run_id ? one(db, 'SELECT id,metadata,ended_at FROM task_runs WHERE id=?', r.current_run_id) : null, readingContext(db, r.id, r.current_run_id)) }, { nativeHolds }));
|
|
1172
1653
|
}
|
|
1173
1654
|
|
|
1174
1655
|
export function showCard(db, id, options = {}) {
|
|
1175
1656
|
const task = cardState({ ...must(db, id), reviewer: reviewerOf(db, id) }, options);
|
|
1657
|
+
const review = options.review ? reviewedPullRequest(db, id) : null;
|
|
1658
|
+
if (review) return {
|
|
1659
|
+
task: { ...task, body: cardContext(db, id, options), result: null },
|
|
1660
|
+
review_request: { id: review.id, kind: review.kind, payload: json(review.payload, {}) },
|
|
1661
|
+
parents: [], children: [], subtasks: openPrReviewFindings(db, id, review),
|
|
1662
|
+
pr_evidence: [], comments: [], events: [], runs: [], subscriptions: [],
|
|
1663
|
+
};
|
|
1176
1664
|
return {
|
|
1177
1665
|
task,
|
|
1178
1666
|
parents: parentsOf(db, id),
|
|
1179
1667
|
children: childrenOf(db, id),
|
|
1180
|
-
subtasks: all(db, `SELECT t.id, n.number, t.status, t.title, c.stage, c.paused, c.blocked_by FROM tasks t JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at`, id).map(row => cardState(row, options)),
|
|
1668
|
+
subtasks: all(db, `SELECT t.id, n.number, t.status, t.title, c.stage, c.paused, c.backlog, c.blocked_by FROM tasks t JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at`, id).map(row => cardState(row, options)),
|
|
1669
|
+
pr_evidence: all(db, "SELECT id,kind,payload FROM task_events WHERE task_id=? AND kind IN ('pr_disposition_recorded','pr_release_readback') ORDER BY id", id).map(row => ({ ...row, payload: json(row.payload, null) })),
|
|
1181
1670
|
comments: all(db, 'SELECT author, body, created_at FROM task_comments WHERE task_id = ? ORDER BY id', id),
|
|
1182
1671
|
runs: all(db, 'SELECT * FROM task_runs WHERE task_id = ? ORDER BY id', id).map((r) => ({ ...r, metadata: json(r.metadata, null) })),
|
|
1183
1672
|
events: all(db, 'SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? ORDER BY id', id).map((e) => ({ ...e, payload: json(e.payload, null) })),
|
|
@@ -1189,13 +1678,49 @@ export function showCard(db, id, options = {}) {
|
|
|
1189
1678
|
// existed is withheld there, naming why, so no prompt carries it.
|
|
1190
1679
|
const cap = (text, n) => { text = text ? withheldIfDump(text) : text; return text && text.length > n ? `${text.slice(0, n)}… (${text.length - n} more characters)` : text; };
|
|
1191
1680
|
|
|
1192
|
-
/**
|
|
1681
|
+
/** The PR review request the card's current reviewer run judges, or null: a plan or final review reads the whole arc. */
|
|
1682
|
+
export function reviewedPullRequest(db, id) {
|
|
1683
|
+
const session = currentRun(db, id)?.metadata?.supercode;
|
|
1684
|
+
if (session?.role !== 'reviewer' && session?.slot !== 'reviewer') return null;
|
|
1685
|
+
const request = latestReviewRequest(db, id);
|
|
1686
|
+
return request?.kind === 'pr' ? request : null;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/** Whether the calling session or run is the card's current reviewer: its brief, context and show are its review's. */
|
|
1690
|
+
export function reviewsCard(db, id, { sessionId = null, runId = null } = {}) {
|
|
1691
|
+
const run = currentRun(db, id), session = run?.metadata?.supercode;
|
|
1692
|
+
if (!run || run.ended_at != null || (session?.role !== 'reviewer' && session?.slot !== 'reviewer')) return false;
|
|
1693
|
+
return (runId != null && Number(runId) === run.id) || (sessionId != null && session?.session_id === sessionId);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function reviewBody(request) {
|
|
1697
|
+
const payload = json(request.payload, {});
|
|
1698
|
+
return [
|
|
1699
|
+
`PR review request ${request.id}. Review the named diff against its base, judged against the card's purpose above and the ADRs the change falls under, whether or not the diff edits them.`,
|
|
1700
|
+
...candidatePullRequests(payload.ci).map(p => `${p.pr.url} at ${p.commit ?? p.pr.head}${p.pr.base ? ` against ${p.pr.base}` : ''}${p.repository ? ` (${p.repository})` : ''}.`),
|
|
1701
|
+
...(payload.summary ? [`Summary: ${withheldIfDump(payload.summary)}`] : []),
|
|
1702
|
+
].join('\n');
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
/** What a session starting on a card reads first: the card, its parents' results, earlier attempts and the thread. A PR
|
|
1706
|
+
* review's reviewer (`review`) reads the card itself, then that PR's frozen candidate and its open findings in place of
|
|
1707
|
+
* the rest; each brief replaces the last in its retained conversation. */
|
|
1193
1708
|
export function cardContext(db, id, options = {}) {
|
|
1194
1709
|
const task = cardState(must(db, id), options);
|
|
1195
1710
|
const out = [`# Card ${task.id}: ${task.title}`, ''];
|
|
1196
1711
|
out.push(`Readable id: ${task.readable_id}.`);
|
|
1197
1712
|
out.push(`Status: ${task.status}. Machine: ${task.machine ?? 'unset'}. Workspace: ${task.workspace_kind}${task.workspace_anchor ? ` ${task.workspace_anchor}` : task.workspace_path ? ` ${task.workspace_path}` : ''}${task.branch_name ? ` (branch ${task.branch_name})` : ''}.${task.subtask_of ? ` A subtask of ${task.subtask_of}.` : ''}`);
|
|
1198
1713
|
if (task.body) out.push('', cap(task.body, 8 * 1024));
|
|
1714
|
+
const review = options.review ? reviewedPullRequest(db, id) : null;
|
|
1715
|
+
if (review) {
|
|
1716
|
+
out.push('', reviewBody(review));
|
|
1717
|
+
const findings = openPrReviewFindings(db, id, review);
|
|
1718
|
+
if (findings.length) {
|
|
1719
|
+
out.push('', '## This PR\'s open findings');
|
|
1720
|
+
for (const finding of findings) out.push(`- ${finding.id} [${finding.status}] ${withheldIfDump(finding.title)}${finding.body ? `\n${withheldIfDump(finding.body)}` : ''}`);
|
|
1721
|
+
}
|
|
1722
|
+
return out.join('\n');
|
|
1723
|
+
}
|
|
1199
1724
|
const parents = all(db, 'SELECT t.id, t.title, t.result FROM tasks t JOIN task_links l ON l.parent_id = t.id WHERE l.child_id = ?', id);
|
|
1200
1725
|
if (parents.length) {
|
|
1201
1726
|
out.push('', '## Parent cards');
|
|
@@ -1285,9 +1810,17 @@ export function stats(db, options = {}) {
|
|
|
1285
1810
|
for (const r of all(db, `SELECT t.assignee, ${stateSql} AS board_status, COUNT(*) AS n ${rows} WHERE t.status != 'archived' AND t.assignee IS NOT NULL GROUP BY t.assignee, board_status`)) (byAssignee[r.assignee] ??= {})[r.board_status] = Number(r.n);
|
|
1286
1811
|
const byMachine = {};
|
|
1287
1812
|
for (const r of all(db, `SELECT c.machine, ${stateSql} AS board_status, COUNT(*) AS n ${rows} WHERE t.status != 'archived' AND c.machine IS NOT NULL GROUP BY c.machine, board_status`)) (byMachine[r.machine] ??= {})[r.board_status] = Number(r.n);
|
|
1288
|
-
const
|
|
1813
|
+
const byLane = {};
|
|
1814
|
+
for (const row of all(db, `SELECT COALESCE(c.stage,t.status) AS lane,COUNT(*) AS n ${rows} WHERE t.status != 'archived' GROUP BY lane`)) byLane[row.lane] = Number(row.n);
|
|
1815
|
+
const backlog = { total: 0, by_assignee: {}, by_machine: {} };
|
|
1816
|
+
for (const row of all(db, `SELECT t.assignee,c.machine,COUNT(*) AS n ${rows} WHERE t.status != 'archived' AND c.backlog IS NOT NULL AND c.backlog != 'null' GROUP BY t.assignee,c.machine`)) {
|
|
1817
|
+
const count = Number(row.n); backlog.total += count;
|
|
1818
|
+
if (row.assignee != null) backlog.by_assignee[row.assignee] = (backlog.by_assignee[row.assignee] ?? 0) + count;
|
|
1819
|
+
if (row.machine != null) backlog.by_machine[row.machine] = (backlog.by_machine[row.machine] ?? 0) + count;
|
|
1820
|
+
}
|
|
1821
|
+
const oldest = one(db, `SELECT MIN(t.created_at) AS ts ${rows} WHERE ${stateSql} = 'ready' AND (c.backlog IS NULL OR c.backlog = 'null')`)?.ts;
|
|
1289
1822
|
const t = now();
|
|
1290
|
-
return { by_status: byStatus, by_assignee: byAssignee, by_machine: byMachine, oldest_ready_age_seconds: oldest ? t - Number(oldest) : null, now: t };
|
|
1823
|
+
return { by_status: byStatus, by_lane: byLane, by_assignee: byAssignee, by_machine: byMachine, oldest_ready_age_seconds: oldest ? t - Number(oldest) : null, backlog, now: t };
|
|
1291
1824
|
}
|
|
1292
1825
|
|
|
1293
1826
|
export function runs(db, id, { stateType = null, stateName = null } = {}) {
|
|
@@ -1297,13 +1830,14 @@ export function runs(db, id, { stateType = null, stateName = null } = {}) {
|
|
|
1297
1830
|
}
|
|
1298
1831
|
|
|
1299
1832
|
/** Retain assignment facts in checkpoints, then delete old thread events of done and archived cards; their creation and
|
|
1300
|
-
* branch records (branch_named, worktree_made) stay, by which a reopened card finds its worktree folder
|
|
1833
|
+
* branch records (branch_named, worktree_made) stay, by which a reopened card finds its worktree folder, and so do its
|
|
1834
|
+
* worktree's retirement records (worktree_kept, worktree_removed), by which gc reads a kept worktree again. */
|
|
1301
1835
|
export function gcEvents(board, seconds) {
|
|
1302
1836
|
return tx(board, db=>{
|
|
1303
1837
|
const cut=now()-seconds;
|
|
1304
1838
|
const cards=all(db,`SELECT DISTINCT task_id FROM task_events WHERE kind IN (${AGENT_KIND_LIST.map(()=>'?').join(',')}) AND created_at<? AND task_id IN (SELECT id FROM tasks WHERE status IN ('done','archived'))`,...AGENT_KIND_LIST,cut);
|
|
1305
1839
|
let compacted=0;for(const card of cards)compacted+=compactCardAgentEvents(db,card.task_id);
|
|
1306
|
-
return compacted+Number(run(db,"DELETE FROM task_events WHERE kind NOT IN ('created','branch_named','worktree_made') AND created_at<? AND task_id IN (SELECT id FROM tasks WHERE status IN ('done','archived'))",cut).changes);
|
|
1840
|
+
return compacted+Number(run(db,"DELETE FROM task_events WHERE kind NOT IN ('created','branch_named','worktree_made','worktree_kept','worktree_removed') AND created_at<? AND task_id IN (SELECT id FROM tasks WHERE status IN ('done','archived'))",cut).changes);
|
|
1307
1841
|
});
|
|
1308
1842
|
}
|
|
1309
1843
|
|
|
@@ -1352,6 +1886,7 @@ export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, stran
|
|
|
1352
1886
|
if (lost >= 2) out.push({ kind: 'repeated_session_loss', severity: 'warning', title: `its session was lost ${lost}x in 24h`, data: { lost }, actions: [`supercode workflow set ${task.id} machine <machine>`] });
|
|
1353
1887
|
const crashes = events.filter((e) => e.kind === 'crashed' && t - e.created_at < 24 * 3600).length;
|
|
1354
1888
|
if (crashes >= 2) out.push({ kind: 'repeated_crashes', severity: 'error', title: `its worker crashed ${crashes}x in 24h`, data: { crashes }, actions: [`supercode workflow log ${task.id}`] });
|
|
1889
|
+
if (task.backlog) out.push({ kind: 'backlog', severity: 'info', title: holdDescription(task), data: { backlog: task.backlog, lane: task.stage ?? task.workflow_status ?? task.status }, actions: [`supercode workflow plan ${task.id}`] });
|
|
1355
1890
|
if (task.status === 'paused') out.push({ kind: 'paused', severity: 'info', title: holdDescription(task), data: { paused: task.paused }, actions: [`supercode workflow resume ${task.id}`] });
|
|
1356
1891
|
if (task.status === 'blocked' && (!nativeHolds || task.blocked_by.length)) {
|
|
1357
1892
|
const since = [...events].reverse().find((e) => e.kind === 'blocked' || e.kind === 'gave_up')?.created_at ?? task.created_at;
|
|
@@ -1359,7 +1894,7 @@ export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, stran
|
|
|
1359
1894
|
const cycles = events.filter((e) => e.kind === 'unblocked' && t - e.created_at < 24 * 3600).length;
|
|
1360
1895
|
if (cycles >= 3) out.push({ kind: 'block_unblock_cycling', severity: 'warning', title: `block→unblock cycled ${cycles}x in 24h`, data: { cycles }, actions: [] });
|
|
1361
1896
|
}
|
|
1362
|
-
if (!task.detached && task.status === 'ready' && task.assignee) {
|
|
1897
|
+
if (!task.detached && !task.backlog && task.status === 'ready' && task.assignee) {
|
|
1363
1898
|
const since = [...events].reverse().find((e) => ['created', 'promoted', 'unblocked', 'reclaimed', 'session_lost', 'promoted_manual'].includes(e.kind))?.created_at ?? task.created_at;
|
|
1364
1899
|
const age = t - since;
|
|
1365
1900
|
if (age >= strandedSeconds) {
|
|
@@ -1394,8 +1929,11 @@ export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, stran
|
|
|
1394
1929
|
for (const row of all(db, "SELECT kind, payload FROM task_events WHERE task_id = ? AND kind IN ('notice_filed', 'session_notice_filed', 'actor_notice_filed', 'board_message_sent') ORDER BY id DESC LIMIT 200", task.id)) {
|
|
1395
1930
|
const p = json(row.payload, {}) ?? {};
|
|
1396
1931
|
const what = row.kind === 'notice_filed' ? `its ${p.kind} notice` : row.kind === 'board_message_sent' ? 'its board mail' : `its ${row.kind.replace('_filed', '').replace('_', ' ')}`;
|
|
1397
|
-
const
|
|
1398
|
-
|
|
1932
|
+
const recorded = p.message_id && expiredMail?.get(p.message_id), waiting = p.message_id && waitingMail?.get(p.message_id);
|
|
1933
|
+
// a held failure its carrier later saw delivered is over (mail/carrier.mjs `delivered_at_ms`)
|
|
1934
|
+
const expiry = recorded && !(recorded.held && recorded.delivered_at_ms != null) ? recorded : null;
|
|
1935
|
+
if (expiry?.held) out.push({ kind: 'mail_expired', severity: 'warning', title: `${what} to ${expiry.to} is unconfirmed since ${at(expiry.held_since_ms ?? expiry.expired_at_ms)}: ${expiry.reason}; its recipient's input queue still holds it`, data: { message_id: p.message_id, to: expiry.to, held: true }, actions: [] });
|
|
1936
|
+
else if (expiry) out.push({ kind: 'mail_expired', severity: 'warning', title: `${what} to ${expiry.to} was never delivered: ${expiry.reason} (${expiry.attempts} attempts); it stays unread in that mailbox`, data: { message_id: p.message_id, to: expiry.to, attempts: expiry.attempts, expired_at_ms: expiry.expired_at_ms }, actions: [] });
|
|
1399
1937
|
else if (waiting) out.push({ kind: 'mail_waiting', severity: 'warning', title: waiting.waiting_for ? `${what} to ${waiting.to} is waiting for ${waiting.waiting_for} since ${at(waiting.since_ms)} (offline)` : `${what} to ${waiting.to} is waiting since ${at(waiting.since_ms)}: ${waiting.last_error ?? 'not delivered yet'}`, data: { message_id: p.message_id, to: waiting.to, waiting_for: waiting.waiting_for, since_ms: waiting.since_ms }, actions: [] });
|
|
1400
1938
|
}
|
|
1401
1939
|
}
|