@volter/supercode-orchestrator 0.4.151 → 0.4.153
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 +30 -17
- package/board/cli.mjs +1 -1
- package/board/dispatch.mjs +14 -13
- package/board/store.mjs +6 -5
- package/board/workflow.mjs +8 -2
- package/board/ztrack.mjs +5 -2
- package/board.mjs +1 -1
- package/loop.mjs +51 -4
- package/package.json +1 -1
package/board/ci.mjs
CHANGED
|
@@ -24,15 +24,19 @@ for (let page = 0; url && page < 10; page += 1) {
|
|
|
24
24
|
url = /<([^>]+)>;\\s*rel="next"/.exec(r.headers.get('link') ?? '')?.[1] ?? null;
|
|
25
25
|
}
|
|
26
26
|
process.stdout.write(JSON.stringify(out));`;
|
|
27
|
-
let
|
|
28
|
-
/** One GitHub REST read (`all`: every page of a list). The token is the environment's, else gh's stored login
|
|
27
|
+
let loginToken = null;
|
|
28
|
+
/** One GitHub REST read (`all`: every page of a list). The token is the environment's, else gh's stored login: a failed
|
|
29
|
+
* read of the login is not kept, and a login token GitHub refuses (401, rotated or revoked) is read again once. */
|
|
29
30
|
function github(path, all = false) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (!
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
const fixed = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null;
|
|
32
|
+
const read = () => {
|
|
33
|
+
if (!fixed && !loginToken) try { loginToken = run('gh', ['auth', 'token']) || null; } catch { loginToken = null; }
|
|
34
|
+
const token = fixed ?? loginToken;
|
|
35
|
+
const env = token ? { ...process.env, GH_TOKEN: token } : process.env;
|
|
36
|
+
return spawnSync(process.execPath, ['--input-type=module', '-e', GITHUB_READ, path, all ? '1' : '0'], { env, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
37
|
+
};
|
|
38
|
+
let result = read();
|
|
39
|
+
if (result.status !== 0 && !fixed && loginToken && /^GitHub 401 /.test(result.stderr ?? '')) { loginToken = null; result = read(); }
|
|
36
40
|
if (result.status !== 0) throw new Error(`GitHub: ${(result.stderr || result.error?.message || 'read failed').trim().slice(-500)}`);
|
|
37
41
|
return JSON.parse(result.stdout);
|
|
38
42
|
}
|
|
@@ -40,7 +44,7 @@ const repositoryOf = (url) => new URL(url).pathname.split('/').slice(1, 3).join(
|
|
|
40
44
|
const numberOf = (url) => Number(new URL(url).pathname.split('/')[4]);
|
|
41
45
|
/** A PR as the board reads it (gh's field names). */
|
|
42
46
|
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) });
|
|
47
|
+
headRefName: p.head.ref, baseRefName: p.base.ref, defaultBranch: p.base.repo?.default_branch ?? null, isCrossRepository: p.head.repo?.full_name?.toLowerCase() !== repository.toLowerCase(), isDraft: Boolean(p.draft) });
|
|
44
48
|
const readPullRequest = (url) => pullRequest(repositoryOf(url), github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`));
|
|
45
49
|
|
|
46
50
|
const observations = new Map();
|
|
@@ -122,18 +126,22 @@ function registeredBranches(board, id) {
|
|
|
122
126
|
return out;
|
|
123
127
|
}
|
|
124
128
|
|
|
125
|
-
/** Every opened PR of the arc
|
|
126
|
-
* card's own repository
|
|
127
|
-
* checkout the arc registered (`registeredBranches`).
|
|
129
|
+
/** Every opened PR of the arc, each proposed to its repository's default branch: an open PR whose head is the card's
|
|
130
|
+
* work branch in the card's own repository, a `wt/<card id>` branch in any repository this machine keeps a checkout
|
|
131
|
+
* of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
|
|
132
|
+
* checked out (or no branch), is not a work branch: such a card's PRs are only its `wt/<card>` and registered ones, and with none it
|
|
133
|
+
* answers `{ waits }` saying so. One review covers them all; the caller decides the transition. */
|
|
128
134
|
export function openReviewPullRequests(board, id) {
|
|
129
135
|
const own = repositoryWorkspace(board, id);
|
|
130
136
|
if (!own) return null;
|
|
131
137
|
const task = board.db.prepare('SELECT branch_name FROM tasks WHERE id=?').get(id);
|
|
132
|
-
const branch = task?.branch_name ?? run('git', ['branch', '--show-current'], own);
|
|
133
138
|
const ownRepository = originOf(own);
|
|
134
139
|
if (!ownRepository) return null;
|
|
140
|
+
const current = task?.branch_name ? null : run('git', ['branch', '--show-current'], own);
|
|
141
|
+
let branch = task?.branch_name ?? (current || null);
|
|
135
142
|
const registered = registeredBranches(board, id);
|
|
136
|
-
const
|
|
143
|
+
const named = (head) => [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`].some((prefix) => head === prefix || head.startsWith(`${prefix}-`) || head.startsWith(`${prefix}/`));
|
|
144
|
+
const mine = (repository, head) => (repository === ownRepository && head === branch) || named(head)
|
|
137
145
|
|| registered.some((r) => r.repository === repository && r.branch === head);
|
|
138
146
|
const prs = [];
|
|
139
147
|
for (const repository of new Set([ownRepository, ...registered.map((r) => r.repository), ...knownRepositories()])) {
|
|
@@ -147,10 +155,15 @@ export function openReviewPullRequests(board, id) {
|
|
|
147
155
|
if (pullRequestLists.size >= 64) pullRequestLists.delete(pullRequestLists.keys().next().value);
|
|
148
156
|
pullRequestLists.set(repository, list);
|
|
149
157
|
}
|
|
150
|
-
//
|
|
151
|
-
|
|
158
|
+
// The repository's default branch is not a work branch, declared or checked out (a card working in a `dir` on main).
|
|
159
|
+
if (repository === ownRepository && branch) {
|
|
160
|
+
const defaultBranch = list.items.find((pr) => pr.defaultBranch)?.defaultBranch ?? (() => { try { return run('git', ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], own).replace(/^origin\//, ''); } catch { return null; } })();
|
|
161
|
+
if (branch === defaultBranch) branch = null;
|
|
162
|
+
}
|
|
163
|
+
// a draft is not handed over yet: its review starts when it is marked ready; a PR to another base (main→prod) is a release, not the arc's work
|
|
164
|
+
for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && pr.baseRefName === pr.defaultBranch && mine(repository, pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid });
|
|
152
165
|
}
|
|
153
|
-
if (!prs.length) return null;
|
|
166
|
+
if (!prs.length) return branch ? null : { waits: `the card has no work branch (${task?.branch_name ? `it declares ${task.branch_name}, its repository's default` : current ? `its checkout ${own} is on ${current}, its repository's default` : `its checkout ${own} is on no branch`}): give it one with --branch, or open its PR from a wt/${id} branch` };
|
|
154
167
|
return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch };
|
|
155
168
|
}
|
|
156
169
|
|
package/board/cli.mjs
CHANGED
|
@@ -1254,7 +1254,7 @@ async function serve(root, args = {}) {
|
|
|
1254
1254
|
for (const [t, why] of r.failed) log(` ${t} did not start: ${why}`);
|
|
1255
1255
|
for (const [t, why] of r.unsettled ?? []) log(` ${t} could not be read: ${why}`);
|
|
1256
1256
|
for (const [t, s, m] of r.started) log(` ${t} started ${s} (${m})`);
|
|
1257
|
-
for (const [t, to, why] of r.undelivered) log(` notice for ${t} to ${to} not delivered: ${why}`);
|
|
1257
|
+
for (const [t, to, why, what] of r.undelivered) log(what === 'close' ? ` close of ${to} for ${t} waits: ${why}` : ` notice for ${t} to ${to} not delivered: ${why}`);
|
|
1258
1258
|
}
|
|
1259
1259
|
} catch (error) {
|
|
1260
1260
|
log(`tick failed: ${error.message}`);
|
package/board/dispatch.mjs
CHANGED
|
@@ -428,7 +428,7 @@ function limitsOf(db, board, entry, task, session) {
|
|
|
428
428
|
|
|
429
429
|
function stopSession(board, session) {
|
|
430
430
|
if (!session) return;
|
|
431
|
-
if (boardCopyOf()) throw copyRefusal('closes no session');
|
|
431
|
+
if (boardCopyOf(board)) throw copyRefusal(board, 'closes no session');
|
|
432
432
|
if (session.surface === 'headless' && pidAlive(session.pid)) {
|
|
433
433
|
// SIGTERM to the worker's process group, then SIGKILL if it is still there after a grace, as Hermes stops its own
|
|
434
434
|
const signal = (sig) => { try { process.kill(-session.pid, sig); } catch { try { process.kill(session.pid, sig); } catch { /* gone */ } } };
|
|
@@ -698,7 +698,7 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
|
|
|
698
698
|
setRunSession(db, row.id, { close_error: error, close_requested: session.close_requested ?? now() });
|
|
699
699
|
if (session.close_error !== error) event(db, row.task_id, 'session_close_failed', { error, retry: true }, row.id);
|
|
700
700
|
});
|
|
701
|
-
report.undelivered.push([row.task_id, addressOf(session), error]);
|
|
701
|
+
report.undelivered.push([row.task_id, addressOf(session), error, 'close']);
|
|
702
702
|
continue;
|
|
703
703
|
}
|
|
704
704
|
}
|
|
@@ -751,7 +751,7 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
|
|
|
751
751
|
event(db, row.task_id, 'session_closed', { session_id: session.session_id, pane: session.pane, machine: session.machine }, row.id);
|
|
752
752
|
}
|
|
753
753
|
});
|
|
754
|
-
if (error) report.undelivered.push([row.task_id, addressOf(session), error]);
|
|
754
|
+
if (error) report.undelivered.push([row.task_id, addressOf(session), error, 'close']);
|
|
755
755
|
else (report.closed ??= []).push({ task_id: row.task_id, ...session });
|
|
756
756
|
}
|
|
757
757
|
}
|
|
@@ -836,7 +836,7 @@ export async function sessionNotice(board, e) {
|
|
|
836
836
|
let error = null, receipt = null;
|
|
837
837
|
try {
|
|
838
838
|
if (!e.address) throw new Error('lifecycle notice awaits the owned launch session identity');
|
|
839
|
-
receipt = await send(boardAddress(board.label), board.label, e.address, e.text, effectMessageKey(e));
|
|
839
|
+
receipt = await send(board, boardAddress(board.label), board.label, e.address, e.text, effectMessageKey(e));
|
|
840
840
|
// A notice filed in the session's mailbox is done: the session reads its mail at its next
|
|
841
841
|
// turn, and a resumed session first. Only a notice no mailbox took is retried.
|
|
842
842
|
if (!receipt.delivered && !receipt.queued) error = receipt.reason ?? 'notice was not filed'; }
|
|
@@ -860,7 +860,7 @@ export async function sessionEffect(board, e) {
|
|
|
860
860
|
let receipt;
|
|
861
861
|
try {
|
|
862
862
|
if (!address) throw new Error('actor session identity unavailable');
|
|
863
|
-
receipt = await send(boardAddress(board.label), board.label, address, e.text, effectMessageKey(e));
|
|
863
|
+
receipt = await send(board, boardAddress(board.label), board.label, address, e.text, effectMessageKey(e));
|
|
864
864
|
} catch (error) { receipt = { delivered: false, reason: error.message }; }
|
|
865
865
|
const kind = receipt.delivered ? 'actor_notice_delivered' : receipt.queued ? 'actor_notice_queued' : 'actor_notice_failed';
|
|
866
866
|
tx(board, db => {
|
|
@@ -1355,7 +1355,7 @@ function resolveLocalWorkspace(board, task) {
|
|
|
1355
1355
|
*/
|
|
1356
1356
|
export function requestAdoption(board, id, who, { workspace = null, assignee = null, role = null } = {}) {
|
|
1357
1357
|
if (!who) throw new Error('name the session to adopt');
|
|
1358
|
-
if (boardCopyOf()) throw copyRefusal('adopts no session');
|
|
1358
|
+
if (boardCopyOf(board)) throw copyRefusal(board, 'adopts no session');
|
|
1359
1359
|
tx(board, db => {
|
|
1360
1360
|
if (!getTask(db, id)) throw new Error(`no card ${id}`);
|
|
1361
1361
|
event(db, id, 'dispatch_held', { session: who, reason: 'adoption requested', workspace, assignee, role });
|
|
@@ -1444,7 +1444,7 @@ function adoptSession(board, id, who, { workspace = null, ttl, assignee = null,
|
|
|
1444
1444
|
|
|
1445
1445
|
// a notice sent within a transaction's caller that cannot await (the launcher): the same door, synchronously
|
|
1446
1446
|
function deliverNow(board, to, body, key) {
|
|
1447
|
-
if (boardCopyOf()) throw copyRefusal('sends no notice to sessions');
|
|
1447
|
+
if (boardCopyOf(board)) throw copyRefusal(board, 'sends no notice to sessions');
|
|
1448
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),
|
|
1449
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.)` };
|
|
1450
1450
|
const r = sc(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 120_000 });
|
|
@@ -1453,8 +1453,8 @@ function deliverNow(board, to, body, key) {
|
|
|
1453
1453
|
if (!answer.receipt?.delivered) throw new Error(answer.receipt?.reason ?? 'native delivery confirmation pending');
|
|
1454
1454
|
}
|
|
1455
1455
|
|
|
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');
|
|
1456
|
+
async function send(board, from, label, to, body, key, { queue = false, ask = false, subject } = {}) {
|
|
1457
|
+
if (boardCopyOf(board)) throw copyRefusal(board, 'sends no mail to sessions or managers');
|
|
1458
1458
|
const request = {
|
|
1459
1459
|
op: 'send', from, from_name: `${label} board`, to, queue, id: key, subject: String(subject ?? String(body).split('\n')[0]).slice(0, 160),
|
|
1460
1460
|
// a question the board waits on is answered by a reply; a notice is not
|
|
@@ -1485,7 +1485,7 @@ export async function sendBoardMessages(board, report, taskId = null) {
|
|
|
1485
1485
|
try {
|
|
1486
1486
|
const task = view(board, db => getTask(db, first.task_id));
|
|
1487
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'}`;
|
|
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 });
|
|
1488
|
+
const receipt = await send(board, boardAddress(board.label), board.label, mail.to, mail.text, mail.message_id ?? `board-message-${first.id}`, { ask: Boolean(mail.message_id), subject });
|
|
1489
1489
|
if (!receipt.delivered && !receipt.queued) throw new Error(receipt.reason ?? 'message was not filed');
|
|
1490
1490
|
tx(board, db => {
|
|
1491
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 } : {}) });
|
|
@@ -1591,7 +1591,7 @@ async function notify(board, fleet, report, entry) {
|
|
|
1591
1591
|
} else text = noticeOf(entry, role, ev.kind, task, p);
|
|
1592
1592
|
if (text) {
|
|
1593
1593
|
const body = ['worker', 'dependency'].includes(role) ? identifyNotice(ev, text) : text;
|
|
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}` });
|
|
1594
|
+
const receipt = await send(board, 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
1595
|
// A notice the recipient's mailbox took is filed: the machine delivers it at the session's next turn, so the
|
|
1596
1596
|
// board does not send it again.
|
|
1597
1597
|
if (!receipt.delivered && !receipt.queued) throw new Error(receipt.reason ?? 'notice was not filed');
|
|
@@ -1793,7 +1793,7 @@ function readMailbox(board, address, report) {
|
|
|
1793
1793
|
answer = `${clean.slice(0, 2).join(' ')}: ${(r.status === 0 ? r.stdout : r.stderr || r.stdout).trim().slice(-600) || `exit ${r.status}`}`;
|
|
1794
1794
|
}
|
|
1795
1795
|
report.mailed.push([envelope.from, argv?.[0] ?? '?']);
|
|
1796
|
-
report.answers.push(send(address, board.label, envelope.from, answer, `board-answer-${envelope.id}`).catch((error) => { report.undelivered.push(['mail', envelope.from, String(error.message).slice(-200)]); }));
|
|
1796
|
+
report.answers.push(send(board, address, board.label, envelope.from, answer, `board-answer-${envelope.id}`).catch((error) => { report.undelivered.push(['mail', envelope.from, String(error.message).slice(-200)]); }));
|
|
1797
1797
|
}
|
|
1798
1798
|
}
|
|
1799
1799
|
|
|
@@ -1880,6 +1880,7 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
1880
1880
|
// The arc's open PRs ask for one review only while some head has no verdict: after changes are
|
|
1881
1881
|
// requested the next run is the implementer's, until a new head is pushed.
|
|
1882
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 (payload.waits) { report.queued.push([id, `review waits: ${payload.waits}`]); payload = null; continue; }
|
|
1883
1884
|
if (!reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
|
|
1884
1885
|
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
1886
|
continue;
|
|
@@ -1931,7 +1932,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
1931
1932
|
const marker = `${path}.corrupt-seen`;
|
|
1932
1933
|
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
1934
|
// 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; }
|
|
1935
|
+
if (boardCopyOf({ root })) { report.error = `skipped: ${copyRefusal({ root }, 'starts, supervises, adopts and mails nothing').message}`; out.push(report); continue; }
|
|
1935
1936
|
let board;
|
|
1936
1937
|
try {
|
|
1937
1938
|
board = openBoard(root, slug);
|
package/board/store.mjs
CHANGED
|
@@ -144,17 +144,18 @@ export function layOut(db, { fresh }) {
|
|
|
144
144
|
*/
|
|
145
145
|
// A home records where it was made (`.board-origin` at its root). A copy of the home carries that record elsewhere and
|
|
146
146
|
// is a copy: it may be read and written, but it addresses no session and no manager, which are the original's.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
export const
|
|
147
|
+
// Kept per home (the path it was opened by), so a process that opens several homes judges each by its own record.
|
|
148
|
+
const copies = new Map();
|
|
149
|
+
/** The original home's path when the board's home (`board.root`) is a copy of it; null for a home of its own. */
|
|
150
|
+
export const boardCopyOf = (board) => { if (!copies.has(board.root)) noteOrigin(board.root); return copies.get(board.root) ?? null; };
|
|
151
|
+
export const copyRefusal = (board, what) => new Error(`this board is a copy of the home at ${boardCopyOf(board)}; it ${what}, which belong to the original. A home moved here becomes the original once its .board-origin is removed (rm ${join(board.root, '.board-origin')}).`);
|
|
151
152
|
function noteOrigin(root) {
|
|
152
153
|
if (!existsSync(root)) return;
|
|
153
154
|
const here = realpathSync(root), file = join(root, '.board-origin');
|
|
154
155
|
let origin = null;
|
|
155
156
|
try { origin = JSON.parse(readFileSync(file, 'utf8')).root ?? null; } catch { /* none yet */ }
|
|
156
157
|
if (!origin) { writeFileSync(file, `${JSON.stringify({ root: here })}\n`); origin = here; }
|
|
157
|
-
|
|
158
|
+
copies.set(root, origin === here ? null : origin);
|
|
158
159
|
}
|
|
159
160
|
|
|
160
161
|
export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing = null, document = null } = {}) {
|
package/board/workflow.mjs
CHANGED
|
@@ -179,8 +179,11 @@ function answerTo(id) {
|
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
const OPEN_OUTCOME = /^(\s*[-*] )\[ \] (.+)$/;
|
|
182
|
+
const TICKED_OUTCOME = /^\s*[-*] \[[xX]\] .+$/;
|
|
183
|
+
const POINTED_OUTCOME = /^\s*[-*] \[[xX]\] .+: \S.+$/;
|
|
182
184
|
/** Whether `body` is `was` with only outcomes ticked: each changed line is under the Acceptance heading, was `- [ ] X`
|
|
183
|
-
* and is now `- [x] X` or `- [x] X: <pointer
|
|
185
|
+
* and is now `- [x] X` or `- [x] X: <pointer>`, or was `- [x] X` and is now `- [x] X: <pointer>` (the pointer a tick
|
|
186
|
+
* needs, added after it); no other line added, removed or changed. */
|
|
184
187
|
function onlyTicks(was, body) {
|
|
185
188
|
if (typeof body !== 'string') return false;
|
|
186
189
|
const before = String(was ?? '').split('\n');
|
|
@@ -193,10 +196,13 @@ function onlyTicks(was, body) {
|
|
|
193
196
|
if (heading) section = heading[1].toLowerCase();
|
|
194
197
|
if (line === after[i]) return true;
|
|
195
198
|
if (section !== 'acceptance') return false;
|
|
199
|
+
const pointed = (ticked) => after[i].startsWith(`${ticked}: `) && after[i].trim().length > ticked.trim().length + 1;
|
|
200
|
+
// a ticked outcome that already carries a pointer (as acceptance_complete reads one) is not given another
|
|
201
|
+
if (TICKED_OUTCOME.test(line)) return !POINTED_OUTCOME.test(line) && pointed(line);
|
|
196
202
|
const open = OPEN_OUTCOME.exec(line);
|
|
197
203
|
if (!open) return false;
|
|
198
204
|
const ticked = `${open[1]}[x] ${open[2]}`;
|
|
199
|
-
return after[i] === ticked || (
|
|
205
|
+
return after[i] === ticked || pointed(ticked);
|
|
200
206
|
});
|
|
201
207
|
}
|
|
202
208
|
|
package/board/ztrack.mjs
CHANGED
|
@@ -601,7 +601,10 @@ class ZtrackBoard {
|
|
|
601
601
|
workspace_kind: workspace?.[1] ?? 'scratch', workspace_path: runtime.workspace_path ?? (/^(\/|~|[A-Za-z]:[\\/])/.test(workspace?.[2] ?? '') ? workspace[2] : null), branch_name: card.branch ?? null };
|
|
602
602
|
const state = { task_id: id, workspace_anchor: workspace?.[2] ?? null, stage: card.status, machine: card.machine ?? null, subtask_of: parent,
|
|
603
603
|
blocked_by: JSON.stringify((card.relations ?? []).map((r) => ({ ...(meta.blocked_by ?? []).find((b) => b.id === nativeId(r.issueId)), id: nativeId(r.issueId), kind: /^[ma]-/.test(r.issueId) ? 'message' : 'card' }))),
|
|
604
|
-
|
|
604
|
+
// A card whose text says nothing of archiving keeps the store's flag: an entry rendered before subtask lines
|
|
605
|
+
// carried it (or edited without it) is not an unarchive. Saying `archived: false` of an archived card is.
|
|
606
|
+
paused: card.paused ? JSON.stringify(card.paused) : null, every: card.every ?? null,
|
|
607
|
+
archived: (meta.archived ?? card.archived) === undefined ? Number(this.one('SELECT archived FROM main.supercode_cards WHERE task_id = ?', id)?.archived ?? 0) : ((meta.archived ?? card.archived) ? 1 : 0) };
|
|
605
608
|
return { task, state };
|
|
606
609
|
}
|
|
607
610
|
|
|
@@ -737,7 +740,7 @@ class ZtrackBoard {
|
|
|
737
740
|
card.acceptanceCriteria.push({ sources: json(stored?.sources, undefined) ?? undefined, id: key, status: ['done', 'cancelled'].includes(status) ? 'passed' : 'pending', evidence: [], text: match?.[2] ?? r.title,
|
|
738
741
|
metadata: { id: r.id, status, body: task.body, assignee: r.assignee, machine: s.machine,
|
|
739
742
|
properties: Object.fromEntries(Object.entries(r).filter(([k, v]) => !RUNTIME.has(k) && !CARD_MAIN.has(k) && v != null)),
|
|
740
|
-
card: { paused: json(s.paused, null) ?? undefined, every: s.every || undefined } }, lines: task.acceptance,
|
|
743
|
+
card: { paused: json(s.paused, null) ?? undefined, every: s.every || undefined, archived: s.archived ? true : undefined } }, lines: task.acceptance,
|
|
741
744
|
waitingOn: messages[0]?.id,
|
|
742
745
|
blockedBy: blocks.filter((block) => block !== messages[0]).map((block) => {
|
|
743
746
|
const target = keyOf(block.id);
|
package/board.mjs
CHANGED
|
@@ -59,7 +59,7 @@ export async function startBoard({ root, orchestration = null, log = () => {}, a
|
|
|
59
59
|
if (!r.paused) said.delete(`paused:${r.board}`);
|
|
60
60
|
if (!line.endsWith('nothing to do')) log(r.error ? 'warn' : 'info', line);
|
|
61
61
|
for (const [task, why] of r.failed ?? []) log('warn', `board ${r.board}: ${task} did not start: ${why}`);
|
|
62
|
-
for (const [task, to, why] of r.undelivered ?? []) log('warn', `board ${r.board}: a notice for ${task} to ${to} was not delivered: ${why}`);
|
|
62
|
+
for (const [task, to, why, what] of r.undelivered ?? []) log('warn', what === 'close' ? `board ${r.board}: the close of ${to} for ${task} waits: ${why}` : `board ${r.board}: a notice for ${task} to ${to} was not delivered: ${why}`);
|
|
63
63
|
const unserved = r.chat_subscriptions && !Object.keys(adapters).length;
|
|
64
64
|
if (unserved && !said.has(`subs:${r.board}`)) { said.add(`subs:${r.board}`); log('warn', `board ${r.board}: ${r.chat_subscriptions} subscription(s) name a chat platform and this daemon hosts no channel adapter to tell them`); }
|
|
65
65
|
if (r.nonspawnable?.length && !said.has(`lane:${r.board}`)) { said.add(`lane:${r.board}`); log('warn', `board ${r.board}: ${r.nonspawnable.length} card(s) name an assignee that is no profile of this home, and wait`); }
|
package/loop.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, rename
|
|
|
8
8
|
import { basename, join, dirname } from 'node:path';
|
|
9
9
|
import { step, leaseId } from './reducer.mjs';
|
|
10
10
|
import * as events from './events.mjs';
|
|
11
|
-
import { save, retainWorld, releaseWorld } from './orchestration.mjs';
|
|
11
|
+
import { read, save, retainWorld, releaseWorld } from './orchestration.mjs';
|
|
12
12
|
import { INSTRUCTION_ARGUMENTS, NAME_ARGUMENTS, activationEnv, activationCwd, approvalPolicy, sandboxArguments, limitArguments, materializePersona, materializeSkills, modelArguments, turnCounter, userHomeArguments, userHomePersona } from './activation.mjs';
|
|
13
13
|
import { workerRoute } from './worker-route.mjs';
|
|
14
14
|
import { hermesAccess, hermesApproval, untrustedServerVerdict, hermesToolEnv, memorySnapshot, mirrorSession, mirrorState, recordIncident, sealMirror } from './hermes-cli.mjs';
|
|
@@ -60,6 +60,8 @@ export class Orchestrator {
|
|
|
60
60
|
state.runtime.machine = localMachine(); // a job naming another machine fires there (cron.mjs startFire)
|
|
61
61
|
state.runtime.hostedFires = {};
|
|
62
62
|
this.vault = vault;
|
|
63
|
+
// each profile's cron/jobs.json as the state was loaded from it: what changes there later was written by another hand
|
|
64
|
+
this.jobsWritten = this.jobsFiles();
|
|
63
65
|
this.client = client;
|
|
64
66
|
this.adapters = adapters;
|
|
65
67
|
this.clock = clock ?? (() => new Date().toISOString());
|
|
@@ -133,8 +135,53 @@ export class Orchestrator {
|
|
|
133
135
|
return this.webhooks?.port ?? null;
|
|
134
136
|
}
|
|
135
137
|
|
|
138
|
+
/** The folder written from state, and each profile's `cron/jobs.json` as that write left it. */
|
|
139
|
+
async save() {
|
|
140
|
+
await save(this.state, this.state.root);
|
|
141
|
+
this.jobsWritten = this.jobsFiles();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
jobsFiles() {
|
|
145
|
+
const files = new Map();
|
|
146
|
+
for (const profile of Object.values(this.state.profiles ?? {})) {
|
|
147
|
+
if (!profile?.dir) continue;
|
|
148
|
+
const path = join(profile.dir, 'cron', 'jobs.json');
|
|
149
|
+
files.set(path, existsSync(path) ? readFileSync(path, 'utf8') : null);
|
|
150
|
+
}
|
|
151
|
+
return files;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Jobs another writer put in `cron/jobs.json` (Hermes's own `hermes cron create|edit|remove`, which write the file
|
|
156
|
+
* while this daemon serves the home). Hermes's scheduler reads the file on every tick, so its jobs run whoever wrote
|
|
157
|
+
* them; here, a file that is no longer what the last save left is read back before the tick, job by job against
|
|
158
|
+
* that last save: a job the other writer added or changed is taken from the file, one it removed is dropped, and
|
|
159
|
+
* every other job keeps this daemon's own state, so a change not yet saved here survives the read.
|
|
160
|
+
*/
|
|
161
|
+
async adoptOutsideJobs() {
|
|
162
|
+
const now = this.jobsFiles();
|
|
163
|
+
const changed = [...now].filter(([path, text]) => this.jobsWritten.has(path) && this.jobsWritten.get(path) !== text).map(([path]) => path);
|
|
164
|
+
if (!changed.length) { this.jobsWritten = now; return; }
|
|
165
|
+
const fresh = await read(this.state.root);
|
|
166
|
+
for (const [name, profile] of Object.entries(this.state.profiles ?? {})) {
|
|
167
|
+
const theirs = fresh.profiles?.[name];
|
|
168
|
+
if (!theirs || !changed.includes(join(profile.dir, 'cron', 'jobs.json'))) continue;
|
|
169
|
+
const path = join(profile.dir, 'cron', 'jobs.json');
|
|
170
|
+
const byId = (text) => { try { return new Map((JSON.parse(text ?? '{}').jobs ?? []).map((job) => [job.id, JSON.stringify(job)])); } catch { return null; } };
|
|
171
|
+
const before = byId(this.jobsWritten.get(path)), after = byId(now.get(path));
|
|
172
|
+
if (!before || !after) { profile.jobs = theirs.jobs ?? {}; this.log('info', `profile ${name}: cron/jobs.json changed outside the daemon; its jobs read back whole`); continue; }
|
|
173
|
+
const touched = [];
|
|
174
|
+
profile.jobs ??= {};
|
|
175
|
+
for (const [id, text] of after) if (before.get(id) !== text && theirs.jobs?.[id]) { profile.jobs[id] = theirs.jobs[id]; touched.push(id); }
|
|
176
|
+
for (const id of before.keys()) if (!after.has(id) && profile.jobs[id]) { delete profile.jobs[id]; touched.push(id); }
|
|
177
|
+
this.log('info', `profile ${name}: cron/jobs.json changed outside the daemon; jobs read back: ${touched.join(', ') || 'none'}`);
|
|
178
|
+
}
|
|
179
|
+
this.jobsWritten = now;
|
|
180
|
+
}
|
|
181
|
+
|
|
136
182
|
/** One clock tick: expiry, due cron jobs, obligation retry, breaker windows (§4.3). */
|
|
137
183
|
tick(at = this.clock()) {
|
|
184
|
+
this.queue = this.queue.then(() => this.adoptOutsideJobs()).catch((error) => this.log('warn', `cron/jobs.json not read back: ${error.message}`));
|
|
138
185
|
return this.dispatch(events.tick(at, cronInactivitySeconds(), cronMaxParallel())).then(
|
|
139
186
|
(result) => { this.tickerHeartbeat(true); return result; },
|
|
140
187
|
(error) => { this.tickerHeartbeat(false); throw error; },
|
|
@@ -261,7 +308,7 @@ export class Orchestrator {
|
|
|
261
308
|
}
|
|
262
309
|
return;
|
|
263
310
|
}
|
|
264
|
-
case 'save': await save(
|
|
311
|
+
case 'save': await this.save(); return;
|
|
265
312
|
case 'log': this.log(effect.level, effect.message); return;
|
|
266
313
|
default: this.log('warn', `unknown effect ${effect.type}`);
|
|
267
314
|
}
|
|
@@ -703,7 +750,7 @@ export class Orchestrator {
|
|
|
703
750
|
o.state = 'failed';
|
|
704
751
|
o.last_error = `no adapter for ${o.target.platform}`;
|
|
705
752
|
this.recordDelivery(profile, o, o.last_error);
|
|
706
|
-
await save(
|
|
753
|
+
await this.save();
|
|
707
754
|
return;
|
|
708
755
|
}
|
|
709
756
|
const entry = (rt.adapters[o.target.platform] ??= { state: 'up', breaker: { failures: 0 } });
|
|
@@ -732,7 +779,7 @@ export class Orchestrator {
|
|
|
732
779
|
}
|
|
733
780
|
if (o.attempts >= MAX_ATTEMPTS) { o.state = 'failed'; this.recordDelivery(profile, o, error.message); }
|
|
734
781
|
}
|
|
735
|
-
await save(
|
|
782
|
+
await this.save();
|
|
736
783
|
}
|
|
737
784
|
|
|
738
785
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.153",
|
|
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": {
|