@volter/supercode-orchestrator 0.4.29 → 0.4.30
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 +65 -42
- package/board/dispatch.mjs +290 -274
- package/board/engine.mjs +151 -310
- package/board/expr.mjs +212 -0
- package/board/store.mjs +4 -2
- package/board/tools.mjs +17 -8
- package/board/workflow.mjs +442 -0
- package/board.mjs +8 -4
- package/kanban-worker.mjs +2 -1
- package/loop.mjs +1 -1
- package/package.json +1 -1
package/board/engine.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { existsSync, rmSync } from 'node:fs';
|
|
|
12
12
|
import { join, resolve } from 'node:path';
|
|
13
13
|
import { DatabaseSync } from 'node:sqlite';
|
|
14
14
|
import { claimer, event, json, newTaskId, now, tx } from './store.mjs';
|
|
15
|
+
import { fire, initialStatus, loadWorkflow, setStatus, settle, statusOf, workflowFor } from './workflow.mjs';
|
|
15
16
|
|
|
16
17
|
export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived'];
|
|
17
18
|
export const BLOCK_KINDS = ['dependency', 'needs_input', 'capability', 'transient'];
|
|
@@ -111,10 +112,11 @@ export function setRunSession(db, runId, session) {
|
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
/** The card's latest session the dispatcher recorded, newest run first; null when it never had one. */
|
|
114
|
-
export function lastSession(db, taskId) {
|
|
115
|
+
export function lastSession(db, taskId, slot = 'worker') {
|
|
116
|
+
// a session slot's own last session (runs recorded before slots existed are the worker's)
|
|
115
117
|
for (const row of all(db, 'SELECT metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC', taskId)) {
|
|
116
118
|
const s = json(row.metadata, {})?.supercode;
|
|
117
|
-
if (s?.session_id) return s;
|
|
119
|
+
if (s?.session_id && (s.slot ?? 'worker') === slot) return s;
|
|
118
120
|
}
|
|
119
121
|
return null;
|
|
120
122
|
}
|
|
@@ -174,10 +176,6 @@ export function createCard(board, spec) {
|
|
|
174
176
|
const arc = spec.subtaskOf ? getTask(db, spec.subtaskOf) : null;
|
|
175
177
|
if (spec.subtaskOf && !arc) throw new Error(`no card ${spec.subtaskOf} to be a subtask of`);
|
|
176
178
|
if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; a subtask belongs to an arc`);
|
|
177
|
-
let status = 'ready';
|
|
178
|
-
if (initial === 'blocked') status = 'blocked';
|
|
179
|
-
else if (spec.triage) status = 'triage';
|
|
180
|
-
else if (parents.some((p) => !['done', 'archived'].includes(one(db, 'SELECT status FROM tasks WHERE id = ?', p).status))) status = 'todo';
|
|
181
179
|
let id = newTaskId();
|
|
182
180
|
while (one(db, 'SELECT 1 FROM tasks WHERE id = ?', id)) id = newTaskId();
|
|
183
181
|
// a project (Hermes's projects registry, `projects.db` in the home) anchors the card's worktree in its repository
|
|
@@ -196,13 +194,17 @@ export function createCard(board, spec) {
|
|
|
196
194
|
tenant, idempotency_key, max_runtime_seconds, skills, max_retries, model_override, provider_override, reasoning_effort, session_id,
|
|
197
195
|
project_id, goal_mode, goal_max_turns)
|
|
198
196
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
199
|
-
id, title, spec.body ?? null, spec.assignee || null,
|
|
197
|
+
id, title, spec.body ?? null, spec.assignee || null, 'todo', Number(spec.priority) || 0, spec.createdBy ?? 'user', t, ws.kind, ws.path,
|
|
200
198
|
spec.branch ?? null, spec.tenant ?? null, spec.idempotencyKey ?? null, spec.maxRuntime ?? null,
|
|
201
199
|
spec.skills?.length ? JSON.stringify(spec.skills) : null, spec.maxRetries ?? null, spec.model ?? null, spec.provider ?? null,
|
|
202
200
|
spec.reasoning ?? null, spec.sessionId ?? null, spec.project ?? null, spec.goal ? 1 : 0, spec.goal?.maxTurns ?? null);
|
|
203
201
|
const machine = spec.machine ?? arc?.machine ?? null;
|
|
204
202
|
if (machine || arc) run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id, machine, subtask_of) VALUES (?, ?, ?)', id, machine, arc?.id ?? null);
|
|
205
203
|
for (const p of parents) run(db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', p, id);
|
|
204
|
+
// where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
|
|
205
|
+
const entry = workflowFor(board.root);
|
|
206
|
+
const status = initialStatus(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial });
|
|
207
|
+
setStatus(db, entry.workflow, id, status);
|
|
206
208
|
event(db, id, 'created', { assignee: spec.assignee || null, status, parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
|
|
207
209
|
if (parents.length) inheritSubscriptions(db, id, parents);
|
|
208
210
|
if (arc) {
|
|
@@ -228,58 +230,18 @@ function wouldCycle(db, parent, child) {
|
|
|
228
230
|
return false;
|
|
229
231
|
}
|
|
230
232
|
|
|
231
|
-
/** `child` waits for `parent`: a ready child goes back to todo while the parent is not done. */
|
|
232
|
-
export function link(board, parent, child) {
|
|
233
|
-
if (parent === child) throw new Error('a card cannot depend on itself');
|
|
234
|
-
return tx(board, (db) => {
|
|
235
|
-
must(db, parent); must(db, child);
|
|
236
|
-
if (wouldCycle(db, parent, child)) throw new Error(`linking ${parent} -> ${child} would make a cycle`);
|
|
237
|
-
run(db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', parent, child);
|
|
238
|
-
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);
|
|
239
|
-
event(db, child, 'linked', { parent, child });
|
|
240
|
-
inheritSubscriptions(db, child, [parent]);
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
233
|
|
|
244
|
-
export function unlink(board, parent, child) {
|
|
245
|
-
return tx(board, (db) => {
|
|
246
|
-
const r = run(db, 'DELETE FROM task_links WHERE parent_id = ? AND child_id = ?', parent, child);
|
|
247
|
-
if (!r.changes) return false;
|
|
248
|
-
event(db, child, 'unlinked', { parent, child });
|
|
249
|
-
recomputeIn(db);
|
|
250
|
-
return true;
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
234
|
|
|
254
235
|
// ------------------------------------------------------------------ the thread and the card's fields
|
|
255
236
|
|
|
256
|
-
export function comment(board, id, author, body) {
|
|
257
|
-
if (!String(body ?? '').trim()) throw new Error('a comment has a body');
|
|
258
|
-
if (!String(author ?? '').trim()) throw new Error('a comment has an author');
|
|
259
|
-
return tx(board, (db) => {
|
|
260
|
-
must(db, id);
|
|
261
|
-
const r = run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', id, author.trim(), body.trim(), now());
|
|
262
|
-
event(db, id, 'commented', { author: author.trim(), len: body.length });
|
|
263
|
-
return Number(r.lastInsertRowid);
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
237
|
|
|
267
|
-
/** A new assignee (profile). A running card is reclaimed first, on purpose, never moved under its session. */
|
|
268
|
-
export function assign(board, id, profile) {
|
|
269
|
-
return tx(board, (db) => {
|
|
270
|
-
const task = must(db, id);
|
|
271
|
-
if (task.status === 'running' && task.claim_lock) throw new Error(`${id} is running; reclaim it before reassigning`);
|
|
272
|
-
run(db, `UPDATE tasks SET assignee = ?${task.assignee !== (profile || null) ? ', consecutive_failures = 0, last_failure_error = NULL' : ''} WHERE id = ?`, profile || null, id);
|
|
273
|
-
event(db, id, 'assigned', { assignee: profile || null });
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
238
|
|
|
277
239
|
/** The machine a card's session runs on. Moving it is a decision: a running card is reclaimed first. */
|
|
278
240
|
export function setMachine(board, id, machine) {
|
|
279
241
|
return tx(board, (db) => {
|
|
280
242
|
const task = must(db, id);
|
|
281
243
|
if (task.status === 'running' && task.claim_lock) throw new Error(`${id} is running on ${task.machine}; reclaim it before moving it`);
|
|
282
|
-
run(db, 'INSERT
|
|
244
|
+
run(db, 'INSERT INTO supercode_cards (task_id, machine) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET machine = excluded.machine', id, machine || null);
|
|
283
245
|
event(db, id, 'machine_set', { machine: machine || null, was: task.machine });
|
|
284
246
|
});
|
|
285
247
|
}
|
|
@@ -355,10 +317,6 @@ export function recordFailure(db, id, error, { failureLimit = DEFAULT_FAILURE_LI
|
|
|
355
317
|
return true;
|
|
356
318
|
}
|
|
357
319
|
|
|
358
|
-
export function complete(board, id, opts = {}) {
|
|
359
|
-
completeIn(board, id, opts);
|
|
360
|
-
cleanupWorkspace(board, id);
|
|
361
|
-
}
|
|
362
320
|
|
|
363
321
|
/**
|
|
364
322
|
* A completed card's workspace goes, as Hermes cleans up on completion: a scratch directory under the board's own
|
|
@@ -374,197 +332,171 @@ function cleanupWorkspace(board, id) {
|
|
|
374
332
|
if (resolve(task.workspace_path).startsWith(`${managed}/`)) rmSync(task.workspace_path, { recursive: true, force: true });
|
|
375
333
|
}
|
|
376
334
|
|
|
377
|
-
function completeIn(board, id, { result = null, summary = null, metadata = null } = {}) {
|
|
378
|
-
return tx(board, (db) => {
|
|
379
|
-
const task = must(db, id);
|
|
380
|
-
if (!parentsDone(db, id)) throw new Error(`${id} waits on parents that are not done`);
|
|
381
|
-
if (!['running', 'ready', 'blocked', 'review'].includes(task.status)) throw new Error(`${id} is ${task.status}; only a running, ready, blocked or review card completes`);
|
|
382
|
-
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 = ?",
|
|
383
|
-
result, now(), id);
|
|
384
|
-
const said = summary ?? result;
|
|
385
|
-
let runId = endRun(db, id, { outcome: 'completed', status: 'done', summary: said, metadata: metadata ?? undefined });
|
|
386
|
-
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 });
|
|
387
|
-
const first = String(said ?? '').trim().split('\n')[0].slice(0, 400);
|
|
388
|
-
const artifacts = Array.isArray(metadata?.artifacts) ? metadata.artifacts.filter((a) => typeof a === 'string' && a.trim()) : [];
|
|
389
|
-
event(db, id, 'completed', { result_len: result ? result.length : 0, summary: first || null, ...(artifacts.length ? { artifacts } : {}) }, runId);
|
|
390
|
-
// card ids the handoff names that no card has (Hermes flags them the same way)
|
|
391
|
-
const named = [...new Set(`${summary ?? ''} ${result ?? ''}`.match(/\bt_[a-f0-9]{8,}\b/g) ?? [])];
|
|
392
|
-
const phantom = named.filter((t) => !one(db, 'SELECT 1 FROM tasks WHERE id = ?', t));
|
|
393
|
-
if (phantom.length) event(db, id, 'suspected_hallucinated_references', { phantom_refs: phantom, source: 'completion_summary' }, runId);
|
|
394
|
-
recomputeIn(db);
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
335
|
|
|
398
|
-
export function block(board, id, { reason = null, kind = null } = {}) {
|
|
399
|
-
return tx(board, (db) => blockIn(db, id, { reason, kind }));
|
|
400
|
-
}
|
|
401
336
|
|
|
402
|
-
/** `block` inside the caller's transaction (the dispatcher's own blocks keep the same recurrence accounting). */
|
|
403
|
-
export function blockIn(db, id, { reason = null, kind = null } = {}) {
|
|
404
|
-
if (kind && !BLOCK_KINDS.includes(kind)) throw new Error(`block kind is one of ${BLOCK_KINDS.join(', ')}`);
|
|
405
|
-
const task = must(db, id);
|
|
406
|
-
if (!['running', 'ready'].includes(task.status)) throw new Error(`${id} is ${task.status}; only a running or ready card blocks`);
|
|
407
|
-
const source = task.status === 'running' ? retryStatus(db, id) : 'ready';
|
|
408
|
-
if (kind === 'dependency') {
|
|
409
|
-
run(db, "UPDATE tasks SET status = 'todo', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, block_kind = ? WHERE id = ?", kind, id);
|
|
410
|
-
const runId = endRun(db, id, { outcome: 'blocked', summary: reason }) ?? (reason ? synthesizeRun(db, id, { outcome: 'blocked', summary: reason }) : null);
|
|
411
|
-
event(db, id, 'dependency_wait', { reason, kind, source_status: source }, runId);
|
|
412
|
-
return 'todo';
|
|
413
|
-
}
|
|
414
|
-
const recurrences = task.block_kind === kind ? Number(task.block_recurrences || 0) + 1 : 1;
|
|
415
|
-
const loop = recurrences >= BLOCK_RECURRENCE_LIMIT;
|
|
416
|
-
const status = loop ? 'triage' : 'blocked';
|
|
417
|
-
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);
|
|
418
|
-
const runId = endRun(db, id, { outcome: 'blocked', summary: reason }) ?? (reason ? synthesizeRun(db, id, { outcome: 'blocked', summary: reason }) : null);
|
|
419
|
-
event(db, id, loop ? 'block_loop_detected' : 'blocked', { reason, kind, recurrences, ...(loop ? { limit: BLOCK_RECURRENCE_LIMIT } : {}), source_status: source }, runId);
|
|
420
|
-
return status;
|
|
421
|
-
}
|
|
422
337
|
|
|
423
|
-
export function unblock(board, id) {
|
|
424
|
-
return tx(board, (db) => {
|
|
425
|
-
const task = must(db, id);
|
|
426
|
-
if (!['blocked', 'scheduled'].includes(task.status)) throw new Error(`${id} is ${task.status}; only a blocked or scheduled card unblocks`);
|
|
427
|
-
const resume = task.status === 'blocked' ? resumeStatus(db, id) : 'ready';
|
|
428
|
-
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);
|
|
429
|
-
const landing = parentsDone(db, id) ? 'ready' : 'todo';
|
|
430
|
-
const status = landing === 'ready' && resume === 'review' ? 'review' : landing;
|
|
431
|
-
run(db, 'UPDATE tasks SET status = ?, current_run_id = NULL, consecutive_failures = 0, last_failure_error = NULL WHERE id = ?', status, id);
|
|
432
|
-
event(db, id, 'unblocked', status !== 'ready' || resume !== 'ready' ? { status, resume_status: resume } : null);
|
|
433
|
-
return status;
|
|
434
|
-
});
|
|
435
|
-
}
|
|
436
338
|
|
|
437
|
-
/** Implementation done, awaiting review (not a block): the review lane, optionally under a reviewer profile. */
|
|
438
|
-
export function requestReview(board, id, { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null } = {}) {
|
|
439
|
-
return tx(board, (db) => {
|
|
440
|
-
const task = must(db, id);
|
|
441
|
-
if (!parentsDone(db, id)) throw new Error(`${id} waits on parents that are not done`);
|
|
442
|
-
if (!['running', 'ready'].includes(task.status)) throw new Error(`${id} is ${task.status}; only a running or ready card goes to review`);
|
|
443
|
-
// a live claim is its worker's to hand off; anyone else says so on purpose (Hermes's guard)
|
|
444
|
-
// (Hermes's expected_run_id: the worker names the run it holds; a superseded run changes nothing)
|
|
445
|
-
if (expectedRunId !== null && Number(task.current_run_id) !== Number(expectedRunId)) throw new Error(`run ${expectedRunId} of ${id} is no longer its current run; nothing was changed`);
|
|
446
|
-
if (task.status === 'running' && task.claim_lock && !force && expectedRunId === null) throw new Error(`${id} is running under a live claim; its worker requests review, or pass --force to override on purpose`);
|
|
447
|
-
// a re-review goes back to the reviewer who asked for the changes
|
|
448
|
-
if (!reviewer) {
|
|
449
|
-
const changed = one(db, "SELECT id FROM task_runs WHERE task_id = ? AND outcome = 'changes_requested' ORDER BY id DESC LIMIT 1", id);
|
|
450
|
-
if (changed) {
|
|
451
|
-
const asked = json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND run_id = ? AND kind = 'changes_requested' ORDER BY id DESC LIMIT 1", id, changed.id)?.payload, {}) ?? {};
|
|
452
|
-
if (!asked.reviewer) throw new Error(`${id}'s last changes request names no reviewer; pass --reviewer`);
|
|
453
|
-
reviewer = asked.reviewer;
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
run(db, `UPDATE tasks SET status = 'review', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL${reviewer ? ', assignee = ?' : ''} WHERE id = ?`, ...(reviewer ? [reviewer, id] : [id]));
|
|
457
|
-
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);
|
|
458
|
-
event(db, id, 'review_requested', { summary: String(summary ?? '').trim().split('\n')[0].slice(0, 400) || null, implementer: task.assignee, reviewer }, runId);
|
|
459
|
-
});
|
|
460
|
-
}
|
|
461
339
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
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);
|
|
471
|
-
const runId = endRun(db, id, { outcome: 'changes_requested', summary: reason });
|
|
472
|
-
event(db, id, 'changes_requested', { reason, reviewer, implementer: back }, runId);
|
|
473
|
-
});
|
|
474
|
-
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
// ------------------------------------------------------------------ events through the workflow
|
|
475
348
|
|
|
476
349
|
/**
|
|
477
|
-
*
|
|
478
|
-
*
|
|
350
|
+
* Send card `id` the event `name` (the board's workflow decides what it does: `workflow.mjs`), then settle the cards
|
|
351
|
+
* that wait on it (Hermes's recompute_ready) and run what the move asked for once it is committed. `roles` are who
|
|
352
|
+
* sends it: `manager` for a person or the fleet's manager at the CLI, the card's own role for its session,
|
|
353
|
+
* `platform` for the dispatcher. Answers `{ from, to, moved }`; throws the workflow's refusal.
|
|
479
354
|
*/
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const runId = row.status === 'running' ? endRun(db, row.id, { outcome: 'reclaimed', status: 'todo', summary: `ancestor ${id} reopened` }) : null;
|
|
495
|
-
run(db, "UPDATE tasks SET status = 'todo', completed_at = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, current_run_id = NULL, consecutive_failures = 0 WHERE id = ?", row.id);
|
|
496
|
-
event(db, row.id, 'descendant_invalidated', { ancestor: id, prior_status: row.status, new_status: 'todo', resume_status: resume }, runId);
|
|
497
|
-
event(db, row.id, 'status', { status: 'todo', reason: 'ancestor_reopened', parent: id, previous_status: row.status, resume_status: resume }, runId);
|
|
498
|
-
run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', row.id, author, `Invalidated: ancestor ${id} was reopened; retracted from '${row.status}' to 'todo' (will resume via '${resume}').`, t);
|
|
499
|
-
invalidated.push({ id: row.id, prior_status: row.status });
|
|
500
|
-
}
|
|
501
|
-
return { status, invalidated };
|
|
355
|
+
// who is sending, when a caller does not say: the CLI answers with the caller's role on the card (setCaller)
|
|
356
|
+
let callerRoles = () => ['manager'];
|
|
357
|
+
/** How the engine learns who a caller is on a card: `fn(db, cardId)` answers the caller's roles there. */
|
|
358
|
+
export function setCaller(fn) { callerRoles = fn; }
|
|
359
|
+
|
|
360
|
+
export async function act(board, id, name, { roles = null, payload = {}, extra = {} } = {}) {
|
|
361
|
+
const entry = await loadWorkflow(board.root);
|
|
362
|
+
const result = tx(board, (db) => {
|
|
363
|
+
roles ??= callerRoles(db, id);
|
|
364
|
+
const r = fire(db, board, entry, id, name, { roles, payload, extra });
|
|
365
|
+
// the cards that wait on it, and its subtasks, see it change
|
|
366
|
+
const around = [...childrenOf(db, id), ...all(db, 'SELECT task_id AS id FROM supercode_cards WHERE subtask_of = ?', id).map((x) => x.id)];
|
|
367
|
+
for (const other of new Set(around)) settle(db, board, entry, other, r.effects);
|
|
368
|
+
return r;
|
|
502
369
|
});
|
|
370
|
+
await runEffects(board, result.effects);
|
|
371
|
+
return { from: result.from, to: result.to, moved: result.moved };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** The I/O a committed move asked for. */
|
|
375
|
+
export async function runEffects(board, effects = []) {
|
|
376
|
+
for (const e of effects) {
|
|
377
|
+
if (e.kind === 'cleanup_workspace') cleanupWorkspace(board, e.id);
|
|
378
|
+
else if (e.kind === 'invalidate_descendants') tx(board, (db) => invalidateDescendants(db, board, e.id));
|
|
379
|
+
else if ((e.kind === 'run_ended' && e.outcome === 'reclaimed') || e.kind === 'stop_run') stopWorker(board, e.runId);
|
|
380
|
+
else if (e.kind === 'close_session' || e.kind === 'send_to_actor') {
|
|
381
|
+
const { sessionEffect } = await import('./dispatch.mjs');
|
|
382
|
+
await sessionEffect(board, e);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
503
385
|
}
|
|
504
386
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
});
|
|
387
|
+
// a run taken back stops its one-shot worker on this host (Hermes's _terminate_reclaimed_worker); a pane session is
|
|
388
|
+
// a person-visible session and is never killed from the board
|
|
389
|
+
function stopWorker(board, runId) {
|
|
390
|
+
if (!runId) return;
|
|
391
|
+
const row = tx(board, (db) => one(db, 'SELECT metadata, claim_lock FROM task_runs WHERE id = ?', runId));
|
|
392
|
+
const worker = json(row?.metadata, {})?.supercode;
|
|
393
|
+
if (worker?.surface === 'headless' && worker.pid) terminate(Number(worker.pid));
|
|
513
394
|
}
|
|
514
395
|
|
|
515
|
-
|
|
396
|
+
/** Every card whose workflow status has an `always` move that holds takes it (the tick's recompute). Answers how many moved. */
|
|
397
|
+
export function recomputeReady(board) {
|
|
398
|
+
const entry = workflowFor(board.root);
|
|
516
399
|
return tx(board, (db) => {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
400
|
+
let moved = 0;
|
|
401
|
+
for (const { id } of all(db, "SELECT id FROM tasks WHERE status NOT IN ('done', 'archived')")) {
|
|
402
|
+
const before = statusOf(db, getTask(db, id));
|
|
403
|
+
settle(db, board, entry, id);
|
|
404
|
+
if (statusOf(db, getTask(db, id)) !== before) moved += 1;
|
|
405
|
+
}
|
|
406
|
+
return moved;
|
|
523
407
|
});
|
|
524
408
|
}
|
|
525
409
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
410
|
+
// Hermes's invalidate_descendants_for_parent_reopen: every descendant that moved on because of the reopened card
|
|
411
|
+
// is pulled back to todo, its run ended, a comment saying why
|
|
412
|
+
function invalidateDescendants(db, board, id) {
|
|
413
|
+
const { workflow } = workflowFor(board.root);
|
|
414
|
+
const t = now();
|
|
415
|
+
const rows = all(db, `WITH RECURSIVE d(id) AS (SELECT child_id FROM task_links WHERE parent_id = ? UNION SELECT l.child_id FROM task_links l JOIN d ON d.id = l.parent_id)
|
|
416
|
+
SELECT t.id, t.status FROM d JOIN tasks t ON t.id = d.id ORDER BY t.id`, id);
|
|
417
|
+
const out = [];
|
|
418
|
+
for (const row of rows) {
|
|
419
|
+
if (!['ready', 'review', 'running', 'done'].includes(row.status)) continue;
|
|
420
|
+
const resume = row.status === 'review' ? 'review' : row.status === 'running' ? retryStatus(db, row.id) : 'ready';
|
|
421
|
+
const runId = row.status === 'running' ? endRun(db, row.id, { outcome: 'reclaimed', status: 'todo', summary: `ancestor ${id} reopened` }) : null;
|
|
422
|
+
run(db, 'UPDATE tasks SET completed_at = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, current_run_id = NULL, consecutive_failures = 0 WHERE id = ?', row.id);
|
|
423
|
+
setStatus(db, workflow, row.id, 'todo');
|
|
424
|
+
event(db, row.id, 'descendant_invalidated', { ancestor: id, prior_status: row.status, new_status: 'todo', resume_status: resume }, runId);
|
|
425
|
+
event(db, row.id, 'status', { status: 'todo', reason: 'ancestor_reopened', parent: id, previous_status: row.status, resume_status: resume }, runId);
|
|
426
|
+
run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', row.id, 'workflow', `Invalidated: ancestor ${id} was reopened; retracted from '${row.status}' to 'todo' (will resume via '${resume}').`, t);
|
|
427
|
+
out.push({ id: row.id, prior_status: row.status });
|
|
428
|
+
}
|
|
429
|
+
return out;
|
|
534
430
|
}
|
|
535
431
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
432
|
+
// each of Hermes's verbs is an event of the workflow; the words it takes are the event's payload
|
|
433
|
+
|
|
434
|
+
export const complete = (board, id, { result = null, summary = null, metadata = null, roles, runId = null } = {}) =>
|
|
435
|
+
act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId } });
|
|
436
|
+
export const block = (board, id, { reason = null, kind = null, roles, runId = null } = {}) => {
|
|
437
|
+
if (kind && !BLOCK_KINDS.includes(kind)) throw new Error(`block kind is one of ${BLOCK_KINDS.join(', ')}`);
|
|
438
|
+
return act(board, id, 'block', { roles, payload: { reason, kind, run_id: runId } });
|
|
439
|
+
};
|
|
440
|
+
export const unblock = (board, id, { reason = null, roles } = {}) => act(board, id, 'unblock', { roles, payload: { reason } });
|
|
441
|
+
export const requestReview = (board, id, { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles } = {}) =>
|
|
442
|
+
act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId } });
|
|
443
|
+
export const requestChanges = (board, id, { reason = null, reviewer = null, roles } = {}) => act(board, id, 'request_changes', { roles, payload: { reason, reviewer } });
|
|
444
|
+
export const reopen = (board, id, { author = 'user', reason = null, roles } = {}) => act(board, id, 'reopen', { roles, payload: { author, reason } });
|
|
445
|
+
export const reopenReview = (board, id, { roles } = {}) => act(board, id, 'reopen_review', { roles });
|
|
446
|
+
export const schedule = (board, id, reason = null, { roles } = {}) => act(board, id, 'schedule', { roles, payload: { reason } });
|
|
447
|
+
export const promote = (board, id, { actor = 'user', reason = null, force = false, roles } = {}) => act(board, id, 'promote', { roles, payload: { actor, reason, force } });
|
|
448
|
+
export const reclaim = (board, id, reason = null, { roles } = {}) => act(board, id, 'reclaim', { roles, payload: { reason } });
|
|
449
|
+
export const archive = (board, id, { roles } = {}) => act(board, id, 'archive', { roles });
|
|
450
|
+
export const specify = (board, id, { title = null, body = null, author = 'user', roles } = {}) => act(board, id, 'specify', { roles, payload: { title, body, author } });
|
|
451
|
+
export const editResult = (board, id, { result, summary = null, metadata = undefined, roles } = {}) => act(board, id, 'edit', { roles, payload: { result, summary, metadata } });
|
|
452
|
+
export const heartbeat = (board, id, note = null, { roles } = {}) => act(board, id, 'heartbeat', { roles, payload: { note } });
|
|
453
|
+
export const setModel = (board, id, model = null, provider = null, { roles } = {}) => {
|
|
454
|
+
if (provider && !model) throw new Error('a provider needs a model');
|
|
455
|
+
return act(board, id, 'set_model', { roles, payload: { model: model || null, provider: provider || null } });
|
|
456
|
+
};
|
|
457
|
+
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 } });
|
|
458
|
+
export const assign = (board, id, profile, { roles } = {}) => act(board, id, 'assign', { roles, payload: { assignee: profile || null } });
|
|
459
|
+
|
|
460
|
+
/** A triage card fans out into children it waits on (Hermes's decomposer); answers their ids. */
|
|
461
|
+
export async function decompose(board, id, children, { author = 'user', roles } = {}) {
|
|
462
|
+
if (!children.length) throw new Error('decompose names at least one child (--child "<title>[@<assignee>]")');
|
|
463
|
+
const task = tx(board, (db) => must(db, id));
|
|
464
|
+
const ids = children.map((c) => createCard(board, {
|
|
465
|
+
title: c.title, body: c.body ?? null, assignee: c.assignee ?? task.assignee, machine: task.machine,
|
|
466
|
+
workspace: task.workspace_path ? `${task.workspace_kind}:${task.workspace_path}` : task.workspace_kind, tenant: task.tenant, createdBy: author,
|
|
467
|
+
}).id);
|
|
468
|
+
await act(board, id, 'decompose', { roles, payload: { children: ids, author } });
|
|
469
|
+
return ids;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** `child` waits for `parent`. */
|
|
473
|
+
export function link(board, parent, child, { roles } = {}) {
|
|
474
|
+
if (parent === child) throw new Error('a card cannot depend on itself');
|
|
475
|
+
tx(board, (db) => {
|
|
476
|
+
must(db, parent); must(db, child);
|
|
477
|
+
if (wouldCycle(db, parent, child)) throw new Error(`linking ${parent} -> ${child} would make a cycle`);
|
|
478
|
+
inheritSubscriptions(db, child, [parent]);
|
|
545
479
|
});
|
|
480
|
+
return act(board, child, 'link', { roles, payload: { parent, child } });
|
|
546
481
|
}
|
|
547
482
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
483
|
+
export async function unlink(board, parent, child, { roles } = {}) {
|
|
484
|
+
const linked = tx(board, (db) => one(db, 'SELECT 1 FROM task_links WHERE parent_id = ? AND child_id = ?', parent, child));
|
|
485
|
+
if (!linked) return false;
|
|
486
|
+
await act(board, child, 'unlink', { roles, payload: { parent, child } });
|
|
487
|
+
return true;
|
|
551
488
|
}
|
|
552
489
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
run(db, 'UPDATE tasks SET status = ? WHERE id = ? AND status = ?', status, row.id, row.status);
|
|
564
|
-
event(db, row.id, 'promoted', status !== 'ready' ? { status } : null);
|
|
565
|
-
promoted += 1;
|
|
566
|
-
}
|
|
567
|
-
return promoted;
|
|
490
|
+
/** A comment on the card's thread; answers its id. */
|
|
491
|
+
export async function comment(board, id, author, body, { roles } = {}) {
|
|
492
|
+
if (!String(body ?? '').trim()) throw new Error('a comment has a body');
|
|
493
|
+
if (!String(author ?? '').trim()) throw new Error('a comment has an author');
|
|
494
|
+
const commentId = tx(board, (db) => {
|
|
495
|
+
must(db, id);
|
|
496
|
+
return Number(run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', id, author.trim(), body.trim(), now()).lastInsertRowid);
|
|
497
|
+
});
|
|
498
|
+
await act(board, id, 'comment', { roles, payload: { author: author.trim(), body } });
|
|
499
|
+
return commentId;
|
|
568
500
|
}
|
|
569
501
|
|
|
570
502
|
// ------------------------------------------------------------------ subscriptions
|
|
@@ -663,40 +595,7 @@ export function cardContext(db, id) {
|
|
|
663
595
|
|
|
664
596
|
// ------------------------------------------------------------------ the rest of Hermes's verbs
|
|
665
597
|
|
|
666
|
-
/** A triage card gets its spec (a tightened title and a body) and waits in todo for its parents, as Hermes's specifier leaves it. */
|
|
667
|
-
export function specify(board, id, { title = null, body = null, author = 'user' } = {}) {
|
|
668
|
-
return tx(board, (db) => {
|
|
669
|
-
const task = must(db, id);
|
|
670
|
-
if (task.status !== 'triage' && task.status !== 'running') throw new Error(`${id} is ${task.status}; only a triage card is specified`);
|
|
671
|
-
if (title && String(title).trim()) run(db, 'UPDATE tasks SET title = ? WHERE id = ?', String(title).trim(), id);
|
|
672
|
-
if (body !== null) run(db, 'UPDATE tasks SET body = ? WHERE id = ?', body, id);
|
|
673
|
-
run(db, "UPDATE tasks SET status = 'todo', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?", id);
|
|
674
|
-
const runId = endRun(db, id, { outcome: 'specified', summary: 'specified' });
|
|
675
|
-
event(db, id, 'specified', { author, title_changed: Boolean(title), body_len: body ? body.length : 0 }, runId);
|
|
676
|
-
recomputeIn(db);
|
|
677
|
-
});
|
|
678
|
-
}
|
|
679
598
|
|
|
680
|
-
/**
|
|
681
|
-
* A triage card fans out into children it waits on (Hermes's decomposer: the root stays and is the child of every
|
|
682
|
-
* leaf, so it wakes when the graph is done). `children` are `{ title, body?, assignee? }`; answers their ids.
|
|
683
|
-
*/
|
|
684
|
-
export function decompose(board, id, children, { author = 'user' } = {}) {
|
|
685
|
-
if (!children.length) throw new Error('decompose names at least one child (--child "<title>[@<assignee>]")');
|
|
686
|
-
const task = tx(board, (db) => must(db, id));
|
|
687
|
-
if (task.status !== 'triage' && task.status !== 'running') throw new Error(`${id} is ${task.status}; only a triage card is decomposed`);
|
|
688
|
-
const ids = children.map((c) => createCard(board, {
|
|
689
|
-
title: c.title, body: c.body ?? null, assignee: c.assignee ?? task.assignee, machine: task.machine,
|
|
690
|
-
workspace: task.workspace_path ? `${task.workspace_kind}:${task.workspace_path}` : task.workspace_kind, tenant: task.tenant, createdBy: author,
|
|
691
|
-
}).id);
|
|
692
|
-
tx(board, (db) => {
|
|
693
|
-
for (const child of ids) run(db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', child, id);
|
|
694
|
-
run(db, "UPDATE tasks SET status = 'todo', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?", id);
|
|
695
|
-
const runId = endRun(db, id, { outcome: 'decomposed', summary: `decomposed into ${ids.join(', ')}` });
|
|
696
|
-
event(db, id, 'decomposed', { children: ids, author }, runId);
|
|
697
|
-
});
|
|
698
|
-
return ids;
|
|
699
|
-
}
|
|
700
599
|
|
|
701
600
|
/** Hermes's swarm: a completed root as the shared blackboard, workers after it, a verifier after them all, a synthesizer last. */
|
|
702
601
|
export function swarm(board, { goal, workers, verifier, synthesizer, tenant = null, createdBy = 'swarm-orchestrator', priority = 0, idempotencyKey = null }) {
|
|
@@ -717,68 +616,10 @@ export function swarm(board, { goal, workers, verifier, synthesizer, tenant = nu
|
|
|
717
616
|
return { root_id: root.id, worker_ids: workerIds, verifier_id: verifierId, synthesizer_id: synthesizerId };
|
|
718
617
|
}
|
|
719
618
|
|
|
720
|
-
/** Hermes's `edit`: correct a done card's result (and its completed run's summary and metadata). */
|
|
721
|
-
export function editResult(board, id, { result, summary = null, metadata = undefined }) {
|
|
722
|
-
return tx(board, (db) => {
|
|
723
|
-
const task = must(db, id);
|
|
724
|
-
if (task.status !== 'done') throw new Error(`${id} is ${task.status}; edit corrects a done card's result`);
|
|
725
|
-
run(db, 'UPDATE tasks SET result = ? WHERE id = ?', result, id);
|
|
726
|
-
const said = summary ?? result;
|
|
727
|
-
const last = one(db, "SELECT id FROM task_runs WHERE task_id = ? AND outcome = 'completed' ORDER BY COALESCE(ended_at, started_at, 0) DESC, id DESC LIMIT 1", id);
|
|
728
|
-
let runId = last ? Number(last.id) : null;
|
|
729
|
-
if (runId === null) runId = synthesizeRun(db, id, { outcome: 'completed', summary: said, metadata: metadata ?? null });
|
|
730
|
-
else {
|
|
731
|
-
run(db, 'UPDATE task_runs SET summary = ? WHERE id = ?', said, runId);
|
|
732
|
-
if (metadata !== undefined) run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(metadata), runId);
|
|
733
|
-
}
|
|
734
|
-
event(db, id, 'edited', { fields: ['result', 'summary', ...(metadata !== undefined ? ['metadata'] : [])], result_len: result ? result.length : 0, summary: String(said ?? '').split('\n')[0].slice(0, 400) || null }, runId);
|
|
735
|
-
});
|
|
736
|
-
}
|
|
737
619
|
|
|
738
|
-
/** Send a card in review back to its implementer (ready, or todo while parents are open). */
|
|
739
|
-
export function reopenReview(board, id) {
|
|
740
|
-
return tx(board, (db) => {
|
|
741
|
-
const task = must(db, id);
|
|
742
|
-
if (task.status !== 'review') throw new Error(`${id} is ${task.status}, not in review`);
|
|
743
|
-
const handoff = json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'review_requested' ORDER BY id DESC LIMIT 1", id)?.payload, {}) ?? {};
|
|
744
|
-
const status = parentsDone(db, id) ? 'ready' : 'todo';
|
|
745
|
-
const implementer = typeof handoff.implementer === 'string' && handoff.implementer.trim() ? handoff.implementer : null;
|
|
746
|
-
run(db, `UPDATE tasks SET status = ?, current_run_id = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL${implementer ? ', assignee = ?' : ''} WHERE id = ?`, ...(implementer ? [status, implementer, id] : [status, id]));
|
|
747
|
-
event(db, id, 'review_reopened', { status, ...(implementer ? { implementer } : {}) });
|
|
748
|
-
return status;
|
|
749
|
-
});
|
|
750
|
-
}
|
|
751
620
|
|
|
752
|
-
/** A running card's worker says it is alive (Hermes's `heartbeat`); the dispatcher does not rely on it. */
|
|
753
|
-
export function heartbeat(board, id, note = null) {
|
|
754
|
-
return tx(board, (db) => {
|
|
755
|
-
const task = must(db, id);
|
|
756
|
-
if (task.status !== 'running') throw new Error(`${id} is ${task.status}, not running`);
|
|
757
|
-
const t = now();
|
|
758
|
-
run(db, 'UPDATE tasks SET last_heartbeat_at = ? WHERE id = ?', t, id);
|
|
759
|
-
if (task.current_run_id) run(db, 'UPDATE task_runs SET last_heartbeat_at = ? WHERE id = ?', t, task.current_run_id);
|
|
760
|
-
event(db, id, 'heartbeat', note ? { note } : null, task.current_run_id);
|
|
761
|
-
});
|
|
762
|
-
}
|
|
763
621
|
|
|
764
|
-
/** A card's model (and provider) for its next start, or cleared. */
|
|
765
|
-
export function setModel(board, id, model = null, provider = null) {
|
|
766
|
-
if (provider && !model) throw new Error('a provider needs a model');
|
|
767
|
-
return tx(board, (db) => {
|
|
768
|
-
must(db, id);
|
|
769
|
-
run(db, 'UPDATE tasks SET model_override = ?, provider_override = ? WHERE id = ?', model || null, provider || null, id);
|
|
770
|
-
event(db, id, 'model_override_set', { model: model || null, provider: provider || null });
|
|
771
|
-
});
|
|
772
|
-
}
|
|
773
622
|
|
|
774
|
-
/** Goal mode on a card (Hermes's goal loop, which Hermes sets only at creation): on or off, with its turn budget. */
|
|
775
|
-
export function setGoal(board, id, { on = true, maxTurns = null } = {}) {
|
|
776
|
-
return tx(board, (db) => {
|
|
777
|
-
must(db, id);
|
|
778
|
-
run(db, 'UPDATE tasks SET goal_mode = ?, goal_max_turns = ? WHERE id = ?', on ? 1 : 0, on ? maxTurns : null, id);
|
|
779
|
-
event(db, id, 'goal_set', { goal_mode: Boolean(on), goal_max_turns: on ? maxTurns : null });
|
|
780
|
-
});
|
|
781
|
-
}
|
|
782
623
|
|
|
783
624
|
export function addAttachment(board, id, { file, name, contentType = null, author = 'user', dir }) {
|
|
784
625
|
return tx(board, (db) => {
|