@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 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
  }
@@ -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: each open PR whose head is the card's branch or a `wt/<card id>` branch, in the
126
- * card's own repository and in every repository this machine keeps a checkout of, and each open PR on a sibling
127
- * checkout the arc registered (`registeredBranches`). One review covers them all; the caller decides the transition. */
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 mine = (repository, head) => head === branch || [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`].some((prefix) => head === prefix || head.startsWith(`${prefix}-`) || head.startsWith(`${prefix}/`))
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
- // a draft is not handed over yet: its review starts when it is marked ready
151
- for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && mine(repository, pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid });
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}`);
@@ -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
- 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 } = {}) {
@@ -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>`; no other line added, removed or changed. */
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 || (after[i].startsWith(`${ticked}: `) && after[i].length > ticked.length + 2);
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
- 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.151",
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": {