@volter/supercode-orchestrator 0.5.15 → 0.5.17

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' && entry.harness && 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
@@ -7,11 +7,11 @@
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
9
  import { execFileSync, spawn } from 'node:child_process';
10
- import { readFileSync, statSync, writeFileSync } from 'node:fs';
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) => {
@@ -24,6 +24,10 @@ const indexed = (cwd, args) => {
24
24
  };
25
25
 
26
26
  const FETCH_EVERY_MS = 15_000;
27
+ /** The background push: `git push`, then its error kept for `behind()` (or the last one cleared when it landed). */
28
+ const PUSH = `const {spawnSync}=require('node:child_process'),fs=require('node:fs');const [branch,failure,temporary]=process.argv.slice(1);
29
+ const r=spawnSync('git',['push','--quiet','origin','HEAD:'+branch],{encoding:'utf8',windowsHide:true});
30
+ try{if(r.status===0)fs.rmSync(failure,{force:true});else{fs.writeFileSync(temporary,String(r.stderr||r.error||'push failed'));fs.renameSync(temporary,failure);}}catch{}`;
27
31
  /** When this checkout last asked its remote (git writes FETCH_HEAD on every fetch, whichever process ran it). */
28
32
  const fetchedAt = (root) => { try { return statSync(git(root, ['rev-parse', '--git-path', 'FETCH_HEAD']).trim().replace(/^(?!\/)/, `${root}/`)).mtimeMs; } catch { return 0; } };
29
33
 
@@ -84,20 +88,53 @@ export class GitDocument {
84
88
  // 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
89
  // write's pull (unpushed commits), which merges what was pushed and pushes before it writes.
86
90
  // While it runs, this process's next write does not push the same commits beside it.
87
- const pushing = spawn('git', ['push', '--quiet', 'origin', `HEAD:${branch}`], { cwd: this.root, stdio: 'ignore', detached: true });
88
- this.pushing = pushing;
89
- const done = () => { if (this.pushing === pushing) this.pushing = null; };
91
+ // Writes that land while it runs are one more push after it (a burst publishes in two pushes, not one per write).
92
+ if (this.pushing) { this.again = true; return; }
93
+ this.pushInBackground(branch);
94
+ }
95
+
96
+ /** `git push` beside the process, outliving it. Its refusal is kept where `behind()` reads it, and cleared by a push
97
+ * that lands. */
98
+ pushInBackground(branch) {
99
+ const failure = this.failurePath();
100
+ // Node, not a shell: a Windows checkout outside Git Bash has no `sh`. Each push keeps its error in a file of its own
101
+ // until it ends, so two pushes never write one.
102
+ const pushing = spawn(process.execPath, ['-e', PUSH, branch, failure, `${failure}.${process.pid}.${Date.now()}.tmp`], { cwd: this.root, stdio: 'ignore', detached: true, windowsHide: true });
103
+ this.pushing = pushing; this.again = false;
104
+ const done = () => {
105
+ if (this.pushing !== pushing) return;
106
+ this.pushing = null;
107
+ if (this.again) this.pushInBackground(branch);
108
+ };
90
109
  pushing.on('error', done); pushing.on('exit', done);
91
110
  pushing.unref();
92
111
  }
93
112
 
113
+ failurePath() {
114
+ return git(this.root, ['rev-parse', '--git-path', 'supercode-board-push.err']).trim().replace(/^(?!\/)/, `${this.root}/`);
115
+ }
116
+
117
+ /** The board documents' publish when it is behind: commits the remote lacks, the oldest one's time and the last push's
118
+ * error; null when the remote has everything (or this checkout pushes nowhere). Local reads only, no network. */
119
+ behind() {
120
+ if (!this.usable) return null;
121
+ const branch = this.branch();
122
+ if (!branch) return null;
123
+ const times = (quiet(this.root, ['log', '--format=%ct', `origin/${branch}..HEAD`]) ?? '').trim().split('\n').filter(Boolean).map(Number);
124
+ if (!times.length) return null;
125
+ let error = null;
126
+ try { error = readFileSync(this.failurePath(), 'utf8').split('\n').map((line) => line.replace(/\s+/g, ' ').trim()).find(Boolean) ?? null; } catch { /* none recorded */ }
127
+ return { commits: times.length, since: Math.min(...times) * 1000, error };
128
+ }
129
+
94
130
  /** The board's round: publish what an earlier write committed and the remote has not taken (a refused or failed
95
131
  * background push), with the same merge and three attempts a write's pull uses. */
96
132
  publishPending() {
97
133
  if (!this.usable || this.pushing) return;
98
134
  const branch = this.branch();
99
135
  if (!branch || !this.unpushed(branch)) return;
100
- if (quiet(this.root, ['fetch', '--quiet', 'origin', branch]) === null) return; // offline: the next round tries again
136
+ // A remote that hangs costs the round 15 s, not git's two minutes.
137
+ if (quiet(this.root, ['fetch', '--quiet', 'origin', branch], 15_000) === null) return; // offline: the next round tries again
101
138
  if (this.unpushed(branch)) this.publish(branch, 'board');
102
139
  }
103
140
 
@@ -109,7 +146,7 @@ export class GitDocument {
109
146
  /** Push what this checkout holds, taking anything pushed meanwhile. */
110
147
  publish(branch, who) {
111
148
  for (let attempt = 0; attempt < 3; attempt++) {
112
- if (quiet(this.root, ['push', '--quiet', 'origin', `HEAD:${branch}`]) !== null) return;
149
+ if (quiet(this.root, ['push', '--quiet', 'origin', `HEAD:${branch}`]) !== null) { try { rmSync(this.failurePath(), { force: true }); } catch { /* none */ } return; }
113
150
  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');
114
151
  this.integrate(branch, who);
115
152
  }
package/board/ztrack.mjs CHANGED
@@ -72,6 +72,8 @@ export function openZtrackBoard(marker, { create = false, document = null, seed
72
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.15",
3
+ "version": "0.5.17",
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": {