@volter/supercode-orchestrator 0.4.148 → 0.4.150
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/ci.mjs +147 -56
- package/board/cli.mjs +68 -61
- package/board/dispatch.mjs +129 -58
- package/board/engine.mjs +69 -25
- package/board/store.mjs +6 -3
- package/board/tools.mjs +6 -3
- package/board/workflow.mjs +30 -45
- package/board/workspace.mjs +32 -0
- package/board/ztrack.mjs +802 -542
- package/cron.mjs +8 -2
- package/hermes-locks.mjs +1 -20
- package/loop.mjs +23 -2
- package/mcp/hermes-kanban-tools.json +47 -18
- package/package.json +2 -2
- package/board/rows.mjs +0 -132
package/board/engine.mjs
CHANGED
|
@@ -37,7 +37,7 @@ export function getTask(db, id) {
|
|
|
37
37
|
const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
|
|
38
38
|
if (!row) return null;
|
|
39
39
|
const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
|
|
40
|
-
return { ...row, workspace_anchor: ours?.workspace_anchor ?? null,
|
|
40
|
+
return { ...row, workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
function must(db, id) {
|
|
@@ -152,10 +152,36 @@ function projectOf(root, idOrSlug) {
|
|
|
152
152
|
} catch { return null; } finally { db.close(); }
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* A card's Tasks are its subtasks (the document renders them as its Tasks section). Task lines written in an arc's
|
|
157
|
+
* body (`## Tasks` then `- [ ] step`, its indented lines the step's own text) are answered as the steps to file as
|
|
158
|
+
* its subtasks, and leave the body. A Tasks section with no task line is the body's own text.
|
|
159
|
+
*/
|
|
160
|
+
export function bodyTasks(body) {
|
|
161
|
+
const lines = String(body ?? '').split('\n');
|
|
162
|
+
const at = lines.findIndex((line) => /^#{2,3}\s+Tasks\s*$/i.test(line));
|
|
163
|
+
if (at < 0) return { body, tasks: [] };
|
|
164
|
+
let end = lines.findIndex((line, i) => i > at && /^#{1,3}\s/.test(line));
|
|
165
|
+
if (end < 0) end = lines.length;
|
|
166
|
+
const section = lines.slice(at + 1, end);
|
|
167
|
+
if (!section.some((line) => /^[-*] \[[ xX]\]/.test(line))) return { body, tasks: [] };
|
|
168
|
+
const tasks = [];
|
|
169
|
+
for (const line of section) {
|
|
170
|
+
if (!line.trim()) continue;
|
|
171
|
+
const step = /^[-*] \[ \]\s+(\S.*)$/.exec(line);
|
|
172
|
+
if (step) tasks.push({ title: step[1].trim(), body: [] });
|
|
173
|
+
else if (/^\s+\S/.test(line) && tasks.length) tasks.at(-1).body.push(line.replace(/^\s{1,4}/, ''));
|
|
174
|
+
else throw new Error("A card's Tasks section holds open task lines ('- [ ] <step>', each filed as a subtask, with its own indented lines); record finished work as a comment and move other text out of the section");
|
|
175
|
+
}
|
|
176
|
+
const rest = [...lines.slice(0, at), ...lines.slice(end)].join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
177
|
+
return { body: rest || null, tasks: tasks.map((t) => ({ title: t.title, body: t.body.join('\n') || null })) };
|
|
178
|
+
}
|
|
179
|
+
|
|
155
180
|
export function createCard(board, spec) {
|
|
156
|
-
if (spec.reviewOnly !== undefined && typeof spec.reviewOnly !== 'boolean') throw new Error('reviewOnly must be a boolean');
|
|
157
181
|
const title = String(spec.title ?? '').trim();
|
|
158
182
|
if (!title) throw new Error('a card has a title');
|
|
183
|
+
const steps = spec.subtaskOf ? [] : bodyTasks(spec.body).tasks;
|
|
184
|
+
if (steps.length) spec = { ...spec, body: bodyTasks(spec.body).body };
|
|
159
185
|
const ws = parseWorkspace(spec.workspace ?? 'scratch');
|
|
160
186
|
if (spec.branch && ws.kind === 'scratch') throw new Error('a scratch workspace has no repository branch');
|
|
161
187
|
const parents = [...new Set((spec.parents ?? []).filter(Boolean))];
|
|
@@ -184,14 +210,15 @@ export function createCard(board, spec) {
|
|
|
184
210
|
if (missing.length) throw new Error(`unknown parent card(s): ${missing.join(', ')}`);
|
|
185
211
|
const arc = spec.subtaskOf ? getTask(db, spec.subtaskOf) : null;
|
|
186
212
|
const entry = workflowFor(board.root);
|
|
187
|
-
if (spec.reviewOnly) {
|
|
188
|
-
const permitted = entry.params.review_only_by;
|
|
189
|
-
if (arc || spec.every || initial || !Array.isArray(permitted) || !callerRoles(db, arc?.id ?? '').some(role => permitted.includes(role))) throw new Error('Only a permitted manager creates a review-only arc, without recurrence, subtasks or an initial-status override.');
|
|
190
|
-
}
|
|
191
213
|
if (spec.subtaskOf && !arc) throw new Error(`no card ${spec.subtaskOf} to be a subtask of`);
|
|
192
214
|
if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; a subtask belongs to an arc`);
|
|
193
215
|
let id = newTaskId();
|
|
194
216
|
while (one(db, 'SELECT 1 FROM tasks WHERE id = ?', id)) id = newTaskId();
|
|
217
|
+
// where a card's blocks are relations, a card created blocked names what it waits on: one that names nothing is
|
|
218
|
+
// ready at once (board-ir-2 started six minutes after it was created "blocked")
|
|
219
|
+
if (initial === 'blocked' && !entry.workflow.statuses?.blocked && !parents.length) {
|
|
220
|
+
throw new Error('A card created blocked names what it waits on: --parent <card> (it starts when that card is done); or create it and block it with --waiting-on <message>.');
|
|
221
|
+
}
|
|
195
222
|
const creationRoles = workflowFor(board.root).params.create_dependencies_by;
|
|
196
223
|
if ((parents.length || initial === 'blocked') && creationRoles && !callerRoles(db, arc?.id ?? id).some(role => creationRoles.includes(role))) {
|
|
197
224
|
throw new Error('Creating a dependency or initially blocked card requires a permitted manager role; file the task without a blocker and use request-state.');
|
|
@@ -216,13 +243,13 @@ export function createCard(board, spec) {
|
|
|
216
243
|
spec.skills?.length ? JSON.stringify(spec.skills) : null, spec.maxRetries ?? null, spec.model ?? null, spec.provider ?? null,
|
|
217
244
|
spec.reasoning ?? null, spec.sessionId ?? null, spec.project ?? null, spec.goal ? 1 : 0, spec.goal?.maxTurns ?? null);
|
|
218
245
|
const machine = spec.machine ?? arc?.machine ?? null;
|
|
219
|
-
run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root
|
|
246
|
+
run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root) VALUES (?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.mainRoot ?? null);
|
|
220
247
|
for (const p of parents) addDependency(db, entry, id, p);
|
|
221
248
|
// where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
|
|
222
249
|
// the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
|
|
223
250
|
const status = startNew(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial,
|
|
224
|
-
|
|
225
|
-
event(db, id, 'created', { creator: actorOf(), assignee: spec.assignee || null, status,
|
|
251
|
+
every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
|
|
252
|
+
event(db, id, 'created', { creator: actorOf(), assignee: spec.assignee || null, status, parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
|
|
226
253
|
if ((spec.noStart || spec.adopt) && !arc) event(db, id, 'dispatch_held', { session: spec.adopt ?? null, reason: spec.adopt ? 'awaiting adoption' : 'created with --no-start' });
|
|
227
254
|
if (parents.length) inheritSubscriptions(db, id, parents);
|
|
228
255
|
if (arc) {
|
|
@@ -232,7 +259,9 @@ export function createCard(board, spec) {
|
|
|
232
259
|
}
|
|
233
260
|
if (initial === 'running' && arc && entry.workflow.run) setStatus(db, entry.workflow, id, 'running');
|
|
234
261
|
else if (initial === 'running') claimIn(db, id, { ttl: claimTtl() });
|
|
235
|
-
|
|
262
|
+
// the body's task lines, as subtasks the creator filed, in this same write
|
|
263
|
+
const subtasks = steps.map((step) => createCard(board, { ...step, subtaskOf: id, assignee: spec.assignee || null }).id);
|
|
264
|
+
return { id, existing: false, ...(subtasks.length ? { subtasks } : {}) };
|
|
236
265
|
});
|
|
237
266
|
}
|
|
238
267
|
|
|
@@ -255,8 +284,20 @@ function wouldCycle(db, parent, child) {
|
|
|
255
284
|
|
|
256
285
|
|
|
257
286
|
|
|
258
|
-
/**
|
|
259
|
-
|
|
287
|
+
/**
|
|
288
|
+
* Set one of a card's properties (`assignee`, `machine`, `model`, `every`) through the workflow's `set` verb, whose
|
|
289
|
+
* rules say who may and what follows: a change to how its session runs ends a live run and lets the dispatcher start
|
|
290
|
+
* the next one itself. A workflow without `set` (Hermes's own) has a verb per property.
|
|
291
|
+
*/
|
|
292
|
+
export const SETTABLE = ['assignee', 'machine', 'model', 'every'];
|
|
293
|
+
export function set(board, id, field, value, { provider = null, roles } = {}) {
|
|
294
|
+
if (!SETTABLE.includes(field)) throw new Error(`set takes one of ${SETTABLE.join(', ')}, not ${field}`);
|
|
295
|
+
if (provider && (field !== 'model' || !value)) throw new Error('a provider goes with a model');
|
|
296
|
+
value = value || null;
|
|
297
|
+
if (workflowFor(board.root).workflow.verbs?.set) return act(board, id, 'set', { roles, payload: { field, value, provider } });
|
|
298
|
+
const verb = { assignee: ['assign', { assignee: value }], machine: ['move', { machine: value }], model: ['set_model', { model: value, provider }], every: ['set_every', { every: value }] }[field];
|
|
299
|
+
return act(board, id, verb[0], { roles, payload: verb[1] });
|
|
300
|
+
}
|
|
260
301
|
|
|
261
302
|
// ------------------------------------------------------------------ lifecycle
|
|
262
303
|
|
|
@@ -345,8 +386,9 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
|
|
|
345
386
|
const entry = await loadWorkflow(board.root);
|
|
346
387
|
if (name === 'comment' && (!String(payload.body ?? '').trim() || !String(payload.author ?? '').trim())) throw new Error('a comment has an author and body');
|
|
347
388
|
refreshCardInputs(board, id, { commits: ['complete', 'request_review'].includes(name) });
|
|
348
|
-
|
|
349
|
-
if (
|
|
389
|
+
const evidencePhase = ['complete', 'request_review', 'request_state'].includes(name) ? completionPhase(board, id, entry, name, payload, roles ?? callerRoles(board.db, id)) : null;
|
|
390
|
+
if (evidencePhase) payload = { ...payload, ci: observeCompletionEvidence(board, id, entry, payload, evidencePhase) };
|
|
391
|
+
if (payload.ci?.required && !payload.ci.ready && (name !== 'request_state' || evidencePhase === 'review_verdict')) throw new Error(payload.ci.reason ?? 'The PR candidate could not be verified.');
|
|
350
392
|
const result = tx(board, (db) => {
|
|
351
393
|
roles ??= callerRoles(db, id);
|
|
352
394
|
const r = fire(db, board, entry, id, name, { roles, payload, extra });
|
|
@@ -368,9 +410,14 @@ export async function requestStateMany(board, ids, payload = {}) {
|
|
|
368
410
|
const entry = await loadWorkflow(board.root);
|
|
369
411
|
const prepared = new Map(targets.map(id => {
|
|
370
412
|
const roles = callerRoles(board.db, id);
|
|
371
|
-
|
|
413
|
+
const phase = completionPhase(board, id, entry, 'request_state', payload, roles);
|
|
414
|
+
const evidence = inspectCompletionEvidence(board, id, entry, payload, phase);
|
|
415
|
+
// An ordinary request mails a decision to the manager, not a completion.
|
|
416
|
+
// Keep a failed source observation in that mail. A reviewer recording an
|
|
417
|
+
// actual verdict still has to verify the requested PR head.
|
|
418
|
+
if (phase === 'review_verdict' && evidence.required && !evidence.ready) throw new Error(evidence.reason ?? 'The reviewed PR head could not be verified.');
|
|
419
|
+
return [id, evidence];
|
|
372
420
|
}));
|
|
373
|
-
for (const evidence of prepared.values()) if (evidence.required && !evidence.ready) throw new Error(evidence.reason ?? 'The reviewed PR head could not be verified.');
|
|
374
421
|
const result = tx(board, db => {
|
|
375
422
|
const effects = [];
|
|
376
423
|
const results = targets.map(id => withCompletionEvidence(board, id, prepared.get(id), () => {
|
|
@@ -494,7 +541,7 @@ export const schedule = (board, id, reason = null, { roles } = {}) => act(board,
|
|
|
494
541
|
export const promote = (board, id, { actor = 'user', reason = null, force = false, roles } = {}) => act(board, id, 'promote', { roles, payload: { actor, reason, force } });
|
|
495
542
|
export const reclaim = (board, id, reason = null, { roles } = {}) => act(board, id, 'reclaim', { roles, payload: { reason } });
|
|
496
543
|
export const archive = (board, id, { roles } = {}) => act(board, id, 'archive', { roles });
|
|
497
|
-
export function specify(board, id, { title = null, body = null, workspace = null, branch = null,
|
|
544
|
+
export async function specify(board, id, { title = null, body = null, workspace = null, branch = null, author = 'user', roles } = {}) {
|
|
498
545
|
let declaration = null;
|
|
499
546
|
if (workspace != null || branch != null) {
|
|
500
547
|
const task = getTask(board.db, id);
|
|
@@ -507,17 +554,15 @@ export function specify(board, id, { title = null, body = null, workspace = null
|
|
|
507
554
|
if (declaration.kind === 'worktree') declaration.branch ??= `wt/${id}`;
|
|
508
555
|
if (declaration.kind === 'dir') declaration.path = checkWorkspace(board, { ...task, workspace_kind: declaration.kind, workspace_anchor: declaration.repository, workspace_path: declaration.path, branch_name: declaration.branch }).path;
|
|
509
556
|
}
|
|
510
|
-
|
|
511
|
-
|
|
557
|
+
// an arc's new task lines are filed as its subtasks once the specify itself is accepted
|
|
558
|
+
const steps = body != null && !one(board.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id = ?', id)?.subtask_of ? bodyTasks(body) : { body, tasks: [] };
|
|
559
|
+
const done = await act(board, id, 'specify', { roles, payload: { title, body: steps.body, author, ...(declaration ? { workspace: declaration } : {}) } });
|
|
560
|
+
for (const step of steps.tasks) createCard(board, { ...step, subtaskOf: id, assignee: getTask(board.db, id)?.assignee || null });
|
|
561
|
+
return done;
|
|
512
562
|
}
|
|
513
563
|
export const editResult = (board, id, { result, summary = null, metadata = undefined, roles } = {}) => act(board, id, 'edit', { roles, payload: { result, summary, metadata } });
|
|
514
564
|
export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
|
|
515
|
-
export const setModel = (board, id, model = null, provider = null, { roles } = {}) => {
|
|
516
|
-
if (provider && !model) throw new Error('a provider needs a model');
|
|
517
|
-
return act(board, id, 'set_model', { roles, payload: { model: model || null, provider: provider || null } });
|
|
518
|
-
};
|
|
519
565
|
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 } });
|
|
520
|
-
export const assign = (board, id, profile, { roles } = {}) => act(board, id, 'assign', { roles, payload: { assignee: profile || null } });
|
|
521
566
|
|
|
522
567
|
/** A triage card fans out into children it waits on (Hermes's decomposer); answers their ids. */
|
|
523
568
|
export async function decompose(board, id, children, { author = 'user', roles } = {}) {
|
|
@@ -665,7 +710,6 @@ export function cardContext(db, id) {
|
|
|
665
710
|
const task = must(db, id);
|
|
666
711
|
const out = [`# Card ${task.id}: ${task.title}`, ''];
|
|
667
712
|
out.push(`Status: ${task.status}. Machine: ${task.machine ?? 'unset'}. Workspace: ${task.workspace_kind}${task.workspace_anchor ? ` ${task.workspace_anchor}` : task.workspace_path ? ` ${task.workspace_path}` : ''}${task.branch_name ? ` (branch ${task.branch_name})` : ''}.${task.subtask_of ? ` A subtask of ${task.subtask_of}.` : ''}`);
|
|
668
|
-
if (task.review_only) out.push('Purpose: review-only. Record the independent verdict with request-state --state done --reason <verdict> --evidence <artifact>; its creator or manager accepts this review job.');
|
|
669
713
|
if (task.body) out.push('', cap(task.body, 8 * 1024));
|
|
670
714
|
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);
|
|
671
715
|
if (parents.length) {
|
package/board/store.mjs
CHANGED
|
@@ -73,7 +73,7 @@ const OURS = `CREATE TABLE IF NOT EXISTS supercode_cards (task_id TEXT PRIMARY K
|
|
|
73
73
|
CREATE TABLE IF NOT EXISTS supercode_effects (id INTEGER PRIMARY KEY AUTOINCREMENT, effect TEXT NOT NULL);`;
|
|
74
74
|
// `stage`: the card's workflow status where kanban.db stores another (a `reviewing` card is `running` to Hermes)
|
|
75
75
|
// `since`: when the card entered its workflow status; `after_fired`: which of the status's `after` moves have run since
|
|
76
|
-
const OURS_COLUMNS = { workspace_anchor: 'TEXT', workspace_root: 'TEXT', subtask_of: 'TEXT',
|
|
76
|
+
const OURS_COLUMNS = { workspace_anchor: 'TEXT', workspace_root: 'TEXT', subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT', blocked_by: 'TEXT', paused: 'TEXT', archived: 'INTEGER DEFAULT 0', every: 'TEXT', next_run_at: 'INTEGER' };
|
|
77
77
|
|
|
78
78
|
// ------------------------------------------------------------------ backings
|
|
79
79
|
//
|
|
@@ -172,18 +172,21 @@ export const tx = (board, fn) => {
|
|
|
172
172
|
export const view = (board, fn) => fn(board.db);
|
|
173
173
|
|
|
174
174
|
// A deferred native transaction, with the whole operation retried on an optimistic conflict.
|
|
175
|
-
// There is no separate application writer lock or contention timeout.
|
|
175
|
+
// There is no separate application writer lock or contention timeout. A write inside one is part of it.
|
|
176
|
+
const inTx = new WeakSet();
|
|
176
177
|
function sqliteTx(db, fn) {
|
|
178
|
+
if (inTx.has(db)) return fn(db);
|
|
177
179
|
for (;;) {
|
|
178
180
|
try {
|
|
179
181
|
db.exec('BEGIN');
|
|
182
|
+
inTx.add(db);
|
|
180
183
|
const out=fn(db);
|
|
181
184
|
db.exec('COMMIT');
|
|
182
185
|
return out;
|
|
183
186
|
} catch(error) {
|
|
184
187
|
try {db.exec('ROLLBACK');} catch {}
|
|
185
188
|
if (!/locked|busy/i.test(error.message))throw error;
|
|
186
|
-
}
|
|
189
|
+
} finally { inTx.delete(db); }
|
|
187
190
|
}
|
|
188
191
|
}
|
|
189
192
|
|
package/board/tools.mjs
CHANGED
|
@@ -80,7 +80,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
80
80
|
return JSON.stringify({
|
|
81
81
|
task: { id: t.id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, tenant: t.tenant, priority: t.priority, workspace_kind: t.workspace_kind,
|
|
82
82
|
workspace_path: t.workspace_path, created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
|
|
83
|
-
current_run_id: t.current_run_id, model_override: t.model_override, provider_override: t.provider_override
|
|
83
|
+
current_run_id: t.current_run_id, model_override: t.model_override, provider_override: t.provider_override },
|
|
84
84
|
parents: card.parents.map((p) => p.id), children: card.children,
|
|
85
85
|
comments: card.comments, events: card.events.slice(-50), runs: card.runs.map((r) => ({ id: r.id, profile: r.profile, status: r.status, outcome: r.outcome, summary: r.summary, error: r.error, metadata: r.metadata, started_at: r.started_at, ended_at: r.ended_at })),
|
|
86
86
|
worker_context: card.context,
|
|
@@ -132,8 +132,11 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
132
132
|
}
|
|
133
133
|
case 'kanban_request_changes': {
|
|
134
134
|
const stale = ownRun(); if (stale) return error(stale);
|
|
135
|
+
// each finding is filed as a task on the arc first, so the arc stays open until a pushed head fixes it
|
|
136
|
+
const findings = [].concat(args.findings ?? []).map((f) => redact(String(f)).trim()).filter(Boolean);
|
|
137
|
+
const filed = findings.map((finding) => engine.createCard(board, { title: finding, subtaskOf: tid }).id);
|
|
135
138
|
await engine.requestChanges(board, tid, { reason: args.reason ? redact(String(args.reason)) : null, reviewer: author });
|
|
136
|
-
return ok({ task_id: tid });
|
|
139
|
+
return ok({ task_id: tid, findings: filed });
|
|
137
140
|
}
|
|
138
141
|
case 'kanban_heartbeat': {
|
|
139
142
|
const stale = ownRun(); if (stale) return error(stale);
|
|
@@ -176,7 +179,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
176
179
|
title: args.title, body: args.body ? redact(String(args.body)) : null, assignee: args.assignee ?? null, parents: [args.parents ?? []].flat(), tenant: args.tenant ?? null,
|
|
177
180
|
priority: args.priority ?? 0, workspace, project: args.project ?? null, triage: Boolean(args.triage), idempotencyKey: args.idempotency_key ?? null,
|
|
178
181
|
maxRuntime: args.max_runtime_seconds ?? null, initialStatus: args.initial_status ?? null, skills: args.skills ?? null, model: args.model ?? null, provider: args.provider ?? null,
|
|
179
|
-
goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author,
|
|
182
|
+
goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author,
|
|
180
183
|
});
|
|
181
184
|
return ok({ task_id: made.id, existing: made.existing, status: tx(board, (db) => engine.getTask(db, made.id)).status });
|
|
182
185
|
}
|
package/board/workflow.mjs
CHANGED
|
@@ -104,7 +104,8 @@ function pattern(p) {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
// the time of the latest commit that names `id` in its message, on any ref of the repository at `path` (a worktree's
|
|
107
|
-
// pushes land on the shared remote-tracking refs), or null (asked only when an expression does)
|
|
107
|
+
// pushes land on the shared remote-tracking refs), or null (asked only when an expression does). Merge commits are
|
|
108
|
+
// not the card's work: merging its reviewed PR, or main into its branch, is not a newer change to review.
|
|
108
109
|
const commitTimes = new Map();
|
|
109
110
|
function observeLastCommit(path, id) {
|
|
110
111
|
if (!path) return null;
|
|
@@ -112,7 +113,7 @@ function observeLastCommit(path, id) {
|
|
|
112
113
|
const cached = commitTimes.get(key);
|
|
113
114
|
if (cached && now() - cached.at < 15) return cached.value;
|
|
114
115
|
const readCommit = (repo) => {
|
|
115
|
-
const r = spawnSync('git', ['-C', repo, 'log', '--all', '-1', '--format=%ct', '--fixed-strings', `--grep=${id}`], { encoding: 'utf8', timeout: 10_000 });
|
|
116
|
+
const r = spawnSync('git', ['-C', repo, 'log', '--all', '--no-merges', '-1', '--format=%ct', '--fixed-strings', `--grep=${id}`], { encoding: 'utf8', timeout: 10_000 });
|
|
116
117
|
const value = Number(r.stdout?.trim());
|
|
117
118
|
return { repo: r.status === 0, value: Number.isFinite(value) && value > 0 ? value : null };
|
|
118
119
|
};
|
|
@@ -201,11 +202,8 @@ function onlyTicks(was, body) {
|
|
|
201
202
|
|
|
202
203
|
/** Seconds a card's runs spent by role, and the rest of its life as `waiting`. */
|
|
203
204
|
export function runWorkingSeconds(row, at = now()) {
|
|
204
|
-
const metadata = typeof row.metadata === 'string' ? json(row.metadata, {}) : row.metadata;
|
|
205
|
-
const session = metadata?.supercode ?? {};
|
|
206
205
|
const end = Number(row.ended_at ?? at);
|
|
207
|
-
|
|
208
|
-
return Math.max(0, end - Number(row.started_at ?? end) - waiting);
|
|
206
|
+
return Math.max(0, end - Number(row.started_at ?? end));
|
|
209
207
|
}
|
|
210
208
|
|
|
211
209
|
function timeOf(db, task) {
|
|
@@ -250,7 +248,6 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
|
|
|
250
248
|
...task,
|
|
251
249
|
creator_address: creatorOf(db, task.id),
|
|
252
250
|
completion_owner_address: completionOwnerOf(db, task.id),
|
|
253
|
-
review_only: Boolean(state.review_only),
|
|
254
251
|
latest_review: latestReview(db, task.id) ?? {},
|
|
255
252
|
review_candidate: reviewCandidate(board, task.id) ?? {},
|
|
256
253
|
ci: completionEvidence(board, task.id),
|
|
@@ -363,6 +360,11 @@ function queueTransitionNotice(db, board, entry, task, from, name, payload, befo
|
|
|
363
360
|
cancellation?.outcome === 'not_issued' && cancellation?.live === false && cancellation?.pane === null) return;
|
|
364
361
|
const address = session?.address ?? (session?.session_id && session.machine ? `sc:${session.machine}:${session.harness ?? 'claude-code'}:${session.session_id}` : null);
|
|
365
362
|
if (!address && !session?.launch_key && !session?.pane) return;
|
|
363
|
+
// The session that made this change learns it from its own command's answer; a notice about
|
|
364
|
+
// its own act would only hold its close until the notice is confirmed. A session whose close is
|
|
365
|
+
// confirmed is not told either: its next run resumes it with the card's state in its prompt.
|
|
366
|
+
if (address && address === actorOf()) return;
|
|
367
|
+
if (session?.close_confirmed_at) return;
|
|
366
368
|
const scope = scopeOf(db, board, after, { params: entry.params, event: { ...payload, verb: name, from, to }, extra: { run: { id: beforeRun.id } } });
|
|
367
369
|
const body = render(entry.workflow.notify?.worker?.run_changed ?? '{{card.id}}: {{event.verb}} changed run {{run.id}} ({{event.from}} → {{event.to}}). Stop work under the previous run; follow the board’s current state. {{event.reason ?? ""}}', scope);
|
|
368
370
|
const source = one(db, 'SELECT MAX(id) AS id FROM task_events WHERE task_id = ?', task.id)?.id;
|
|
@@ -581,7 +583,7 @@ const FORMAT = {
|
|
|
581
583
|
commented: ({ e }) => ({ author: String(e.author ?? 'user').trim(), len: String(e.body ?? '').length }),
|
|
582
584
|
archived: () => null,
|
|
583
585
|
status: ({ e, before, to }) => ({ status: to, reason: e.reason ?? 'reopened', previous_status: before.stored_as }),
|
|
584
|
-
machine_set: ({ e, before }) => ({ machine: e.machine ?? null, was: before.machine }),
|
|
586
|
+
machine_set: ({ e, before }) => ({ machine: e.field === 'machine' ? e.value ?? null : e.machine ?? null, was: before.machine }),
|
|
585
587
|
};
|
|
586
588
|
const RETRY_KINDS = new Set(['crashed', 'timed_out', 'stale', 'rate_limited', 'session_lost', 'reclaimed', 'reconciled', 'spawn_failed']);
|
|
587
589
|
|
|
@@ -642,17 +644,6 @@ const ACTIONS = {
|
|
|
642
644
|
const result = fire(ctx.db, ctx.board, ctx.entry, ctx.id, name, { roles: [].concat(as), payload, extra: { lanes: lanesIn(ctx.entry) } });
|
|
643
645
|
ctx.effects.push(...result.effects);
|
|
644
646
|
},
|
|
645
|
-
wait_run(ctx, waiting) {
|
|
646
|
-
if (typeof waiting !== 'boolean') throw new Error('wait_run requires a boolean');
|
|
647
|
-
const at = now();
|
|
648
|
-
runSession(ctx, session => {
|
|
649
|
-
if (waiting) return { ...session, wait_started_at: session.wait_started_at ?? at };
|
|
650
|
-
if (session.wait_started_at == null) return session;
|
|
651
|
-
return { ...session, wait_started_at: null,
|
|
652
|
-
wait_seconds: Number(session.wait_seconds ?? 0) + Math.max(0, at - Number(session.wait_started_at)),
|
|
653
|
-
goal_nudged_at: at };
|
|
654
|
-
});
|
|
655
|
-
},
|
|
656
647
|
record(ctx, args, scope) {
|
|
657
648
|
const kind = typeof args === 'string' ? args : args?.kind;
|
|
658
649
|
const given = typeof args === 'object' && args?.payload ? args.payload : null;
|
|
@@ -772,12 +763,6 @@ const ACTIONS = {
|
|
|
772
763
|
run(ctx.db, 'DELETE FROM task_links WHERE parent_id = ? AND child_id = ?', parent, ctx.id);
|
|
773
764
|
},
|
|
774
765
|
specify(ctx, said) {
|
|
775
|
-
if (said?.review_only != null) {
|
|
776
|
-
const task = cardOf(ctx.db, ctx.id);
|
|
777
|
-
if (typeof said.review_only !== 'boolean' || task.current_run_id || task.subtask_of || ctx.scope.card.every) throw new Error('A review-only declaration changes only an arc with no current run or recurrence.');
|
|
778
|
-
cardField(ctx, 'review_only', said.review_only ? 1 : 0);
|
|
779
|
-
record(ctx.db, ctx.id, 'review_mode_changed', { review_only: said.review_only }, null);
|
|
780
|
-
}
|
|
781
766
|
if (said?.workspace) {
|
|
782
767
|
const task = cardOf(ctx.db, ctx.id), ws = said.workspace;
|
|
783
768
|
if (task.current_run_id) throw new Error('end the current run before changing its workspace');
|
|
@@ -821,29 +806,15 @@ const ACTIONS = {
|
|
|
821
806
|
const name = String(typeof slot === 'object' && slot ? slot.slot ?? 'worker' : slot ?? 'worker');
|
|
822
807
|
const harness = typeof slot === 'object' && slot ? ctx.entry.profiles[slot.assignee]?.worker?.harness : null;
|
|
823
808
|
if (typeof slot === 'object' && slot && !harness) return;
|
|
824
|
-
const released = new Set();
|
|
825
809
|
for (const row of all(ctx.db, 'SELECT id, metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC', ctx.id)) {
|
|
826
810
|
const whole = json(row.metadata, {}) ?? {};
|
|
827
811
|
const session = whole.supercode;
|
|
828
812
|
if ((!session?.session_id && !session?.pane && !session?.launch_key) || (name !== '*' && (session.slot ?? 'worker') !== name) || (harness && (session.harness ?? 'claude-code') === harness)) continue;
|
|
829
813
|
requestSessionClose(ctx.db, ctx.id, { runId: row.id });
|
|
830
814
|
const latest = json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id=?', row.id)?.metadata, {}) ?? {};
|
|
831
|
-
if (session.session_id) released.add(session.session_id);
|
|
832
815
|
latest.supercode = { ...latest.supercode, forgotten: now(), resumable: false };
|
|
833
816
|
run(ctx.db, "UPDATE task_runs SET metadata=?, ended_at=COALESCE(ended_at,?), status=CASE WHEN ended_at IS NULL THEN 'superseded' ELSE status END, outcome=CASE WHEN ended_at IS NULL THEN 'superseded' ELSE outcome END, claim_lock=NULL,claim_expires=NULL,worker_pid=NULL WHERE id=?", JSON.stringify(latest), now(), row.id);
|
|
834
817
|
}
|
|
835
|
-
const gaveUp = one(ctx.db, "SELECT created_at,payload FROM task_events WHERE task_id=? AND kind='gave_up' ORDER BY id DESC LIMIT 1", ctx.id);
|
|
836
|
-
const failures = Number(json(gaveUp?.payload, {})?.failures ?? 0);
|
|
837
|
-
if (!failures || !released.size) return;
|
|
838
|
-
const recent = all(ctx.db, 'SELECT outcome,metadata FROM task_runs WHERE task_id=? AND ended_at<=? ORDER BY id DESC LIMIT ?', ctx.id, gaveUp.created_at, failures);
|
|
839
|
-
if (recent.length !== failures || !recent.every(row => {
|
|
840
|
-
const failure = json(row.metadata, {})?.last_events?.spawn_failed?.payload;
|
|
841
|
-
return row.outcome === 'spawn_failed' && failure?.resume_failed === true && released.has(failure.session_id);
|
|
842
|
-
})) return;
|
|
843
|
-
const blocks = json(one(ctx.db, 'SELECT blocked_by FROM supercode_cards WHERE task_id=?', ctx.id)?.blocked_by, []);
|
|
844
|
-
for (const block of blocks.filter(block => block.kind === 'message' && block.reason === 'failure limit reached' && block.since === gaveUp.created_at))
|
|
845
|
-
ACTIONS.relation(ctx, { remove: block.id, reason: 'The manager released the session responsible for these stale continuation failures.' });
|
|
846
|
-
run(ctx.db, 'UPDATE tasks SET consecutive_failures=0,last_failure_error=NULL WHERE id=?', ctx.id);
|
|
847
818
|
},
|
|
848
819
|
// an event sent to other cards (`to`: an expression naming their ids, e.g. a card's open subtasks), as `as` sends it;
|
|
849
820
|
// a card it does not apply to now is passed over
|
|
@@ -867,6 +838,8 @@ const ACTIONS = {
|
|
|
867
838
|
requester: actorOf() ?? ctx.scope.requester,
|
|
868
839
|
window_seconds: Math.max(1, Math.min(60, Number(ctx.entry.params.request_batch_seconds) || 10)),
|
|
869
840
|
} : null;
|
|
841
|
+
const observation = ctx.scope.event?.ci;
|
|
842
|
+
if (batch && observation?.required && !observation.ready) text = `${String(text ?? '')}\nSource evidence could not be verified: ${observation.reason ?? 'unavailable'}. The decision request is retained.`;
|
|
870
843
|
record(ctx.db, ctx.id, 'board_message', { to: String(to), text: batch ? String(text ?? '') : `${String(text ?? '')}\n\nAnswer with reply_to ${id}.`, message_id: id, ...(batch ? { batch } : {}) }, null);
|
|
871
844
|
ctx.effects.push({ kind: 'board_message', id: ctx.id });
|
|
872
845
|
},
|
|
@@ -1025,15 +998,21 @@ function settleRelations(db, board, entry, id, effects) {
|
|
|
1025
998
|
if (!json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []).some((b) => b.id === block.id)) continue;
|
|
1026
999
|
const dependency = block.kind === 'card' ? one(db, 'SELECT * FROM tasks WHERE id = ?', block.id) : null;
|
|
1027
1000
|
const status = dependency ? statusOf(db, dependency) : null;
|
|
1028
|
-
|
|
1001
|
+
// an answer is handed to the workflow once; the block stays until a verb lifts it
|
|
1002
|
+
const answer = block.kind === 'message' && !block.answered ? answerTo(block.id) : null;
|
|
1029
1003
|
const policy = entry.workflow.relations.blocked_by;
|
|
1030
1004
|
const name = answer ? policy.answered : status === 'done' ? policy.completed : status === 'cancelled' ? policy.cancelled : null;
|
|
1005
|
+
if (answer) {
|
|
1006
|
+
const current = json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []);
|
|
1007
|
+
run(db, 'UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?', JSON.stringify(current.map((b) => b.id === block.id ? { ...b, answered: answer } : b)), id);
|
|
1008
|
+
}
|
|
1031
1009
|
if (name) effects.push(...fire(db, board, entry, id, name, { roles: ['platform'], payload: { on: block.id, reason: answer ? `reply ${answer}` : `${block.id} ${status}` } }).effects);
|
|
1032
|
-
|
|
1010
|
+
// a block that has aged is told once, not every age interval for as long as it lasts
|
|
1011
|
+
else if (policy.aged && policy.age_seconds != null && !block.aged_at) {
|
|
1033
1012
|
const seconds = Number(evaluate(String(policy.age_seconds), { params: entry.params }));
|
|
1034
1013
|
if (seconds > 0 && now() - Number(block.notified_at ?? block.since ?? now()) >= seconds) {
|
|
1035
1014
|
const current = json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []);
|
|
1036
|
-
run(db, 'UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?', JSON.stringify(current.map((b) => b.id === block.id ? { ...b,
|
|
1015
|
+
run(db, 'UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?', JSON.stringify(current.map((b) => b.id === block.id ? { ...b, aged_at: now() } : b)), id);
|
|
1037
1016
|
effects.push(...fire(db, board, entry, id, policy.aged, { roles: ['platform'], payload: { on: block.id, reason: block.reason } }).effects);
|
|
1038
1017
|
}
|
|
1039
1018
|
}
|
|
@@ -1041,13 +1020,19 @@ function settleRelations(db, board, entry, id, effects) {
|
|
|
1041
1020
|
}
|
|
1042
1021
|
|
|
1043
1022
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1023
|
+
/** A card's workflow status and the status Hermes's `tasks.status` stores it as, read without writing. */
|
|
1024
|
+
export function storedStatus(db, board, entry, id) {
|
|
1046
1025
|
const task = cardOf(db, id);
|
|
1047
1026
|
const status = statusOf(db, task);
|
|
1027
|
+
if (!entry.workflow.stored_as?.length) return { status, stored: task.status };
|
|
1048
1028
|
const scope = scopeOf(db, board, task, { params: entry.params });
|
|
1049
1029
|
const rule = entry.workflow.stored_as.find((r) => holds(r.guard, scope));
|
|
1050
|
-
|
|
1030
|
+
return { status, stored: rule?.status ?? storedAs(entry.workflow, status) };
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
export function projectStatus(db, board, entry, id) {
|
|
1034
|
+
if (!entry.workflow.stored_as?.length) return;
|
|
1035
|
+
const { status, stored } = storedStatus(db, board, entry, id);
|
|
1051
1036
|
run(db, 'UPDATE tasks SET status = ? WHERE id = ?', stored, id);
|
|
1052
1037
|
run(db, 'INSERT INTO supercode_cards (task_id, stage) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET stage = excluded.stage', id, status);
|
|
1053
1038
|
}
|
package/board/workspace.mjs
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
// Filesystem/RPC work happens before a board transaction; the ownership check is pure.
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { basename, dirname } from 'node:path';
|
|
3
5
|
import { Workspaces, inspectCheckout, repositoryId, teamsHome } from '@volter/supercode-teams/workspaces';
|
|
4
6
|
import { localMachine, normalMachine, supercodeBin, commandEnv } from './dispatch.mjs';
|
|
5
7
|
export const isWorkspacePath = value => /^(\/|~|[A-Za-z]:[\\/])/.test(value ?? '');
|
|
8
|
+
// The dispatcher's latest presence reading (observeFleet): a machine it reads as offline is not asked.
|
|
9
|
+
let presence = null;
|
|
10
|
+
export const notePresence = (reading) => { presence = reading; };
|
|
6
11
|
function workspaceDoor(machine, verb, value, options = {}) {
|
|
12
|
+
if (machine && presence?.known === true && normalMachine(machine) !== localMachine() && !presence.online.has(normalMachine(machine))) {
|
|
13
|
+
throw new Error(`workspace on ${machine}: machine ${normalMachine(machine)} is offline`);
|
|
14
|
+
}
|
|
7
15
|
if (!machine || normalMachine(machine) === localMachine()) {
|
|
8
16
|
const map = new Workspaces(teamsHome());
|
|
9
17
|
if (verb === 'inspect') return inspectCheckout(value);
|
|
@@ -28,6 +36,17 @@ export function declareWorkspace(kind, value, machine, branch = null) {
|
|
|
28
36
|
const row = workspaceDoor(machine, 'register', value);
|
|
29
37
|
return { repository: row.repository, branch: branch ?? (kind === 'dir' ? row.branch : null), path: kind === 'dir' ? row.path : null };
|
|
30
38
|
}
|
|
39
|
+
/** The checkouts this machine keeps of any repository on `branch`: where an arc changing several repositories
|
|
40
|
+
* keeps its work in each of them (its own worktree aside). Another machine's map is not read from here. */
|
|
41
|
+
export function branchCheckouts(machine, branch) {
|
|
42
|
+
if (!branch || (machine && normalMachine(machine) !== localMachine())) return [];
|
|
43
|
+
return new Workspaces(teamsHome()).list().filter((row) => (branch === '*' || row.branch === branch) && existsSync(row.path));
|
|
44
|
+
}
|
|
45
|
+
/** The registered checkout of `repository` on `branch` on `machine`: where a card changing several repositories
|
|
46
|
+
* keeps its work in each of them. */
|
|
47
|
+
export function checkoutOf(machine, repository, branch) {
|
|
48
|
+
return workspaceDoor(machine, 'resolve', repositoryId(repository), { branch });
|
|
49
|
+
}
|
|
31
50
|
export function resolveWorkspace(task, create = false) {
|
|
32
51
|
const declared = task.workspace_anchor ?? task.workspace_path;
|
|
33
52
|
if (isWorkspacePath(declared)) {
|
|
@@ -36,6 +55,19 @@ export function resolveWorkspace(task, create = false) {
|
|
|
36
55
|
if (info.linked) throw new Error('worktree declaration is already a linked worktree; name its repository instead');
|
|
37
56
|
throw new Error('legacy worktree path needs registration and a repository declaration before launch');
|
|
38
57
|
}
|
|
58
|
+
// A primary checkout can change when another clone is registered. That does
|
|
59
|
+
// not move an already allocated card worktree. Machine moves and workspace
|
|
60
|
+
// retargets clear this runtime receipt; never borrow an arbitrary checkout.
|
|
61
|
+
if (task.workspace_kind === 'worktree' && task.workspace_path &&
|
|
62
|
+
basename(task.workspace_path) === task.id && basename(dirname(task.workspace_path)) === '.worktrees' &&
|
|
63
|
+
(!isLocalWorkspace(task) || existsSync(task.workspace_path))) {
|
|
64
|
+
const cached = workspaceDoor(task.machine, 'inspect', task.workspace_path);
|
|
65
|
+
if (cached.linked && basename(cached.path) === task.id && basename(dirname(cached.path)) === '.worktrees' &&
|
|
66
|
+
cached.repository === repositoryId(declared) && cached.branch === (task.branch_name ?? `wt/${task.id}`)) {
|
|
67
|
+
return { ...cached, root: dirname(dirname(cached.path)) };
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`card worktree ${task.workspace_path} does not match ${declared}, branch ${task.branch_name ?? `wt/${task.id}`}; retarget its workspace explicitly`);
|
|
70
|
+
}
|
|
39
71
|
return workspaceDoor(task.machine, 'resolve', declared, { branch: task.branch_name, solo: task.workspace_kind === 'worktree', card: task.id, create });
|
|
40
72
|
}
|
|
41
73
|
export function checkWorkspace(board, task) {
|