@volter/supercode-orchestrator 0.4.147 → 0.4.149

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.
@@ -11,8 +11,9 @@ import { resolve, relative, isAbsolute } from 'node:path';
11
11
  import { randomBytes } from 'node:crypto';
12
12
  import { actorOf, appendRunObservation, event as record, json, now } from './store.mjs';
13
13
  import { evaluate, holds, render } from './expr.mjs';
14
- import { completionEvidence } from './ci.mjs';
14
+ import { completionEvidence, reviewCandidate } from './ci.mjs';
15
15
  import { isWorkspacePath } from './workspace.mjs';
16
+ import { identifyNotice } from './notices.mjs';
16
17
 
17
18
  const loaded = new Map();
18
19
 
@@ -103,7 +104,8 @@ function pattern(p) {
103
104
  }
104
105
 
105
106
  // the time of the latest commit that names `id` in its message, on any ref of the repository at `path` (a worktree's
106
- // pushes land on the shared remote-tracking refs), or null (asked only when an expression does)
107
+ // pushes land on the shared remote-tracking refs), or null (asked only when an expression does). Merge commits are
108
+ // not the card's work: merging its reviewed PR, or main into its branch, is not a newer change to review.
107
109
  const commitTimes = new Map();
108
110
  function observeLastCommit(path, id) {
109
111
  if (!path) return null;
@@ -111,7 +113,7 @@ function observeLastCommit(path, id) {
111
113
  const cached = commitTimes.get(key);
112
114
  if (cached && now() - cached.at < 15) return cached.value;
113
115
  const readCommit = (repo) => {
114
- const r = spawnSync('git', ['-C', repo, 'log', '--all', '-1', '--format=%ct', '--fixed-strings', `--grep=${id}`], { encoding: 'utf8', timeout: 10_000 });
116
+ const r = spawnSync('git', ['-C', repo, 'log', '--all', '--no-merges', '-1', '--format=%ct', '--fixed-strings', `--grep=${id}`], { encoding: 'utf8', timeout: 10_000 });
115
117
  const value = Number(r.stdout?.trim());
116
118
  return { repo: r.status === 0, value: Number.isFinite(value) && value > 0 ? value : null };
117
119
  };
@@ -200,11 +202,8 @@ function onlyTicks(was, body) {
200
202
 
201
203
  /** Seconds a card's runs spent by role, and the rest of its life as `waiting`. */
202
204
  export function runWorkingSeconds(row, at = now()) {
203
- const metadata = typeof row.metadata === 'string' ? json(row.metadata, {}) : row.metadata;
204
- const session = metadata?.supercode ?? {};
205
205
  const end = Number(row.ended_at ?? at);
206
- const waiting = Number(session.wait_seconds ?? 0) + (session.wait_started_at != null ? Math.max(0, end - Number(session.wait_started_at)) : 0);
207
- return Math.max(0, end - Number(row.started_at ?? end) - waiting);
206
+ return Math.max(0, end - Number(row.started_at ?? end));
208
207
  }
209
208
 
210
209
  function timeOf(db, task) {
@@ -249,8 +248,8 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
249
248
  ...task,
250
249
  creator_address: creatorOf(db, task.id),
251
250
  completion_owner_address: completionOwnerOf(db, task.id),
252
- review_only: Boolean(state.review_only),
253
251
  latest_review: latestReview(db, task.id) ?? {},
252
+ review_candidate: reviewCandidate(board, task.id) ?? {},
254
253
  ci: completionEvidence(board, task.id),
255
254
  dispatch_hold: one(db, "SELECT kind FROM task_events WHERE task_id=? AND kind IN ('dispatch_held','dispatch_released','session_adopted') ORDER BY id DESC LIMIT 1", task.id)?.kind === 'dispatch_held',
256
255
  status,
@@ -356,11 +355,20 @@ function queueTransitionNotice(db, board, entry, task, from, name, payload, befo
356
355
  const afterState = beforeRun && json(one(db, 'SELECT metadata FROM task_runs WHERE id=?', beforeRun.id)?.metadata, {})?.supercode?.state;
357
356
  if (!beforeRun || (state(task, from) === state(after, to) && beforeState === afterState) || effects.some((e) => e.kind === 'session_notice' && e.runId === beforeRun.id)) return;
358
357
  const session = json(beforeRun.metadata, {})?.supercode;
358
+ const cancellation = session?.launch_cancellation;
359
+ if (cancellation?.key === session?.launch_key && cancellation?.status === 'cancelled' && cancellation?.cancelled === true &&
360
+ cancellation?.outcome === 'not_issued' && cancellation?.live === false && cancellation?.pane === null) return;
359
361
  const address = session?.address ?? (session?.session_id && session.machine ? `sc:${session.machine}:${session.harness ?? 'claude-code'}:${session.session_id}` : null);
360
362
  if (!address && !session?.launch_key && !session?.pane) return;
363
+ // The session that made this change learns it from its own command's answer; a notice about
364
+ // its own act would only hold its close until the notice is confirmed. A session whose close is
365
+ // confirmed is not told either: its next run resumes it with the card's state in its prompt.
366
+ if (address && address === actorOf()) return;
367
+ if (session?.close_confirmed_at) return;
361
368
  const scope = scopeOf(db, board, after, { params: entry.params, event: { ...payload, verb: name, from, to }, extra: { run: { id: beforeRun.id } } });
362
- const text = render(entry.workflow.notify?.worker?.run_changed ?? '{{card.id}}: {{event.verb}} changed run {{run.id}} ({{event.from}} → {{event.to}}). Stop work under the previous run; follow the board’s current state. {{event.reason ?? ""}}', scope);
369
+ const body = render(entry.workflow.notify?.worker?.run_changed ?? '{{card.id}}: {{event.verb}} changed run {{run.id}} ({{event.from}} → {{event.to}}). Stop work under the previous run; follow the board’s current state. {{event.reason ?? ""}}', scope);
363
370
  const source = one(db, 'SELECT MAX(id) AS id FROM task_events WHERE task_id = ?', task.id)?.id;
371
+ const text = identifyNotice({ id: source, kind: name, run_id: beforeRun.id, created_at: now() }, body);
364
372
  const effect = { kind: 'session_notice', id: task.id, runId: beforeRun.id, address, text, sourceEvent: source, verb: name, messageKey: `board-run-notice-${randomBytes(16).toString('hex')}` };
365
373
  const inserted = run(db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
366
374
  effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
@@ -385,7 +393,7 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
385
393
  const candidates = transitionsFor(workflow, from, name);
386
394
  if (!candidates.length) throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} does not apply there`);
387
395
  // `sender`: the roles the event was sent with (a validator can ask who is asking)
388
- const scope = scopeOf(db, board, task, { params, event: payload, extra: { lanes: lanesIn(entry), ...extra, sender: roles } });
396
+ const scope = scopeOf(db, board, task, { params, event: payload, extra: { lanes: lanesIn(entry), ...extra, sender: roles, verb: name } });
389
397
  const allowed = candidates.filter((t) => !t.by?.length || t.by.some((r) => roles.includes(r)));
390
398
  if (!allowed.length) {
391
399
  const state = name === 'complete' ? 'done' : name === 'cancel' ? 'cancelled' : null;
@@ -555,7 +563,7 @@ const FORMAT = {
555
563
  blocked: ({ e, after, source }) => ({ reason: e.reason ?? null, on: e.on ?? null, waiting_on: e.waiting_on ?? null, recurrences: Number(after.block_recurrences || 0), source_status: source }),
556
564
  block_loop_detected: ({ e, after, source, params }) => ({ reason: e.reason ?? null, kind: e.kind ?? null, recurrences: Number(after.block_recurrences || 0), limit: params.block_recurrence_limit, source_status: source }),
557
565
  dependency_wait: ({ e, source }) => ({ reason: e.reason ?? null, on: e.on ?? null, source_status: source }),
558
- review_requested: ({ e, before, after }) => ({ summary: firstLine(e.summary), ci: e.ci ?? null, implementer: before.assignee, reviewer: after.assignee !== before.assignee ? after.assignee : (e.reviewer ?? null) }),
566
+ review_requested: ({ e, before, after }) => ({ summary: firstLine(e.summary), ci: e.ci ?? null, pr: e.ci?.pr ?? null, commit: e.ci?.commit ?? null, implementer: before.assignee, reviewer: after.assignee !== before.assignee ? after.assignee : (e.reviewer ?? null) }),
559
567
  // the reviewer is the lane that reviewed (the card's assignee while in review), unless the verdict names one
560
568
  changes_requested: ({ e, before, after }) => ({ reason: e.reason ?? null, reviewer: e.reviewer ?? before.assignee ?? null, implementer: after.assignee }),
561
569
  review_reopened: ({ after, to }) => ({ status: to, implementer: after.assignee }),
@@ -636,17 +644,6 @@ const ACTIONS = {
636
644
  const result = fire(ctx.db, ctx.board, ctx.entry, ctx.id, name, { roles: [].concat(as), payload, extra: { lanes: lanesIn(ctx.entry) } });
637
645
  ctx.effects.push(...result.effects);
638
646
  },
639
- wait_run(ctx, waiting) {
640
- if (typeof waiting !== 'boolean') throw new Error('wait_run requires a boolean');
641
- const at = now();
642
- runSession(ctx, session => {
643
- if (waiting) return { ...session, wait_started_at: session.wait_started_at ?? at };
644
- if (session.wait_started_at == null) return session;
645
- return { ...session, wait_started_at: null,
646
- wait_seconds: Number(session.wait_seconds ?? 0) + Math.max(0, at - Number(session.wait_started_at)),
647
- goal_nudged_at: at };
648
- });
649
- },
650
647
  record(ctx, args, scope) {
651
648
  const kind = typeof args === 'string' ? args : args?.kind;
652
649
  const given = typeof args === 'object' && args?.payload ? args.payload : null;
@@ -766,15 +763,12 @@ const ACTIONS = {
766
763
  run(ctx.db, 'DELETE FROM task_links WHERE parent_id = ? AND child_id = ?', parent, ctx.id);
767
764
  },
768
765
  specify(ctx, said) {
769
- if (said?.review_only != null) {
770
- const task = cardOf(ctx.db, ctx.id);
771
- if (typeof said.review_only !== 'boolean' || task.current_run_id || task.subtask_of || ctx.scope.card.every) throw new Error('A review-only declaration changes only an arc with no current run or recurrence.');
772
- cardField(ctx, 'review_only', said.review_only ? 1 : 0);
773
- record(ctx.db, ctx.id, 'review_mode_changed', { review_only: said.review_only }, null);
774
- }
775
766
  if (said?.workspace) {
776
767
  const task = cardOf(ctx.db, ctx.id), ws = said.workspace;
777
768
  if (task.current_run_id) throw new Error('end the current run before changing its workspace');
769
+ ACTIONS.forget_session(ctx, '*');
770
+ const prior = one(ctx.db, 'SELECT workspace_anchor FROM supercode_cards WHERE task_id=?', ctx.id);
771
+ record(ctx.db, ctx.id, 'workspace_changed', { from: { kind: task.workspace_kind, repository: prior?.workspace_anchor, branch: task.branch_name }, to: { kind: ws.kind, repository: ws.repository, branch: ws.branch } }, null);
778
772
  run(ctx.db, 'UPDATE tasks SET workspace_kind=?,workspace_path=?,branch_name=? WHERE id=?', ws.kind, ws.path ?? null, ws.branch ?? null, ctx.id);
779
773
  cardField(ctx, 'workspace_anchor', ws.repository ?? null);
780
774
  cardField(ctx, 'workspace_root', null);
@@ -854,12 +848,20 @@ const ACTIONS = {
854
848
  if (!to) throw new Error(`the workflow asks nobody about ${ctx.id} (ask.to is empty)`);
855
849
  const id = `m-${randomBytes(12).toString('hex')}`;
856
850
  ctx.scope.asked = id;
857
- record(ctx.db, ctx.id, 'board_message', { to: String(to), text: `${String(text ?? '')}\n\nAnswer with reply_to ${id}.`, message_id: id }, null);
851
+ const batch = ctx.scope.verb === 'request_state' ? {
852
+ requester: actorOf() ?? ctx.scope.requester,
853
+ window_seconds: Math.max(1, Math.min(60, Number(ctx.entry.params.request_batch_seconds) || 10)),
854
+ } : null;
855
+ const observation = ctx.scope.event?.ci;
856
+ if (batch && observation?.required && !observation.ready) text = `${String(text ?? '')}\nSource evidence could not be verified: ${observation.reason ?? 'unavailable'}. The decision request is retained.`;
857
+ record(ctx.db, ctx.id, 'board_message', { to: String(to), text: batch ? String(text ?? '') : `${String(text ?? '')}\n\nAnswer with reply_to ${id}.`, message_id: id, ...(batch ? { batch } : {}) }, null);
858
858
  ctx.effects.push({ kind: 'board_message', id: ctx.id });
859
859
  },
860
860
  send_to_actor(ctx, text) {
861
861
  const effect = { kind: 'send_to_actor', id: ctx.id, runId: ctx.runId, text, messageKey: `board-actor-${randomBytes(16).toString('hex')}` };
862
862
  const inserted = run(ctx.db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
863
+ effect.text = identifyNotice({ id: `notice-${inserted.lastInsertRowid}`, kind: 'actor_notice', run_id: ctx.runId, created_at: now() }, text);
864
+ run(ctx.db, 'UPDATE supercode_effects SET effect=? WHERE id=?', JSON.stringify(effect), Number(inserted.lastInsertRowid));
863
865
  ctx.effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
864
866
  },
865
867
  };
@@ -1026,13 +1028,19 @@ function settleRelations(db, board, entry, id, effects) {
1026
1028
  }
1027
1029
 
1028
1030
 
1029
- export function projectStatus(db, board, entry, id) {
1030
- if (!entry.workflow.stored_as?.length) return;
1031
+ /** A card's workflow status and the status Hermes's `tasks.status` stores it as, read without writing. */
1032
+ export function storedStatus(db, board, entry, id) {
1031
1033
  const task = cardOf(db, id);
1032
1034
  const status = statusOf(db, task);
1035
+ if (!entry.workflow.stored_as?.length) return { status, stored: task.status };
1033
1036
  const scope = scopeOf(db, board, task, { params: entry.params });
1034
1037
  const rule = entry.workflow.stored_as.find((r) => holds(r.guard, scope));
1035
- const stored = rule?.status ?? storedAs(entry.workflow, status);
1038
+ return { status, stored: rule?.status ?? storedAs(entry.workflow, status) };
1039
+ }
1040
+
1041
+ export function projectStatus(db, board, entry, id) {
1042
+ if (!entry.workflow.stored_as?.length) return;
1043
+ const { status, stored } = storedStatus(db, board, entry, id);
1036
1044
  run(db, 'UPDATE tasks SET status = ? WHERE id = ?', stored, id);
1037
1045
  run(db, 'INSERT INTO supercode_cards (task_id, stage) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET stage = excluded.stage', id, status);
1038
1046
  }
@@ -1,5 +1,7 @@
1
1
  // Filesystem/RPC work happens before a board transaction; the ownership check is pure.
2
2
  import { spawnSync } from 'node:child_process';
3
+ import { existsSync } from 'node:fs';
4
+ import { basename, dirname } from 'node:path';
3
5
  import { Workspaces, inspectCheckout, repositoryId, teamsHome } from '@volter/supercode-teams/workspaces';
4
6
  import { localMachine, normalMachine, supercodeBin, commandEnv } from './dispatch.mjs';
5
7
  export const isWorkspacePath = value => /^(\/|~|[A-Za-z]:[\\/])/.test(value ?? '');
@@ -28,6 +30,17 @@ export function declareWorkspace(kind, value, machine, branch = null) {
28
30
  const row = workspaceDoor(machine, 'register', value);
29
31
  return { repository: row.repository, branch: branch ?? (kind === 'dir' ? row.branch : null), path: kind === 'dir' ? row.path : null };
30
32
  }
33
+ /** The checkouts this machine keeps of any repository on `branch`: where an arc changing several repositories
34
+ * keeps its work in each of them (its own worktree aside). Another machine's map is not read from here. */
35
+ export function branchCheckouts(machine, branch) {
36
+ if (!branch || (machine && normalMachine(machine) !== localMachine())) return [];
37
+ return new Workspaces(teamsHome()).list().filter((row) => (branch === '*' || row.branch === branch) && existsSync(row.path));
38
+ }
39
+ /** The registered checkout of `repository` on `branch` on `machine`: where a card changing several repositories
40
+ * keeps its work in each of them. */
41
+ export function checkoutOf(machine, repository, branch) {
42
+ return workspaceDoor(machine, 'resolve', repositoryId(repository), { branch });
43
+ }
31
44
  export function resolveWorkspace(task, create = false) {
32
45
  const declared = task.workspace_anchor ?? task.workspace_path;
33
46
  if (isWorkspacePath(declared)) {
@@ -36,6 +49,19 @@ export function resolveWorkspace(task, create = false) {
36
49
  if (info.linked) throw new Error('worktree declaration is already a linked worktree; name its repository instead');
37
50
  throw new Error('legacy worktree path needs registration and a repository declaration before launch');
38
51
  }
52
+ // A primary checkout can change when another clone is registered. That does
53
+ // not move an already allocated card worktree. Machine moves and workspace
54
+ // retargets clear this runtime receipt; never borrow an arbitrary checkout.
55
+ if (task.workspace_kind === 'worktree' && task.workspace_path &&
56
+ basename(task.workspace_path) === task.id && basename(dirname(task.workspace_path)) === '.worktrees' &&
57
+ (!isLocalWorkspace(task) || existsSync(task.workspace_path))) {
58
+ const cached = workspaceDoor(task.machine, 'inspect', task.workspace_path);
59
+ if (cached.linked && basename(cached.path) === task.id && basename(dirname(cached.path)) === '.worktrees' &&
60
+ cached.repository === repositoryId(declared) && cached.branch === (task.branch_name ?? `wt/${task.id}`)) {
61
+ return { ...cached, root: dirname(dirname(cached.path)) };
62
+ }
63
+ throw new Error(`card worktree ${task.workspace_path} does not match ${declared}, branch ${task.branch_name ?? `wt/${task.id}`}; retarget its workspace explicitly`);
64
+ }
39
65
  return workspaceDoor(task.machine, 'resolve', declared, { branch: task.branch_name, solo: task.workspace_kind === 'worktree', card: task.id, create });
40
66
  }
41
67
  export function checkWorkspace(board, task) {