@volter/supercode-orchestrator 0.5.84 → 0.5.86

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,250 @@
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
40
76
 
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()]);
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;
45
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
+ // until none is left: a step that ran may have queued another (a commit its push follows); a retry whose timer fires
95
+ // meanwhile joins the chain and is waited for too
96
+ while (fetching.size || chains.size) {
97
+ const left = deadline - Date.now();
98
+ 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`); break; }
99
+ await Promise.race([Promise.all([...fetching.values(), ...chains.values()]), new Promise((done) => setTimeout(done, left).unref?.())]);
100
+ }
101
+ // then what still waits on a timer, said: after the chains, so a retry a step set while settling is said too
102
+ for (const boards of boardsAt.values()) for (const board of boards) if (board.retryTimer) {
103
+ clearTimeout(board.retryTimer); board.retryTimer = null;
104
+ 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`);
105
+ }
106
+ 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); the next process to open a board there writes them\n`); }
107
+ for (const [root, sync] of indexOwed) if (sync.timer) {
108
+ clearTimeout(sync.timer); sync.timer = null;
109
+ 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`);
110
+ }
111
+ }
112
+ /** How long a busy index may hold an index sync before it is said (with its age), once per stretch. */
113
+ const BUSY_SAID_MS = 60_000;
114
+ // checkout root → { paths, since, said, delay, timer }: board documents whose entries in the checkout's index are to be
115
+ // moved to HEAD (after a commit or a take moved HEAD), waiting out a busy index.
116
+ const indexOwed = new Map();
117
+
118
+ /** The checkout's index told these paths are as HEAD has them (a path HEAD lacks leaves the index), on the chain. HEAD
119
+ * moves first, by compare-and-swap; the index follows, so it is never ahead of HEAD. A busy index is tried again on a
120
+ * timer, from 250 ms doubling to 30 s, and said with its age once it has held the sync a minute. */
121
+ function syncIndex(root, paths) {
122
+ const sync = indexOwed.get(root) ?? { paths: new Set(), since: null, said: false, delay: 0, timer: null };
123
+ indexOwed.set(root, sync);
124
+ for (const path of paths) sync.paths.add(path);
125
+ if (!sync.timer) onChain(root, () => syncNow(root));
126
+ }
127
+ async function syncNow(root) {
128
+ const sync = indexOwed.get(root);
129
+ if (!sync || !sync.paths.size) return;
130
+ const paths = [...sync.paths];
131
+ try { await git(root, ['reset', '-q', 'HEAD', '--', ...paths]); }
132
+ catch (error) {
133
+ 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]}`); }
134
+ sync.since ??= Date.now();
135
+ if (!sync.said && Date.now() - sync.since >= BUSY_SAID_MS) {
136
+ sync.said = true;
137
+ 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`);
138
+ }
139
+ sync.delay = Math.min(30_000, Math.max(250, (sync.delay || 125) * 2));
140
+ sync.timer = setTimeout(() => { sync.timer = null; onChain(root, () => syncNow(root)); }, sync.delay);
141
+ sync.timer.unref?.();
142
+ return;
143
+ }
144
+ for (const path of paths) sync.paths.delete(path);
145
+ if (!sync.paths.size) indexOwed.delete(root);
146
+ }
147
+
148
+ // checkout root → { files: Map(path → { from, to }), since, said, delay, timer }: working files a take moved HEAD past,
149
+ // to be written from `to` (or removed, when `to` lacks them), each only while it is still `from`'s text.
150
+ const filesOwed = new Map();
151
+
152
+ /** The working files a take changed, brought to what HEAD now holds, on the chain: each only while the file is still
153
+ * what the commit before the take held (a file changed here since is left, said). One that fails is tried again on a
154
+ * timer, said with its age after a minute. */
155
+ function moveFiles(root, files, from, to) {
156
+ const moves = filesOwed.get(root) ?? { files: new Map(), since: null, said: false, delay: 0, timer: null };
157
+ filesOwed.set(root, moves);
158
+ // a file a second take moves again keeps the base of the first: its working text is still that one's until written
159
+ for (const file of files) moves.files.set(file, { from: moves.files.get(file)?.from ?? from, to });
160
+ keepMoves(root);
161
+ if (!moves.timer) onChain(root, () => moveNow(root));
162
+ }
163
+ /** The moves still owed, kept in the checkout's git dir until each is written: a process that ends first leaves them for
164
+ * the next one to open a board there (`derive`). */
165
+ const movesPath = (root) => { const checkout = checkoutOf(root); return checkout ? join(checkout.gitDir, 'supercode-board-moves.json') : null; };
166
+ function keepMoves(root) {
167
+ const path = movesPath(root); if (!path) return;
168
+ const moves = filesOwed.get(root);
169
+ try {
170
+ if (!moves?.files.size) rmSync(path, { force: true });
171
+ else writeAtomically(path, JSON.stringify(Object.fromEntries(moves.files)));
172
+ } catch (error) { current().err(`board: the moves owed in ${root} could not be kept (${error.message}); a process that ends before they are written leaves those files differing from HEAD\n`); }
173
+ }
174
+ /** Moves an earlier process left owed, queued again (once per checkout per process). */
175
+ function deriveMoves(root) {
176
+ const path = movesPath(root); if (!path) return;
177
+ let kept = null; try { kept = JSON.parse(readFileSync(path, 'utf8')); } catch { return; }
178
+ for (const [file, { from, to }] of Object.entries(kept ?? {})) if (from && to) moveFiles(root, [file], from, to);
179
+ }
180
+ async function moveNow(root) {
181
+ const moves = filesOwed.get(root);
182
+ if (!moves) return;
183
+ let failed = null;
184
+ for (const [file, { from, to }] of [...moves.files]) {
185
+ try {
186
+ const was = (await quiet(root, ['rev-parse', '-q', '--verify', `${from}:${file}`]))?.trim() || null;
187
+ let exists = true; try { statSync(join(root, file)); } catch { exists = false; }
188
+ const now = exists ? (await git(root, ['hash-object', '--', file])).trim() : null;
189
+ if (now !== was) {
190
+ // already what the take brought (written before a process ended): done; else changed here since, left and said
191
+ const brought = (await quiet(root, ['rev-parse', '-q', '--verify', `${to}:${file}`]))?.trim() || null;
192
+ if (now !== brought) 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`);
193
+ // written before a process ended whose index sync was still owed: the index entry follows HEAD now (a file HEAD
194
+ // lacks leaves the index, so it is not left staged against HEAD)
195
+ else await git(root, ['reset', '-q', 'HEAD', '--', file]);
196
+ moves.files.delete(file); continue;
197
+ }
198
+ const kept = Boolean((await quiet(root, ['rev-parse', '-q', '--verify', `${to}:${file}`]))?.trim());
199
+ if (kept) await git(root, ['checkout', to, '--', file]);
200
+ else {
201
+ // the index entry first (a busy index retries with the file still in place), then the file
202
+ await git(root, ['rm', '--cached', '-q', '--ignore-unmatch', '--', file]);
203
+ rmSync(join(root, file), { force: true });
204
+ }
205
+ moves.files.delete(file);
206
+ } catch (error) { failed ??= error; }
207
+ }
208
+ keepMoves(root);
209
+ if (!moves.files.size) { filesOwed.delete(root); return; }
210
+ moves.since ??= Date.now();
211
+ if (!moves.said && Date.now() - moves.since >= BUSY_SAID_MS) {
212
+ moves.said = true;
213
+ 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`);
214
+ }
215
+ moves.delay = Math.min(30_000, Math.max(250, (moves.delay || 125) * 2));
216
+ moves.timer = setTimeout(() => { moves.timer = null; onChain(root, () => moveNow(root)); }, moves.delay);
217
+ moves.timer.unref?.();
218
+ }
219
+
220
+ /** Whether this git can merge without a working tree (`merge-tree --write-tree`, git 2.38): asked once per process,
221
+ * beside the loop, on the first take; an older git is said once and nothing pushed is taken. */
222
+ let mergeTreeAble = null;
223
+ async function canMergeTree(root) {
224
+ if (mergeTreeAble === null) {
225
+ const version = /(\d+)\.(\d+)/.exec((await quiet(root, ['version'])) ?? '');
226
+ mergeTreeAble = Boolean(version) && (Number(version[1]) > 2 || (Number(version[1]) === 2 && Number(version[2]) >= 38));
227
+ 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`);
228
+ }
229
+ return mergeTreeAble;
230
+ }
231
+
46
232
  /** The background push: `git push`, then its error kept for `behind()` (or the last one cleared when it landed). */
47
233
  const PUSH = `const {spawnSync}=require('node:child_process'),fs=require('node:fs');const [branch,failure,temporary]=process.argv.slice(1);
48
234
  const r=spawnSync('git',['push','--quiet','origin','HEAD:'+branch],{encoding:'utf8',windowsHide:true});
49
235
  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
236
 
53
237
  export class GitDocument {
54
238
  constructor(paths, merge) {
55
239
  this.paths = paths;
56
240
  this.cwd = dirname(paths[0]);
57
- this.root = quiet(this.cwd, ['rev-parse', '--show-toplevel'])?.trim() ?? null;
241
+ this.checkout = checkoutOf(this.cwd);
242
+ this.root = this.checkout?.root ?? null;
58
243
  this.merge = merge; // (base, theirs, ours, path) => text | null, ztrack's mergeBoardDocument
244
+ this.owed = []; // the writes whose commit is owed: { doer, message, files }
245
+ if (this.root !== null) { if (!boardsAt.has(this.root)) boardsAt.set(this.root, new Set()); boardsAt.get(this.root).add(this); }
246
+ }
247
+
248
+ /** The board closed: it is no longer one of its checkout's boards, and a take still waiting on it is given up. */
249
+ release() {
250
+ this.onTaken = null;
251
+ boardsAt.get(this.root)?.delete(this);
252
+ const taking = takings.get(this.root);
253
+ if (taking?.handed.has(this) && !taking.applied.has(this)) abandon(taking);
59
254
  }
60
255
 
61
256
  /** Whether this board's owed commit is still to be derived in this process (its board calls `derive` after its first
@@ -69,25 +264,36 @@ export class GitDocument {
69
264
  if (!this.usable) return;
70
265
  derived.add(this.paths[0]);
71
266
  this.commitDirty('board: documents written before this process started');
267
+ // an earlier process that moved HEAD and ended before the index followed: the entries are moved now
268
+ syncIndex(this.root, this.paths.map((path) => this.rel(path)));
72
269
  if (pushDerived.has(this.root)) return;
73
270
  pushDerived.add(this.root);
271
+ deriveMoves(this.root);
74
272
  const branch = this.branch();
75
- if (branch && this.unpushed(branch) && !this.pushing) this.pushInBackground(branch);
273
+ if (branch) onChain(this.root, async () => { if (await this.unpushed(branch) && !this.pushing) this.pushInBackground(branch); });
76
274
  }
77
275
 
78
- /** This board's documents that differ from HEAD, committed as the board (a recovery's writes are no doer's). */
276
+ /** This board's documents that differ from HEAD, committed as the board (a recovery's writes are no doer's), with
277
+ * their text as it is now: a later write's text is that write's own commit, never this one's. */
79
278
  commitDirty(message) {
80
279
  if (!this.usable) return;
81
- if (quiet(this.root, ['status', '--porcelain', '--', ...this.paths.map((path) => this.rel(path))])?.trim()) this.commit(null, message);
280
+ this.commit(null, message, this.paths.map((path) => ({ path, text: readText(path) })));
82
281
  }
83
282
 
84
283
  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). */
284
+ rel(path) {
285
+ let real = path; try { real = join(realpathSync(dirname(path)), basename(path)); } catch { /* not written yet */ }
286
+ return relative(this.root, real);
287
+ }
288
+ /** The checkout's branch, or null on no branch (a detached HEAD has nowhere to push and nothing to pull): its HEAD file. */
87
289
  branch() {
88
- const name = git(this.root, ['rev-parse', '--abbrev-ref', 'HEAD']).trim();
89
- return name && name !== 'HEAD' ? name : null;
290
+ try { const head = readFileSync(join(this.checkout.gitDir, 'HEAD'), 'utf8').trim(); return /^ref: refs\/heads\/(.+)$/.exec(head)?.[1] ?? null; } catch { return null; }
90
291
  }
292
+ /** When this checkout last asked its remote (git writes FETCH_HEAD on every fetch, whichever process ran it). */
293
+ // 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
294
+ // the push error this keeps beside it.
295
+ fetchedAt() { try { return statSync(join(this.checkout.gitDir, 'FETCH_HEAD')).mtimeMs; } catch { return 0; } }
296
+ failurePath() { return join(this.checkout.gitDir, 'supercode-board-push.err'); }
91
297
 
92
298
  /** Said once per process: a checkout on no branch keeps its commits here (every write tried to push `HEAD:HEAD`
93
299
  * three times, with a fetch and a merge between, and failed: seconds of network on each write, t_23e47b73). */
@@ -96,67 +302,127 @@ export class GitDocument {
96
302
  this.warnedDetached = true;
97
303
  }
98
304
 
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.
305
+ // Before a write: take what was pushed, so a merged pull request's edit reaches the document. The remote is asked at
306
+ // most every FETCH_EVERY_MS (git's own FETCH_HEAD is the stamp, so every board process shares it), beside the write:
307
+ // what it brings is merged on the checkout's chain when it lands, and the board's next read takes the document.
103
308
  pull() {
104
309
  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;
310
+ if (Date.now() - this.fetchedAt() < FETCH_EVERY_MS) return;
108
311
  const branch = this.branch();
109
312
  if (!branch) return this.detached();
110
313
  this.fetchThenTake(branch);
111
314
  }
112
315
 
113
- /** The fetch beside the process; when it lands, what was pushed is taken (take) in one local step. One at a time. */
316
+ /** The fetch beside the process; when it lands, what was pushed is taken (take) on the chain. One at a time. */
114
317
  fetchThenTake(branch, timeout) {
115
318
  if (fetching.has(this.root)) return fetching.get(this.root);
116
319
  const root = this.root;
117
320
  const landing = fetchAside(root, branch, timeout).then((fetched) => {
118
321
  if (!fetched) return; // offline: a later write asks again
119
- try { this.take(branch); } catch (error) { current().err(`board: ${error.message}\n`); }
322
+ return onChain(root, () => this.take(branch));
120
323
  }).finally(() => fetching.delete(root));
121
324
  fetching.set(root, landing);
122
325
  return landing;
123
326
  }
124
327
 
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);
328
+ /** After a fetch: what was pushed taken (integrate), then a push of what this checkout holds that the remote lacks,
329
+ * beside the process. */
330
+ async take(branch) {
331
+ const head = (await git(this.root, ['rev-parse', 'HEAD'])).trim();
332
+ const theirs = (await git(this.root, ['rev-parse', `origin/${branch}`])).trim();
333
+ const behind = Number((await git(this.root, ['rev-list', '--count', `${head}..${theirs}`])).trim());
334
+ if (behind > 0) await this.integrate(branch, head, theirs);
335
+ if (await this.unpushed(branch) && !this.pushing) this.pushInBackground(branch);
135
336
  this.refreshLateness();
136
337
  }
137
338
 
138
- // After a write: commit the board documents as their doer, then push, taking anything pushed meanwhile.
139
- commit(doer, message) {
339
+ // After a write: its commit is owed, made from the write's own text (`files`, its rendered documents; a recovery's or
340
+ // an earlier process's has none, and commits the documents as they are), as its doer, then pushed, beside the process.
341
+ commit(doer, message, files = null) {
140
342
  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]);
343
+ this.owed.push({ doer: doer || null, message, files: files ? files.map((file) => ({ path: file.path, text: file.text })) : null });
344
+ if (this.owed.length === 1) this.owedSince = Date.now();
345
+ if (this.owed.length === 1 && !this.retryTimer) onChain(this.root, () => this.commitOwed());
346
+ }
347
+
348
+ /** Each owed write committed in order, each as its own commit (none needs the checkout's index: a busy one holds
349
+ * only the index's following, syncIndex). */
350
+ async commitOwed() {
351
+ let made = 0, held = null;
352
+ while (this.owed.length) {
353
+ try { if (await this.commitOne(this.owed[0])) made += 1; }
354
+ catch (error) {
355
+ // a ref git holds locked (HEAD.lock, a branch's lock), or a HEAD that kept moving: the write stays owed, in order
356
+ if (refHeld(error)) { held = error; break; }
357
+ // said, and the writes after it still committed: its documents stay owed in git's state (they differ from HEAD)
358
+ 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`);
359
+ }
360
+ this.owed.shift();
361
+ }
362
+ if (held) this.waitOutRef(held);
363
+ else { this.owedSince = null; this.refSince = null; this.refSaid = false; this.refDelay = 0; }
149
364
  this.refreshLateness();
365
+ if (!made) return;
150
366
  const branch = this.branch();
151
367
  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).
368
+ // The push leaves the write's path: the commit is the write's publication, and a push the remote refuses is taken
369
+ // up by the next fetch (unpushed commits), which merges what was pushed and pushes. While a push runs, the next
370
+ // commit does not push the same commits beside it: one more push after it (a burst publishes in two pushes).
156
371
  if (this.pushing) { this.again = true; return; }
157
372
  this.pushInBackground(branch);
158
373
  }
159
374
 
375
+ /** git would not move HEAD (its ref locked, or moving under the commit): the owed writes are tried again on a timer,
376
+ * from 250 ms doubling to 30 s, and said with how long once they have waited a minute. */
377
+ waitOutRef(error) {
378
+ this.refSince ??= Date.now();
379
+ if (!this.refSaid && Date.now() - this.refSince >= BUSY_SAID_MS) {
380
+ this.refSaid = true;
381
+ 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`);
382
+ }
383
+ this.refDelay = Math.min(30_000, Math.max(250, (this.refDelay || 125) * 2));
384
+ this.retryTimer = setTimeout(() => { this.retryTimer = null; onChain(this.root, () => this.commitOwed()); }, this.refDelay);
385
+ this.retryTimer.unref?.();
386
+ }
387
+
388
+ /** One write's commit: its documents' text written as blobs, through an index of this process's own read from HEAD
389
+ * (the checkout's staged files never join it), committed as its doer; HEAD moved only if it is where the commit was
390
+ * made (another commit came first: made again on the new HEAD), and then the checkout's index told these documents
391
+ * are as committed. Answers whether a commit was made. */
392
+ async commitOne(write, tries = 3) {
393
+ const { doer, message, files } = write;
394
+ const who = doer || 'board';
395
+ const texts = (files ?? this.paths.map((path) => ({ path, text: readText(path) }))).filter((file) => file.text !== null);
396
+ if (!texts.length) return false;
397
+ const head = (await quiet(this.root, ['rev-parse', '--verify', '-q', 'HEAD']))?.trim() || null;
398
+ const index = join(this.checkout.gitDir, `supercode-board-index.${process.pid}`);
399
+ let moved = false;
400
+ try {
401
+ const own = { GIT_INDEX_FILE: index };
402
+ await git(this.root, head ? ['read-tree', head] : ['read-tree', '--empty'], 120_000, { env: own });
403
+ const entries = [];
404
+ for (const { path, text } of texts) {
405
+ const rel = this.rel(path);
406
+ const blob = (await git(this.root, ['hash-object', '-w', '--stdin'], 120_000, { input: text })).trim();
407
+ entries.push({ rel, blob });
408
+ await git(this.root, ['update-index', '--add', '--cacheinfo', `100644,${blob},${rel}`], 120_000, { env: own });
409
+ }
410
+ const tree = (await git(this.root, ['write-tree'], 120_000, { env: own })).trim();
411
+ if (head && tree === (await git(this.root, ['rev-parse', `${head}^{tree}`])).trim()) return false;
412
+ const commit = (await git(this.root, ['commit-tree', tree, ...(head ? ['-p', head] : []), '-m', message], 120_000, { env: author(who) })).trim();
413
+ try { await git(this.root, ['update-ref', '-m', `board: ${message.split('\n')[0]}`, 'HEAD', commit, head ?? '0'.repeat(40)]); }
414
+ catch (error) {
415
+ const now = (await quiet(this.root, ['rev-parse', '--verify', '-q', 'HEAD']))?.trim() || null;
416
+ if (now === head) throw error; // HEAD where it was: its ref was refused (a lock), said by the caller
417
+ if (tries <= 1) throw Object.assign(new Error('HEAD moved under the commit on every try'), { refHeld: true });
418
+ moved = true;
419
+ }
420
+ if (!moved) syncIndex(this.root, entries.map(({ rel }) => rel));
421
+ } finally { rmSync(index, { force: true }); }
422
+ // HEAD moved under it (another commit, or a take): the same write made again on the new HEAD
423
+ return moved ? this.commitOne(write, tries - 1) : true;
424
+ }
425
+
160
426
  /** `git push` beside the process, outliving it. Its refusal is kept where `behind()` reads it, and cleared by a push
161
427
  * that lands. */
162
428
  pushInBackground(branch) {
@@ -175,86 +441,181 @@ export class GitDocument {
175
441
  pushing.unref();
176
442
  }
177
443
 
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
444
  /** A held board's lateness: read once beside the owner's loop, then again after each of its own commits, pushes and
185
445
  * takes (refreshLateness), never by spawning git on an open. `lateness` is that reading (null while none is owed). */
186
446
  holdLateness() { this.heldLate = true; this.lateness = null; this.refreshLateness(); }
187
447
  refreshLateness() {
188
448
  if (!this.heldLate || !this.usable) return;
189
449
  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
450
  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}/`);
451
+ const name = this.branch();
452
+ if (!name) return null;
453
+ const times = ((await quiet(this.root, ['log', '--format=%ct', `origin/${name}..HEAD`], 30_000)) ?? '').trim().split('\n').filter(Boolean).map(Number);
454
+ // writes not committed yet (a busy index holding them), as well as commits not pushed
455
+ const uncommitted = this.owed.length ? { writes: this.owed.length, since: this.owedSince ?? Date.now() } : null;
456
+ if (!times.length && !uncommitted) return null;
198
457
  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 };
458
+ try { error = readFileSync(this.failurePath(), 'utf8').split('\n').map((line) => line.replace(/\s+/g, ' ').trim()).find(Boolean) ?? null; } catch { /* none recorded */ }
459
+ return { commits: times.length, since: times.length ? Math.min(...times) * 1000 : null, error, uncommitted };
201
460
  })().then((late) => { this.lateness = late; }, () => {}).finally(() => {
202
461
  this.lateReading = null;
203
462
  if (this.lateAgain) { this.lateAgain = false; this.refreshLateness(); }
204
463
  });
205
464
  }
206
465
 
466
+ /** The board documents' publish when it is behind, for a one-off board open (a CLI process, never the owner, whose
467
+ * boards are held and read `lateness`): commits the remote lacks, the oldest one's time and the last push's error, and
468
+ * documents that differ from HEAD (a commit owed: an earlier process ended, or a busy index held it, before it was
469
+ * made); null when nothing is owed (or this checkout pushes nowhere). Local reads only, no network. */
207
470
  behind() {
208
471
  if (!this.usable) return null;
209
472
  const branch = this.branch();
210
473
  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;
474
+ const run = (args) => { try { return execFileSync('git', args, { cwd: this.root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000 }); } catch { return ''; } };
475
+ const times = run(['log', '--format=%ct', `origin/${branch}..HEAD`]).trim().split('\n').filter(Boolean).map(Number);
476
+ const owed = run(['status', '--porcelain', '--', ...this.paths.map((path) => this.rel(path))]).trim().split('\n').filter(Boolean).length;
477
+ if (!times.length && !owed) return null;
213
478
  let error = null;
214
479
  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 };
480
+ return { commits: times.length, since: times.length ? Math.min(...times) * 1000 : null, error, uncommitted: owed ? { documents: owed, since: null } : null };
216
481
  }
217
482
 
218
483
  /** 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. */
484
+ * background push), with the same merge a write's pull uses; beside the round. */
220
485
  publishPending() {
221
486
  if (!this.usable || this.pushing) return;
222
487
  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);
488
+ if (!branch) return;
489
+ onChain(this.root, async () => {
490
+ // A remote that hangs costs this fetch 15 s, beside the round, never the round.
491
+ if (await this.unpushed(branch)) this.fetchThenTake(branch, 15_000);
492
+ });
226
493
  }
227
494
 
228
495
  /** 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;
496
+ async unpushed(branch) {
497
+ return Number((await quiet(this.root, ['rev-list', '--count', `origin/${branch}..HEAD`]))?.trim() || 0) > 0;
231
498
  }
232
499
 
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]}`);
500
+ // Take what was pushed: merged without touching the working tree (`git merge-tree`), so a board write never meets a
501
+ // half-merged document; a board document both sides changed is merged by the board's own document merge, and any other
502
+ // conflict leaves the checkout as it was and says so. What changes a board document is handed to the board
503
+ // (applyTaken, in its next transaction); what changes only other files moves git at once (finishTake).
504
+ async integrate(branch, head, theirs) {
505
+ if (takings.has(this.root)) return; // one is with the boards already
506
+ if (!(await canMergeTree(this.root))) return;
507
+ // every board document this process holds in this checkout, by its path in the checkout, with its board
508
+ const own = new Map();
509
+ for (const board of boardsAt.get(this.root) ?? []) for (const path of board.paths) own.set(board.rel(path), { path, board });
510
+ const merged = await git(this.root, ['merge-tree', '--write-tree', '-z', head, theirs]).then(
511
+ (out) => ({ out, clean: true }),
512
+ (error) => (error.code === 1 && error.stdout ? { out: error.stdout, clean: false } : Promise.reject(error)));
513
+ const fields = merged.out.split('\0');
514
+ let tree = fields[0].trim();
515
+ const stages = new Map();
516
+ for (let i = 1; i < fields.length && fields[i] !== ''; i++) {
517
+ const entry = /^(\d+) ([0-9a-f]+) (\d)\t(.*)$/s.exec(fields[i]);
518
+ if (!entry) continue;
519
+ if (!stages.has(entry[4])) stages.set(entry[4], {});
520
+ stages.get(entry[4])[entry[3]] = entry[2];
243
521
  }
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]);
522
+ if (!merged.clean) {
523
+ 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`);
524
+ const blob = async (oid) => (oid ? git(this.root, ['cat-file', 'blob', oid]) : '');
525
+ const index = join(this.checkout.gitDir, `supercode-board-index.${process.pid}`);
526
+ try {
527
+ const env = { GIT_INDEX_FILE: index };
528
+ await git(this.root, ['read-tree', tree], 120_000, { env });
529
+ for (const [file, stage] of stages) {
530
+ // stage 2 is this checkout's text and stage 3 the pushed one
531
+ 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;
532
+ 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`);
533
+ const oid = (await git(this.root, ['hash-object', '-w', '--stdin'], 120_000, { input: text })).trim();
534
+ await git(this.root, ['update-index', '--add', '--cacheinfo', `100644,${oid},${file}`], 120_000, { env });
535
+ }
536
+ tree = (await git(this.root, ['write-tree'], 120_000, { env })).trim();
537
+ } finally { rmSync(index, { force: true }); }
538
+ }
539
+ 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();
540
+ // without rename detection: a renamed file is its old path deleted and its new one added, both moved
541
+ const changed = (await git(this.root, ['diff', '--no-renames', '--name-only', '-z', head, commit])).split('\0').filter(Boolean);
542
+ const docs = changed.filter((file) => own.has(file));
543
+ const others = changed.filter((file) => !own.has(file));
544
+ if (others.length && (await git(this.root, ['status', '--porcelain', '--', ...others])).trim()) {
545
+ throw new Error(`what was pushed to ${branch} changes files this checkout has changed too (${others.slice(0, 3).join(', ')}); nothing was taken`);
546
+ }
547
+ const taking = { root: this.root, head, commit, others, docs, handed: new Map(), applied: new Set() };
548
+ if (!docs.length) return finishTake(taking);
549
+ for (const file of docs) {
550
+ const { path, board } = own.get(file);
551
+ if (!taking.handed.has(board)) taking.handed.set(board, []);
552
+ taking.handed.get(board).push({ path, base: (await quiet(this.root, ['show', `${head}:${file}`])) ?? '', text: (await quiet(this.root, ['show', `${commit}:${file}`])) ?? '' });
255
553
  }
256
- indexed(this.root, ['-c', `user.name=${who}`, '-c', 'user.email=board@supercode.invalid', 'commit', '--quiet', '--no-edit']);
554
+ takings.set(this.root, taking);
555
+ for (const board of taking.handed.keys()) board.onTaken?.();
257
556
  }
557
+
558
+ /**
559
+ * In the board's transaction, on its loop: what a fetch brought, written as the board's own edit (its stale read then
560
+ * takes it into the store), each document only if it is still what HEAD held when the merge was made. A write owed
561
+ * first drops it: that commit goes first, and the next fetch takes what was pushed again. Then git moves (finishTake).
562
+ */
563
+ applyTaken() {
564
+ const taking = takings.get(this.root);
565
+ const mine = taking?.handed.get(this);
566
+ if (!mine || taking.applied.has(this)) return false;
567
+ // compared without line endings: a checkout with core.autocrlf holds CRLF where git's blob holds LF
568
+ if (mine.some(({ path, base }) => lf(readText(path) ?? '') !== lf(base))) { abandon(taking); return false; }
569
+ for (const { path, text } of mine) writeAtomically(path, text);
570
+ taking.applied.add(this);
571
+ // git moves once every board the merge changes has written its part
572
+ if (taking.applied.size === taking.handed.size) onChain(this.root, () => finishTake(taking));
573
+ return true;
574
+ }
575
+ }
576
+
577
+ /** A take given up (a board's document is no longer what HEAD held, or the board closed): what the boards already
578
+ * wrote of it is their edit, committed as owed; the next fetch merges what was pushed again, cleanly. */
579
+ function abandon(taking) {
580
+ if (takings.get(taking.root) === taking) takings.delete(taking.root);
581
+ for (const board of taking.applied) board.commitDirty('board: documents taken in part from what was pushed');
258
582
  }
259
583
 
260
- function readable(path) { try { readFileSync(path); return true; } catch { return false; } }
584
+ /** git moved to the merge. HEAD first, by compare-and-swap from where the merge was made; then the checkout's index
585
+ * follows for every path the merge changed (syncIndex), and the other files the merge changed are written (a file it
586
+ * deleted removed), only while unchanged here. Whatever fails before HEAD moves gives the take up (abandon: what the
587
+ * boards wrote is committed as owed); after, what is left is said and its index entries stay owed. */
588
+ async function finishTake(taking) {
589
+ const { root, head, commit, others, docs } = taking;
590
+ let moved = false;
591
+ try {
592
+ if ((await git(root, ['rev-parse', 'HEAD'])).trim() !== head) return abandon(taking); // a commit came first
593
+ if (others.length && (await git(root, ['status', '--porcelain', '--', ...others])).trim()) {
594
+ 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`);
595
+ }
596
+ await git(root, ['update-ref', '-m', 'board: took what was pushed', 'HEAD', commit, head]);
597
+ moved = true;
598
+ syncIndex(root, [...docs, ...others]);
599
+ if (others.length) moveFiles(root, others, head, commit);
600
+ } catch (error) {
601
+ if (!moved) abandon(taking);
602
+ throw error;
603
+ } finally { if (takings.get(root) === taking) takings.delete(root); }
604
+ }
605
+
606
+ /** Text without CR before LF. */
607
+ const lf = (text) => text.replace(/\r\n/g, '\n');
608
+
609
+ /** A document's text, or null when it does not exist. */
610
+ function readText(path) { try { return readFileSync(path, 'utf8'); } catch { return null; } }
611
+
612
+ /** A document written whole, never half: beside it, then renamed over it. */
613
+ function writeAtomically(path, text) {
614
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
615
+ writeFileSync(temporary, text);
616
+ renameSync(temporary, path);
617
+ }
618
+
619
+ /** The board's commits are made as `who`. */
620
+ const author = (who) => ({ GIT_AUTHOR_NAME: who, GIT_AUTHOR_EMAIL: 'board@supercode.invalid', GIT_COMMITTER_NAME: who, GIT_COMMITTER_EMAIL: 'board@supercode.invalid' });
621
+
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.86",
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": {