@volter/supercode-orchestrator 0.4.15 → 0.4.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/board/cli.mjs +318 -0
- package/board/dispatch.mjs +613 -0
- package/board/engine.mjs +547 -0
- package/board/store.mjs +150 -0
- package/package.json +2 -1
package/board/engine.mjs
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
// The board's transitions (docs/architecture/orchestrator.md §2.9): what a card may do, written for supercode and
|
|
2
|
+
// kept to Hermes Kanban's lifecycle so the two read each other's boards. Statuses: triage · todo · scheduled ·
|
|
3
|
+
// ready · running · blocked · review · done · archived. A card with a parent that is not done waits in `todo`
|
|
4
|
+
// and becomes `ready` when every parent is; a claim moves `ready` to `running` and opens a run; a run ends
|
|
5
|
+
// completed, blocked, reclaimed, review_requested, spawn_failed, crashed or timed_out. Every change appends an
|
|
6
|
+
// event (Hermes's kinds) to the card's thread, which is what subscribers are told.
|
|
7
|
+
//
|
|
8
|
+
// Each function takes an open board (`store.mjs`) and runs in one write transaction. Nothing here launches,
|
|
9
|
+
// observes or sends: the dispatcher (`dispatch.mjs`) does, and records what it did through these.
|
|
10
|
+
import { claimer, event, json, newTaskId, now, tx } from './store.mjs';
|
|
11
|
+
|
|
12
|
+
export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived'];
|
|
13
|
+
export const BLOCK_KINDS = ['dependency', 'needs_input', 'capability', 'transient'];
|
|
14
|
+
export const WORKSPACE_KINDS = ['scratch', 'dir', 'worktree'];
|
|
15
|
+
/** A card blocked this many times in a row for the same kind goes to triage for a person (Hermes's limit). */
|
|
16
|
+
export const BLOCK_RECURRENCE_LIMIT = 2;
|
|
17
|
+
export const DEFAULT_FAILURE_LIMIT = 2;
|
|
18
|
+
export const DEFAULT_CLAIM_TTL_SECONDS = 15 * 60;
|
|
19
|
+
|
|
20
|
+
export function claimTtl(configured) {
|
|
21
|
+
const env = Number(process.env.HERMES_KANBAN_CLAIM_TTL_SECONDS);
|
|
22
|
+
const n = Number(configured) || (Number.isInteger(env) && env > 0 ? env : 0);
|
|
23
|
+
return n > 0 ? Math.floor(n) : DEFAULT_CLAIM_TTL_SECONDS;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const one = (db, sql, ...args) => db.prepare(sql).get(...args);
|
|
27
|
+
const all = (db, sql, ...args) => db.prepare(sql).all(...args);
|
|
28
|
+
const run = (db, sql, ...args) => db.prepare(sql).run(...args);
|
|
29
|
+
|
|
30
|
+
export function getTask(db, id) {
|
|
31
|
+
const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
|
|
32
|
+
if (!row) return null;
|
|
33
|
+
const ours = one(db, 'SELECT machine, subtask_of FROM supercode_cards WHERE task_id = ?', id);
|
|
34
|
+
return { ...row, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function must(db, id) {
|
|
38
|
+
const task = getTask(db, id);
|
|
39
|
+
if (!task) throw new Error(`no card ${id}`);
|
|
40
|
+
return task;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const parentsOf = (db, id) => all(db, 'SELECT t.id, t.status FROM tasks t JOIN task_links l ON l.parent_id = t.id WHERE l.child_id = ?', id);
|
|
44
|
+
const childrenOf = (db, id) => all(db, 'SELECT child_id AS id FROM task_links WHERE parent_id = ?', id).map((r) => r.id);
|
|
45
|
+
const parentsDone = (db, id) => parentsOf(db, id).every((p) => p.status === 'done' || p.status === 'archived');
|
|
46
|
+
|
|
47
|
+
/** The workspace word Hermes's CLI takes: `scratch`, `worktree`, `worktree:<path>`, `dir:<path>`. */
|
|
48
|
+
export function parseWorkspace(word = 'scratch') {
|
|
49
|
+
const [kind, ...rest] = String(word).split(':');
|
|
50
|
+
const path = rest.join(':') || null;
|
|
51
|
+
if (!WORKSPACE_KINDS.includes(kind)) throw new Error(`workspace is scratch | worktree | worktree:<path> | dir:<path>, not ${word}`);
|
|
52
|
+
if (kind === 'dir' && !path) throw new Error('a dir workspace names its path (dir:<absolute path>)');
|
|
53
|
+
if (path && !/^(\/|~|[A-Za-z]:[\\/])/.test(path)) throw new Error(`workspace path ${path} is not absolute`);
|
|
54
|
+
return { kind, path };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ------------------------------------------------------------------ runs
|
|
58
|
+
|
|
59
|
+
/** Close the card's open run; answers its id, or null when none was open. */
|
|
60
|
+
export function endRun(db, taskId, { outcome, status = null, summary = null, error = null, metadata = undefined }) {
|
|
61
|
+
const row = one(db, 'SELECT current_run_id FROM tasks WHERE id = ?', taskId);
|
|
62
|
+
if (!row?.current_run_id) return null;
|
|
63
|
+
const runId = Number(row.current_run_id);
|
|
64
|
+
const keep = metadata === undefined ? one(db, 'SELECT metadata FROM task_runs WHERE id = ?', runId)?.metadata ?? null : (metadata ? JSON.stringify(metadata) : null);
|
|
65
|
+
run(db, `UPDATE task_runs SET status = ?, outcome = ?, summary = ?, error = ?, metadata = ?, ended_at = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL
|
|
66
|
+
WHERE id = ? AND ended_at IS NULL`, status ?? outcome, outcome, summary, error, keep, now(), runId);
|
|
67
|
+
run(db, 'UPDATE tasks SET current_run_id = NULL WHERE id = ?', taskId);
|
|
68
|
+
return runId;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A closed run recording an outcome reached with no run open (a person completing a card nobody claimed). */
|
|
72
|
+
function synthesizeRun(db, taskId, { outcome, summary = null, metadata = null }) {
|
|
73
|
+
const task = one(db, 'SELECT assignee FROM tasks WHERE id = ?', taskId);
|
|
74
|
+
const t = now();
|
|
75
|
+
const r = run(db, 'INSERT INTO task_runs (task_id, profile, status, started_at, ended_at, outcome, summary, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
76
|
+
taskId, task?.assignee ?? null, outcome, t, t, outcome, summary, metadata ? JSON.stringify(metadata) : null);
|
|
77
|
+
return Number(r.lastInsertRowid);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function currentRun(db, taskId) {
|
|
81
|
+
const row = one(db, 'SELECT r.* FROM task_runs r JOIN tasks t ON t.current_run_id = r.id WHERE t.id = ?', taskId);
|
|
82
|
+
return row ? { ...row, metadata: json(row.metadata, {}) ?? {} } : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Merge `patch` into the run's `metadata.supercode` (what the dispatcher launched and observed). */
|
|
86
|
+
export function setRunSession(db, runId, session) {
|
|
87
|
+
const row = one(db, 'SELECT metadata FROM task_runs WHERE id = ?', runId);
|
|
88
|
+
const whole = json(row?.metadata, {}) ?? {};
|
|
89
|
+
whole.supercode = session;
|
|
90
|
+
run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(whole), runId);
|
|
91
|
+
return whole;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The card's latest session the dispatcher recorded, newest run first; null when it never had one. */
|
|
95
|
+
export function lastSession(db, taskId) {
|
|
96
|
+
for (const row of all(db, 'SELECT metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC', taskId)) {
|
|
97
|
+
const s = json(row.metadata, {})?.supercode;
|
|
98
|
+
if (s?.session_id) return s;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Where an interrupted run resumes: `review` for a reviewer's run, `ready` otherwise. */
|
|
104
|
+
function retryStatus(db, taskId) {
|
|
105
|
+
const row = one(db, 'SELECT current_run_id FROM tasks WHERE id = ?', taskId);
|
|
106
|
+
if (!row?.current_run_id) return 'ready';
|
|
107
|
+
const claimed = one(db, "SELECT payload FROM task_events WHERE task_id = ? AND run_id = ? AND kind = 'claimed' ORDER BY id DESC LIMIT 1", taskId, row.current_run_id);
|
|
108
|
+
return json(claimed?.payload, {})?.source_status === 'review' ? 'review' : 'ready';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The phase a card blocked out of comes back to (Hermes reads it off the latest interruption). */
|
|
112
|
+
function resumeStatus(db, taskId) {
|
|
113
|
+
const row = one(db, `SELECT payload FROM task_events WHERE task_id = ? AND kind IN ('blocked', 'block_loop_detected', 'dependency_wait', 'gave_up',
|
|
114
|
+
'unblocked', 'changes_requested', 'review_reopened', 'status', 'reclaimed', 'stale', 'timed_out', 'crashed', 'spawn_failed', 'rate_limited', 'session_lost')
|
|
115
|
+
ORDER BY id DESC LIMIT 1`, taskId);
|
|
116
|
+
const p = json(row?.payload, {}) ?? {};
|
|
117
|
+
return ['resume_status', 'retry_status', 'source_status'].some((k) => p[k] === 'review') ? 'review' : 'ready';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ------------------------------------------------------------------ creating and linking
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A new card. `workspace` is Hermes's word (`dir:<path>` for a machine's directory); `machine` the enrolled
|
|
124
|
+
* machine its session runs on. Answers its id; with an `idempotencyKey` a live card already filed under it is
|
|
125
|
+
* answered instead.
|
|
126
|
+
*/
|
|
127
|
+
export function createCard(board, spec) {
|
|
128
|
+
const title = String(spec.title ?? '').trim();
|
|
129
|
+
if (!title) throw new Error('a card has a title');
|
|
130
|
+
const ws = parseWorkspace(spec.workspace ?? 'scratch');
|
|
131
|
+
if (spec.branch && ws.kind !== 'worktree') throw new Error('a branch is only for a worktree workspace');
|
|
132
|
+
const parents = [...new Set((spec.parents ?? []).filter(Boolean))];
|
|
133
|
+
const initial = spec.initialStatus ?? null;
|
|
134
|
+
if (initial && !['blocked', 'running'].includes(initial)) throw new Error('initial status is blocked or running');
|
|
135
|
+
if (spec.provider && !spec.model) throw new Error('a provider needs a model');
|
|
136
|
+
return tx(board, (db) => {
|
|
137
|
+
if (spec.idempotencyKey) {
|
|
138
|
+
const got = one(db, "SELECT id FROM tasks WHERE idempotency_key = ? AND status != 'archived' ORDER BY created_at DESC LIMIT 1", spec.idempotencyKey);
|
|
139
|
+
if (got) return { id: got.id, existing: true };
|
|
140
|
+
}
|
|
141
|
+
const missing = parents.filter((p) => !one(db, 'SELECT 1 FROM tasks WHERE id = ?', p));
|
|
142
|
+
if (missing.length) throw new Error(`unknown parent card(s): ${missing.join(', ')}`);
|
|
143
|
+
const arc = spec.subtaskOf ? getTask(db, spec.subtaskOf) : null;
|
|
144
|
+
if (spec.subtaskOf && !arc) throw new Error(`no card ${spec.subtaskOf} to be a subtask of`);
|
|
145
|
+
if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; a subtask belongs to an arc`);
|
|
146
|
+
let status = 'ready';
|
|
147
|
+
if (initial === 'blocked') status = 'blocked';
|
|
148
|
+
else if (spec.triage) status = 'triage';
|
|
149
|
+
else if (parents.some((p) => !['done', 'archived'].includes(one(db, 'SELECT status FROM tasks WHERE id = ?', p).status))) status = 'todo';
|
|
150
|
+
let id = newTaskId();
|
|
151
|
+
while (one(db, 'SELECT 1 FROM tasks WHERE id = ?', id)) id = newTaskId();
|
|
152
|
+
const t = now();
|
|
153
|
+
run(db, `INSERT INTO tasks (id, title, body, assignee, status, priority, created_by, created_at, workspace_kind, workspace_path, branch_name,
|
|
154
|
+
tenant, idempotency_key, max_runtime_seconds, skills, max_retries, model_override, provider_override, reasoning_effort, session_id)
|
|
155
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
156
|
+
id, title, spec.body ?? null, spec.assignee || null, status, Number(spec.priority) || 0, spec.createdBy ?? 'user', t, ws.kind, ws.path,
|
|
157
|
+
spec.branch ?? null, spec.tenant ?? null, spec.idempotencyKey ?? null, spec.maxRuntime ?? null,
|
|
158
|
+
spec.skills?.length ? JSON.stringify(spec.skills) : null, spec.maxRetries ?? null, spec.model ?? null, spec.provider ?? null,
|
|
159
|
+
spec.reasoning ?? null, spec.sessionId ?? null);
|
|
160
|
+
const machine = spec.machine ?? arc?.machine ?? null;
|
|
161
|
+
if (machine || arc) run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id, machine, subtask_of) VALUES (?, ?, ?)', id, machine, arc?.id ?? null);
|
|
162
|
+
for (const p of parents) run(db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', p, id);
|
|
163
|
+
event(db, id, 'created', { assignee: spec.assignee || null, status, parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
|
|
164
|
+
if (parents.length) inheritSubscriptions(db, id, parents);
|
|
165
|
+
if (arc) {
|
|
166
|
+
// the arc's subscribers hear its subtasks, and the arc's session works them
|
|
167
|
+
inheritSubscriptions(db, id, [arc.id], { workers: true });
|
|
168
|
+
event(db, arc.id, 'subtask_added', { subtask: id, title });
|
|
169
|
+
}
|
|
170
|
+
if (initial === 'running') claimIn(db, id, { ttl: claimTtl() });
|
|
171
|
+
return { id, existing: false };
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function wouldCycle(db, parent, child) {
|
|
176
|
+
const seen = new Set();
|
|
177
|
+
const stack = [child];
|
|
178
|
+
while (stack.length) {
|
|
179
|
+
const node = stack.pop();
|
|
180
|
+
if (node === parent) return true;
|
|
181
|
+
if (seen.has(node)) continue;
|
|
182
|
+
seen.add(node);
|
|
183
|
+
stack.push(...childrenOf(db, node));
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** `child` waits for `parent`: a ready child goes back to todo while the parent is not done. */
|
|
189
|
+
export function link(board, parent, child) {
|
|
190
|
+
if (parent === child) throw new Error('a card cannot depend on itself');
|
|
191
|
+
return tx(board, (db) => {
|
|
192
|
+
must(db, parent); must(db, child);
|
|
193
|
+
if (wouldCycle(db, parent, child)) throw new Error(`linking ${parent} -> ${child} would make a cycle`);
|
|
194
|
+
run(db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', parent, child);
|
|
195
|
+
if (one(db, 'SELECT status FROM tasks WHERE id = ?', parent).status !== 'done') run(db, "UPDATE tasks SET status = 'todo' WHERE id = ? AND status = 'ready'", child);
|
|
196
|
+
event(db, child, 'linked', { parent, child });
|
|
197
|
+
inheritSubscriptions(db, child, [parent]);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function unlink(board, parent, child) {
|
|
202
|
+
return tx(board, (db) => {
|
|
203
|
+
const r = run(db, 'DELETE FROM task_links WHERE parent_id = ? AND child_id = ?', parent, child);
|
|
204
|
+
if (!r.changes) return false;
|
|
205
|
+
event(db, child, 'unlinked', { parent, child });
|
|
206
|
+
recomputeIn(db);
|
|
207
|
+
return true;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ------------------------------------------------------------------ the thread and the card's fields
|
|
212
|
+
|
|
213
|
+
export function comment(board, id, author, body) {
|
|
214
|
+
if (!String(body ?? '').trim()) throw new Error('a comment has a body');
|
|
215
|
+
if (!String(author ?? '').trim()) throw new Error('a comment has an author');
|
|
216
|
+
return tx(board, (db) => {
|
|
217
|
+
must(db, id);
|
|
218
|
+
const r = run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', id, author.trim(), body.trim(), now());
|
|
219
|
+
event(db, id, 'commented', { author: author.trim(), len: body.length });
|
|
220
|
+
return Number(r.lastInsertRowid);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A new assignee (profile). A running card is reclaimed first, on purpose, never moved under its session. */
|
|
225
|
+
export function assign(board, id, profile) {
|
|
226
|
+
return tx(board, (db) => {
|
|
227
|
+
const task = must(db, id);
|
|
228
|
+
if (task.status === 'running' && task.claim_lock) throw new Error(`${id} is running; reclaim it before reassigning`);
|
|
229
|
+
run(db, `UPDATE tasks SET assignee = ?${task.assignee !== (profile || null) ? ', consecutive_failures = 0, last_failure_error = NULL' : ''} WHERE id = ?`, profile || null, id);
|
|
230
|
+
event(db, id, 'assigned', { assignee: profile || null });
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The machine a card's session runs on. Moving it is a decision: a running card is reclaimed first. */
|
|
235
|
+
export function setMachine(board, id, machine) {
|
|
236
|
+
return tx(board, (db) => {
|
|
237
|
+
const task = must(db, id);
|
|
238
|
+
if (task.status === 'running' && task.claim_lock) throw new Error(`${id} is running on ${task.machine}; reclaim it before moving it`);
|
|
239
|
+
run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id, machine) VALUES (?, ?)', id, machine || null);
|
|
240
|
+
event(db, id, 'machine_set', { machine: machine || null, was: task.machine });
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ------------------------------------------------------------------ lifecycle
|
|
245
|
+
|
|
246
|
+
/** Claim a ready card: running, a claim held until `ttl` from now, a run opened. Answers the run id or null. */
|
|
247
|
+
export function claim(board, id, { ttl, lock = claimer(), expectStatus = 'ready' } = {}) {
|
|
248
|
+
return tx(board, (db) => claimIn(db, id, { ttl, lock, expectStatus }));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** `claim` inside the caller's own transaction (a dispatcher's guard and claim see one state). */
|
|
252
|
+
export function claimIn(db, id, { ttl = claimTtl(), lock = claimer(), expectStatus = null } = {}) {
|
|
253
|
+
if (!parentsDone(db, id)) {
|
|
254
|
+
run(db, "UPDATE tasks SET status = 'todo' WHERE id = ? AND status = 'ready'", id);
|
|
255
|
+
event(db, id, 'claim_rejected', { reason: 'parents_not_done' });
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
const task = must(db, id);
|
|
259
|
+
if (expectStatus && task.status !== expectStatus) return null;
|
|
260
|
+
if (task.claim_lock) return null;
|
|
261
|
+
const t = now();
|
|
262
|
+
if (task.current_run_id) {
|
|
263
|
+
run(db, "UPDATE task_runs SET status = 'reclaimed', outcome = 'reclaimed', summary = COALESCE(summary, 'invariant recovery on re-claim'), ended_at = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ? AND ended_at IS NULL", t, task.current_run_id);
|
|
264
|
+
}
|
|
265
|
+
const expires = t + ttl;
|
|
266
|
+
run(db, 'UPDATE tasks SET status = ?, claim_lock = ?, claim_expires = ?, started_at = COALESCE(started_at, ?) WHERE id = ?', 'running', lock, expires, t, id);
|
|
267
|
+
const r = run(db, "INSERT INTO task_runs (task_id, profile, step_key, status, claim_lock, claim_expires, max_runtime_seconds, started_at) VALUES (?, ?, ?, 'running', ?, ?, ?, ?)",
|
|
268
|
+
id, task.assignee, task.current_step_key, lock, expires, task.max_runtime_seconds, t);
|
|
269
|
+
const runId = Number(r.lastInsertRowid);
|
|
270
|
+
run(db, 'UPDATE tasks SET current_run_id = ? WHERE id = ?', runId, id);
|
|
271
|
+
event(db, id, 'claimed', { lock, expires, run_id: runId, source_status: task.status === 'review' ? 'review' : 'ready' }, runId);
|
|
272
|
+
return runId;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Extend a running card's claim (the dispatcher keeps the claim of a session it sees live). */
|
|
276
|
+
export function extendClaim(db, id, { ttl, reason, extra = {} }) {
|
|
277
|
+
const task = one(db, 'SELECT claim_lock, current_run_id FROM tasks WHERE id = ? AND status = ?', id, 'running');
|
|
278
|
+
if (!task) return false;
|
|
279
|
+
const expires = now() + ttl;
|
|
280
|
+
run(db, 'UPDATE tasks SET claim_expires = ? WHERE id = ? AND status = ?', expires, id, 'running');
|
|
281
|
+
if (task.current_run_id) run(db, 'UPDATE task_runs SET claim_expires = ? WHERE id = ?', expires, task.current_run_id);
|
|
282
|
+
event(db, id, 'claim_extended', { reason, claim_expires_now: expires, ...extra }, task.current_run_id);
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Put a running card back where its run came from, closing the run with `outcome`. Answers the closed run id. */
|
|
287
|
+
export function release(db, id, { outcome = 'reclaimed', error = null, metadata = undefined, kind = 'reclaimed', payload = {} } = {}) {
|
|
288
|
+
const task = one(db, 'SELECT status, claim_lock FROM tasks WHERE id = ?', id);
|
|
289
|
+
if (!task || task.status !== 'running') return null;
|
|
290
|
+
const retry = retryStatus(db, id);
|
|
291
|
+
run(db, 'UPDATE tasks SET status = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?', retry, id);
|
|
292
|
+
const runId = endRun(db, id, { outcome, status: outcome, error, metadata });
|
|
293
|
+
event(db, id, kind, { ...payload, retry_status: retry }, runId);
|
|
294
|
+
return runId;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Count a failure against the card (Hermes's circuit breaker): at the card's `max_retries`, else `failureLimit`,
|
|
299
|
+
* consecutive failures it is blocked with a `gave_up` event. Answers true when it tripped.
|
|
300
|
+
*/
|
|
301
|
+
export function recordFailure(db, id, error, { failureLimit = DEFAULT_FAILURE_LIMIT, outcome = 'crashed' } = {}) {
|
|
302
|
+
const task = one(db, 'SELECT consecutive_failures, max_retries, status FROM tasks WHERE id = ?', id);
|
|
303
|
+
if (!task) return false;
|
|
304
|
+
const failures = Number(task.consecutive_failures || 0) + 1;
|
|
305
|
+
run(db, 'UPDATE tasks SET consecutive_failures = ?, last_failure_error = ? WHERE id = ?', failures, String(error).slice(0, 500), id);
|
|
306
|
+
const limit = task.max_retries ?? failureLimit;
|
|
307
|
+
if (failures < limit) return false;
|
|
308
|
+
if (task.status === 'running') endRun(db, id, { outcome, error: String(error) });
|
|
309
|
+
run(db, "UPDATE tasks SET status = 'blocked', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?", id);
|
|
310
|
+
event(db, id, 'gave_up', { error: String(error).slice(0, 500), failures, limit, outcome });
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function complete(board, id, { result = null, summary = null, metadata = null } = {}) {
|
|
315
|
+
return tx(board, (db) => {
|
|
316
|
+
const task = must(db, id);
|
|
317
|
+
if (!parentsDone(db, id)) throw new Error(`${id} waits on parents that are not done`);
|
|
318
|
+
if (!['running', 'ready', 'blocked', 'review'].includes(task.status)) throw new Error(`${id} is ${task.status}; only a running, ready, blocked or review card completes`);
|
|
319
|
+
run(db, "UPDATE tasks SET status = 'done', result = ?, completed_at = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, block_kind = NULL, block_recurrences = 0, consecutive_failures = 0, last_failure_error = NULL WHERE id = ?",
|
|
320
|
+
result, now(), id);
|
|
321
|
+
const said = summary ?? result;
|
|
322
|
+
let runId = endRun(db, id, { outcome: 'completed', status: 'done', summary: said, metadata: metadata ?? undefined });
|
|
323
|
+
if (runId === null && (said || metadata || task.status === 'review')) runId = synthesizeRun(db, id, { outcome: 'completed', summary: said ?? (task.status === 'review' ? 'Review approved without additional evidence.' : null), metadata });
|
|
324
|
+
const first = String(said ?? '').trim().split('\n')[0].slice(0, 400);
|
|
325
|
+
event(db, id, 'completed', { result_len: result ? result.length : 0, summary: first || null }, runId);
|
|
326
|
+
recomputeIn(db);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function block(board, id, { reason = null, kind = null } = {}) {
|
|
331
|
+
if (kind && !BLOCK_KINDS.includes(kind)) throw new Error(`block kind is one of ${BLOCK_KINDS.join(', ')}`);
|
|
332
|
+
return tx(board, (db) => {
|
|
333
|
+
const task = must(db, id);
|
|
334
|
+
if (!['running', 'ready'].includes(task.status)) throw new Error(`${id} is ${task.status}; only a running or ready card blocks`);
|
|
335
|
+
const source = task.status === 'running' ? retryStatus(db, id) : 'ready';
|
|
336
|
+
if (kind === 'dependency') {
|
|
337
|
+
run(db, "UPDATE tasks SET status = 'todo', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, block_kind = ? WHERE id = ?", kind, id);
|
|
338
|
+
const runId = endRun(db, id, { outcome: 'blocked', summary: reason }) ?? (reason ? synthesizeRun(db, id, { outcome: 'blocked', summary: reason }) : null);
|
|
339
|
+
event(db, id, 'dependency_wait', { reason, kind, source_status: source }, runId);
|
|
340
|
+
return 'todo';
|
|
341
|
+
}
|
|
342
|
+
const recurrences = task.block_kind === kind ? Number(task.block_recurrences || 0) + 1 : 1;
|
|
343
|
+
const loop = recurrences >= BLOCK_RECURRENCE_LIMIT;
|
|
344
|
+
const status = loop ? 'triage' : 'blocked';
|
|
345
|
+
run(db, 'UPDATE tasks SET status = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, block_kind = ?, block_recurrences = ? WHERE id = ?', status, kind, recurrences, id);
|
|
346
|
+
const runId = endRun(db, id, { outcome: 'blocked', summary: reason }) ?? (reason ? synthesizeRun(db, id, { outcome: 'blocked', summary: reason }) : null);
|
|
347
|
+
event(db, id, loop ? 'block_loop_detected' : 'blocked', { reason, kind, recurrences, ...(loop ? { limit: BLOCK_RECURRENCE_LIMIT } : {}), source_status: source }, runId);
|
|
348
|
+
return status;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function unblock(board, id) {
|
|
353
|
+
return tx(board, (db) => {
|
|
354
|
+
const task = must(db, id);
|
|
355
|
+
if (!['blocked', 'scheduled'].includes(task.status)) throw new Error(`${id} is ${task.status}; only a blocked or scheduled card unblocks`);
|
|
356
|
+
const resume = task.status === 'blocked' ? resumeStatus(db, id) : 'ready';
|
|
357
|
+
if (task.current_run_id) run(db, "UPDATE task_runs SET status = 'reclaimed', outcome = 'reclaimed', summary = COALESCE(summary, 'invariant recovery on unblock'), ended_at = ? WHERE id = ? AND ended_at IS NULL", now(), task.current_run_id);
|
|
358
|
+
const landing = parentsDone(db, id) ? 'ready' : 'todo';
|
|
359
|
+
const status = landing === 'ready' && resume === 'review' ? 'review' : landing;
|
|
360
|
+
run(db, 'UPDATE tasks SET status = ?, current_run_id = NULL, consecutive_failures = 0, last_failure_error = NULL WHERE id = ?', status, id);
|
|
361
|
+
event(db, id, 'unblocked', status !== 'ready' || resume !== 'ready' ? { status, resume_status: resume } : null);
|
|
362
|
+
return status;
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Implementation done, awaiting review (not a block): the review lane, optionally under a reviewer profile. */
|
|
367
|
+
export function requestReview(board, id, { summary = null, metadata = null, reviewer = null } = {}) {
|
|
368
|
+
return tx(board, (db) => {
|
|
369
|
+
const task = must(db, id);
|
|
370
|
+
if (!parentsDone(db, id)) throw new Error(`${id} waits on parents that are not done`);
|
|
371
|
+
if (!['running', 'ready'].includes(task.status)) throw new Error(`${id} is ${task.status}; only a running or ready card goes to review`);
|
|
372
|
+
run(db, `UPDATE tasks SET status = 'review', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL${reviewer ? ', assignee = ?' : ''} WHERE id = ?`, ...(reviewer ? [reviewer, id] : [id]));
|
|
373
|
+
const runId = endRun(db, id, { outcome: 'review_requested', status: 'review', summary, metadata: metadata ?? undefined }) ?? (summary || metadata ? synthesizeRun(db, id, { outcome: 'review_requested', summary, metadata }) : null);
|
|
374
|
+
event(db, id, 'review_requested', { summary: String(summary ?? '').trim().split('\n')[0].slice(0, 400) || null, implementer: task.assignee, reviewer }, runId);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** A reviewer's verdict sending the card back to its implementer. */
|
|
379
|
+
export function requestChanges(board, id, { reason = null, reviewer = null, implementer = null } = {}) {
|
|
380
|
+
return tx(board, (db) => {
|
|
381
|
+
const task = must(db, id);
|
|
382
|
+
if (task.status !== 'review' && !(task.status === 'running' && retryStatus(db, id) === 'review')) throw new Error(`${id} is not in review`);
|
|
383
|
+
const back = implementer ?? task.assignee;
|
|
384
|
+
run(db, "UPDATE tasks SET status = ?, assignee = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?", parentsDone(db, id) ? 'ready' : 'todo', back, id);
|
|
385
|
+
const runId = endRun(db, id, { outcome: 'changes_requested', summary: reason });
|
|
386
|
+
event(db, id, 'changes_requested', { reason, reviewer, implementer: back }, runId);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function schedule(board, id, reason = null) {
|
|
391
|
+
return tx(board, (db) => {
|
|
392
|
+
const task = must(db, id);
|
|
393
|
+
if (!['todo', 'ready', 'running', 'blocked'].includes(task.status)) throw new Error(`${id} is ${task.status}; it cannot be scheduled`);
|
|
394
|
+
run(db, "UPDATE tasks SET status = 'scheduled', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?", id);
|
|
395
|
+
const runId = endRun(db, id, { outcome: 'scheduled', summary: reason }) ?? (reason ? synthesizeRun(db, id, { outcome: 'scheduled', summary: reason }) : null);
|
|
396
|
+
event(db, id, 'scheduled', { reason }, runId);
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function promote(board, id, { actor = 'user', reason = null, force = false } = {}) {
|
|
401
|
+
return tx(board, (db) => {
|
|
402
|
+
const task = must(db, id);
|
|
403
|
+
if (!['todo', 'blocked'].includes(task.status)) throw new Error(`${id} is ${task.status}; promote moves a todo or blocked card`);
|
|
404
|
+
const open = parentsOf(db, id).filter((p) => !['done', 'archived'].includes(p.status)).map((p) => p.id);
|
|
405
|
+
if (open.length && !force) throw new Error(`parents not done: ${open.join(', ')} (force to override)`);
|
|
406
|
+
run(db, "UPDATE tasks SET status = 'ready' WHERE id = ?", id);
|
|
407
|
+
event(db, id, 'promoted_manual', { actor, reason, forced: force });
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Release a running card's claim by hand (its session is left alone); failures are forgiven. */
|
|
412
|
+
export function reclaim(board, id, reason = null) {
|
|
413
|
+
return tx(board, (db) => {
|
|
414
|
+
const task = must(db, id);
|
|
415
|
+
if (task.status !== 'running') throw new Error(`${id} is ${task.status}, not running`);
|
|
416
|
+
release(db, id, { outcome: 'reclaimed', error: reason ? `manual_reclaim: ${reason}` : 'manual_reclaim', payload: { manual: true, reason, prev_lock: task.claim_lock } });
|
|
417
|
+
run(db, 'UPDATE tasks SET consecutive_failures = 0, last_failure_error = NULL WHERE id = ?', id);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function archive(board, id) {
|
|
422
|
+
return tx(board, (db) => {
|
|
423
|
+
const task = must(db, id);
|
|
424
|
+
if (task.status === 'archived') return false;
|
|
425
|
+
run(db, "UPDATE tasks SET status = 'archived', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?", id);
|
|
426
|
+
const runId = endRun(db, id, { outcome: 'reclaimed', summary: 'task archived with run still active' });
|
|
427
|
+
event(db, id, 'archived', null, runId);
|
|
428
|
+
recomputeIn(db);
|
|
429
|
+
return true;
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Every todo (and non-sticky blocked) card whose parents are all done becomes ready. Answers how many. */
|
|
434
|
+
export function recomputeReady(board, failureLimit = DEFAULT_FAILURE_LIMIT) {
|
|
435
|
+
return tx(board, (db) => recomputeIn(db, failureLimit));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function recomputeIn(db, failureLimit = DEFAULT_FAILURE_LIMIT) {
|
|
439
|
+
let promoted = 0;
|
|
440
|
+
for (const row of all(db, "SELECT id, status, consecutive_failures, max_retries FROM tasks WHERE status IN ('todo', 'blocked')")) {
|
|
441
|
+
if (row.status === 'blocked') {
|
|
442
|
+
const last = one(db, "SELECT kind FROM task_events WHERE task_id = ? AND kind IN ('blocked', 'unblocked') ORDER BY id DESC LIMIT 1", row.id);
|
|
443
|
+
if (last?.kind === 'blocked') continue; // a person's block stays until a person unblocks it
|
|
444
|
+
if (Number(row.consecutive_failures || 0) >= (row.max_retries ?? failureLimit)) continue;
|
|
445
|
+
}
|
|
446
|
+
if (!parentsDone(db, row.id)) continue;
|
|
447
|
+
const status = resumeStatus(db, row.id);
|
|
448
|
+
run(db, 'UPDATE tasks SET status = ? WHERE id = ? AND status = ?', status, row.id, row.status);
|
|
449
|
+
event(db, row.id, 'promoted', status !== 'ready' ? { status } : null);
|
|
450
|
+
promoted += 1;
|
|
451
|
+
}
|
|
452
|
+
return promoted;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ------------------------------------------------------------------ subscriptions
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Subscribe `address` (a supercode session address) to a card's events from now on. `role` is `creator` (told of
|
|
459
|
+
* starts, completion and blocks) or `worker` (told of updates to the card it works).
|
|
460
|
+
*/
|
|
461
|
+
export function subscribeIn(db, id, address, role = 'creator') {
|
|
462
|
+
const cursor = one(db, 'SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?', id).c;
|
|
463
|
+
run(db, `INSERT OR IGNORE INTO kanban_notify_subs (task_id, platform, chat_id, thread_id, chat_type, delivery_mode, delivery_metadata, created_at, last_event_id)
|
|
464
|
+
VALUES (?, 'supercode', ?, '', 'dm', 'notify', ?, ?, ?)`, id, address, JSON.stringify({ role }), now(), cursor);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export function subscribe(board, id, address, role = 'creator') {
|
|
468
|
+
return tx(board, (db) => { must(db, id); subscribeIn(db, id, address, role); });
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function unsubscribe(board, id, address) {
|
|
472
|
+
return tx(board, (db) => run(db, "DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?", id, address).changes > 0);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** A child hears what its parents' subscribers hear (Hermes copies them at link time); a subtask its arc's session too. */
|
|
476
|
+
function inheritSubscriptions(db, child, parents, { workers = false } = {}) {
|
|
477
|
+
const cursor = one(db, 'SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?', child).c;
|
|
478
|
+
for (const p of parents) {
|
|
479
|
+
for (const s of all(db, 'SELECT * FROM kanban_notify_subs WHERE task_id = ?', p)) {
|
|
480
|
+
const role = json(s.delivery_metadata, {})?.role;
|
|
481
|
+
if ((role === 'worker' && !workers) || role === 'dependency') continue; // a parent's session does not work the child
|
|
482
|
+
run(db, `INSERT OR IGNORE INTO kanban_notify_subs (task_id, platform, chat_id, thread_id, user_id, user_id_alt, chat_type, notifier_profile, delivery_mode, delivery_metadata, created_at, last_event_id)
|
|
483
|
+
VALUES (?, ?, ?, ?, ?, ?, COALESCE(?, 'dm'), ?, COALESCE(?, 'notify'), ?, ?, ?)`,
|
|
484
|
+
child, s.platform, s.chat_id, s.thread_id, s.user_id, s.user_id_alt, s.chat_type, s.notifier_profile, s.delivery_mode, s.delivery_metadata, now(), cursor);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export const subscriptions = (db, id = null) => (id ? all(db, 'SELECT * FROM kanban_notify_subs WHERE task_id = ?', id) : all(db, 'SELECT * FROM kanban_notify_subs'))
|
|
490
|
+
.map((s) => ({ ...s, delivery_metadata: json(s.delivery_metadata, {}) ?? {} }));
|
|
491
|
+
|
|
492
|
+
// ------------------------------------------------------------------ reading
|
|
493
|
+
|
|
494
|
+
export function listCards(db, { status = null, assignee = null, machine = null, includeArchived = false } = {}) {
|
|
495
|
+
const rows = all(db, `SELECT t.*, c.machine, c.subtask_of FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id
|
|
496
|
+
WHERE (? IS NULL OR t.status = ?) AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? OR t.status != 'archived')
|
|
497
|
+
ORDER BY t.priority DESC, t.created_at ASC`, status, status, assignee, assignee, machine, machine, includeArchived ? 1 : 0);
|
|
498
|
+
return rows.map((r) => ({ ...r, skills: json(r.skills, []) ?? [] }));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function showCard(db, id) {
|
|
502
|
+
const task = must(db, id);
|
|
503
|
+
return {
|
|
504
|
+
task,
|
|
505
|
+
parents: parentsOf(db, id),
|
|
506
|
+
children: childrenOf(db, id),
|
|
507
|
+
subtasks: all(db, 'SELECT t.id, t.status, t.title FROM tasks t JOIN supercode_cards c ON c.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at', id),
|
|
508
|
+
comments: all(db, 'SELECT author, body, created_at FROM task_comments WHERE task_id = ? ORDER BY id', id),
|
|
509
|
+
runs: all(db, 'SELECT * FROM task_runs WHERE task_id = ? ORDER BY id', id).map((r) => ({ ...r, metadata: json(r.metadata, null) })),
|
|
510
|
+
events: all(db, 'SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? ORDER BY id', id).map((e) => ({ ...e, payload: json(e.payload, null) })),
|
|
511
|
+
subscriptions: subscriptions(db, id),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const cap = (text, n) => (text && text.length > n ? `${text.slice(0, n)}… (${text.length - n} more characters)` : text);
|
|
516
|
+
|
|
517
|
+
/** What a session starting on a card reads first: the card, its parents' results, earlier attempts and the thread. */
|
|
518
|
+
export function cardContext(db, id) {
|
|
519
|
+
const task = must(db, id);
|
|
520
|
+
const out = [`# Card ${task.id}: ${task.title}`, ''];
|
|
521
|
+
out.push(`Status: ${task.status}. Machine: ${task.machine ?? 'unset'}. Workspace: ${task.workspace_kind}${task.workspace_path ? ` ${task.workspace_path}` : ''}.${task.subtask_of ? ` A subtask of ${task.subtask_of}.` : ''}`);
|
|
522
|
+
if (task.body) out.push('', cap(task.body, 8 * 1024));
|
|
523
|
+
const parents = all(db, 'SELECT t.id, t.title, t.result FROM tasks t JOIN task_links l ON l.parent_id = t.id WHERE l.child_id = ?', id);
|
|
524
|
+
if (parents.length) {
|
|
525
|
+
out.push('', '## Parent cards');
|
|
526
|
+
for (const p of parents) {
|
|
527
|
+
const last = one(db, "SELECT summary FROM task_runs WHERE task_id = ? AND outcome = 'completed' ORDER BY id DESC LIMIT 1", p.id);
|
|
528
|
+
out.push(`- ${p.id} ${p.title}${last?.summary || p.result ? `: ${cap(last?.summary || p.result, 4 * 1024)}` : ''}`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const subtasks = all(db, 'SELECT t.id, t.status, t.title FROM tasks t JOIN supercode_cards c ON c.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at', id);
|
|
532
|
+
if (subtasks.length) {
|
|
533
|
+
out.push('', '## Subtasks');
|
|
534
|
+
for (const t of subtasks) out.push(`- ${t.id} [${t.status}] ${t.title}`);
|
|
535
|
+
}
|
|
536
|
+
const runs = all(db, 'SELECT id, outcome, summary, error, started_at, ended_at FROM task_runs WHERE task_id = ? AND ended_at IS NOT NULL ORDER BY id DESC LIMIT 10', id);
|
|
537
|
+
if (runs.length) {
|
|
538
|
+
out.push('', '## Earlier attempts (newest first)');
|
|
539
|
+
for (const r of runs) out.push(`- run ${r.id}: ${r.outcome ?? 'ended'}${r.summary ? ` — ${cap(r.summary, 4 * 1024)}` : ''}${r.error ? ` (error: ${cap(r.error, 1024)})` : ''}`);
|
|
540
|
+
}
|
|
541
|
+
const comments = all(db, 'SELECT author, body FROM task_comments WHERE task_id = ? ORDER BY id DESC LIMIT 30', id).reverse();
|
|
542
|
+
if (comments.length) {
|
|
543
|
+
out.push('', '## Thread');
|
|
544
|
+
for (const c of comments) out.push(`- ${c.author}: ${cap(c.body, 2 * 1024)}`);
|
|
545
|
+
}
|
|
546
|
+
return out.join('\n');
|
|
547
|
+
}
|