@volter/supercode-orchestrator 0.5.2 → 0.5.4

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.
@@ -154,10 +154,10 @@ function unpublishedAlive(session) {
154
154
  /** Native activity comes from the machine's harness discovery, not catalogue age. */
155
155
  function nativeSessions(machine, harness) {
156
156
  if (offlineMachine(machine)) throw unavailable(`native liveness unavailable: machine ${normalMachine(machine)} is offline`);
157
- const args = normalMachine(machine) === localMachine()
158
- ? ['teams', 'rpc', 'sessions.discover', JSON.stringify({ harnesses: [harness], limit: 2000 })]
159
- : ['discover', '--on', machine, '--harness', harness, '--limit', '2000'];
160
- const result = sc(args, { timeout: 120_000 });
157
+ // This machine's discovery is its harness door, as every other harness read here is; another machine's is supercode's fleet arm
158
+ const result = normalMachine(machine) === localMachine()
159
+ ? doorOn(machine, 'harness.v1.sessions.discover', { harnesses: [harness], limit: 2000 }, { timeout: 120_000 })
160
+ : sc(['discover', '--on', machine, '--harness', harness, '--limit', '2000'], { timeout: 120_000 });
161
161
  const page = result.code === 0 ? lastJson(result.stdout) : null;
162
162
  if (!Array.isArray(page?.sessions)) throw unavailable(`native liveness unavailable on ${machine}: ${(result.stderr || result.stdout).trim().slice(-300)}`);
163
163
  return page.sessions;
@@ -1516,9 +1516,12 @@ export function requestAdoption(board, id, who, { workspace = null, assignee = n
1516
1516
  });
1517
1517
  }
1518
1518
 
1519
+ /** How long a requested adoption waits for its session to appear in the fleet before it fails. */
1520
+ const ADOPTION_LISTING_WINDOW_S = 180;
1521
+
1519
1522
  /** Adoptions the manager requested and no tick has answered yet, completed from this tick's fleet reading. */
1520
1523
  export function completeAdoptions(board, fleet, settings, report, lanes = null) {
1521
- const pending = view(board, db => db.prepare(`SELECT e.task_id, e.payload FROM task_events e WHERE e.kind = 'dispatch_held'
1524
+ const pending = view(board, db => db.prepare(`SELECT e.task_id, e.payload, e.created_at FROM task_events e WHERE e.kind = 'dispatch_held'
1522
1525
  AND json_extract(e.payload, '$.reason') = 'adoption requested'
1523
1526
  AND e.id = (SELECT MAX(id) FROM task_events l WHERE l.task_id = e.task_id AND l.kind IN ('dispatch_held','dispatch_released','session_adopted','adoption_failed'))`).all());
1524
1527
  for (const row of pending) {
@@ -1528,6 +1531,12 @@ export function completeAdoptions(board, fleet, settings, report, lanes = null)
1528
1531
  (report.adopted ??= []).push([row.task_id, got.address]);
1529
1532
  } catch (error) {
1530
1533
  const message = String(error.message).slice(-400);
1534
+ // A session opened moments before its adoption is asked may not be in the fleet's published set yet: the
1535
+ // card stays held and each tick looks again, until the window closes
1536
+ if (error.notListed && Date.now() / 1000 - Number(row.created_at) < ADOPTION_LISTING_WINDOW_S) {
1537
+ report.queued.push([row.task_id, `adoption of ${asked.session} waits: ${message}`]);
1538
+ continue;
1539
+ }
1531
1540
  tx(board, db => event(db, row.task_id, 'adoption_failed', { session: asked.session, error: message }));
1532
1541
  report.failed.push([row.task_id, `adoption of ${asked.session}: ${message}`]);
1533
1542
  }
@@ -1538,7 +1547,7 @@ function adoptSession(board, id, who, { workspace = null, ttl, assignee = null,
1538
1547
  fleet ??= observeFleet();
1539
1548
  if (!fleet.ok) throw new Error(`supercode could not read the fleet: ${fleet.error}`);
1540
1549
  const found = fleet.sessions.find((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who);
1541
- if (!found) throw new Error(`the fleet lists no live session ${who}`);
1550
+ if (!found) throw Object.assign(new Error(`the fleet lists no live session ${who}`), { notListed: true });
1542
1551
  const [, host, harness, sessionId] = found.address.split(':');
1543
1552
  if (!HARNESSES[harness]) throw new Error(`${found.address} is ${harness}; the board keeps and resumes ${Object.keys(HARNESSES).join(' and ')} sessions`);
1544
1553
  const native = found.pane ? null : nativeReading(nativeSessions(host, harness), { session_id: sessionId, harness });
@@ -0,0 +1,97 @@
1
+ // THE BOARD DOCUMENT IN GIT (company RFC 0025 decision 3; ADR 0001 points 2 and 3): when the board's document lives in a
2
+ // git checkout and the board says so (`"commit": true` in its ztrack.json), each write the manager does not make (its
3
+ // `"manager"`) is committed with its doer as author and pushed, after the checkout has taken what others pushed. People edit the document both here
4
+ // (through the board's verbs) and as pages (Git Notes, where an edit is a pull request under their own account): a pull
5
+ // request merged on the remote reaches the board before its next write, and a write that raced one takes it in a merge,
6
+ // the two texts of a board document merged by ztrack's own document merge, in which a hand edit wins its cards. Nothing
7
+ // either side wrote is dropped; a merge that cannot be made stops the push and says why, leaving both texts in the
8
+ // checkout's history.
9
+ import { execFileSync } from 'node:child_process';
10
+ import { readFileSync, writeFileSync } from 'node:fs';
11
+ import { basename, dirname, relative } from 'node:path';
12
+
13
+ const git = (cwd, args, input) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], ...(input === undefined ? {} : { input }), timeout: 120_000 });
14
+ const quiet = (cwd, args) => { try { return git(cwd, args); } catch { return null; } };
15
+ // Another board process (or the manager's own git) may hold git's index for a moment: its index.lock is waited out, not
16
+ // raced. The board keeps no lock of its own (ADR 0003).
17
+ const indexed = (cwd, args) => {
18
+ for (let attempt = 0; ; attempt++) {
19
+ try { return git(cwd, args); } catch (error) {
20
+ if (attempt >= 20 || !/index\.lock/.test(String(error.stderr ?? error.message))) throw error;
21
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250);
22
+ }
23
+ }
24
+ };
25
+
26
+ export class GitDocument {
27
+ constructor(paths, merge) {
28
+ this.paths = paths;
29
+ this.cwd = dirname(paths[0]);
30
+ this.root = quiet(this.cwd, ['rev-parse', '--show-toplevel'])?.trim() ?? null;
31
+ this.merge = merge; // (base, theirs, ours, path) => text | null, ztrack's mergeBoardDocument
32
+ }
33
+
34
+ get usable() { return this.root !== null; }
35
+ rel(path) { return relative(this.root, path); }
36
+ branch() { return git(this.root, ['rev-parse', '--abbrev-ref', 'HEAD']).trim(); }
37
+
38
+ // Before a write: take what was pushed, so a merged pull request's edit is the document the write starts from.
39
+ pull() {
40
+ if (!this.usable) return;
41
+ const branch = this.branch();
42
+ if (quiet(this.root, ['fetch', '--quiet', 'origin', branch]) === null) return; // offline: the next write tries again
43
+ const behind = Number(git(this.root, ['rev-list', '--count', `HEAD..origin/${branch}`]).trim());
44
+ if (behind === 0) return;
45
+ const dirty = git(this.root, ['status', '--porcelain', '--', ...this.paths.map((path) => this.rel(path))]).trim();
46
+ if (dirty) return; // a write in progress publishes first and takes what was pushed after
47
+ this.integrate(branch, 'board');
48
+ }
49
+
50
+ // After a write: commit the board documents as their doer, then push, taking anything pushed meanwhile.
51
+ commit(doer, message) {
52
+ if (!this.usable) return;
53
+ // Only documents that exist or are tracked: the archive is written when a card first ends, and a pathspec naming a
54
+ // file git does not know fails the whole commit.
55
+ const files = this.paths.map((path) => this.rel(path)).filter((file) => quiet(this.root, ['ls-files', '--error-unmatch', file]) !== null || readable(`${this.root}/${file}`));
56
+ if (!files.length) return;
57
+ indexed(this.root, ['add', '--', ...files]);
58
+ if (!git(this.root, ['diff', '--cached', '--name-only', '--', ...files]).trim()) return;
59
+ const who = doer || 'board';
60
+ indexed(this.root, ['-c', `user.name=${who}`, '-c', 'user.email=board@supercode.invalid', 'commit', '--quiet', '--author', `${who} <board@supercode.invalid>`, '-m', message, '--', ...files]);
61
+ const branch = this.branch();
62
+ for (let attempt = 0; attempt < 3; attempt++) {
63
+ if (quiet(this.root, ['push', '--quiet', 'origin', `HEAD:${branch}`]) !== null) return;
64
+ if (quiet(this.root, ['fetch', '--quiet', 'origin', branch]) === null) throw new Error('the board documents were committed here but could not be pushed: the remote did not answer');
65
+ this.integrate(branch, who);
66
+ }
67
+ throw new Error('the board documents were committed here but the push was refused three times');
68
+ }
69
+
70
+ // Take what was pushed with a merge: it works beside the other edits in this checkout (the manager's own files), which
71
+ // a rebase refuses. A board document both sides changed is merged by the board's own document merge; any other
72
+ // conflict leaves the checkout as it was and says so.
73
+ integrate(branch, who) {
74
+ const merged = (() => { try { git(this.root, ['-c', `user.name=${who}`, '-c', 'user.email=board@supercode.invalid', 'merge', '--no-edit', '--quiet', `origin/${branch}`]); return true; } catch (error) { return String(error.stderr ?? error.message); } })();
75
+ if (merged === true) return;
76
+ const conflicted = git(this.root, ['diff', '--name-only', '--diff-filter=U']).trim().split('\n').filter(Boolean);
77
+ if (!conflicted.length) {
78
+ quiet(this.root, ['merge', '--abort']);
79
+ throw new Error(`what was pushed could not be taken: ${merged.trim().split('\n')[0]}`);
80
+ }
81
+ for (const file of conflicted) {
82
+ const path = `${this.root}/${file}`;
83
+ // In a merge, stage 2 is this checkout's text and stage 3 the pushed one.
84
+ const stage = (n) => quiet(this.root, ['show', `:${n}:${file}`]) ?? '';
85
+ const text = this.paths.some((own) => this.rel(own) === file) ? this.merge(stage(1), stage(3), stage(2), path) : null;
86
+ if (text === null) {
87
+ quiet(this.root, ['merge', '--abort']);
88
+ throw new Error(`${basename(file)} could not be merged with what was pushed; nothing was pushed, and this checkout is as it was`);
89
+ }
90
+ writeFileSync(path, text);
91
+ indexed(this.root, ['add', '--', file]);
92
+ }
93
+ indexed(this.root, ['-c', `user.name=${who}`, '-c', 'user.email=board@supercode.invalid', 'commit', '--quiet', '--no-edit']);
94
+ }
95
+ }
96
+
97
+ function readable(path) { try { readFileSync(path); return true; } catch { return false; } }
package/board/ztrack.mjs CHANGED
@@ -10,9 +10,10 @@
10
10
  import { createRequire } from 'node:module';
11
11
  import { createHash, randomBytes } from 'node:crypto';
12
12
  import { existsSync, linkSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
13
- import { dirname, resolve } from 'node:path';
13
+ import { basename, dirname, resolve } from 'node:path';
14
14
  import { DatabaseSync } from 'node:sqlite';
15
- import { actorOf, event, json, now, setActor } from './store.mjs';
15
+ import { actorOf, event, json, now, principalOf, setActor } from './store.mjs';
16
+ import { GitDocument } from './git-document.mjs';
16
17
  import { fire, statusOf, storedStatus, workflowFor } from './workflow.mjs';
17
18
  import { completionPhase, inspectCompletionEvidence, withCompletionEvidence } from './ci.mjs';
18
19
 
@@ -24,6 +25,11 @@ const quote = (s) => `"${String(s).replaceAll('"', '""')}"`;
24
25
  const nativeId = (id) => /^t-[a-f0-9]+$/.test(id) ? id.replace('t-', 't_') : id;
25
26
  const RUNTIME = new Set(['claim_lock', 'claim_expires', 'worker_pid', 'last_heartbeat_at', 'current_run_id', 'consecutive_failures', 'last_failure_error', 'block_recurrences', 'session_id', 'workspace_path']);
26
27
  const CARD_MAIN = new Set(['id', 'title', 'body', 'status', 'assignee', 'priority', 'workspace_kind', 'workspace_path', 'branch_name']);
28
+ // What a run left on a card (its result, who and when): the runtime store's, never the document's (company RFC 0025
29
+ // decision 4; ADR 0001 point 1). A document edit keeps the store's values.
30
+ const RUN_OUTPUT = new Set(['result', 'created_by', 'created_at', 'started_at', 'completed_at', 'idempotency_key']);
31
+ const declared = (key, value) => value != null && !RUNTIME.has(key) && !CARD_MAIN.has(key) && !RUN_OUTPUT.has(key) && !(key === 'goal_mode' && value === 0);
32
+ const ENDED = new Set(['done', 'cancelled']);
27
33
  const CLOCKS = new Set(['since', 'after_fired', 'next_run_at', 'workspace_root']);
28
34
  const atomic = (path, text) => { const tmp = `${path}.${process.pid}.tmp`; writeFileSync(tmp, text, { mode: 0o600 }); renameSync(tmp, path); };
29
35
  const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
@@ -63,10 +69,16 @@ export function openZtrackBoard(marker, { create = false, document = null, seed
63
69
  }
64
70
 
65
71
  class ZtrackBoard {
66
- constructor({ document, seed, marker, init, root, slug, compatPath }) {
72
+ constructor({ document, seed, marker, init, root, slug, compatPath, commit = false, manager = null }) {
67
73
  this.path = resolve(dirname(marker), document);
68
74
  this.archive = `${this.path}.archive.md`;
69
75
  this.docs = [{ role: 'open', path: this.path }, { role: 'archived', path: this.archive }];
76
+ // `"commit": true` in the board's ztrack.json: the documents live in a git checkout, and each write is committed as
77
+ // its doer and pushed, after taking what was pushed (git-document.mjs; company RFC 0025 decision 3).
78
+ // `"manager"`: the board's manager (its principal or session address), whose writes it commits itself as part of its
79
+ // own work; the backing commits only the writes the manager does not make.
80
+ this.manager = manager;
81
+ this.git = commit ? new GitDocument([this.path, this.archive], (base, theirs, ours, path) => { try { return this.codec.mergeBoardDocument(base, theirs, ours, path)?.text ?? null; } catch { return null; } }) : null;
70
82
  this.seed = seed; this.root = root; this.slug = slug; this.init = init; this.compatPath = compatPath;
71
83
  this.file = `${marker}.board.db`;
72
84
  mkdirSync(dirname(this.path), { recursive: true });
@@ -321,6 +333,8 @@ class ZtrackBoard {
321
333
  // ------------------------------------------------------------------ writes
322
334
 
323
335
  tx(fn) {
336
+ // A pull request merged on the remote (a page edited in Git Notes) is in the document before this write reads it.
337
+ try { this.git?.pull(); } catch (error) { process.stderr.write(`board: ${error.message}\n`); }
324
338
  if (this.active) return fn(this.db);
325
339
  for (let attempt = 0; ; attempt++) {
326
340
  const hermes = this.seed ? null : this.prepareHermes();
@@ -358,7 +372,13 @@ class ZtrackBoard {
358
372
  throw error;
359
373
  } finally { this.active = false; }
360
374
  this.seed = null;
361
- if (outcome.intent) this.publish(outcome);
375
+ if (outcome.intent) {
376
+ this.publish(outcome);
377
+ // Committed as the session (or person) whose act this write was, and pushed.
378
+ const byManager = this.manager !== null && (actorOf() === this.manager || principalOf() === this.manager);
379
+ if (!byManager) try { this.git?.commit(actorOf(), `board: ${outcome.files.map((file) => basename(file.path)).join(', ')} written for ${actorOf() ?? 'the board'}`); }
380
+ catch (error) { process.stderr.write(`board: ${error.message}\n`); }
381
+ }
362
382
  this.exportFollowing();
363
383
  return outcome.result;
364
384
  }
@@ -468,7 +488,10 @@ class ZtrackBoard {
468
488
  subtasks.push(tid);
469
489
  }
470
490
  plan.subtasks.set(id, subtasks);
471
- for (const old of this.statement('SELECT task_id FROM main.supercode_cards WHERE subtask_of = ?').all(id)) if (!subtasks.includes(old.task_id)) this.planRemoval(plan, old.task_id);
491
+ // An ended subtask is kept in the archive document, not in its card's text: its absence there is no removal.
492
+ for (const old of this.statement('SELECT s.task_id, s.stage, t.status FROM main.supercode_cards s LEFT JOIN main.tasks t ON t.id = s.task_id WHERE s.subtask_of = ?').all(id)) {
493
+ if (!subtasks.includes(old.task_id) && !ENDED.has(old.stage ?? old.status)) this.planRemoval(plan, old.task_id);
494
+ }
472
495
  }
473
496
  // A task ref (`<arc>:c3`) names a subtask by its key; resolve it once every key is known.
474
497
  const keyed = new Map([...plan.tasks].map(([tid, { key }]) => [key, tid]));
@@ -663,11 +686,13 @@ class ZtrackBoard {
663
686
 
664
687
  // A card's rows from its parsed document form. Run fields and clocks stay the table's.
665
688
  cardRows(id, card, parent = null) {
666
- const meta = card.metadata ?? {};
689
+ // An ended subtask kept in the archive document names its card (`subtask_of`); it is not a column of the task.
690
+ const { subtask_of: declaredParent, ...meta } = card.metadata ?? {};
691
+ parent ??= declaredParent ? nativeId(declaredParent) : null;
667
692
  const stored = this.preset.serializeKanbanCard({ ...card, acceptanceCriteria: [], relations: [], metadata: undefined, paused: undefined, every: undefined, workspace: undefined, branch: undefined, machine: undefined, priority: undefined });
668
693
  const workspace = /^(scratch|dir|worktree)(?::(.*))?$/.exec(card.workspace ?? 'scratch');
669
694
  const old = this.one('SELECT * FROM main.tasks WHERE id = ?', id) ?? {};
670
- const runtime = Object.fromEntries(Object.entries(old).filter(([k]) => RUNTIME.has(k)));
695
+ const runtime = Object.fromEntries(Object.entries(old).filter(([k]) => RUNTIME.has(k) || RUN_OUTPUT.has(k)));
671
696
  const task = { ...meta, ...runtime, id, title: card.title, body: stored.body,
672
697
  status: card.status, assignee: card.assignee ?? null, created_at: meta.created_at ?? old.created_at ?? now(), priority: card.priority ?? 0,
673
698
  workspace_kind: workspace?.[1] ?? 'scratch', workspace_path: runtime.workspace_path ?? (/^(\/|~|[A-Za-z]:[\\/])/.test(workspace?.[2] ?? '') ? workspace[2] : null), branch_name: card.branch ?? null };
@@ -685,10 +710,17 @@ class ZtrackBoard {
685
710
  return card.acceptanceCriteria.map((task) => {
686
711
  const key = `${id}:${task.id}`;
687
712
  const tid = task.metadata?.id ?? this.one('SELECT id FROM main.board_tasks WHERE key = ?', key)?.id ?? `t_${randomBytes(4).toString('hex')}`;
688
- const taskCard = { ...task.metadata?.card, id: tid, title: `${task.id} ${task.text}`, body: task.metadata?.body ?? '',
689
- status: task.status === 'passed' ? (task.metadata?.status === 'cancelled' ? 'cancelled' : 'done') : (['done', 'cancelled'].includes(task.metadata?.status) ? 'todo' : task.metadata?.status ?? 'todo'),
690
- metadata: task.metadata?.properties ?? {}, assignee: task.metadata?.assignee ?? card.assignee,
691
- machine: task.metadata?.machine ?? card.machine, acceptanceCriteria: [], acceptance: task.lines ?? [],
713
+ // A task line carries its brief and its tick; what else the subtask holds is the store's (an older document's
714
+ // `metadata:` line is still read).
715
+ const stored = task.metadata ? null : this.one('SELECT * FROM main.tasks WHERE id = ?', tid);
716
+ const storedState = task.metadata ? null : this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', tid);
717
+ const was = task.metadata?.status ?? storedState?.stage ?? stored?.status;
718
+ const taskCard = { ...task.metadata?.card, ...(storedState ? { paused: json(storedState.paused, null) ?? undefined, every: storedState.every || undefined } : {}),
719
+ id: tid, title: `${task.id} ${task.text}`, body: task.metadata?.body ?? task.brief ?? '',
720
+ status: task.status === 'passed' ? (was === 'cancelled' ? 'cancelled' : 'done') : (ENDED.has(was) ? 'todo' : was ?? 'todo'),
721
+ metadata: task.metadata?.properties ?? Object.fromEntries(Object.entries(stored ?? {}).filter(([k, v]) => declared(k, v))),
722
+ assignee: task.metadata?.assignee ?? stored?.assignee ?? card.assignee,
723
+ machine: task.metadata?.machine ?? storedState?.machine ?? card.machine, acceptanceCriteria: [], acceptance: task.lines ?? [],
692
724
  relations: (task.blockedBy ?? []).map((b) => ({ type: 'blocked-by', issueId: b.ac && /^(?:[ma]-[0-9a-f]{6,}|t_[0-9a-f]+)$/.test(b.ac) ? b.ac : b.ac ? `${nativeId(b.issue)}:${b.ac}` : nativeId(b.issue) })) };
693
725
  if (task.waitingOn) taskCard.relations.push({ type: 'blocked-by', issueId: task.waitingOn });
694
726
  return [tid, taskCard, key, task.sources];
@@ -731,6 +763,22 @@ class ZtrackBoard {
731
763
  const at = where.get(id);
732
764
  if (!task) { if (at) edits[at.role].push({ index: at.index, remove: true }); continue; }
733
765
  const role = state.archived || ['done', 'cancelled'].includes(state.stage ?? task.status) ? 'archived' : 'open';
766
+ // Its ended subtasks: each its own entry in the archive document while the card is open (the open document lists
767
+ // only what is still to do); back in the card's own text once the card has ended.
768
+ for (const sub of this.statement('SELECT t.* FROM main.tasks t JOIN main.supercode_cards s ON s.task_id = t.id WHERE s.subtask_of = ?').all(id)) {
769
+ const subState = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', sub.id) ?? {};
770
+ const subAt = where.get(sub.id);
771
+ if (role !== 'open' || !ENDED.has(subState.stage ?? sub.status)) { if (subAt) edits[subAt.role].push({ index: subAt.index, remove: true }); continue; }
772
+ const subRecord = this.subtaskRecord(sub.id, sub, subState, id, where);
773
+ const subRec = hash(JSON.stringify(subRecord));
774
+ if (subAt?.role === 'archived' && subAt.entry.rec === subRec) continue;
775
+ let subText;
776
+ try { subText = this.codec.renderBoardCard(subRecord, subAt?.role === 'archived' ? subAt.entry.level : 2, this.docOf('archived').path); }
777
+ catch (error) { this.errors.set(sub.id, error.message); continue; }
778
+ const subEntry = { cid: null, id: subRecord.id, level: subAt?.role === 'archived' ? subAt.entry.level : 2, len: subText.length, rec: subRec, text: subText };
779
+ if (subAt?.role === 'archived') edits.archived.push({ index: subAt.index, entry: subEntry });
780
+ else { if (subAt) edits[subAt.role].push({ index: subAt.index, remove: true }); edits.archived.push({ entry: subEntry }); }
781
+ }
734
782
  const record = this.recordOf(id, task, state, where);
735
783
  const rec = hash(JSON.stringify(record));
736
784
  if (at?.role === role && at.entry.rec === rec) continue;
@@ -794,16 +842,23 @@ class ZtrackBoard {
794
842
  const keyOf = (x) => this.one('SELECT key FROM main.board_tasks WHERE id = ?', x)?.key ?? null;
795
843
  const parse = (record) => this.preset.KanbanRootSchema.parse(this.preset.parseKanban([record])).issues[0];
796
844
  const parsed = parse({ id: fileId(id), title: row.title, status: c.stage ?? row.status, assignee: row.assignee || undefined, body: row.body ?? '' });
797
- const metadata = Object.fromEntries(Object.entries(row).filter(([k, v]) => !RUNTIME.has(k) && !CARD_MAIN.has(k) && v != null));
845
+ const metadata = Object.fromEntries(Object.entries(row).filter(([k, v]) => declared(k, v)));
798
846
  if (c.archived) metadata.archived = true;
799
847
  if (json(c.blocked_by, []).length) metadata.blocked_by = json(c.blocked_by, []);
800
848
  const card = { ...parsed, metadata, priority: row.priority ?? 0, workspace: row.workspace_kind + (row.workspace_kind !== 'scratch' && (c.workspace_anchor ?? row.workspace_path) ? `:${c.workspace_anchor ?? row.workspace_path}` : ''),
801
849
  branch: row.branch_name || undefined, machine: c.machine || undefined, paused: json(c.paused, null) ?? undefined, every: c.every || undefined,
802
850
  // Task lines left in a stored body (create and specify file them as subtasks) stay in its Tasks, before its subtasks.
803
851
  relations: json(c.blocked_by, []).map((b) => ({ type: 'blocked-by', issueId: fileId(b.id) })), acceptanceCriteria: [...(parsed.acceptanceCriteria ?? [])] };
804
- const rows = this.statement('SELECT t.* FROM main.tasks t JOIN main.supercode_cards s ON s.task_id = t.id WHERE s.subtask_of = ? ORDER BY t.created_at, t.id').all(id);
852
+ // An open card lists its open subtasks; its ended ones are kept in the archive document (subtaskRecord), so the open
853
+ // document stays readable and small. An ended card keeps all of them.
854
+ const cardEnded = c.archived || ENDED.has(c.stage ?? row.status);
855
+ const all = this.statement('SELECT t.* FROM main.tasks t JOIN main.supercode_cards s ON s.task_id = t.id WHERE s.subtask_of = ? ORDER BY t.created_at, t.id').all(id);
856
+ const rows = all.filter((r) => cardEnded || !ENDED.has(this.one('SELECT stage FROM main.supercode_cards WHERE task_id = ?', r.id)?.stage ?? r.status));
805
857
  const states = new Map(rows.map((r) => [r.id, this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', r.id)]));
806
858
  const keys = new Map(), taken = new Set(card.acceptanceCriteria.map((task) => task.id));
859
+ // An ended subtask, kept in the archive, still holds its key: a new one never takes it, so a line can never be bound
860
+ // back to a finished subtask by a reused key.
861
+ for (const r of all) { if (rows.includes(r)) continue; const key = keyOf(r.id)?.split(':').at(-1) ?? /^([a-z]+\d+)\s/.exec(r.title)?.[1]; if (key) taken.add(key); }
807
862
  for (const r of rows) { const key = keyOf(r.id)?.split(':').at(-1) ?? /^([a-z]+\d+)\s/.exec(r.title)?.[1]; if (key && !taken.has(key)) { keys.set(r.id, key); taken.add(key); } }
808
863
  for (const r of rows) {
809
864
  if (keys.has(r.id)) continue;
@@ -818,10 +873,10 @@ class ZtrackBoard {
818
873
  const task = parse({ id: r.id, title: r.title, status, body: r.body ?? '' });
819
874
  const blocks = json(s.blocked_by, []);
820
875
  const messages = blocks.filter((block) => block.kind === 'message');
821
- card.acceptanceCriteria.push({ sources: json(stored?.sources, undefined) ?? undefined, id: key, status: ['done', 'cancelled'].includes(status) ? 'passed' : 'pending', evidence: [], text: match?.[2] ?? r.title,
822
- metadata: { id: r.id, status, body: task.body, assignee: r.assignee, machine: s.machine,
823
- properties: Object.fromEntries(Object.entries(r).filter(([k, v]) => !RUNTIME.has(k) && !CARD_MAIN.has(k) && v != null)),
824
- card: { paused: json(s.paused, null) ?? undefined, every: s.every || undefined, archived: s.archived ? true : undefined } }, lines: task.acceptance,
876
+ // A subtask is its line and its brief (its body, as Markdown under it); its id, status words, assignee and run
877
+ // output stay in the store (company RFC 0025 decision 4).
878
+ card.acceptanceCriteria.push({ sources: json(stored?.sources, undefined) ?? undefined, id: key, status: ENDED.has(status) ? 'passed' : 'pending', evidence: [], text: match?.[2] ?? r.title,
879
+ ...(task.body ? { brief: task.body } : {}), lines: task.acceptance,
825
880
  waitingOn: messages[0]?.id,
826
881
  blockedBy: blocks.filter((block) => block !== messages[0]).map((block) => {
827
882
  const target = keyOf(block.id);
@@ -834,6 +889,16 @@ class ZtrackBoard {
834
889
  return { id: card.id, title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
835
890
  }
836
891
 
892
+ // An ended subtask's own record in the archive document: its title and brief, and the card it belongs to.
893
+ subtaskRecord(id, task, state, parentId, where) {
894
+ const fileId = (x) => where.get(x)?.entry.id ?? x;
895
+ const parsed = this.preset.KanbanRootSchema.parse(this.preset.parseKanban([{ id: fileId(id), title: task.title, status: state.stage ?? task.status, assignee: task.assignee || undefined, body: task.body ?? '' }])).issues[0];
896
+ const metadata = { subtask_of: fileId(parentId), ...Object.fromEntries(Object.entries(task).filter(([k, v]) => declared(k, v))) };
897
+ const card = { ...parsed, metadata, machine: state.machine || undefined, relations: [] };
898
+ const serialized = this.preset.serializeKanbanCard(card);
899
+ return { id: card.id, title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
900
+ }
901
+
837
902
  // ------------------------------------------------------------------ migration from a SQLite board
838
903
 
839
904
  indexForSeed() {
package/gates.mjs CHANGED
@@ -68,6 +68,9 @@ export function scriptTimeoutSeconds(profile, env = process.env) {
68
68
  function scriptEnv() {
69
69
  const env = {};
70
70
  for (const k of ['PATH', 'HOME', 'LANG', 'LC_ALL', 'TZ', 'TMPDIR', 'USER', 'SHELL']) if (process.env[k] !== undefined) env[k] = process.env[k];
71
+ // A script's `supercode` is the one this daemon runs, as an agent worker's is: its folder leads PATH
72
+ const own = process.env.SUPERCODE_BIN ? dirname(process.env.SUPERCODE_BIN) : null;
73
+ if (own) env.PATH = [own, ...(env.PATH ?? '').split(':').filter(dir => dir && dir !== own)].join(':');
71
74
  return env;
72
75
  }
73
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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": {
@@ -25,9 +25,9 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@volter/supercode-harness-sdk": "^0.3.41",
28
- "@volter/ztrack": "^2.4.21",
28
+ "@volter/ztrack": "^2.4.23",
29
29
  "yaml": "^2.9.0",
30
- "@volter/teams": "0.2.1"
30
+ "@volter/teams": "0.2.2"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@chat-adapter/state-memory": "4.39.0",