@volter/supercode-orchestrator 0.5.84 → 0.5.85

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.
@@ -7,55 +7,216 @@
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
  //
10
- // The remote is never waited on (D192): a fetch runs beside the process, and what it brings is merged in one local step
11
- // on this process's loop, serialized with its writes, as a write's own commit is; a push runs beside the process too.
12
- // What is owed is git's own state, never a queue in memory: board documents that differ from HEAD are a commit owed,
13
- // and a HEAD ahead of its upstream is a push owed. A process that ends with either owed leaves it in the checkout, and
14
- // the next one to open each board derives it again and does it (`derive`): after that board's own recovery has written
15
- // what its store accepted, over every document the board has (the open one, its archive and each rotated archive).
10
+ // Git is publication, not durability: the board's store and its documents are the record, and a write answers once they
11
+ // are written. Nothing here runs on the caller's loop or waits on it (t_00f071f4: a busy index once held the board
12
+ // owner's loop 5 s). Every git step of a checkout runs beside the process, one at a time on that checkout's chain
13
+ // (`git` through execFile): a write's commit, a fetch's merge, a push check. Those are still git processes, one set per
14
+ // write, off the loop (gap A41). What is owed survives in git's own state: board documents that differ from HEAD are a
15
+ // commit owed, and a HEAD ahead of its upstream is a push owed. In memory is only each owed write's own text, in order,
16
+ // so that each commit holds its write and no other. A commit needs no lock but HEAD's: HEAD moves by compare-and-swap,
17
+ // and the checkout's index follows it after (never ahead), a busy index tried again on a timer and said once it has
18
+ // held the entries a minute. A process that ends with anything owed leaves it in the checkout, and the next one to open
19
+ // each board derives it again and does it (`derive`: documents that differ from HEAD committed, the board documents'
20
+ // index entries moved to HEAD, an unpushed HEAD pushed).
21
+ //
22
+ // Only the board writes its documents. A write's commit is made from that write's own text, through an index of its
23
+ // own (never the checkout's staged files, never a later write's text). What a fetch brings is merged without touching
24
+ // the working tree (`git merge-tree`), a board document both sides changed merged by the board's own document merge;
25
+ // the merged documents are handed to their boards (every board this process holds in the checkout), each of which
26
+ // writes its own as an edit in its next transaction only if each is still what HEAD holds (a write owed goes first,
27
+ // and the next fetch takes what was pushed again); then git's HEAD and index move to the merge. A write that lands
28
+ // between stays a commit owed, never lost.
29
+ // Where the checkout is, its branch and its per-worktree git paths (what `git rev-parse --git-path` names: FETCH_HEAD
30
+ // and the push error live in a linked worktree's own git dir) are read from `.git` itself, so a board open starts no
31
+ // process. A write does start its commit's git processes, beside the loop (A41).
16
32
  import { current } from './call.mjs';
17
33
  import { execFile, execFileSync, spawn } from 'node:child_process';
18
- import { readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
19
- import { basename, dirname, relative } from 'node:path';
20
-
21
- 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 });
22
- const quiet = (cwd, args, timeout) => { try { return git(cwd, args, undefined, timeout); } catch { return null; } };
23
- // Another board process (or the manager's own git) may hold git's index for a moment: its index.lock is waited out, not
24
- // raced. The board keeps no lock of its own (ADR 0003).
25
- const indexed = (cwd, args) => {
26
- for (let attempt = 0; ; attempt++) {
27
- try { return git(cwd, args); } catch (error) {
28
- if (attempt >= 20 || !/index\.lock/.test(String(error.stderr ?? error.message))) throw error;
29
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250);
34
+ import { readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
35
+ import { basename, dirname, join, relative, resolve } from 'node:path';
36
+
37
+ /** git beside the process: its stdout, or a rejection carrying git's stdout and stderr. `input` is written to its stdin;
38
+ * `env` is added to this process's. */
39
+ const git = (cwd, args, timeout = 120_000, { input = null, env = null } = {}) => new Promise((done, fail) => {
40
+ const child = execFile('git', args, { cwd, encoding: 'utf8', timeout, maxBuffer: 64 * 1024 * 1024, windowsHide: true, ...(env ? { env: { ...process.env, ...env } } : {}) }, (error, stdout, stderr) => {
41
+ if (error) fail(Object.assign(error, { stdout: String(stdout ?? ''), stderr: String(stderr ?? '') })); else done(String(stdout));
42
+ });
43
+ if (input !== null) child.stdin.end(input); else child.stdin.end();
44
+ });
45
+ const quiet = (cwd, args, timeout) => git(cwd, args, timeout).catch(() => null);
46
+ const busyIndex = (error) => /index\.lock/.test(String(error?.stderr ?? error?.message ?? ''));
47
+ // a ref git would not move: its lock held by another git step, or HEAD moving under the commit on every try
48
+ const refHeld = (error) => Boolean(error?.refHeld) || /\.lock\b|cannot lock ref|unable to lock/i.test(`${error?.stderr ?? ''} ${error?.message ?? ''}`);
49
+
50
+ /** The checkout `dir` is in, read from its `.git` (a folder, or a linked worktree's `gitdir:` file): its work tree root,
51
+ * its git dir and its common dir. Null outside a checkout. */
52
+ function checkoutOf(dir) {
53
+ let at; try { at = realpathSync(dir); } catch { return null; }
54
+ for (;;) {
55
+ const dotgit = join(at, '.git');
56
+ let stat = null; try { stat = statSync(dotgit); } catch { /* not here */ }
57
+ if (stat) {
58
+ let gitDir = dotgit;
59
+ if (stat.isFile()) { const named = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotgit, 'utf8')); if (!named) return null; gitDir = resolve(at, named[1].trim()); }
60
+ let common = gitDir; try { common = resolve(gitDir, readFileSync(join(gitDir, 'commondir'), 'utf8').trim()); } catch { /* its own */ }
61
+ return { root: at, gitDir, common };
30
62
  }
63
+ const up = dirname(at); if (up === at) return null; at = up;
31
64
  }
32
- };
65
+ }
33
66
 
34
67
  const FETCH_EVERY_MS = 15_000;
35
- // A fetch beside the process, the network's part: its result is merged (locally, in one step) when it lands.
36
- const fetchAside = (cwd, branch, timeout = 120_000) => new Promise((resolve) => execFile('git', ['fetch', '--quiet', 'origin', branch], { cwd, timeout }, (error) => resolve(!error)));
68
+ // A fetch beside the process, the network's part: its result is merged (on the checkout's chain) when it lands.
69
+ const fetchAside = (cwd, branch, timeout = 120_000) => new Promise((done) => execFile('git', ['fetch', '--quiet', 'origin', branch], { cwd, timeout }, (error) => done(!error)));
37
70
  const fetching = new Map(); // checkout root → the fetch in flight (one at a time per checkout, in this process)
71
+ const chains = new Map(); // checkout root → its git steps, one at a time
38
72
  const derived = new Set(); // boards (by their open document) whose owed commit this process has derived from git's state
39
73
  const pushDerived = new Set(); // checkouts whose owed push this process has derived
74
+ const boardsAt = new Map(); // checkout root → the boards (GitDocument) this process holds in it
75
+ const takings = new Map(); // checkout root → what a fetch brought, with its boards until each has written its part
76
+
77
+ /** One git step of a checkout, after the ones before it; its failure is said, never thrown into the caller. */
78
+ function onChain(root, step) {
79
+ const previous = chains.get(root) ?? Promise.resolve();
80
+ const next = previous.then(step).catch((error) => { current().err(`board: ${error.message}\n`); });
81
+ chains.set(root, next);
82
+ next.finally(() => { if (chains.get(root) === next) chains.delete(root); });
83
+ return next;
84
+ }
85
+
86
+ /** How long a process that is ending waits for its git steps to land. What is still owed then stays in git's state (a
87
+ * document that differs from HEAD, a HEAD ahead of its upstream) for the next process to derive. */
88
+ const SETTLE_MS = 10_000;
89
+
90
+ /** Every fetch and git step in flight, landed, until none is left or SETTLE_MS has passed: a process that exits after a
91
+ * write awaits this first. A commit waiting out a busy index is not waited for: it stays owed, said. */
92
+ export async function settleDocuments(within = SETTLE_MS) {
93
+ const deadline = Date.now() + within;
94
+ for (const boards of boardsAt.values()) for (const board of boards) if (board.retryTimer) {
95
+ clearTimeout(board.retryTimer); board.retryTimer = null;
96
+ current().err(`board: ${board.owed.length} write${board.owed.length === 1 ? '' : 's'} of the board documents not committed as this process ends (git would not move HEAD); ${board.owed.length === 1 ? 'it stays' : 'they stay'} owed in the checkout (documents that differ from HEAD) for the next process\n`);
97
+ }
98
+ for (const [root, moves] of filesOwed) if (moves.timer) { clearTimeout(moves.timer); moves.timer = null; current().err(`board: ${moves.files.size} file(s) in ${root} still differ from HEAD as this process ends (what was pushed, not yet written there)\n`); }
99
+ for (const [root, sync] of indexOwed) if (sync.timer) {
100
+ clearTimeout(sync.timer); sync.timer = null;
101
+ current().err(`board: git's index in ${root} is still locked as this process ends; its board documents' index entries stay behind HEAD until the next process opens the board\n`);
102
+ }
103
+ // until none is left: a step that ran may have queued another (a commit its push follows)
104
+ while (fetching.size || chains.size) {
105
+ const left = deadline - Date.now();
106
+ if (left <= 0) { current().err(`board: ${chains.size + fetching.size} git step(s) of the board documents were still running as this process ended; what they owed stays in the checkout for the next process\n`); return; }
107
+ await Promise.race([Promise.all([...fetching.values(), ...chains.values()]), new Promise((done) => setTimeout(done, left).unref?.())]);
108
+ }
109
+ }
110
+ /** How long a busy index may hold an index sync before it is said (with its age), once per stretch. */
111
+ const BUSY_SAID_MS = 60_000;
112
+ // checkout root → { paths, since, said, delay, timer }: board documents whose entries in the checkout's index are to be
113
+ // moved to HEAD (after a commit or a take moved HEAD), waiting out a busy index.
114
+ const indexOwed = new Map();
40
115
 
41
- /** The fetches in flight, landed and merged, until none is left: a process that exits after a write awaits this first. */
42
- export async function settleDocuments() {
43
- // until none is left: a verb answered while one landed may have started another
44
- while (fetching.size) await Promise.all([...fetching.values()]);
116
+ /** The checkout's index told these paths are as HEAD has them (a path HEAD lacks leaves the index), on the chain. HEAD
117
+ * moves first, by compare-and-swap; the index follows, so it is never ahead of HEAD. A busy index is tried again on a
118
+ * timer, from 250 ms doubling to 30 s, and said with its age once it has held the sync a minute. */
119
+ function syncIndex(root, paths) {
120
+ const sync = indexOwed.get(root) ?? { paths: new Set(), since: null, said: false, delay: 0, timer: null };
121
+ indexOwed.set(root, sync);
122
+ for (const path of paths) sync.paths.add(path);
123
+ if (!sync.timer) onChain(root, () => syncNow(root));
45
124
  }
125
+ async function syncNow(root) {
126
+ const sync = indexOwed.get(root);
127
+ if (!sync || !sync.paths.size) return;
128
+ const paths = [...sync.paths];
129
+ try { await git(root, ['reset', '-q', 'HEAD', '--', ...paths]); }
130
+ catch (error) {
131
+ if (!busyIndex(error)) { indexOwed.delete(root); throw new Error(`the index entries of ${paths.slice(0, 3).join(', ')} could not be moved to HEAD: ${error.message.split('\n')[0]}`); }
132
+ sync.since ??= Date.now();
133
+ if (!sync.said && Date.now() - sync.since >= BUSY_SAID_MS) {
134
+ sync.said = true;
135
+ current().err(`board: git's index in ${root} has been locked for ${Math.round((Date.now() - sync.since) / 1000)} s (its index.lock: another git process, or one that ended holding it); ${paths.length} board document index entr${paths.length === 1 ? 'y stays' : 'ies stay'} behind HEAD until it frees\n`);
136
+ }
137
+ sync.delay = Math.min(30_000, Math.max(250, (sync.delay || 125) * 2));
138
+ sync.timer = setTimeout(() => { sync.timer = null; onChain(root, () => syncNow(root)); }, sync.delay);
139
+ sync.timer.unref?.();
140
+ return;
141
+ }
142
+ for (const path of paths) sync.paths.delete(path);
143
+ if (!sync.paths.size) indexOwed.delete(root);
144
+ }
145
+
146
+ // checkout root → { files: Map(path → { from, to }), since, said, delay, timer }: working files a take moved HEAD past,
147
+ // to be written from `to` (or removed, when `to` lacks them), each only while it is still `from`'s text.
148
+ const filesOwed = new Map();
149
+
150
+ /** The working files a take changed, brought to what HEAD now holds, on the chain: each only while the file is still
151
+ * what the commit before the take held (a file changed here since is left, said). One that fails is tried again on a
152
+ * timer, said with its age after a minute. */
153
+ function moveFiles(root, files, from, to) {
154
+ const moves = filesOwed.get(root) ?? { files: new Map(), since: null, said: false, delay: 0, timer: null };
155
+ filesOwed.set(root, moves);
156
+ for (const file of files) moves.files.set(file, { from, to });
157
+ if (!moves.timer) onChain(root, () => moveNow(root));
158
+ }
159
+ async function moveNow(root) {
160
+ const moves = filesOwed.get(root);
161
+ if (!moves) return;
162
+ let failed = null;
163
+ for (const [file, { from, to }] of [...moves.files]) {
164
+ try {
165
+ const was = (await quiet(root, ['rev-parse', '-q', '--verify', `${from}:${file}`]))?.trim() || null;
166
+ let exists = true; try { statSync(join(root, file)); } catch { exists = false; }
167
+ const now = exists ? (await git(root, ['hash-object', '--', file])).trim() : null;
168
+ if (now !== was) { current().err(`board: ${file} in ${root} changed here since what was pushed was merged; it is left as it is (HEAD has the pushed text)\n`); moves.files.delete(file); continue; }
169
+ const kept = Boolean((await quiet(root, ['rev-parse', '-q', '--verify', `${to}:${file}`]))?.trim());
170
+ if (kept) await git(root, ['checkout', to, '--', file]);
171
+ else rmSync(join(root, file), { force: true });
172
+ moves.files.delete(file);
173
+ } catch (error) { failed ??= error; }
174
+ }
175
+ if (!moves.files.size) { filesOwed.delete(root); return; }
176
+ moves.since ??= Date.now();
177
+ if (!moves.said && Date.now() - moves.since >= BUSY_SAID_MS) {
178
+ moves.said = true;
179
+ current().err(`board: ${moves.files.size} file(s) in ${root} have differed from HEAD for ${Math.round((Date.now() - moves.since) / 1000)} s since what was pushed was taken (${failed?.message.split('\n')[0]}); tried again until written\n`);
180
+ }
181
+ moves.delay = Math.min(30_000, Math.max(250, (moves.delay || 125) * 2));
182
+ moves.timer = setTimeout(() => { moves.timer = null; onChain(root, () => moveNow(root)); }, moves.delay);
183
+ moves.timer.unref?.();
184
+ }
185
+
186
+ /** Whether this git can merge without a working tree (`merge-tree --write-tree`, git 2.38): asked once per process,
187
+ * beside the loop, on the first take; an older git is said once and nothing pushed is taken. */
188
+ let mergeTreeAble = null;
189
+ async function canMergeTree(root) {
190
+ if (mergeTreeAble === null) {
191
+ const version = /(\d+)\.(\d+)/.exec((await quiet(root, ['version'])) ?? '');
192
+ mergeTreeAble = Boolean(version) && (Number(version[1]) > 2 || (Number(version[1]) === 2 && Number(version[2]) >= 38));
193
+ if (!mergeTreeAble) current().err(`board: this git (${version ? version[0] : 'its version unread'}) is older than 2.38 and cannot merge without a working tree; what others pushed to the board documents is not taken until git is updated\n`);
194
+ }
195
+ return mergeTreeAble;
196
+ }
197
+
46
198
  /** The background push: `git push`, then its error kept for `behind()` (or the last one cleared when it landed). */
47
199
  const PUSH = `const {spawnSync}=require('node:child_process'),fs=require('node:fs');const [branch,failure,temporary]=process.argv.slice(1);
48
200
  const r=spawnSync('git',['push','--quiet','origin','HEAD:'+branch],{encoding:'utf8',windowsHide:true});
49
201
  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{}`;
50
- /** When this checkout last asked its remote (git writes FETCH_HEAD on every fetch, whichever process ran it). */
51
- const fetchedAt = (root) => { try { return statSync(git(root, ['rev-parse', '--git-path', 'FETCH_HEAD']).trim().replace(/^(?!\/)/, `${root}/`)).mtimeMs; } catch { return 0; } };
52
202
 
53
203
  export class GitDocument {
54
204
  constructor(paths, merge) {
55
205
  this.paths = paths;
56
206
  this.cwd = dirname(paths[0]);
57
- this.root = quiet(this.cwd, ['rev-parse', '--show-toplevel'])?.trim() ?? null;
207
+ this.checkout = checkoutOf(this.cwd);
208
+ this.root = this.checkout?.root ?? null;
58
209
  this.merge = merge; // (base, theirs, ours, path) => text | null, ztrack's mergeBoardDocument
210
+ this.owed = []; // the writes whose commit is owed: { doer, message, files }
211
+ if (this.root !== null) { if (!boardsAt.has(this.root)) boardsAt.set(this.root, new Set()); boardsAt.get(this.root).add(this); }
212
+ }
213
+
214
+ /** The board closed: it is no longer one of its checkout's boards, and a take still waiting on it is given up. */
215
+ release() {
216
+ this.onTaken = null;
217
+ boardsAt.get(this.root)?.delete(this);
218
+ const taking = takings.get(this.root);
219
+ if (taking?.handed.has(this) && !taking.applied.has(this)) abandon(taking);
59
220
  }
60
221
 
61
222
  /** Whether this board's owed commit is still to be derived in this process (its board calls `derive` after its first
@@ -69,25 +230,35 @@ export class GitDocument {
69
230
  if (!this.usable) return;
70
231
  derived.add(this.paths[0]);
71
232
  this.commitDirty('board: documents written before this process started');
233
+ // an earlier process that moved HEAD and ended before the index followed: the entries are moved now
234
+ syncIndex(this.root, this.paths.map((path) => this.rel(path)));
72
235
  if (pushDerived.has(this.root)) return;
73
236
  pushDerived.add(this.root);
74
237
  const branch = this.branch();
75
- if (branch && this.unpushed(branch) && !this.pushing) this.pushInBackground(branch);
238
+ if (branch) onChain(this.root, async () => { if (await this.unpushed(branch) && !this.pushing) this.pushInBackground(branch); });
76
239
  }
77
240
 
78
- /** This board's documents that differ from HEAD, committed as the board (a recovery's writes are no doer's). */
241
+ /** This board's documents that differ from HEAD, committed as the board (a recovery's writes are no doer's), with
242
+ * their text as it is now: a later write's text is that write's own commit, never this one's. */
79
243
  commitDirty(message) {
80
244
  if (!this.usable) return;
81
- if (quiet(this.root, ['status', '--porcelain', '--', ...this.paths.map((path) => this.rel(path))])?.trim()) this.commit(null, message);
245
+ this.commit(null, message, this.paths.map((path) => ({ path, text: readText(path) })));
82
246
  }
83
247
 
84
248
  get usable() { return this.root !== null; }
85
- rel(path) { return relative(this.root, path); }
86
- /** The checkout's branch, or null on no branch (a detached HEAD has nowhere to push and nothing to pull). */
249
+ rel(path) {
250
+ let real = path; try { real = join(realpathSync(dirname(path)), basename(path)); } catch { /* not written yet */ }
251
+ return relative(this.root, real);
252
+ }
253
+ /** The checkout's branch, or null on no branch (a detached HEAD has nowhere to push and nothing to pull): its HEAD file. */
87
254
  branch() {
88
- const name = git(this.root, ['rev-parse', '--abbrev-ref', 'HEAD']).trim();
89
- return name && name !== 'HEAD' ? name : null;
255
+ try { const head = readFileSync(join(this.checkout.gitDir, 'HEAD'), 'utf8').trim(); return /^ref: refs\/heads\/(.+)$/.exec(head)?.[1] ?? null; } catch { return null; }
90
256
  }
257
+ /** When this checkout last asked its remote (git writes FETCH_HEAD on every fetch, whichever process ran it). */
258
+ // FETCH_HEAD is the checkout's own (a linked worktree's in its git dir, as `git rev-parse --git-path` says), and so is
259
+ // the push error this keeps beside it.
260
+ fetchedAt() { try { return statSync(join(this.checkout.gitDir, 'FETCH_HEAD')).mtimeMs; } catch { return 0; } }
261
+ failurePath() { return join(this.checkout.gitDir, 'supercode-board-push.err'); }
91
262
 
92
263
  /** Said once per process: a checkout on no branch keeps its commits here (every write tried to push `HEAD:HEAD`
93
264
  * three times, with a fetch and a merge between, and failed: seconds of network on each write, t_23e47b73). */
@@ -96,67 +267,127 @@ export class GitDocument {
96
267
  this.warnedDetached = true;
97
268
  }
98
269
 
99
- // Before a write: take what was pushed, so a merged pull request's edit is the document the write starts from. The
100
- // remote is asked at most every FETCH_EVERY_MS (git's own FETCH_HEAD is the stamp, so every board process shares it):
101
- // a fetch on every write put a network round trip on each one. A checkout holding commits its background push did
102
- // not land (refused: something was pushed meanwhile) takes what was pushed now and pushes before writing again.
270
+ // Before a write: take what was pushed, so a merged pull request's edit reaches the document. The remote is asked at
271
+ // most every FETCH_EVERY_MS (git's own FETCH_HEAD is the stamp, so every board process shares it), beside the write:
272
+ // what it brings is merged on the checkout's chain when it lands, and the board's next read takes the document.
103
273
  pull() {
104
274
  if (!this.usable) return;
105
- // Every board transaction pulls (about a hundred a dispatcher round): inside the fetch interval nothing is read,
106
- // not even the branch, so a round no longer spends seconds on git subprocesses that decide nothing.
107
- if (Date.now() - fetchedAt(this.root) < FETCH_EVERY_MS) return;
275
+ if (Date.now() - this.fetchedAt() < FETCH_EVERY_MS) return;
108
276
  const branch = this.branch();
109
277
  if (!branch) return this.detached();
110
278
  this.fetchThenTake(branch);
111
279
  }
112
280
 
113
- /** The fetch beside the process; when it lands, what was pushed is taken (take) in one local step. One at a time. */
281
+ /** The fetch beside the process; when it lands, what was pushed is taken (take) on the chain. One at a time. */
114
282
  fetchThenTake(branch, timeout) {
115
283
  if (fetching.has(this.root)) return fetching.get(this.root);
116
284
  const root = this.root;
117
285
  const landing = fetchAside(root, branch, timeout).then((fetched) => {
118
286
  if (!fetched) return; // offline: a later write asks again
119
- try { this.take(branch); } catch (error) { current().err(`board: ${error.message}\n`); }
287
+ return onChain(root, () => this.take(branch));
120
288
  }).finally(() => fetching.delete(root));
121
289
  fetching.set(root, landing);
122
290
  return landing;
123
291
  }
124
292
 
125
- /** After a fetch: what was pushed merged into this checkout (local, serialized with this process's writes), then a
126
- * push of what this checkout holds that the remote lacks, beside the process. */
127
- take(branch) {
128
- const behind = Number(git(this.root, ['rev-list', '--count', `HEAD..origin/${branch}`]).trim());
129
- if (behind > 0) {
130
- const dirty = git(this.root, ['status', '--porcelain', '--', ...this.paths.map((path) => this.rel(path))]).trim();
131
- if (dirty) return; // a write's documents are not committed yet: its commit is owed, and the next fetch takes this
132
- this.integrate(branch, 'board');
133
- }
134
- if (this.unpushed(branch) && !this.pushing) this.pushInBackground(branch);
293
+ /** After a fetch: what was pushed taken (integrate), then a push of what this checkout holds that the remote lacks,
294
+ * beside the process. */
295
+ async take(branch) {
296
+ const head = (await git(this.root, ['rev-parse', 'HEAD'])).trim();
297
+ const theirs = (await git(this.root, ['rev-parse', `origin/${branch}`])).trim();
298
+ const behind = Number((await git(this.root, ['rev-list', '--count', `${head}..${theirs}`])).trim());
299
+ if (behind > 0) await this.integrate(branch, head, theirs);
300
+ if (await this.unpushed(branch) && !this.pushing) this.pushInBackground(branch);
135
301
  this.refreshLateness();
136
302
  }
137
303
 
138
- // After a write: commit the board documents as their doer, then push, taking anything pushed meanwhile.
139
- commit(doer, message) {
304
+ // After a write: its commit is owed, made from the write's own text (`files`, its rendered documents; a recovery's or
305
+ // an earlier process's has none, and commits the documents as they are), as its doer, then pushed, beside the process.
306
+ commit(doer, message, files = null) {
140
307
  if (!this.usable) return;
141
- // Only documents that exist or are tracked: the archive is written when a card first ends, and a pathspec naming a
142
- // file git does not know fails the whole commit.
143
- const files = this.paths.map((path) => this.rel(path)).filter((file) => quiet(this.root, ['ls-files', '--error-unmatch', file]) !== null || readable(`${this.root}/${file}`));
144
- if (!files.length) return;
145
- indexed(this.root, ['add', '--', ...files]);
146
- if (!git(this.root, ['diff', '--cached', '--name-only', '--', ...files]).trim()) return;
147
- const who = doer || 'board';
148
- indexed(this.root, ['-c', `user.name=${who}`, '-c', 'user.email=board@supercode.invalid', 'commit', '--quiet', '--author', `${who} <board@supercode.invalid>`, '-m', message, '--', ...files]);
308
+ this.owed.push({ doer: doer || null, message, files: files ? files.map((file) => ({ path: file.path, text: file.text })) : null });
309
+ if (this.owed.length === 1) this.owedSince = Date.now();
310
+ if (this.owed.length === 1 && !this.retryTimer) onChain(this.root, () => this.commitOwed());
311
+ }
312
+
313
+ /** Each owed write committed in order, each as its own commit (none needs the checkout's index: a busy one holds
314
+ * only the index's following, syncIndex). */
315
+ async commitOwed() {
316
+ let made = 0, held = null;
317
+ while (this.owed.length) {
318
+ try { if (await this.commitOne(this.owed[0])) made += 1; }
319
+ catch (error) {
320
+ // a ref git holds locked (HEAD.lock, a branch's lock), or a HEAD that kept moving: the write stays owed, in order
321
+ if (refHeld(error)) { held = error; break; }
322
+ // said, and the writes after it still committed: its documents stay owed in git's state (they differ from HEAD)
323
+ current().err(`board: a write of the board documents could not be committed (${error.message.split('\n')[0]}); its documents stay owed in the checkout\n`);
324
+ }
325
+ this.owed.shift();
326
+ }
327
+ if (held) this.waitOutRef(held);
328
+ else { this.owedSince = null; this.refSince = null; this.refSaid = false; this.refDelay = 0; }
149
329
  this.refreshLateness();
330
+ if (!made) return;
150
331
  const branch = this.branch();
151
332
  if (!branch) return this.detached();
152
- // The push leaves the write's path: the commit is the write, and a push the remote refuses is taken up by the next
153
- // write's pull (unpushed commits), which merges what was pushed and pushes before it writes.
154
- // While it runs, this process's next write does not push the same commits beside it.
155
- // Writes that land while it runs are one more push after it (a burst publishes in two pushes, not one per write).
333
+ // The push leaves the write's path: the commit is the write's publication, and a push the remote refuses is taken
334
+ // up by the next fetch (unpushed commits), which merges what was pushed and pushes. While a push runs, the next
335
+ // commit does not push the same commits beside it: one more push after it (a burst publishes in two pushes).
156
336
  if (this.pushing) { this.again = true; return; }
157
337
  this.pushInBackground(branch);
158
338
  }
159
339
 
340
+ /** git would not move HEAD (its ref locked, or moving under the commit): the owed writes are tried again on a timer,
341
+ * from 250 ms doubling to 30 s, and said with how long once they have waited a minute. */
342
+ waitOutRef(error) {
343
+ this.refSince ??= Date.now();
344
+ if (!this.refSaid && Date.now() - this.refSince >= BUSY_SAID_MS) {
345
+ this.refSaid = true;
346
+ current().err(`board: ${this.owed.length} write${this.owed.length === 1 ? '' : 's'} of the board documents not committed for ${Math.round((Date.now() - this.refSince) / 1000)} s: git would not move HEAD in ${this.root} (${error.message.split('\n')[0]}); ${this.owed.length === 1 ? 'it stays' : 'they stay'} owed, tried again until it does\n`);
347
+ }
348
+ this.refDelay = Math.min(30_000, Math.max(250, (this.refDelay || 125) * 2));
349
+ this.retryTimer = setTimeout(() => { this.retryTimer = null; onChain(this.root, () => this.commitOwed()); }, this.refDelay);
350
+ this.retryTimer.unref?.();
351
+ }
352
+
353
+ /** One write's commit: its documents' text written as blobs, through an index of this process's own read from HEAD
354
+ * (the checkout's staged files never join it), committed as its doer; HEAD moved only if it is where the commit was
355
+ * made (another commit came first: made again on the new HEAD), and then the checkout's index told these documents
356
+ * are as committed. Answers whether a commit was made. */
357
+ async commitOne(write, tries = 3) {
358
+ const { doer, message, files } = write;
359
+ const who = doer || 'board';
360
+ const texts = (files ?? this.paths.map((path) => ({ path, text: readText(path) }))).filter((file) => file.text !== null);
361
+ if (!texts.length) return false;
362
+ const head = (await quiet(this.root, ['rev-parse', '--verify', '-q', 'HEAD']))?.trim() || null;
363
+ const index = join(this.checkout.gitDir, `supercode-board-index.${process.pid}`);
364
+ let moved = false;
365
+ try {
366
+ const own = { GIT_INDEX_FILE: index };
367
+ await git(this.root, head ? ['read-tree', head] : ['read-tree', '--empty'], 120_000, { env: own });
368
+ const entries = [];
369
+ for (const { path, text } of texts) {
370
+ const rel = this.rel(path);
371
+ const blob = (await git(this.root, ['hash-object', '-w', '--stdin'], 120_000, { input: text })).trim();
372
+ entries.push({ rel, blob });
373
+ await git(this.root, ['update-index', '--add', '--cacheinfo', `100644,${blob},${rel}`], 120_000, { env: own });
374
+ }
375
+ const tree = (await git(this.root, ['write-tree'], 120_000, { env: own })).trim();
376
+ if (head && tree === (await git(this.root, ['rev-parse', `${head}^{tree}`])).trim()) return false;
377
+ const commit = (await git(this.root, ['commit-tree', tree, ...(head ? ['-p', head] : []), '-m', message], 120_000, { env: author(who) })).trim();
378
+ try { await git(this.root, ['update-ref', '-m', `board: ${message.split('\n')[0]}`, 'HEAD', commit, head ?? '0'.repeat(40)]); }
379
+ catch (error) {
380
+ const now = (await quiet(this.root, ['rev-parse', '--verify', '-q', 'HEAD']))?.trim() || null;
381
+ if (now === head) throw error; // HEAD where it was: its ref was refused (a lock), said by the caller
382
+ if (tries <= 1) throw Object.assign(new Error('HEAD moved under the commit on every try'), { refHeld: true });
383
+ moved = true;
384
+ }
385
+ if (!moved) syncIndex(this.root, entries.map(({ rel }) => rel));
386
+ } finally { rmSync(index, { force: true }); }
387
+ // HEAD moved under it (another commit, or a take): the same write made again on the new HEAD
388
+ return moved ? this.commitOne(write, tries - 1) : true;
389
+ }
390
+
160
391
  /** `git push` beside the process, outliving it. Its refusal is kept where `behind()` reads it, and cleared by a push
161
392
  * that lands. */
162
393
  pushInBackground(branch) {
@@ -175,86 +406,181 @@ export class GitDocument {
175
406
  pushing.unref();
176
407
  }
177
408
 
178
- failurePath() {
179
- return git(this.root, ['rev-parse', '--git-path', 'supercode-board-push.err']).trim().replace(/^(?!\/)/, `${this.root}/`);
180
- }
181
-
182
- /** The board documents' publish when it is behind: commits the remote lacks, the oldest one's time and the last push's
183
- * error; null when the remote has everything (or this checkout pushes nowhere). Local reads only, no network. */
184
409
  /** A held board's lateness: read once beside the owner's loop, then again after each of its own commits, pushes and
185
410
  * takes (refreshLateness), never by spawning git on an open. `lateness` is that reading (null while none is owed). */
186
411
  holdLateness() { this.heldLate = true; this.lateness = null; this.refreshLateness(); }
187
412
  refreshLateness() {
188
413
  if (!this.heldLate || !this.usable) return;
189
414
  if (this.lateReading) { this.lateAgain = true; return; }
190
- const root = this.root;
191
- const run = (args) => new Promise((resolve) => execFile('git', args, { cwd: root, timeout: 30_000 }, (error, out) => resolve(error ? null : String(out))));
192
415
  this.lateReading = (async () => {
193
- const name = (await run(['rev-parse', '--abbrev-ref', 'HEAD']))?.trim();
194
- if (!name || name === 'HEAD') return null;
195
- const times = ((await run(['log', '--format=%ct', `origin/${name}..HEAD`])) ?? '').trim().split('\n').filter(Boolean).map(Number);
196
- if (!times.length) return null;
197
- const failure = (await run(['rev-parse', '--git-path', 'supercode-board-push.err']))?.trim().replace(/^(?!\/)/, `${root}/`);
416
+ const name = this.branch();
417
+ if (!name) return null;
418
+ const times = ((await quiet(this.root, ['log', '--format=%ct', `origin/${name}..HEAD`], 30_000)) ?? '').trim().split('\n').filter(Boolean).map(Number);
419
+ // writes not committed yet (a busy index holding them), as well as commits not pushed
420
+ const uncommitted = this.owed.length ? { writes: this.owed.length, since: this.owedSince ?? Date.now() } : null;
421
+ if (!times.length && !uncommitted) return null;
198
422
  let error = null;
199
- try { error = failure ? readFileSync(failure, 'utf8').split('\n').map((line) => line.replace(/\s+/g, ' ').trim()).find(Boolean) ?? null : null; } catch { /* none recorded */ }
200
- return { commits: times.length, since: Math.min(...times) * 1000, error };
423
+ try { error = readFileSync(this.failurePath(), 'utf8').split('\n').map((line) => line.replace(/\s+/g, ' ').trim()).find(Boolean) ?? null; } catch { /* none recorded */ }
424
+ return { commits: times.length, since: times.length ? Math.min(...times) * 1000 : null, error, uncommitted };
201
425
  })().then((late) => { this.lateness = late; }, () => {}).finally(() => {
202
426
  this.lateReading = null;
203
427
  if (this.lateAgain) { this.lateAgain = false; this.refreshLateness(); }
204
428
  });
205
429
  }
206
430
 
431
+ /** The board documents' publish when it is behind, for a one-off board open (a CLI process, never the owner, whose
432
+ * boards are held and read `lateness`): commits the remote lacks, the oldest one's time and the last push's error, and
433
+ * documents that differ from HEAD (a commit owed: an earlier process ended, or a busy index held it, before it was
434
+ * made); null when nothing is owed (or this checkout pushes nowhere). Local reads only, no network. */
207
435
  behind() {
208
436
  if (!this.usable) return null;
209
437
  const branch = this.branch();
210
438
  if (!branch) return null;
211
- const times = (quiet(this.root, ['log', '--format=%ct', `origin/${branch}..HEAD`]) ?? '').trim().split('\n').filter(Boolean).map(Number);
212
- if (!times.length) return null;
439
+ const run = (args) => { try { return execFileSync('git', args, { cwd: this.root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000 }); } catch { return ''; } };
440
+ const times = run(['log', '--format=%ct', `origin/${branch}..HEAD`]).trim().split('\n').filter(Boolean).map(Number);
441
+ const owed = run(['status', '--porcelain', '--', ...this.paths.map((path) => this.rel(path))]).trim().split('\n').filter(Boolean).length;
442
+ if (!times.length && !owed) return null;
213
443
  let error = null;
214
444
  try { error = readFileSync(this.failurePath(), 'utf8').split('\n').map((line) => line.replace(/\s+/g, ' ').trim()).find(Boolean) ?? null; } catch { /* none recorded */ }
215
- return { commits: times.length, since: Math.min(...times) * 1000, error };
445
+ return { commits: times.length, since: times.length ? Math.min(...times) * 1000 : null, error, uncommitted: owed ? { documents: owed, since: null } : null };
216
446
  }
217
447
 
218
448
  /** The board's round: publish what an earlier write committed and the remote has not taken (a refused or failed
219
- * background push), with the same merge and three attempts a write's pull uses. */
449
+ * background push), with the same merge a write's pull uses; beside the round. */
220
450
  publishPending() {
221
451
  if (!this.usable || this.pushing) return;
222
452
  const branch = this.branch();
223
- if (!branch || !this.unpushed(branch)) return;
224
- // A remote that hangs costs this fetch 15 s, beside the round, never the round.
225
- this.fetchThenTake(branch, 15_000);
453
+ if (!branch) return;
454
+ onChain(this.root, async () => {
455
+ // A remote that hangs costs this fetch 15 s, beside the round, never the round.
456
+ if (await this.unpushed(branch)) this.fetchThenTake(branch, 15_000);
457
+ });
226
458
  }
227
459
 
228
460
  /** Whether this checkout holds board commits the remote does not have. */
229
- unpushed(branch) {
230
- return Number(quiet(this.root, ['rev-list', '--count', `origin/${branch}..HEAD`])?.trim() || 0) > 0;
461
+ async unpushed(branch) {
462
+ return Number((await quiet(this.root, ['rev-list', '--count', `origin/${branch}..HEAD`]))?.trim() || 0) > 0;
231
463
  }
232
464
 
233
- // Take what was pushed with a merge: it works beside the other edits in this checkout (the manager's own files), which
234
- // a rebase refuses. A board document both sides changed is merged by the board's own document merge; any other
235
- // conflict leaves the checkout as it was and says so.
236
- integrate(branch, who) {
237
- 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); } })();
238
- if (merged === true) return;
239
- const conflicted = git(this.root, ['diff', '--name-only', '--diff-filter=U']).trim().split('\n').filter(Boolean);
240
- if (!conflicted.length) {
241
- quiet(this.root, ['merge', '--abort']);
242
- throw new Error(`what was pushed could not be taken: ${merged.trim().split('\n')[0]}`);
465
+ // Take what was pushed: merged without touching the working tree (`git merge-tree`), so a board write never meets a
466
+ // half-merged document; a board document both sides changed is merged by the board's own document merge, and any other
467
+ // conflict leaves the checkout as it was and says so. What changes a board document is handed to the board
468
+ // (applyTaken, in its next transaction); what changes only other files moves git at once (finishTake).
469
+ async integrate(branch, head, theirs) {
470
+ if (takings.has(this.root)) return; // one is with the boards already
471
+ if (!(await canMergeTree(this.root))) return;
472
+ // every board document this process holds in this checkout, by its path in the checkout, with its board
473
+ const own = new Map();
474
+ for (const board of boardsAt.get(this.root) ?? []) for (const path of board.paths) own.set(board.rel(path), { path, board });
475
+ const merged = await git(this.root, ['merge-tree', '--write-tree', '-z', head, theirs]).then(
476
+ (out) => ({ out, clean: true }),
477
+ (error) => (error.code === 1 && error.stdout ? { out: error.stdout, clean: false } : Promise.reject(error)));
478
+ const fields = merged.out.split('\0');
479
+ let tree = fields[0].trim();
480
+ const stages = new Map();
481
+ for (let i = 1; i < fields.length && fields[i] !== ''; i++) {
482
+ const entry = /^(\d+) ([0-9a-f]+) (\d)\t(.*)$/s.exec(fields[i]);
483
+ if (!entry) continue;
484
+ if (!stages.has(entry[4])) stages.set(entry[4], {});
485
+ stages.get(entry[4])[entry[3]] = entry[2];
243
486
  }
244
- for (const file of conflicted) {
245
- const path = `${this.root}/${file}`;
246
- // In a merge, stage 2 is this checkout's text and stage 3 the pushed one.
247
- const stage = (n) => quiet(this.root, ['show', `:${n}:${file}`]) ?? '';
248
- const text = this.paths.some((own) => this.rel(own) === file) ? this.merge(stage(1), stage(3), stage(2), path) : null;
249
- if (text === null) {
250
- quiet(this.root, ['merge', '--abort']);
251
- throw new Error(`${basename(file)} could not be merged with what was pushed; nothing was pushed, and this checkout is as it was`);
252
- }
253
- writeFileSync(path, text);
254
- indexed(this.root, ['add', '--', file]);
487
+ if (!merged.clean) {
488
+ if (!stages.size) throw new Error(`what was pushed to ${branch} could not be taken: a conflict git names by no file; nothing was taken, and this checkout is as it was`);
489
+ const blob = async (oid) => (oid ? git(this.root, ['cat-file', 'blob', oid]) : '');
490
+ const index = join(this.checkout.gitDir, `supercode-board-index.${process.pid}`);
491
+ try {
492
+ const env = { GIT_INDEX_FILE: index };
493
+ await git(this.root, ['read-tree', tree], 120_000, { env });
494
+ for (const [file, stage] of stages) {
495
+ // stage 2 is this checkout's text and stage 3 the pushed one
496
+ const text = own.has(file) ? own.get(file).board.merge(await blob(stage[1]), await blob(stage[3]), await blob(stage[2]), own.get(file).path) : null;
497
+ if (text === null) throw new Error(`${basename(file)} could not be merged with what was pushed to ${branch}; nothing was taken, and this checkout is as it was`);
498
+ const oid = (await git(this.root, ['hash-object', '-w', '--stdin'], 120_000, { input: text })).trim();
499
+ await git(this.root, ['update-index', '--add', '--cacheinfo', `100644,${oid},${file}`], 120_000, { env });
500
+ }
501
+ tree = (await git(this.root, ['write-tree'], 120_000, { env })).trim();
502
+ } finally { rmSync(index, { force: true }); }
503
+ }
504
+ const commit = (await git(this.root, ['commit-tree', tree, '-p', head, '-p', theirs, '-m', `board: took what was pushed to ${branch}`], 120_000, { env: author('board') })).trim();
505
+ // without rename detection: a renamed file is its old path deleted and its new one added, both moved
506
+ const changed = (await git(this.root, ['diff', '--no-renames', '--name-only', '-z', head, commit])).split('\0').filter(Boolean);
507
+ const docs = changed.filter((file) => own.has(file));
508
+ const others = changed.filter((file) => !own.has(file));
509
+ if (others.length && (await git(this.root, ['status', '--porcelain', '--', ...others])).trim()) {
510
+ throw new Error(`what was pushed to ${branch} changes files this checkout has changed too (${others.slice(0, 3).join(', ')}); nothing was taken`);
255
511
  }
256
- indexed(this.root, ['-c', `user.name=${who}`, '-c', 'user.email=board@supercode.invalid', 'commit', '--quiet', '--no-edit']);
512
+ const taking = { root: this.root, head, commit, others, docs, handed: new Map(), applied: new Set() };
513
+ if (!docs.length) return finishTake(taking);
514
+ for (const file of docs) {
515
+ const { path, board } = own.get(file);
516
+ if (!taking.handed.has(board)) taking.handed.set(board, []);
517
+ taking.handed.get(board).push({ path, base: (await quiet(this.root, ['show', `${head}:${file}`])) ?? '', text: (await quiet(this.root, ['show', `${commit}:${file}`])) ?? '' });
518
+ }
519
+ takings.set(this.root, taking);
520
+ for (const board of taking.handed.keys()) board.onTaken?.();
521
+ }
522
+
523
+ /**
524
+ * In the board's transaction, on its loop: what a fetch brought, written as the board's own edit (its stale read then
525
+ * takes it into the store), each document only if it is still what HEAD held when the merge was made. A write owed
526
+ * first drops it: that commit goes first, and the next fetch takes what was pushed again. Then git moves (finishTake).
527
+ */
528
+ applyTaken() {
529
+ const taking = takings.get(this.root);
530
+ const mine = taking?.handed.get(this);
531
+ if (!mine || taking.applied.has(this)) return false;
532
+ // compared without line endings: a checkout with core.autocrlf holds CRLF where git's blob holds LF
533
+ if (mine.some(({ path, base }) => lf(readText(path) ?? '') !== lf(base))) { abandon(taking); return false; }
534
+ for (const { path, text } of mine) writeAtomically(path, text);
535
+ taking.applied.add(this);
536
+ // git moves once every board the merge changes has written its part
537
+ if (taking.applied.size === taking.handed.size) onChain(this.root, () => finishTake(taking));
538
+ return true;
257
539
  }
258
540
  }
259
541
 
260
- function readable(path) { try { readFileSync(path); return true; } catch { return false; } }
542
+ /** A take given up (a board's document is no longer what HEAD held, or the board closed): what the boards already
543
+ * wrote of it is their edit, committed as owed; the next fetch merges what was pushed again, cleanly. */
544
+ function abandon(taking) {
545
+ if (takings.get(taking.root) === taking) takings.delete(taking.root);
546
+ for (const board of taking.applied) board.commitDirty('board: documents taken in part from what was pushed');
547
+ }
548
+
549
+ /** git moved to the merge. HEAD first, by compare-and-swap from where the merge was made; then the checkout's index
550
+ * follows for every path the merge changed (syncIndex), and the other files the merge changed are written (a file it
551
+ * deleted removed), only while unchanged here. Whatever fails before HEAD moves gives the take up (abandon: what the
552
+ * boards wrote is committed as owed); after, what is left is said and its index entries stay owed. */
553
+ async function finishTake(taking) {
554
+ const { root, head, commit, others, docs } = taking;
555
+ let moved = false;
556
+ try {
557
+ if ((await git(root, ['rev-parse', 'HEAD'])).trim() !== head) return abandon(taking); // a commit came first
558
+ if (others.length && (await git(root, ['status', '--porcelain', '--', ...others])).trim()) {
559
+ throw new Error(`what was pushed changes files this checkout has changed too (${others.slice(0, 3).join(', ')}); it is taken by a later fetch`);
560
+ }
561
+ await git(root, ['update-ref', '-m', 'board: took what was pushed', 'HEAD', commit, head]);
562
+ moved = true;
563
+ syncIndex(root, [...docs, ...others]);
564
+ if (others.length) moveFiles(root, others, head, commit);
565
+ } catch (error) {
566
+ if (!moved) abandon(taking);
567
+ throw error;
568
+ } finally { if (takings.get(root) === taking) takings.delete(root); }
569
+ }
570
+
571
+ /** Text without CR before LF. */
572
+ const lf = (text) => text.replace(/\r\n/g, '\n');
573
+
574
+ /** A document's text, or null when it does not exist. */
575
+ function readText(path) { try { return readFileSync(path, 'utf8'); } catch { return null; } }
576
+
577
+ /** A document written whole, never half: beside it, then renamed over it. */
578
+ function writeAtomically(path, text) {
579
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
580
+ writeFileSync(temporary, text);
581
+ renameSync(temporary, path);
582
+ }
583
+
584
+ /** The board's commits are made as `who`. */
585
+ const author = (who) => ({ GIT_AUTHOR_NAME: who, GIT_AUTHOR_EMAIL: 'board@supercode.invalid', GIT_COMMITTER_NAME: who, GIT_COMMITTER_EMAIL: 'board@supercode.invalid' });
586
+
package/board/ztrack.mjs CHANGED
@@ -101,6 +101,8 @@ class ZtrackBoard {
101
101
  // own work; the backing commits only the writes the manager does not make.
102
102
  this.manager = manager;
103
103
  this.git = commit ? new GitDocument(this.baseDocs.map((doc) => doc.path), (base, theirs, ours, path) => { try { return this.codec.mergeBoardDocument(base, theirs, ours, path)?.text ?? null; } catch { return null; } }) : null;
104
+ // what a fetch brought is written in the board's next transaction: one soon, on this loop, if none comes
105
+ if (this.git) this.git.onTaken = () => setImmediate(() => { try { if (!this.closed) this.tx(() => {}); } catch (error) { current().err(`board: ${error.message}\n`); } });
104
106
  // A publish that is behind is said once per call, on whichever workflow verb opened the board: a refused push
105
107
  // surfaces on the next call instead of only in the dispatcher's report, and stops being said once the push lands.
106
108
  // A held board reads git's state once, beside the owner's loop, and again after its own commits and pushes; an
@@ -145,7 +147,11 @@ class ZtrackBoard {
145
147
  if (!this.git || said.has(`behind:${this.path}`)) return;
146
148
  said.add(`behind:${this.path}`);
147
149
  // A push younger than a minute is still on its way.
148
- if (late && Date.now() - late.since > 60_000) current().err(`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`);
150
+ if (late?.commits && Date.now() - late.since > 60_000) current().err(`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`);
151
+ // A commit not made: writes still waiting on the checkout's git steps past a minute, or documents an earlier process
152
+ // left owed.
153
+ const owed = late?.uncommitted;
154
+ if (owed && (owed.since === null || Date.now() - owed.since > 60_000)) current().err(`board: warning: ${owed.writes ? `${owed.writes} board document write${owed.writes === 1 ? '' : 's'}` : `${owed.documents} board document${owed.documents === 1 ? '' : 's'}`} not committed${owed.since ? ` since ${new Date(owed.since).toISOString().slice(0, 16)}Z (waiting on the checkout's git steps)` : ''}; ${owed.writes ? 'committed when they land' : 'committed by the board owner when it next opens the board'}\n`);
149
155
  }
150
156
  /** The owner's watch saw this board's documents change: their stamps are read on the next read, and only then. */
151
157
  documentsChanged() { this.docsChanged = true; this.importDue = true; }
@@ -160,7 +166,7 @@ class ZtrackBoard {
160
166
  get codec() { return this._codec ??= require('@volter/ztrack/board-document'); }
161
167
  get preset() { return this._preset ??= require('@volter/ztrack/kanban-preset'); }
162
168
  board() { return { root: this.root, slug: this.slug, label: this.slug, db: this.db }; }
163
- close() { this.dropOverlay(); this.conn.close(); }
169
+ close() { this.closed = true; this.git?.release(); this.dropOverlay(); this.conn.close(); }
164
170
 
165
171
  statement(sql) {
166
172
  let statement = this.statements.get(sql);
@@ -424,6 +430,8 @@ class ZtrackBoard {
424
430
  this.refused = new Set();
425
431
  timedStep('board tx errors', () => this.loadErrors());
426
432
  const recovered = timedStep('board tx recover', () => this.recover());
433
+ // what a fetch brought, merged beside the loop: written here as the board's own edit, read by the stale check
434
+ if (this.git && !this.seed) timedStep('board tx take what was pushed', () => this.git.applyTaken());
427
435
  if (this.seed) this.indexForSeed();
428
436
  else {
429
437
  const stale = timedStep('board tx stale check', () => this.staleDocuments({ pending: false }));
@@ -468,7 +476,7 @@ class ZtrackBoard {
468
476
  // Committed as the session (or person) whose act this write was, and pushed.
469
477
  const byManager = this.manager !== null && (actorOf() === this.manager || principalOf() === this.manager);
470
478
  if (this.git) this.git.paths = this.docs.map((doc) => doc.path);
471
- if (!byManager) try { timedStep('board git commit and push', () => this.git?.commit(actorOf(), `board: ${outcome.files.map((file) => basename(file.path)).join(', ')} written for ${actorOf() ?? 'the board'}`)); }
479
+ if (!byManager) try { timedStep('board git commit and push', () => this.git?.commit(actorOf(), `board: ${outcome.files.map((file) => basename(file.path)).join(', ')} written for ${actorOf() ?? 'the board'}`, outcome.files)); }
472
480
  catch (error) { current().err(`board: ${error.message}\n`); }
473
481
  }
474
482
  return outcome.result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.84",
3
+ "version": "0.5.85",
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": {