@volter/supercode-orchestrator 0.5.73 → 0.5.75

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.
package/board/engine.mjs CHANGED
@@ -8,10 +8,10 @@
8
8
  // Each function takes an open board (`store.mjs`) and runs in one write transaction. Nothing here launches,
9
9
  // observes or sends: the dispatcher (`dispatch.mjs`) does, and records what it did through these.
10
10
  import { current } from './call.mjs';
11
- import { spawn, spawnSync } from 'node:child_process';
11
+ import { spawn, execFile } from 'node:child_process';
12
12
  import { existsSync, rmSync } from 'node:fs';
13
13
  import { join, resolve } from 'node:path';
14
- import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace } from './workspace.mjs';
14
+ import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace, plainWorkspace } from './workspace.mjs';
15
15
  import { DatabaseSync } from 'node:sqlite';
16
16
  import { view, actorOf, principalOf, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
17
17
  import { completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
@@ -181,6 +181,25 @@ export function bodyTasks(body) {
181
181
  return { body: rest || null, tasks: tasks.map((t) => ({ title: t.title, body: t.body.join('\n') || null })) };
182
182
  }
183
183
 
184
+ /**
185
+ * A card's workspace declared through its machine's door when it names a path (a `dir`, a legacy checkout path), awaited
186
+ * before the card is filed: the filing (createCard) is one write that asks no machine. Answers the spec to file.
187
+ */
188
+ export async function prepareCard(board, spec) {
189
+ const ws = parseWorkspace(spec.workspace ?? 'scratch');
190
+ const project = spec.project ? projectOf(board.root, spec.project) : null;
191
+ if (project) {
192
+ if (ws.kind === 'scratch') ws.kind = 'worktree';
193
+ if (ws.kind === 'worktree' && !ws.path) ws.path = project.primary_path;
194
+ }
195
+ if (plainWorkspace(ws.kind, ws.path, spec.branch)) return spec;
196
+ const machineHint = spec.machine ?? (spec.subtaskOf ? getTask(board.db, spec.subtaskOf)?.machine : null);
197
+ const declared = await declareWorkspace(ws.kind, ws.path, machineHint, spec.branch);
198
+ const workspaceTask = { workspace_kind: ws.kind, workspace_path: declared.path, workspace_anchor: declared.repository, branch_name: declared.branch, machine: machineHint };
199
+ // Logical repositories may be filed before a machine has registered them; launch refuses missing mappings.
200
+ const checked = ws.kind === 'dir' && declared.path ? await checkWorkspace(board, workspaceTask) : null;
201
+ return { ...spec, workspaceDeclared: { declared, checked } };
202
+ }
184
203
  export function createCard(board, spec) {
185
204
  const title = String(spec.title ?? '').trim();
186
205
  if (!title) throw new Error('a card has a title');
@@ -196,10 +215,11 @@ export function createCard(board, spec) {
196
215
  if (ws.kind === 'worktree' && !ws.path) ws.path = project.primary_path;
197
216
  }
198
217
  const machineHint = spec.machine ?? (spec.subtaskOf ? getTask(board.db, spec.subtaskOf)?.machine : null);
199
- const declared = declareWorkspace(ws.kind, ws.path, machineHint, spec.branch);
218
+ const declared = spec.workspaceDeclared?.declared ?? plainWorkspace(ws.kind, ws.path, spec.branch);
219
+ if (!declared) throw new Error('a workspace path is declared through its machine before the card is filed (prepareCard)');
200
220
  const workspaceTask = { workspace_kind: ws.kind, workspace_path: declared.path, workspace_anchor: declared.repository, branch_name: declared.branch, machine: machineHint };
201
221
  // Logical repositories may be filed before a machine has registered them; launch refuses missing mappings.
202
- const checked = ws.kind === 'dir' && declared.path ? checkWorkspace(board, workspaceTask) : null;
222
+ const checked = spec.workspaceDeclared?.checked ?? null;
203
223
  ws.path = declared.path;
204
224
  spec = { ...spec, branch: declared.branch ?? spec.branch };
205
225
  const initial = spec.initialStatus ?? null;
@@ -367,12 +387,13 @@ export function extendClaim(db, id, { ttl, reason, extra = {} }) {
367
387
  * A completed card's workspace goes, as Hermes cleans up on completion: a scratch directory under the board's own
368
388
  * workspaces, or a worktree (its branch stays); never while a child still waits on it, never a `dir`.
369
389
  */
370
- function cleanupWorkspace(board, id) {
390
+ async function cleanupWorkspace(board, id) {
371
391
  const task = view(board, (db) => getTask(db, id));
372
392
  if (!task?.workspace_path || !isLocalWorkspace(task) || !['scratch', 'worktree'].includes(task.workspace_kind)) return;
373
393
  const waiting = view(board, (db) => one(db, "SELECT 1 FROM task_links l JOIN tasks t ON t.id = l.child_id WHERE l.parent_id = ? AND t.status NOT IN ('done', 'archived') LIMIT 1", id));
374
394
  if (waiting || !existsSync(task.workspace_path)) return;
375
- if (task.workspace_kind === 'worktree') { spawnSync('git', ['-C', task.workspace_path, 'worktree', 'remove', '--force', task.workspace_path], { encoding: 'utf8' }); return; }
395
+ // awaited, never a wait that holds the event loop
396
+ if (task.workspace_kind === 'worktree') { await new Promise((resolve) => execFile('git', ['-C', task.workspace_path, 'worktree', 'remove', '--force', task.workspace_path], { encoding: 'utf8' }, () => resolve())); return; }
376
397
  const managed = resolve(board.slug === 'default' ? join(board.root, 'kanban', 'workspaces') : join(board.root, 'kanban', 'boards', board.slug, 'workspaces'));
377
398
  if (resolve(task.workspace_path).startsWith(`${managed}/`)) rmSync(task.workspace_path, { recursive: true, force: true });
378
399
  }
@@ -406,9 +427,9 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
406
427
  const entry = await loadWorkflow(board.root);
407
428
  if (name === 'comment' && (!String(payload.body ?? '').trim() || !String(payload.author ?? '').trim())) throw new Error('a comment has an author and body');
408
429
  refuseCredentialDump(name === 'comment' ? `the comment on ${id}` : `${name.replace(/_/g, '-')} on ${id}`, payload);
409
- refreshCardInputs(board, id, { commits: ['complete', 'request_review'].includes(name) });
430
+ await refreshCardInputs(board, id, { commits: ['complete', 'request_review'].includes(name) });
410
431
  const evidencePhase = ['complete', 'request_review', 'request_state'].includes(name) ? completionPhase(board, id, entry, name, payload, roles ?? callerRoles(board.db, id)) : null;
411
- if (evidencePhase) payload = { ...payload, ci: observeCompletionEvidence(board, id, entry, payload, evidencePhase) };
432
+ if (evidencePhase) payload = { ...payload, ci: await observeCompletionEvidence(board, id, entry, payload, evidencePhase) };
412
433
  if (payload.ci?.required && !payload.ci.ready && (name !== 'request_state' || evidencePhase === 'review_verdict')) throw new Error(payload.ci.reason ?? 'The PR candidate could not be verified.');
413
434
  const result = tx(board, (db) => {
414
435
  roles ??= callerRoles(db, id);
@@ -423,7 +444,7 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
423
444
  });
424
445
  await runEffects(board, result.effects);
425
446
  // A card this verb closed ends its agents' assignments; the verb hands the ends on itself (agent-declarations.mjs).
426
- if (result.moved) { const { endFinishedAgents } = await import('./agent-declarations.mjs'); endFinishedAgents(board, [id]); }
447
+ if (result.moved) { const { endFinishedAgents } = await import('./agent-declarations.mjs'); await endFinishedAgents(board, [id]); }
427
448
  return { from: result.from, to: result.to, moved: result.moved, ...(result.commentId ? { commentId: result.commentId } : {}) };
428
449
  }
429
450
 
@@ -431,16 +452,16 @@ export async function requestStateMany(board, ids, payload = {}) {
431
452
  const targets = [...new Set(ids.map(String))];
432
453
  if (!targets.length) throw new Error('A state request names at least one card.');
433
454
  const entry = await loadWorkflow(board.root);
434
- const prepared = new Map(targets.map(id => {
455
+ const prepared = new Map(await Promise.all(targets.map(async id => {
435
456
  const roles = callerRoles(board.db, id);
436
457
  const phase = completionPhase(board, id, entry, 'request_state', payload, roles);
437
- const evidence = inspectCompletionEvidence(board, id, entry, payload, phase);
458
+ const evidence = await inspectCompletionEvidence(board, id, entry, payload, phase);
438
459
  // An ordinary request mails a decision to the manager, not a completion.
439
460
  // Keep a failed source observation in that mail. A reviewer recording an
440
461
  // actual verdict still has to verify the requested PR head.
441
462
  if (phase === 'review_verdict' && evidence.required && !evidence.ready) throw new Error(evidence.reason ?? 'The reviewed PR head could not be verified.');
442
463
  return [id, evidence];
443
- }));
464
+ })));
444
465
  const result = tx(board, db => {
445
466
  const effects = [];
446
467
  const results = targets.map(id => withCompletionEvidence(board, id, prepared.get(id), () => {
@@ -451,15 +472,15 @@ export async function requestStateMany(board, ids, payload = {}) {
451
472
  return { results, effects };
452
473
  });
453
474
  await runEffects(board, result.effects);
454
- { const { endFinishedAgents } = await import('./agent-declarations.mjs'); endFinishedAgents(board, targets); }
475
+ { const { endFinishedAgents } = await import('./agent-declarations.mjs'); await endFinishedAgents(board, targets); }
455
476
  return result.results;
456
477
  }
457
478
 
458
479
  /** Take card `id` to `status` by the event the workflow says goes there for this caller (`goto`). */
459
480
  export async function goto(board, id, status, { roles = null, payload = {} } = {}) {
460
481
  const entry = await loadWorkflow(board.root);
461
- refreshCardInputs(board, id, { commits: true });
462
- observeCompletionEvidence(board, id, entry, payload);
482
+ await refreshCardInputs(board, id, { commits: true });
483
+ await observeCompletionEvidence(board, id, entry, payload);
463
484
  const { name, refusal, from } = (() => {
464
485
  const db=board.db;
465
486
  roles ??= callerRoles(db, id);
@@ -553,10 +574,10 @@ export async function runEffects(board, effects = [], { pending: due = false, el
553
574
  /** One effect's I/O: true when it is done, false when it is not done yet (the caller counts the attempt). */
554
575
  async function runEffect(board, e) {
555
576
  if (e.kind === 'session_notice') { const { sessionNotice } = await import('./dispatch.mjs'); return await sessionNotice(board, e) !== false; }
556
- if (e.kind === 'cleanup_workspace') { cleanupWorkspace(board, e.id); return true; }
557
- if (e.kind === 'declare_card_agent') { const { declareCardAgentEffect } = await import('./agent-declarations.mjs'); return declareCardAgentEffect(board, e) !== false; }
577
+ if (e.kind === 'cleanup_workspace') { await cleanupWorkspace(board, e.id); return true; }
578
+ if (e.kind === 'declare_card_agent') { const { declareCardAgentEffect } = await import('./agent-declarations.mjs'); return (await declareCardAgentEffect(board, e)) !== false; }
558
579
  // a verb's own ask files its card's mail at once (a local write; the mailbox delivers it)
559
- if (e.kind === 'board_message') { const { fileBoardMail } = await import('./dispatch.mjs'); fileBoardMail(board, { undelivered: [] }, e.id); return true; }
580
+ if (e.kind === 'board_message') { const { fileBoardMail } = await import('./dispatch.mjs'); await fileBoardMail(board, { undelivered: [] }, e.id); return true; }
560
581
  if (e.kind === 'merge_reviewed') { const { mergeReviewed } = await import('./dispatch.mjs'); return await mergeReviewed(board, e) !== false; }
561
582
  if (e.kind === 'invalidate_descendants') { tx(board, (db) => invalidateDescendants(db, board, e.id)); return true; }
562
583
  if (e.kind === 'stop_run') { stopWorker(board, e.runId); return true; }
@@ -624,14 +645,14 @@ function invalidateDescendants(db, board, id) {
624
645
 
625
646
  // each of Hermes's verbs is an event of the workflow; the words it takes are the event's payload
626
647
 
627
- export const complete = (board, id, { result = null, summary = null, metadata = null, roles, runId = null, commit = null, ci_run = null, branch = null, pr = null, pr_head = null, reviewed_sha = null } = {}) =>
628
- act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId, commit, ci_run, branch, pr, pr_head, reviewed_sha } });
648
+ export const complete = async (board, id, { result = null, summary = null, metadata = null, roles, runId = null, commit = null, ci_run = null, branch = null, pr = null, pr_head = null, reviewed_sha = null } = {}) =>
649
+ await act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId, commit, ci_run, branch, pr, pr_head, reviewed_sha } });
629
650
  // a block names the one thing that ends it: a card it waits on (`on`), or the message whose answer it waits for (`waiting_on`)
630
- export const block = (board, id, { reason = null, on = null, waitingOn = null, roles, runId = null } = {}) =>
631
- act(board, id, 'block', { roles, payload: { reason, on, waiting_on: waitingOn, run_id: runId } });
651
+ export const block = async (board, id, { reason = null, on = null, waitingOn = null, roles, runId = null } = {}) =>
652
+ await act(board, id, 'block', { roles, payload: { reason, on, waiting_on: waitingOn, run_id: runId } });
632
653
  export const unblock = (board, id, { reason = null, on = null, roles } = {}) => act(board, id, 'unblock', { roles, payload: { reason, on } });
633
- export const requestReview = (board, id, { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, ci_run = null, branch = null, pr = null, pr_head = null } = {}) =>
634
- act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId, commit, ci_run, branch, pr, pr_head } });
654
+ export const requestReview = async (board, id, { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, ci_run = null, branch = null, pr = null, pr_head = null } = {}) =>
655
+ await act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId, commit, ci_run, branch, pr, pr_head } });
635
656
  export const requestChanges = (board, id, { reason = null, reviewer = null, roles } = {}) => act(board, id, 'request_changes', { roles, payload: { reason, reviewer } });
636
657
  export const reopen = (board, id, { author = 'user', reason = null, roles } = {}) => act(board, id, 'reopen', { roles, payload: { author, reason } });
637
658
  export const reopenReview = (board, id, { roles, reason = null } = {}) => act(board, id, 'reopen_review', { roles, payload: { reason } });
@@ -651,10 +672,10 @@ export async function specify(board, id, { title = null, body = null, workspace
651
672
  const ws = parseWorkspace(word);
652
673
  if (task.current_run_id) throw new Error('end the current run before changing its workspace');
653
674
  if (branch != null && ws.kind === 'scratch') throw new Error('a scratch workspace has no repository branch');
654
- declaration = { kind: ws.kind, ...declareWorkspace(ws.kind, ws.path, target, branch), ...(machine != null && target !== task.machine ? { machine: target } : {}) };
675
+ declaration = { kind: ws.kind, ...(await declareWorkspace(ws.kind, ws.path, target, branch)), ...(machine != null && target !== task.machine ? { machine: target } : {}) };
655
676
  if (declaration.repository === task.workspace_anchor) declaration.branch ??= task.branch_name;
656
677
  if (declaration.kind === 'worktree') declaration.branch ??= `wt/${id}`;
657
- if (declaration.kind === 'dir') declaration.path = checkWorkspace(board, { ...task, machine: target, workspace_kind: declaration.kind, workspace_anchor: declaration.repository, workspace_path: declaration.path, branch_name: declaration.branch }).path;
678
+ if (declaration.kind === 'dir') declaration.path = (await checkWorkspace(board, { ...task, machine: target, workspace_kind: declaration.kind, workspace_anchor: declaration.repository, workspace_path: declaration.path, branch_name: declaration.branch })).path;
658
679
  }
659
680
  // an arc's new task lines are filed as its subtasks once the specify itself is accepted
660
681
  const steps = body != null && !one(board.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id = ?', id)?.subtask_of ? bodyTasks(body) : { body, tasks: [] };
@@ -670,10 +691,11 @@ export const setGoal = (board, id, { on = true, maxTurns = null, roles } = {}) =
670
691
  export async function decompose(board, id, children, { author = 'user', roles } = {}) {
671
692
  if (!children.length) throw new Error('decompose names at least one child (--child "<title>[@<assignee>]")');
672
693
  const task = view(board, (db) => must(db, id));
673
- const ids = children.map((c) => createCard(board, {
694
+ const ids = [];
695
+ for (const c of children) ids.push(createCard(board, await prepareCard(board, {
674
696
  title: c.title, body: c.body ?? null, assignee: c.assignee ?? task.assignee, machine: task.machine,
675
697
  workspace: task.workspace_anchor ? `${task.workspace_kind}:${task.workspace_anchor}` : task.workspace_path ? `${task.workspace_kind}:${task.workspace_path}` : task.workspace_kind, tenant: task.tenant, createdBy: author,
676
- }).id);
698
+ })).id);
677
699
  await act(board, id, 'decompose', { roles, payload: { children: ids, author } });
678
700
  return ids;
679
701
  }
@@ -6,8 +6,15 @@
6
6
  // the two texts of a board document merged by ztrack's own document merge, in which a hand edit wins its cards. Nothing
7
7
  // either side wrote is dropped; a merge that cannot be made stops the push and says why, leaving both texts in the
8
8
  // checkout's history.
9
+ //
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).
9
16
  import { current } from './call.mjs';
10
- import { execFileSync, spawn } from 'node:child_process';
17
+ import { execFile, execFileSync, spawn } from 'node:child_process';
11
18
  import { readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
12
19
  import { basename, dirname, relative } from 'node:path';
13
20
 
@@ -25,6 +32,17 @@ const indexed = (cwd, args) => {
25
32
  };
26
33
 
27
34
  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)));
37
+ const fetching = new Map(); // checkout root → the fetch in flight (one at a time per checkout, in this process)
38
+ const derived = new Set(); // boards (by their open document) whose owed commit this process has derived from git's state
39
+ const pushDerived = new Set(); // checkouts whose owed push this process has derived
40
+
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()]);
45
+ }
28
46
  /** The background push: `git push`, then its error kept for `behind()` (or the last one cleared when it landed). */
29
47
  const PUSH = `const {spawnSync}=require('node:child_process'),fs=require('node:fs');const [branch,failure,temporary]=process.argv.slice(1);
30
48
  const r=spawnSync('git',['push','--quiet','origin','HEAD:'+branch],{encoding:'utf8',windowsHide:true});
@@ -40,6 +58,29 @@ export class GitDocument {
40
58
  this.merge = merge; // (base, theirs, ours, path) => text | null, ztrack's mergeBoardDocument
41
59
  }
42
60
 
61
+ /** Whether this board's owed commit is still to be derived in this process (its board calls `derive` after its first
62
+ * recovery, when `paths` is its whole inventory). */
63
+ get underived() { return this.usable && !derived.has(this.paths[0]); }
64
+
65
+ /** What an earlier process left owed, from git's own state: this board's documents that differ from HEAD are
66
+ * committed (a write, or a recovered one, that ended before its commit), and a HEAD ahead of its upstream is pushed.
67
+ * Once per board per process, by the board, after its recovery and with every document it has in `paths`. */
68
+ derive() {
69
+ if (!this.usable) return;
70
+ derived.add(this.paths[0]);
71
+ this.commitDirty('board: documents written before this process started');
72
+ if (pushDerived.has(this.root)) return;
73
+ pushDerived.add(this.root);
74
+ const branch = this.branch();
75
+ if (branch && this.unpushed(branch) && !this.pushing) this.pushInBackground(branch);
76
+ }
77
+
78
+ /** This board's documents that differ from HEAD, committed as the board (a recovery's writes are no doer's). */
79
+ commitDirty(message) {
80
+ if (!this.usable) return;
81
+ if (quiet(this.root, ['status', '--porcelain', '--', ...this.paths.map((path) => this.rel(path))])?.trim()) this.commit(null, message);
82
+ }
83
+
43
84
  get usable() { return this.root !== null; }
44
85
  rel(path) { return relative(this.root, path); }
45
86
  /** The checkout's branch, or null on no branch (a detached HEAD has nowhere to push and nothing to pull). */
@@ -66,13 +107,31 @@ export class GitDocument {
66
107
  if (Date.now() - fetchedAt(this.root) < FETCH_EVERY_MS) return;
67
108
  const branch = this.branch();
68
109
  if (!branch) return this.detached();
69
- if (this.unpushed(branch)) return this.pushing ? undefined : this.publish(branch, 'board');
70
- if (quiet(this.root, ['fetch', '--quiet', 'origin', branch]) === null) return; // offline: the next write tries again
110
+ this.fetchThenTake(branch);
111
+ }
112
+
113
+ /** The fetch beside the process; when it lands, what was pushed is taken (take) in one local step. One at a time. */
114
+ fetchThenTake(branch, timeout) {
115
+ if (fetching.has(this.root)) return fetching.get(this.root);
116
+ const root = this.root;
117
+ const landing = fetchAside(root, branch, timeout).then((fetched) => {
118
+ if (!fetched) return; // offline: a later write asks again
119
+ try { this.take(branch); } catch (error) { current().err(`board: ${error.message}\n`); }
120
+ }).finally(() => fetching.delete(root));
121
+ fetching.set(root, landing);
122
+ return landing;
123
+ }
124
+
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) {
71
128
  const behind = Number(git(this.root, ['rev-list', '--count', `HEAD..origin/${branch}`]).trim());
72
- if (behind === 0) return;
73
- const dirty = git(this.root, ['status', '--porcelain', '--', ...this.paths.map((path) => this.rel(path))]).trim();
74
- if (dirty) return; // a write in progress publishes first and takes what was pushed after
75
- this.integrate(branch, 'board');
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);
76
135
  }
77
136
 
78
137
  // After a write: commit the board documents as their doer, then push, taking anything pushed meanwhile.
@@ -136,9 +195,8 @@ export class GitDocument {
136
195
  if (!this.usable || this.pushing) return;
137
196
  const branch = this.branch();
138
197
  if (!branch || !this.unpushed(branch)) return;
139
- // A remote that hangs costs the round 15 s, not git's two minutes.
140
- if (quiet(this.root, ['fetch', '--quiet', 'origin', branch], 15_000) === null) return; // offline: the next round tries again
141
- if (this.unpushed(branch)) this.publish(branch, 'board');
198
+ // A remote that hangs costs this fetch 15 s, beside the round, never the round.
199
+ this.fetchThenTake(branch, 15_000);
142
200
  }
143
201
 
144
202
  /** Whether this checkout holds board commits the remote does not have. */
@@ -146,16 +204,6 @@ export class GitDocument {
146
204
  return Number(quiet(this.root, ['rev-list', '--count', `origin/${branch}..HEAD`])?.trim() || 0) > 0;
147
205
  }
148
206
 
149
- /** Push what this checkout holds, taking anything pushed meanwhile. */
150
- publish(branch, who) {
151
- for (let attempt = 0; attempt < 3; attempt++) {
152
- if (quiet(this.root, ['push', '--quiet', 'origin', `HEAD:${branch}`]) !== null) { try { rmSync(this.failurePath(), { force: true }); } catch { /* none */ } return; }
153
- if (quiet(this.root, ['fetch', '--quiet', 'origin', branch]) === null) throw new Error('the board documents were committed here but could not be pushed: the remote did not answer');
154
- this.integrate(branch, who);
155
- }
156
- throw new Error('the board documents were committed here but the push was refused three times');
157
- }
158
-
159
207
  // Take what was pushed with a merge: it works beside the other edits in this checkout (the manager's own files), which
160
208
  // a rebase refuses. A board document both sides changed is merged by the board's own document merge; any other
161
209
  // conflict leaves the checkout as it was and says so.
@@ -4,32 +4,45 @@
4
4
  // (`capabilities`: its board process guard's harnesses, and whether it has the keyed launch door). Teams' session feed
5
5
  // is for viewers; dispatch never waits on it (D137, D138).
6
6
  //
7
- // Every machine is read at once, each within DEADLINE_MS (bin/machine-fleet.mjs), so one hung machine or relay costs a
7
+ // Every machine is read at once, each within DEADLINE_MS, in this process through its door (doors.mjs), so one hung machine or relay costs a
8
8
  // reading that long, not the sum of them. A machine that answered is `online`. A remote machine that did not is
9
9
  // `unknown` (its runs are kept, as an unread machine's always were), unless the relay positively answered that it is
10
10
  // not linked (`machine_offline`): then it is offline. This machine's door not answering is no reading at all.
11
- import { spawnSync } from 'node:child_process';
12
- import { fileURLToPath } from 'node:url';
13
11
  import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
12
+ import { machineCall } from './doors.mjs';
14
13
 
15
- const entry = fileURLToPath(new URL('../bin/machine-fleet.mjs', import.meta.url));
16
14
  const DEADLINE_MS = 10_000;
15
+ const within = (promise, ms) => Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(Object.assign(new Error(`no answer within ${ms} ms`), { code: 'timeout' })), ms).unref?.())]);
17
16
 
18
- /** One reading of `machines` (normalized names) and this machine, `local`. */
19
- export function readMachineFleet(machines, local) {
17
+ // One machine's reading, through its door in this process (doors.mjs): its panes, what a board launch needs of it, and
18
+ // the session under each live pane.
19
+ async function readOne(name, at) {
20
+ const panes = await machineCall(at, 'harness.v1.panes.list', {});
21
+ // What a board launch needs of this machine: its board process guard (describe), and its keyed launch door, asked
22
+ // for a key no launch has (an answer of null is the door; an unknown method is its absence)
23
+ const description = await machineCall(at, 'harness.v1.machine.describe', {}).catch(() => null);
24
+ const keyed = await machineCall(at, 'harness.v1.launches.get', { key: 'board-capability-reading' }).then(() => null, (error) => String(error?.message ?? error).slice(0, 200));
25
+ const capabilities = { guard: description?.boardProcessPolicy?.version === 1 ? description.boardProcessPolicy.harnesses ?? [] : [], launches: keyed === null, launchesRefused: keyed };
26
+ const live = (Array.isArray(panes) ? panes : []).filter((pane) => !pane.dead && pane.pid);
27
+ // the session under each live pane: the harness stream, an operator's; a caller the machine admits only to its panes
28
+ // (a box another custodian enrolled, granting view and control) reads its panes and no sessions
29
+ let activities = [], sessionsUnread = null;
30
+ if (live.length) {
31
+ try { activities = (await machineCall(at, 'harness.v1.sessions.activity_under', { pids: live.map((pane) => pane.pid) }))?.activities ?? []; }
32
+ catch (error) { sessionsUnread = String(error?.message ?? error).slice(0, 200); }
33
+ }
34
+ return { machine: name, panes, activities, capabilities, sessionsUnread };
35
+ }
36
+
37
+ /** One reading of `machines` (normalized names) and this machine, `local`: every machine at once, each bounded. */
38
+ export async function readMachineFleet(machines, local) {
20
39
  const names = [...new Set([local, ...machines])];
21
40
  const started = performance.now();
22
- const reply = spawnSync(process.execPath, [entry], { input: JSON.stringify({ machines: names.map((name) => ({ name, at: name === local ? null : name })), deadlineMs: DEADLINE_MS }),
23
- encoding: 'utf8', timeout: DEADLINE_MS + 5_000, maxBuffer: 64 * 1024 * 1024 });
41
+ const rows = await Promise.all(names.map((name) => within(readOne(name, name === local ? null : name), DEADLINE_MS)
42
+ .catch((error) => ({ machine: name, error: String(error?.message ?? error).slice(0, 300), code: error?.code ?? 'unavailable' }))));
24
43
  noteStep(`fleet reading (${names.length} machine${names.length === 1 ? '' : 's'})`, performance.now() - started);
25
- let rows = null;
26
- try { rows = JSON.parse(String(reply.stdout).trim().split('\n').pop()); } catch { /* no reading */ }
27
44
  // `panes`: each answering machine's live pane ids, whether or not a session was read in them
28
45
  const online = new Set(), unknown = new Set(), alive = new Set(), native = new Map(), sessions = [], capabilities = new Map(), panes = new Map();
29
- if (!Array.isArray(rows)) {
30
- const error = String(reply.stderr || reply.error?.message || 'no answer').trim().slice(-300);
31
- return { ok: false, presenceKnown: false, online, unknown: new Set(names), alive, native, sessions, capabilities, panes, error: `the fleet reading did not answer: ${error}` };
32
- }
33
46
  let localError = null;
34
47
  for (const row of rows) {
35
48
  if (row.error) {
@@ -1,16 +1,19 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { fileURLToPath } from 'node:url';
3
- import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
4
- const entry = fileURLToPath(new URL('../bin/runtime-door.mjs',import.meta.url));
1
+ // A machine's public door (`harness.v1.*`), called in this process through the held channel to its daemon (doors.mjs):
2
+ // no helper process, nothing that waits on this process's event loop. Answered as the helper it replaces answered,
3
+ // `{code, stdout, stderr}`, so a caller reads a door's refusal as it always did.
4
+ import { machineCall } from './doors.mjs';
5
5
 
6
- /** Public machine RPC only; no Teams binary or implementation/state-file access. */
7
- export function runtimeDoor(machine,method,params={}, {env=process.env,timeout=60_000,as=null}={}) {
8
- // a step of the running command (a dispatch round's slow-log line names its doors, and their time, by method)
9
- const started = performance.now();
10
- const result = spawnSync(process.execPath,[entry],{
11
- input:JSON.stringify({machine,method,params}),encoding:'utf8',timeout,
12
- env:{...env,...(as?{SUPERCODE_CALLER:as}:{})},maxBuffer:512*1024*1024,
13
- });
14
- noteStep(`door ${method}${machine ? ' (another machine)' : ''}`, performance.now() - started);
15
- return {code:result.status,stdout:result.stdout??'',stderr:(result.stderr??'')+(result.error?.message??'')};
6
+ /**
7
+ * One door call. `machine` null for this machine. `as`: the board this call acts for (`sc:<machine>:board:<name>`),
8
+ * named to the daemon as the caller, which it takes from the process that registered that board's home (machine.mjs).
9
+ */
10
+ export async function runtimeDoor(machine, method, params = {}, { timeout = 60_000, as = null } = {}) {
11
+ try {
12
+ if (!method.startsWith('harness.v1.')) throw Object.assign(new Error('the runtime door accepts harness.v1 methods'), { code: 'invalid_request' });
13
+ const named = as && !method.startsWith('harness.v1.sessions.') ? { ...params, caller: as } : params;
14
+ const result = await machineCall(machine, method, named, { timeoutMs: timeout });
15
+ return { code: 0, stdout: `${JSON.stringify(result ?? null)}\n`, stderr: '' };
16
+ } catch (error) {
17
+ return { code: 1, stdout: '', stderr: `${JSON.stringify({ code: error?.code ?? 'unavailable', message: String(error?.message ?? error) })}\n` };
18
+ }
16
19
  }
package/board/store.mjs CHANGED
@@ -217,7 +217,7 @@ export const tx = (board, fn) => {
217
217
  const caller = /at (?:async )?([\w.$<>]+) /.exec(String(new Error().stack).split('\n')[2] ?? '')?.[1] ?? 'unknown';
218
218
  const started = performance.now();
219
219
  try {
220
- const result = board.tx(db => {const result=fn(db);recordCardPublications(db,actorOf());return result;});
220
+ const result = board.tx(db => {const result=fn(db);const began=performance.now();try{recordCardPublications(db,actorOf());}finally{noteStep('board tx publications',performance.now()-began);}return result;});
221
221
  signalBoardWrite(board.root, actorOf());
222
222
  return result;
223
223
  } finally { noteStep(`board tx from ${caller}`, performance.now() - started); }
package/board/tools.mjs CHANGED
@@ -175,12 +175,12 @@ export async function boardTool(root, name, args = {}, env = {}) {
175
175
  return ok({ task_id: tid, attachments: tx(board, (db) => engine.attachments(db, tid)).map((a) => ({ id: a.id, filename: a.filename, size: a.size, content_type: a.content_type, path: a.stored_path })) });
176
176
  case 'kanban_create': {
177
177
  const workspace = args.workspace_kind ? `${args.workspace_kind}${args.workspace_path ? `:${args.workspace_path}` : ''}` : undefined;
178
- const made = engine.createCard(board, {
178
+ const made = engine.createCard(board, await engine.prepareCard(board, {
179
179
  title: args.title, body: args.body ? redact(String(args.body)) : null, assignee: args.assignee ?? null, parents: [args.parents ?? []].flat(), tenant: args.tenant ?? null,
180
180
  priority: args.priority ?? 0, workspace, project: args.project ?? null, triage: Boolean(args.triage), idempotencyKey: args.idempotency_key ?? null,
181
181
  maxRuntime: args.max_runtime_seconds ?? null, initialStatus: args.initial_status ?? null, skills: args.skills ?? null, model: args.model ?? null, provider: args.provider ?? null,
182
182
  goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author,
183
- });
183
+ }));
184
184
  await engine.runEffects(board,made.effects??[]);
185
185
  return ok({ task_id: made.id, existing: made.existing, status: tx(board, (db) => engine.getTask(db, made.id)).status });
186
186
  }
@@ -6,7 +6,7 @@
6
6
  // pane, sending a session a message, cleaning a workspace) is answered as effects the caller runs after commit.
7
7
  import { current } from './call.mjs';
8
8
  import { notRuntime } from './owner-files.mjs';
9
- import { spawnSync, execFile } from 'node:child_process';
9
+ import { execFile } from 'node:child_process';
10
10
  import { promisify } from 'node:util';
11
11
  import { existsSync, readdirSync, realpathSync, watch, readFileSync } from 'node:fs';
12
12
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
@@ -206,26 +206,27 @@ function pattern(p) {
206
206
  // a change to its reviewed work. Merge commits are not the card's work: merging its reviewed PR, or main into its
207
207
  // branch, is not a newer change to review.
208
208
  const commitTimes = new Map();
209
- function observeLastCommit(path, id, branches = []) {
209
+ async function observeLastCommit(path, id, branches = []) {
210
210
  if (!path) return null;
211
211
  const key = `${path}:${id}`;
212
212
  const cached = commitTimes.get(key);
213
213
  if (cached && now() - cached.at < 15) return cached.value;
214
- const git = (repo, args) => spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', timeout: 10_000 });
215
- const readCommit = (repo) => {
216
- const listed = git(repo, ['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes']);
214
+ // awaited, never a wait that holds the event loop: `status` 0 or the exit code, as a synchronous run answered
215
+ const git = (repo, args) => execFileAsync('git', ['-C', repo, ...args], { encoding: 'utf8', timeout: 10_000 }).then(({ stdout }) => ({ status: 0, stdout }), (error) => ({ status: error.code ?? 1, stdout: error.stdout ?? '' }));
216
+ const readCommit = async (repo) => {
217
+ const listed = await git(repo, ['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes']);
217
218
  if (listed.status !== 0) return { repo: false, value: null };
218
- const current = git(repo, ['branch', '--show-current']).stdout?.trim();
219
- const fallback = git(repo, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']).stdout?.trim().replace(/^refs\/remotes\/origin\//, '');
219
+ const current = (await git(repo, ['branch', '--show-current'])).stdout?.trim();
220
+ const fallback = (await git(repo, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])).stdout?.trim().replace(/^refs\/remotes\/origin\//, '');
220
221
  const names = new Set([...branches, current, fallback].filter(Boolean));
221
222
  const short = (ref) => ref.startsWith('refs/heads/') ? ref.slice(11) : ref.split('/').slice(3).join('/');
222
223
  const refs = listed.stdout.split('\n').filter((ref) => ref && !ref.endsWith('/HEAD') && (names.has(short(ref)) || namedWorkBranch(id, short(ref))));
223
224
  if (!refs.length) return { repo: true, value: null };
224
- const r = git(repo, ['log', ...refs, '--no-merges', '-1', '--format=%ct', '--fixed-strings', `--grep=${id}`, '--']);
225
+ const r = await git(repo, ['log', ...refs, '--no-merges', '-1', '--format=%ct', '--fixed-strings', `--grep=${id}`, '--']);
225
226
  const value = Number(r.stdout?.trim());
226
227
  return { repo: r.status === 0, value: Number.isFinite(value) && value > 0 ? value : null };
227
228
  };
228
- const own = readCommit(path);
229
+ const own = await readCommit(path);
229
230
  let value = own.value;
230
231
  if (!own.repo) {
231
232
  // A fleet card may span sibling repositories under its declared workspace.
@@ -235,7 +236,7 @@ function observeLastCommit(path, id, branches = []) {
235
236
  if (!child.isDirectory() && !child.isSymbolicLink()) continue;
236
237
  const repo = resolve(path, child.name);
237
238
  if (!existsSync(resolve(repo, '.git'))) continue;
238
- const t = readCommit(repo).value;
239
+ const t = (await readCommit(repo)).value;
239
240
  if (t != null) value = Math.max(value ?? 0, t);
240
241
  }
241
242
  }
@@ -245,7 +246,7 @@ function observeLastCommit(path, id, branches = []) {
245
246
 
246
247
  function lastCommitAt(path, id) { return path ? commitTimes.get(`${path}:${id}`)?.value ?? null : null; }
247
248
  /** Observe external facts before entering an optimistic board mutation. */
248
- export function refreshCardInputs(board, id, { commits = false } = {}) {
249
+ export async function refreshCardInputs(board, id, { commits = false } = {}) {
249
250
  const task=board.db.prepare('SELECT t.*,c.subtask_of FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
250
251
  if (!task)return;
251
252
  if (commits && !task.subtask_of) {
@@ -253,7 +254,7 @@ export function refreshCardInputs(board, id, { commits = false } = {}) {
253
254
  const parent=!own && task.subtask_of?board.db.prepare('SELECT workspace_path FROM tasks WHERE id=?').get(task.subtask_of)?.workspace_path:null;
254
255
  const path=own??parent??null;
255
256
  if(path)commitTimes.delete(`${path}:${id}`);
256
- observeLastCommit(path,id,declaredWorkBranches(board.db,id));
257
+ await observeLastCommit(path,id,declaredWorkBranches(board.db,id));
257
258
  }
258
259
  }
259
260