@volter/supercode-orchestrator 0.4.152 → 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
  }
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 } = {}) {
@@ -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.152",
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": {