@volter/supercode-orchestrator 0.4.147 → 0.4.149
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 +177 -108
- package/board/cli.mjs +58 -35
- package/board/dispatch.mjs +169 -43
- package/board/engine.mjs +130 -30
- package/board/notices.mjs +5 -0
- package/board/request-mail.mjs +59 -0
- package/board/store.mjs +6 -3
- package/board/tools.mjs +15 -9
- package/board/workflow.mjs +40 -32
- package/board/workspace.mjs +26 -0
- package/board/ztrack.mjs +781 -544
- package/hermes-locks.mjs +1 -20
- package/mcp/hermes-kanban-tools.json +76 -14
- package/package.json +2 -2
- package/board/rows.mjs +0 -132
package/board/engine.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { join, resolve } from 'node:path';
|
|
|
13
13
|
import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace } from './workspace.mjs';
|
|
14
14
|
import { DatabaseSync } from 'node:sqlite';
|
|
15
15
|
import { view, actorOf, claimer, event, json, newTaskId, now, tx } from './store.mjs';
|
|
16
|
-
import { observeCompletionEvidence } from './ci.mjs';
|
|
16
|
+
import { completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
|
|
17
17
|
import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
|
|
18
18
|
|
|
19
19
|
export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived'];
|
|
@@ -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,10 +210,6 @@ 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();
|
|
@@ -216,13 +238,13 @@ export function createCard(board, spec) {
|
|
|
216
238
|
spec.skills?.length ? JSON.stringify(spec.skills) : null, spec.maxRetries ?? null, spec.model ?? null, spec.provider ?? null,
|
|
217
239
|
spec.reasoning ?? null, spec.sessionId ?? null, spec.project ?? null, spec.goal ? 1 : 0, spec.goal?.maxTurns ?? null);
|
|
218
240
|
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
|
|
241
|
+
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
242
|
for (const p of parents) addDependency(db, entry, id, p);
|
|
221
243
|
// where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
|
|
222
244
|
// the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
|
|
223
245
|
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,
|
|
246
|
+
every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
|
|
247
|
+
event(db, id, 'created', { creator: actorOf(), assignee: spec.assignee || null, status, parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
|
|
226
248
|
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
249
|
if (parents.length) inheritSubscriptions(db, id, parents);
|
|
228
250
|
if (arc) {
|
|
@@ -232,7 +254,9 @@ export function createCard(board, spec) {
|
|
|
232
254
|
}
|
|
233
255
|
if (initial === 'running' && arc && entry.workflow.run) setStatus(db, entry.workflow, id, 'running');
|
|
234
256
|
else if (initial === 'running') claimIn(db, id, { ttl: claimTtl() });
|
|
235
|
-
|
|
257
|
+
// the body's task lines, as subtasks the creator filed, in this same write
|
|
258
|
+
const subtasks = steps.map((step) => createCard(board, { ...step, subtaskOf: id, assignee: spec.assignee || null }).id);
|
|
259
|
+
return { id, existing: false, ...(subtasks.length ? { subtasks } : {}) };
|
|
236
260
|
});
|
|
237
261
|
}
|
|
238
262
|
|
|
@@ -343,11 +367,16 @@ export function setCaller(fn) { callerRoles = fn; }
|
|
|
343
367
|
|
|
344
368
|
export async function act(board, id, name, { roles = null, payload = {}, extra = {} } = {}) {
|
|
345
369
|
const entry = await loadWorkflow(board.root);
|
|
370
|
+
if (name === 'comment' && (!String(payload.body ?? '').trim() || !String(payload.author ?? '').trim())) throw new Error('a comment has an author and body');
|
|
346
371
|
refreshCardInputs(board, id, { commits: ['complete', 'request_review'].includes(name) });
|
|
347
|
-
|
|
372
|
+
const evidencePhase = ['complete', 'request_review', 'request_state'].includes(name) ? completionPhase(board, id, entry, name, payload, roles ?? callerRoles(board.db, id)) : null;
|
|
373
|
+
if (evidencePhase) payload = { ...payload, ci: observeCompletionEvidence(board, id, entry, payload, evidencePhase) };
|
|
374
|
+
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.');
|
|
348
375
|
const result = tx(board, (db) => {
|
|
349
376
|
roles ??= callerRoles(db, id);
|
|
350
377
|
const r = fire(db, board, entry, id, name, { roles, payload, extra });
|
|
378
|
+
// Authorization and the comment row share one transaction: a refusal stores nothing.
|
|
379
|
+
if (name === 'comment') r.commentId = Number(run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', id, payload.author.trim(), payload.body.trim(), now()).lastInsertRowid);
|
|
351
380
|
// the cards that wait on it, and its subtasks, see it change
|
|
352
381
|
const related = all(db, 'SELECT task_id, blocked_by FROM supercode_cards').filter((c) => json(c.blocked_by, []).some((b) => b.id === id)).map((c) => c.task_id);
|
|
353
382
|
const around = [...related, ...childrenOf(db, id), ...all(db, 'SELECT task_id AS id FROM supercode_cards WHERE subtask_of = ?', id).map((x) => x.id)];
|
|
@@ -355,7 +384,34 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
|
|
|
355
384
|
return r;
|
|
356
385
|
});
|
|
357
386
|
await runEffects(board, result.effects);
|
|
358
|
-
return { from: result.from, to: result.to, moved: result.moved };
|
|
387
|
+
return { from: result.from, to: result.to, moved: result.moved, ...(result.commentId ? { commentId: result.commentId } : {}) };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export async function requestStateMany(board, ids, payload = {}) {
|
|
391
|
+
const targets = [...new Set(ids.map(String))];
|
|
392
|
+
if (!targets.length) throw new Error('A state request names at least one card.');
|
|
393
|
+
const entry = await loadWorkflow(board.root);
|
|
394
|
+
const prepared = new Map(targets.map(id => {
|
|
395
|
+
const roles = callerRoles(board.db, id);
|
|
396
|
+
const phase = completionPhase(board, id, entry, 'request_state', payload, roles);
|
|
397
|
+
const evidence = inspectCompletionEvidence(board, id, entry, payload, phase);
|
|
398
|
+
// An ordinary request mails a decision to the manager, not a completion.
|
|
399
|
+
// Keep a failed source observation in that mail. A reviewer recording an
|
|
400
|
+
// actual verdict still has to verify the requested PR head.
|
|
401
|
+
if (phase === 'review_verdict' && evidence.required && !evidence.ready) throw new Error(evidence.reason ?? 'The reviewed PR head could not be verified.');
|
|
402
|
+
return [id, evidence];
|
|
403
|
+
}));
|
|
404
|
+
const result = tx(board, db => {
|
|
405
|
+
const effects = [];
|
|
406
|
+
const results = targets.map(id => withCompletionEvidence(board, id, prepared.get(id), () => {
|
|
407
|
+
const move = fire(db, board, entry, id, 'request_state', { roles: callerRoles(db, id), payload: { ...payload, ci: prepared.get(id) } });
|
|
408
|
+
effects.push(...move.effects);
|
|
409
|
+
return [id, move.to];
|
|
410
|
+
}));
|
|
411
|
+
return { results, effects };
|
|
412
|
+
});
|
|
413
|
+
await runEffects(board, result.effects);
|
|
414
|
+
return result.results;
|
|
359
415
|
}
|
|
360
416
|
|
|
361
417
|
/** Take card `id` to `status` by the event the workflow says goes there for this caller (`goto`). */
|
|
@@ -377,7 +433,7 @@ export async function goto(board, id, status, { roles = null, payload = {} } = {
|
|
|
377
433
|
export async function runEffects(board, effects = [], { retryNotices = false } = {}) {
|
|
378
434
|
const requested = new Set(effects.map((e) => e.durableId).filter(Boolean));
|
|
379
435
|
const pending = board.db.prepare('SELECT id, effect FROM supercode_effects ORDER BY id').all()
|
|
380
|
-
.filter((p) => retryNotices || requested.has(p.id)
|
|
436
|
+
.filter((p) => retryNotices || requested.has(p.id));
|
|
381
437
|
const durable = new Set(pending.map((p) => p.id));
|
|
382
438
|
const combined = [...pending.map((p) => ({ ...json(p.effect, {}), durableId: p.id })), ...effects.filter((e) => !e.durableId || !durable.has(e.durableId))];
|
|
383
439
|
combined.sort((a,b) => Number(b.kind === 'session_notice') - Number(a.kind === 'session_notice'));
|
|
@@ -453,14 +509,14 @@ function invalidateDescendants(db, board, id) {
|
|
|
453
509
|
|
|
454
510
|
// each of Hermes's verbs is an event of the workflow; the words it takes are the event's payload
|
|
455
511
|
|
|
456
|
-
export const complete = (board, id, { result = null, summary = null, metadata = null, roles, runId = null, commit = null, ci_run = null } = {}) =>
|
|
457
|
-
act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId, commit, ci_run } });
|
|
512
|
+
export const complete = (board, id, { result = null, summary = null, metadata = null, roles, runId = null, commit = null, ci_run = null, branch = null, pr = null, pr_head = null, reviewed_sha = null } = {}) =>
|
|
513
|
+
act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId, commit, ci_run, branch, pr, pr_head, reviewed_sha } });
|
|
458
514
|
// a block names the one thing that ends it: a card it waits on (`on`), or the message whose answer it waits for (`waiting_on`)
|
|
459
515
|
export const block = (board, id, { reason = null, on = null, waitingOn = null, roles, runId = null } = {}) =>
|
|
460
516
|
act(board, id, 'block', { roles, payload: { reason, on, waiting_on: waitingOn, run_id: runId } });
|
|
461
517
|
export const unblock = (board, id, { reason = null, on = null, roles } = {}) => act(board, id, 'unblock', { roles, payload: { reason, on } });
|
|
462
|
-
export const requestReview = (board, id, { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, ci_run = null } = {}) =>
|
|
463
|
-
act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId, commit, ci_run } });
|
|
518
|
+
export const requestReview = (board, id, { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, ci_run = null, branch = null, pr = null, pr_head = null } = {}) =>
|
|
519
|
+
act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId, commit, ci_run, branch, pr, pr_head } });
|
|
464
520
|
export const requestChanges = (board, id, { reason = null, reviewer = null, roles } = {}) => act(board, id, 'request_changes', { roles, payload: { reason, reviewer } });
|
|
465
521
|
export const reopen = (board, id, { author = 'user', reason = null, roles } = {}) => act(board, id, 'reopen', { roles, payload: { author, reason } });
|
|
466
522
|
export const reopenReview = (board, id, { roles } = {}) => act(board, id, 'reopen_review', { roles });
|
|
@@ -468,16 +524,24 @@ export const schedule = (board, id, reason = null, { roles } = {}) => act(board,
|
|
|
468
524
|
export const promote = (board, id, { actor = 'user', reason = null, force = false, roles } = {}) => act(board, id, 'promote', { roles, payload: { actor, reason, force } });
|
|
469
525
|
export const reclaim = (board, id, reason = null, { roles } = {}) => act(board, id, 'reclaim', { roles, payload: { reason } });
|
|
470
526
|
export const archive = (board, id, { roles } = {}) => act(board, id, 'archive', { roles });
|
|
471
|
-
export function specify(board, id, { title = null, body = null, workspace = null, branch = null,
|
|
527
|
+
export async function specify(board, id, { title = null, body = null, workspace = null, branch = null, author = 'user', roles } = {}) {
|
|
472
528
|
let declaration = null;
|
|
473
|
-
if (workspace != null) {
|
|
474
|
-
const task = getTask(board.db, id)
|
|
529
|
+
if (workspace != null || branch != null) {
|
|
530
|
+
const task = getTask(board.db, id);
|
|
531
|
+
const word = workspace ?? `${task.workspace_kind}${task.workspace_anchor || task.workspace_path ? `:${task.workspace_anchor ?? task.workspace_path}` : ''}`;
|
|
532
|
+
const ws = parseWorkspace(word);
|
|
475
533
|
if (task.current_run_id) throw new Error('end the current run before changing its workspace');
|
|
534
|
+
if (branch != null && ws.kind === 'scratch') throw new Error('a scratch workspace has no repository branch');
|
|
476
535
|
declaration = { kind: ws.kind, ...declareWorkspace(ws.kind, ws.path, task.machine, branch) };
|
|
536
|
+
if (declaration.repository === task.workspace_anchor) declaration.branch ??= task.branch_name;
|
|
477
537
|
if (declaration.kind === 'worktree') declaration.branch ??= `wt/${id}`;
|
|
538
|
+
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;
|
|
478
539
|
}
|
|
479
|
-
|
|
480
|
-
|
|
540
|
+
// an arc's new task lines are filed as its subtasks once the specify itself is accepted
|
|
541
|
+
const steps = body != null && !one(board.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id = ?', id)?.subtask_of ? bodyTasks(body) : { body, tasks: [] };
|
|
542
|
+
const done = await act(board, id, 'specify', { roles, payload: { title, body: steps.body, author, ...(declaration ? { workspace: declaration } : {}) } });
|
|
543
|
+
for (const step of steps.tasks) createCard(board, { ...step, subtaskOf: id, assignee: getTask(board.db, id)?.assignee || null });
|
|
544
|
+
return done;
|
|
481
545
|
}
|
|
482
546
|
export const editResult = (board, id, { result, summary = null, metadata = undefined, roles } = {}) => act(board, id, 'edit', { roles, payload: { result, summary, metadata } });
|
|
483
547
|
export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
|
|
@@ -522,12 +586,8 @@ export async function unlink(board, parent, child, { roles } = {}) {
|
|
|
522
586
|
export async function comment(board, id, author, body, { roles } = {}) {
|
|
523
587
|
if (!String(body ?? '').trim()) throw new Error('a comment has a body');
|
|
524
588
|
if (!String(author ?? '').trim()) throw new Error('a comment has an author');
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
return Number(run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', id, author.trim(), body.trim(), now()).lastInsertRowid);
|
|
528
|
-
});
|
|
529
|
-
await act(board, id, 'comment', { roles, payload: { author: author.trim(), body } });
|
|
530
|
-
return commentId;
|
|
589
|
+
const result = await act(board, id, 'comment', { roles, payload: { author: author.trim(), body } });
|
|
590
|
+
return result.commentId;
|
|
531
591
|
}
|
|
532
592
|
|
|
533
593
|
// ------------------------------------------------------------------ subscriptions
|
|
@@ -550,6 +610,47 @@ export function unsubscribe(board, id, address) {
|
|
|
550
610
|
return tx(board, (db) => run(db, "DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?", id, address).changes > 0);
|
|
551
611
|
}
|
|
552
612
|
|
|
613
|
+
function dependencyRecipient(db, id) {
|
|
614
|
+
const owner = must(db, id);
|
|
615
|
+
if (owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) return null;
|
|
616
|
+
const session = currentRun(db, id)?.metadata?.supercode ?? lastSession(db, id);
|
|
617
|
+
return session?.forgotten || session?.close_confirmed_at ? null : session?.address ?? null;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export function subscribeDependency(board, ownerId, sourceId, { remove = false } = {}) {
|
|
621
|
+
return tx(board, db => {
|
|
622
|
+
const roles = callerRoles(db, ownerId);
|
|
623
|
+
if (!roles.some(role => ['implementer', 'reviewer', 'auditor', 'manager', 'owner'].includes(role))) throw new Error('A dependency subscription belongs to your working arc; use --for-card <your-card>.');
|
|
624
|
+
const owner = must(db, ownerId);
|
|
625
|
+
if (owner.subtask_of) throw new Error('The dependency subscription names its arc, not a subtask.');
|
|
626
|
+
must(db, sourceId);
|
|
627
|
+
const thread = `arc:${ownerId}`;
|
|
628
|
+
if (remove) return { removed: run(db, "DELETE FROM kanban_notify_subs WHERE task_id=? AND platform='supercode' AND thread_id=? AND json_extract(delivery_metadata,'$.dependent_card')=?", sourceId, thread, ownerId).changes > 0 };
|
|
629
|
+
const address = dependencyRecipient(db, ownerId);
|
|
630
|
+
if (!address) throw new Error('The subscribing arc has no current reachable session identity.');
|
|
631
|
+
const existing = one(db, "SELECT * FROM kanban_notify_subs WHERE task_id=? AND platform='supercode' AND thread_id=? AND json_extract(delivery_metadata,'$.dependent_card')=?", sourceId, thread, ownerId);
|
|
632
|
+
const cursor = existing?.last_event_id ?? one(db, 'SELECT COALESCE(MAX(id),0) AS cursor FROM task_events WHERE task_id=?', sourceId).cursor;
|
|
633
|
+
run(db, "DELETE FROM kanban_notify_subs WHERE task_id=? AND platform='supercode' AND thread_id=? AND json_extract(delivery_metadata,'$.dependent_card')=?", sourceId, thread, ownerId);
|
|
634
|
+
run(db, "INSERT INTO kanban_notify_subs(task_id,platform,chat_id,thread_id,chat_type,delivery_mode,delivery_metadata,created_at,last_event_id) VALUES (?,'supercode',?,?,'dm','notify+wake',?,?,?)",
|
|
635
|
+
sourceId, address, thread, JSON.stringify({ role: 'dependency', dependent_card: ownerId }), now(), cursor);
|
|
636
|
+
event(db, ownerId, 'dependency_subscribed', { source: sourceId, address });
|
|
637
|
+
return { address, source: sourceId, owner: ownerId };
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export function reconcileDependencySubscriptions(db) {
|
|
642
|
+
for (const row of all(db, "SELECT * FROM kanban_notify_subs WHERE platform='supercode' AND json_extract(delivery_metadata,'$.dependent_card') IS NOT NULL")) {
|
|
643
|
+
const metadata = json(row.delivery_metadata, {});
|
|
644
|
+
const owner = getTask(db, metadata.dependent_card);
|
|
645
|
+
const address = owner ? dependencyRecipient(db, owner.id) : null;
|
|
646
|
+
const where = "WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=?";
|
|
647
|
+
const identity = [row.task_id, row.chat_id, row.thread_id];
|
|
648
|
+
if (!owner || owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) run(db, `DELETE FROM kanban_notify_subs ${where}`, ...identity);
|
|
649
|
+
else if (address && (address !== row.chat_id || metadata.waiting_recipient)) run(db, `UPDATE kanban_notify_subs SET chat_id=?,delivery_metadata=? ${where}`, address, JSON.stringify({ ...metadata, waiting_recipient: false, failures: 0 }), ...identity);
|
|
650
|
+
else if (!address && !metadata.waiting_recipient) run(db, `UPDATE kanban_notify_subs SET delivery_metadata=? ${where}`, JSON.stringify({ ...metadata, waiting_recipient: true }), ...identity);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
553
654
|
/** A child hears what its parents' subscribers hear (Hermes copies them at link time); a subtask its arc's session too. */
|
|
554
655
|
function inheritSubscriptions(db, child, parents, { workers = false } = {}) {
|
|
555
656
|
const cursor = one(db, 'SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?', child).c;
|
|
@@ -597,7 +698,6 @@ export function cardContext(db, id) {
|
|
|
597
698
|
const task = must(db, id);
|
|
598
699
|
const out = [`# Card ${task.id}: ${task.title}`, ''];
|
|
599
700
|
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}.` : ''}`);
|
|
600
|
-
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.');
|
|
601
701
|
if (task.body) out.push('', cap(task.body, 8 * 1024));
|
|
602
702
|
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);
|
|
603
703
|
if (parents.length) {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export function identifyNotice(event, text) {
|
|
2
|
+
const time = new Date(Number(event.created_at) * 1000).toISOString();
|
|
3
|
+
const kind = String(event.kind).replaceAll('_', ' ');
|
|
4
|
+
return `New board update: ${kind}; run ${event.run_id ?? 'none'}; event ${event.id}; ${time}.\n${text}`;
|
|
5
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { event, json, now } from './store.mjs';
|
|
2
|
+
|
|
3
|
+
export function pendingRequestMail(db, taskId = null) {
|
|
4
|
+
const pending = db.prepare(`SELECT id, task_id, payload, created_at FROM task_events original
|
|
5
|
+
WHERE kind='board_message' AND NOT EXISTS (SELECT 1 FROM task_events sent WHERE sent.kind='board_message_sent'
|
|
6
|
+
AND sent.task_id=original.task_id AND json_extract(sent.payload,'$.event')=original.id) ORDER BY id`).all()
|
|
7
|
+
.map(row => ({ ...row, mail: json(row.payload, {}) ?? {} }));
|
|
8
|
+
if (!pending.length) return { ready: [], nextAt: Infinity };
|
|
9
|
+
const batches = db.prepare("SELECT payload FROM task_events WHERE kind='board_message_batch' AND id>=? ORDER BY id").all(pending[0].id)
|
|
10
|
+
.map(row => json(row.payload, {}));
|
|
11
|
+
const assigned = new Set();
|
|
12
|
+
const ready = [];
|
|
13
|
+
let nextAt = Infinity;
|
|
14
|
+
for (const envelope of batches) {
|
|
15
|
+
const remaining = pending.filter(row => envelope.members?.some(member => member.id === row.id));
|
|
16
|
+
if (!remaining.length) continue;
|
|
17
|
+
for (const row of remaining) assigned.add(row.id);
|
|
18
|
+
if (!taskId || envelope.members.some(member => member.task_id === taskId)) ready.push(envelope);
|
|
19
|
+
}
|
|
20
|
+
const groups = new Map();
|
|
21
|
+
for (const row of pending) {
|
|
22
|
+
if (assigned.has(row.id)) continue;
|
|
23
|
+
if (!row.mail.batch) {
|
|
24
|
+
if (!taskId || row.task_id === taskId) ready.push({ ...row.mail, members: [{ id: row.id, task_id: row.task_id }] });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const key = JSON.stringify([row.mail.to, row.mail.batch.requester]);
|
|
28
|
+
const groupsForRequester = groups.get(key) ?? [];
|
|
29
|
+
let group = groupsForRequester.at(-1);
|
|
30
|
+
if (!group || row.created_at > group[0].created_at + 60) {
|
|
31
|
+
group = [];
|
|
32
|
+
groupsForRequester.push(group);
|
|
33
|
+
groups.set(key, groupsForRequester);
|
|
34
|
+
}
|
|
35
|
+
group.push(row);
|
|
36
|
+
}
|
|
37
|
+
for (const groupsForRequester of groups.values()) for (const group of groupsForRequester) {
|
|
38
|
+
if (taskId && !group.some(row => row.task_id === taskId)) continue;
|
|
39
|
+
const first = group[0];
|
|
40
|
+
const last = group.at(-1);
|
|
41
|
+
const due = Math.min(first.created_at + 60, last.created_at + last.mail.batch.window_seconds);
|
|
42
|
+
if (now() < due) {
|
|
43
|
+
nextAt = Math.min(nextAt, due * 1000);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const envelope = {
|
|
47
|
+
to: first.mail.to,
|
|
48
|
+
message_id: first.mail.message_id,
|
|
49
|
+
requester: first.mail.batch.requester,
|
|
50
|
+
members: group.map(row => ({ id: row.id, task_id: row.task_id, message_id: row.mail.message_id })),
|
|
51
|
+
text: `State requests from ${first.mail.batch.requester}: ${group.length} request(s) for ${new Set(group.map(row => row.task_id)).size} card(s).\n\n` +
|
|
52
|
+
group.map(row => `${row.task_id} [${row.mail.message_id}]\n${row.mail.text}`).join('\n\n---\n\n') +
|
|
53
|
+
`\n\nAnswer with reply_to ${first.mail.message_id}.`,
|
|
54
|
+
};
|
|
55
|
+
event(db, first.task_id, 'board_message_batch', envelope);
|
|
56
|
+
ready.push(envelope);
|
|
57
|
+
}
|
|
58
|
+
return { ready, nextAt };
|
|
59
|
+
}
|
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
|
@@ -34,7 +34,8 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
34
34
|
const runId = env.HERMES_KANBAN_RUN_ID ? Number(env.HERMES_KANBAN_RUN_ID) : null;
|
|
35
35
|
const author = env.HERMES_PROFILE || 'worker';
|
|
36
36
|
if (own && ORCHESTRATOR_ONLY.has(name)) return error(`${name} is the orchestrator's; read kanban_show for your task's declared doors, or use kanban_request_state, kanban_heartbeat or kanban_comment`);
|
|
37
|
-
|
|
37
|
+
if (args.task_ids !== undefined && (!Array.isArray(args.task_ids) || !args.task_ids.length || args.task_ids.some(id => typeof id !== 'string' || !id.trim()))) return error('task_ids is a nonempty array of card ids.');
|
|
38
|
+
const tid = args.task_id || args.task_ids?.[0] || own;
|
|
38
39
|
if (!['kanban_list', 'kanban_create', 'kanban_link'].includes(name) && !tid) return error('task_id is required (or HERMES_KANBAN_TASK in the environment)');
|
|
39
40
|
const slug = args.board || env.HERMES_KANBAN_BOARD || DEFAULT_BOARD;
|
|
40
41
|
// the board's workflow decides what each tool's event does; the worker is its run's role on its own task, and
|
|
@@ -44,6 +45,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
44
45
|
if (!own || !runId) return own ? [] : ['manager'];
|
|
45
46
|
const meta = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(runId)?.metadata, {})?.supercode ?? {};
|
|
46
47
|
const roles = cardId === own ? [meta.role ?? 'implementer'] : [];
|
|
48
|
+
if (engine.getTask(db, cardId)?.subtask_of === own) roles.push('arc');
|
|
47
49
|
if (name === 'kanban_comment' && isLatestReviewer(db, cardId, actorOf()) && !roles.includes('reviewer')) roles.push('reviewer');
|
|
48
50
|
if (actorOf() && completionOwnerOf(db, cardId) === actorOf()) roles.push('creator');
|
|
49
51
|
return roles;
|
|
@@ -53,8 +55,12 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
53
55
|
board.label = slug;
|
|
54
56
|
const callingRun = runId ? json(board.db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(runId)?.metadata, {})?.supercode : null;
|
|
55
57
|
setActor(own && runId ? callingRun?.address ?? `hermes-run:${slug}:${runId}` : null);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
const targets = name === 'kanban_request_state' ? [...new Set([tid, ...(args.task_ids ?? [])])] : [tid];
|
|
59
|
+
if (own && LIFECYCLE.has(name)) for (const target of targets) {
|
|
60
|
+
const arcTask = ['kanban_complete', 'kanban_request_state'].includes(name) && engine.getTask(board.db, target)?.subtask_of === own;
|
|
61
|
+
if (target !== own && !arcTask && completionOwnerOf(board.db, target) !== actorOf()) {
|
|
62
|
+
board.close(); return error(`worker is scoped to task ${own}; refusing to change ${target}. Use kanban_comment to hand off to other tasks.`);
|
|
63
|
+
}
|
|
58
64
|
}
|
|
59
65
|
const current = () => tx(board, (db) => engine.getTask(db, tid));
|
|
60
66
|
// a worker whose run was superseded (reclaimed, re-dispatched) no longer speaks for the card
|
|
@@ -74,7 +80,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
74
80
|
return JSON.stringify({
|
|
75
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,
|
|
76
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,
|
|
77
|
-
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 },
|
|
78
84
|
parents: card.parents.map((p) => p.id), children: card.children,
|
|
79
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 })),
|
|
80
86
|
worker_context: card.context,
|
|
@@ -106,7 +112,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
106
112
|
}
|
|
107
113
|
}
|
|
108
114
|
if (env.HERMES_SESSION_ID && own === tid) metadata = { ...(metadata ?? {}), worker_session_id: env.HERMES_SESSION_ID };
|
|
109
|
-
await engine.complete(board, tid, { summary, result, metadata, commit: args.commit ?? null, ci_run: args.ci_run ?? null });
|
|
115
|
+
await engine.complete(board, tid, { summary, result, metadata, commit: args.commit ?? null, ci_run: args.ci_run ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? null, reviewed_sha: args.reviewed_sha ?? null });
|
|
110
116
|
return ok({ task_id: tid, status: 'done' });
|
|
111
117
|
}
|
|
112
118
|
case 'kanban_block': {
|
|
@@ -116,12 +122,12 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
116
122
|
}
|
|
117
123
|
case 'kanban_request_state': {
|
|
118
124
|
const stale = ownRun(); if (stale) return error(stale);
|
|
119
|
-
await engine.
|
|
120
|
-
return ok({ task_id: tid, requested: args.state, changed: false });
|
|
125
|
+
await engine.requestStateMany(board, targets, { state: args.state, reason: args.reason, on: args.on ?? null, waiting_on: args.waiting_on ?? null, evidence: args.evidence ?? null, commit: args.commit ?? null, ci_run: args.ci_run ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? null, reviewed_sha: args.reviewed_sha ?? null, deployed_sha: args.deployed_sha ?? null });
|
|
126
|
+
return ok({ task_id: tid, task_ids: targets, requested: args.state, changed: false });
|
|
121
127
|
}
|
|
122
128
|
case 'kanban_request_review': {
|
|
123
129
|
const stale = ownRun(); if (stale) return error(stale);
|
|
124
|
-
await engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, commit: args.commit ?? null, ci_run: args.ci_run ?? null, reviewer: args.reviewer ?? null, expectedRunId: own === tid && runId ? runId : null });
|
|
130
|
+
await engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, commit: args.commit ?? null, ci_run: args.ci_run ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? args.commit ?? null, reviewer: args.reviewer ?? null, expectedRunId: own === tid && runId ? runId : null });
|
|
125
131
|
return ok({ task_id: tid, status: 'review' });
|
|
126
132
|
}
|
|
127
133
|
case 'kanban_request_changes': {
|
|
@@ -170,7 +176,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
170
176
|
title: args.title, body: args.body ? redact(String(args.body)) : null, assignee: args.assignee ?? null, parents: [args.parents ?? []].flat(), tenant: args.tenant ?? null,
|
|
171
177
|
priority: args.priority ?? 0, workspace, project: args.project ?? null, triage: Boolean(args.triage), idempotencyKey: args.idempotency_key ?? null,
|
|
172
178
|
maxRuntime: args.max_runtime_seconds ?? null, initialStatus: args.initial_status ?? null, skills: args.skills ?? null, model: args.model ?? null, provider: args.provider ?? null,
|
|
173
|
-
goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author,
|
|
179
|
+
goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author,
|
|
174
180
|
});
|
|
175
181
|
return ok({ task_id: made.id, existing: made.existing, status: tx(board, (db) => engine.getTask(db, made.id)).status });
|
|
176
182
|
}
|