@volter/supercode-orchestrator 0.5.211 → 0.5.213
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 +33 -7
- package/bin/orchestrator.mjs +5 -0
- package/board/attachment-write.mjs +68 -0
- package/board/ci.mjs +105 -78
- package/board/cli.mjs +38 -2
- package/board/dispatch.mjs +171 -51
- package/board/engine.mjs +20 -11
- package/board/github-rest.mjs +84 -2
- package/board/incidents.mjs +1 -0
- package/board/machine-fleet.mjs +3 -1
- package/board/session-work.mjs +7 -0
- package/board/store.mjs +2 -2
- package/board/workflow.mjs +39 -13
- package/board/workspace.mjs +21 -10
- package/board-input.d.ts +2 -0
- package/board-input.mjs +33 -1
- package/connection.mjs +5 -1
- package/hermes-cli.mjs +7 -19
- package/index.d.ts +1 -1
- package/loop.mjs +11 -1
- package/mcp/mount.mjs +22 -14
- package/orchestration.mjs +29 -2
- package/package.json +3 -3
package/board/dispatch.mjs
CHANGED
|
@@ -25,9 +25,9 @@ import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
|
25
25
|
import { monitorsRuns, recoversRuns, retainRecovery, failedObservation, surfaceIncident, auditEndedRuns, originOf } from './incidents.mjs';
|
|
26
26
|
import { readMachineFleet } from './machine-fleet.mjs';
|
|
27
27
|
import { homeFleetMachines } from './fleet-targets.mjs';
|
|
28
|
-
import { delegatedWork, idleInterval } from './session-work.mjs';
|
|
28
|
+
import { delegatedWork, idleInterval, questionText } from './session-work.mjs';
|
|
29
29
|
import { localSessionDelegations } from '../agents.mjs';
|
|
30
|
-
import { creatorOf, sessionProgress } from './workflow.mjs';
|
|
30
|
+
import { cardSectionHeading, creatorOf, sessionProgress } from './workflow.mjs';
|
|
31
31
|
import { boardLabel, boardName } from '@volter/teams/board-name';
|
|
32
32
|
let fleetRoot = null;
|
|
33
33
|
import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, withFreshReviewReads, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
|
|
@@ -51,7 +51,7 @@ import { homedir, hostname, tmpdir, totalmem } from 'node:os';
|
|
|
51
51
|
import { basename, dirname, join } from 'node:path';
|
|
52
52
|
import { createHash, randomUUID } from 'node:crypto';
|
|
53
53
|
import { fileURLToPath } from 'node:url';
|
|
54
|
-
import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, NOTICE_RUN_IDS, openBoard, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
|
|
54
|
+
import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, NOTICE_RUN_IDS, openBoard, openBoardSnapshot, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
|
|
55
55
|
import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
|
|
56
56
|
import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
|
|
57
57
|
import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
|
|
@@ -813,6 +813,40 @@ async function stopOwnedSession(board, session) {
|
|
|
813
813
|
}
|
|
814
814
|
}
|
|
815
815
|
|
|
816
|
+
/**
|
|
817
|
+
* Answer the question card `id`'s session shows on its pane (VO-3395): the option `choice` names (its number or its
|
|
818
|
+
* label), chosen through the pane machine's `panes.answer` door, as this board, for `by` (who asked the board to answer,
|
|
819
|
+
* recorded as said). The card records `question_answered` with who answered, what was chosen and whether the pane
|
|
820
|
+
* showed the answer taken; a door that refused or could not be reached records nothing and throws.
|
|
821
|
+
*/
|
|
822
|
+
export async function answerQuestion(board, id, choice, { by }) {
|
|
823
|
+
const run = view(board, (db) => { const task = getTask(db, id); return task?.current_run_id ? currentRun(db, id) : null; });
|
|
824
|
+
if (!run) throw new Error(`${id} has no active run, so no session of it is asking anything`);
|
|
825
|
+
const session = run.metadata?.supercode ?? {};
|
|
826
|
+
if (!session.pane) throw new Error(`${id}'s session runs in no pane a machine keeps (${session.surface ?? 'unknown surface'}); its question has no pane door`);
|
|
827
|
+
const asked = session.question ?? null;
|
|
828
|
+
const machine = normalMachine(session.machine ?? localMachine()) === localMachine() ? null : session.machine;
|
|
829
|
+
const r = await runtimeDoor(machine, 'harness.v1.panes.answer', {
|
|
830
|
+
// the board has held the asker to the card's manager, creator or a person: on its own machine it says so as
|
|
831
|
+
// --foreign does, since a Windows daemon takes no local caller as established (#1245) and would refuse the board
|
|
832
|
+
pane: session.pane, choice: String(choice), answerer: by ?? null, foreign: true,
|
|
833
|
+
...(asked?.question ? { question: asked.question } : {}), ...(session.launch_key ? { launch: session.launch_key } : {}),
|
|
834
|
+
}, { timeout: 30_000, as: boardAddress(board.label) });
|
|
835
|
+
if (r.code !== 0) {
|
|
836
|
+
const failure = json(r.stderr.trim(), null);
|
|
837
|
+
throw new Error(`${id}: pane ${session.pane} on ${session.machine ?? localMachine()} was not answered: ${failure?.message ?? (r.stderr || r.stdout).trim().slice(-500)}`);
|
|
838
|
+
}
|
|
839
|
+
const answer = json(r.stdout.trim(), {});
|
|
840
|
+
tx(board, (db) => {
|
|
841
|
+
const current = currentRun(db, id);
|
|
842
|
+
event(db, id, 'question_answered', { by: by ?? null, through: boardAddress(board.label), pane: session.pane, machine: session.machine ?? null,
|
|
843
|
+
question: answer.question ?? asked?.question ?? null, choice: answer.choice ?? null, keys: answer.keys ?? null, answered: answer.answered === true,
|
|
844
|
+
...(answer.reason ? { reason: answer.reason } : {}) }, current?.id === run.id ? run.id : null);
|
|
845
|
+
if (answer.answered === true && current?.id === run.id) setRunSession(db, run.id, { question: null });
|
|
846
|
+
});
|
|
847
|
+
return answer;
|
|
848
|
+
}
|
|
849
|
+
|
|
816
850
|
// ------------------------------------------------------------------ starting, as the workflow's dispatch says
|
|
817
851
|
|
|
818
852
|
/** The command form a card's session uses to reach its board: `--root` on the board's machine, `--at` elsewhere. */
|
|
@@ -830,7 +864,14 @@ async function homeHoldings(root, slugs, lanes, fleet, entry) {
|
|
|
830
864
|
const out = [];
|
|
831
865
|
for (const slug of slugs) {
|
|
832
866
|
try {
|
|
833
|
-
|
|
867
|
+
// Capacity spans awaits. It needs its own committed snapshot, not the shared writer's total_changes counter:
|
|
868
|
+
// unrelated owner calls and delivery writes used to invalidate all three attempts at this read.
|
|
869
|
+
let b;
|
|
870
|
+
try { b = openBoardSnapshot(root, slug); }
|
|
871
|
+
catch (error) {
|
|
872
|
+
if (error.code !== 'board_read_by_owner') throw error;
|
|
873
|
+
b = openBoard(root, slug); // files/non-WAL backings retain their guarded optimistic read
|
|
874
|
+
}
|
|
834
875
|
try {
|
|
835
876
|
b.slug ??= slug; b.label ??= boardLabel(root, slug);
|
|
836
877
|
// each run's session read before it is counted live or not (unknown stays unknown)
|
|
@@ -840,7 +881,7 @@ async function homeHoldings(root, slugs, lanes, fleet, entry) {
|
|
|
840
881
|
runLaneOf: row => runLane(b.db, b, entry, getTask(b.db, row.id), lanes) }));
|
|
841
882
|
}
|
|
842
883
|
finally { b.close(); }
|
|
843
|
-
} catch (error) {
|
|
884
|
+
} catch (error) { error.capacityUnread = true; throw error; }
|
|
844
885
|
}
|
|
845
886
|
out.defaults = harnessDefaults(out);
|
|
846
887
|
return out;
|
|
@@ -927,6 +968,9 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
|
|
|
927
968
|
if (task.subtask_of || task.current_run_id) continue;
|
|
928
969
|
const state = scopeFor(task).card;
|
|
929
970
|
if (state.archived || ['done', 'cancelled'].includes(state.status)) continue;
|
|
971
|
+
if (state.status === 'running' && completedWork(board.db, task.id)) {
|
|
972
|
+
waiting(task, 'work is complete; the board must select its independent review before another implementer run'); continue;
|
|
973
|
+
}
|
|
930
974
|
if (state.detached) { waiting(task, `session belongs to ${state.detached.person}; verified adoption is required`); continue; }
|
|
931
975
|
if (state.dispatch_hold) { waiting(task, 'dispatch held: an explicit start or verified adoption must release it'); continue; }
|
|
932
976
|
if (workflow.run && state.paused) { waiting(task, `paused: ${state.paused.reason ?? 'manager pause'}`); continue; }
|
|
@@ -991,7 +1035,7 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
|
|
|
991
1035
|
const refused = caps.admit(waitingEntries[i], scope, waitingEntries);
|
|
992
1036
|
if (refused) throw Object.assign(new Error(refused.why), { claimedElsewhere: true });
|
|
993
1037
|
assertWorkspaceOwner(db, task, checked);
|
|
994
|
-
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.
|
|
1038
|
+
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, task.id);
|
|
995
1039
|
if (!task.assignee && plan.profile) {
|
|
996
1040
|
db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(plan.profile, task.id);
|
|
997
1041
|
event(db, task.id, 'assigned', { assignee: plan.profile, source: 'workflow role' });
|
|
@@ -1076,7 +1120,7 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
|
|
|
1076
1120
|
const checked = await checkWorkspace(board, task);
|
|
1077
1121
|
if (checked) runTx(board, id, runId, (db) => {
|
|
1078
1122
|
assertWorkspaceOwner(db, task, checked);
|
|
1079
|
-
db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.
|
|
1123
|
+
db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, id);
|
|
1080
1124
|
});
|
|
1081
1125
|
const status = view(board, (db) => statusOf(db, task));
|
|
1082
1126
|
const spec = runSpec(entry, status, task);
|
|
@@ -1209,7 +1253,7 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
|
|
|
1209
1253
|
const moved = tx(board, (db) => {
|
|
1210
1254
|
if (detachedCard(db, id)) throw new Error(`${id} is detached to a person; request and verify adoption before starting it`);
|
|
1211
1255
|
assertWorkspaceOwner(db, before, checked);
|
|
1212
|
-
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.
|
|
1256
|
+
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, id);
|
|
1213
1257
|
if (!roles.some(role => ['manager', 'creator'].includes(role))) throw new Error('Only the manager or creator starts a held card; request its decision.');
|
|
1214
1258
|
event(db, id, 'dispatch_released', { reason: 'explicit start' });
|
|
1215
1259
|
requestSessionClose(db,id);
|
|
@@ -1450,7 +1494,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1450
1494
|
continue;
|
|
1451
1495
|
}
|
|
1452
1496
|
}
|
|
1453
|
-
if (session?.monitor_held && !session.close_requested) continue; // only an explicit close action ends this process
|
|
1497
|
+
if ((session?.monitor_held || session?.retain_after_run) && !session.close_requested) continue; // only an explicit close action ends this process
|
|
1454
1498
|
if ((!session?.pane && session?.surface !== 'native') || session.close_transferred_to || session.close_confirmed_at || (!row.ended_at && !session.close_requested)) continue;
|
|
1455
1499
|
if (caller && session.session_id === caller) continue; // let the command that ended its own run return
|
|
1456
1500
|
// The board closes only what it launched. An adopted session (a person's own thread, a hook session the card was
|
|
@@ -1617,7 +1661,18 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1617
1661
|
return Number(rows?.find((row) => row.locator?.session_id === session.session_id)?.updated_at_ms) || null;
|
|
1618
1662
|
};
|
|
1619
1663
|
const managers = [].concat(entry.params.managers ?? []).filter(Boolean);
|
|
1620
|
-
const
|
|
1664
|
+
const running = board.db.prepare(entry.workflow.run ? "SELECT id FROM tasks WHERE current_run_id IS NOT NULL" : "SELECT id FROM tasks WHERE status = 'running'").all();
|
|
1665
|
+
const edges = localSessionDelegations(board.root, localMachine());
|
|
1666
|
+
const reached = new Set(running.map(({ id }) => addressOf(currentRun(board.db, id)?.metadata?.supercode)).filter(Boolean));
|
|
1667
|
+
const delegations = [];
|
|
1668
|
+
const pending = [...reached];
|
|
1669
|
+
const byStarter = new Map();
|
|
1670
|
+
for (const edge of edges) { const children = byStarter.get(edge.starter) ?? []; children.push(edge); byStarter.set(edge.starter, children); }
|
|
1671
|
+
while (pending.length) for (const edge of byStarter.get(pending.pop()) ?? []) {
|
|
1672
|
+
delegations.push(edge);
|
|
1673
|
+
if (!reached.has(edge.session)) { reached.add(edge.session); pending.push(edge.session); }
|
|
1674
|
+
}
|
|
1675
|
+
report.idle_delegations = { recorded: edges.length, reachable: delegations.length };
|
|
1621
1676
|
const delegatedReadings = [...fleet.sessions];
|
|
1622
1677
|
// Forks and concurrent sessions need not have a pane. Read only the exact
|
|
1623
1678
|
// native identities this home's starter receipts name, before any write.
|
|
@@ -1633,7 +1688,6 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1633
1688
|
if (row?.activity) delegatedReadings.push({ address, state: row.activity.presence, turn: row.activity.turn });
|
|
1634
1689
|
} catch { /* an unread child is never called working */ }
|
|
1635
1690
|
}
|
|
1636
|
-
const running = board.db.prepare(entry.workflow.run ? "SELECT id FROM tasks WHERE current_run_id IS NOT NULL" : "SELECT id FROM tasks WHERE status = 'running'").all();
|
|
1637
1691
|
// every running card a session is on: a session waiting on its user is told once, naming them all
|
|
1638
1692
|
const cardsOf = new Map();
|
|
1639
1693
|
for (const { id } of running) {
|
|
@@ -1720,23 +1774,35 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1720
1774
|
}
|
|
1721
1775
|
// a reading that shows the session no longer waiting ends the prompt; a round that did not read it does not
|
|
1722
1776
|
if (session.held_notice && live && !asked) setRunSession(db, runRow.id, { held_notice: null });
|
|
1777
|
+
if (session.question && live && !(asked && live.question)) setRunSession(db, runRow.id, { question: null });
|
|
1723
1778
|
if (asked) {
|
|
1724
1779
|
if (session.idle_since != null || session.idle_notice) setRunSession(db, runRow.id, { idle_since: null, idle_notice: null });
|
|
1725
|
-
//
|
|
1726
|
-
//
|
|
1727
|
-
const
|
|
1780
|
+
// The question its pane shows, with its options, is the card's to carry while it shows (VO-3395): kept on the
|
|
1781
|
+
// run, said in its event and its manager's notice with the door that answers it.
|
|
1782
|
+
const question = live.question && Array.isArray(live.question.options) && live.question.options.length ? {
|
|
1783
|
+
question: String(live.question.question ?? '').slice(0, 1000),
|
|
1784
|
+
options: live.question.options.slice(0, 20).map((option) => ({ number: Number(option.number), label: String(option.label ?? '').slice(0, 300), ...(option.detail ? { detail: String(option.detail).slice(0, 300) } : {}) })),
|
|
1785
|
+
} : null;
|
|
1786
|
+
const asking = question ? JSON.stringify([question.question, question.options.map((option) => option.label)]) : '';
|
|
1787
|
+
if (question && JSON.stringify([session.question?.question, session.question?.options?.map((option) => option.label)]) !== asking)
|
|
1788
|
+
setRunSession(db, runRow.id, { question: { ...question, address, pane: session.pane ?? null, machine: session.machine ?? null, since: observedAt } });
|
|
1789
|
+
// One prompt is one notice: the same wait (what it waits for, since when its harness says, the question its
|
|
1790
|
+
// pane shows) on the same session, whichever of its cards a round reads first and however long it stands.
|
|
1791
|
+
const key = [address, live.waiting_for ?? '', live.turn_since_ms ?? '', asking].join('|');
|
|
1728
1792
|
if (session.held_notice?.key === key) return;
|
|
1729
1793
|
// every run a card works under: an ended run can still be its card's current one
|
|
1730
1794
|
const told = db.prepare(`SELECT r.metadata FROM task_runs r WHERE r.id != ? AND r.id IN (${ACTIVE_RUN_IDS})`).all(runRow.id).some((r) => json(r.metadata, {})?.supercode?.held_notice?.key === key);
|
|
1731
1795
|
setRunSession(db, runRow.id, { held_notice: { key, at: observedAt } });
|
|
1732
1796
|
if (told) return;
|
|
1733
|
-
const observation = retain('waiting_on_user', { waiting_for: live.waiting_for ?? null, since_ms: live.turn_since_ms ?? null });
|
|
1797
|
+
const observation = retain('waiting_on_user', { waiting_for: live.waiting_for ?? null, since_ms: live.turn_since_ms ?? null, ...(question ? { question } : {}) });
|
|
1734
1798
|
const others = (cardsOf.get(address) ?? []).filter((card) => card !== id);
|
|
1735
|
-
const what =
|
|
1799
|
+
const what = question ? `asks on its pane: ${questionText(question)}`
|
|
1800
|
+
: live.turn_source === 'pane_prompt' ? 'shows a prompt on its pane that waits for an answer'
|
|
1736
1801
|
: `reports that it is waiting on its user${live.waiting_for ? ` (${live.waiting_for})` : ''}${live.turn_since_ms ? ` since ${new Date(Number(live.turn_since_ms)).toISOString().slice(0, 19)}Z` : ''}`;
|
|
1737
1802
|
event(db, id, 'session_held', observation, runRow.id);
|
|
1738
1803
|
const to = managers[0] ?? null;
|
|
1739
|
-
|
|
1804
|
+
const door = question ? ` Answer it with: supercode workflow answer ${id} <option number> ${whereOf(board, task)}.` : '';
|
|
1805
|
+
if (to) event(db, id, 'board_message', { to, text: `${id}: its session ${address} ${what}.${door}${others.length ? ` It is also on ${others.join(', ')}.` : ''}` }, null);
|
|
1740
1806
|
(report.held ??= []).push([id, 'waiting_on_user', address]);
|
|
1741
1807
|
return;
|
|
1742
1808
|
}
|
|
@@ -2284,6 +2350,10 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2284
2350
|
const pending = view(board, (db) => db.prepare('SELECT id, ended_at, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId))
|
|
2285
2351
|
.map((r) => ({ ...r, session: json(r.metadata, {})?.supercode }))
|
|
2286
2352
|
.find((r) => (r.session?.pane || r.session?.launch_key || r.session?.surface === 'native') && !r.session.close_transferred_to && (r.session.slot ?? 'worker') === slot && (r.ended_at || r.session.close_requested) && !r.session.close_confirmed_at
|
|
2353
|
+
// Deliberate retention owes no close. Only this slot's latest identity may reach the liveness/continuation
|
|
2354
|
+
// checks below; an explicit close or another unresolved identity still holds the slot.
|
|
2355
|
+
&& !(r.session.retain_after_run && !r.session.close_requested && !r.session.forgotten && last?.session_id
|
|
2356
|
+
&& r.session.session_id === last.session_id && r.session.pane === last.pane && r.session.machine === machine && (r.session.harness ?? 'claude-code') === harness)
|
|
2287
2357
|
&& !(closeRefused(r.session) && last?.session_id && r.session.session_id === last.session_id));
|
|
2288
2358
|
if (pending) throw unavailable(`slot ${slot} still owns pane ${pending.session.pane} from run ${pending.id}; closure must be confirmed before another session starts: ${pending.session.close_error ?? 'close pending'}`);
|
|
2289
2359
|
let prev = view(board, (db) => lastSession(db, task.id, slot));
|
|
@@ -2431,21 +2501,9 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2431
2501
|
if (next === 'refuse') throw new Error(`session ${prev.session_id} still has live pane ${prev.pane}; cannot resume or replace it`);
|
|
2432
2502
|
if (next === 'keep') {
|
|
2433
2503
|
// the session is kept; the heartbeat's count belongs to the run that ended, so the new run starts it again
|
|
2434
|
-
const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _is, idle_notice: _in, unknown_since: _us, unknown_notice: _un, work_progress: _wp, idle_observation: _io, idle_below_threshold: _ib, idle_eligible: _ie, close_requested: _q, closed_at: _c, close_confirmed_at: _cc, close_error: _ce, close_attempts: _ca, close_attempted_at: _ct, close_failed_at: _cf, close_unread: _cu, close_claim: _cl, monitor_held: _mh, released: _rl, review_held: _rh, ...kept } = prev;
|
|
2504
|
+
const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _is, idle_notice: _in, unknown_since: _us, unknown_notice: _un, work_progress: _wp, idle_observation: _io, idle_below_threshold: _ib, idle_eligible: _ie, close_requested: _q, closed_at: _c, close_confirmed_at: _cc, close_error: _ce, close_attempts: _ca, close_attempted_at: _ct, close_failed_at: _cf, close_unread: _cu, close_claim: _cl, monitor_held: _mh, released: _rl, review_held: _rh, retain_after_run: _rr, ...kept } = prev;
|
|
2435
2505
|
session = { ...kept, mode: 'adopt', launched_at: now(), seen_at: now() };
|
|
2436
|
-
//
|
|
2437
|
-
// this run is still the card's current one (its pane and native reads were awaited)
|
|
2438
|
-
tx(board, (db) => {
|
|
2439
|
-
if (!runCurrent(db, task.id, runId)) throw Object.assign(new Error(`run ${runId} of ${task.id} ended or was replaced while its session was read; nothing was told to it`), { launchSuperseded: true });
|
|
2440
|
-
setRunSession(db, runId, { ...session, ...meta });
|
|
2441
|
-
// an earlier run's close of this pane is this run's now: the pane is the kept session's, not a close to retry
|
|
2442
|
-
if (prev.pane) for (const r of db.prepare('SELECT id, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId)) {
|
|
2443
|
-
const s = json(r.metadata, {})?.supercode;
|
|
2444
|
-
if (s?.pane === prev.pane && s.machine === prev.machine && !s.close_confirmed_at && !s.close_transferred_to) setRunSession(db, r.id, { ...s, close_transferred_to: runId, close_requested: null });
|
|
2445
|
-
}
|
|
2446
|
-
});
|
|
2447
|
-
// filed in its mailbox, which delivers it
|
|
2448
|
-
if (input) { assertRunCurrent(board, task.id, runId); await fileMail(board, addressOf(prev), input, `board-run-${task.id}-r${runId}`); }
|
|
2506
|
+
// The common binding below transfers ownership and subscriptions before filing the continuation prompt.
|
|
2449
2507
|
} else if (prev.resumable !== false && await nativeAbsent(prev)) {
|
|
2450
2508
|
// nothing to resume and no context to keep: the slot's next session starts fresh
|
|
2451
2509
|
replaced = { session_id: prev.session_id, why: `${prev.machine} holds no ${prev.harness ?? 'claude-code'} session of that id (it never took a first turn)`, letGo: true };
|
|
@@ -2506,6 +2564,7 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2506
2564
|
const saved = json(row?.metadata, {})?.supercode;
|
|
2507
2565
|
if (saved?.address === session.address && !saved.launch_pending) return true;
|
|
2508
2566
|
setRunSession(db, runId, session);
|
|
2567
|
+
transferRetainedSession(db, task.id, runId, session);
|
|
2509
2568
|
event(db, task.id, kind, { session_id: session.session_id, machine, pane: session.pane, address: session.address, mode: session.mode, ...(replaced ? { replaced } : {}) }, runId);
|
|
2510
2569
|
// the working session hears updates to its card; the slot's previous session no longer does (a reviewer starting
|
|
2511
2570
|
// leaves the implementer's, and a subtask's arc session's, subscription alone)
|
|
@@ -2517,9 +2576,26 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2517
2576
|
return true;
|
|
2518
2577
|
});
|
|
2519
2578
|
if (!recorded) return { ...session, mode: 'finished_during_launch' };
|
|
2579
|
+
if (session.mode === 'adopt' && input) {
|
|
2580
|
+
assertRunCurrent(board, task.id, runId);
|
|
2581
|
+
await fileMail(board, session.address, input, `board-run-${task.id}-r${runId}`);
|
|
2582
|
+
}
|
|
2520
2583
|
return session;
|
|
2521
2584
|
}
|
|
2522
2585
|
|
|
2586
|
+
/** A continued role identity now belongs to its new attempt. Preserve the completed receipt, and keep an old
|
|
2587
|
+
* retained reference from closing the same native process/pane later. Transfer only after a successful bind. */
|
|
2588
|
+
function transferRetainedSession(db, id, runId, session) {
|
|
2589
|
+
if (!session?.session_id) return;
|
|
2590
|
+
for (const row of db.prepare('SELECT id,metadata FROM task_runs WHERE task_id=? AND id<? AND ended_at IS NOT NULL').all(id, runId)) {
|
|
2591
|
+
const old = json(row.metadata, {})?.supercode;
|
|
2592
|
+
if (!old?.retain_after_run || old.close_requested || old.close_confirmed_at || old.close_transferred_to || old.forgotten
|
|
2593
|
+
|| old.session_id !== session.session_id || old.machine !== session.machine || (old.slot ?? 'worker') !== (session.slot ?? 'worker')) continue;
|
|
2594
|
+
setRunSession(db, row.id, { close_transferred_to: runId });
|
|
2595
|
+
event(db, id, 'session_transferred', { successor: runId, session_id: old.session_id, pane: old.pane ?? null }, row.id);
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2523
2599
|
/** The agent a slot's last session belongs to, when it is not the agent this run's `meta` names: the agent store's
|
|
2524
2600
|
* owner of the session where it records one in this run's org, else the agent its own run ran as. Null when it is
|
|
2525
2601
|
* this run's agent, or no agent is known for it (a session from before card agents). */
|
|
@@ -2776,7 +2852,19 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2776
2852
|
const beforeRun = view(board, (db) => currentRun(db, id));
|
|
2777
2853
|
const beforeCustody = view(board, db => JSON.stringify(detachedCard(db, id)));
|
|
2778
2854
|
const beforeSession = beforeRun?.metadata?.supercode;
|
|
2779
|
-
|
|
2855
|
+
const entry0 = workflowFor(board.root), card0 = view(board, (db) => getTask(db, id));
|
|
2856
|
+
const adoptionSlot = beforeSession?.slot ?? view(board, db => {
|
|
2857
|
+
const scope = scopeOf(db, board, card0, { params: entry0.params });
|
|
2858
|
+
return String(templateValue(runSpec(entry0, statusOf(db, card0), card0)?.session ?? 'worker', scope));
|
|
2859
|
+
});
|
|
2860
|
+
const predecessor = view(board, db => lastSession(db, id, adoptionSlot));
|
|
2861
|
+
const sameIdentity = session => addressOf(session) === found.address;
|
|
2862
|
+
// A deferred run has issued no different identity. Bind its own slot's live predecessor, or the session
|
|
2863
|
+
// discovered in its keyed pane, without ending the run or opening another process.
|
|
2864
|
+
const unissued = !beforeSession?.session_id && !beforeSession?.pane && !beforeSession?.launch_key && !beforeSession?.pid && !beforeSession?.launch_pending;
|
|
2865
|
+
const keyedPane = !beforeSession?.session_id && found.pane && beforeSession?.pane === found.pane
|
|
2866
|
+
&& beforeSession?.machine === host && beforeSession?.harness === harness;
|
|
2867
|
+
if (beforeRun && !sameIdentity(beforeSession) && !keyedPane && !(unissued && sameIdentity(predecessor))) throw new Error(`${id} has another active run/session; end it before adopting a different identity`);
|
|
2780
2868
|
if (beforeSession?.pane && beforeSession.pane !== found.pane && await panePresent(beforeSession.machine, beforeSession.pane)) throw new Error(`${id} still has live pane ${beforeSession.pane}; close the extra pane before rebinding ${sessionId}`);
|
|
2781
2869
|
|
|
2782
2870
|
// the session's own working directory, as discovery records it (where `--resume` finds it), else its pane's
|
|
@@ -2790,7 +2878,6 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2790
2878
|
}
|
|
2791
2879
|
// Rule 2: a session working a card role is that role's agent, however it started; adopted, it joins the agent as a
|
|
2792
2880
|
// launch does, and its recorded profile is compared with the role's intent (rule 4).
|
|
2793
|
-
const entry0 = workflowFor(board.root), card0 = view(board, (db) => getTask(db, id));
|
|
2794
2881
|
const agentRole = view(board, (db) => adoptedRole(db, board, entry0, card0, beforeRun)) ?? 'implementer';
|
|
2795
2882
|
const laneName = view(board, (db) => evaluate(entry0.workflow.roles?.[agentRole] ?? 'card.assignee', scopeOf(db, board, card0, { params: entry0.params }))) ?? assignee ?? card0?.assignee;
|
|
2796
2883
|
const lane = laneName ? lanes?.[laneName] : null, context = current().facts.SUPERCODE_CONTEXT, team = agentScope(context);
|
|
@@ -2800,6 +2887,8 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2800
2887
|
const stale = (db) => {
|
|
2801
2888
|
if (request !== null && !adoptionOpenTo(db, id, request, open)) return Object.assign(new Error(`${id}'s adoption request was answered or claimed meanwhile`), { superseded: true });
|
|
2802
2889
|
if ((getTask(db, id)?.current_run_id ?? null) !== (beforeRun?.id ?? null)) return new Error(`${id} changed runs during adoption; retry against its current run`);
|
|
2890
|
+
const binding = currentRun(db, id)?.metadata?.supercode;
|
|
2891
|
+
if (['session_id', 'pane', 'launch_key', 'launch_pending', 'launch_deferred'].some(key => (binding?.[key] ?? null) !== (beforeSession?.[key] ?? null))) return new Error(`${id}'s session binding changed during adoption; retry against its current session`);
|
|
2803
2892
|
if (JSON.stringify(detachedCard(db, id)) !== beforeCustody) return new Error(`${id} changed session custody during adoption; request adoption again`);
|
|
2804
2893
|
return null;
|
|
2805
2894
|
};
|
|
@@ -2816,8 +2905,9 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2816
2905
|
// agent store another, and every round after would trip on it.
|
|
2817
2906
|
const owner = agent ? agentRunForSession(board.root, [`${harness}:${sessionId}`]) : null;
|
|
2818
2907
|
if (owner && owner.agent_id !== agent.identity.id) throw new Error(`${found.address} already works for agent ${owner.agent_id} (run ${owner.run_id}); it cannot be adopted as ${agent.identity.id}`);
|
|
2819
|
-
//
|
|
2820
|
-
|
|
2908
|
+
// Each role owns its own slot. A retained worker does not prevent binding the independent reviewer; only this
|
|
2909
|
+
// adoption's slot can conflict with the incoming identity. Independence still reads worker history below.
|
|
2910
|
+
await readBefore(fleet, view(board, (db) => lastSession(db, id, adoptionSlot)));
|
|
2821
2911
|
const adopted = tx(board, (db) => {
|
|
2822
2912
|
const task = getTask(db, id);
|
|
2823
2913
|
if (!task) throw new Error(`no card ${id}`);
|
|
@@ -2826,10 +2916,10 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2826
2916
|
if (changed) throw changed;
|
|
2827
2917
|
if (beforeRun && assignee && assignee !== task.assignee) throw new Error('rebinding a session does not reassign its active run');
|
|
2828
2918
|
if (task.machine && normalMachine(task.machine) !== host) throw new Error(`${id} runs on ${task.machine}, and ${found.address} is on ${host}; move the card first`);
|
|
2829
|
-
const was = lastSession(db, id);
|
|
2919
|
+
const was = lastSession(db, id, adoptionSlot);
|
|
2830
2920
|
// a session whose close the board confirmed is not live, whatever a fleet reading still lists (a released one was
|
|
2831
2921
|
// left running, and is read)
|
|
2832
|
-
if (was && was
|
|
2922
|
+
if (was && !sameIdentity(was) && !(was.close_confirmed_at && !was.released) && sessionAlive(fleet, was)) throw new Error(`${id} already has a live session, ${was.address}`);
|
|
2833
2923
|
if (!task.machine) db.prepare('INSERT INTO supercode_cards (task_id, machine) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET machine = excluded.machine').run(id, host);
|
|
2834
2924
|
// the lane that resumes it if it is lost, set in the same transaction (assigning first would let a tick start another)
|
|
2835
2925
|
if (assignee && assignee !== task.assignee) { db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(assignee, id); event(db, id, 'assigned', { assignee }); }
|
|
@@ -2863,7 +2953,13 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2863
2953
|
...(agent ? { agent_id: agent.identity.id, agent_org: team.org, agent_context: context ?? null,
|
|
2864
2954
|
agent_profile_intent: { ...structuredClone(agent.intended.resolved), worker: { ...agent.intended.resolved?.worker, harness: lane.harness, model: lane.model ?? null, cwd: dir ?? task.workspace_path ?? null } } } : {}) };
|
|
2865
2955
|
// a deferred launch was never issued: the adopted session is the run's, and no later round launches another
|
|
2866
|
-
|
|
2956
|
+
const owned = [beforeSession, was].find(s => s && s.pane === session.pane && (sameIdentity(s) || s === beforeSession && keyedPane));
|
|
2957
|
+
setRunSession(db, runId, { ...(currentRun(db, id)?.metadata?.supercode ?? {}), ...session,
|
|
2958
|
+
detached_at: null, detached_to: null, launch_key: owned?.launch_key ?? null, launch_pending: false, launch_error: null,
|
|
2959
|
+
launch_deferred: false, launch_deferred_error: null, slot: adoptionSlot, retain_after_run: false,
|
|
2960
|
+
close_requested: null, closed_at: null, close_confirmed_at: null, close_error: null, close_failed_at: null, close_claim: null,
|
|
2961
|
+
released: false, forgotten: false, monitor_held: false, missing_since: null });
|
|
2962
|
+
transferRetainedSession(db, id, runId, session);
|
|
2867
2963
|
event(db, id, 'session_adopted', { session_id: sessionId, machine: host, pane: session.pane, address: found.address, mode: 'adopt', role: currentRun(db, id)?.metadata?.supercode?.role }, runId);
|
|
2868
2964
|
replaceWorkerSubscriptions(db, id, found.address);
|
|
2869
2965
|
for (const p of db.prepare('SELECT parent_id FROM task_links WHERE child_id = ?').all(id)) subscribeIn(db, p.parent_id, found.address, 'dependency');
|
|
@@ -3526,14 +3622,37 @@ export async function plan({ root, lanes, settings, slugs = null, params = {} })
|
|
|
3526
3622
|
return out;
|
|
3527
3623
|
}
|
|
3528
3624
|
|
|
3625
|
+
/** An explicit completion of the latest attempt, after any previous review or reopening. A reclaimed or failed
|
|
3626
|
+
* attempt is not a completed disposition, and a new attempt or returned verdict invalidates the old receipt. */
|
|
3627
|
+
function completedWork(db, id) {
|
|
3628
|
+
const last = db.prepare(`SELECT id,kind,run_id FROM task_events WHERE task_id=?
|
|
3629
|
+
AND kind IN ('work_completed','review_requested','changes_requested','review_pass','review_voided','reopened')
|
|
3630
|
+
ORDER BY id DESC LIMIT 1`).get(id);
|
|
3631
|
+
if (last?.kind !== 'work_completed') return null;
|
|
3632
|
+
const attempt = db.prepare('SELECT id,outcome,ended_at FROM task_runs WHERE task_id=? ORDER BY id DESC LIMIT 1').get(id);
|
|
3633
|
+
return attempt?.id === last.run_id && attempt.outcome === 'completed' && attempt.ended_at != null ? last : null;
|
|
3634
|
+
}
|
|
3635
|
+
|
|
3636
|
+
/** Finish ends the attempt, not its caller's native turn. Keep observing that retained attempt until the board
|
|
3637
|
+
* requests review; the receipt's lifecycle/latest-attempt fence prevents an older session holding later work. */
|
|
3638
|
+
function reviewTurnRun(db, id) {
|
|
3639
|
+
const active = currentRun(db, id);
|
|
3640
|
+
if (active) return active;
|
|
3641
|
+
const completed = completedWork(db, id);
|
|
3642
|
+
if (!completed || detachedCard(db, id)) return null;
|
|
3643
|
+
const row = db.prepare('SELECT * FROM task_runs WHERE id=?').get(completed.run_id);
|
|
3644
|
+
const metadata = json(row?.metadata, {}) ?? {}, session = metadata.supercode;
|
|
3645
|
+
return row && session?.retain_after_run && !session.close_transferred_to && !detachedSession(db, session) ? { ...row, metadata } : null;
|
|
3646
|
+
}
|
|
3647
|
+
|
|
3529
3648
|
/** Whether an arc with no open PR may be one that changed no code and is due its review: it has done work, by filing
|
|
3530
|
-
* tasks or
|
|
3649
|
+
* tasks, an explicit completed-work receipt, or a completed run with an Acceptance outcome ticked, and no
|
|
3531
3650
|
* review of it is pending or passed (after changes are requested, closing the tasks that own the findings makes it
|
|
3532
3651
|
* due again). */
|
|
3533
3652
|
function noCodeReviewDue(board, id) {
|
|
3534
3653
|
return view(board, (db) => {
|
|
3535
3654
|
const filed = db.prepare('SELECT 1 FROM supercode_cards WHERE subtask_of = ? LIMIT 1').get(id);
|
|
3536
|
-
if (!filed && !(db.prepare(
|
|
3655
|
+
if (!filed && !completedWork(db, id) && !(db.prepare("SELECT 1 FROM task_runs WHERE task_id = ? AND outcome = 'completed' AND ended_at IS NOT NULL LIMIT 1").get(id) &&
|
|
3537
3656
|
outcomeTicked(db.prepare('SELECT body FROM tasks WHERE id = ?').get(id)?.body))) return false;
|
|
3538
3657
|
const last = db.prepare("SELECT kind FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass','review_voided') ORDER BY id DESC LIMIT 1").get(id);
|
|
3539
3658
|
return !last || ['changes_requested','review_voided'].includes(last.kind);
|
|
@@ -3549,8 +3668,8 @@ function passedReview(board, id) {
|
|
|
3549
3668
|
function outcomeTicked(body) {
|
|
3550
3669
|
let acceptance = false;
|
|
3551
3670
|
return String(body ?? '').split('\n').some((line) => {
|
|
3552
|
-
const heading =
|
|
3553
|
-
if (heading) acceptance = heading
|
|
3671
|
+
const heading = cardSectionHeading(line);
|
|
3672
|
+
if (heading !== null) acceptance = heading === 'acceptance';
|
|
3554
3673
|
return acceptance && /^\s*[-*] \[[xX]\] \S/.test(line);
|
|
3555
3674
|
});
|
|
3556
3675
|
}
|
|
@@ -3571,7 +3690,7 @@ function outcomeTicked(body) {
|
|
|
3571
3690
|
* running a proof, still holds the review. */
|
|
3572
3691
|
async function turnInFlight(board, id, fleet, heldSince = null) {
|
|
3573
3692
|
if (!fleet?.ok) return null;
|
|
3574
|
-
const session =
|
|
3693
|
+
const session = reviewTurnRun(board.db, id)?.metadata?.supercode;
|
|
3575
3694
|
if (!session?.session_id || session.surface === 'headless' || session.close_requested) return null;
|
|
3576
3695
|
let live = fleet.sessions?.find((x) => x.address === addressOf(session)) ?? null;
|
|
3577
3696
|
if (live?.turn == null && session.surface === 'native') try {
|
|
@@ -3596,7 +3715,8 @@ function reviewDueKey(board, id, payload) {
|
|
|
3596
3715
|
return `${source}|${closed ?? ''}`;
|
|
3597
3716
|
}
|
|
3598
3717
|
|
|
3599
|
-
|
|
3718
|
+
/** Review selection shared by rounds and read-only inspection (`dry: true`). */
|
|
3719
|
+
export async function activatePullRequestReviews(board, entry, report, effects, { only = null, dry = false, fleet = null } = {}) {
|
|
3600
3720
|
const candidates = view(board, db => db.prepare(`SELECT t.id FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
|
|
3601
3721
|
WHERE c.subtask_of IS NULL AND c.every IS NULL
|
|
3602
3722
|
${only ? 'AND t.id=?' : ''}`).all(...(only ? [only] : [])));
|
|
@@ -3643,22 +3763,22 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
3643
3763
|
}
|
|
3644
3764
|
// A native turn holds the advisory proposal before authoritative source reads. Retain the proposal on the run
|
|
3645
3765
|
// (`review_held`) so a goal turn can be told to have started after that work was first observed.
|
|
3646
|
-
const heldRun =
|
|
3766
|
+
const heldRun = reviewTurnRun(board.db, id), dueKey = reviewDueKey(board, id, payload);
|
|
3647
3767
|
const held = heldRun?.metadata?.supercode?.review_held?.key === dueKey ? heldRun.metadata.supercode.review_held : null;
|
|
3648
3768
|
const inTurn = await turnInFlight(board, id, fleet, held ? Number(held.since) : null);
|
|
3649
3769
|
if (inTurn) {
|
|
3650
|
-
if (!held && !dry && heldRun) tx(board, (db) => { if (
|
|
3770
|
+
if (!held && !dry && heldRun) tx(board, (db) => { if (reviewTurnRun(db, id)?.id === heldRun.id) setRunSession(db, heldRun.id, { review_held: { key: dueKey, since: now() } }); });
|
|
3651
3771
|
report.queued.push([id, `review waits: ${inTurn.why}`]);
|
|
3652
3772
|
continue;
|
|
3653
3773
|
}
|
|
3654
|
-
// A stable
|
|
3655
|
-
//
|
|
3774
|
+
// A stable PR-property or local refusal must not spend a full fresh REST scan every round. Advisory
|
|
3775
|
+
// discovery is sufficient to refuse, never to authorize; the scan below still owns that.
|
|
3656
3776
|
if (!payload.no_code) await openReviewPullRequests(board, id, { checkWorkspaces: true });
|
|
3657
3777
|
if (!dry) await refreshCardInputs(board, id, { commits: true });
|
|
3658
3778
|
// Cached draft/closed exclusions are useful while waiting, but cannot define a committed review's set.
|
|
3659
3779
|
const observed = await withFreshReviewReads(async () => {
|
|
3660
|
-
const candidate = await openReviewPullRequests(board, id, { fresh: true })
|
|
3661
|
-
?? (noCodeReviewDue(board, id) ? await noCodeChange(board, id) : null);
|
|
3780
|
+
const candidate = await openReviewPullRequests(board, id, { fresh: true, repositories: payload.review_repositories ?? [] })
|
|
3781
|
+
?? (noCodeReviewDue(board, id) ? await noCodeChange(board, id, { fresh: true }) : null);
|
|
3662
3782
|
const evidence = !candidate || candidate.waits ? null : candidate.no_code
|
|
3663
3783
|
? { required: false, ready: true, reason: null, phase: 'source', no_code: candidate.no_code }
|
|
3664
3784
|
: await inspectCompletionEvidence(board, id, entry, candidate, 'request_review');
|
|
@@ -3902,7 +4022,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
3902
4022
|
if (dispatch && !only && !report.paused) await observeIdle(board, fleet, settings, report, entry, effects);
|
|
3903
4023
|
// what the moves asked for is recorded with them; the delivery pass (`deliver`) carries it out, outside the round
|
|
3904
4024
|
queueEffects(board, effects);
|
|
3905
|
-
phase('
|
|
4025
|
+
phase('idle');
|
|
3906
4026
|
ensureSubscriptions(board, entry);
|
|
3907
4027
|
fireHooks(board, settings, report);
|
|
3908
4028
|
if (String(board.db.prepare('PRAGMA journal_mode').get()?.journal_mode).toLowerCase() === 'wal') board.db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get();
|
package/board/engine.mjs
CHANGED
|
@@ -131,7 +131,7 @@ export function setRunSession(db, runId, session) {
|
|
|
131
131
|
const before = whole.supercode;
|
|
132
132
|
whole.supercode = { ...before, ...session };
|
|
133
133
|
const task = one(db, 'SELECT task_id FROM task_runs WHERE id = ?', runId);
|
|
134
|
-
if (task && session.session_id &&
|
|
134
|
+
if (task && session.session_id && (before?.session_id !== session.session_id || before?.pane !== session.pane)) retireSupersededRuns(db, task.task_id, runId, whole.supercode);
|
|
135
135
|
run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(whole), runId);
|
|
136
136
|
return whole;
|
|
137
137
|
}
|
|
@@ -292,7 +292,7 @@ export function createCard(board, spec) {
|
|
|
292
292
|
const handle = cardHandle(boardKey(board.root, board.slug), numberOf(db, id));
|
|
293
293
|
if (namedBranch) run(db, 'UPDATE tasks SET branch_name = ? WHERE id = ?', handle ? namedBranch(handle) : `wt/${id}`, id);
|
|
294
294
|
const machine = spec.machine ?? arc?.machine ?? null;
|
|
295
|
-
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?.
|
|
295
|
+
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,
|
|
296
296
|
tags.length ? JSON.stringify(tags) : null, spec.requestedByUnverified ? JSON.stringify(spec.requestedByUnverified) : null);
|
|
297
297
|
for (const p of parents) addDependency(db, entry, id, p);
|
|
298
298
|
// where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
|
|
@@ -740,17 +740,26 @@ export const editResult = (board, id, { result, summary = null, metadata = undef
|
|
|
740
740
|
export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
|
|
741
741
|
export const setGoal = (board, id, { on = true, maxTurns = null, roles } = {}) => act(board, id, 'set_goal', { roles, payload: { goal: Boolean(on), max_turns: on ? maxTurns : null } });
|
|
742
742
|
|
|
743
|
-
/**
|
|
743
|
+
/** File the named card's tasks only if its workflow accepts decompose; refusal leaves no cards behind. */
|
|
744
744
|
export async function decompose(board, id, children, { author = 'user', roles } = {}) {
|
|
745
745
|
if (!children.length) throw new Error('decompose names at least one child (--child "<title>[@<assignee>]")');
|
|
746
|
-
const
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
746
|
+
const entry = await loadWorkflow(board.root);
|
|
747
|
+
await refreshCardInputs(board, id);
|
|
748
|
+
const result = tx(board, db => {
|
|
749
|
+
const task = must(db, id), sender = roles ?? callerRoles(db, id);
|
|
750
|
+
if (task.subtask_of) throw new Error(`${id} is itself a subtask of ${task.subtask_of}; decompose its arc instead`);
|
|
751
|
+
// Creation and authorization share this transaction. Guards see the actual child ids, and any refusal
|
|
752
|
+
// (including a later child's invalid spec) rolls back every child, event, subscription and queued effect.
|
|
753
|
+
const ids = children.map(c => createCard(board, {
|
|
754
|
+
title: c.title, body: c.body ?? null, assignee: c.assignee ?? task.assignee, machine: task.machine,
|
|
755
|
+
subtaskOf: id, workspace: 'scratch', tenant: task.tenant, createdBy: author,
|
|
756
|
+
}).id);
|
|
757
|
+
const move = fire(db, board, entry, id, 'decompose', { roles: sender, payload: { children: ids, author } });
|
|
758
|
+
for (const child of ids) settle(db, board, entry, child, move.effects);
|
|
759
|
+
return { ids, effects: move.effects };
|
|
760
|
+
});
|
|
761
|
+
await runEffects(board, result.effects);
|
|
762
|
+
return result.ids;
|
|
754
763
|
}
|
|
755
764
|
|
|
756
765
|
/** `child` waits for `parent`. */
|