@volter/supercode-orchestrator 0.4.149 → 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 +34 -16
- package/board/cli.mjs +50 -41
- package/board/dispatch.mjs +101 -45
- package/board/engine.mjs +19 -7
- package/board/tools.mjs +4 -1
- package/board/workflow.mjs +10 -18
- package/board/workspace.mjs +6 -0
- package/board/ztrack.mjs +24 -1
- package/cron.mjs +8 -2
- package/loop.mjs +23 -2
- package/mcp/hermes-kanban-tools.json +7 -0
- package/package.json +1 -1
package/board/ci.mjs
CHANGED
|
@@ -66,14 +66,28 @@ function repositoryWorkspace(board, id) {
|
|
|
66
66
|
|
|
67
67
|
const pullRequestLists = new Map();
|
|
68
68
|
|
|
69
|
-
function branchCheckout(board, id, repository) {
|
|
70
|
-
const task = board.db.prepare('SELECT
|
|
71
|
-
try { return checkoutOf(task?.machine, repository,
|
|
69
|
+
function branchCheckout(board, id, repository, branch) {
|
|
70
|
+
const task = board.db.prepare('SELECT c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
|
|
71
|
+
try { return checkoutOf(task?.machine, repository, branch)?.path ?? null; } catch { return null; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The sibling checkouts an arc registered: each subtask (and the card itself) that declares a repository
|
|
75
|
+
* workspace and a branch, e.g. `create "<what>" --subtask-of <arc> --workspace worktree:<repository> --branch <name>`.
|
|
76
|
+
* Their PRs are the arc's whatever the branch is named. */
|
|
77
|
+
function registeredBranches(board, id) {
|
|
78
|
+
const rows = board.db.prepare(`SELECT t.branch_name, t.workspace_kind, c.workspace_anchor FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
|
|
79
|
+
WHERE (t.id=? OR c.subtask_of=?) AND t.status != 'cancelled' AND t.workspace_kind IN ('worktree','dir') AND t.branch_name IS NOT NULL`).all(id, id);
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const row of rows) {
|
|
82
|
+
if (!row.workspace_anchor || /^(\/|~|[A-Za-z]:[\\/])/.test(row.workspace_anchor)) continue;
|
|
83
|
+
out.push({ repository: row.workspace_anchor.replace(/^github\.com\//i, '').replace(/\.git$/, ''), branch: row.branch_name });
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
72
86
|
}
|
|
73
87
|
|
|
74
88
|
/** Every opened PR of the arc: each open PR whose head is the card's branch or a `wt/<card id>` branch, in the
|
|
75
|
-
* card's own repository and in every repository this machine keeps a checkout of
|
|
76
|
-
* the caller decides the transition. */
|
|
89
|
+
* card's own repository and in every repository this machine keeps a checkout of, and each open PR on a sibling
|
|
90
|
+
* checkout the arc registered (`registeredBranches`). One review covers them all; the caller decides the transition. */
|
|
77
91
|
export function openReviewPullRequests(board, id) {
|
|
78
92
|
const own = repositoryWorkspace(board, id);
|
|
79
93
|
if (!own) return null;
|
|
@@ -81,20 +95,23 @@ export function openReviewPullRequests(board, id) {
|
|
|
81
95
|
const branch = task?.branch_name ?? run('git', ['branch', '--show-current'], own);
|
|
82
96
|
const ownRepository = originOf(own);
|
|
83
97
|
if (!ownRepository) return null;
|
|
84
|
-
const
|
|
98
|
+
const registered = registeredBranches(board, id);
|
|
99
|
+
const mine = (repository, head) => head === branch || [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`].some((prefix) => head === prefix || head.startsWith(`${prefix}-`) || head.startsWith(`${prefix}/`))
|
|
100
|
+
|| registered.some((r) => r.repository === repository && r.branch === head);
|
|
85
101
|
const prs = [];
|
|
86
|
-
for (const repository of new Set([ownRepository, ...knownRepositories()])) {
|
|
102
|
+
for (const repository of new Set([ownRepository, ...registered.map((r) => r.repository), ...knownRepositories()])) {
|
|
87
103
|
// Discovery is shared per repository for one minute; each selected PR/head is re-read before it authorizes a move.
|
|
88
104
|
let list = pullRequestLists.get(repository);
|
|
89
105
|
if (!list || Date.now() - list.at >= 60_000) {
|
|
90
106
|
let items = [];
|
|
91
|
-
try { items = JSON.parse(run('gh', ['pr', 'list', '--repo', repository, '--state', 'open', '--limit', '1000', '--json', 'url,headRefOid,headRefName,baseRefName,isCrossRepository'])); }
|
|
92
|
-
catch (error) { if (repository === ownRepository) throw error; } // another repository unreachable here is not this arc's
|
|
107
|
+
try { items = JSON.parse(run('gh', ['pr', 'list', '--repo', repository, '--state', 'open', '--limit', '1000', '--json', 'url,headRefOid,headRefName,baseRefName,isCrossRepository,isDraft'])); }
|
|
108
|
+
catch (error) { if (repository === ownRepository || registered.some((r) => r.repository === repository)) throw error; } // another repository unreachable here is not this arc's
|
|
93
109
|
list = { at: Date.now(), items };
|
|
94
110
|
if (pullRequestLists.size >= 64) pullRequestLists.delete(pullRequestLists.keys().next().value);
|
|
95
111
|
pullRequestLists.set(repository, list);
|
|
96
112
|
}
|
|
97
|
-
|
|
113
|
+
// a draft is not handed over yet: its review starts when it is marked ready
|
|
114
|
+
for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && mine(repository, pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid });
|
|
98
115
|
}
|
|
99
116
|
if (!prs.length) return null;
|
|
100
117
|
return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch };
|
|
@@ -192,11 +209,12 @@ function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
|
|
|
192
209
|
if (typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url)) throw new Error('Name the opened PR URL with --pr.');
|
|
193
210
|
const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
|
|
194
211
|
// An arc may change several repositories. A PR is read from GitHub; its pushed head is the reviewed one. The
|
|
195
|
-
// card's own checkout, and this machine's checkout of
|
|
196
|
-
//
|
|
197
|
-
const
|
|
198
|
-
const
|
|
212
|
+
// card's own checkout, and this machine's checkout of the PR's branch when it keeps one, must also hold that head
|
|
213
|
+
// with nothing uncommitted.
|
|
214
|
+
const observed = JSON.parse(run('gh', ['pr', 'view', url, '--repo', repository, '--json', 'url,number,state,headRefOid,headRefName,baseRefName,isCrossRepository,isDraft']));
|
|
215
|
+
const path = originOf(own) === repository && run('git', ['branch', '--show-current'], own) === observed.headRefName ? own : branchCheckout(board, id, repository, observed.headRefName);
|
|
199
216
|
if (observed.state !== 'OPEN' || observed.isCrossRepository) throw new Error(`Source review needs an open PR from its repository (${url}).`);
|
|
217
|
+
if (observed.isDraft) throw new Error(`${url} is a draft; its review starts when it is marked ready for review.`);
|
|
200
218
|
const head = supplied ?? observed.headRefOid;
|
|
201
219
|
if (!/^[a-f0-9]{40}$/.test(head)) throw new Error('Name the full PR head commit with --pr-head (reviewer: --reviewed-sha).');
|
|
202
220
|
if (observed.headRefOid !== head) throw new Error(`The head of ${url} changed to ${observed.headRefOid}; request review of that head before recording a verdict.`);
|
|
@@ -206,8 +224,8 @@ function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
|
|
|
206
224
|
if (stage === 'request_review' && path) {
|
|
207
225
|
run('git', ['check-ref-format', '--branch', branch], path);
|
|
208
226
|
if (run('git', ['rev-parse', 'HEAD'], path) !== head) throw new Error(`The head named for ${url} must be its worktree head.`);
|
|
209
|
-
run('git', ['
|
|
210
|
-
if (
|
|
227
|
+
const uncommitted = run('git', ['status', '--porcelain', '--untracked-files=normal'], path).split('\n').filter(Boolean);
|
|
228
|
+
if (uncommitted.length) throw new Error(`Commit the work in ${path} before requesting review: ${uncommitted.length} uncommitted path(s): ${uncommitted.slice(0, 5).map((l) => l.replace(/^\s*\S+\s+/, '')).join(', ')}${uncommitted.length > 5 ? ', …' : ''}`);
|
|
211
229
|
const pushed = run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path).split(/\s+/)[0];
|
|
212
230
|
if (pushed !== head) throw new Error(`Push the exact head of ${url} before requesting review.`);
|
|
213
231
|
}
|
package/board/cli.mjs
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { spawnSync } from 'node:child_process';
|
|
9
9
|
import { basename, join, resolve } from 'node:path';
|
|
10
|
-
import { tmpdir } from 'node:os';
|
|
10
|
+
import { tmpdir, userInfo } from 'node:os';
|
|
11
11
|
import { read } from '../orchestration.mjs';
|
|
12
12
|
import { actorOf, view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
|
|
13
13
|
import { isLocalWorkspace } from './workspace.mjs';
|
|
14
14
|
import { openZtrackBoard } from './ztrack.mjs';
|
|
15
15
|
import * as engine from './engine.mjs';
|
|
16
|
-
import {
|
|
16
|
+
import { requestAdoption, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, supercodeBin, tick } from './dispatch.mjs';
|
|
17
17
|
import { watchBoard } from './wake.mjs';
|
|
18
18
|
import { completionOwnerOf, isLatestReviewer, loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
|
|
19
19
|
import { evaluate } from './expr.mjs';
|
|
@@ -29,14 +29,14 @@ Hermes Kanban's verbs, with their flags:
|
|
|
29
29
|
[--model] [--provider] [--goal|--no-goal] [--goal-max-turns] [--initial-status blocked|running]
|
|
30
30
|
swarm <goal> --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
|
|
31
31
|
list [--mine] [--assignee] [--status] [--tenant] [--session] [--archived] [--sort] show <id> context <id> runs <id>
|
|
32
|
-
|
|
32
|
+
set <id> <assignee|machine|model|every> <value|none> [--provider] [--ids ...] assign <id> <profile|none> set-model <id> [model] [--provider] set-goal <id>... [--goal-max-turns N] [--off]
|
|
33
33
|
claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] [--until <time|30m>] link|unlink <parent> <child>
|
|
34
34
|
comment <id> <text> [--author] [--max-len] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
|
|
35
35
|
complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
|
|
36
36
|
block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
|
|
37
|
-
pause <id> --reason <why> resume <id> cancel <id>
|
|
37
|
+
pause <id> --reason <why> resume <id> cancel <id>
|
|
38
38
|
request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
|
|
39
|
-
unblock <id>...
|
|
39
|
+
unblock <id>... --on <blocker> --reason <what arrived> request-review <id> (--pr URL --pr-head SHA)... [--summary] [--reviewer] [--force] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... reopen <id>... [--reason]
|
|
40
40
|
promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... [--rm <id>...]
|
|
41
41
|
specify <id> [--title --body] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--all]
|
|
42
42
|
decompose <id> [--child "<title>[@<profile>]"]... [--all]
|
|
@@ -71,7 +71,7 @@ supercode's own:
|
|
|
71
71
|
workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
|
|
72
72
|
`;
|
|
73
73
|
|
|
74
|
-
const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'pr', 'pr-head', 'reviewed-sha']);
|
|
74
|
+
const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'pr', 'pr-head', 'reviewed-sha', 'finding']);
|
|
75
75
|
const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'reclaim', 'dry-run',
|
|
76
76
|
'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose']);
|
|
77
77
|
// flags that take every following word up to the next flag (Hermes's nargs='+')
|
|
@@ -264,8 +264,8 @@ export async function runWorkflow(argv) {
|
|
|
264
264
|
if (verb === 'comment' && isLatestReviewer(db, cardId, actorOf()) && !roles.includes('reviewer')) roles.push('reviewer');
|
|
265
265
|
return roles;
|
|
266
266
|
});
|
|
267
|
-
// a verb with no session behind it
|
|
268
|
-
if (!sid && verb !== 'help')
|
|
267
|
+
// a verb with no session behind it is recorded as the OS user's; it holds no role on any card
|
|
268
|
+
if (!sid && verb !== 'help') setActor(`user:${userInfo().username}`);
|
|
269
269
|
|
|
270
270
|
switch (verb) {
|
|
271
271
|
case 'migrate': {
|
|
@@ -314,9 +314,8 @@ export async function runWorkflow(argv) {
|
|
|
314
314
|
let answer = { id: created.id, existing: created.existing };
|
|
315
315
|
if (args.adopt) {
|
|
316
316
|
withBoard(b => { if (!rolesFor(b.db, created.id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
answer = { ...answer, started: true, session: adopted.address, mode: adopted.mode };
|
|
317
|
+
withBoard(b => requestAdoption(b, created.id, args.adopt));
|
|
318
|
+
answer = { ...answer, adoption_requested: args.adopt };
|
|
320
319
|
}
|
|
321
320
|
// Starting a card is the dispatcher's: this write's wake hint reaches it, and the card's
|
|
322
321
|
// thread records the start. A create never runs a dispatch tick inline.
|
|
@@ -326,7 +325,7 @@ export async function runWorkflow(argv) {
|
|
|
326
325
|
const task = withBoard((b) => view(b, (db) => engine.getTask(db, created.id)));
|
|
327
326
|
const recipients = withBoard(b => engine.subscriptions(b.db, created.id)).filter(s => s.platform === 'supercode').map(s => s.chat_id);
|
|
328
327
|
if (args.json) { say(args, '', { ...cardDict(task), ...answer, notify: address ?? null, recipients }); return answer.failed ? 1 : 0; }
|
|
329
|
-
const said = answer.started ? `; started session ${answer.session} (${answer.mode})` : answer.failed ? `; did not start: ${answer.failed}` : answer.waits ? `; waits: ${answer.waits}` : '';
|
|
328
|
+
const said = answer.adoption_requested ? `; held for ${answer.adoption_requested}, which the dispatcher binds on its next tick` : answer.started ? `; started session ${answer.session} (${answer.mode})` : answer.failed ? `; did not start: ${answer.failed}` : answer.waits ? `; waits: ${answer.waits}` : '';
|
|
330
329
|
say(args, `${created.existing ? 'Existing' : 'Created'} ${created.id} (${task.status}, assignee=${task.assignee || '-'})${said}${recipients.length ? `; notices go to ${recipients.join(', ')}` : ''}`, null);
|
|
331
330
|
return answer.failed ? 1 : 0;
|
|
332
331
|
}
|
|
@@ -466,17 +465,18 @@ export async function runWorkflow(argv) {
|
|
|
466
465
|
return 0;
|
|
467
466
|
}
|
|
468
467
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
});
|
|
477
|
-
say(args,
|
|
468
|
+
// One door sets a card's properties; the workflow's `set` rules say who may and what follows (a live run
|
|
469
|
+
// whose session must change is ended, and the dispatcher starts the next itself). `assign`/`reassign` are
|
|
470
|
+
// Hermes's names for setting the assignee, and `set-model` its name for setting the model.
|
|
471
|
+
case 'set': {
|
|
472
|
+
need(3, 'a card id, a property (assignee, machine, model or every) and its value (or none)');
|
|
473
|
+
const value = rest[2] === 'none' ? null : rest[2];
|
|
474
|
+
const cards = ids([id]);
|
|
475
|
+
for (const t of cards) await withBoardAsync((b) => engine.set(b, t, rest[1], value, { provider: args.provider ?? null }));
|
|
476
|
+
say(args, cards.map((t) => `${t}: ${rest[1]} ${value ?? '(none)'}`).join('\n'), { ok: true }); return 0;
|
|
478
477
|
}
|
|
479
|
-
case 'set-model': need(1, 'a card id'); await withBoardAsync((b) => engine.
|
|
478
|
+
case 'set-model': need(1, 'a card id'); await withBoardAsync((b) => engine.set(b, id, 'model', rest[1] ?? null, { provider: args.provider ?? null })); say(args, rest[1] ? `${id} runs on model ${rest[1]}${args.provider ? ` (${args.provider})` : ''} from its next start` : `${id} uses its profile's model`, { ok: true }); return 0;
|
|
479
|
+
case 'assign': case 'reassign': need(2, 'a card id and a profile (or none)'); await withBoardAsync((b) => engine.set(b, id, 'assignee', rest[1] === 'none' ? null : rest[1])); say(args, `Assigned ${id} to ${rest[1] === 'none' ? '(nobody)' : rest[1]}`, { ok: true }); return 0;
|
|
480
480
|
case 'set-goal': {
|
|
481
481
|
// a card into goal mode (or out of it with --off) from its next idle turn: the heartbeat keeps its session going
|
|
482
482
|
need(1, 'a card id');
|
|
@@ -485,7 +485,6 @@ export async function runWorkflow(argv) {
|
|
|
485
485
|
for (const t of ids()) await withBoardAsync((b) => engine.setGoal(b, t, { on: !args.off, maxTurns }));
|
|
486
486
|
say(args, ids().map((t) => args.off ? `${t} is no longer a goal card` : `${t} is a goal card${maxTurns ? ` (${maxTurns} turns)` : ''}`).join('\n'), { ok: true }); return 0;
|
|
487
487
|
}
|
|
488
|
-
case 'move': need(2, 'a card id and a machine (or none)'); await withBoardAsync((b) => engine.setMachine(b, id, rest[1] === 'none' ? null : rest[1])); say(args, `${id} runs on ${rest[1]}`, { ok: true }); return 0;
|
|
489
488
|
|
|
490
489
|
case 'claim': {
|
|
491
490
|
need(1, 'a card id');
|
|
@@ -571,13 +570,21 @@ export async function runWorkflow(argv) {
|
|
|
571
570
|
case 'unblock': {
|
|
572
571
|
need(1, 'a card id');
|
|
573
572
|
const results = [];
|
|
574
|
-
for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.unblock(b, t, { reason: args.reason ?? null, on: args.on ?? null }))).to]);
|
|
573
|
+
for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.unblock(b, t, { reason: args.reason ?? null, on: args.on ?? args['waiting-on'] ?? null }))).to]);
|
|
575
574
|
// Hermes records the reason as a comment on each card it unblocks
|
|
576
575
|
if (args.reason) for (const [t] of results) await withBoardAsync((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
|
|
577
576
|
say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
578
577
|
}
|
|
579
578
|
case 'request-review': need(1, 'a card id'); await withBoardAsync((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(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.head ?? args.commit ?? null, force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
|
|
580
|
-
case 'request-changes':
|
|
579
|
+
case 'request-changes': {
|
|
580
|
+
need(2, 'a card id and the reason');
|
|
581
|
+
// each finding is filed as a task on the arc first, so the arc stays open until a pushed head fixes it
|
|
582
|
+
const findings = [].concat(args.finding ?? []).map((f) => String(f).trim()).filter(Boolean);
|
|
583
|
+
const filed = [];
|
|
584
|
+
for (const finding of findings) filed.push((await withBoardAsync((b) => engine.createCard(b, { title: finding, subtaskOf: id }))).id);
|
|
585
|
+
await withBoardAsync((b) => engine.requestChanges(b, id, { reason: rest.slice(1).join(' '), reviewer: args.reviewer ?? null }));
|
|
586
|
+
say(args, `${id} sent back for changes${filed.length ? `; findings filed as ${filed.join(', ')}` : ''}`, { ok: true, findings: filed }); return 0;
|
|
587
|
+
}
|
|
581
588
|
case 'reopen': {
|
|
582
589
|
// a done card back to work; the cards that built on it are invalidated with it (Hermes's reopen)
|
|
583
590
|
need(1, 'a card id');
|
|
@@ -805,11 +812,6 @@ export async function runWorkflow(argv) {
|
|
|
805
812
|
return report.status === 'corrupt' ? 1 : 0;
|
|
806
813
|
}
|
|
807
814
|
|
|
808
|
-
case 'set-every': {
|
|
809
|
-
need(1, 'a card id');
|
|
810
|
-
await withBoardAsync((b) => engine.act(b, id, 'set_every', { payload: { every: rest[1] === 'none' ? null : rest[1] } }));
|
|
811
|
-
say(args, `${id} recurrence updated`, { ok: true }); return 0;
|
|
812
|
-
}
|
|
813
815
|
case 'cancel': {
|
|
814
816
|
need(1, 'a card id');
|
|
815
817
|
await withBoardAsync((b) => engine.act(b, id, 'cancel', { payload: { reason: args.reason ?? rest.slice(1).join(' ') } }));
|
|
@@ -844,9 +846,8 @@ export async function runWorkflow(argv) {
|
|
|
844
846
|
case 'adopt': {
|
|
845
847
|
need(2, 'a card id and a session');
|
|
846
848
|
withBoard(b => { if (!rolesFor(b.db, id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
say(args, `${id} is worked by ${got.address} (pane ${got.pane ?? '?'} in ${got.workspace ?? 'its card\'s directory'})`, { ok: true, ...got });
|
|
849
|
+
withBoard((b) => requestAdoption(b, id, rest[1], { workspace: args.workspace ?? null, assignee: args.assignee ?? null, role: args.role ?? null }));
|
|
850
|
+
say(args, `${id} is held for ${rest[1]}; the dispatcher verifies and binds it on its next tick (session_adopted, or adoption_failed with the reason)`, { ok: true, requested: rest[1] });
|
|
850
851
|
return 0;
|
|
851
852
|
}
|
|
852
853
|
case 'goto': {
|
|
@@ -1093,12 +1094,12 @@ function ownCard(db, sessionId) {
|
|
|
1093
1094
|
const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'notify-list', 'help', 'init']);
|
|
1094
1095
|
|
|
1095
1096
|
/**
|
|
1096
|
-
* Who a caller is on card `cardId`, in the workflow's roles: a
|
|
1097
|
-
*
|
|
1097
|
+
* Who a caller is on card `cardId`, in the workflow's roles: a caller with no session behind it has no role (the
|
|
1098
|
+
* manager acts from its own session, named in params.managers); the session of the card's current run has that run's role; the card's working session between runs
|
|
1098
1099
|
* is its `implementer`; the session working a subtask's arc is `arc`; its recorded creator also has the creator role; any other session is nobody on it.
|
|
1099
1100
|
*/
|
|
1100
1101
|
function rolesFor(db, cardId, sessionId, params = {}) {
|
|
1101
|
-
if (!sessionId) return [
|
|
1102
|
+
if (!sessionId) return [];
|
|
1102
1103
|
// a session the workflow names as the board's manager (params.managers: its addresses) is the manager on every card
|
|
1103
1104
|
const managers = Array.isArray(params.managers) ? params.managers.map(String) : [];
|
|
1104
1105
|
if (managers.some((m) => m === sessionId || m.endsWith(`:${sessionId}`))) return ['manager'];
|
|
@@ -1127,7 +1128,7 @@ function authorize(db, verb, rest, args, own) {
|
|
|
1127
1128
|
if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
|
|
1128
1129
|
// verbs that act on the board rather than on a card are the manager's and the board's, never a card's session's
|
|
1129
1130
|
// (what a session may do to a card is its workflow's: each transition's `by`)
|
|
1130
|
-
if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'close', '
|
|
1131
|
+
if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'close', 'set', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
|
|
1131
1132
|
if (args.all) refuse(`does not ${verb} every card; only its own`);
|
|
1132
1133
|
if (['notify-subscribe', 'notify-unsubscribe'].includes(verb) && args['for-card']) {
|
|
1133
1134
|
if (args['for-card'] !== own.id) refuse(`subscribes only its own arc (--for-card ${own.id})`);
|
|
@@ -1205,13 +1206,21 @@ async function serve(root, args = {}) {
|
|
|
1205
1206
|
// An installer owns this operational window, not a card pause. Finish the
|
|
1206
1207
|
// current tick, acknowledge the request, then leave every run untouched.
|
|
1207
1208
|
// A replacement serve sees the same request before its first tick.
|
|
1209
|
+
// A request whose installer is gone (its pane closed, or it was killed before clearing it) is void.
|
|
1208
1210
|
const installation = join(root, 'installation-window.json');
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
+
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
1212
|
+
const request = existsSync(installation) ? readJson(installation) : null;
|
|
1213
|
+
const installerAlive = (pid) => { try { process.kill(Number(pid), 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
1214
|
+
if (request?.token && request.pid && !installerAlive(request.pid)) {
|
|
1215
|
+
log(`installation window ${request.token} of ${request.owner} is void: its installer ${request.pid} is gone`);
|
|
1216
|
+
for (const path of [installation, join(root, 'installation-window.ack.json')]) if (readJson(path)?.token === request.token) try { unlinkSync(path); } catch { /* gone */ }
|
|
1217
|
+
} else if (request?.token) {
|
|
1211
1218
|
const acknowledgment = join(root, 'installation-window.ack.json');
|
|
1212
|
-
const previous =
|
|
1219
|
+
const previous = readJson(acknowledgment);
|
|
1213
1220
|
if (previous?.token !== request.token || previous?.pid !== process.pid) {
|
|
1214
|
-
|
|
1221
|
+
const temporary = `${acknowledgment}.${process.pid}`;
|
|
1222
|
+
writeFileSync(temporary, JSON.stringify({ token: request.token, pid: process.pid, at: Date.now() }));
|
|
1223
|
+
renameSync(temporary, acknowledgment);
|
|
1215
1224
|
log(`installation window held by ${request.owner}; dispatch waits`);
|
|
1216
1225
|
}
|
|
1217
1226
|
await new Promise(resolve => setTimeout(resolve, 250));
|
package/board/dispatch.mjs
CHANGED
|
@@ -19,13 +19,13 @@ import { inspectCompletionEvidence, openReviewPullRequests, reviewDue, withCompl
|
|
|
19
19
|
import { createRequire } from 'node:module';
|
|
20
20
|
import { spawn as spawnChild, spawnSync } from 'node:child_process';
|
|
21
21
|
import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
22
|
-
import { hostname, tmpdir } from 'node:os';
|
|
22
|
+
import { hostname, tmpdir, totalmem } from 'node:os';
|
|
23
23
|
import { basename, dirname, join } from 'node:path';
|
|
24
24
|
import { createHash, randomUUID } from 'node:crypto';
|
|
25
25
|
import { fileURLToPath } from 'node:url';
|
|
26
26
|
import { view, boardPath, claimer, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
|
|
27
27
|
import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, templateValue, workflowFor } from './workflow.mjs';
|
|
28
|
-
import { checkWorkspace, assertWorkspaceOwner, resolveWorkspace } from './workspace.mjs';
|
|
28
|
+
import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
|
|
29
29
|
import { evaluate, holds, render } from './expr.mjs';
|
|
30
30
|
import { identifyNotice } from './notices.mjs';
|
|
31
31
|
import { pendingRequestMail } from './request-mail.mjs';
|
|
@@ -123,6 +123,8 @@ export function observeFleet() {
|
|
|
123
123
|
const presence = machines.code === 0 ? lastJson(machines.stdout) : null;
|
|
124
124
|
const online = new Set([localMachine()]);
|
|
125
125
|
for (const m of presence?.items ?? []) if (m.linked) online.add(normalMachine(m.name));
|
|
126
|
+
presenceReading = { known: presence?.connected === true, online };
|
|
127
|
+
notePresence(presenceReading);
|
|
126
128
|
return { ok: true, sessions, alive: new Set(sessions.map((s) => s.address).filter(Boolean)), online,
|
|
127
129
|
presenceKnown: presence?.connected === true, machineEvents: presence,
|
|
128
130
|
error: presence?.connected ? null : 'machine event stream unavailable; retaining existing runs until availability is known' };
|
|
@@ -143,6 +145,7 @@ const sessionAlive = (fleet, session) => fleet.native?.has(addressOf(session)) ?
|
|
|
143
145
|
|
|
144
146
|
/** Native activity comes from the machine's harness discovery, not catalogue age. */
|
|
145
147
|
function nativeSessions(machine, harness) {
|
|
148
|
+
if (offlineMachine(machine)) throw unavailable(`native liveness unavailable: machine ${normalMachine(machine)} is offline`);
|
|
146
149
|
const result = sc(['discover', '--on', machine, '--harness', harness, '--limit', '2000'], { timeout: 120_000 });
|
|
147
150
|
const page = result.code === 0 ? lastJson(result.stdout) : null;
|
|
148
151
|
if (!Array.isArray(page?.sessions)) throw unavailable(`native liveness unavailable on ${machine}: ${(result.stderr || result.stdout).trim().slice(-300)}`);
|
|
@@ -173,13 +176,12 @@ function pidAlive(pid) {
|
|
|
173
176
|
|
|
174
177
|
/**
|
|
175
178
|
* Hermes's default for kanban.max_in_progress when none is set: one worker per 512 MiB of the host's memory, between
|
|
176
|
-
* two and eight
|
|
179
|
+
* two and eight. The memory is the host's own reading (os.totalmem: /proc/meminfo on Linux, hw.memsize on macOS), so
|
|
180
|
+
* the cap holds on every host, not only where /proc exists.
|
|
177
181
|
*/
|
|
178
182
|
function derivedMaxInProgress() {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return Number.isFinite(total) && total > 0 ? Math.max(2, Math.min(Math.floor(total / 1024 / 512), 8)) : null;
|
|
182
|
-
} catch { return null; }
|
|
183
|
+
const total = totalmem();
|
|
184
|
+
return Number.isFinite(total) && total > 0 ? Math.max(2, Math.min(Math.floor(total / 1024 / 1024 / 512), 8)) : null;
|
|
183
185
|
}
|
|
184
186
|
|
|
185
187
|
/**
|
|
@@ -219,6 +221,17 @@ export function lanesOf(profiles = {}) {
|
|
|
219
221
|
}
|
|
220
222
|
|
|
221
223
|
const onMachine = machine => normalMachine(machine ?? localMachine()) === localMachine() ? [] : ['--machine', machine];
|
|
224
|
+
// The latest presence reading (observeFleet). A machine it reads as offline is not asked: each remote call would
|
|
225
|
+
// wait out its timeout, and one offline machine held every close and launch on this one.
|
|
226
|
+
let presenceReading = null;
|
|
227
|
+
const offlineMachine = (machine) => {
|
|
228
|
+
const m = normalMachine(machine ?? localMachine());
|
|
229
|
+
return m !== localMachine() && presenceReading?.known === true && !presenceReading.online.has(m);
|
|
230
|
+
};
|
|
231
|
+
/** `supercode <args>` for `machine`: on another machine through its Teams server, answered at once when it is offline. */
|
|
232
|
+
const scOn = (machine, args, options) => offlineMachine(machine)
|
|
233
|
+
? { code: 1, stdout: '', stderr: `machine ${normalMachine(machine)} is offline` }
|
|
234
|
+
: sc([...args, ...onMachine(machine)], options);
|
|
222
235
|
const unavailable = message => Object.assign(new Error(message), { launchDeferred: true });
|
|
223
236
|
|
|
224
237
|
// ------------------------------------------------------------------ supervise
|
|
@@ -242,7 +255,7 @@ function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
242
255
|
for (const { id } of rows) {
|
|
243
256
|
const s = currentRun(board.db, id)?.metadata?.supercode;
|
|
244
257
|
if (!s?.pane || observed.has(s.machine)) continue;
|
|
245
|
-
const r =
|
|
258
|
+
const r = scOn(s.machine, ['teams', 'panes', 'ls'], { timeout: 60_000 });
|
|
246
259
|
const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
247
260
|
observed.set(s.machine, Array.isArray(list) ? new Set(list.filter(p => !p.dead).map((p) => p.pane)) : null);
|
|
248
261
|
if (!Array.isArray(list)) report.failed.push([id, `pane liveness unavailable; retaining run: ${(r.stderr || r.stdout).trim().slice(-200)}`]);
|
|
@@ -357,7 +370,17 @@ function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
357
370
|
if (session?.session_id) {
|
|
358
371
|
if(exits.has(id)) {
|
|
359
372
|
const exit=exits.get(id);
|
|
360
|
-
|
|
373
|
+
// an interactive session that ended cleanly was closed (by a person, or by itself): the workflow says what
|
|
374
|
+
// follows, and it is not a crash to restart
|
|
375
|
+
const ended = { code: exit.failed ? 1 : 0, error: `native session ${session.session_id} ended: ${exit.how}`, session_id: session.session_id, pane: session.pane };
|
|
376
|
+
if (!exit.failed) {
|
|
377
|
+
try {
|
|
378
|
+
const r = fire(db, board, entry, id, 'session_closed', { roles: ['platform'], payload: ended });
|
|
379
|
+
effects.push(...r.effects); (report.closed_by_hand ??= []).push(id);
|
|
380
|
+
return;
|
|
381
|
+
} catch (error) { if (!error.noTransition) { report.failed.push([id, `session_closed: ${String(error.message).slice(-200)}`]); return; } }
|
|
382
|
+
}
|
|
383
|
+
observe('exited', ended, 'crashed');
|
|
361
384
|
return;
|
|
362
385
|
}
|
|
363
386
|
if (session.pane && paneLive === null) return; // no machine reading, no loss verdict
|
|
@@ -413,6 +436,7 @@ function stopSession(board, session) {
|
|
|
413
436
|
if (pidAlive(session.pid)) signal('SIGKILL');
|
|
414
437
|
return;
|
|
415
438
|
}
|
|
439
|
+
if (session.surface !== 'headless' && offlineMachine(session.machine)) throw new Error(`close deferred: machine ${normalMachine(session.machine)} is offline`);
|
|
416
440
|
if (!session.pane && session.surface === 'native') {
|
|
417
441
|
const r = sc(['close', addressOf(session)], { timeout: 120_000, as: boardAddress(board.label) });
|
|
418
442
|
if (r.code !== 0) throw new Error(`closing native session ${addressOf(session)}: ${(r.stderr || r.stdout).trim().slice(-500)}`);
|
|
@@ -527,7 +551,8 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
|
|
|
527
551
|
if (why) { note(task.id, why); report.queued.push([task.id, why]); continue; }
|
|
528
552
|
if (dry) {
|
|
529
553
|
report.would.push({ id: task.id, status: plan.target, role: plan.runSpec?.role ?? null, assignee: plan.profile, machine: task.machine ?? null, surface: plan.lane.surface });
|
|
530
|
-
|
|
554
|
+
// a card this tick starts counts toward its groups (its scope was read before the start, with no run yet)
|
|
555
|
+
for (const l of limits) { const g = JSON.stringify(evaluate(l.group, scope)); l.tally.set(g, (l.tally.get(g) ?? 0) + 1); }
|
|
531
556
|
continue;
|
|
532
557
|
}
|
|
533
558
|
let moved, checked;
|
|
@@ -549,7 +574,7 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
|
|
|
549
574
|
continue;
|
|
550
575
|
}
|
|
551
576
|
effects.push(...moved.effects);
|
|
552
|
-
for (const l of limits)
|
|
577
|
+
for (const l of limits) { const g = JSON.stringify(evaluate(l.group, scope)); l.tally.set(g, (l.tally.get(g) ?? 0) + 1); }
|
|
553
578
|
try {
|
|
554
579
|
const session = launchRun(board, entry, task.id, plan.lane, fleet, settings);
|
|
555
580
|
if (session) {
|
|
@@ -690,20 +715,17 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
|
|
|
690
715
|
if (reading.alive) {
|
|
691
716
|
const protectedNow = active.some(r => addressOf(json(r.metadata, {})?.supercode) === addressOf(session));
|
|
692
717
|
if (protectedNow) throw new Error('close deferred: native session is owned by an unended run');
|
|
693
|
-
if (noticeHoldsClose(session, null)) throw new Error(`close deferred: lifecycle notice ${session.lifecycle_notice_pending} has not been filed`);
|
|
694
718
|
stopSession(board, session);
|
|
695
719
|
}
|
|
696
720
|
} else {
|
|
697
721
|
if (!panes.has(machine)) {
|
|
698
|
-
const r =
|
|
722
|
+
const r = scOn(machine, ['teams', 'panes', 'ls'], { timeout: 60_000, as: boardAddress(board.label) });
|
|
699
723
|
const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
700
724
|
panes.set(machine, Array.isArray(list) ? { live: new Set(list.filter(p => !p.dead).map((p) => p.pane)) } : { error: `cannot read panes on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}` });
|
|
701
725
|
}
|
|
702
726
|
const observed = panes.get(machine);
|
|
703
727
|
if (observed.error) throw new Error(observed.error);
|
|
704
728
|
if (observed.live.has(session.pane)) {
|
|
705
|
-
const pendingNotice = view(board, (db) => db.prepare("SELECT id FROM supercode_effects WHERE json_extract(effect, '$.kind')='session_notice' AND json_extract(effect, '$.runId')=? LIMIT 1").get(row.id));
|
|
706
|
-
if (noticeHoldsClose(session, pendingNotice)) throw new Error(`close deferred: lifecycle notice ${pendingNotice?.id ?? session.lifecycle_notice_pending} has not been filed: ${session.lifecycle_notice_error ?? 'pending'}`);
|
|
707
729
|
const protectedNow = view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)`).all())
|
|
708
730
|
.some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); });
|
|
709
731
|
if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
|
|
@@ -800,9 +822,10 @@ export async function sessionNotice(board, e) {
|
|
|
800
822
|
const prior = view(board, (db) => json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId)?.metadata, {})?.supercode);
|
|
801
823
|
if (prior?.close_confirmed_at) {
|
|
802
824
|
tx(board, (db) => {
|
|
825
|
+
// A closed session has nothing to be told; its next run starts with its own prompt.
|
|
803
826
|
const unissued = unissuedLaunch(prior.launch_cancellation, prior);
|
|
804
|
-
event(db, e.id,
|
|
805
|
-
|
|
827
|
+
event(db, e.id, 'session_notice_not_required', { address: e.address, verb: e.verb, notice: e.durableId,
|
|
828
|
+
reason: unissued ? 'machine confirmed launch was not issued; no session exists to notify' : 'session closed',
|
|
806
829
|
...(unissued ? { receipt: prior.launch_cancellation } : {}) }, e.runId);
|
|
807
830
|
if (prior.lifecycle_notice_pending === e.durableId) setRunSession(db, e.runId, { lifecycle_notice_pending: null });
|
|
808
831
|
});
|
|
@@ -820,19 +843,13 @@ export async function sessionNotice(board, e) {
|
|
|
820
843
|
tx(board, (db) => {
|
|
821
844
|
const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
|
|
822
845
|
if (session) setRunSession(db, e.runId, { ...session,
|
|
823
|
-
...(error ? { lifecycle_notice_error: error
|
|
824
|
-
: { lifecycle_notice_error: null,
|
|
846
|
+
...(error ? { lifecycle_notice_error: error }
|
|
847
|
+
: { lifecycle_notice_error: null, ...(session.lifecycle_notice_pending === e.durableId ? { lifecycle_notice_pending: null } : {}) }) });
|
|
825
848
|
if (!error || session?.lifecycle_notice_error !== error) event(db, e.id, error ? 'session_notice_failed' : receipt?.delivered ? 'session_notice_delivered' : 'session_notice_queued', { address: e.address, verb: e.verb, source_event: e.sourceEvent, notice: e.durableId, receipt, ...(error ? { error, retry: true } : {}) }, e.runId);
|
|
826
849
|
});
|
|
827
850
|
return !error;
|
|
828
851
|
}
|
|
829
852
|
|
|
830
|
-
// A close waits for its run's lifecycle notice to be filed, but not past this many failed
|
|
831
|
-
// attempts: an unreachable session must not hold its pane, and its slot's next launch, forever.
|
|
832
|
-
const NOTICE_ATTEMPTS_BEFORE_CLOSE = 3;
|
|
833
|
-
const noticeHoldsClose = (session, pending) => Boolean(pending || session.lifecycle_notice_pending)
|
|
834
|
-
&& Number(session.lifecycle_notice_failures ?? 0) < NOTICE_ATTEMPTS_BEFORE_CLOSE;
|
|
835
|
-
|
|
836
853
|
/** A session effect a committed move asked for: a message to the card's current actor, or closing a slot's pane. */
|
|
837
854
|
export async function sessionEffect(board, e) {
|
|
838
855
|
if (e.kind === 'send_to_actor') {
|
|
@@ -898,8 +915,16 @@ const BLOCKER = /\b(quota|rate[\s_-]?limit|429|403|auth\w*|unauthorized|forbidde
|
|
|
898
915
|
const PR_URL = /https?:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/i;
|
|
899
916
|
|
|
900
917
|
|
|
901
|
-
/**
|
|
918
|
+
/**
|
|
919
|
+
* This machine's memory pressure: on Linux as Hermes reads it (MemAvailable); on macOS the kernel's own level
|
|
920
|
+
* (kern.memorystatus_vm_pressure_level: 1 normal, 2 warning, 4 critical); `unknown` elsewhere, which restricts nothing.
|
|
921
|
+
*/
|
|
902
922
|
function memoryPressure() {
|
|
923
|
+
if (process.platform === 'darwin') {
|
|
924
|
+
const r = spawnSync('sysctl', ['-n', 'kern.memorystatus_vm_pressure_level'], { encoding: 'utf8', timeout: 5_000 });
|
|
925
|
+
const level = Number(String(r.stdout ?? '').trim());
|
|
926
|
+
return level >= 4 ? 'critical' : level >= 2 ? 'elevated' : level === 1 ? 'ok' : 'unknown';
|
|
927
|
+
}
|
|
903
928
|
try {
|
|
904
929
|
const info = Object.fromEntries(readFileSync('/proc/meminfo', 'utf8').split('\n').map((l) => /^(\w+):\s+(\d+)/.exec(l)).filter(Boolean).map((m) => [m[1], Number(m[2])]));
|
|
905
930
|
const avail = info.MemAvailable;
|
|
@@ -937,7 +962,7 @@ const getTaskNow = (board, id) => view(board, (db) => getTask(db, id));
|
|
|
937
962
|
|
|
938
963
|
/** A transport error is not a negative launch receipt. The key remains owned by its run. */
|
|
939
964
|
function launchReceipt(board, session) {
|
|
940
|
-
const r =
|
|
965
|
+
const r = scOn(session.machine, ['teams', 'launches', 'get', session.launch_key],
|
|
941
966
|
{ timeout: 30_000, as: boardAddress(board.label) });
|
|
942
967
|
if (r.code !== 0) throw unavailable(`launch receipt unavailable on ${session.machine}: install the matching Teams CLI and machine capability (teams launches get): ${(r.stderr || r.stdout).trim().slice(-300)}`);
|
|
943
968
|
if (r.stdout.trim() === 'null') return null;
|
|
@@ -947,7 +972,7 @@ function launchReceipt(board, session) {
|
|
|
947
972
|
}
|
|
948
973
|
|
|
949
974
|
function cancelUnissuedLaunch(board, session) {
|
|
950
|
-
const result =
|
|
975
|
+
const result = scOn(session.machine, ['teams', 'launches', 'cancel', session.launch_key],
|
|
951
976
|
{ timeout: 30_000, as: boardAddress(board.label) });
|
|
952
977
|
if (result.code !== 0) throw unavailable(`launch cancellation unavailable on ${session.machine}; retain ${session.launch_key}: ${(result.stderr || result.stdout).trim().slice(-300)}`);
|
|
953
978
|
const receipt = lastJson(result.stdout);
|
|
@@ -1112,7 +1137,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1112
1137
|
if (prev?.session_id && !prev.forgotten && (prev.harness ?? 'claude-code') === harness && prev.machine === machine) {
|
|
1113
1138
|
const fresh = observeFleet();
|
|
1114
1139
|
if (!fresh.ok) throw unavailable(`cannot check for a manual continuation of ${prev.session_id}: ${fresh.error}`);
|
|
1115
|
-
const r =
|
|
1140
|
+
const r = scOn(machine, ['teams', 'panes', 'ls'], { timeout: 60_000 });
|
|
1116
1141
|
const listed = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
1117
1142
|
if (!Array.isArray(listed)) throw unavailable(`cannot read panes before continuing ${prev.session_id}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
|
|
1118
1143
|
const pane = continuationPane(fresh, prev, new Set(listed.filter(p => !p.dead).map((p) => p.pane)));
|
|
@@ -1245,7 +1270,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1245
1270
|
|
|
1246
1271
|
/** A machine reading, not a missing catalog entry, establishes whether a pane is alive. */
|
|
1247
1272
|
function panePresent(machine, pane) {
|
|
1248
|
-
const r =
|
|
1273
|
+
const r = scOn(machine, ['teams', 'panes', 'ls'], { timeout: 60_000 });
|
|
1249
1274
|
const panes = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
1250
1275
|
if (!Array.isArray(panes)) throw unavailable(`cannot establish pane ${pane} liveness on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
|
|
1251
1276
|
return panes.some((p) => p.pane === pane && !p.dead);
|
|
@@ -1322,14 +1347,39 @@ function resolveLocalWorkspace(board, task) {
|
|
|
1322
1347
|
* never as the session describes itself. From then on the board watches it, resumes it when lost, and tells it of
|
|
1323
1348
|
* its card. `who` is an address, a session id, or a `name@machine` the fleet lists.
|
|
1324
1349
|
*/
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1350
|
+
/**
|
|
1351
|
+
* The manager asks the board to adopt session `who` for card `id`: the card is held at once (no replacement can be
|
|
1352
|
+
* launched), and the dispatcher's next tick verifies the session from the fleet reading it already takes and binds
|
|
1353
|
+
* it (`completeAdoptions`). The verb does no process I/O, so it answers as fast as any board write.
|
|
1354
|
+
*/
|
|
1355
|
+
export function requestAdoption(board, id, who, { workspace = null, assignee = null, role = null } = {}) {
|
|
1356
|
+
if (!who) throw new Error('name the session to adopt');
|
|
1328
1357
|
tx(board, db => {
|
|
1329
1358
|
if (!getTask(db, id)) throw new Error(`no card ${id}`);
|
|
1330
|
-
event(db, id, 'dispatch_held', { session: who, reason: 'adoption requested' });
|
|
1359
|
+
event(db, id, 'dispatch_held', { session: who, reason: 'adoption requested', workspace, assignee, role });
|
|
1331
1360
|
});
|
|
1332
|
-
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/** Adoptions the manager requested and no tick has answered yet, completed from this tick's fleet reading. */
|
|
1364
|
+
export function completeAdoptions(board, fleet, settings, report) {
|
|
1365
|
+
const pending = view(board, db => db.prepare(`SELECT e.task_id, e.payload FROM task_events e WHERE e.kind = 'dispatch_held'
|
|
1366
|
+
AND json_extract(e.payload, '$.reason') = 'adoption requested'
|
|
1367
|
+
AND e.id = (SELECT MAX(id) FROM task_events l WHERE l.task_id = e.task_id AND l.kind IN ('dispatch_held','dispatch_released','session_adopted','adoption_failed'))`).all());
|
|
1368
|
+
for (const row of pending) {
|
|
1369
|
+
const asked = json(row.payload, {}) ?? {};
|
|
1370
|
+
try {
|
|
1371
|
+
const got = adoptSession(board, row.task_id, asked.session, { workspace: asked.workspace ?? null, assignee: asked.assignee ?? null, role: asked.role ?? null, ttl: settings.ttl, fleet: fleet?.ok ? fleet : null });
|
|
1372
|
+
(report.adopted ??= []).push([row.task_id, got.address]);
|
|
1373
|
+
} catch (error) {
|
|
1374
|
+
const message = String(error.message).slice(-400);
|
|
1375
|
+
tx(board, db => event(db, row.task_id, 'adoption_failed', { session: asked.session, error: message }));
|
|
1376
|
+
report.failed.push([row.task_id, `adoption of ${asked.session}: ${message}`]);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
function adoptSession(board, id, who, { workspace = null, ttl, assignee = null, role = null, fleet = null } = {}) {
|
|
1382
|
+
fleet ??= observeFleet();
|
|
1333
1383
|
if (!fleet.ok) throw new Error(`supercode could not read the fleet: ${fleet.error}`);
|
|
1334
1384
|
const found = fleet.sessions.find((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who);
|
|
1335
1385
|
if (!found) throw new Error(`the fleet lists no live session ${who}`);
|
|
@@ -1432,7 +1482,7 @@ export async function sendBoardMessages(board, report, taskId = null) {
|
|
|
1432
1482
|
const task = view(board, db => getTask(db, first.task_id));
|
|
1433
1483
|
const subject = mail.requester ? `State requests · ${mail.members.length} request(s) · ${task?.title || first.task_id}` : `${task?.title || first.task_id} · ${mail.message_id ? 'Question' : 'Update'}`;
|
|
1434
1484
|
const receipt = await send(boardAddress(board.label), board.label, mail.to, mail.text, mail.message_id ?? `board-message-${first.id}`, { ask: Boolean(mail.message_id), subject });
|
|
1435
|
-
if (!receipt.delivered) throw new Error(receipt.reason ?? '
|
|
1485
|
+
if (!receipt.delivered && !receipt.queued) throw new Error(receipt.reason ?? 'message was not filed');
|
|
1436
1486
|
tx(board, db => {
|
|
1437
1487
|
for (const member of mail.members) event(db, member.task_id, 'board_message_sent', { event: member.id, to: mail.to, receipt, ...(mail.message_id ? { message_id: mail.message_id, request_id: member.message_id ?? mail.message_id } : {}) });
|
|
1438
1488
|
});
|
|
@@ -1510,8 +1560,11 @@ async function notify(board, fleet, report, entry) {
|
|
|
1510
1560
|
if (!events.length) return;
|
|
1511
1561
|
// the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's)
|
|
1512
1562
|
const task = view(board, (db) => { const t = getTask(db, sub.task_id); return t ? { ...t, status: statusOf(db, t), stored_as: t.status } : t; });
|
|
1563
|
+
// a worker whose run has ended is not woken by a notice (a second session would start working the card beside
|
|
1564
|
+
// the one the next run gets): the notice is filed in its mailbox, read when it next works
|
|
1565
|
+
const working = role !== 'worker' || view(board, (db) => addressOf(currentRun(db, sub.task_id)?.metadata?.supercode) === sub.chat_id);
|
|
1513
1566
|
let cursor = Number(sub.last_event_id);
|
|
1514
|
-
let failed = null
|
|
1567
|
+
let failed = null;
|
|
1515
1568
|
try {
|
|
1516
1569
|
for (const ev of events) {
|
|
1517
1570
|
const p = json(ev.payload, {}) ?? {};
|
|
@@ -1525,9 +1578,11 @@ async function notify(board, fleet, report, entry) {
|
|
|
1525
1578
|
} else text = noticeOf(entry, role, ev.kind, task, p);
|
|
1526
1579
|
if (text) {
|
|
1527
1580
|
const body = ['worker', 'dependency'].includes(role) ? identifyNotice(ev, text) : text;
|
|
1528
|
-
const receipt = await send(from, board.label, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { queue: !entry.workflow.run && sub.delivery_mode === 'notify', subject: `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}` });
|
|
1529
|
-
|
|
1530
|
-
|
|
1581
|
+
const receipt = await send(from, board.label, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { queue: !working || (!entry.workflow.run && sub.delivery_mode === 'notify'), subject: `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}` });
|
|
1582
|
+
// A notice the recipient's mailbox took is filed: the machine delivers it at the session's next turn, so the
|
|
1583
|
+
// board does not send it again.
|
|
1584
|
+
if (!receipt.delivered && !receipt.queued) throw new Error(receipt.reason ?? 'notice was not filed');
|
|
1585
|
+
tx(board, (db) => event(db, sub.task_id, receipt.delivered ? 'notice_delivered' : 'notice_filed', { source_event: ev.id, kind: ev.kind, address: sub.chat_id, receipt }));
|
|
1531
1586
|
report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
|
|
1532
1587
|
}
|
|
1533
1588
|
cursor = ev.id;
|
|
@@ -1535,7 +1590,7 @@ async function notify(board, fleet, report, entry) {
|
|
|
1535
1590
|
} catch (error) {
|
|
1536
1591
|
failed = String(error.message).slice(-200);
|
|
1537
1592
|
tx(board, (db) => {
|
|
1538
|
-
const kind =
|
|
1593
|
+
const kind = 'notice_failed';
|
|
1539
1594
|
const previous = db.prepare('SELECT payload FROM task_events WHERE task_id=? AND kind=? ORDER BY id DESC LIMIT 1').get(sub.task_id, kind);
|
|
1540
1595
|
const payload = { after_event: cursor, address: sub.chat_id, error: failed, retry: true };
|
|
1541
1596
|
if (JSON.stringify(json(previous?.payload, {})) !== JSON.stringify(payload)) event(db, sub.task_id, kind, payload);
|
|
@@ -1547,7 +1602,7 @@ async function notify(board, fleet, report, entry) {
|
|
|
1547
1602
|
const where = "WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?";
|
|
1548
1603
|
const identity = [sub.task_id, sub.chat_id, sub.thread_id ?? ''];
|
|
1549
1604
|
db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND last_event_id < ?`).run(cursor, ...identity, cursor);
|
|
1550
|
-
const failures = failed
|
|
1605
|
+
const failures = failed ? Number(sub.delivery_metadata?.failures ?? 0) + 1 : 0;
|
|
1551
1606
|
if (failures >= MAX_SEND_FAILURES) { db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity); return; }
|
|
1552
1607
|
if (failures !== Number(sub.delivery_metadata?.failures ?? 0)) db.prepare(`UPDATE kanban_notify_subs SET delivery_metadata = ? ${where}`).run(JSON.stringify({ ...sub.delivery_metadata, failures }), ...identity);
|
|
1553
1608
|
if (task?.status === 'archived' && cursor >= events.at(-1).id) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity);
|
|
@@ -1805,8 +1860,9 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
1805
1860
|
const ready = view(board, eligible);
|
|
1806
1861
|
if (!ready) continue;
|
|
1807
1862
|
if (ready !== true) { report.queued.push([id, `review waits: ${ready.length} open task(s) in the card's Tasks: ${ready[0].id} ${ready[0].title}`]); continue; }
|
|
1863
|
+
let payload = null;
|
|
1808
1864
|
try {
|
|
1809
|
-
|
|
1865
|
+
payload = openReviewPullRequests(board, id);
|
|
1810
1866
|
// The arc's open PRs ask for one review only while some head has no verdict: after changes are
|
|
1811
1867
|
// requested the next run is the implementer's, until a new head is pushed.
|
|
1812
1868
|
if (!payload || !reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) continue;
|
|
@@ -1824,7 +1880,7 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
1824
1880
|
effects.push(...moved.effects);
|
|
1825
1881
|
(report.review_requested ??= []).push({ id, prs: payload.pr, heads: payload.pr_head });
|
|
1826
1882
|
}
|
|
1827
|
-
} catch (error) { report.queued.push([id, `PR review waits: ${error.message}`]); }
|
|
1883
|
+
} catch (error) { report.queued.push([id, `PR review waits${payload ? ` (${payload.pr.join(', ')})` : ''}: ${error.message}`]); }
|
|
1828
1884
|
}
|
|
1829
1885
|
}
|
|
1830
1886
|
|
|
@@ -1872,7 +1928,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
1872
1928
|
readMailbox(board, boardAddress(board.label), report); phase('mailbox');
|
|
1873
1929
|
await refreshRelationAnswers(board); phase('answers');
|
|
1874
1930
|
const effects = [];
|
|
1875
|
-
reconcileLaunches(board, fleet, report, entry, effects, settings, lanes); supervise(board, fleet, settings, report, entry, effects); await runEffects(board, effects.splice(0), { retryNotices: true }); closeRequested(board, report); phase('supervise');
|
|
1931
|
+
completeAdoptions(board, fleet, settings, report); reconcileLaunches(board, fleet, report, entry, effects, settings, lanes); supervise(board, fleet, settings, report, entry, effects); await runEffects(board, effects.splice(0), { retryNotices: true }); closeRequested(board, report); phase('supervise');
|
|
1876
1932
|
report.unsettled = [];
|
|
1877
1933
|
report.promoted = recomputeReady(board, effects, report.unsettled);
|
|
1878
1934
|
if (dispatch) await activatePullRequestReviews(board, entry, report, effects, { only });
|
package/board/engine.mjs
CHANGED
|
@@ -214,6 +214,11 @@ export function createCard(board, spec) {
|
|
|
214
214
|
if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; a subtask belongs to an arc`);
|
|
215
215
|
let id = newTaskId();
|
|
216
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
|
+
}
|
|
217
222
|
const creationRoles = workflowFor(board.root).params.create_dependencies_by;
|
|
218
223
|
if ((parents.length || initial === 'blocked') && creationRoles && !callerRoles(db, arc?.id ?? id).some(role => creationRoles.includes(role))) {
|
|
219
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.');
|
|
@@ -279,8 +284,20 @@ function wouldCycle(db, parent, child) {
|
|
|
279
284
|
|
|
280
285
|
|
|
281
286
|
|
|
282
|
-
/**
|
|
283
|
-
|
|
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
|
+
}
|
|
284
301
|
|
|
285
302
|
// ------------------------------------------------------------------ lifecycle
|
|
286
303
|
|
|
@@ -545,12 +562,7 @@ export async function specify(board, id, { title = null, body = null, workspace
|
|
|
545
562
|
}
|
|
546
563
|
export const editResult = (board, id, { result, summary = null, metadata = undefined, roles } = {}) => act(board, id, 'edit', { roles, payload: { result, summary, metadata } });
|
|
547
564
|
export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
|
|
548
|
-
export const setModel = (board, id, model = null, provider = null, { roles } = {}) => {
|
|
549
|
-
if (provider && !model) throw new Error('a provider needs a model');
|
|
550
|
-
return act(board, id, 'set_model', { roles, payload: { model: model || null, provider: provider || null } });
|
|
551
|
-
};
|
|
552
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 } });
|
|
553
|
-
export const assign = (board, id, profile, { roles } = {}) => act(board, id, 'assign', { roles, payload: { assignee: profile || null } });
|
|
554
566
|
|
|
555
567
|
/** A triage card fans out into children it waits on (Hermes's decomposer); answers their ids. */
|
|
556
568
|
export async function decompose(board, id, children, { author = 'user', roles } = {}) {
|
package/board/tools.mjs
CHANGED
|
@@ -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);
|
package/board/workflow.mjs
CHANGED
|
@@ -583,7 +583,7 @@ const FORMAT = {
|
|
|
583
583
|
commented: ({ e }) => ({ author: String(e.author ?? 'user').trim(), len: String(e.body ?? '').length }),
|
|
584
584
|
archived: () => null,
|
|
585
585
|
status: ({ e, before, to }) => ({ status: to, reason: e.reason ?? 'reopened', previous_status: before.stored_as }),
|
|
586
|
-
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 }),
|
|
587
587
|
};
|
|
588
588
|
const RETRY_KINDS = new Set(['crashed', 'timed_out', 'stale', 'rate_limited', 'session_lost', 'reclaimed', 'reconciled', 'spawn_failed']);
|
|
589
589
|
|
|
@@ -806,29 +806,15 @@ const ACTIONS = {
|
|
|
806
806
|
const name = String(typeof slot === 'object' && slot ? slot.slot ?? 'worker' : slot ?? 'worker');
|
|
807
807
|
const harness = typeof slot === 'object' && slot ? ctx.entry.profiles[slot.assignee]?.worker?.harness : null;
|
|
808
808
|
if (typeof slot === 'object' && slot && !harness) return;
|
|
809
|
-
const released = new Set();
|
|
810
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)) {
|
|
811
810
|
const whole = json(row.metadata, {}) ?? {};
|
|
812
811
|
const session = whole.supercode;
|
|
813
812
|
if ((!session?.session_id && !session?.pane && !session?.launch_key) || (name !== '*' && (session.slot ?? 'worker') !== name) || (harness && (session.harness ?? 'claude-code') === harness)) continue;
|
|
814
813
|
requestSessionClose(ctx.db, ctx.id, { runId: row.id });
|
|
815
814
|
const latest = json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id=?', row.id)?.metadata, {}) ?? {};
|
|
816
|
-
if (session.session_id) released.add(session.session_id);
|
|
817
815
|
latest.supercode = { ...latest.supercode, forgotten: now(), resumable: false };
|
|
818
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);
|
|
819
817
|
}
|
|
820
|
-
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);
|
|
821
|
-
const failures = Number(json(gaveUp?.payload, {})?.failures ?? 0);
|
|
822
|
-
if (!failures || !released.size) return;
|
|
823
|
-
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);
|
|
824
|
-
if (recent.length !== failures || !recent.every(row => {
|
|
825
|
-
const failure = json(row.metadata, {})?.last_events?.spawn_failed?.payload;
|
|
826
|
-
return row.outcome === 'spawn_failed' && failure?.resume_failed === true && released.has(failure.session_id);
|
|
827
|
-
})) return;
|
|
828
|
-
const blocks = json(one(ctx.db, 'SELECT blocked_by FROM supercode_cards WHERE task_id=?', ctx.id)?.blocked_by, []);
|
|
829
|
-
for (const block of blocks.filter(block => block.kind === 'message' && block.reason === 'failure limit reached' && block.since === gaveUp.created_at))
|
|
830
|
-
ACTIONS.relation(ctx, { remove: block.id, reason: 'The manager released the session responsible for these stale continuation failures.' });
|
|
831
|
-
run(ctx.db, 'UPDATE tasks SET consecutive_failures=0,last_failure_error=NULL WHERE id=?', ctx.id);
|
|
832
818
|
},
|
|
833
819
|
// an event sent to other cards (`to`: an expression naming their ids, e.g. a card's open subtasks), as `as` sends it;
|
|
834
820
|
// a card it does not apply to now is passed over
|
|
@@ -1012,15 +998,21 @@ function settleRelations(db, board, entry, id, effects) {
|
|
|
1012
998
|
if (!json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []).some((b) => b.id === block.id)) continue;
|
|
1013
999
|
const dependency = block.kind === 'card' ? one(db, 'SELECT * FROM tasks WHERE id = ?', block.id) : null;
|
|
1014
1000
|
const status = dependency ? statusOf(db, dependency) : null;
|
|
1015
|
-
|
|
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;
|
|
1016
1003
|
const policy = entry.workflow.relations.blocked_by;
|
|
1017
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
|
+
}
|
|
1018
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);
|
|
1019
|
-
|
|
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) {
|
|
1020
1012
|
const seconds = Number(evaluate(String(policy.age_seconds), { params: entry.params }));
|
|
1021
1013
|
if (seconds > 0 && now() - Number(block.notified_at ?? block.since ?? now()) >= seconds) {
|
|
1022
1014
|
const current = json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []);
|
|
1023
|
-
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);
|
|
1024
1016
|
effects.push(...fire(db, board, entry, id, policy.aged, { roles: ['platform'], payload: { on: block.id, reason: block.reason } }).effects);
|
|
1025
1017
|
}
|
|
1026
1018
|
}
|
package/board/workspace.mjs
CHANGED
|
@@ -5,7 +5,13 @@ import { basename, dirname } from 'node:path';
|
|
|
5
5
|
import { Workspaces, inspectCheckout, repositoryId, teamsHome } from '@volter/supercode-teams/workspaces';
|
|
6
6
|
import { localMachine, normalMachine, supercodeBin, commandEnv } from './dispatch.mjs';
|
|
7
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; };
|
|
8
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
|
+
}
|
|
9
15
|
if (!machine || normalMachine(machine) === localMachine()) {
|
|
10
16
|
const map = new Workspaces(teamsHome());
|
|
11
17
|
if (verb === 'inspect') return inspectCheckout(value);
|
package/board/ztrack.mjs
CHANGED
|
@@ -70,8 +70,13 @@ class ZtrackBoard {
|
|
|
70
70
|
this.seed = seed; this.root = root; this.slug = slug; this.init = init; this.compatPath = compatPath;
|
|
71
71
|
this.file = `${marker}.board.db`;
|
|
72
72
|
mkdirSync(dirname(this.path), { recursive: true });
|
|
73
|
-
|
|
73
|
+
const legacy = `${marker}.runtime.db`;
|
|
74
|
+
// A 0-byte file is no store: a read through the sqlite3 CLI creates one at a path that has none (on 2026-10-01 a
|
|
75
|
+
// `sqlite3 <store> "select …"` made the empty file the next release then opened instead of importing).
|
|
76
|
+
if (existsSync(this.file) && statSync(this.file).size === 0) unlinkSync(this.file);
|
|
77
|
+
if (!existsSync(this.file)) this.create(legacy);
|
|
74
78
|
this.conn = new DatabaseSync(this.file);
|
|
79
|
+
this.refuseUnimported(legacy);
|
|
75
80
|
this.errors = new Map(); this.statements = new Map(); this.hashes = new Map();
|
|
76
81
|
this.active = false; this.overlay = false; this.seen = null;
|
|
77
82
|
this.prepareConnection();
|
|
@@ -146,6 +151,24 @@ class ZtrackBoard {
|
|
|
146
151
|
}
|
|
147
152
|
}
|
|
148
153
|
|
|
154
|
+
// A store holding fewer runs than the earlier store beside it was made without the one-time import (on 2026-10-01
|
|
155
|
+
// one such store, created by an opener nobody named, sent the dispatcher on to start a second session on every
|
|
156
|
+
// running card). The board refuses it, so nothing runs on a board without its history.
|
|
157
|
+
refuseUnimported(legacy) {
|
|
158
|
+
if (!existsSync(legacy)) return;
|
|
159
|
+
const here = this.conn.prepare('SELECT COUNT(*) AS n FROM main.task_runs').get().n;
|
|
160
|
+
let runs = 0;
|
|
161
|
+
const old = new DatabaseSync(legacy, { readOnly: true });
|
|
162
|
+
try {
|
|
163
|
+
if (old.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'runtime_rows'").get()) {
|
|
164
|
+
runs = old.prepare("SELECT COUNT(*) AS n FROM runtime_rows WHERE name = 'task_runs' AND value IS NOT NULL").get().n;
|
|
165
|
+
}
|
|
166
|
+
} finally { old.close(); }
|
|
167
|
+
if (here >= runs) return;
|
|
168
|
+
this.conn.close();
|
|
169
|
+
throw new Error(`${this.file} holds ${here} runs, but ${legacy} holds ${runs}: it was created without importing them. Move it aside (keep it; writes made to it are not in the earlier store) and run any board command to import.`);
|
|
170
|
+
}
|
|
171
|
+
|
|
149
172
|
// A board created from the previous runtime store imports its rows once; the old file stays as it was.
|
|
150
173
|
create(legacy) {
|
|
151
174
|
const tmp = `${this.file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
package/cron.mjs
CHANGED
|
@@ -750,18 +750,24 @@ function firePrompt(job) {
|
|
|
750
750
|
return job.prompt ?? '';
|
|
751
751
|
}
|
|
752
752
|
|
|
753
|
+
// supercode's normal machine name (session addresses spell a machine this way)
|
|
754
|
+
const machineName = (name) => String(name ?? '').split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
|
|
755
|
+
|
|
753
756
|
function startFire(state, profile, job, now, effects) {
|
|
754
757
|
const fire = claimFire(profile, job, now);
|
|
755
758
|
if (!fire) return;
|
|
756
759
|
effects.push({ type: 'save' }); // the claim is durable before anything runs (at-most-once)
|
|
757
|
-
|
|
760
|
+
// A job that names another machine runs each fire in a session there: the loop launches it on that machine, and the
|
|
761
|
+
// launch's receipt finishes the fire as a host's would.
|
|
762
|
+
const remote = job.machine && state.runtime?.machine && machineName(job.machine) !== state.runtime.machine ? machineName(job.machine) : null;
|
|
763
|
+
if (state.runtime?.hostedJobs || remote) {
|
|
758
764
|
fire.started_at = now;
|
|
759
765
|
fire.status = 'running';
|
|
760
766
|
fire.session_id = null; // a host callback is not a fabricated native worker session
|
|
761
767
|
fire.residue = { execution: 'host_task' };
|
|
762
768
|
state.runtime.hostedFires[`${profile.name}:${fire.id}`] = true;
|
|
763
769
|
effects.push({ type: 'save' });
|
|
764
|
-
effects.push({ type: 'execute_job', profile: profile.name, job_id: job.id, fire_id: fire.id });
|
|
770
|
+
effects.push({ type: 'execute_job', profile: profile.name, job_id: job.id, fire_id: fire.id, ...(remote ? { machine: remote } : {}) });
|
|
765
771
|
return;
|
|
766
772
|
}
|
|
767
773
|
// A gate (a script, a monitor source) runs before any model, off the event
|
package/loop.mjs
CHANGED
|
@@ -23,6 +23,9 @@ import { performRemoveProfileDir } from './operator.mjs';
|
|
|
23
23
|
import { harnessHomes } from './mcp/verbs.mjs';
|
|
24
24
|
import { checkMonitor, runJobScript } from './gates.mjs';
|
|
25
25
|
import { startBoard } from './board.mjs';
|
|
26
|
+
import { localMachine } from './board/dispatch.mjs';
|
|
27
|
+
import { execFile } from 'node:child_process';
|
|
28
|
+
import { promisify } from 'node:util';
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* Where a worker keeps its transcripts inside its config home (`<profile>/<harness>`), as discovery and
|
|
@@ -54,6 +57,7 @@ export class Orchestrator {
|
|
|
54
57
|
this.hostedTasks = new Set();
|
|
55
58
|
state.runtime ??= {};
|
|
56
59
|
state.runtime.hostedJobs = !!executeJob;
|
|
60
|
+
state.runtime.machine = localMachine(); // a job naming another machine fires there (cron.mjs startFire)
|
|
57
61
|
state.runtime.hostedFires = {};
|
|
58
62
|
this.vault = vault;
|
|
59
63
|
this.client = client;
|
|
@@ -263,12 +267,12 @@ export class Orchestrator {
|
|
|
263
267
|
}
|
|
264
268
|
}
|
|
265
269
|
|
|
266
|
-
executeHostedJob({ profile: name, job_id, fire_id }) {
|
|
270
|
+
executeHostedJob({ profile: name, job_id, fire_id, machine = null }) {
|
|
267
271
|
const profile = this.state.profiles[name];
|
|
268
272
|
const job = structuredClone(profile.jobs[job_id]);
|
|
269
273
|
const fire = structuredClone(profile.fires.find((f) => f.id === fire_id));
|
|
270
274
|
// Host work never blocks the state queue; completion returns as an event.
|
|
271
|
-
const task = Promise.resolve().then(() => this.executeJob({ profile: name, job, fire }))
|
|
275
|
+
const task = Promise.resolve().then(() => machine ? this.launchRemoteFire({ profile, job, fire, machine }) : this.executeJob({ profile: name, job, fire }))
|
|
272
276
|
.then((receipt) => {
|
|
273
277
|
if (!receipt || typeof receipt.output !== 'string' || receipt.accepted !== true)
|
|
274
278
|
throw new Error('The host did not confirm this scheduled task');
|
|
@@ -279,6 +283,23 @@ export class Orchestrator {
|
|
|
279
283
|
this.hostedTasks.add(task);
|
|
280
284
|
}
|
|
281
285
|
|
|
286
|
+
/**
|
|
287
|
+
* A fire of a job pinned to another machine: a session of the profile's worker harness on that machine, in a window
|
|
288
|
+
* there, given the job's prompt (`supercode open --new --on`, keyed by the fire so a retry never opens a second).
|
|
289
|
+
* The launch is the fire's receipt; the session does the job's work and reports through its own doors.
|
|
290
|
+
*/
|
|
291
|
+
async launchRemoteFire({ profile, job, fire, machine }) {
|
|
292
|
+
const harness = profile.worker?.harness;
|
|
293
|
+
const program = { 'claude-code': 'claude', codex: 'codex' }[harness];
|
|
294
|
+
if (!program) throw new Error(`profile ${profile.name}'s worker (${harness ?? 'none'}) cannot run a fire on ${machine}`);
|
|
295
|
+
const args = ['open', '--new', program, '--detach', '--window=true', '--on', machine, '--key', `cron-${fire.id}`, '--input', job.prompt ?? ''];
|
|
296
|
+
if (job.workdir && job.workdir.startsWith('/')) args.push('--cwd', job.workdir);
|
|
297
|
+
if (job.model) args.push('--', '--model', job.model);
|
|
298
|
+
const { stdout } = await promisify(execFile)(process.env.SUPERCODE_BIN || 'supercode', args, { timeout: 180_000, maxBuffer: 4 * 1024 * 1024 });
|
|
299
|
+
const pane = /"pane"\s*:\s*"([^"]+)"/.exec(stdout)?.[1] ?? null;
|
|
300
|
+
return { accepted: true, output: `Fire ${fire.id} of ${job.id} launched on ${machine}${pane ? ` in pane ${pane}` : ''}.`, machine, pane };
|
|
301
|
+
}
|
|
302
|
+
|
|
282
303
|
/**
|
|
283
304
|
* A fire's gate, off the queue (a script may run for Hermes's hour): the
|
|
284
305
|
* monitor source first, then the pre-run script unless the monitor already
|
|
@@ -226,6 +226,13 @@
|
|
|
226
226
|
"board": {
|
|
227
227
|
"type": "string",
|
|
228
228
|
"description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env \u2192 HERMES_KANBAN_BOARD env \u2192 the 'current' symlink under the kanban home \u2192 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
|
|
229
|
+
},
|
|
230
|
+
"findings": {
|
|
231
|
+
"type": "array",
|
|
232
|
+
"items": {
|
|
233
|
+
"type": "string"
|
|
234
|
+
},
|
|
235
|
+
"description": "Each finding, filed as a task on the arc before the card returns; an arc with no open task is refused."
|
|
229
236
|
}
|
|
230
237
|
},
|
|
231
238
|
"required": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.150",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
|
|
6
6
|
"exports": {
|