@volter/supercode-orchestrator 0.4.149 → 0.4.151
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 +97 -24
- package/board/cli.mjs +50 -41
- package/board/dispatch.mjs +124 -48
- package/board/engine.mjs +19 -7
- package/board/store.mjs +17 -1
- 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
|
@@ -6,6 +6,43 @@ import { dirname, join, relative } from 'node:path';
|
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { branchCheckouts, checkoutOf, resolveWorkspace } from './workspace.mjs';
|
|
8
8
|
|
|
9
|
+
// GitHub is read through its REST API with Node's own fetch, not the gh binary: Node takes a World's trust bundle and
|
|
10
|
+
// injector (NODE_EXTRA_CA_CERTS, NODE_OPTIONS), so a review World's GitHub twin answers it, where gh (Go) trusts only
|
|
11
|
+
// the system keychain. The read runs in a child Node so the callers stay synchronous.
|
|
12
|
+
const GITHUB_READ = `
|
|
13
|
+
const [path, all] = process.argv.slice(1);
|
|
14
|
+
const base = (process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\\/$/, '');
|
|
15
|
+
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
|
16
|
+
const headers = { accept: 'application/vnd.github+json', 'user-agent': 'supercode-board', ...(token ? { authorization: 'Bearer ' + token } : {}) };
|
|
17
|
+
let url = base + path, out = [];
|
|
18
|
+
for (let page = 0; url && page < 10; page += 1) {
|
|
19
|
+
const r = await fetch(url, { headers });
|
|
20
|
+
if (!r.ok) { process.stderr.write('GitHub ' + r.status + ' for ' + path + ': ' + (await r.text()).slice(0, 300)); process.exit(1); }
|
|
21
|
+
const body = await r.json();
|
|
22
|
+
if (all !== '1') { process.stdout.write(JSON.stringify(body)); process.exit(0); }
|
|
23
|
+
out = out.concat(body);
|
|
24
|
+
url = /<([^>]+)>;\\s*rel="next"/.exec(r.headers.get('link') ?? '')?.[1] ?? null;
|
|
25
|
+
}
|
|
26
|
+
process.stdout.write(JSON.stringify(out));`;
|
|
27
|
+
let githubToken;
|
|
28
|
+
/** One GitHub REST read (`all`: every page of a list). The token is the environment's, else gh's stored login. */
|
|
29
|
+
function github(path, all = false) {
|
|
30
|
+
if (githubToken === undefined) {
|
|
31
|
+
githubToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null;
|
|
32
|
+
if (!githubToken) try { githubToken = run('gh', ['auth', 'token']) || null; } catch { githubToken = null; }
|
|
33
|
+
}
|
|
34
|
+
const env = githubToken ? { ...process.env, GH_TOKEN: githubToken } : process.env;
|
|
35
|
+
const result = spawnSync(process.execPath, ['--input-type=module', '-e', GITHUB_READ, path, all ? '1' : '0'], { env, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
36
|
+
if (result.status !== 0) throw new Error(`GitHub: ${(result.stderr || result.error?.message || 'read failed').trim().slice(-500)}`);
|
|
37
|
+
return JSON.parse(result.stdout);
|
|
38
|
+
}
|
|
39
|
+
const repositoryOf = (url) => new URL(url).pathname.split('/').slice(1, 3).join('/');
|
|
40
|
+
const numberOf = (url) => Number(new URL(url).pathname.split('/')[4]);
|
|
41
|
+
/** A PR as the board reads it (gh's field names). */
|
|
42
|
+
const pullRequest = (repository, p) => ({ url: p.html_url, number: p.number, state: p.merged_at ? 'MERGED' : String(p.state).toUpperCase(), headRefOid: p.head.sha,
|
|
43
|
+
headRefName: p.head.ref, baseRefName: p.base.ref, isCrossRepository: p.head.repo?.full_name?.toLowerCase() !== repository.toLowerCase(), isDraft: Boolean(p.draft) });
|
|
44
|
+
const readPullRequest = (url) => pullRequest(repositoryOf(url), github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`));
|
|
45
|
+
|
|
9
46
|
const observations = new Map();
|
|
10
47
|
const key = (board, id) => `${board.root}\0${board.slug}\0${id}`;
|
|
11
48
|
const run = (program, args, cwd) => {
|
|
@@ -66,14 +103,28 @@ function repositoryWorkspace(board, id) {
|
|
|
66
103
|
|
|
67
104
|
const pullRequestLists = new Map();
|
|
68
105
|
|
|
69
|
-
function branchCheckout(board, id, repository) {
|
|
70
|
-
const task = board.db.prepare('SELECT
|
|
71
|
-
try { return checkoutOf(task?.machine, repository,
|
|
106
|
+
function branchCheckout(board, id, repository, branch) {
|
|
107
|
+
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);
|
|
108
|
+
try { return checkoutOf(task?.machine, repository, branch)?.path ?? null; } catch { return null; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The sibling checkouts an arc registered: each subtask (and the card itself) that declares a repository
|
|
112
|
+
* workspace and a branch, e.g. `create "<what>" --subtask-of <arc> --workspace worktree:<repository> --branch <name>`.
|
|
113
|
+
* Their PRs are the arc's whatever the branch is named. */
|
|
114
|
+
function registeredBranches(board, id) {
|
|
115
|
+
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
|
|
116
|
+
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);
|
|
117
|
+
const out = [];
|
|
118
|
+
for (const row of rows) {
|
|
119
|
+
if (!row.workspace_anchor || /^(\/|~|[A-Za-z]:[\\/])/.test(row.workspace_anchor)) continue;
|
|
120
|
+
out.push({ repository: row.workspace_anchor.replace(/^github\.com\//i, '').replace(/\.git$/, ''), branch: row.branch_name });
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
72
123
|
}
|
|
73
124
|
|
|
74
125
|
/** 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. */
|
|
126
|
+
* card's own repository and in every repository this machine keeps a checkout of, and each open PR on a sibling
|
|
127
|
+
* checkout the arc registered (`registeredBranches`). One review covers them all; the caller decides the transition. */
|
|
77
128
|
export function openReviewPullRequests(board, id) {
|
|
78
129
|
const own = repositoryWorkspace(board, id);
|
|
79
130
|
if (!own) return null;
|
|
@@ -81,20 +132,23 @@ export function openReviewPullRequests(board, id) {
|
|
|
81
132
|
const branch = task?.branch_name ?? run('git', ['branch', '--show-current'], own);
|
|
82
133
|
const ownRepository = originOf(own);
|
|
83
134
|
if (!ownRepository) return null;
|
|
84
|
-
const
|
|
135
|
+
const registered = registeredBranches(board, id);
|
|
136
|
+
const mine = (repository, head) => head === branch || [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`].some((prefix) => head === prefix || head.startsWith(`${prefix}-`) || head.startsWith(`${prefix}/`))
|
|
137
|
+
|| registered.some((r) => r.repository === repository && r.branch === head);
|
|
85
138
|
const prs = [];
|
|
86
|
-
for (const repository of new Set([ownRepository, ...knownRepositories()])) {
|
|
139
|
+
for (const repository of new Set([ownRepository, ...registered.map((r) => r.repository), ...knownRepositories()])) {
|
|
87
140
|
// Discovery is shared per repository for one minute; each selected PR/head is re-read before it authorizes a move.
|
|
88
141
|
let list = pullRequestLists.get(repository);
|
|
89
142
|
if (!list || Date.now() - list.at >= 60_000) {
|
|
90
143
|
let items = [];
|
|
91
|
-
try { items =
|
|
92
|
-
catch (error) { if (repository === ownRepository) throw error; } // another repository unreachable here is not this arc's
|
|
144
|
+
try { items = github(`/repos/${repository}/pulls?state=open&per_page=100`, true).map((p) => pullRequest(repository, p)); }
|
|
145
|
+
catch (error) { if (repository === ownRepository || registered.some((r) => r.repository === repository)) throw error; } // another repository unreachable here is not this arc's
|
|
93
146
|
list = { at: Date.now(), items };
|
|
94
147
|
if (pullRequestLists.size >= 64) pullRequestLists.delete(pullRequestLists.keys().next().value);
|
|
95
148
|
pullRequestLists.set(repository, list);
|
|
96
149
|
}
|
|
97
|
-
|
|
150
|
+
// a draft is not handed over yet: its review starts when it is marked ready
|
|
151
|
+
for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && mine(repository, pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid });
|
|
98
152
|
}
|
|
99
153
|
if (!prs.length) return null;
|
|
100
154
|
return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch };
|
|
@@ -105,17 +159,35 @@ function knownRepositories() {
|
|
|
105
159
|
try { return [...new Set(branchCheckouts(null, '*').map((row) => row.repository))]; } catch { return []; }
|
|
106
160
|
}
|
|
107
161
|
|
|
108
|
-
/** Whether the arc's open PRs (`[{ url, head }]`) call for its review
|
|
109
|
-
*
|
|
110
|
-
*
|
|
162
|
+
/** Whether the arc's open PRs (`[{ url, head }]`) call for its review: some head no verdict judged that is new work.
|
|
163
|
+
* Before any verdict every unjudged head is. After a verdict, a PR it judged with a new head is, and so is a head
|
|
164
|
+
* committed after that verdict (a new PR carrying the fixes, or any PR after a verdict that named none). A PR that
|
|
165
|
+
* joins with a head committed before the verdict is not: after changes are requested the next run is the
|
|
166
|
+
* implementer's until it pushes. */
|
|
111
167
|
export function reviewDue(board, id, prs) {
|
|
112
|
-
const unjudged = prs.
|
|
113
|
-
|
|
168
|
+
const unjudged = prs.filter((p) => !judgedHead(board, id, p.head));
|
|
169
|
+
if (!unjudged.length) return false;
|
|
170
|
+
const last = board.db.prepare(`SELECT r.payload, (SELECT MIN(v.created_at) FROM task_events v WHERE v.task_id = r.task_id
|
|
171
|
+
AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id) AS verdict_at
|
|
172
|
+
FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested'
|
|
114
173
|
AND EXISTS (SELECT 1 FROM task_events v WHERE v.task_id = r.task_id AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id)
|
|
115
174
|
ORDER BY r.id DESC LIMIT 1`).get(id);
|
|
116
|
-
if (!last) return
|
|
175
|
+
if (!last) return true;
|
|
117
176
|
const judged = candidatePullRequests(JSON.parse(last.payload)?.ci);
|
|
118
|
-
|
|
177
|
+
if (judged.some((j) => prs.some((p) => p.url === j.pr.url && p.head !== j.commit))) return true;
|
|
178
|
+
return unjudged.some((p) => (commitTime(p) ?? 0) > Number(last.verdict_at));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const commitTimes = new Map();
|
|
182
|
+
/** When a PR's head was committed (seconds), read from GitHub once per head; null when it cannot be read. */
|
|
183
|
+
function commitTime({ url, head }) {
|
|
184
|
+
if (commitTimes.has(head)) return commitTimes.get(head);
|
|
185
|
+
let at = null;
|
|
186
|
+
try {
|
|
187
|
+
at = Math.floor(Date.parse(github(`/repos/${repositoryOf(url)}/commits/${head}`).commit.committer.date) / 1000) || null;
|
|
188
|
+
} catch { /* unreadable: not counted as new */ }
|
|
189
|
+
commitTimes.set(head, at);
|
|
190
|
+
return at;
|
|
119
191
|
}
|
|
120
192
|
|
|
121
193
|
/** The verdict already recorded on `head` of the card's PR: a changes-requested or pass that answered
|
|
@@ -146,7 +218,7 @@ export function inspectCompletionEvidence(board, id, entry, payload = {}, stage
|
|
|
146
218
|
const wanted = candidatePullRequests(candidate);
|
|
147
219
|
if (wanted.length !== passed.length || wanted.some((w) => !passed.some((p) => p.pr.url === w.pr.url && p.commit === w.commit))) throw new Error('The current PR heads need their independent verdict before acceptance.');
|
|
148
220
|
for (const w of wanted) {
|
|
149
|
-
const pr =
|
|
221
|
+
const pr = readPullRequest(w.pr.url);
|
|
150
222
|
if (!['OPEN', 'MERGED'].includes(pr.state) || pr.headRefOid !== w.commit) throw new Error(`The head of ${w.pr.url} changed after its verdict; request a new review.`);
|
|
151
223
|
}
|
|
152
224
|
Object.assign(state, { ...candidate, ready: true, reason: null });
|
|
@@ -192,11 +264,12 @@ function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
|
|
|
192
264
|
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
265
|
const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
|
|
194
266
|
// 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
|
|
267
|
+
// card's own checkout, and this machine's checkout of the PR's branch when it keeps one, must also hold that head
|
|
268
|
+
// with nothing uncommitted.
|
|
269
|
+
const observed = readPullRequest(url);
|
|
270
|
+
const path = originOf(own) === repository && run('git', ['branch', '--show-current'], own) === observed.headRefName ? own : branchCheckout(board, id, repository, observed.headRefName);
|
|
199
271
|
if (observed.state !== 'OPEN' || observed.isCrossRepository) throw new Error(`Source review needs an open PR from its repository (${url}).`);
|
|
272
|
+
if (observed.isDraft) throw new Error(`${url} is a draft; its review starts when it is marked ready for review.`);
|
|
200
273
|
const head = supplied ?? observed.headRefOid;
|
|
201
274
|
if (!/^[a-f0-9]{40}$/.test(head)) throw new Error('Name the full PR head commit with --pr-head (reviewer: --reviewed-sha).');
|
|
202
275
|
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 +279,8 @@ function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
|
|
|
206
279
|
if (stage === 'request_review' && path) {
|
|
207
280
|
run('git', ['check-ref-format', '--branch', branch], path);
|
|
208
281
|
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 (
|
|
282
|
+
const uncommitted = run('git', ['status', '--porcelain', '--untracked-files=normal'], path).split('\n').filter(Boolean);
|
|
283
|
+
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
284
|
const pushed = run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path).split(/\s+/)[0];
|
|
212
285
|
if (pushed !== head) throw new Error(`Push the exact head of ${url} before requesting review.`);
|
|
213
286
|
}
|
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
|
-
import { view, boardPath, claimer, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
|
|
26
|
+
import { view, boardCopyOf, boardPath, claimer, copyRefusal, 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
|
|
@@ -405,6 +428,7 @@ function limitsOf(db, board, entry, task, session) {
|
|
|
405
428
|
|
|
406
429
|
function stopSession(board, session) {
|
|
407
430
|
if (!session) return;
|
|
431
|
+
if (boardCopyOf()) throw copyRefusal('closes no session');
|
|
408
432
|
if (session.surface === 'headless' && pidAlive(session.pid)) {
|
|
409
433
|
// SIGTERM to the worker's process group, then SIGKILL if it is still there after a grace, as Hermes stops its own
|
|
410
434
|
const signal = (sig) => { try { process.kill(-session.pid, sig); } catch { try { process.kill(session.pid, sig); } catch { /* gone */ } } };
|
|
@@ -413,6 +437,7 @@ function stopSession(board, session) {
|
|
|
413
437
|
if (pidAlive(session.pid)) signal('SIGKILL');
|
|
414
438
|
return;
|
|
415
439
|
}
|
|
440
|
+
if (session.surface !== 'headless' && offlineMachine(session.machine)) throw new Error(`close deferred: machine ${normalMachine(session.machine)} is offline`);
|
|
416
441
|
if (!session.pane && session.surface === 'native') {
|
|
417
442
|
const r = sc(['close', addressOf(session)], { timeout: 120_000, as: boardAddress(board.label) });
|
|
418
443
|
if (r.code !== 0) throw new Error(`closing native session ${addressOf(session)}: ${(r.stderr || r.stdout).trim().slice(-500)}`);
|
|
@@ -527,7 +552,8 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
|
|
|
527
552
|
if (why) { note(task.id, why); report.queued.push([task.id, why]); continue; }
|
|
528
553
|
if (dry) {
|
|
529
554
|
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
|
-
|
|
555
|
+
// a card this tick starts counts toward its groups (its scope was read before the start, with no run yet)
|
|
556
|
+
for (const l of limits) { const g = JSON.stringify(evaluate(l.group, scope)); l.tally.set(g, (l.tally.get(g) ?? 0) + 1); }
|
|
531
557
|
continue;
|
|
532
558
|
}
|
|
533
559
|
let moved, checked;
|
|
@@ -549,7 +575,7 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
|
|
|
549
575
|
continue;
|
|
550
576
|
}
|
|
551
577
|
effects.push(...moved.effects);
|
|
552
|
-
for (const l of limits)
|
|
578
|
+
for (const l of limits) { const g = JSON.stringify(evaluate(l.group, scope)); l.tally.set(g, (l.tally.get(g) ?? 0) + 1); }
|
|
553
579
|
try {
|
|
554
580
|
const session = launchRun(board, entry, task.id, plan.lane, fleet, settings);
|
|
555
581
|
if (session) {
|
|
@@ -690,20 +716,17 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
|
|
|
690
716
|
if (reading.alive) {
|
|
691
717
|
const protectedNow = active.some(r => addressOf(json(r.metadata, {})?.supercode) === addressOf(session));
|
|
692
718
|
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
719
|
stopSession(board, session);
|
|
695
720
|
}
|
|
696
721
|
} else {
|
|
697
722
|
if (!panes.has(machine)) {
|
|
698
|
-
const r =
|
|
723
|
+
const r = scOn(machine, ['teams', 'panes', 'ls'], { timeout: 60_000, as: boardAddress(board.label) });
|
|
699
724
|
const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
700
725
|
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
726
|
}
|
|
702
727
|
const observed = panes.get(machine);
|
|
703
728
|
if (observed.error) throw new Error(observed.error);
|
|
704
729
|
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
730
|
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
731
|
.some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); });
|
|
709
732
|
if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
|
|
@@ -800,9 +823,10 @@ export async function sessionNotice(board, e) {
|
|
|
800
823
|
const prior = view(board, (db) => json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId)?.metadata, {})?.supercode);
|
|
801
824
|
if (prior?.close_confirmed_at) {
|
|
802
825
|
tx(board, (db) => {
|
|
826
|
+
// A closed session has nothing to be told; its next run starts with its own prompt.
|
|
803
827
|
const unissued = unissuedLaunch(prior.launch_cancellation, prior);
|
|
804
|
-
event(db, e.id,
|
|
805
|
-
|
|
828
|
+
event(db, e.id, 'session_notice_not_required', { address: e.address, verb: e.verb, notice: e.durableId,
|
|
829
|
+
reason: unissued ? 'machine confirmed launch was not issued; no session exists to notify' : 'session closed',
|
|
806
830
|
...(unissued ? { receipt: prior.launch_cancellation } : {}) }, e.runId);
|
|
807
831
|
if (prior.lifecycle_notice_pending === e.durableId) setRunSession(db, e.runId, { lifecycle_notice_pending: null });
|
|
808
832
|
});
|
|
@@ -820,19 +844,13 @@ export async function sessionNotice(board, e) {
|
|
|
820
844
|
tx(board, (db) => {
|
|
821
845
|
const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
|
|
822
846
|
if (session) setRunSession(db, e.runId, { ...session,
|
|
823
|
-
...(error ? { lifecycle_notice_error: error
|
|
824
|
-
: { lifecycle_notice_error: null,
|
|
847
|
+
...(error ? { lifecycle_notice_error: error }
|
|
848
|
+
: { lifecycle_notice_error: null, ...(session.lifecycle_notice_pending === e.durableId ? { lifecycle_notice_pending: null } : {}) }) });
|
|
825
849
|
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
850
|
});
|
|
827
851
|
return !error;
|
|
828
852
|
}
|
|
829
853
|
|
|
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
854
|
/** A session effect a committed move asked for: a message to the card's current actor, or closing a slot's pane. */
|
|
837
855
|
export async function sessionEffect(board, e) {
|
|
838
856
|
if (e.kind === 'send_to_actor') {
|
|
@@ -898,8 +916,16 @@ const BLOCKER = /\b(quota|rate[\s_-]?limit|429|403|auth\w*|unauthorized|forbidde
|
|
|
898
916
|
const PR_URL = /https?:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/i;
|
|
899
917
|
|
|
900
918
|
|
|
901
|
-
/**
|
|
919
|
+
/**
|
|
920
|
+
* This machine's memory pressure: on Linux as Hermes reads it (MemAvailable); on macOS the kernel's own level
|
|
921
|
+
* (kern.memorystatus_vm_pressure_level: 1 normal, 2 warning, 4 critical); `unknown` elsewhere, which restricts nothing.
|
|
922
|
+
*/
|
|
902
923
|
function memoryPressure() {
|
|
924
|
+
if (process.platform === 'darwin') {
|
|
925
|
+
const r = spawnSync('sysctl', ['-n', 'kern.memorystatus_vm_pressure_level'], { encoding: 'utf8', timeout: 5_000 });
|
|
926
|
+
const level = Number(String(r.stdout ?? '').trim());
|
|
927
|
+
return level >= 4 ? 'critical' : level >= 2 ? 'elevated' : level === 1 ? 'ok' : 'unknown';
|
|
928
|
+
}
|
|
903
929
|
try {
|
|
904
930
|
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
931
|
const avail = info.MemAvailable;
|
|
@@ -937,7 +963,7 @@ const getTaskNow = (board, id) => view(board, (db) => getTask(db, id));
|
|
|
937
963
|
|
|
938
964
|
/** A transport error is not a negative launch receipt. The key remains owned by its run. */
|
|
939
965
|
function launchReceipt(board, session) {
|
|
940
|
-
const r =
|
|
966
|
+
const r = scOn(session.machine, ['teams', 'launches', 'get', session.launch_key],
|
|
941
967
|
{ timeout: 30_000, as: boardAddress(board.label) });
|
|
942
968
|
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
969
|
if (r.stdout.trim() === 'null') return null;
|
|
@@ -947,7 +973,7 @@ function launchReceipt(board, session) {
|
|
|
947
973
|
}
|
|
948
974
|
|
|
949
975
|
function cancelUnissuedLaunch(board, session) {
|
|
950
|
-
const result =
|
|
976
|
+
const result = scOn(session.machine, ['teams', 'launches', 'cancel', session.launch_key],
|
|
951
977
|
{ timeout: 30_000, as: boardAddress(board.label) });
|
|
952
978
|
if (result.code !== 0) throw unavailable(`launch cancellation unavailable on ${session.machine}; retain ${session.launch_key}: ${(result.stderr || result.stdout).trim().slice(-300)}`);
|
|
953
979
|
const receipt = lastJson(result.stdout);
|
|
@@ -1112,7 +1138,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1112
1138
|
if (prev?.session_id && !prev.forgotten && (prev.harness ?? 'claude-code') === harness && prev.machine === machine) {
|
|
1113
1139
|
const fresh = observeFleet();
|
|
1114
1140
|
if (!fresh.ok) throw unavailable(`cannot check for a manual continuation of ${prev.session_id}: ${fresh.error}`);
|
|
1115
|
-
const r =
|
|
1141
|
+
const r = scOn(machine, ['teams', 'panes', 'ls'], { timeout: 60_000 });
|
|
1116
1142
|
const listed = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
1117
1143
|
if (!Array.isArray(listed)) throw unavailable(`cannot read panes before continuing ${prev.session_id}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
|
|
1118
1144
|
const pane = continuationPane(fresh, prev, new Set(listed.filter(p => !p.dead).map((p) => p.pane)));
|
|
@@ -1245,7 +1271,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1245
1271
|
|
|
1246
1272
|
/** A machine reading, not a missing catalog entry, establishes whether a pane is alive. */
|
|
1247
1273
|
function panePresent(machine, pane) {
|
|
1248
|
-
const r =
|
|
1274
|
+
const r = scOn(machine, ['teams', 'panes', 'ls'], { timeout: 60_000 });
|
|
1249
1275
|
const panes = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
1250
1276
|
if (!Array.isArray(panes)) throw unavailable(`cannot establish pane ${pane} liveness on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
|
|
1251
1277
|
return panes.some((p) => p.pane === pane && !p.dead);
|
|
@@ -1322,14 +1348,40 @@ function resolveLocalWorkspace(board, task) {
|
|
|
1322
1348
|
* never as the session describes itself. From then on the board watches it, resumes it when lost, and tells it of
|
|
1323
1349
|
* its card. `who` is an address, a session id, or a `name@machine` the fleet lists.
|
|
1324
1350
|
*/
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1351
|
+
/**
|
|
1352
|
+
* The manager asks the board to adopt session `who` for card `id`: the card is held at once (no replacement can be
|
|
1353
|
+
* launched), and the dispatcher's next tick verifies the session from the fleet reading it already takes and binds
|
|
1354
|
+
* it (`completeAdoptions`). The verb does no process I/O, so it answers as fast as any board write.
|
|
1355
|
+
*/
|
|
1356
|
+
export function requestAdoption(board, id, who, { workspace = null, assignee = null, role = null } = {}) {
|
|
1357
|
+
if (!who) throw new Error('name the session to adopt');
|
|
1358
|
+
if (boardCopyOf()) throw copyRefusal('adopts no session');
|
|
1328
1359
|
tx(board, db => {
|
|
1329
1360
|
if (!getTask(db, id)) throw new Error(`no card ${id}`);
|
|
1330
|
-
event(db, id, 'dispatch_held', { session: who, reason: 'adoption requested' });
|
|
1361
|
+
event(db, id, 'dispatch_held', { session: who, reason: 'adoption requested', workspace, assignee, role });
|
|
1331
1362
|
});
|
|
1332
|
-
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/** Adoptions the manager requested and no tick has answered yet, completed from this tick's fleet reading. */
|
|
1366
|
+
export function completeAdoptions(board, fleet, settings, report) {
|
|
1367
|
+
const pending = view(board, db => db.prepare(`SELECT e.task_id, e.payload FROM task_events e WHERE e.kind = 'dispatch_held'
|
|
1368
|
+
AND json_extract(e.payload, '$.reason') = 'adoption requested'
|
|
1369
|
+
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());
|
|
1370
|
+
for (const row of pending) {
|
|
1371
|
+
const asked = json(row.payload, {}) ?? {};
|
|
1372
|
+
try {
|
|
1373
|
+
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 });
|
|
1374
|
+
(report.adopted ??= []).push([row.task_id, got.address]);
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
const message = String(error.message).slice(-400);
|
|
1377
|
+
tx(board, db => event(db, row.task_id, 'adoption_failed', { session: asked.session, error: message }));
|
|
1378
|
+
report.failed.push([row.task_id, `adoption of ${asked.session}: ${message}`]);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
function adoptSession(board, id, who, { workspace = null, ttl, assignee = null, role = null, fleet = null } = {}) {
|
|
1384
|
+
fleet ??= observeFleet();
|
|
1333
1385
|
if (!fleet.ok) throw new Error(`supercode could not read the fleet: ${fleet.error}`);
|
|
1334
1386
|
const found = fleet.sessions.find((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who);
|
|
1335
1387
|
if (!found) throw new Error(`the fleet lists no live session ${who}`);
|
|
@@ -1392,6 +1444,7 @@ export function adoptSession(board, id, who, { workspace = null, ttl, assignee =
|
|
|
1392
1444
|
|
|
1393
1445
|
// a notice sent within a transaction's caller that cannot await (the launcher): the same door, synchronously
|
|
1394
1446
|
function deliverNow(board, to, body, key) {
|
|
1447
|
+
if (boardCopyOf()) throw copyRefusal('sends no notice to sessions');
|
|
1395
1448
|
const request = { op: 'send', from: boardAddress(board.label), from_name: `${board.label} board`, to, queue: false, id: key, subject: String(body).split('\n')[0].slice(0, 160),
|
|
1396
1449
|
body: `[Board notice from the ${board.label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)` };
|
|
1397
1450
|
const r = sc(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 120_000 });
|
|
@@ -1401,6 +1454,7 @@ function deliverNow(board, to, body, key) {
|
|
|
1401
1454
|
}
|
|
1402
1455
|
|
|
1403
1456
|
async function send(from, label, to, body, key, { queue = false, ask = false, subject } = {}) {
|
|
1457
|
+
if (boardCopyOf()) throw copyRefusal('sends no mail to sessions or managers');
|
|
1404
1458
|
const request = {
|
|
1405
1459
|
op: 'send', from, from_name: `${label} board`, to, queue, id: key, subject: String(subject ?? String(body).split('\n')[0]).slice(0, 160),
|
|
1406
1460
|
// a question the board waits on is answered by a reply; a notice is not
|
|
@@ -1432,7 +1486,7 @@ export async function sendBoardMessages(board, report, taskId = null) {
|
|
|
1432
1486
|
const task = view(board, db => getTask(db, first.task_id));
|
|
1433
1487
|
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
1488
|
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 ?? '
|
|
1489
|
+
if (!receipt.delivered && !receipt.queued) throw new Error(receipt.reason ?? 'message was not filed');
|
|
1436
1490
|
tx(board, db => {
|
|
1437
1491
|
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
1492
|
});
|
|
@@ -1510,11 +1564,23 @@ async function notify(board, fleet, report, entry) {
|
|
|
1510
1564
|
if (!events.length) return;
|
|
1511
1565
|
// the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's)
|
|
1512
1566
|
const task = view(board, (db) => { const t = getTask(db, sub.task_id); return t ? { ...t, status: statusOf(db, t), stored_as: t.status } : t; });
|
|
1567
|
+
// a worker whose run has ended is not woken by a notice (a second session would start working the card beside
|
|
1568
|
+
// the one the next run gets): the notice is filed in its mailbox, read when it next works
|
|
1569
|
+
const working = role !== 'worker' || view(board, (db) => addressOf(currentRun(db, sub.task_id)?.metadata?.supercode) === sub.chat_id);
|
|
1570
|
+
// An idle Claude session cannot hold a notice in its mailbox (it starts a turn for every message it is given), so
|
|
1571
|
+
// a notice that must not wake it waits, unsent, for the session's next work.
|
|
1572
|
+
if (!working && fleet.ok && fleet.sessions.some((s) => s.address === sub.chat_id && s.harness === 'claude-code' && s.status === 'idle')) {
|
|
1573
|
+
report.notices_waiting = (report.notices_waiting ?? 0) + 1;
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1513
1576
|
let cursor = Number(sub.last_event_id);
|
|
1514
|
-
let failed = null
|
|
1577
|
+
let failed = null;
|
|
1515
1578
|
try {
|
|
1579
|
+
// an event older than the workflow's notice_max_age_seconds is history, not news: it is passed over, never mailed
|
|
1580
|
+
const maxAge = Number(entry.params.notice_max_age_seconds) || null;
|
|
1516
1581
|
for (const ev of events) {
|
|
1517
1582
|
const p = json(ev.payload, {}) ?? {};
|
|
1583
|
+
if (maxAge && now() - Number(ev.created_at) > maxAge) { cursor = ev.id; (report.stale_notices_passed ??= 0); report.stale_notices_passed += 1; continue; }
|
|
1518
1584
|
// nobody is told of their own act
|
|
1519
1585
|
if (p.by && p.by === sub.chat_id) { cursor = ev.id; continue; }
|
|
1520
1586
|
let text;
|
|
@@ -1525,9 +1591,11 @@ async function notify(board, fleet, report, entry) {
|
|
|
1525
1591
|
} else text = noticeOf(entry, role, ev.kind, task, p);
|
|
1526
1592
|
if (text) {
|
|
1527
1593
|
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
|
-
|
|
1594
|
+
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}` });
|
|
1595
|
+
// A notice the recipient's mailbox took is filed: the machine delivers it at the session's next turn, so the
|
|
1596
|
+
// board does not send it again.
|
|
1597
|
+
if (!receipt.delivered && !receipt.queued) throw new Error(receipt.reason ?? 'notice was not filed');
|
|
1598
|
+
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
1599
|
report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
|
|
1532
1600
|
}
|
|
1533
1601
|
cursor = ev.id;
|
|
@@ -1535,10 +1603,11 @@ async function notify(board, fleet, report, entry) {
|
|
|
1535
1603
|
} catch (error) {
|
|
1536
1604
|
failed = String(error.message).slice(-200);
|
|
1537
1605
|
tx(board, (db) => {
|
|
1538
|
-
const kind =
|
|
1606
|
+
const kind = 'notice_failed';
|
|
1539
1607
|
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
1608
|
const payload = { after_event: cursor, address: sub.chat_id, error: failed, retry: true };
|
|
1541
|
-
|
|
1609
|
+
const { by: _, ...said } = json(previous?.payload, {}) ?? {};
|
|
1610
|
+
if (JSON.stringify(said) !== JSON.stringify(payload)) event(db, sub.task_id, kind, payload);
|
|
1542
1611
|
});
|
|
1543
1612
|
report.undelivered.push([sub.task_id, sub.chat_id, failed]);
|
|
1544
1613
|
}
|
|
@@ -1547,7 +1616,7 @@ async function notify(board, fleet, report, entry) {
|
|
|
1547
1616
|
const where = "WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?";
|
|
1548
1617
|
const identity = [sub.task_id, sub.chat_id, sub.thread_id ?? ''];
|
|
1549
1618
|
db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND last_event_id < ?`).run(cursor, ...identity, cursor);
|
|
1550
|
-
const failures = failed
|
|
1619
|
+
const failures = failed ? Number(sub.delivery_metadata?.failures ?? 0) + 1 : 0;
|
|
1551
1620
|
if (failures >= MAX_SEND_FAILURES) { db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity); return; }
|
|
1552
1621
|
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
1622
|
if (task?.status === 'archived' && cursor >= events.at(-1).id) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity);
|
|
@@ -1805,11 +1874,16 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
1805
1874
|
const ready = view(board, eligible);
|
|
1806
1875
|
if (!ready) continue;
|
|
1807
1876
|
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; }
|
|
1877
|
+
let payload = null;
|
|
1808
1878
|
try {
|
|
1809
|
-
|
|
1879
|
+
payload = openReviewPullRequests(board, id);
|
|
1810
1880
|
// The arc's open PRs ask for one review only while some head has no verdict: after changes are
|
|
1811
1881
|
// requested the next run is the implementer's, until a new head is pushed.
|
|
1812
|
-
if (!payload
|
|
1882
|
+
if (!payload) { report.queued.push([id, 'review waits: no open PR on its branch, a wt/<card> branch or a registered checkout']); continue; }
|
|
1883
|
+
if (!reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
|
|
1884
|
+
report.queued.push([id, `review waits: every open PR head (${payload.pr.join(', ')}) has a verdict or predates the last one; push a commit that answers it`]);
|
|
1885
|
+
continue;
|
|
1886
|
+
}
|
|
1813
1887
|
const evidence = inspectCompletionEvidence(board, id, entry, payload, 'request_review');
|
|
1814
1888
|
if (!evidence.ready) throw new Error(evidence.reason);
|
|
1815
1889
|
refreshCardInputs(board, id, { commits: true });
|
|
@@ -1824,7 +1898,7 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
1824
1898
|
effects.push(...moved.effects);
|
|
1825
1899
|
(report.review_requested ??= []).push({ id, prs: payload.pr, heads: payload.pr_head });
|
|
1826
1900
|
}
|
|
1827
|
-
} catch (error) { report.queued.push([id, `PR review waits: ${error.message}`]); }
|
|
1901
|
+
} catch (error) { report.queued.push([id, `PR review waits${payload ? ` (${payload.pr.join(', ')})` : ''}: ${error.message}`]); }
|
|
1828
1902
|
}
|
|
1829
1903
|
}
|
|
1830
1904
|
|
|
@@ -1856,6 +1930,8 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
1856
1930
|
const path = boardPath(root, slug);
|
|
1857
1931
|
const marker = `${path}.corrupt-seen`;
|
|
1858
1932
|
if (existsSync(marker) && readFileSync(marker, 'utf8').trim() === fingerprint(path)) { report.error = 'skipped: the board file was corrupt and has not changed since (`supercode workflow repair`)'; out.push(report); continue; }
|
|
1933
|
+
// a copy of a home dispatches nothing: its runs, sessions and managers are the original's
|
|
1934
|
+
if (boardCopyOf()) { report.error = `skipped: ${copyRefusal('starts, supervises, adopts and mails nothing').message}`; out.push(report); continue; }
|
|
1859
1935
|
let board;
|
|
1860
1936
|
try {
|
|
1861
1937
|
board = openBoard(root, slug);
|
|
@@ -1872,7 +1948,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
1872
1948
|
readMailbox(board, boardAddress(board.label), report); phase('mailbox');
|
|
1873
1949
|
await refreshRelationAnswers(board); phase('answers');
|
|
1874
1950
|
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');
|
|
1951
|
+
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
1952
|
report.unsettled = [];
|
|
1877
1953
|
report.promoted = recomputeReady(board, effects, report.unsettled);
|
|
1878
1954
|
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/store.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// are in `engine.mjs`. Hermes's own tools may write a `kanban.db` at the same time: every write there takes SQLite's
|
|
11
11
|
// native transaction boundary and retries an optimistic conflict.
|
|
12
12
|
import { randomBytes } from 'node:crypto';
|
|
13
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { hostname } from 'node:os';
|
|
15
15
|
import { dirname, join } from 'node:path';
|
|
16
16
|
import { DatabaseSync } from 'node:sqlite';
|
|
@@ -142,7 +142,23 @@ export function layOut(db, { fresh }) {
|
|
|
142
142
|
* Open (creating when `create`, in `backing`) board `slug`: `{ db, root, slug, path, backing, tx, close }`. `db` answers
|
|
143
143
|
* SQLite's statement API (`prepare(sql).get|all|run`, `exec`) whatever the backing.
|
|
144
144
|
*/
|
|
145
|
+
// A home records where it was made (`.board-origin` at its root). A copy of the home carries that record elsewhere and
|
|
146
|
+
// is a copy: it may be read and written, but it addresses no session and no manager, which are the original's.
|
|
147
|
+
let copyOfHome = null;
|
|
148
|
+
/** The original home's path when the board opened last is a copy of it; null for a home of its own. */
|
|
149
|
+
export const boardCopyOf = () => copyOfHome;
|
|
150
|
+
export const copyRefusal = (what) => new Error(`this board is a copy of the home at ${copyOfHome}; it ${what}, which belong to the original`);
|
|
151
|
+
function noteOrigin(root) {
|
|
152
|
+
if (!existsSync(root)) return;
|
|
153
|
+
const here = realpathSync(root), file = join(root, '.board-origin');
|
|
154
|
+
let origin = null;
|
|
155
|
+
try { origin = JSON.parse(readFileSync(file, 'utf8')).root ?? null; } catch { /* none yet */ }
|
|
156
|
+
if (!origin) { writeFileSync(file, `${JSON.stringify({ root: here })}\n`); origin = here; }
|
|
157
|
+
copyOfHome = origin === here ? null : origin;
|
|
158
|
+
}
|
|
159
|
+
|
|
145
160
|
export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing = null, document = null } = {}) {
|
|
161
|
+
noteOrigin(root);
|
|
146
162
|
const kept = backingOf(root, slug);
|
|
147
163
|
if (!kept && !create) throw new Error(`no board ${slug} in ${root} (${boardPath(root, slug)})`);
|
|
148
164
|
if (kept && backing && kept !== backing) throw new Error(`board ${slug} in ${root} is kept by the ${kept} backing, not ${backing}`);
|
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.151",
|
|
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": {
|