@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 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 githubToken;
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
- 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 });
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 named = (head) => [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`].some((prefix) => head === prefix || head.startsWith(`${prefix}-`) || head.startsWith(`${prefix}/`));
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}`);
@@ -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
- 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`);
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
- copyOfHome = origin === here ? null : origin;
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 } = {}) {
@@ -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, on any ref of the repository at `path` (a worktree's
107
- // pushes land on the shared remote-tracking refs), or null (asked only when an expression does). Merge commits are
108
- // not the card's work: merging its reviewed PR, or main into its branch, is not a newer change to review.
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 r = spawnSync('git', ['-C', repo, 'log', '--all', '--no-merges', '-1', '--format=%ct', '--fixed-strings', `--grep=${id}`], { encoding: 'utf8', timeout: 10_000 });
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>`; no other line added, removed or changed. */
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 || (after[i].startsWith(`${ticked}: `) && after[i].length > ticked.length + 2);
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 subtask's: its arc's), as epoch seconds
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
- paused: card.paused ? JSON.stringify(card.paused) : null, archived: meta.archived ? 1 : 0, every: card.every ?? null };
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(this.state, this.state.root); return;
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(this.state, this.state.root);
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(this.state, this.state.root);
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.152",
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": {