@volter/supercode-orchestrator 0.5.226 → 0.5.228
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/event-stream.mjs +3 -7
- 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/store.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import { openZtrackBoard } from './ztrack.mjs';
|
|
|
23
23
|
import { byOwner, LIST_CONNECTION, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
|
|
24
24
|
import { isFilesBoard, openFilesBoard } from './files.mjs';
|
|
25
25
|
import { signalBoardWrite } from './wake.mjs';
|
|
26
|
-
import { PUBLICATION_COLUMNS, PUBLICATION_SCHEMA, pruneDue, prunePublications, publicationTriggers, recordCardPublications } from './published.mjs';
|
|
26
|
+
import { PUBLICATION_COLUMNS, PUBLICATION_SCHEMA, incrementalStore, pruneDue, prunePublications, publicationTriggers, reclaimDue, reclaimSlice, recordCardPublications, trimDue, trimPublications } from './published.mjs';
|
|
27
27
|
|
|
28
28
|
import { fillReadableIds } from './readable-id.mjs';
|
|
29
29
|
|
|
@@ -191,14 +191,19 @@ export function boardKey(root, slug = DEFAULT_BOARD, { creating = false, key: re
|
|
|
191
191
|
}
|
|
192
192
|
/** A card as a person names it: its board's key and its number (`VO-856`). */
|
|
193
193
|
export const cardRef = (key, number) => (number ? `${key}-${number}` : null);
|
|
194
|
-
//
|
|
195
|
-
//
|
|
194
|
+
// A card's readable id names its branch, its worktree folder (workspace.mjs worktreeFolder) and its fresh sessions;
|
|
195
|
+
// card agents use role-readable-id. Its keyed number stays the immutable reference beside it.
|
|
196
196
|
/** A card's handle (`vo-856`), or null for a card with no number. */
|
|
197
197
|
export const cardHandle = (key, number) => (number ? `${String(key).toLowerCase()}-${number}` : null);
|
|
198
198
|
/** The handle of card `id` on `board` (`vo-856`), or null before the card is filed. */
|
|
199
199
|
export const cardHandleOf = (board, id) => id == null ? null : cardHandle(boardKey(board.root, board.slug), board.db.prepare(`SELECT number FROM ${CARD_NUMBERS} WHERE task_id = ?`).get(id)?.number);
|
|
200
|
-
/**
|
|
201
|
-
export
|
|
200
|
+
/** Card `id`'s branch, named by its readable id (`voice-calls`, under a project's `<slug>/`), held by no other card. */
|
|
201
|
+
export function cardBranch(db, id, readableId, prefix = '') {
|
|
202
|
+
const branch = `${prefix}${readableId}`;
|
|
203
|
+
const holder = db.prepare('SELECT id FROM tasks WHERE branch_name=? AND id<>? LIMIT 1').get(branch, id);
|
|
204
|
+
if (holder) throw new Error(`branch ${branch} belongs to card ${holder.id}; name this card with another readable id`);
|
|
205
|
+
return branch;
|
|
206
|
+
}
|
|
202
207
|
export const readableIdOf = (board, id) => board.db.prepare('SELECT readable_id FROM tasks WHERE id=?').get(id)?.readable_id ?? null;
|
|
203
208
|
|
|
204
209
|
/** The home's boards that exist: `default` when it does, and every named one. */
|
|
@@ -370,6 +375,34 @@ function layOutNumbers(db) {
|
|
|
370
375
|
} catch (error) { db.exec('ROLLBACK TO supercode_card_numbering; RELEASE supercode_card_numbering'); throw error; }
|
|
371
376
|
}
|
|
372
377
|
|
|
378
|
+
/** Only cards are numbered (VO-3578 row 22): a store from before that rule keeps the numbers its tasks were given, and
|
|
379
|
+
* they go TASK_NUMBER_BATCH at a time, one batch in each board transaction (tx), so the republication each dropped
|
|
380
|
+
* number marks (published.mjs) is bounded with it: the live store's 3,320 at once held one transaction 16.5 s
|
|
381
|
+
* (VO-3578 take68). The sequence stays where it is, so no number a task held is handed out again. Once a board has
|
|
382
|
+
* none, this process stops looking. */
|
|
383
|
+
const TASK_NUMBER_BATCH = 100;
|
|
384
|
+
const unnumbered = new Set();
|
|
385
|
+
function dropTaskNumbers(db, board) {
|
|
386
|
+
const key = `${board.root}\0${board.key}`;
|
|
387
|
+
if (unnumbered.has(key)) return;
|
|
388
|
+
const rows = db.prepare(`SELECT n.task_id FROM ${CARD_NUMBERS} n JOIN supercode_cards c ON c.task_id = n.task_id WHERE c.subtask_of IS NOT NULL LIMIT ?`).all(TASK_NUMBER_BATCH);
|
|
389
|
+
if (!rows.length) { unnumbered.add(key); return; }
|
|
390
|
+
const drop = db.prepare(`DELETE FROM ${CARD_NUMBERS} WHERE task_id = ?`);
|
|
391
|
+
for (const row of rows) drop.run(row.task_id);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Only cards are numbered (VO-3578 row 22): a task's row (supercode_cards.subtask_of) is numbered by the insert trigger
|
|
395
|
+
* like any row, so a load gives each task's number back unless its source named it (`named`, task ids). The sequence
|
|
396
|
+
* returns to the highest number held, never below `floor` (what the source had already handed out). */
|
|
397
|
+
export function unnumberTasks(db, named = new Set(), floor = 0) {
|
|
398
|
+
const taken = db.prepare(`SELECT n.task_id FROM ${CARD_NUMBERS} n JOIN supercode_cards c ON c.task_id = n.task_id WHERE c.subtask_of IS NOT NULL`).all()
|
|
399
|
+
.filter((row) => !named.has(row.task_id));
|
|
400
|
+
if (!taken.length) return;
|
|
401
|
+
const drop = db.prepare(`DELETE FROM ${CARD_NUMBERS} WHERE task_id = ?`);
|
|
402
|
+
for (const row of taken) drop.run(row.task_id);
|
|
403
|
+
db.prepare(`UPDATE sqlite_sequence SET seq = MAX(?, (SELECT COALESCE(MAX(number), 0) FROM ${CARD_NUMBERS})) WHERE name = ?`).run(Number(floor) || 0, CARD_NUMBERS);
|
|
404
|
+
}
|
|
405
|
+
|
|
373
406
|
/** Raise the numbers' high-water mark to the highest number held, and to `floor`: AUTOINCREMENT hands out only numbers
|
|
374
407
|
* above it. For a store whose numbers were given or copied explicitly. */
|
|
375
408
|
export function raiseCardNumbers(db, floor = 0) {
|
|
@@ -542,7 +575,9 @@ function heldHandle(entry, slug) {
|
|
|
542
575
|
const statement = (st) => new Proxy(st, { get: (target, key) => key === 'run' ? (...args) => { guard(); return target.run(...args); } : bound(target, key) });
|
|
543
576
|
const db = new Proxy(entry.board.db, { get: (target, key) => key === LIST_CONNECTION ? target : key === 'prepare' ? (sql) => statement(target.prepare(sql)) : key === 'exec' ? (sql) => { guard(); return target.exec(sql); } : bound(target, key) });
|
|
544
577
|
let closed = false;
|
|
545
|
-
|
|
578
|
+
// a rebuild runs on the backing's own connection, outside its write wrapper (ztrack.mjs vacuum); a plain store's is its db
|
|
579
|
+
const vacuum = (sql) => { guard(); return typeof entry.board.vacuum === 'function' ? entry.board.vacuum(sql) : entry.board.db.exec(sql); };
|
|
580
|
+
return { ...entry.board, db, vacuum, tx: (fn) => { guard(); return entry.board.tx(fn); },
|
|
546
581
|
close() { if (closed) return; closed = true; entry.uses -= 1; if (entry.retired && !entry.uses) try { entry.board.close(); } catch { /* closed */ } } };
|
|
547
582
|
}
|
|
548
583
|
|
|
@@ -620,7 +655,7 @@ export const tx = (board, fn) => {
|
|
|
620
655
|
const caller = /at (?:async )?([\w.$<>]+) /.exec(String(new Error().stack).split('\n')[2] ?? '')?.[1] ?? 'unknown';
|
|
621
656
|
const started = performance.now();
|
|
622
657
|
try {
|
|
623
|
-
const result = board.tx(db => {const result=fn(db);fillReadableIds(db);const began=performance.now();try{recordCardPublications(db,actorOf(),board.root,board.key);}finally{noteStep('board tx publications',performance.now()-began);}return result;});
|
|
658
|
+
const result = board.tx(db => {const result=fn(db);fillReadableIds(db);dropTaskNumbers(db, board);const began=performance.now();try{recordCardPublications(db,actorOf(),board.root,board.key);}finally{noteStep('board tx publications',performance.now()-began);}return result;});
|
|
624
659
|
signalBoardWrite(board.root, actorOf());
|
|
625
660
|
return result;
|
|
626
661
|
} finally { noteStep(`board tx from ${caller}`, performance.now() - started); }
|
|
@@ -642,6 +677,13 @@ export function publishMarked(root) {
|
|
|
642
677
|
const marked = step('marks', () => board.db.prepare('SELECT 1 FROM supercode_publication_dirty LIMIT 1').get()) || step('unimported', () => board.unimported?.());
|
|
643
678
|
if (marked) published += step('publish', () => board.tx((db) => recordCardPublications(db, actorOf(), board.root, board.key)));
|
|
644
679
|
if (step('prune due', () => pruneDue(board.db))) step('prune', () => board.tx((db) => prunePublications(db)));
|
|
680
|
+
// Maintenance that fails is said and left to the next wake: it never fails the owner's round. Older changes
|
|
681
|
+
// stored complete are trimmed a slice per wake (published.mjs storedSnapshot); a store `workflow compact`
|
|
682
|
+
// rebuilt gives free pages back a slice at a time.
|
|
683
|
+
try {
|
|
684
|
+
if (step('trim due', () => trimDue(board.db))) step('trim', () => board.tx((db) => trimPublications(db)));
|
|
685
|
+
else if (step('reclaim due', () => incrementalStore(board.db) && reclaimDue(board.db))) step('reclaim', () => board.tx((db) => reclaimSlice(db)));
|
|
686
|
+
} catch (error) { if (contention(error)) throw error; writeSync(2, `board ${slug}: publication maintenance failed and waits for the next wake: ${String(error?.message ?? error).slice(0, 300)}\n`); }
|
|
645
687
|
} finally { board?.close(); }
|
|
646
688
|
}
|
|
647
689
|
return published;
|
package/board/tools.mjs
CHANGED
|
@@ -132,7 +132,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
132
132
|
}
|
|
133
133
|
case 'kanban_request_review': {
|
|
134
134
|
const stale = ownRun(); if (stale) return error(stale);
|
|
135
|
-
await engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? args.commit ?? null, reviewer: args.reviewer ?? null, expectedRunId: own === tid && runId ? runId : null });
|
|
135
|
+
await engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? args.commit ?? null, plan: args.plan === true, reviewer: args.reviewer ?? null, expectedRunId: own === tid && runId ? runId : null });
|
|
136
136
|
return ok({ task_id: tid, status: 'review' });
|
|
137
137
|
}
|
|
138
138
|
case 'kanban_request_changes': {
|
package/board/workflow.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { allocateReadableId } from './readable-id.mjs';
|
|
1
|
+
import { allocateReadableId, cardReadableId } from './readable-id.mjs';
|
|
2
2
|
// The board's workflow, interpreted (docs/architecture/orchestrator.md §2.9). The IR's `Orchestration.workflow`
|
|
3
3
|
// (crates/interchange/src/orchestration/workflow.rs; Hermes's dispatcher, hermes.workflow.yaml, when a home declares
|
|
4
4
|
// none) says every status a card can be in, what runs there, which events move a card and who may send them, what
|
|
@@ -6,7 +6,7 @@ import { allocateReadableId } from './readable-id.mjs';
|
|
|
6
6
|
// the engine's primitives (the rows Hermes's tables hold); the I/O an action asks for (stopping a worker, closing a
|
|
7
7
|
// pane, sending a session a message, cleaning a workspace) is answered as effects the caller runs after commit.
|
|
8
8
|
import { current } from './call.mjs';
|
|
9
|
-
import { detachedCard, detachedSession } from './custody.mjs';
|
|
9
|
+
import { detachedCard, detachedSession, personOf } from './custody.mjs';
|
|
10
10
|
import { notRuntime } from './owner-files.mjs';
|
|
11
11
|
import { machineCall } from './doors.mjs';
|
|
12
12
|
import { execFile } from 'node:child_process';
|
|
@@ -14,12 +14,12 @@ import { promisify } from 'node:util';
|
|
|
14
14
|
import { existsSync, readdirSync, realpathSync, watch, readFileSync } from 'node:fs';
|
|
15
15
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
16
16
|
import { randomBytes } from 'node:crypto';
|
|
17
|
-
import { actorOf, appendRunObservation, boardKey, cardHandleOf, event as record, json, now } from './store.mjs';
|
|
18
|
-
import { releaseAllSideSessions } from './side-sessions.mjs';
|
|
17
|
+
import { actorOf, appendRunObservation, boardKey, cardBranch, cardHandleOf, event as record, json, now } from './store.mjs';
|
|
18
|
+
import { releaseAllSideSessions, sideSessions } from './side-sessions.mjs';
|
|
19
19
|
import { evaluate, holds, render, needsSources } from './expr.mjs';
|
|
20
20
|
import { completionEvidence, declaredWorkBranches, namedWorkBranch, reviewCandidate } from './ci.mjs';
|
|
21
|
-
import { cardCommitsOn, isLocalWorkspace, isWorkspacePath } from './workspace.mjs';
|
|
22
|
-
import { cardNumber, identifyNotice, numbered } from './notices.mjs';
|
|
21
|
+
import { branchClaim, cardCommitsOn, isLocalWorkspace, isWorkspacePath, unrecordedMaking } from './workspace.mjs';
|
|
22
|
+
import { cardName, cardNumber, identifyNotice, numbered } from './notices.mjs';
|
|
23
23
|
import { latestEvent } from './queries.mjs';
|
|
24
24
|
|
|
25
25
|
const loaded = new Map();
|
|
@@ -309,7 +309,7 @@ function commitSource(db, task) {
|
|
|
309
309
|
* (`commit_unread` says why): an unread checkout is not a card with no commits, and nothing may count it as one. */
|
|
310
310
|
export function sessionProgress(db, task) {
|
|
311
311
|
const event = one(db, `SELECT MAX(e.id) AS id FROM task_events e WHERE
|
|
312
|
-
(e.task_id=? AND e.kind IN ('specified','review_requested','review_pass','changes_requested','audited')) OR
|
|
312
|
+
(e.task_id=? AND e.kind IN ('specified','review_requested','review_pass','pr_review_pass','changes_requested','audited')) OR
|
|
313
313
|
(e.task_id IN (SELECT task_id FROM supercode_cards WHERE subtask_of=?) AND e.kind IN ('completed','cancelled'))`, task.id, task.id);
|
|
314
314
|
const { path } = commitSource(db, task);
|
|
315
315
|
const read = path ? commitTimes.get(`${path}:${task.id}`) ?? { known: false, error: 'its checkout has not been read yet' } : { known: true, value: null };
|
|
@@ -445,28 +445,51 @@ export function cardSectionHeading(line) {
|
|
|
445
445
|
const label = /^\s*([A-Za-z][A-Za-z0-9 _-]*):\s*$/.exec(line);
|
|
446
446
|
return label ? label[1].trim().toLowerCase() : null;
|
|
447
447
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
*
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
const
|
|
454
|
-
const
|
|
455
|
-
if (before.length !== after.length) return false;
|
|
456
|
-
// an outcome is a line of the card's Acceptance section (its heading at any level), not a task or any other checkbox
|
|
448
|
+
const CHECKBOX = /^\s*[-*] \[[ xX]\]/;
|
|
449
|
+
/** A card body read as Markdown reads it: a checkbox item is one block with its continuation lines (the indented lines
|
|
450
|
+
* after it, with any blank lines between them; engine.mjs bodyTasks reads a task's lines the same way), its `text` the
|
|
451
|
+
* item joined onto one line; every other line is a block of its own. Each block keeps the section it sits under. */
|
|
452
|
+
function bodyBlocks(body) {
|
|
453
|
+
const lines = String(body ?? '').split('\n');
|
|
454
|
+
const blocks = [];
|
|
457
455
|
let section = null;
|
|
458
|
-
|
|
456
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
457
|
+
const line = lines[i];
|
|
458
|
+
const item = blocks.at(-1)?.item ? blocks.at(-1) : null;
|
|
459
|
+
let next = i;
|
|
460
|
+
while (item && next < lines.length && !lines[next].trim()) next += 1;
|
|
461
|
+
if (item && next < lines.length && /^\s+\S/.test(lines[next]) && !CHECKBOX.test(lines[next])) {
|
|
462
|
+
item.lines.push(...lines.slice(i, next + 1));
|
|
463
|
+
item.text += ` ${lines[next].trim()}`;
|
|
464
|
+
i = next;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
459
467
|
const heading = cardSectionHeading(line);
|
|
460
468
|
if (heading !== null) section = heading;
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
469
|
+
blocks.push({ section, item: CHECKBOX.test(line), text: line, lines: [line] });
|
|
470
|
+
}
|
|
471
|
+
return blocks;
|
|
472
|
+
}
|
|
473
|
+
/** Whether `body` is `was` with only outcomes ticked: each changed outcome (a checkbox item under the Acceptance heading,
|
|
474
|
+
* read whole with its continuation lines) was `- [ ] X` and is now `- [x] X` or `- [x] X: <pointer>`, or was `- [x] X`
|
|
475
|
+
* and is now `- [x] X: <pointer>` (the pointer a tick needs, added after it); no other line added, removed or changed. */
|
|
476
|
+
function onlyTicks(was, body) {
|
|
477
|
+
if (typeof body !== 'string') return false;
|
|
478
|
+
const before = bodyBlocks(was);
|
|
479
|
+
const after = bodyBlocks(body);
|
|
480
|
+
if (before.length !== after.length) return false;
|
|
481
|
+
// an outcome is an item of the card's Acceptance section (its heading at any level), not a task or any other checkbox
|
|
482
|
+
return before.every((block, i) => {
|
|
483
|
+
const now = after[i];
|
|
484
|
+
if (block.lines.join('\n') === now.lines.join('\n')) return true;
|
|
485
|
+
if (block.section !== 'acceptance' || !block.item || !now.item) return false;
|
|
486
|
+
const pointed = (ticked) => now.text.startsWith(`${ticked}: `) && now.text.trim().length > ticked.trim().length + 1;
|
|
487
|
+
// Outcome prose may contain colons. Preserve the entire old item and permit only an appended pointer.
|
|
488
|
+
if (TICKED_OUTCOME.test(block.text)) return pointed(block.text);
|
|
489
|
+
const open = OPEN_OUTCOME.exec(block.text);
|
|
467
490
|
if (!open) return false;
|
|
468
491
|
const ticked = `${open[1]}[x] ${open[2]}`;
|
|
469
|
-
return
|
|
492
|
+
return now.text === ticked || pointed(ticked);
|
|
470
493
|
});
|
|
471
494
|
}
|
|
472
495
|
|
|
@@ -538,6 +561,9 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
|
|
|
538
561
|
blocked_by: blocks,
|
|
539
562
|
paused: json(state.paused, null),
|
|
540
563
|
detached: json(state.detached, null),
|
|
564
|
+
// the person the card is assigned to (custody.mjs personOf), or null: a person's card runs no session and closes on
|
|
565
|
+
// that person's completion
|
|
566
|
+
person: personOf(task.assignee),
|
|
541
567
|
archived: Boolean(state.archived),
|
|
542
568
|
every: state.every ?? null,
|
|
543
569
|
next_run_at: state.next_run_at ?? null,
|
|
@@ -546,16 +572,8 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
|
|
|
546
572
|
board: board.slug,
|
|
547
573
|
get parents() { return parents(); },
|
|
548
574
|
get children() { return children(); },
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
let acceptance = false;
|
|
552
|
-
return lines.every((line) => {
|
|
553
|
-
const heading = cardSectionHeading(line);
|
|
554
|
-
if (heading !== null) acceptance = heading === 'acceptance';
|
|
555
|
-
if (!acceptance || !/^\s*[-*] \[[ xX]\]/.test(line)) return true;
|
|
556
|
-
return /^\s*[-*] \[[xX]\] .+: \S.+$/.test(line);
|
|
557
|
-
});
|
|
558
|
-
},
|
|
575
|
+
// every outcome ticked with a pointer, each outcome read whole: a pointer on a wrapped item's continuation line counts
|
|
576
|
+
acceptance_complete: () => bodyBlocks(task.body).every((b) => b.section !== 'acceptance' || !b.item || /^\s*[-*] \[[xX]\] .+: \S.+$/.test(b.text)),
|
|
559
577
|
get counters() { return { failures: Number(task.consecutive_failures || 0), block_recurrences: Number(task.block_recurrences || 0), goal_turns: Number(session().goal_turns || 0) }; },
|
|
560
578
|
goal_mode: Boolean(task.goal_mode),
|
|
561
579
|
get implementer() { return asked().implementer ?? task.assignee; },
|
|
@@ -571,7 +589,9 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
|
|
|
571
589
|
get last_reviewer() { return changes().reviewer ?? null; },
|
|
572
590
|
get arc() { return task.subtask_of ? one(db, `SELECT t.id, ${related}, t.title, t.body, t.assignee FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.id = ?`, task.subtask_of) ?? null : null; },
|
|
573
591
|
// the cards filed as its subtasks (`create --subtask-of`), in the order they were filed
|
|
574
|
-
|
|
592
|
+
// `paused`: a paused subtask's pause (who and why), or null; a paused subtask holds no review of its arc
|
|
593
|
+
get subtasks() { return all(db, `SELECT t.id, ${related}, t.title, t.assignee, c.paused FROM tasks t JOIN supercode_cards c ON c.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at`, task.id)
|
|
594
|
+
.map((t) => ({ ...t, paused: json(t.paused, null) })); },
|
|
575
595
|
get queue_reason() { return json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'respawn_guarded' AND created_at >= ? ORDER BY id DESC LIMIT 1",
|
|
576
596
|
task.id, Number(one(db, 'SELECT since FROM supercode_cards WHERE task_id = ?', task.id)?.since ?? 0))?.payload, {})?.reason ?? null; },
|
|
577
597
|
// the message whose answer the standing block waits for (`block --waiting-on`), and that answer's id once it has landed
|
|
@@ -662,18 +682,17 @@ function noticeRun(db, task) {
|
|
|
662
682
|
: all(db, 'SELECT id, metadata FROM task_runs WHERE task_id = ? ORDER BY id DESC', task.id).find((r) => json(r.metadata, {})?.supercode?.session_id) ?? null;
|
|
663
683
|
}
|
|
664
684
|
|
|
665
|
-
function queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects,
|
|
685
|
+
function queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects, adopting = false) {
|
|
686
|
+
// A verified adoption starts a new run before binding its session; that session is told its role's brief by the
|
|
687
|
+
// adoption itself (dispatch.mjs roleBrief), never a second notice of the transition.
|
|
688
|
+
if (adopting) return;
|
|
666
689
|
const after = cardOf(db, task.id), to = statusOf(db, after);
|
|
667
|
-
|
|
668
|
-
// belongs to that new run and verified identity, never the last ended session
|
|
669
|
-
// that noticeRun found before the transition.
|
|
670
|
-
const targetRun = noticeSession && after.current_run_id
|
|
671
|
-
? one(db, 'SELECT id, metadata FROM task_runs WHERE id = ?', after.current_run_id) : beforeRun;
|
|
690
|
+
const targetRun = beforeRun;
|
|
672
691
|
const state = (t, status) => JSON.stringify([status, t.current_run_id, t.paused, t.archived, t.blocked_by]);
|
|
673
692
|
const beforeState = json(beforeRun?.metadata, {})?.supercode?.state;
|
|
674
693
|
const afterState = beforeRun && json(one(db, 'SELECT metadata FROM task_runs WHERE id=?', beforeRun.id)?.metadata, {})?.supercode?.state;
|
|
675
694
|
if (!targetRun || (state(task, from) === state(after, to) && beforeState === afterState) || effects.some((e) => e.kind === 'session_notice' && e.runId === targetRun.id)) return;
|
|
676
|
-
const session =
|
|
695
|
+
const session = json(targetRun.metadata, {})?.supercode;
|
|
677
696
|
if (detachedSession(db, session)) return;
|
|
678
697
|
const address = session?.address ?? (session?.session_id && session.machine ? `sc:${session.machine}:${session.harness ?? 'claude-code'}:${session.session_id}` : null);
|
|
679
698
|
if (!address && !session?.launch_key && !session?.pane) return;
|
|
@@ -683,10 +702,8 @@ function queueTransitionNotice(db, board, entry, task, from, name, payload, befo
|
|
|
683
702
|
if (address && address === actorOf()) return;
|
|
684
703
|
if (session?.close_confirmed_at) return;
|
|
685
704
|
const scope = scopeOf(db, board, after, { params: entry.params, event: { ...payload, verb: name, from, to }, extra: { run: { id: targetRun.id } } });
|
|
686
|
-
const defaultNotice =
|
|
687
|
-
|
|
688
|
-
: '{{card.id}}: {{event.verb}} changed run {{run.id}} ({{event.from}} → {{event.to}}). Stop work under the previous run; follow the board’s current state. {{event.reason ?? ""}}';
|
|
689
|
-
const body = numbered(boardKey(board.root, board.slug), cardNumber(db, task.id), render(entry.workflow.notify?.worker?.run_changed ?? defaultNotice, scope));
|
|
705
|
+
const defaultNotice = '{{card.id}}: {{event.verb}} changed run {{run.id}} ({{event.from}} → {{event.to}}). Stop work under the previous run; follow the board’s current state. {{event.reason ?? ""}}';
|
|
706
|
+
const body = numbered(boardKey(board.root, board.slug), cardNumber(db, task.id), render(entry.workflow.notify?.worker?.run_changed ?? defaultNotice, scope), cardName(db, task.id));
|
|
690
707
|
const source = one(db, 'SELECT MAX(id) AS id FROM task_events WHERE task_id = ?', task.id)?.id;
|
|
691
708
|
const text = identifyNotice({ id: source, kind: name, run_id: targetRun.id, created_at: now() }, body);
|
|
692
709
|
const effect = { kind: 'session_notice', id: task.id, runId: targetRun.id, address, text, sourceEvent: source, verb: name, messageKey: `board-run-notice-${randomBytes(16).toString('hex')}` };
|
|
@@ -752,7 +769,12 @@ export function validateEvent(db, board, entry, id, name, { roles = [], payload
|
|
|
752
769
|
const from = localOnly ? statusOf(db, task) : declaredStatus(db, board, entry, task);
|
|
753
770
|
if (localOnly && !workflow.statuses[from]) return null; // only fire adopts an imported status
|
|
754
771
|
const candidates = transitionsFor(workflow, from, name);
|
|
755
|
-
|
|
772
|
+
// a refusal names the right form: each transition's description, its card placeholders filled with this card
|
|
773
|
+
const said = (t) => t.description ? String(t.description).replaceAll('<arc>', id).replaceAll('<card>', id) : null;
|
|
774
|
+
if (!candidates.length) {
|
|
775
|
+
const elsewhere = (workflow.verbs?.[name] ?? []).filter((t) => t.from?.length && t.description);
|
|
776
|
+
throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} does not apply there.${elsewhere.map((t) => ` From ${t.from.join(' or ')}: ${said(t)}`).join('')}`);
|
|
777
|
+
}
|
|
756
778
|
// `sender`: the roles the event was sent with (a validator can ask who is asking)
|
|
757
779
|
const scope = scopeOf(db, board, task, { params, event: payload, lazy: localOnly, deferSources: localOnly, extra: { lanes: lanesIn(entry), ...extra, sender: roles, verb: name } });
|
|
758
780
|
const allowed = candidates.filter((t) => !t.by?.length || t.by.some((r) => roles.includes(r)));
|
|
@@ -760,7 +782,7 @@ export function validateEvent(db, board, entry, id, name, { roles = [], payload
|
|
|
760
782
|
const state = name === 'complete' ? 'done' : name === 'cancel' ? 'cancelled' : null;
|
|
761
783
|
const request = state && candidates.some(transition => transition.by?.includes('creator'))
|
|
762
784
|
? ` Ask the manager with workflow request-state ${id} --state ${state} --reason <why> --evidence <verdict-and-pointers>.` : '';
|
|
763
|
-
throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(candidates.flatMap((t) => t.by ?? []))].join(' or ')}. ${candidates.map(
|
|
785
|
+
throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(candidates.flatMap((t) => t.by ?? []))].join(' or ')}. ${candidates.map(said).filter(Boolean).join(' ')}${request}`);
|
|
764
786
|
}
|
|
765
787
|
// A source-dependent branch is undecidable before observation; preflight must not choose its fallback.
|
|
766
788
|
const needsSource = expr => needsSources(expr, workflow.derived);
|
|
@@ -770,7 +792,7 @@ export function validateEvent(db, board, entry, id, name, { roles = [], payload
|
|
|
770
792
|
// none of the sender's rules holds, but another role's does (one verb with a rule per case, as `set` has one per
|
|
771
793
|
// field): it is that role's to send
|
|
772
794
|
const theirs = taken ? [] : candidates.filter((t) => !allowed.includes(t) && holds(t.guard ?? null, scope));
|
|
773
|
-
if (theirs.length) throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(theirs.flatMap((t) => t.by ?? []))].join(' or ')}. ${theirs.map((
|
|
795
|
+
if (theirs.length) throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(theirs.flatMap((t) => t.by ?? []))].join(' or ')}. ${theirs.map(said).filter(Boolean).join(' ')}${allowed.length ? ` Yours: ${allowed.map(said).filter(Boolean).join(' ')}` : ''}`);
|
|
774
796
|
if (!taken) {
|
|
775
797
|
const state = scope.card;
|
|
776
798
|
const reasons = [state.paused && `paused: ${state.paused.reason ?? 'manager pause'}`,
|
|
@@ -787,12 +809,18 @@ export function validateEvent(db, board, entry, id, name, { roles = [], payload
|
|
|
787
809
|
return { task, from, taken, scope, beforeRun: localOnly ? null : noticeRun(db, task) };
|
|
788
810
|
}
|
|
789
811
|
|
|
790
|
-
export function fire(db, board, entry, id, name, { roles = [], payload = {}, extra = {},
|
|
812
|
+
export function fire(db, board, entry, id, name, { roles = [], payload = {}, extra = {}, adopting = false } = {}) {
|
|
791
813
|
const checked = validateEvent(db, board, entry, id, name, { roles, payload, extra });
|
|
792
814
|
if (!checked) return fireRun(db, board, entry, id, name, { roles, payload, extra });
|
|
793
|
-
const { task, from,
|
|
815
|
+
const { task, from, scope, beforeRun } = checked;
|
|
816
|
+
let { taken } = checked;
|
|
794
817
|
const { params } = entry;
|
|
795
818
|
const effects = [];
|
|
819
|
+
// Changing a card's text never moves it out of a review or an ended status, whatever a workflow's specify declares:
|
|
820
|
+
// a reviewed card sent back to work would launch a coder run on it. Its words still land and are recorded. The one
|
|
821
|
+
// move specify makes is a workflow's intake (Hermes's triage → todo), which leaves neither.
|
|
822
|
+
if (name === 'specify' && taken.target && (entry.workflow.statuses[from]?.run_role === 'reviewer' || ['done', 'archived'].includes(storedAs(entry.workflow, from))))
|
|
823
|
+
taken = { ...taken, target: undefined };
|
|
796
824
|
const to = take(db, board, entry, task, from, taken, scope, effects);
|
|
797
825
|
settle(db, board, entry, id, effects);
|
|
798
826
|
projectStatus(db, board, entry, id);
|
|
@@ -800,11 +828,11 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
|
|
|
800
828
|
// never turns it into an incident that waits for a second act
|
|
801
829
|
const ended = task.current_run_id && one(db, 'SELECT ended_at FROM task_runs WHERE id = ?', task.current_run_id)?.ended_at != null ? task.current_run_id : null;
|
|
802
830
|
if ((scope.card.dispatch_hold || ended) && roles.some(role => ['manager','creator'].includes(role)) &&
|
|
803
|
-
(['resume','reclaim','replace_session','reopen','reopen_review'].includes(name) || (name === 'set' && ['assignee','machine','model'].includes(payload.field))))
|
|
831
|
+
(['resume','reclaim','replace_session','reopen','reopen_review','return_review'].includes(name) || (name === 'set' && ['assignee','machine','model'].includes(payload.field))))
|
|
804
832
|
record(db,id,'dispatch_released',{reason:`explicit ${name}`,incident:scope.card.last_events.run_incident?.payload?.run ?? null,...(ended ? { ended_run: ended } : {}),
|
|
805
833
|
...(ended && name === 'set' && payload.field === 'assignee' && (payload.value ?? null) !== (task.assignee ?? null)
|
|
806
834
|
? { reassigned_from: task.assignee ?? null, reassigned_to: payload.value ?? null, abandon_waiting_input: true } : {})});
|
|
807
|
-
queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects,
|
|
835
|
+
queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects, adopting);
|
|
808
836
|
return { from, to: statusOf(db, cardOf(db, id)), moved: to !== from, effects };
|
|
809
837
|
}
|
|
810
838
|
|
|
@@ -967,6 +995,10 @@ const FORMAT = {
|
|
|
967
995
|
// the reviewer is the lane that reviewed (the card's assignee while in review), unless the verdict names one
|
|
968
996
|
changes_requested: ({ e, before, after }) => ({ reason: e.reason ?? null, reviewer: e.reviewer ?? before.assignee ?? null, implementer: after.assignee }),
|
|
969
997
|
review_reopened: ({ after, to }) => ({ status: to, implementer: after.assignee }),
|
|
998
|
+
// the manager's return of a review with no verdict: the request it returned and its run, so the next review's selection
|
|
999
|
+
// reads which heads it named (ci.mjs returnedReview)
|
|
1000
|
+
review_returned: ({ e, before, after, to }) => ({ reason: e.reason ?? null, status: to, review_request: before.last_events.review_requested?.id ?? null,
|
|
1001
|
+
review_run: before.current_run_id ?? null, implementer: after.assignee }),
|
|
970
1002
|
review_voided: ({ e, before }) => ({ reason: e.reason, review_request: before.last_events.review_requested?.id ?? null,
|
|
971
1003
|
review_run: before.latest_review?.id ?? null, verdict: Math.max(before.last_events.review_pass?.id ?? 0, before.last_events.changes_requested?.id ?? 0) || null }),
|
|
972
1004
|
// what arrived (an unblock says it), and where the card resumes
|
|
@@ -1083,6 +1115,10 @@ const ACTIONS = {
|
|
|
1083
1115
|
if ((value ?? null) === task.machine) return;
|
|
1084
1116
|
if (task.workspace_kind !== 'scratch' && (!task.workspace_anchor || isWorkspacePath(task.workspace_anchor))) throw new Error('declare the repository and branch with workflow specify --workspace before moving a legacy path card');
|
|
1085
1117
|
ACTIONS.forget_session(ctx, '*');
|
|
1118
|
+
// a worktree the card made with only its path recorded (workspace.mjs unrecordedMaking) is recorded as made before
|
|
1119
|
+
// the move clears the path, so the branch it made stays its own on the new machine
|
|
1120
|
+
const made = unrecordedMaking(ctx.db, ctx.id);
|
|
1121
|
+
if (made) record(ctx.db, ctx.id, 'worktree_made', made, null);
|
|
1086
1122
|
run(ctx.db, 'UPDATE tasks SET workspace_path=NULL WHERE id=?', ctx.id);
|
|
1087
1123
|
cardField(ctx, 'workspace_root', null);
|
|
1088
1124
|
cardField(ctx, 'machine', value ?? null);
|
|
@@ -1130,10 +1166,13 @@ const ACTIONS = {
|
|
|
1130
1166
|
WHERE id = ? AND ended_at IS NULL`, outcome, outcome, summary, error ?? ctx.scope.event?.error ?? null, t, runId);
|
|
1131
1167
|
ctx.runId = runId;
|
|
1132
1168
|
ctx.lastOutcome = outcome;
|
|
1133
|
-
|
|
1169
|
+
// the card's own coder bound as its reviewer leaves the reviewer slot unclosed (handBackCoder), here or earlier in
|
|
1170
|
+
// this move (its close_session); the run being ended is the one it was handed back from
|
|
1171
|
+
const handed = handBackCoder(ctx.db, ctx.id, runId) || handedBackFrom(json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id = ?', runId)?.metadata, {})?.supercode, runId);
|
|
1172
|
+
if (!handed) runSession(ctx, session => ({ ...session, retain_after_run: !close_session }));
|
|
1134
1173
|
// A declared completion may end the attempt while retaining its role session. In particular, its own caller
|
|
1135
1174
|
// must be able to finish and return before the board starts the independent review.
|
|
1136
|
-
if (close_session) {
|
|
1175
|
+
if (close_session && !handed) {
|
|
1137
1176
|
requestSessionClose(ctx.db, ctx.id, { runId });
|
|
1138
1177
|
ctx.effects.push({ kind: 'run_ended', id: ctx.id, runId, outcome });
|
|
1139
1178
|
}
|
|
@@ -1198,19 +1237,31 @@ const ACTIONS = {
|
|
|
1198
1237
|
if (said?.workspace) {
|
|
1199
1238
|
const task = cardOf(ctx.db, ctx.id), ws = said.workspace;
|
|
1200
1239
|
if (task.current_run_id) throw new Error('end the current run before changing its workspace');
|
|
1201
|
-
ACTIONS.forget_session(ctx, '*');
|
|
1202
|
-
const prior = one(ctx.db, 'SELECT workspace_anchor FROM supercode_cards WHERE task_id=?', ctx.id);
|
|
1203
1240
|
const moves = Object.hasOwn(ws, 'machine');
|
|
1241
|
+
// A retarget lets go of the card's sessions, but never of the arc's own coder while it is open (openCoder): its
|
|
1242
|
+
// next run, a review's return included, is delivered into that session, never a fresh one started beside it
|
|
1243
|
+
// (VO-3755 t_c2d5d21b: a retarget forgot adopted coder c46c2896 at 06:51:01Z on 2026-10-27, and the run after its
|
|
1244
|
+
// review's changes_requested, 2736, launched ee91b4d8 beside it). A move to another machine lets it go.
|
|
1245
|
+
forgetSessions(ctx, '*', null, moves ? null : openCoder(ctx.db, ctx.id));
|
|
1246
|
+
const prior = one(ctx.db, 'SELECT workspace_anchor FROM supercode_cards WHERE task_id=?', ctx.id);
|
|
1204
1247
|
record(ctx.db, ctx.id, 'workspace_changed', { from: { kind: task.workspace_kind, repository: prior?.workspace_anchor, branch: task.branch_name, ...(moves ? { machine: task.machine } : {}) }, to: { kind: ws.kind, repository: ws.repository, branch: ws.branch, ...(moves ? { machine: ws.machine } : {}) } }, null);
|
|
1205
1248
|
run(ctx.db, 'UPDATE tasks SET workspace_kind=?,workspace_path=?,branch_name=? WHERE id=?', ws.kind, ws.path ?? null, ws.branch ?? null, ctx.id);
|
|
1206
1249
|
cardField(ctx, 'workspace_anchor', ws.repository ?? null);
|
|
1207
1250
|
cardField(ctx, 'workspace_root', null);
|
|
1208
1251
|
if (moves) cardField(ctx, 'machine', ws.machine ?? null);
|
|
1252
|
+
// a branch the board named for the card is claimed until its worktree is made (workspace.mjs branchClaim)
|
|
1253
|
+
if (ws.named && ws.branch) record(ctx.db, ctx.id, 'branch_named', { branch: ws.branch, repository: ws.repository ?? null }, null);
|
|
1209
1254
|
}
|
|
1210
1255
|
if (said?.readable_id != null) {
|
|
1211
1256
|
const task = cardOf(ctx.db, ctx.id);
|
|
1212
|
-
const readable = allocateReadableId(ctx.db, task.title, said.readable_id, ctx.id);
|
|
1257
|
+
const readable = task.subtask_of ? allocateReadableId(ctx.db, task.title, said.readable_id, ctx.id) : cardReadableId(ctx.db, said.readable_id, ctx.id);
|
|
1213
1258
|
run(ctx.db, 'UPDATE tasks SET readable_id=? WHERE id=?', readable, ctx.id);
|
|
1259
|
+
// a branch the board named for the card follows its name until its worktree is made (workspace.mjs branchClaim)
|
|
1260
|
+
if (readable !== task.readable_id && !task.subtask_of && task.branch_name?.split('/').at(-1) === task.readable_id && branchClaim(ctx.db, ctx.id, task.branch_name)) {
|
|
1261
|
+
const branch = cardBranch(ctx.db, ctx.id, readable, task.branch_name.slice(0, task.branch_name.lastIndexOf('/') + 1));
|
|
1262
|
+
run(ctx.db, 'UPDATE tasks SET branch_name=? WHERE id=?', branch, ctx.id);
|
|
1263
|
+
record(ctx.db, ctx.id, 'branch_named', { branch, repository: one(ctx.db, 'SELECT workspace_anchor FROM supercode_cards WHERE task_id=?', ctx.id)?.workspace_anchor ?? null }, null);
|
|
1264
|
+
}
|
|
1214
1265
|
if (readable !== task.readable_id) {
|
|
1215
1266
|
// Keep the obligation with the edit: a stopped dispatcher or interrupted caller must not lose the rename.
|
|
1216
1267
|
// Delivery reads the current name, so an older edit can never restore an obsolete name.
|
|
@@ -1264,15 +1315,7 @@ const ACTIONS = {
|
|
|
1264
1315
|
const name = String(typeof slot === 'object' && slot ? slot.slot ?? 'worker' : slot ?? 'worker');
|
|
1265
1316
|
const harness = typeof slot === 'object' && slot ? ctx.entry.profiles[slot.assignee]?.worker?.harness : null;
|
|
1266
1317
|
if (typeof slot === 'object' && slot && !harness) return;
|
|
1267
|
-
|
|
1268
|
-
const whole = json(row.metadata, {}) ?? {};
|
|
1269
|
-
const session = whole.supercode;
|
|
1270
|
-
if ((!session?.session_id && !session?.pane && !session?.launch_key) || (name !== '*' && (session.slot ?? 'worker') !== name) || (harness && (session.harness ?? 'claude-code') === harness)) continue;
|
|
1271
|
-
requestSessionClose(ctx.db, ctx.id, { runId: row.id });
|
|
1272
|
-
const latest = json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id=?', row.id)?.metadata, {}) ?? {};
|
|
1273
|
-
latest.supercode = { ...latest.supercode, forgotten: now(), resumable: false };
|
|
1274
|
-
run(ctx.db, "UPDATE task_runs SET metadata=?, ended_at=COALESCE(ended_at,?), status=CASE WHEN ended_at IS NULL THEN 'superseded' ELSE status END, outcome=CASE WHEN ended_at IS NULL THEN 'superseded' ELSE outcome END, claim_lock=NULL,claim_expires=NULL,worker_pid=NULL WHERE id=?", JSON.stringify(latest), now(), row.id);
|
|
1275
|
-
}
|
|
1318
|
+
forgetSessions(ctx, name, harness);
|
|
1276
1319
|
},
|
|
1277
1320
|
// an event sent to other cards (`to`: an expression naming their ids, e.g. a card's open subtasks), as `as` sends it;
|
|
1278
1321
|
// a card it does not apply to now is passed over
|
|
@@ -1302,15 +1345,19 @@ const ACTIONS = {
|
|
|
1302
1345
|
ctx.effects.push({ kind: 'board_message', id: ctx.id });
|
|
1303
1346
|
},
|
|
1304
1347
|
send_to_actor(ctx, text) {
|
|
1348
|
+
// A board message that wakes the run's session (an idle reminder) carries the run's pending brief when its caller
|
|
1349
|
+
// read one (dispatch.mjs pendingBrief): the text saved here is the text delivered, whatever the workflow's words.
|
|
1350
|
+
const brief = typeof ctx.scope.pending_brief === 'string' && ctx.scope.pending_brief ? ctx.scope.pending_brief : null;
|
|
1351
|
+
if (brief && !String(text ?? '').includes(brief)) text = `${String(text ?? '')} ${brief}`;
|
|
1305
1352
|
const effect = { kind: 'send_to_actor', id: ctx.id, runId: ctx.runId, text, messageKey: `board-actor-${randomBytes(16).toString('hex')}` };
|
|
1306
1353
|
const inserted = run(ctx.db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
|
|
1307
|
-
effect.text = identifyNotice({ id: `notice-${inserted.lastInsertRowid}`, kind: 'actor_notice', run_id: ctx.runId, created_at: now() }, numbered(boardKey(ctx.board.root, ctx.board.slug), cardNumber(ctx.db, ctx.id), text));
|
|
1354
|
+
effect.text = identifyNotice({ id: `notice-${inserted.lastInsertRowid}`, kind: 'actor_notice', run_id: ctx.runId, created_at: now() }, numbered(boardKey(ctx.board.root, ctx.board.slug), cardNumber(ctx.db, ctx.id), text, cardName(ctx.db, ctx.id)));
|
|
1308
1355
|
run(ctx.db, 'UPDATE supercode_effects SET effect=? WHERE id=?', JSON.stringify(effect), Number(inserted.lastInsertRowid));
|
|
1309
1356
|
ctx.effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
|
|
1310
1357
|
},
|
|
1311
1358
|
};
|
|
1312
1359
|
|
|
1313
|
-
/**
|
|
1360
|
+
/** A newer attempt on a card retires its stale references. Adoption can share a session across cards. */
|
|
1314
1361
|
export function retireSupersededRuns(db, id, runId, session) {
|
|
1315
1362
|
if (!session?.session_id && !session?.pane) return;
|
|
1316
1363
|
for (const old of all(db, `SELECT id,task_id,ended_at,metadata FROM task_runs WHERE id IN (
|
|
@@ -1321,6 +1368,10 @@ export function retireSupersededRuns(db, id, runId, session) {
|
|
|
1321
1368
|
runId, session.machine ?? null, session.session_id ?? null, runId, session.machine ?? null, session.pane ?? null)) {
|
|
1322
1369
|
const whole = json(old.metadata, {}) ?? {}, s = whole.supercode;
|
|
1323
1370
|
if (!s || detachedSession(db, s) || s.machine !== session.machine || !((session.session_id && s.session_id === session.session_id) || (session.pane && s.pane === session.pane))) continue;
|
|
1371
|
+
// Attaching records work on another card, not a transfer of its native session or agent identity.
|
|
1372
|
+
// Keep both cards' runs and close receipts independent; an attachment cannot retire its starter's run,
|
|
1373
|
+
// and the starter's later lifecycle must not retire an attached card's run either.
|
|
1374
|
+
if (old.task_id !== id && (session.mode === 'adopt' || s.mode === 'adopt')) continue;
|
|
1324
1375
|
if (old.task_id !== id && one(db, 'SELECT 1 FROM tasks WHERE current_run_id = ?', old.id)) throw new Error(`session is still owned by active run ${old.id} of ${old.task_id}`);
|
|
1325
1376
|
const transferred = (s.pane && s.pane === session.pane) || (!s.pane && !session.pane && s.session_id === session.session_id);
|
|
1326
1377
|
if (old.ended_at) {
|
|
@@ -1351,6 +1402,8 @@ export function requestSessionClose(db, id, { runId = null, slot = null, through
|
|
|
1351
1402
|
const session = whole.supercode;
|
|
1352
1403
|
if (detachedSession(db, session)) continue;
|
|
1353
1404
|
if ((!session?.pane && !session?.launch_key && !(session?.surface === 'native' && session.session_id)) || session.close_transferred_to || session.close_confirmed_at || (slot != null && (session.slot ?? 'worker') !== slot)) continue;
|
|
1405
|
+
// A reviewer binding of the card's own coder is never the review's to close (handBackCoder)
|
|
1406
|
+
if ((session.slot ?? 'worker') === 'reviewer' && handBackCoder(db, id, row.id)) continue;
|
|
1354
1407
|
if (session.close_requested && !session.closed_at) continue;
|
|
1355
1408
|
whole.supercode = { ...session, close_requested: session.close_requested ?? now() };
|
|
1356
1409
|
delete whole.supercode.closed_at; // older versions recorded success without checking the receipt
|
|
@@ -1358,6 +1411,78 @@ export function requestSessionClose(db, id, { runId = null, slot = null, through
|
|
|
1358
1411
|
}
|
|
1359
1412
|
}
|
|
1360
1413
|
|
|
1414
|
+
/**
|
|
1415
|
+
* Let go of card `ctx.id`'s sessions in slot `name` ('*': every slot), those of another harness than `harness` when it is
|
|
1416
|
+
* given: each is asked to close and is never continued; the slot's next run starts a new session. `keep` names a worker
|
|
1417
|
+
* session that stays the arc's (openCoder): it is neither closed nor forgotten, so its next run is delivered into it.
|
|
1418
|
+
*/
|
|
1419
|
+
function forgetSessions(ctx, name, harness, keep = null) {
|
|
1420
|
+
for (const row of all(ctx.db, 'SELECT id, metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC', ctx.id)) {
|
|
1421
|
+
const whole = json(row.metadata, {}) ?? {};
|
|
1422
|
+
const session = whole.supercode;
|
|
1423
|
+
if ((!session?.session_id && !session?.pane && !session?.launch_key) || (name !== '*' && (session.slot ?? 'worker') !== name) || (harness && (session.harness ?? 'claude-code') === harness)) continue;
|
|
1424
|
+
if (keep && session.session_id === keep && (session.slot ?? 'worker') === 'worker') continue;
|
|
1425
|
+
requestSessionClose(ctx.db, ctx.id, { runId: row.id });
|
|
1426
|
+
const latest = json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id=?', row.id)?.metadata, {}) ?? {};
|
|
1427
|
+
// a reviewer binding the close handed back to its coder's slot is the coder's, and is not forgotten with the review
|
|
1428
|
+
if (handedBackFrom(latest.supercode, row.id) && name !== '*' && name !== 'worker') continue;
|
|
1429
|
+
latest.supercode = { ...latest.supercode, forgotten: now(), resumable: false };
|
|
1430
|
+
run(ctx.db, "UPDATE task_runs SET metadata=?, ended_at=COALESCE(ended_at,?), status=CASE WHEN ended_at IS NULL THEN 'superseded' ELSE status END, outcome=CASE WHEN ended_at IS NULL THEN 'superseded' ELSE outcome END, claim_lock=NULL,claim_expires=NULL,worker_pid=NULL WHERE id=?", JSON.stringify(latest), now(), row.id);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* The session id of card `id`'s worker slot's latest session when it is still open: an adopted session the board released
|
|
1436
|
+
* (left running at its run's end) or one retained for its next run, never closed or let go. It is the arc's coder, and
|
|
1437
|
+
* every next implementer run continues it (dispatch.mjs continuationOf); null when the slot's session is closed or gone.
|
|
1438
|
+
*/
|
|
1439
|
+
function openCoder(db, id) {
|
|
1440
|
+
for (const row of all(db, 'SELECT metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC', id)) {
|
|
1441
|
+
const s = json(row.metadata, {})?.supercode;
|
|
1442
|
+
if (!s?.session_id || (s.slot ?? 'worker') !== 'worker') continue;
|
|
1443
|
+
return !s.forgotten && !detachedSession(db, s) && (s.released || (s.retain_after_run && !s.close_requested)) ? s.session_id : null;
|
|
1444
|
+
}
|
|
1445
|
+
return null;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* Run `runId`'s reviewer binding, when it is card `id`'s own coder (a worker or side session adopted onto its review by
|
|
1450
|
+
* mistake, t_2a2ef475 run 2732), leaves the reviewer slot as its review ends, and is never closed by that end:
|
|
1451
|
+
* - the worker slot's latest session (the coder's own, kept open through review by ADR 0019) is handed back to the
|
|
1452
|
+
* worker slot, retained as a finish retains it, so the next implementer run continues it (transferRetainedSession);
|
|
1453
|
+
* - any other session (a side session beside that coder) is released, as the board releases any session it did not
|
|
1454
|
+
* launch: left running, its close settled, in no slot, so it neither outranks the coder's session in the worker slot
|
|
1455
|
+
* nor holds a later review.
|
|
1456
|
+
* Answers whether it did. After it, the binding is an ordinary one of its new slot, closed like any other.
|
|
1457
|
+
*/
|
|
1458
|
+
function handBackCoder(db, id, runId) {
|
|
1459
|
+
const whole = json(one(db, 'SELECT metadata FROM task_runs WHERE id = ?', runId)?.metadata, {}) ?? {};
|
|
1460
|
+
const session = whole.supercode;
|
|
1461
|
+
if (!session?.session_id || session.role !== 'reviewer' || (session.slot ?? 'worker') !== 'reviewer' || !ownCoder(db, id, session.session_id)) return false;
|
|
1462
|
+
const coder = one(db, `SELECT json_extract(metadata, '$.supercode.session_id') AS session_id FROM task_runs WHERE task_id = ? AND id != ?
|
|
1463
|
+
AND COALESCE(json_extract(metadata, '$.supercode.slot'), 'worker') = 'worker' AND json_extract(metadata, '$.supercode.session_id') IS NOT NULL
|
|
1464
|
+
ORDER BY id DESC LIMIT 1`, id, runId)?.session_id;
|
|
1465
|
+
const at = now();
|
|
1466
|
+
if (coder === session.session_id) whole.supercode = { ...session, slot: 'worker', retain_after_run: true, close_requested: null, handed_back_from: 'reviewer', handed_back_run: runId };
|
|
1467
|
+
else {
|
|
1468
|
+
whole.supercode = { ...session, slot: 'side', retain_after_run: false, close_requested: session.close_requested ?? at, closed_at: at, close_confirmed_at: at, close_error: null, released: true, handed_back_from: 'reviewer', handed_back_run: runId };
|
|
1469
|
+
record(db, id, 'session_released', { session_id: session.session_id, pane: session.pane ?? null, machine: session.machine ?? null, reason: 'the card\'s own coder, bound as its reviewer: left running, out of the reviewer slot' }, runId);
|
|
1470
|
+
}
|
|
1471
|
+
run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(whole), runId);
|
|
1472
|
+
return true;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/** Whether binding `session` is the one handBackCoder handed back from run `runId`'s review. The marker names its run, so a
|
|
1476
|
+
* copy of the binding on another run (a continuation) is never taken for it, whatever the copy carried. */
|
|
1477
|
+
const handedBackFrom = (session, runId) => session?.handed_back_from === 'reviewer' && Number(session.handed_back_run) === Number(runId);
|
|
1478
|
+
|
|
1479
|
+
/** Whether native session `sessionId` is card `id`'s own coder: a worker-slot session of any of its runs, or a side session. */
|
|
1480
|
+
export function ownCoder(db, id, sessionId) {
|
|
1481
|
+
if (sideSessions(db, id).some((s) => s.session === sessionId)) return true;
|
|
1482
|
+
return Boolean(one(db, `SELECT 1 FROM task_runs WHERE task_id = ? AND COALESCE(json_extract(metadata, '$.supercode.slot'), 'worker') = 'worker'
|
|
1483
|
+
AND json_extract(metadata, '$.supercode.session_id') = ? LIMIT 1`, id, sessionId));
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1361
1486
|
function runSession(ctx, change) {
|
|
1362
1487
|
const runId = ctx.runId ?? one(ctx.db, 'SELECT current_run_id FROM tasks WHERE id = ?', ctx.id)?.current_run_id;
|
|
1363
1488
|
if (!runId) return;
|
|
@@ -1475,7 +1600,9 @@ function settleRelations(db, board, entry, id, effects) {
|
|
|
1475
1600
|
const current = json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []);
|
|
1476
1601
|
run(db, 'UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?', JSON.stringify(current.map((b) => b.id === block.id ? { ...b, answered: answer } : b)), id);
|
|
1477
1602
|
}
|
|
1478
|
-
|
|
1603
|
+
// an answer's reaction names who answered as its actor (`by`): nobody is told of what their own reply caused
|
|
1604
|
+
const replier = answer ? mailboxAnswers.get(block.id)?.by ?? null : null;
|
|
1605
|
+
if (name) effects.push(...fire(db, board, entry, id, name, { roles: ['platform'], payload: { on: block.id, reason: answer ? `reply ${answer}` : `${block.id} ${status}`, ...(replier ? { by: replier } : {}) } }).effects);
|
|
1479
1606
|
// a block that has aged is told once, not every age interval for as long as it lasts
|
|
1480
1607
|
else if (policy.aged && policy.age_seconds != null && !block.aged_at) {
|
|
1481
1608
|
const seconds = Number(evaluate(String(policy.age_seconds), { params: entry.params }));
|