@volter/supercode-orchestrator 0.5.169 → 0.5.171

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/args.mjs CHANGED
@@ -3,7 +3,7 @@ import { resolve } from 'node:path';
3
3
 
4
4
  export const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'tag', 'pr', 'pr-head', 'reviewed-sha', 'finding']);
5
5
  export const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'reclaim', 'dry-run',
6
- 'once', 'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose', 'commit']);
6
+ 'once', 'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose', 'commit', 'side']);
7
7
  // flags that take every following word up to the next flag (Hermes's nargs='+')
8
8
  export const GREEDY = new Set(['ids', 'rm']);
9
9
 
package/board/cli.mjs CHANGED
@@ -66,6 +66,7 @@ async function boardPublishing(root) {
66
66
  }
67
67
  import { dispatchingOf, knowLocalMachine, requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, stopDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
68
68
  import { watchBoard } from './wake.mjs';
69
+ import { attachSideSession, releaseSideSession, sideArcs, sideSessions } from './side-sessions.mjs';
69
70
  import { onPanesChanged } from './machine-fleet.mjs';
70
71
  import { streamBoardPublications } from './event-stream.mjs';
71
72
  import { completionOwnerOf, followHome, isLatestReviewer, loadWorkflow, readHome, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
@@ -127,7 +128,7 @@ And:
127
128
  create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start
128
129
  With a pane lane and room, a card starts at once and answers "started" with its session; else it answers why it
129
130
  waits, and its creator is told when it starts. A subtask is worked by its arc's session and never starts alone.
130
- adopt <id> <session> [--assignee P] [--role ROLE] close <id> serve (= daemon) list --machine M
131
+ adopt <id> <session> [--assignee P] [--role ROLE] adopt <arc> <session> --side · release <arc> <session> close <id> serve (= daemon) list --machine M
131
132
  notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency]
132
133
  notify-subscribe <dependency> --for-card <your-arc>: subscribe only your arc's session to dependency notices.
133
134
  notify-unsubscribe <dependency> --for-card <your-arc>: remove that arc's subscription only.
@@ -145,7 +146,8 @@ And:
145
146
  request-state <id>... --state STATE --reason REASON: request several decisions in one retained manager mail.
146
147
  complete <id>: creator/manager accepts the independently reviewed arc; deployment CI is not a board gate.
147
148
  specify/decompose with no spec start a session on the triage card to write it. A card's own session may comment on,
148
- heartbeat and request review of its card, specify or decompose it in triage, and work its subtasks. The home's
149
+ heartbeat and request review of its card, specify or decompose it in triage, and work its subtasks. A side session
150
+ (adopt --side) works the arc's subtasks and comments on the arc; the arc's run and state stay its main session's. The home's
149
151
  workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
150
152
  `;
151
153
 
@@ -365,13 +367,15 @@ export async function runVerb(argv) {
365
367
  if (sid && !READS.has(verb) && boardExists(root, slug)) {
366
368
  // binding the caller's launch is a write: only the owner, answering the caller's own verb, makes it (a local read
367
369
  // never writes)
368
- await withBoardAsync(async (b) => { if (isCallersCall()) await reconcileCallerLaunch(b, sid); const own = ownCards(b.db, sid); if (own.worked.size) authorize(b.db, verb, rest, args, own); });
370
+ await withBoardAsync(async (b) => { if (isCallersCall()) await reconcileCallerLaunch(b, sid); const own = { ...ownCards(b.db, sid), side: sideArcs(b.db, sid) }; if (own.worked.size || own.side.size) authorize(b.db, verb, rest, args, own); });
369
371
  }
370
372
  // the board's workflow (the IR's): what each verb does, and who may send it, is its
371
373
  // every verb reads the board through its workflow (a list settles cards too), so every verb loads it
372
374
  engine.setCaller((db, cardId) => {
373
375
  const roles = rolesFor(db, cardId, sid, workflowFor(root).params);
374
376
  if (verb === 'comment' && isLatestReviewer(db, cardId, actorOf()) && !roles.includes('reviewer')) roles.push('reviewer');
377
+ // a side session comments on its arc as the arc's (its subtasks it works as `arc` through rolesFor)
378
+ if (verb === 'comment' && sid && !roles.includes('arc') && sideSessions(db, cardId).some((s) => s.session === sid)) roles.push('arc');
375
379
  return roles;
376
380
  });
377
381
  // A plain terminal acts as its signed-in person; an offline OS label grants no creator authority.
@@ -542,7 +546,7 @@ export async function runVerb(argv) {
542
546
  const t = card.task;
543
547
  const latest = [...card.runs].reverse().find((r) => r.summary)?.summary ?? null;
544
548
  if (args.json) {
545
- say(args, '', { task: cardDict(t), latest_summary: latest, parents: card.parents.map((p) => p.id), children: card.children, subtasks: card.subtasks, comments: card.comments, events: card.events, runs: card.runs, time: card.time, subscriptions: card.subscriptions, diagnostics: card.diagnostics });
549
+ say(args, '', { task: cardDict(t), side_sessions: withBoard((b) => sideSessions(b.db, t.id)), latest_summary: latest, parents: card.parents.map((p) => p.id), children: card.children, subtasks: card.subtasks, comments: card.comments, events: card.events, runs: card.runs, time: card.time, subscriptions: card.subscriptions, diagnostics: card.diagnostics });
546
550
  return 0;
547
551
  }
548
552
  const out = [`Task ${t.id}: ${t.title}`, ` status: ${taskStatusOf(card.stage, t.completed_at)} (lane ${card.stage}${card.stage !== t.status ? `, ${t.status} in kanban.db` : ''})`, ` assignee: ${t.assignee || '-'}`];
@@ -572,6 +576,8 @@ export async function runVerb(argv) {
572
576
  if (card.parents.length) out.push(` parents: ${card.parents.map((p) => p.id).join(', ')}`);
573
577
  if (card.children.length) out.push(` children: ${card.children.join(', ')}`);
574
578
  if (card.subtasks.length) out.push(` subtasks: ${card.subtasks.map((s) => `${s.id} (${s.status})`).join(', ')}`);
579
+ const side = withBoard((b) => sideSessions(b.db, t.id));
580
+ if (side.length) out.push(` side sessions: ${side.map((s) => s.address).join(', ')}`);
575
581
  if (t.body) out.push('', 'Body:', t.body);
576
582
  if (t.result) out.push('', 'Result:', t.result);
577
583
  else if (latest) out.push('', 'Latest summary:', latest);
@@ -847,7 +853,7 @@ export async function runVerb(argv) {
847
853
  const children = args.child.map((c) => { const [title, assignee] = c.split(/@(?=[^@]*$)/); return { title: title.trim(), assignee: assignee?.trim() || undefined }; });
848
854
  results.push({ id: t, children: await withBoardAsync((b) => engine.decompose(b, t, children, { author: args.author ?? callerAddress() ?? callerName() })) });
849
855
  } else {
850
- const session = await withBoardAsync((b) => startCard(b, t, { lanes, settings }));
856
+ const session = await withBoardAsync((b) => startCard(b, t, { lanes, settings, roles: rolesFor(b.db, t, sid, workflowFor(root).params) }));
851
857
  results.push({ id: t, session: session.address, mode: session.mode });
852
858
  }
853
859
  }
@@ -1114,10 +1120,22 @@ export async function runVerb(argv) {
1114
1120
  case 'adopt': {
1115
1121
  need(2, 'a card id and a session');
1116
1122
  withBoard(b => { if (!rolesFor(b.db, id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
1123
+ if (args.side) {
1124
+ withBoard((b) => attachSideSession(b, id, rest[1]));
1125
+ say(args, `${rest[1]} is a side session of ${id}: it works ${id}'s subtasks and comments on ${id}; ${id}'s run and state stay its main session's`, { ok: true, side: rest[1] });
1126
+ return 0;
1127
+ }
1117
1128
  withBoard((b) => requestAdoption(b, id, rest[1], { workspace: args.workspace ?? null, assignee: args.assignee ?? null, role: args.role ?? null }));
1118
1129
  say(args, `${id} is held for ${rest[1]}; the dispatcher verifies and binds it on its next tick (session_adopted, or adoption_failed with the reason)`, { ok: true, requested: rest[1] });
1119
1130
  return 0;
1120
1131
  }
1132
+ case 'release': {
1133
+ need(2, 'a card id and its side session');
1134
+ withBoard(b => { if (!rolesFor(b.db, id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager releases a side session.'); });
1135
+ withBoard((b) => releaseSideSession(b, id, rest[1]));
1136
+ say(args, `${rest[1]} is no longer a side session of ${id}`, { ok: true, released: rest[1] });
1137
+ return 0;
1138
+ }
1121
1139
  case 'goto': {
1122
1140
  need(2, 'a card id and the status to take it to');
1123
1141
  const { _: _words, root: _r, board: _b, json: _j, at: _a, ...fields } = args;
@@ -1429,7 +1447,8 @@ function agentMain(address) { return current().agentMains.get(address) ?? null;
1429
1447
  /**
1430
1448
  * Who a caller is on card `cardId`, in the workflow's roles: a signed-in terminal person retains their creator role;
1431
1449
  * the manager acts from its named session; the session of the card's current run has that run's role; the card's working session between runs
1432
- * is its `implementer`; the session working a subtask's arc is `arc`; its recorded creator also has the creator role; any other session is nobody on it.
1450
+ * is its `implementer`; the session working a subtask's arc, and each side session attached to that arc, is `arc`; its recorded creator also has the
1451
+ * creator role; any other session is nobody on it.
1433
1452
  */
1434
1453
  function rolesFor(db, cardId, sessionId, params = {}) {
1435
1454
  // a session the workflow names as the board's manager (params.managers: its addresses) is the manager on every card
@@ -1447,27 +1466,32 @@ function rolesFor(db, cardId, sessionId, params = {}) {
1447
1466
  if (task.subtask_of) {
1448
1467
  const parent = engine.getTask(db, task.subtask_of);
1449
1468
  const worker = parent?.current_run_id ? engine.currentRun(db, parent.id)?.metadata?.supercode : engine.lastSession(db, task.subtask_of);
1450
- if (worker?.session_id === sessionId) roles.push('arc');
1469
+ if (worker?.session_id === sessionId || sideSessions(db, task.subtask_of).some((s) => s.session === sessionId)) roles.push('arc');
1451
1470
  }
1452
1471
  return roles;
1453
1472
  }
1454
1473
 
1474
+ /**
1475
+ * What a session that works a card, or is a side session of an arc (`own.side`), may name: its own cards and their
1476
+ * subtasks, and the subtasks of the arcs it is a side session of, and those arcs themselves only to comment on. What it
1477
+ * may do to them is the workflow's (rolesFor).
1478
+ */
1455
1479
  function authorize(db, verb, rest, args, own) {
1456
- const cards = [...own.worked];
1480
+ const cards = [...new Set([...own.worked, ...own.side])];
1457
1481
  const named = cards.join(', ');
1458
1482
  const refuse = (why) => { throw new Error(`the session working ${named} ${why}`); };
1459
1483
  if (verb === 'create') {
1460
- if (!own.worked.has(args['subtask-of'])) refuse(`files subtasks of its own card only (--subtask-of ${cards.join(' or ')})`);
1484
+ if (!own.worked.has(args['subtask-of']) && !own.side.has(args['subtask-of'])) refuse(`files subtasks of its own card only (--subtask-of ${cards.join(' or ')})`);
1461
1485
  return;
1462
1486
  }
1463
1487
  if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
1464
1488
  // verbs that act on the board rather than on a card are the manager's and the board's, never a card's session's
1465
1489
  // (what a session may do to a card is its workflow's: each transition's `by`)
1466
- if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'close', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
1490
+ if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'release', 'close', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
1467
1491
  if (verb === 'set' && !(own.worked.has(rest[0]) && rest[1] === 'every')) refuse("sets only Every on its own auditor card; other properties are the manager's");
1468
1492
  if (args.all) refuse(`does not ${verb} every card; only its own`);
1469
1493
  if (['notify-subscribe', 'notify-unsubscribe'].includes(verb) && args['for-card']) {
1470
- if (!own.worked.has(args['for-card'])) refuse(`subscribes only its own arc (--for-card ${cards.join(' or ')})`);
1494
+ if (!own.worked.has(args['for-card'])) refuse(`subscribes only its own arc (--for-card ${[...own.worked].join(' or ')})`);
1471
1495
  return;
1472
1496
  }
1473
1497
  const targets = verb === 'request-state' ? [...rest, ...(args.ids ?? [])] : [rest[0], ...(args.ids ?? [])];
@@ -1477,7 +1501,9 @@ function authorize(db, verb, rest, args, own) {
1477
1501
  if (isCaller(completionOwnerOf(db, card.id))) continue;
1478
1502
  if (verb === 'comment' && isLatestReviewer(db, card.id, actorOf())) continue;
1479
1503
  // the card it names is the one it addresses: any card it holds, or a subtask of one it works
1480
- if (!own.held.has(card.id) && !own.worked.has(card.subtask_of)) refuse(`writes only its own card${cards.length > 1 ? 's' : ''} and ${cards.length > 1 ? 'their' : 'its'} subtasks, not ${card.id}`);
1504
+ // a side session names its arc only to comment on it; the arc's other verbs are its main session's
1505
+ if (own.side.has(card.id) && !own.held.has(card.id) && verb !== 'comment') refuse(`only comments on ${card.id}, which it is a side session of; the arc's other verbs are its main session's and the manager's`);
1506
+ if (!own.held.has(card.id) && !own.worked.has(card.subtask_of) && !own.side.has(card.id) && !own.side.has(card.subtask_of)) refuse(`writes only its own card${cards.length > 1 ? 's' : ''} and ${cards.length > 1 ? 'their' : 'its'} subtasks, not ${card.id}`);
1481
1507
  }
1482
1508
  }
1483
1509
 
@@ -0,0 +1,73 @@
1
+ // Side sessions: an arc keeps its one main session (its run, its driver), and the manager may attach other sessions
2
+ // beside it for parts of the arc (`adopt <arc> <session> --side`, `release <arc> <session>`). A side session is bound
3
+ // to no run and to no machine (the card's machine is its main session's); it holds the `arc` role on the arc's
4
+ // subtasks (file, comment on, complete or drop them) and may comment on the arc card, and nothing else of the arc's
5
+ // own: its state, its run and its review stay the main session's and the manager's. An arc that ends (done or
6
+ // cancelled) releases its side sessions in the same transaction.
7
+ //
8
+ // The live attachments are rows of `supercode_side_sessions`, keyed by arc and session and indexed by session, so the
9
+ // question every verb a session runs asks ("which arcs is this session a side session of?") reads only its own rows.
10
+ // Each attach and release is also recorded on the arc as an event (`side_session_attached`, `side_session_released`).
11
+ import { boardCopyOf, copyRefusal, event, tx } from './store.mjs';
12
+ import { currentRun, getTask, lastSession } from './engine.mjs';
13
+
14
+ const TABLE = 'supercode_side_sessions';
15
+ const SCHEMA = `CREATE TABLE IF NOT EXISTS ${TABLE} (task_id TEXT NOT NULL, session TEXT NOT NULL, address TEXT NOT NULL, PRIMARY KEY (task_id, session));
16
+ CREATE INDEX IF NOT EXISTS idx_side_sessions_session ON ${TABLE}(session);`;
17
+ // a read never writes: a board with no attachment yet has no table, and reads as none
18
+ const present = (db) => Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(TABLE));
19
+
20
+ /** A harness session's address, `sc:<machine>:<harness>:<id>`, as `{ session, address }`; refused for anything else. */
21
+ function sessionAddress(who) {
22
+ const address = String(who ?? '').trim();
23
+ const m = /^sc:([^:\s]+):([^:\s]+):([^:\s]+)$/.exec(address);
24
+ if (!m || ['agent', 'board', 'operator'].includes(m[2])) throw new Error(`a side session is named by its harness session address, sc:<machine>:<harness>:<id>, not ${address || 'nothing'}`);
25
+ return { session: m[3], address };
26
+ }
27
+
28
+ export function attachSideSession(board, id, who) {
29
+ const { session, address } = sessionAddress(who);
30
+ if (boardCopyOf(board)) throw copyRefusal(board, 'attaches no session');
31
+ tx(board, (db) => {
32
+ const card = getTask(db, id);
33
+ if (!card) throw new Error(`no card ${id}`);
34
+ if (card.subtask_of) throw new Error(`${id} is a subtask of ${card.subtask_of}; attach the side session to the arc, ${card.subtask_of}`);
35
+ // its main session: the current run's (any slot) and the card's last worker (its implementer while a review runs)
36
+ const main = [card.current_run_id ? currentRun(db, id)?.metadata?.supercode?.session_id : null, lastSession(db, id)?.session_id];
37
+ if (main.includes(session)) throw new Error(`${address} is ${id}'s main session; a side session is another session beside it`);
38
+ if (sideSessions(db, id).some((s) => s.session === session)) throw new Error(`${address} is already a side session of ${id}`);
39
+ db.exec(SCHEMA);
40
+ db.prepare(`INSERT INTO ${TABLE} (task_id, session, address) VALUES (?, ?, ?)`).run(id, session, address);
41
+ event(db, id, 'side_session_attached', { session, address });
42
+ });
43
+ }
44
+
45
+ export function releaseSideSession(board, id, who) {
46
+ const { session, address } = sessionAddress(who);
47
+ if (boardCopyOf(board)) throw copyRefusal(board, 'releases no session');
48
+ tx(board, (db) => {
49
+ if (!getTask(db, id)) throw new Error(`no card ${id}`);
50
+ if (!sideSessions(db, id).some((s) => s.session === session)) throw new Error(`${address} is no side session of ${id}`);
51
+ db.prepare(`DELETE FROM ${TABLE} WHERE task_id = ? AND session = ?`).run(id, session);
52
+ event(db, id, 'side_session_released', { session, address });
53
+ });
54
+ }
55
+
56
+ /** Release every side session of arc `id`, in the caller's transaction (the arc ended: `reason`). */
57
+ export function releaseAllSideSessions(db, id, reason) {
58
+ for (const s of sideSessions(db, id)) {
59
+ db.prepare(`DELETE FROM ${TABLE} WHERE task_id = ? AND session = ?`).run(id, s.session);
60
+ event(db, id, 'side_session_released', { session: s.session, address: s.address, reason });
61
+ }
62
+ }
63
+
64
+ /** The side sessions attached to arc `id` now: `{ session, address }` each. */
65
+ export function sideSessions(db, id) {
66
+ return present(db) ? db.prepare(`SELECT session, address FROM ${TABLE} WHERE task_id = ? ORDER BY rowid`).all(id).map((r) => ({ session: r.session, address: r.address })) : [];
67
+ }
68
+
69
+ /** The arcs session `sessionId` is a side session of now. */
70
+ export function sideArcs(db, sessionId) {
71
+ if (!sessionId || !present(db)) return new Set();
72
+ return new Set(db.prepare(`SELECT task_id FROM ${TABLE} WHERE session = ?`).all(sessionId).map((r) => r.task_id));
73
+ }
@@ -13,6 +13,7 @@ import { existsSync, readdirSync, realpathSync, watch, readFileSync } from 'node
13
13
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
14
14
  import { randomBytes } from 'node:crypto';
15
15
  import { actorOf, appendRunObservation, event as record, json, now } from './store.mjs';
16
+ import { releaseAllSideSessions } from './side-sessions.mjs';
16
17
  import { evaluate, holds, render } from './expr.mjs';
17
18
  import { completionEvidence, declaredWorkBranches, namedWorkBranch, reviewCandidate } from './ci.mjs';
18
19
  import { cardCommitsOn, isLocalWorkspace, isWorkspacePath } from './workspace.mjs';
@@ -682,6 +683,8 @@ export function setStatus(db, workflow, id, status) {
682
683
  run(db, 'UPDATE tasks SET status = ? WHERE id = ?', stored, id);
683
684
  run(db, `INSERT INTO supercode_cards (task_id, stage, since, after_fired) VALUES (?, ?, ?, NULL)
684
685
  ON CONFLICT(task_id) DO UPDATE SET stage = excluded.stage, since = excluded.since, after_fired = NULL`, id, stored === status ? null : status, now());
686
+ // an arc that ends releases its side sessions with it (side-sessions.mjs)
687
+ if (['done', 'archived'].includes(stored) && !db.prepare('SELECT subtask_of FROM supercode_cards WHERE task_id = ?').get(id)?.subtask_of) releaseAllSideSessions(db, id, `${id} is ${status}`);
685
688
  }
686
689
 
687
690
  /** Take every `always` transition that holds, from wherever the card is, until none does. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.169",
3
+ "version": "0.5.171",
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": {
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@volter/supercode-harness-sdk": "^0.3.100",
28
- "@volter/teams": "0.2.71",
28
+ "@volter/teams": "0.2.72",
29
29
  "@volter/ztrack": "^2.4.23",
30
30
  "yaml": "^2.9.0"
31
31
  },