@volter/supercode-orchestrator 0.5.14 → 0.5.16

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.
@@ -46,7 +46,7 @@ function sessionsOf(db, taskId) {
46
46
  * own transcript reports it runs, or null where none can be read; `machineOf` normalises a machine name; `lanes` gives
47
47
  * a profile's harness and model.
48
48
  */
49
- export function holdings(board, { lanes, fleetOk, alive, liveRecord, modelOf = () => null, storedOf = null, machineOf }) {
49
+ export function holdings(board, { lanes, fleetOk, alive, liveRecord, modelOf = () => null, storedOf = null, machineOf, home = null }) {
50
50
  const out = [];
51
51
  const rows = board.db.prepare("SELECT t.id, t.status, t.assignee, t.current_run_id, t.model_override, c.machine, c.subtask_of FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.status != 'archived'").all();
52
52
  for (const row of rows) {
@@ -56,7 +56,8 @@ export function holdings(board, { lanes, fleetOk, alive, liveRecord, modelOf = (
56
56
  // what the board stores for an arc is read through its workflow's stored_as rules now, never the column a write has
57
57
  // not yet brought up to date (a paused card counted as running until its row caught up)
58
58
  stored: !row.subtask_of && storedOf ? storedOf(row.id) : row.status,
59
- machine: row.machine ? machineOf(row.machine) : null,
59
+ // a card that names no machine runs on this home's: a cap on this machine counts it
60
+ machine: row.machine ? machineOf(row.machine) : home,
60
61
  model: row.model_override ?? lane.model ?? null, harness: lane.harness ?? null,
61
62
  };
62
63
  // a session's model: what its transcript reports, else what its launch resolved, else the card's
@@ -165,7 +166,10 @@ export function capacity(caps, held, evaluate) {
165
166
  for (const counts of ['runs', 'cards', 'sessions', 'turns', 'starts']) {
166
167
  const had = things.find((x) => x.card === entry.card && x.counts === counts);
167
168
  // the card is counted already: it is now in the status its start moved it to
168
- if (had) { if (counts === 'cards') Object.assign(had, { status: entry.status, stored: entry.stored, model: had.model ?? entry.model, harness: had.harness ?? entry.harness }); }
169
+ // the start is the card's next run, in its assignee's harness and model now (a card reassigned from Codex to
170
+ // Claude Code counts against Claude Code's caps from this start, not as the harness its last session ran)
171
+ if (had && counts === 'sessions' && had.harness !== entry.harness) things.push({ ...entry, counts });
172
+ else if (had) { if (counts !== 'sessions' && counts !== 'turns') Object.assign(had, { status: entry.status, stored: entry.stored, model: entry.model ?? had.model, harness: entry.harness ?? had.harness, machine: entry.machine ?? had.machine }); }
169
173
  else things.push({ ...entry, counts });
170
174
  }
171
175
  },
@@ -459,7 +459,7 @@ function homeHoldings(root, slugs, lanes, fleet, entry) {
459
459
  try {
460
460
  b.slug ??= slug; b.label ??= boardLabel(root, slug);
461
461
  const storedOf = (id) => storedStatus(b.db, b, entry, id).stored;
462
- out.push(...holdings(b, { lanes, fleetOk: fleet.ok, alive: (s) => sessionAlive(fleet, s), liveRecord: live, modelOf: transcriptModel, storedOf, machineOf: normalMachine }));
462
+ out.push(...holdings(b, { lanes, fleetOk: fleet.ok, alive: (s) => sessionAlive(fleet, s), liveRecord: live, modelOf: transcriptModel, storedOf, machineOf: normalMachine, home: localMachine() }));
463
463
  }
464
464
  finally { b.close(); }
465
465
  } catch { /* its own tick reports it */ }
@@ -569,7 +569,7 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
569
569
  });
570
570
  // the caps, over what the home holds now; what each candidate would be once started
571
571
  const caps = capacity(d.capacity, held, evaluate);
572
- const entryOf = (c) => ({ card: c.task.id, board: board.slug, machine: c.task.machine ? normalMachine(c.task.machine) : null, assignee: c.plan.profile ?? null,
572
+ const entryOf = (c) => ({ card: c.task.id, board: board.slug, machine: normalMachine(c.task.machine ?? localMachine()), assignee: c.plan.profile ?? null,
573
573
  status: c.plan.target, stored: c.plan.target, model: c.task.model_override ?? c.plan.lane?.model ?? held.defaults?.[c.plan.lane?.harness] ?? null, harness: c.plan.lane?.harness ?? null });
574
574
  const waitingEntries = candidates.map(entryOf);
575
575
  // a dry run (dispatch --dry-run) plans the same way and writes nothing: no notes, no moves, no launches
@@ -2192,6 +2192,9 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
2192
2192
  try {
2193
2193
  board = openBoard(root, slug);
2194
2194
  board.label = boardLabel(root, slug);
2195
+ // A board document committed by a write whose push the remote refused (or never answered) is published by the
2196
+ // round, not left for the next write: a quiet board still reaches its remote.
2197
+ try { board.publishPending?.(); } catch (error) { report.queued.push(['board document', `push pending: ${error.message}`]); }
2195
2198
  // what the dispatcher records on its own is the board's act
2196
2199
  setActor(boardAddress(board.label));
2197
2200
  // where the tick's time goes, phase by phase (ms), printed by `serve --verbose` and in `--json`
@@ -6,12 +6,12 @@
6
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
7
  // either side wrote is dropped; a merge that cannot be made stops the push and says why, leaving both texts in the
8
8
  // checkout's history.
9
- import { execFileSync } from 'node:child_process';
10
- import { readFileSync, writeFileSync } from 'node:fs';
9
+ import { execFileSync, spawn } from 'node:child_process';
10
+ import { readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
11
  import { basename, dirname, relative } from 'node:path';
12
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; } };
13
+ const git = (cwd, args, input, timeout = 120_000) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], ...(input === undefined ? {} : { input }), timeout });
14
+ const quiet = (cwd, args, timeout) => { try { return git(cwd, args, undefined, timeout); } catch { return null; } };
15
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
16
  // raced. The board keeps no lock of its own (ADR 0003).
17
17
  const indexed = (cwd, args) => {
@@ -23,6 +23,10 @@ const indexed = (cwd, args) => {
23
23
  }
24
24
  };
25
25
 
26
+ const FETCH_EVERY_MS = 15_000;
27
+ /** When this checkout last asked its remote (git writes FETCH_HEAD on every fetch, whichever process ran it). */
28
+ const fetchedAt = (root) => { try { return statSync(git(root, ['rev-parse', '--git-path', 'FETCH_HEAD']).trim().replace(/^(?!\/)/, `${root}/`)).mtimeMs; } catch { return 0; } };
29
+
26
30
  export class GitDocument {
27
31
  constructor(paths, merge) {
28
32
  this.paths = paths;
@@ -46,11 +50,16 @@ export class GitDocument {
46
50
  this.warnedDetached = true;
47
51
  }
48
52
 
49
- // Before a write: take what was pushed, so a merged pull request's edit is the document the write starts from.
53
+ // Before a write: take what was pushed, so a merged pull request's edit is the document the write starts from. The
54
+ // remote is asked at most every FETCH_EVERY_MS (git's own FETCH_HEAD is the stamp, so every board process shares it):
55
+ // a fetch on every write put a network round trip on each one. A checkout holding commits its background push did
56
+ // not land (refused: something was pushed meanwhile) takes what was pushed now and pushes before writing again.
50
57
  pull() {
51
58
  if (!this.usable) return;
52
59
  const branch = this.branch();
53
60
  if (!branch) return this.detached();
61
+ if (this.unpushed(branch)) return this.pushing ? undefined : this.publish(branch, 'board');
62
+ if (Date.now() - fetchedAt(this.root) < FETCH_EVERY_MS) return;
54
63
  if (quiet(this.root, ['fetch', '--quiet', 'origin', branch]) === null) return; // offline: the next write tries again
55
64
  const behind = Number(git(this.root, ['rev-list', '--count', `HEAD..origin/${branch}`]).trim());
56
65
  if (behind === 0) return;
@@ -72,8 +81,66 @@ export class GitDocument {
72
81
  indexed(this.root, ['-c', `user.name=${who}`, '-c', 'user.email=board@supercode.invalid', 'commit', '--quiet', '--author', `${who} <board@supercode.invalid>`, '-m', message, '--', ...files]);
73
82
  const branch = this.branch();
74
83
  if (!branch) return this.detached();
84
+ // The push leaves the write's path: the commit is the write, and a push the remote refuses is taken up by the next
85
+ // write's pull (unpushed commits), which merges what was pushed and pushes before it writes.
86
+ // While it runs, this process's next write does not push the same commits beside it.
87
+ // Writes that land while it runs are one more push after it (a burst publishes in two pushes, not one per write).
88
+ if (this.pushing) { this.again = true; return; }
89
+ this.pushInBackground(branch);
90
+ }
91
+
92
+ /** `git push` beside the process, outliving it. Its refusal is kept where `behind()` reads it, and cleared by a push
93
+ * that lands. */
94
+ pushInBackground(branch) {
95
+ const failure = this.failurePath();
96
+ const pushing = spawn('sh', ['-c', 'git push --quiet origin "HEAD:$1" 2>"$2.tmp" && rm -f "$2" "$2.tmp" || mv -f "$2.tmp" "$2"', 'push', branch, failure], { cwd: this.root, stdio: 'ignore', detached: true });
97
+ this.pushing = pushing; this.again = false;
98
+ const done = () => {
99
+ if (this.pushing !== pushing) return;
100
+ this.pushing = null;
101
+ if (this.again) this.pushInBackground(branch);
102
+ };
103
+ pushing.on('error', done); pushing.on('exit', done);
104
+ pushing.unref();
105
+ }
106
+
107
+ failurePath() {
108
+ return git(this.root, ['rev-parse', '--git-path', 'supercode-board-push.err']).trim().replace(/^(?!\/)/, `${this.root}/`);
109
+ }
110
+
111
+ /** The board documents' publish when it is behind: commits the remote lacks, the oldest one's time and the last push's
112
+ * error; null when the remote has everything (or this checkout pushes nowhere). Local reads only, no network. */
113
+ behind() {
114
+ if (!this.usable) return null;
115
+ const branch = this.branch();
116
+ if (!branch) return null;
117
+ const times = (quiet(this.root, ['log', '--format=%ct', `origin/${branch}..HEAD`]) ?? '').trim().split('\n').filter(Boolean).map(Number);
118
+ if (!times.length) return null;
119
+ let error = null;
120
+ try { error = readFileSync(this.failurePath(), 'utf8').split('\n').map((line) => line.replace(/\s+/g, ' ').trim()).find(Boolean) ?? null; } catch { /* none recorded */ }
121
+ return { commits: times.length, since: Math.min(...times) * 1000, error };
122
+ }
123
+
124
+ /** The board's round: publish what an earlier write committed and the remote has not taken (a refused or failed
125
+ * background push), with the same merge and three attempts a write's pull uses. */
126
+ publishPending() {
127
+ if (!this.usable || this.pushing) return;
128
+ const branch = this.branch();
129
+ if (!branch || !this.unpushed(branch)) return;
130
+ // A remote that hangs costs the round 15 s, not git's two minutes.
131
+ if (quiet(this.root, ['fetch', '--quiet', 'origin', branch], 15_000) === null) return; // offline: the next round tries again
132
+ if (this.unpushed(branch)) this.publish(branch, 'board');
133
+ }
134
+
135
+ /** Whether this checkout holds board commits the remote does not have. */
136
+ unpushed(branch) {
137
+ return Number(quiet(this.root, ['rev-list', '--count', `origin/${branch}..HEAD`])?.trim() || 0) > 0;
138
+ }
139
+
140
+ /** Push what this checkout holds, taking anything pushed meanwhile. */
141
+ publish(branch, who) {
75
142
  for (let attempt = 0; attempt < 3; attempt++) {
76
- if (quiet(this.root, ['push', '--quiet', 'origin', `HEAD:${branch}`]) !== null) return;
143
+ if (quiet(this.root, ['push', '--quiet', 'origin', `HEAD:${branch}`]) !== null) { try { rmSync(this.failurePath(), { force: true }); } catch { /* none */ } return; }
77
144
  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');
78
145
  this.integrate(branch, who);
79
146
  }
package/board/ztrack.mjs CHANGED
@@ -69,9 +69,11 @@ export function openZtrackBoard(marker, { create = false, document = null, seed
69
69
  const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug, compatPath });
70
70
  // A board made here is in Hermes's kanban.db at once, not after its first write.
71
71
  if (create) backing.exportFollowing();
72
- return { db: backing.db, tx: (fn) => backing.tx(fn), close: () => backing.close(), document: backing.path };
72
+ return { db: backing.db, tx: (fn) => backing.tx(fn), publishPending: () => backing.git?.publishPending(), close: () => backing.close(), document: backing.path };
73
73
  }
74
74
 
75
+ const behindSaid = new Set();
76
+
75
77
  class ZtrackBoard {
76
78
  constructor({ document, seed, marker, init, root, slug, compatPath, commit = false, manager = null }) {
77
79
  this.path = resolve(dirname(marker), document);
@@ -83,6 +85,14 @@ class ZtrackBoard {
83
85
  // own work; the backing commits only the writes the manager does not make.
84
86
  this.manager = manager;
85
87
  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;
88
+ // A publish that is behind is said once per process, on whichever workflow verb opened the board: a refused push
89
+ // surfaces on the next call instead of only in the dispatcher's report, and stops being said once the push lands.
90
+ if (this.git && !behindSaid.has(this.path)) {
91
+ behindSaid.add(this.path);
92
+ const late = this.git.behind();
93
+ // A push younger than a minute is still on its way.
94
+ if (late && Date.now() - late.since > 60_000) process.stderr.write(`board: warning: ${late.commits} board document commit${late.commits === 1 ? '' : 's'} not published since ${new Date(late.since).toISOString().slice(0, 16)}Z${late.error ? ` (last push: ${late.error})` : ''}; the dispatcher's next round retries\n`);
95
+ }
86
96
  this.seed = seed; this.root = root; this.slug = slug; this.init = init; this.compatPath = compatPath;
87
97
  this.file = `${marker}.board.db`;
88
98
  mkdirSync(dirname(this.path), { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.14",
3
+ "version": "0.5.16",
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": {