@volter/supercode-orchestrator 0.5.225 → 0.5.227
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 +29 -18
- package/adapters/rh2.mjs +6 -5
- package/bin/connection-runner.mjs +46 -12
- package/bin/orchestrator.mjs +3 -3
- package/bin/runtime.mjs +6 -1
- package/board/agent-declarations.mjs +1 -1
- package/board/args.mjs +8 -1
- package/board/call.mjs +2 -2
- package/board/ci.mjs +158 -54
- package/board/cli.mjs +175 -53
- package/board/custody.mjs +5 -1
- package/board/dispatch.mjs +969 -256
- package/board/doors.mjs +7 -2
- package/board/engine.mjs +264 -48
- package/board/files.mjs +4 -1
- package/board/fleet-listeners.mjs +3 -1
- package/board/fleet-observer.mjs +3 -2
- package/board/generation-owner.mjs +40 -10
- package/board/generation.mjs +17 -4
- package/board/machine-fleet.mjs +33 -9
- package/board/notices.mjs +10 -2
- package/board/published.mjs +107 -2
- package/board/readable-id.mjs +25 -2
- package/board/runtime-door.mjs +2 -2
- package/board/session-work.mjs +15 -1
- package/board/storage-contract.mjs +41 -3
- package/board/store.mjs +49 -7
- package/board/tools.mjs +1 -1
- package/board/workflow.mjs +197 -70
- package/board/workspace.mjs +99 -13
- package/board/ztrack.mjs +42 -14
- package/connection.mjs +252 -32
- package/index.d.ts +5 -1
- package/installed-apps.mjs +11 -5
- package/loop.mjs +6 -0
- package/mcp/hermes-kanban-tools.json +10 -1
- package/package.json +6 -6
- package/reducer.mjs +31 -2
package/board/doors.mjs
CHANGED
|
@@ -43,7 +43,9 @@ async function opened(machine) {
|
|
|
43
43
|
* harness's own service, spoken by its SDK over the machine's harness stream; every other door is the daemon's node
|
|
44
44
|
* service. Throws the door's refusal (with its code).
|
|
45
45
|
*/
|
|
46
|
-
|
|
46
|
+
// `signal`: a caller that stops wanting the answer (a parked board generation's read) ends the call at once: the phase in
|
|
47
|
+
// progress is aborted with the signal's reason, as its own deadline would abort it.
|
|
48
|
+
export async function machineCall(machine, method, params = {}, { timeoutMs = 60_000, signal = null } = {}) {
|
|
47
49
|
if (!method.startsWith('harness.v1.')) throw new Error('a machine door is a harness.v1 method');
|
|
48
50
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error('a machine door timeout is a positive finite number of milliseconds');
|
|
49
51
|
const started = performance.now();
|
|
@@ -57,15 +59,18 @@ export async function machineCall(machine, method, params = {}, { timeoutMs = 60
|
|
|
57
59
|
code: 'REQUEST_TIMEOUT', phase: name, requestNotSent: name !== 'request',
|
|
58
60
|
});
|
|
59
61
|
if (remaining <= 0) throw expired();
|
|
62
|
+
if (signal?.aborted) throw signal.reason;
|
|
60
63
|
const controller = new AbortController();
|
|
61
|
-
let timer;
|
|
64
|
+
let timer, onAbort;
|
|
62
65
|
try {
|
|
63
66
|
const timeout = new Promise((_, reject) => {
|
|
64
67
|
timer = setTimeout(() => { const error = expired(); controller.abort(error); reject(error); }, remaining);
|
|
68
|
+
if (signal) { onAbort = () => { controller.abort(signal.reason); reject(signal.reason); }; signal.addEventListener('abort', onAbort, { once: true }); }
|
|
65
69
|
});
|
|
66
70
|
return await Promise.race([work({ timeoutMs: remaining, signal: controller.signal }), timeout]);
|
|
67
71
|
} finally {
|
|
68
72
|
clearTimeout(timer);
|
|
73
|
+
if (onAbort) signal.removeEventListener('abort', onAbort);
|
|
69
74
|
noteStep(`door ${method}: ${name}`, performance.now() - at);
|
|
70
75
|
}
|
|
71
76
|
};
|
package/board/engine.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { allocateReadableId } from './readable-id.mjs';
|
|
1
|
+
import { allocateReadableId, cardReadableId, requireReadableId, validateReadableId } from './readable-id.mjs';
|
|
2
2
|
// The board's transitions (docs/architecture/orchestrator.md §2.9): what a card may do, written for supercode and
|
|
3
3
|
// kept to Hermes Kanban's lifecycle so the two read each other's boards. Statuses: triage · todo · scheduled ·
|
|
4
4
|
// ready · running · blocked · review · done · archived. A card with a parent that is not done waits in `todo`
|
|
@@ -9,7 +9,7 @@ import { allocateReadableId } from './readable-id.mjs';
|
|
|
9
9
|
// Each function takes an open board (`store.mjs`) and runs in one write transaction. Nothing here launches,
|
|
10
10
|
// observes or sends: the dispatcher (`dispatch.mjs`) does, and records what it did through these.
|
|
11
11
|
import { current } from './call.mjs';
|
|
12
|
-
import { detachedCard, detachedSession, personCustody, sessionAddress, sessionOperation } from './custody.mjs';
|
|
12
|
+
import { detachedCard, detachedSession, personCustody, personOf, sessionAddress, sessionOperation } from './custody.mjs';
|
|
13
13
|
import { executionOf, readingContext } from './session-work.mjs';
|
|
14
14
|
import { cardState, cardStateSql, holdDescription } from './card-state.mjs';
|
|
15
15
|
import { spawn, execFile } from 'node:child_process';
|
|
@@ -18,9 +18,9 @@ import { join, resolve } from 'node:path';
|
|
|
18
18
|
import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace, plainWorkspace } from './workspace.mjs';
|
|
19
19
|
import { DatabaseSync } from 'node:sqlite';
|
|
20
20
|
import { CARD_VISIBILITY_SQL } from './visibility.mjs';
|
|
21
|
-
import { view, actorOf, principalOf,
|
|
21
|
+
import { view, actorOf, principalOf, cardBranch, CARD_NUMBERS, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
|
|
22
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, workflowFor } from './workflow.mjs';
|
|
23
|
+
import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, reviewerOf, eventTo, fire, validateEvent, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, transitionsFor, workflowFor } from './workflow.mjs';
|
|
24
24
|
import { refuseCredentialDump, withheldIfDump } from './credential-guard.mjs';
|
|
25
25
|
import { AGENT_KIND_LIST, compactCardAgentEvents } from './published.mjs';
|
|
26
26
|
|
|
@@ -42,16 +42,29 @@ const all = (db, sql, ...args) => db.prepare(sql).all(...args);
|
|
|
42
42
|
const run = (db, sql, ...args) => db.prepare(sql).run(...args);
|
|
43
43
|
|
|
44
44
|
// A card's number: what the store gave it when it was made (store.mjs layOutNumbers), read with its board's key
|
|
45
|
-
// (`VO-42`, store.mjs boardKey). A person names a card by it wherever its id is taken (`VO-42`,
|
|
46
|
-
// and every view of a card shows
|
|
45
|
+
// (`VO-42`, store.mjs boardKey). A person names a card by it, or by its readable id, wherever its id is taken (`VO-42`,
|
|
46
|
+
// `vo-42`, `#42`, `42`, `voice-calls`), and every view of a card shows its readable id first, then its number and id.
|
|
47
47
|
/** Card `n`'s id on this board, or null when no card here has that number. */
|
|
48
48
|
export const cardByNumber = (db, n) => one(db, `SELECT t.id FROM ${CARD_NUMBERS} n JOIN tasks t ON t.id = n.task_id WHERE n.number = ?`, Number(n))?.id ?? null;
|
|
49
49
|
/** The number of card `id`, or null. */
|
|
50
50
|
export const numberOf = (db, id) => one(db, `SELECT number FROM ${CARD_NUMBERS} WHERE task_id = ?`, id)?.number ?? null;
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
/** A card as a person reads it: its readable id first, then its keyed number (store.mjs boardKey) and its id
|
|
52
|
+
* (`voice-calls (VO-42 t_4406c88a)`); one with no readable id by its number and id (`VO-42 t_4406c88a`). */
|
|
53
|
+
export const cardLabel = (key, number, id, readableId = null) => {
|
|
54
|
+
const ref = number ? `${key}-${number} ${id}` : String(id);
|
|
55
|
+
return readableId ? `${readableId} (${ref})` : ref;
|
|
56
|
+
};
|
|
57
|
+
/** Cards `ids` as a person reads them (cardLabel): id → label. A subtask, which no person named, by its number and id. */
|
|
58
|
+
export const labelsOf = (db, key, ids) => new Map(ids.length ? all(db, `SELECT t.id, t.readable_id, n.number, c.subtask_of FROM tasks t LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id
|
|
59
|
+
LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.id IN (SELECT value FROM json_each(?))`, JSON.stringify([...new Set(ids)]))
|
|
60
|
+
.map((r) => [r.id, cardLabel(key, r.number, r.id, r.subtask_of ? null : r.readable_id)]) : []);
|
|
61
|
+
/** The card named `readableId` on this board: the open one holding it, else the one card that ever did; null otherwise. */
|
|
62
|
+
export const cardByReadableId = (db, readableId) => {
|
|
63
|
+
const open = one(db, "SELECT id FROM tasks WHERE readable_id = ? AND status NOT IN ('done','cancelled','archived')", readableId)?.id;
|
|
64
|
+
if (open) return open;
|
|
65
|
+
const ever = all(db, 'SELECT id FROM tasks WHERE readable_id = ? LIMIT 2', readableId);
|
|
66
|
+
return ever.length === 1 ? ever[0].id : null;
|
|
67
|
+
};
|
|
55
68
|
|
|
56
69
|
export function getTask(db, id) {
|
|
57
70
|
const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
|
|
@@ -205,6 +218,7 @@ export function bodyTasks(body) {
|
|
|
205
218
|
* before the card is filed: the filing (createCard) is one write that asks no machine. Answers the spec to file.
|
|
206
219
|
*/
|
|
207
220
|
export async function prepareCard(board, spec) {
|
|
221
|
+
if (!spec.subtaskOf) requireReadableId(spec.readableId);
|
|
208
222
|
const ws = parseWorkspace(spec.workspace ?? 'scratch');
|
|
209
223
|
const project = spec.project ? projectOf(board.root, spec.project) : null;
|
|
210
224
|
if (project) {
|
|
@@ -222,6 +236,8 @@ export async function prepareCard(board, spec) {
|
|
|
222
236
|
export function createCard(board, spec) {
|
|
223
237
|
const title = String(spec.title ?? '').trim();
|
|
224
238
|
if (!title) throw new Error('a card has a title');
|
|
239
|
+
// A card is named by its creator (readable-id.mjs); a subtask, a row under its card that no person names, by its title.
|
|
240
|
+
if (!spec.subtaskOf) requireReadableId(spec.readableId);
|
|
225
241
|
refuseCredentialDump('the card', { title, body: spec.body ?? null });
|
|
226
242
|
const steps = spec.subtaskOf ? [] : bodyTasks(spec.body).tasks;
|
|
227
243
|
if (steps.length) spec = { ...spec, body: bodyTasks(spec.body).body };
|
|
@@ -275,8 +291,9 @@ export function createCard(board, spec) {
|
|
|
275
291
|
assertWorkspaceOwner(db, workspaceTask, checked);
|
|
276
292
|
// The repository declaration stays separate from the resolved worktree path.
|
|
277
293
|
if (project && ws.kind === 'scratch') ws.kind = 'worktree';
|
|
278
|
-
// A worktree card's branch is named
|
|
279
|
-
|
|
294
|
+
// A worktree card's branch is named by its readable id (below; store.mjs cardBranch), unless its creator named one; a
|
|
295
|
+
// subtask's, which no person names, by its id.
|
|
296
|
+
const namedBranch = ws.kind === 'worktree' && !spec.branch;
|
|
280
297
|
spec = { ...spec, project: project?.id ?? null };
|
|
281
298
|
const t = now();
|
|
282
299
|
run(db, `INSERT INTO tasks (id, title, body, assignee, status, priority, created_by, created_at, workspace_kind, workspace_path, branch_name,
|
|
@@ -295,10 +312,10 @@ export function createCard(board, spec) {
|
|
|
295
312
|
run(db, `DELETE FROM ${CARD_NUMBERS} WHERE task_id = ?`, id);
|
|
296
313
|
if (taken != null) run(db, 'UPDATE sqlite_sequence SET seq = ? WHERE name = ? AND seq = ?', taken - 1, CARD_NUMBERS, taken);
|
|
297
314
|
}
|
|
298
|
-
const readableId = allocateReadableId(db, title, spec.readableId ?? null, id);
|
|
315
|
+
const readableId = arc ? allocateReadableId(db, title, spec.readableId ?? null, id) : cardReadableId(db, spec.readableId, id);
|
|
299
316
|
run(db, 'UPDATE tasks SET readable_id=? WHERE id=?', readableId, id);
|
|
300
|
-
const
|
|
301
|
-
if (namedBranch) run(db, 'UPDATE tasks SET branch_name = ? WHERE id = ?',
|
|
317
|
+
const named = namedBranch && !arc ? cardBranch(db, id, readableId, project ? `${project.slug}/` : '') : null;
|
|
318
|
+
if (namedBranch) run(db, 'UPDATE tasks SET branch_name = ? WHERE id = ?', named ?? `wt/${id}`, id);
|
|
302
319
|
const machine = spec.machine ?? arc?.machine ?? null;
|
|
303
320
|
run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root,tags,requested_by_unverified) VALUES (?,?,?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.workspaceRoot ?? null,
|
|
304
321
|
tags.length ? JSON.stringify(tags) : null, spec.requestedByUnverified ? JSON.stringify(spec.requestedByUnverified) : null);
|
|
@@ -309,11 +326,15 @@ export function createCard(board, spec) {
|
|
|
309
326
|
every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
|
|
310
327
|
event(db, id, 'created', { creator: actorOf(), created_by: one(db, 'SELECT created_by FROM tasks WHERE id=?', id)?.created_by ?? null, assignee: spec.assignee || null,
|
|
311
328
|
...(spec.reviewer != null ? { reviewer: spec.reviewer } : {}), status, parents, tags, ...(spec.requestedByUnverified ? { requested_by_unverified: spec.requestedByUnverified } : {}), ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
|
|
329
|
+
// its branch is claimed until its worktree is made: its machine takes none of that name that already exists
|
|
330
|
+
// (workspace.mjs branchClaim)
|
|
331
|
+
if (named) event(db, id, 'branch_named', { branch: named, repository: declared.repository ?? null });
|
|
312
332
|
if ((spec.noStart || spec.adopt) && !arc) event(db, id, 'dispatch_held', { session: spec.adopt ?? null, reason: spec.adopt ? 'awaiting adoption' : 'created with --no-start' });
|
|
313
333
|
if (parents.length) inheritSubscriptions(db, id, parents);
|
|
314
334
|
if (arc) {
|
|
315
|
-
// the arc's
|
|
316
|
-
|
|
335
|
+
// the arc's session works its subtasks and hears them; the arc's other followers follow the arc, not each subtask
|
|
336
|
+
// (a subtask's watchers are its own: its creator, its worker, whoever comments on it or is mentioned there)
|
|
337
|
+
inheritSubscriptions(db, id, [arc.id], { workers: true, only: 'worker' });
|
|
317
338
|
event(db, arc.id, 'subtask_added', { subtask: id, title });
|
|
318
339
|
}
|
|
319
340
|
if (initial === 'running' && arc && entry.workflow.run) setStatus(db, entry.workflow, id, 'running');
|
|
@@ -421,12 +442,14 @@ export function extendClaim(db, id, { ttl, reason, extra = {} }) {
|
|
|
421
442
|
|
|
422
443
|
/**
|
|
423
444
|
* A completed card's workspace goes, as Hermes cleans up on completion: a scratch directory under the board's own
|
|
424
|
-
* workspaces, or a worktree (its branch stays); never while a child still waits on it
|
|
445
|
+
* workspaces, or a worktree (its branch stays); never while a child still waits on it or another open card works in
|
|
446
|
+
* it, never a `dir`.
|
|
425
447
|
*/
|
|
426
448
|
async function cleanupWorkspace(board, id) {
|
|
427
449
|
const task = view(board, (db) => getTask(db, id));
|
|
428
450
|
if (!task?.workspace_path || !isLocalWorkspace(task) || !['scratch', 'worktree'].includes(task.workspace_kind)) return;
|
|
429
|
-
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') LIMIT 1", id)
|
|
451
|
+
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') LIMIT 1", id)
|
|
452
|
+
?? one(db, "SELECT 1 FROM tasks WHERE workspace_path = ? AND id <> ? AND status NOT IN ('done', 'archived', 'cancelled') LIMIT 1", task.workspace_path, id));
|
|
430
453
|
if (waiting || !existsSync(task.workspace_path)) return;
|
|
431
454
|
// awaited, never a wait that holds the event loop
|
|
432
455
|
if (task.workspace_kind === 'worktree') { await new Promise((resolve) => execFile('git', ['-C', task.workspace_path, 'worktree', 'remove', '--force', task.workspace_path], { encoding: 'utf8' }, () => resolve())); return; }
|
|
@@ -459,7 +482,12 @@ async function cleanupWorkspace(board, id) {
|
|
|
459
482
|
export function setCaller(fn) { current().roles = fn; }
|
|
460
483
|
const callerRoles = (db, cardId) => current().roles(db, cardId);
|
|
461
484
|
|
|
462
|
-
|
|
485
|
+
/**
|
|
486
|
+
* Fire `name` on card `id` as its caller (or `roles`), in one board write with what the move settles around it.
|
|
487
|
+
* `instead(db, error)`, when given, is asked in that same write when the event is refused: it answers another event's
|
|
488
|
+
* name to fire in its place (read and decided against the same state the refusal was), or null to keep the refusal.
|
|
489
|
+
*/
|
|
490
|
+
export async function act(board, id, name, { roles = null, payload = {}, extra = {}, instead = null } = {}) {
|
|
463
491
|
assertSourceReceipt(payload);
|
|
464
492
|
const entry = await loadWorkflow(board.root);
|
|
465
493
|
if (name === 'comment' && (!String(payload.body ?? '').trim() || !String(payload.author ?? '').trim())) throw new Error('a comment has an author and body');
|
|
@@ -472,9 +500,18 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
|
|
|
472
500
|
if (payload.ci?.required && !payload.ci.ready && (name !== 'request_state' || evidencePhase === 'review_verdict')) throw new Error(payload.ci.reason ?? 'The PR candidate could not be verified.');
|
|
473
501
|
const result = tx(board, (db) => {
|
|
474
502
|
roles ??= callerRoles(db, id);
|
|
475
|
-
|
|
503
|
+
let r;
|
|
504
|
+
try { r = fire(db, board, entry, id, name, { roles, payload, extra }); }
|
|
505
|
+
catch (error) {
|
|
506
|
+
const other = (error.refusal || error.noTransition) && instead ? instead(db, error, entry) : null;
|
|
507
|
+
if (!other) throw error;
|
|
508
|
+
r = { ...fire(db, board, entry, id, other.name, { roles, payload, extra }), instead: other };
|
|
509
|
+
}
|
|
476
510
|
// Authorization and the comment row share one transaction: a refusal stores nothing.
|
|
477
|
-
if (name === 'comment')
|
|
511
|
+
if (name === 'comment') {
|
|
512
|
+
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
|
+
joinThread(db, entry, id, payload.author.trim(), payload.body);
|
|
514
|
+
}
|
|
478
515
|
// the cards that wait on it, and its subtasks, see it change
|
|
479
516
|
// Card blockers are mirrored to task_links by relation writes and document imports. Its parent key is the reverse
|
|
480
517
|
// lookup; reading every card's blocked_by JSON on each public verb only rediscovers the same children.
|
|
@@ -485,7 +522,7 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
|
|
|
485
522
|
await runEffects(board, result.effects);
|
|
486
523
|
// A card this verb closed ends its agents' assignments; the verb hands the ends on itself (agent-declarations.mjs).
|
|
487
524
|
if (result.moved) { const { endFinishedAgents } = await import('./agent-declarations.mjs'); await endFinishedAgents(board, [id]); }
|
|
488
|
-
return { from: result.from, to: result.to, moved: result.moved, ...(result.commentId ? { commentId: result.commentId } : {}) };
|
|
525
|
+
return { from: result.from, to: result.to, moved: result.moved, ...(result.commentId ? { commentId: result.commentId } : {}), ...(result.instead ? { instead: result.instead } : {}) };
|
|
489
526
|
}
|
|
490
527
|
|
|
491
528
|
export async function requestStateMany(board, ids, payload = {}) {
|
|
@@ -496,6 +533,8 @@ export async function requestStateMany(board, ids, payload = {}) {
|
|
|
496
533
|
const prepared = new Map(await Promise.all(targets.map(async id => {
|
|
497
534
|
const roles = callerRoles(board.db, id);
|
|
498
535
|
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.`);
|
|
499
538
|
view(board, db => validateEvent(db, board, entry, id, 'request_state', { roles, payload, localOnly: true }));
|
|
500
539
|
const evidence = await inspectCompletionEvidence(board, id, entry, payload, phase);
|
|
501
540
|
// An ordinary request mails a decision to the manager, not a completion.
|
|
@@ -686,7 +725,8 @@ function invalidateDescendants(db, board, id) {
|
|
|
686
725
|
for (const row of rows) {
|
|
687
726
|
if (!['ready', 'review', 'running', 'done'].includes(row.status)) continue;
|
|
688
727
|
const resume = row.status === 'review' ? 'review' : row.status === 'running' ? retryStatus(db, row.id) : 'ready';
|
|
689
|
-
|
|
728
|
+
// a card holds a current run in review as well as in running: whichever it holds is ended with the card's pull back
|
|
729
|
+
const runId = endRun(db, row.id, { outcome: 'reclaimed', status: 'todo', summary: `ancestor ${id} reopened` });
|
|
690
730
|
run(db, 'UPDATE tasks SET completed_at = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, current_run_id = NULL, consecutive_failures = 0 WHERE id = ?', row.id);
|
|
691
731
|
setStatus(db, workflow, row.id, 'todo');
|
|
692
732
|
event(db, row.id, 'descendant_invalidated', { ancestor: id, prior_status: row.status, new_status: 'todo', resume_status: resume }, runId);
|
|
@@ -702,20 +742,75 @@ function invalidateDescendants(db, board, id) {
|
|
|
702
742
|
export const complete = async (board, id, options = {}) => {
|
|
703
743
|
assertSourceReceipt(options);
|
|
704
744
|
const { result = null, summary = null, metadata = null, roles, runId = null, commit = null, branch = null, pr = null, pr_head = null, reviewed_sha = null } = options;
|
|
745
|
+
// Only a reviewer's verdict reads PRs from a completion (ci.completionPhase); anyone else's would be dropped unread,
|
|
746
|
+
// so it is refused: an arc's PRs are declared on the arc with comment --pr URL --pr-head SHA.
|
|
747
|
+
// A person's card closes only on that person's completion (personDone, as the board's platform event): no session,
|
|
748
|
+
// creator or manager completes it through this verb.
|
|
749
|
+
const person = view(board, (db) => personOf(getTask(db, id)?.assignee));
|
|
750
|
+
if (person) throw Object.assign(new Error(`${id} is assigned to ${person}: it closes when that person completes it in the Workplace (person-done), never by complete. Nothing was completed.`), { refusal: true });
|
|
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
|
+
}
|
|
705
755
|
return await act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId, commit, branch, pr, pr_head, reviewed_sha } });
|
|
706
756
|
};
|
|
757
|
+
/**
|
|
758
|
+
* A person completed the card assigned to them (`person:<Teams principal>`), as the Workplace reported it: the board sends
|
|
759
|
+
* its own `complete` as the platform with the person and their answer, and the workflow closes the card only when that
|
|
760
|
+
* person is the one it is assigned to. The cards blocked on it are released by `dependency_resolved`. The same report
|
|
761
|
+
* again (a retried delivery) answers `already`.
|
|
762
|
+
*/
|
|
763
|
+
export const personDone = async (board, id, { person, result, task = null } = {}) => {
|
|
764
|
+
const refused = (message) => Object.assign(new Error(message), { refusal: message });
|
|
765
|
+
if (!/^person:\S+$/.test(String(person ?? ''))) throw refused('a person\'s completion names the person: --person person:<principal>');
|
|
766
|
+
if (!String(result ?? '').trim()) throw refused('a person\'s completion carries their answer (--result)');
|
|
767
|
+
const done = view(board, (db) => {
|
|
768
|
+
const card = getTask(db, id);
|
|
769
|
+
if (!card) throw refused(`no card ${id}`);
|
|
770
|
+
// done, or archived after it (stored as archived): the person's own completion recorded on it is the same report
|
|
771
|
+
if (!['done', 'archived'].includes(card.status)) return false;
|
|
772
|
+
const completed = db.prepare("SELECT payload FROM task_events WHERE task_id = ? AND kind = 'completed' ORDER BY id DESC LIMIT 1").get(id);
|
|
773
|
+
return json(completed?.payload, {})?.person === person;
|
|
774
|
+
});
|
|
775
|
+
if (done) return { already: true };
|
|
776
|
+
return { already: false, ...await act(board, id, 'complete', { roles: ['platform'], payload: { person, result: String(result).trim(), task } }) };
|
|
777
|
+
};
|
|
707
778
|
// a block names the one thing that ends it: a card it waits on (`on`), or the message whose answer it waits for (`waiting_on`)
|
|
708
779
|
export const block = async (board, id, { reason = null, on = null, waitingOn = null, kind = 'needs_input', roles, runId = null } = {}) =>
|
|
709
780
|
await act(board, id, 'block', { roles, payload: { reason, on, kind, waiting_on: waitingOn, run_id: runId } });
|
|
710
781
|
export const unblock = (board, id, { reason = null, on = null, roles } = {}) => act(board, id, 'unblock', { roles, payload: { reason, on } });
|
|
711
782
|
export const requestReview = async (board, id, options = {}) => {
|
|
712
783
|
assertSourceReceipt(options);
|
|
713
|
-
const { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, branch = null, pr = null, pr_head = null } = options;
|
|
714
|
-
|
|
784
|
+
const { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, branch = null, pr = null, pr_head = null, plan = false } = options;
|
|
785
|
+
// What a session's request asks for is what it says, never its summary's words: `--plan` asks the plan review of the
|
|
786
|
+
// 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
|
+
// clauses read `review`; a request naming several PRs asks neither.
|
|
788
|
+
const one = [].concat(pr ?? []).length === 1 && [].concat(pr_head ?? []).length === 1;
|
|
789
|
+
const review = plan ? 'plan' : one ? 'pr' : null;
|
|
790
|
+
return await act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId, commit, branch, pr, pr_head, review, plan: Boolean(plan) } });
|
|
715
791
|
};
|
|
716
792
|
export const requestChanges = (board, id, { reason = null, reviewer = null, roles } = {}) => act(board, id, 'request_changes', { roles, payload: { reason, reviewer } });
|
|
717
793
|
export const reopen = (board, id, { author = 'user', reason = null, roles } = {}) => act(board, id, 'reopen', { roles, payload: { author, reason } });
|
|
718
|
-
|
|
794
|
+
/** The manager's door out of review: the card goes back to its coder with no verdict, its review run ending as returned
|
|
795
|
+
* (the workflow's `return_review`, where it declares one). */
|
|
796
|
+
export const returnReview = (board, id, { reason = null, roles } = {}) => act(board, id, 'return_review', { roles, payload: { reason } });
|
|
797
|
+
/**
|
|
798
|
+
* Void the latest verdict so the board reviews again; or, for a review whose run is still open with no verdict (its
|
|
799
|
+
* reviewer stalled, VO-3259 run 2408), restart that run: the workflow's own `reclaim` ends it and closes its reviewer
|
|
800
|
+
* session, the card stays in review, and the board's next review run resumes that same reviewer session with the
|
|
801
|
+
* round's brief. Nothing is voided there, because no verdict was given. Answers the move, with `restarted_run` when it
|
|
802
|
+
* restarted one.
|
|
803
|
+
*/
|
|
804
|
+
export async function reopenReview(board, id, { roles, reason = null } = {}) {
|
|
805
|
+
// the refusal, the check that the open run is a reviewer's and the restart are one write: no verdict lands between
|
|
806
|
+
const moved = await act(board, id, 'reopen_review', { roles, payload: { reason }, instead: (db, _error, entry) => {
|
|
807
|
+
const run = currentRun(db, id);
|
|
808
|
+
const stays = transitionsFor(entry.workflow, 'review', 'reclaim').some((t) => !t.target || t.target === 'review');
|
|
809
|
+
return run && run.ended_at == null && run.metadata?.supercode?.slot === 'reviewer' && stays ? { name: 'reclaim', run: run.id } : null;
|
|
810
|
+
} });
|
|
811
|
+
const { instead, ...rest } = moved;
|
|
812
|
+
return instead ? { ...rest, restarted_run: instead.run } : rest;
|
|
813
|
+
}
|
|
719
814
|
export const schedule = (board, id, reason = null, { roles } = {}) => act(board, id, 'schedule', { roles, payload: { reason } });
|
|
720
815
|
export const promote = (board, id, { actor = 'user', reason = null, force = false, roles } = {}) => act(board, id, 'promote', { roles, payload: { actor, reason, force } });
|
|
721
816
|
export const reclaim = (board, id, reason = null, { roles } = {}) => act(board, id, 'reclaim', { roles, payload: { reason } });
|
|
@@ -734,8 +829,13 @@ export async function specify(board, id, { title = null, body = null, readableId
|
|
|
734
829
|
if (branch != null && ws.kind === 'scratch') throw new Error('a scratch workspace has no repository branch');
|
|
735
830
|
declaration = { kind: ws.kind, ...(await declareWorkspace(ws.kind, ws.path, target, branch)), ...(machine != null && target !== task.machine ? { machine: target } : {}) };
|
|
736
831
|
if (declaration.repository === task.workspace_anchor) declaration.branch ??= task.branch_name;
|
|
737
|
-
const
|
|
738
|
-
|
|
832
|
+
const named = task.subtask_of ? null : readableId ?? task.readable_id;
|
|
833
|
+
// a retarget's new branch is named as a new card's is, under its project's `<slug>/` (createCard)
|
|
834
|
+
if (declaration.kind === 'worktree' && declaration.branch == null && named) {
|
|
835
|
+
const project = task.project_id ? projectOf(board.root, task.project_id) : null;
|
|
836
|
+
Object.assign(declaration, { branch: cardBranch(board.db, id, named, project ? `${project.slug}/` : ''), named: true });
|
|
837
|
+
}
|
|
838
|
+
if (declaration.kind === 'worktree') declaration.branch ??= `wt/${id}`;
|
|
739
839
|
if (declaration.kind === 'dir') declaration.path = (await checkWorkspace(board, { ...task, machine: target, workspace_kind: declaration.kind, workspace_anchor: declaration.repository, workspace_path: declaration.path, branch_name: declaration.branch })).path;
|
|
740
840
|
}
|
|
741
841
|
// an arc's new task lines are filed as its subtasks once the specify itself is accepted
|
|
@@ -851,11 +951,120 @@ export async function comment(board, id, author, body, { roles, pr = null, pr_he
|
|
|
851
951
|
* Subscribe `address` (a supercode session address) to a card's events from now on. `role` is `creator` (told of
|
|
852
952
|
* starts, completion and blocks) or `worker` (told of updates to the card it works).
|
|
853
953
|
*/
|
|
854
|
-
export function subscribeIn(db, id, address, role = 'creator') {
|
|
954
|
+
export function subscribeIn(db, id, address, role = 'creator', { explicit = false, own = false } = {}) {
|
|
955
|
+
// a session that is an agent's main follows a card under its agent's address: one follower, one subscription
|
|
956
|
+
if (!['worker', 'dependency'].includes(role)) address = followerAddress(address);
|
|
855
957
|
if (personCustody(db, address)) return;
|
|
856
958
|
const cursor = one(db, 'SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?', id).c;
|
|
857
959
|
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)
|
|
858
|
-
VALUES (?, 'supercode', ?, '', 'dm', 'notify+wake', ?, ?, ?)`, id, address, JSON.stringify({ role }), now(), cursor);
|
|
960
|
+
VALUES (?, 'supercode', ?, '', 'dm', 'notify+wake', ?, ?, ?)`, id, address, JSON.stringify({ role, ...(own ? { own: true } : {}) }), now(), cursor);
|
|
961
|
+
// the card's own creator's subscription says so (`own`): it is the card's, never a copy of its arc's followers
|
|
962
|
+
// (so does an explicit notify-subscribe: a follower who asked for this card follows it, not its arc)
|
|
963
|
+
own ||= explicit;
|
|
964
|
+
if (own) run(db, `UPDATE kanban_notify_subs SET delivery_metadata = json_set(delivery_metadata, '$.own', json('true'))
|
|
965
|
+
WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ''`, id, address);
|
|
966
|
+
// opting in again (notify-subscribe) ends an unsubscribe, from now on: what it missed meanwhile is not told
|
|
967
|
+
if (explicit) run(db, `UPDATE kanban_notify_subs SET delivery_metadata = json_remove(delivery_metadata, '$.unsubscribed'), last_event_id = MAX(last_event_id, ?)
|
|
968
|
+
WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = '' AND json_extract(delivery_metadata, '$.unsubscribed')`, cursor, id, address);
|
|
969
|
+
// A follower that takes a stronger role on the card hears it in that role from now on (its unsubscribe stands): a
|
|
970
|
+
// participant or creator that is the board's manager hears it as the manager, never as both. What the new role hears
|
|
971
|
+
// is told from now on, never the card's history (the cursor moves to the card's last event).
|
|
972
|
+
// 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
|
+
// the role it held is kept beside it (`also`): what that role heard, the follower still hears (a manager that created
|
|
975
|
+
// a card is told its audit rounds as its creator was)
|
|
976
|
+
// (only a follower's role is kept so, appended to what it kept before; a worker or dependency hears its own kinds)
|
|
977
|
+
if (!outranked.length) return;
|
|
978
|
+
const held = all(db, `SELECT delivery_metadata FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ''
|
|
979
|
+
AND json_extract(delivery_metadata, '$.role') IN (${outranked.map(() => '?').join(', ')})`, id, address, ...outranked)[0];
|
|
980
|
+
if (!held) return;
|
|
981
|
+
const meta = json(held.delivery_metadata, {}) ?? {};
|
|
982
|
+
const also = FOLLOWER_RANKS.includes(role) ? [...new Set([...[].concat(meta.also ?? []), meta.role ?? 'creator'])].filter((r) => r !== role) : [].concat(meta.also ?? []);
|
|
983
|
+
run(db, `UPDATE kanban_notify_subs SET delivery_metadata = ?, last_event_id = MAX(last_event_id, ?) WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ''`,
|
|
984
|
+
JSON.stringify({ ...meta, role, ...(also.length ? { also } : {}) }), cursor, id, address);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** The roles a follower of a card holds, strongest first; a follower holds one (subscribeIn). */
|
|
988
|
+
const FOLLOWER_RANKS = ['manager', 'creator', 'participant'];
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* The one address a follower is subscribed under: a session that is the main session of an agent this call read (the
|
|
992
|
+
* board's managers, read before each verb: cli readAgentMains) follows as its agent's address, so a manager commenting
|
|
993
|
+
* or mentioned from its main session is not a second follower beside its agent (sc:<machine>:agent:manager). Any other
|
|
994
|
+
* address is itself.
|
|
995
|
+
*/
|
|
996
|
+
export function followerAddress(address) {
|
|
997
|
+
for (const [agent, main] of current().agentMains ?? []) if (typeof main === 'string' && main === address) return agent;
|
|
998
|
+
return address;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* A card's thread is followed as an issue tracker's is: its author and everyone it @mentions follow the card from this
|
|
1003
|
+
* comment on (`participant`, unless they already follow it in another role), and a follower who had unsubscribed and is
|
|
1004
|
+
* mentioned, or comments, follows it again. Every comment then reaches every follower but its author (notify).
|
|
1005
|
+
*/
|
|
1006
|
+
function joinThread(db, entry, id, author, body) {
|
|
1007
|
+
const said = one(db, "SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind = 'commented'", id)?.id ?? null;
|
|
1008
|
+
// the board's own comments (an unsubscribe's) bring no one in, whoever they name
|
|
1009
|
+
if (said == null || author === 'board') return;
|
|
1010
|
+
if (/^sc:/.test(author)) follow(db, id, author, said);
|
|
1011
|
+
for (const address of mentioned(db, entry, body)) if (address !== author) follow(db, id, address, said - 1);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/** Follow card `id` from event `cursor` on: a new follower as a participant, an unsubscribed one again. */
|
|
1015
|
+
function follow(db, id, address, cursor) {
|
|
1016
|
+
address = followerAddress(address);
|
|
1017
|
+
if (personCustody(db, address)) return;
|
|
1018
|
+
const row = one(db, "SELECT delivery_metadata, last_event_id FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ''", id, address);
|
|
1019
|
+
if (!row) {
|
|
1020
|
+
run(db, `INSERT INTO kanban_notify_subs (task_id, platform, chat_id, thread_id, chat_type, delivery_mode, delivery_metadata, created_at, last_event_id)
|
|
1021
|
+
VALUES (?, 'supercode', ?, '', 'dm', 'notify+wake', ?, ?, ?)`, id, address, JSON.stringify({ role: 'participant' }), now(), cursor);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
const meta = json(row.delivery_metadata, {}) ?? {};
|
|
1025
|
+
if (!meta.unsubscribed) return;
|
|
1026
|
+
const { unsubscribed: _u, ...kept } = meta;
|
|
1027
|
+
run(db, "UPDATE kanban_notify_subs SET delivery_metadata = ?, last_event_id = ? WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ''",
|
|
1028
|
+
JSON.stringify(kept), Math.max(Number(row.last_event_id), cursor), id, address);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/** The addresses a comment @mentions: `@manager` (the board's managers), a session's or agent's full address
|
|
1032
|
+
* (`@sc:<machine>:<kind>:<name>`), or an agent's name (`@<name>`) that a board address already names. Other words
|
|
1033
|
+
* after an @ (a package scope, an e-mail) name no one. */
|
|
1034
|
+
export function mentioned(db, entry, body) {
|
|
1035
|
+
const managers = [].concat(entry?.params?.managers ?? []).map(String);
|
|
1036
|
+
const found = new Set();
|
|
1037
|
+
for (const [, name] of String(body ?? '').matchAll(/(?:^|[^\w@/.-])@([A-Za-z0-9_][\w.:-]*[\w])/g)) {
|
|
1038
|
+
if (name === 'manager') { managers.forEach((m) => found.add(m)); continue; }
|
|
1039
|
+
if (/^sc:[^:\s]+:[^:\s]+:[^:\s]+$/.test(name)) { found.add(name); continue; }
|
|
1040
|
+
const known = [...managers, ...all(db, "SELECT DISTINCT chat_id FROM kanban_notify_subs WHERE platform = 'supercode' AND chat_id LIKE ?", `%:agent:${name}`).map((r) => r.chat_id)]
|
|
1041
|
+
.filter((address) => address.endsWith(`:agent:${name}`));
|
|
1042
|
+
for (const address of new Set(known)) found.add(address);
|
|
1043
|
+
}
|
|
1044
|
+
return [...found];
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/** How a follower is mentioned: an agent by its name (`manager`), a session by its address. */
|
|
1048
|
+
export const mentionName = (address) => /^sc:[^:]+:agent:([^:]+)$/.exec(String(address))?.[1] ?? String(address);
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* `address` stops following card `id`: its subscription is kept, marked unsubscribed, so no round subscribes it again
|
|
1052
|
+
* (a manager or a worker is subscribed each round), and the board says so on the card with how to bring it back, a
|
|
1053
|
+
* comment every remaining follower hears. A mention or its own comment makes it follow again (follow).
|
|
1054
|
+
*/
|
|
1055
|
+
export function unsubscribe(board, id, address) {
|
|
1056
|
+
return tx(board, (db) => {
|
|
1057
|
+
const row = one(db, "SELECT delivery_metadata FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ''", id, address);
|
|
1058
|
+
const meta = json(row?.delivery_metadata, {}) ?? {};
|
|
1059
|
+
if (!row || meta.unsubscribed) return false;
|
|
1060
|
+
run(db, "UPDATE kanban_notify_subs SET delivery_metadata = ? WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ''", JSON.stringify({ ...meta, unsubscribed: true }), id, address);
|
|
1061
|
+
// the board's own comment, written with the mark: no workflow's comment verb stands between them
|
|
1062
|
+
const name = mentionName(address), body = `${name} unsubscribed; mention @${name} to bring them back.`;
|
|
1063
|
+
// the event first, as a verb's comment is: its notice reads the comment written at or after it
|
|
1064
|
+
event(db, id, 'commented', { author: 'board', len: body.length, by: 'board' });
|
|
1065
|
+
run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', id, 'board', body, now());
|
|
1066
|
+
return true;
|
|
1067
|
+
});
|
|
859
1068
|
}
|
|
860
1069
|
|
|
861
1070
|
/** Reconcile only the current worker's audience, inside the binding transaction. */
|
|
@@ -881,12 +1090,8 @@ export function replaceWorkerSubscriptions(db, id, address) {
|
|
|
881
1090
|
return true;
|
|
882
1091
|
}
|
|
883
1092
|
|
|
884
|
-
export function subscribe(board, id, address, role = 'creator') {
|
|
885
|
-
return tx(board, (db) => { must(db, id); subscribeIn(db, id, address, role); });
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
export function unsubscribe(board, id, address) {
|
|
889
|
-
return tx(board, (db) => run(db, "DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?", id, address).changes > 0);
|
|
1093
|
+
export function subscribe(board, id, address, role = 'creator', { own = false } = {}) {
|
|
1094
|
+
return tx(board, (db) => { must(db, id); subscribeIn(db, id, address, role, { explicit: true, own }); });
|
|
890
1095
|
}
|
|
891
1096
|
|
|
892
1097
|
function dependencyRecipient(db, id) {
|
|
@@ -925,20 +1130,27 @@ export function reconcileDependencySubscriptions(db) {
|
|
|
925
1130
|
const where = "WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=?";
|
|
926
1131
|
const identity = [row.task_id, row.chat_id, row.thread_id];
|
|
927
1132
|
if (!owner || owner.detached || owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) run(db, `DELETE FROM kanban_notify_subs ${where}`, ...identity);
|
|
928
|
-
|
|
1133
|
+
// a subscription moved to its arc's new session starts from the card's last event: the new session is told what
|
|
1134
|
+
// happens from now on, never the card's history the old one was told or missed
|
|
1135
|
+
// (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)) 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}`,
|
|
1137
|
+
address, JSON.stringify({ ...metadata, notice_wait: undefined, waiting_recipient: false, failures: 0 }), address !== row.chat_id ? 1 : 0, row.task_id, ...identity);
|
|
929
1138
|
else if (!address && !metadata.waiting_recipient) run(db, `UPDATE kanban_notify_subs SET delivery_metadata=? ${where}`, JSON.stringify({ ...metadata, waiting_recipient: true }), ...identity);
|
|
930
1139
|
}
|
|
931
1140
|
}
|
|
932
1141
|
|
|
933
|
-
/** A child hears what its parents' subscribers hear (Hermes copies them at link time); a subtask its arc's session
|
|
934
|
-
function inheritSubscriptions(db, child, parents, { workers = false } = {}) {
|
|
1142
|
+
/** A child hears what its parents' subscribers hear (Hermes copies them at link time); a subtask only its arc's session. */
|
|
1143
|
+
function inheritSubscriptions(db, child, parents, { workers = false, only = null } = {}) {
|
|
935
1144
|
const cursor = one(db, 'SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?', child).c;
|
|
936
1145
|
for (const p of parents) {
|
|
937
1146
|
for (const s of all(db, 'SELECT * FROM kanban_notify_subs WHERE task_id = ?', p)) {
|
|
938
1147
|
const metadata = json(s.delivery_metadata, {}) ?? {}, role = metadata.role;
|
|
939
1148
|
if ((role === 'worker' && !workers) || role === 'dependency') continue; // a parent's session does not work the child
|
|
1149
|
+
if (only && role !== only) continue;
|
|
940
1150
|
// A child's subscription is a new wait, not the parent's retained delivery state.
|
|
941
1151
|
delete metadata.notice_wait;
|
|
1152
|
+
// a parent's own creator or kept roles are not the child's
|
|
1153
|
+
delete metadata.own; delete metadata.also;
|
|
942
1154
|
run(db, `INSERT OR IGNORE INTO kanban_notify_subs (task_id, platform, chat_id, thread_id, user_id, user_id_alt, chat_type, notifier_profile, delivery_mode, delivery_metadata, created_at, last_event_id)
|
|
943
1155
|
VALUES (?, ?, ?, ?, ?, ?, COALESCE(?, 'dm'), ?, COALESCE(?, 'notify'), ?, ?, ?)`,
|
|
944
1156
|
child, s.platform, s.chat_id, s.thread_id, s.user_id, s.user_id_alt, s.chat_type, s.notifier_profile, s.delivery_mode, JSON.stringify(metadata), now(), cursor);
|
|
@@ -1014,22 +1226,25 @@ export function cardContext(db, id, options = {}) {
|
|
|
1014
1226
|
|
|
1015
1227
|
|
|
1016
1228
|
|
|
1017
|
-
/** Hermes's swarm: a completed root as the shared blackboard, workers after it, a verifier after them all, a synthesizer last.
|
|
1018
|
-
|
|
1229
|
+
/** Hermes's swarm: a completed root as the shared blackboard, workers after it, a verifier after them all, a synthesizer last.
|
|
1230
|
+
* Its cards are named by the swarm's readable id: the root by it, then `<id>-worker-<n>`, `<id>-verify`, `<id>-synthesis`. */
|
|
1231
|
+
export function swarm(board, { goal, readableId, workers, verifier, synthesizer, tenant = null, createdBy = null, priority = 0, idempotencyKey = null }) {
|
|
1019
1232
|
if (!String(goal ?? '').trim()) throw new Error('a swarm has a goal');
|
|
1020
1233
|
if (!workers.length) throw new Error('a swarm has at least one --worker PROFILE:TITLE[:SKILL,SKILL]');
|
|
1234
|
+
requireReadableId(readableId);
|
|
1235
|
+
for (const name of [...workers.map((_, n) => `${readableId}-worker-${n + 1}`), `${readableId}-verify`, `${readableId}-synthesis`]) validateReadableId(name);
|
|
1021
1236
|
const protocol = (root) => `\n\n## Swarm protocol\n- Swarm root / shared blackboard: \`${root}\`.\n- Read sibling/parent handoffs from the board's context before working.\n`
|
|
1022
1237
|
+ `- Put machine-readable facts in completion metadata.\n- Put cross-worker notes on the root card as comments.\n- Goal: ${goal.trim()}\n`;
|
|
1023
|
-
const root = createCard(board, { title: `Swarm: ${goal.trim()}`.slice(0, 200), body: goal, tenant, createdBy, priority, idempotencyKey, initialStatus: 'blocked' });
|
|
1238
|
+
const root = createCard(board, { title: `Swarm: ${goal.trim()}`.slice(0, 200), readableId, body: goal, tenant, createdBy, priority, idempotencyKey, initialStatus: 'blocked' });
|
|
1024
1239
|
if (root.existing) return { root_id: root.id, existing: true };
|
|
1025
1240
|
tx(board, (db) => {
|
|
1026
1241
|
run(db, "UPDATE tasks SET status = 'done', completed_at = ? WHERE id = ?", now(), root.id);
|
|
1027
1242
|
const runId = synthesizeRun(db, root.id, { outcome: 'completed', summary: 'swarm blackboard', metadata: { swarm: true } });
|
|
1028
1243
|
event(db, root.id, 'completed', { result_len: 0, summary: 'swarm blackboard' }, runId);
|
|
1029
1244
|
});
|
|
1030
|
-
const workerIds = workers.map((w) => createCard(board, { title: w.title, body: `${w.title}${protocol(root.id)}`, assignee: w.profile, skills: w.skills, parents: [root.id], tenant, createdBy, priority }).id);
|
|
1031
|
-
const verifierId = createCard(board, { title: 'Verify swarm outputs', body: `Verify the workers' outputs.${protocol(root.id)}`, assignee: verifier, parents: workerIds, tenant, createdBy, priority }).id;
|
|
1032
|
-
const synthesizerId = createCard(board, { title: 'Synthesize swarm outputs', body: `Synthesize the verified outputs.${protocol(root.id)}`, assignee: synthesizer, parents: [verifierId], tenant, createdBy, priority }).id;
|
|
1245
|
+
const workerIds = workers.map((w, n) => createCard(board, { title: w.title, readableId: `${readableId}-worker-${n + 1}`, body: `${w.title}${protocol(root.id)}`, assignee: w.profile, skills: w.skills, parents: [root.id], tenant, createdBy, priority }).id);
|
|
1246
|
+
const verifierId = createCard(board, { title: 'Verify swarm outputs', readableId: `${readableId}-verify`, body: `Verify the workers' outputs.${protocol(root.id)}`, assignee: verifier, parents: workerIds, tenant, createdBy, priority }).id;
|
|
1247
|
+
const synthesizerId = createCard(board, { title: 'Synthesize swarm outputs', readableId: `${readableId}-synthesis`, body: `Synthesize the verified outputs.${protocol(root.id)}`, assignee: synthesizer, parents: [verifierId], tenant, createdBy, priority }).id;
|
|
1033
1248
|
return { root_id: root.id, worker_ids: workerIds, verifier_id: verifierId, synthesizer_id: synthesizerId };
|
|
1034
1249
|
}
|
|
1035
1250
|
|
|
@@ -1081,13 +1296,14 @@ export function runs(db, id, { stateType = null, stateName = null } = {}) {
|
|
|
1081
1296
|
return all(db, `SELECT * FROM task_runs WHERE task_id = ?${where} ORDER BY id`, ...(where ? [id, stateName] : [id])).map((r) => ({ ...r, metadata: json(r.metadata, null) }));
|
|
1082
1297
|
}
|
|
1083
1298
|
|
|
1084
|
-
/** Retain assignment facts in checkpoints, then delete old thread events of done and archived cards
|
|
1299
|
+
/** 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. */
|
|
1085
1301
|
export function gcEvents(board, seconds) {
|
|
1086
1302
|
return tx(board, db=>{
|
|
1087
1303
|
const cut=now()-seconds;
|
|
1088
1304
|
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);
|
|
1089
1305
|
let compacted=0;for(const card of cards)compacted+=compactCardAgentEvents(db,card.task_id);
|
|
1090
|
-
return compacted+Number(run(db,"DELETE FROM task_events WHERE kind
|
|
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);
|
|
1091
1307
|
});
|
|
1092
1308
|
}
|
|
1093
1309
|
|
package/board/files.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { fillReadableIds } from './readable-id.mjs';
|
|
|
12
12
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { fenced } from './fence.mjs';
|
|
14
14
|
import { markRemovedHeads } from './published.mjs';
|
|
15
|
-
import { CARD_NUMBERS } from './store.mjs';
|
|
15
|
+
import { CARD_NUMBERS, unnumberTasks } from './store.mjs';
|
|
16
16
|
import { createHash } from 'node:crypto';
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { DatabaseSync } from 'node:sqlite';
|
|
@@ -133,6 +133,9 @@ class FilesBoard {
|
|
|
133
133
|
cards.sort((a, b) => madeAt(a) - madeAt(b) || (String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0));
|
|
134
134
|
for (const card of cards) for (const [table, rows] of Object.entries(card.tables ?? {})) if (table !== CARD_NUMBERS) insert(table, rows);
|
|
135
135
|
for (const [table, rows] of Object.entries(state?.tables ?? {})) insert(table, rows);
|
|
136
|
+
// A task whose file names no number keeps none: only cards are numbered.
|
|
137
|
+
unnumberTasks(mem, new Set(cards.filter((card) => card.tables?.[CARD_NUMBERS]?.length).map((card) => card.id)),
|
|
138
|
+
(state?.sequence ?? []).find((row) => row.name === CARD_NUMBERS)?.seq ?? 0);
|
|
136
139
|
// Loading inserts every card, which marks every card changed (published.mjs). The first load of this projection keeps
|
|
137
140
|
// those marks: its next write compares each card with its published head once (a named limit: each projection opened
|
|
138
141
|
// compares every card once, as each load already reads every card's file). A reload keeps the marks not yet
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Fleet facts are mail obligations, independent of card transitions and dispatch ticks. Intent comes only from the
|
|
2
2
|
// canonical workflow IR. The sender supplies observations from its admitted sources; this router grants nothing.
|
|
3
|
+
import { GENERATION_STOPPED } from './generation.mjs';
|
|
3
4
|
import { DatabaseSync } from 'node:sqlite';
|
|
4
5
|
import { chmodSync, mkdirSync, existsSync } from 'node:fs';
|
|
5
6
|
import { join } from 'node:path';
|
|
@@ -213,7 +214,8 @@ export class FleetListenerRouter {
|
|
|
213
214
|
}
|
|
214
215
|
}
|
|
215
216
|
|
|
216
|
-
|
|
217
|
+
// A flush its stopping board generation refused never ran (GENERATION_STOPPED, generation.mjs): said as that.
|
|
218
|
+
wake() { if (!this.closed) void this.flush().catch(error => this.log(error?.code === GENERATION_STOPPED ? `fleet listener filing not run: ${error.message}` : `fleet listener filing: ${String(error.message).slice(0, 300)}`)); }
|
|
217
219
|
|
|
218
220
|
/** A lost filing answer leaves the obligation pending. Retrying its stable id goes through the mailbox's own
|
|
219
221
|
* idempotent filing. This receipt proves filing only; native turn delivery remains the mailbox's responsibility. */
|
package/board/fleet-observer.mjs
CHANGED
|
@@ -48,8 +48,9 @@ export function observeRoleFleet(root, router, records, log = () => {}, { waitin
|
|
|
48
48
|
}
|
|
49
49
|
if (!waiting) continue;
|
|
50
50
|
// The existing pane activity grant is still required (operator plus whole-machine view on a peer). A lost index,
|
|
51
|
-
// unread activity or absent record never clears a wait
|
|
52
|
-
|
|
51
|
+
// unread activity or absent record never clears a wait (a part the index could not read is not a lost index: it
|
|
52
|
+
// keeps its rows, machine-fleet.mjs). Waiting on a live native session is not inferred from idle.
|
|
53
|
+
if (record.unread?.panes || record.unread?.sessions_index
|
|
53
54
|
|| record.unread?.sessions_activity || record.unread?.claude_registry_watch || record.panes?.activities_omitted || record.panes?.sessions_unread) continue;
|
|
54
55
|
const grouped = new Map();
|
|
55
56
|
for (const { pid, activity } of record.panes?.activities ?? []) {
|