@volter/supercode-orchestrator 0.4.152 → 0.4.154
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 +27 -10
- package/board/cli.mjs +1 -1
- package/board/dispatch.mjs +13 -13
- package/board/store.mjs +6 -5
- package/board/workflow.mjs +29 -10
- 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
|
}
|
|
@@ -122,6 +126,20 @@ function registeredBranches(board, id) {
|
|
|
122
126
|
return out;
|
|
123
127
|
}
|
|
124
128
|
|
|
129
|
+
/** Whether `head` is a branch named for card `id`: `wt/<id>` (or `wt/t-…`), alone or with a `-…`/`/…` suffix. */
|
|
130
|
+
export const namedWorkBranch = (id, head) => [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`].some((prefix) => head === prefix || head.startsWith(`${prefix}-`) || head.startsWith(`${prefix}/`));
|
|
131
|
+
|
|
132
|
+
/** The branch names an arc declares as its work: its own and its subtasks' `--branch`, and each branch its latest
|
|
133
|
+
* review request covered. With `namedWorkBranch`, these are the branches its PRs are opened from. */
|
|
134
|
+
export function declaredWorkBranches(db, id) {
|
|
135
|
+
const declared = db.prepare(`SELECT t.branch_name FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id
|
|
136
|
+
WHERE (t.id=? OR c.subtask_of=?) AND t.status != 'cancelled' AND t.branch_name IS NOT NULL`).all(id, id).map((r) => r.branch_name);
|
|
137
|
+
const row = db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='review_requested' ORDER BY id DESC LIMIT 1").get(id);
|
|
138
|
+
let reviewed = [];
|
|
139
|
+
try { reviewed = candidatePullRequests(row && JSON.parse(row.payload)?.ci).map((p) => p.branch); } catch { /* an unreadable payload names none */ }
|
|
140
|
+
return [...new Set([...declared, ...reviewed].filter(Boolean))];
|
|
141
|
+
}
|
|
142
|
+
|
|
125
143
|
/** Every opened PR of the arc, each proposed to its repository's default branch: an open PR whose head is the card's
|
|
126
144
|
* work branch in the card's own repository, a `wt/<card id>` branch in any repository this machine keeps a checkout
|
|
127
145
|
* of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
|
|
@@ -136,8 +154,7 @@ export function openReviewPullRequests(board, id) {
|
|
|
136
154
|
const current = task?.branch_name ? null : run('git', ['branch', '--show-current'], own);
|
|
137
155
|
let branch = task?.branch_name ?? (current || null);
|
|
138
156
|
const registered = registeredBranches(board, id);
|
|
139
|
-
const
|
|
140
|
-
const mine = (repository, head) => (repository === ownRepository && head === branch) || named(head)
|
|
157
|
+
const mine = (repository, head) => (repository === ownRepository && head === branch) || namedWorkBranch(id, head)
|
|
141
158
|
|| registered.some((r) => r.repository === repository && r.branch === head);
|
|
142
159
|
const prs = [];
|
|
143
160
|
for (const repository of new Set([ownRepository, ...registered.map((r) => r.repository), ...knownRepositories()])) {
|
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
|
|
|
@@ -1932,7 +1932,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
1932
1932
|
const marker = `${path}.corrupt-seen`;
|
|
1933
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; }
|
|
1934
1934
|
// a copy of a home dispatches nothing: its runs, sessions and managers are the original's
|
|
1935
|
-
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; }
|
|
1936
1936
|
let board;
|
|
1937
1937
|
try {
|
|
1938
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
|
@@ -11,7 +11,7 @@ import { resolve, relative, isAbsolute } from 'node:path';
|
|
|
11
11
|
import { randomBytes } from 'node:crypto';
|
|
12
12
|
import { actorOf, appendRunObservation, event as record, json, now } from './store.mjs';
|
|
13
13
|
import { evaluate, holds, render } from './expr.mjs';
|
|
14
|
-
import { completionEvidence, reviewCandidate } from './ci.mjs';
|
|
14
|
+
import { completionEvidence, declaredWorkBranches, namedWorkBranch, reviewCandidate } from './ci.mjs';
|
|
15
15
|
import { isWorkspacePath } from './workspace.mjs';
|
|
16
16
|
import { identifyNotice } from './notices.mjs';
|
|
17
17
|
|
|
@@ -103,17 +103,29 @@ function pattern(p) {
|
|
|
103
103
|
return text.startsWith('(?i)') ? new RegExp(text.slice(4), 'i') : new RegExp(text);
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
// the time of the latest commit that names `id` in its message
|
|
107
|
-
//
|
|
108
|
-
//
|
|
106
|
+
// the time of the latest commit that names `id` in its message on the card's work in the repository at `path`, or null
|
|
107
|
+
// (asked only when an expression does). Its work is the branches its PRs are opened from (the checkout's own branch,
|
|
108
|
+
// a declared or reviewed one, `wt/<id>…`), local or remote-tracking (a worktree's pushes land on the shared refs),
|
|
109
|
+
// and the default branch they merge into. Another ref naming the card (an evidence branch, a later card's work) is not
|
|
110
|
+
// a change to its reviewed work. Merge commits are not the card's work: merging its reviewed PR, or main into its
|
|
111
|
+
// branch, is not a newer change to review.
|
|
109
112
|
const commitTimes = new Map();
|
|
110
|
-
function observeLastCommit(path, id) {
|
|
113
|
+
function observeLastCommit(path, id, branches = []) {
|
|
111
114
|
if (!path) return null;
|
|
112
115
|
const key = `${path}:${id}`;
|
|
113
116
|
const cached = commitTimes.get(key);
|
|
114
117
|
if (cached && now() - cached.at < 15) return cached.value;
|
|
118
|
+
const git = (repo, args) => spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', timeout: 10_000 });
|
|
115
119
|
const readCommit = (repo) => {
|
|
116
|
-
const
|
|
120
|
+
const listed = git(repo, ['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes']);
|
|
121
|
+
if (listed.status !== 0) return { repo: false, value: null };
|
|
122
|
+
const current = git(repo, ['branch', '--show-current']).stdout?.trim();
|
|
123
|
+
const fallback = git(repo, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']).stdout?.trim().replace(/^refs\/remotes\/origin\//, '');
|
|
124
|
+
const names = new Set([...branches, current, fallback].filter(Boolean));
|
|
125
|
+
const short = (ref) => ref.startsWith('refs/heads/') ? ref.slice(11) : ref.split('/').slice(3).join('/');
|
|
126
|
+
const refs = listed.stdout.split('\n').filter((ref) => ref && !ref.endsWith('/HEAD') && (names.has(short(ref)) || namedWorkBranch(id, short(ref))));
|
|
127
|
+
if (!refs.length) return { repo: true, value: null };
|
|
128
|
+
const r = git(repo, ['log', ...refs, '--no-merges', '-1', '--format=%ct', '--fixed-strings', `--grep=${id}`, '--']);
|
|
117
129
|
const value = Number(r.stdout?.trim());
|
|
118
130
|
return { repo: r.status === 0, value: Number.isFinite(value) && value > 0 ? value : null };
|
|
119
131
|
};
|
|
@@ -145,7 +157,7 @@ export function refreshCardInputs(board, id, { commits = false } = {}) {
|
|
|
145
157
|
const parent=!own && task.subtask_of?board.db.prepare('SELECT workspace_path FROM tasks WHERE id=?').get(task.subtask_of)?.workspace_path:null;
|
|
146
158
|
const path=own??parent??null;
|
|
147
159
|
if(path)commitTimes.delete(`${path}:${id}`);
|
|
148
|
-
observeLastCommit(path,id);
|
|
160
|
+
observeLastCommit(path,id,declaredWorkBranches(board.db,id));
|
|
149
161
|
}
|
|
150
162
|
}
|
|
151
163
|
|
|
@@ -179,8 +191,11 @@ function answerTo(id) {
|
|
|
179
191
|
}
|
|
180
192
|
|
|
181
193
|
const OPEN_OUTCOME = /^(\s*[-*] )\[ \] (.+)$/;
|
|
194
|
+
const TICKED_OUTCOME = /^\s*[-*] \[[xX]\] .+$/;
|
|
195
|
+
const POINTED_OUTCOME = /^\s*[-*] \[[xX]\] .+: \S.+$/;
|
|
182
196
|
/** 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
|
|
197
|
+
* and is now `- [x] X` or `- [x] X: <pointer>`, or was `- [x] X` and is now `- [x] X: <pointer>` (the pointer a tick
|
|
198
|
+
* needs, added after it); no other line added, removed or changed. */
|
|
184
199
|
function onlyTicks(was, body) {
|
|
185
200
|
if (typeof body !== 'string') return false;
|
|
186
201
|
const before = String(was ?? '').split('\n');
|
|
@@ -193,10 +208,13 @@ function onlyTicks(was, body) {
|
|
|
193
208
|
if (heading) section = heading[1].toLowerCase();
|
|
194
209
|
if (line === after[i]) return true;
|
|
195
210
|
if (section !== 'acceptance') return false;
|
|
211
|
+
const pointed = (ticked) => after[i].startsWith(`${ticked}: `) && after[i].trim().length > ticked.trim().length + 1;
|
|
212
|
+
// a ticked outcome that already carries a pointer (as acceptance_complete reads one) is not given another
|
|
213
|
+
if (TICKED_OUTCOME.test(line)) return !POINTED_OUTCOME.test(line) && pointed(line);
|
|
196
214
|
const open = OPEN_OUTCOME.exec(line);
|
|
197
215
|
if (!open) return false;
|
|
198
216
|
const ticked = `${open[1]}[x] ${open[2]}`;
|
|
199
|
-
return after[i] === ticked || (
|
|
217
|
+
return after[i] === ticked || pointed(ticked);
|
|
200
218
|
});
|
|
201
219
|
}
|
|
202
220
|
|
|
@@ -301,7 +319,8 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
|
|
|
301
319
|
last_events: Object.fromEntries(all(db, 'SELECT e.kind, e.created_at, e.payload FROM task_events e JOIN (SELECT kind, MAX(id) AS id FROM task_events WHERE task_id = ? GROUP BY kind) l ON l.id = e.id', task.id)
|
|
302
320
|
.map((r) => [r.kind, { created_at: r.created_at, payload: json(r.payload, {}) ?? {} }])),
|
|
303
321
|
comments_since: (t) => all(db, 'SELECT author, body, created_at FROM task_comments WHERE task_id = ? AND created_at >= ?', task.id, t),
|
|
304
|
-
// the latest commit naming this card, in its workspace's repository (a
|
|
322
|
+
// the latest commit naming this card on its work branches and default branch, in its workspace's repository (a
|
|
323
|
+
// subtask's: its arc's), as epoch seconds
|
|
305
324
|
last_commit_at: () => {
|
|
306
325
|
const own = task.workspace_kind !== 'scratch' ? task.workspace_path : null;
|
|
307
326
|
const arcWs = !own && task.subtask_of ? one(db, "SELECT workspace_path FROM tasks WHERE id = ? AND workspace_kind != 'scratch'", task.subtask_of)?.workspace_path : null;
|
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.154",
|
|
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": {
|