@volter/supercode-orchestrator 0.4.57 → 0.4.59

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/cli.mjs CHANGED
@@ -12,7 +12,7 @@ import { read } from '../orchestration.mjs';
12
12
  import { holdDispatcherLock } from '../hermes-locks.mjs';
13
13
  import { backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, tx } from './store.mjs';
14
14
  import * as engine from './engine.mjs';
15
- import { adoptSession, boardAddress, boardLabel, boardSettings, lanesOf, localMachine, observeFleet, paused, plan, startCard, tick } from './dispatch.mjs';
15
+ import { adoptSession, boardAddress, boardLabel, boardSettings, callerSession, lanesOf, localMachine, observeFleet, paused, plan, startCard, tick } from './dispatch.mjs';
16
16
  import { loadWorkflow, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
17
17
  import { evaluate } from './expr.mjs';
18
18
 
@@ -29,7 +29,7 @@ Hermes Kanban's verbs, with their flags:
29
29
  claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] [--until <time|30m>] link|unlink <parent> <child>
30
30
  comment <id> <text> [--author] [--max-len] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
31
31
  complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
32
- block <id> [reason] [--kind dependency|needs_input|capability|transient] [--ids ...] schedule <id> [reason] [--ids ...]
32
+ block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
33
33
  unblock <id>... [--reason] request-review <id> [--summary] [--reviewer] [--force] request-changes <id> <reason> reopen-review <id>... reopen <id>... [--reason]
34
34
  promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... [--rm <id>...]
35
35
  specify <id> [--title --body] [--all] decompose <id> [--child "<title>[@<profile>]"]... [--all]
@@ -500,7 +500,7 @@ export async function runWorkflow(argv) {
500
500
  need(1, 'a card id');
501
501
  const reason = rest.slice(1).join(' ') || null;
502
502
  const results = [];
503
- for (const t of [id, ...(args.ids ?? [])]) results.push([t, (await withBoardAsync((b) => engine.block(b, t, { reason, kind: args.kind ?? null }))).to]);
503
+ for (const t of [id, ...(args.ids ?? [])]) results.push([t, (await withBoardAsync((b) => engine.block(b, t, { reason, on: args.on ?? null, waitingOn: args['waiting-on'] ?? null }))).to]);
504
504
  say(args, results.map(([t, s]) => `${s === 'todo' ? 'Waiting on dependencies' : s === 'triage' ? 'Routed to triage' : 'Blocked'} ${t}`).join('\n'), { ok: true, results }); return 0;
505
505
  }
506
506
  case 'schedule': {
@@ -500,6 +500,10 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
500
500
 
501
501
  /** A session effect a committed move asked for: a message to the card's current actor, or closing a slot's pane. */
502
502
  export async function sessionEffect(board, e) {
503
+ if (e.kind === 'tell') {
504
+ await send(boardAddress(board.label), board.label, e.to, e.text, `board-tell-${e.id}-${now()}`);
505
+ return;
506
+ }
503
507
  if (e.kind === 'send_to_actor') {
504
508
  const run = tx(board, (db) => currentRun(db, e.id));
505
509
  const session = run?.metadata?.supercode;
package/board/engine.mjs CHANGED
@@ -15,7 +15,6 @@ import { claimer, event, json, newTaskId, now, tx } from './store.mjs';
15
15
  import { eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, workflowFor } from './workflow.mjs';
16
16
 
17
17
  export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived'];
18
- export const BLOCK_KINDS = ['dependency', 'needs_input', 'capability', 'transient'];
19
18
  export const WORKSPACE_KINDS = ['scratch', 'dir', 'worktree'];
20
19
  /** A card blocked this many times in a row for the same kind goes to triage for a person (Hermes's limit). */
21
20
  export const BLOCK_RECURRENCE_LIMIT = 2;
@@ -408,10 +407,9 @@ function invalidateDescendants(db, board, id) {
408
407
 
409
408
  export const complete = (board, id, { result = null, summary = null, metadata = null, roles, runId = null } = {}) =>
410
409
  act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId } });
411
- export const block = (board, id, { reason = null, kind = null, roles, runId = null } = {}) => {
412
- if (kind && !BLOCK_KINDS.includes(kind)) throw new Error(`block kind is one of ${BLOCK_KINDS.join(', ')}`);
413
- return act(board, id, 'block', { roles, payload: { reason, kind, run_id: runId } });
414
- };
410
+ // 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`)
411
+ export const block = (board, id, { reason = null, on = null, waitingOn = null, roles, runId = null } = {}) =>
412
+ act(board, id, 'block', { roles, payload: { reason, on, waiting_on: waitingOn, run_id: runId } });
415
413
  export const unblock = (board, id, { reason = null, roles } = {}) => act(board, id, 'unblock', { roles, payload: { reason } });
416
414
  export const requestReview = (board, id, { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles } = {}) =>
417
415
  act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId } });
package/board/tools.mjs CHANGED
@@ -101,7 +101,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
101
101
  }
102
102
  case 'kanban_block': {
103
103
  const stale = ownRun(); if (stale) return error(stale);
104
- const { to: status } = await engine.block(board, tid, { reason: args.reason ? redact(String(args.reason)) : null, kind: args.kind ?? null });
104
+ const { to: status } = await engine.block(board, tid, { reason: args.reason ? redact(String(args.reason)) : null, on: args.on ?? null, waitingOn: args.waiting_on ?? null });
105
105
  return ok({ task_id: tid, status });
106
106
  }
107
107
  case 'kanban_request_review': {
@@ -14,8 +14,8 @@ export function taskProtocol(taskId) {
14
14
  '- `kanban_heartbeat` says you are still working on a long step; `kanban_comment` leaves a note on any task.',
15
15
  '- `kanban_create` files follow-up work (with `parents` to make it wait on another task); `kanban_link` adds a dependency.',
16
16
  '- When the work is done, `kanban_complete` with a `summary` a person can read (and `metadata`, `artifacts`, `created_cards` where they apply).',
17
- '- If the work needs review, `kanban_request_review` instead. If you cannot go on, `kanban_block` with a `reason` and its `kind`',
18
- ' (needs_input, capability, transient, or dependency when it waits on another task).',
17
+ '- If the work needs review, `kanban_request_review` instead. If you cannot go on, `kanban_block` with a `reason` and the one',
18
+ ' thing that ends the wait: `on` (the task it waits on) or `waiting_on` (the id of the message whose answer it waits for).',
19
19
  'End every run with exactly one of complete, request_review or block: a run that ends without one is counted as a failure.',
20
20
  'You may change only your own task\'s lifecycle; other tasks hear from you through comments.',
21
21
  ].join('\n');
@@ -79,6 +79,20 @@ function lastCommitAt(path, id) {
79
79
  return r.status === 0 && Number.isFinite(t) && t > 0 ? t : null;
80
80
  }
81
81
 
82
+ /** The first answer to message `id` in its sender's mailbox (`supercode message show --replies`), as its id; or null. */
83
+ function answerTo(id) {
84
+ if (!id) return null;
85
+ const r = spawnSync(process.env.SUPERCODE_BIN || 'supercode', ['message', 'show', '--replies', id, '--json'], { encoding: 'utf8', timeout: 20_000 });
86
+ try { return JSON.parse(r.stdout)?.[0]?.replies?.[0]?.id ?? null; } catch { return null; }
87
+ }
88
+
89
+ /** The session message `id` was filed for (`supercode message show`): who was asked; or null. */
90
+ function askedOf(id) {
91
+ if (!id) return null;
92
+ const r = spawnSync(process.env.SUPERCODE_BIN || 'supercode', ['message', 'show', id, '--json'], { encoding: 'utf8', timeout: 20_000 });
93
+ try { return JSON.parse(r.stdout)?.[0]?.message?.session ?? null; } catch { return null; }
94
+ }
95
+
82
96
  /** The scope an expression is evaluated in: the card (with its relations), the event, params, time, extras. */
83
97
  export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}) {
84
98
  const status = statusOf(db, task);
@@ -93,6 +107,8 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
93
107
  const interruption = json(one(db, `SELECT payload FROM task_events WHERE task_id = ? AND kind IN ('blocked', 'block_loop_detected', 'dependency_wait', 'gave_up',
94
108
  'unblocked', 'changes_requested', 'review_reopened', 'status', 'reclaimed', 'stale', 'timed_out', 'crashed', 'spawn_failed', 'rate_limited', 'session_lost')
95
109
  ORDER BY id DESC LIMIT 1`, task.id)?.payload, {}) ?? {};
110
+ const lastBlock = one(db, "SELECT kind, payload FROM task_events WHERE task_id = ? AND kind IN ('blocked', 'unblocked', 'dependency_wait') ORDER BY id DESC LIMIT 1", task.id);
111
+ const waitingOn = lastBlock?.kind === 'blocked' ? json(lastBlock.payload, {})?.waiting_on ?? null : null;
96
112
  const currentRun = task.current_run_id ? one(db, 'SELECT metadata FROM task_runs WHERE id = ?', task.current_run_id) : null;
97
113
  const session = json(currentRun?.metadata, {})?.supercode ?? {};
98
114
  const card = {
@@ -111,6 +127,10 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
111
127
  subtasks: all(db, `SELECT t.id, ${related}, t.title, t.assignee FROM tasks t JOIN supercode_cards c ON c.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at`, task.id),
112
128
  queue_reason: json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'respawn_guarded' AND created_at >= ? ORDER BY id DESC LIMIT 1",
113
129
  task.id, Number(one(db, 'SELECT since FROM supercode_cards WHERE task_id = ?', task.id)?.since ?? 0))?.payload, {})?.reason ?? null,
130
+ // the message whose answer the standing block waits for (`block --waiting-on`), and that answer's id once it has landed
131
+ waiting_on: waitingOn,
132
+ answer: () => answerTo(waitingOn),
133
+ asked_of: () => askedOf(waitingOn),
114
134
  last_block_event: one(db, "SELECT kind FROM task_events WHERE task_id = ? AND kind IN ('blocked', 'unblocked') ORDER BY id DESC LIMIT 1", task.id)?.kind ?? null,
115
135
  // where the latest interruption says the card resumes (any status the workflow declares); none recorded: null
116
136
  resume_status: interruption.resume_status ?? interruption.retry_status ?? interruption.source_status ?? null,
@@ -321,14 +341,15 @@ const FORMAT = {
321
341
  const artifacts = Array.isArray(e.metadata?.artifacts) ? e.metadata.artifacts.filter((a) => typeof a === 'string' && a.trim()) : [];
322
342
  return { result_len: e.result ? String(e.result).length : 0, summary: firstLine(e.summary ?? e.result), ...(artifacts.length ? { artifacts } : {}) };
323
343
  },
324
- blocked: ({ e, after, source }) => ({ reason: e.reason ?? null, kind: e.kind ?? null, recurrences: Number(after.block_recurrences || 0), source_status: source }),
344
+ 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 }),
325
345
  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 }),
326
- dependency_wait: ({ e, source }) => ({ reason: e.reason ?? null, kind: e.kind ?? null, source_status: source }),
346
+ dependency_wait: ({ e, source }) => ({ reason: e.reason ?? null, on: e.on ?? null, source_status: source }),
327
347
  review_requested: ({ e, before, after }) => ({ summary: firstLine(e.summary), implementer: before.assignee, reviewer: after.assignee !== before.assignee ? after.assignee : (e.reviewer ?? null) }),
328
348
  // the reviewer is the lane that reviewed (the card's assignee while in review), unless the verdict names one
329
349
  changes_requested: ({ e, before, after }) => ({ reason: e.reason ?? null, reviewer: e.reviewer ?? before.assignee ?? null, implementer: after.assignee }),
330
350
  review_reopened: ({ after, to }) => ({ status: to, implementer: after.assignee }),
331
- unblocked: ({ to }) => (to !== 'ready' ? { status: to, resume_status: to } : null),
351
+ // what arrived (an unblock says it), and where the card resumes
352
+ unblocked: ({ e, to }) => ({ reason: e.reason ?? null, ...(to !== 'ready' ? { status: to, resume_status: to } : {}) }),
332
353
  gave_up: ({ e, after, params, outcome }) => ({ error: String(e.error ?? '').slice(0, 500) || null, failures: Number(after.consecutive_failures || 0), limit: after.max_retries ?? params.failure_limit, outcome }),
333
354
  promoted_manual: ({ e }) => ({ actor: e.actor ?? 'user', reason: e.reason ?? null, forced: Boolean(e.force) }),
334
355
  promoted: ({ to }) => (to !== 'ready' ? { status: to } : null),
@@ -361,7 +382,10 @@ const ACTIONS = {
361
382
  const { run_id: _r, force: _f, lock: _l, ...said } = e;
362
383
  payload = { ...said, ...(RETRY_KINDS.has(kind) ? { retry_status: ctx.to } : { source_status: source }) };
363
384
  }
364
- record(ctx.db, ctx.id, kind, payload && Object.keys(payload).length ? payload : null, ctx.runId);
385
+ // `on`: the event is another card's (a dependency hears what waits on it), recorded there with no run
386
+ const on = typeof args === 'object' && args?.on ? String(args.on) : null;
387
+ if (on && !one(ctx.db, 'SELECT id FROM tasks WHERE id = ?', on)) return;
388
+ record(ctx.db, on ?? ctx.id, kind, payload && Object.keys(payload).length ? payload : null, on ? null : ctx.runId);
365
389
  },
366
390
  assign(ctx, { field, value }) {
367
391
  if (!ASSIGNABLE.has(field)) throw new Error(`the workflow assigns ${field}, which is not a card field it may set`);
@@ -435,6 +459,7 @@ const ACTIONS = {
435
459
  if (ctx.runId) run(ctx.db, 'UPDATE task_runs SET summary = COALESCE(?, summary), metadata = COALESCE(?, metadata) WHERE id = ?', summary, e.metadata ? JSON.stringify(e.metadata) : null, ctx.runId);
436
460
  },
437
461
  link(ctx, parent) {
462
+ if (!one(ctx.db, 'SELECT id FROM tasks WHERE id = ?', parent)) throw new Error(`${parent} is no card on this board`);
438
463
  run(ctx.db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', parent, ctx.id);
439
464
  },
440
465
  unlink(ctx, parent) {
@@ -476,6 +501,8 @@ const ACTIONS = {
476
501
  return;
477
502
  }
478
503
  },
504
+ // a message from the board to a session (`to`: its address)
505
+ tell(ctx, { to, text }) { if (to) ctx.effects.push({ kind: 'tell', id: ctx.id, to: String(to), text: String(text ?? '') }); },
479
506
  send_to_actor(ctx, text) { ctx.effects.push({ kind: 'send_to_actor', id: ctx.id, text }); },
480
507
  };
481
508
 
@@ -106,7 +106,7 @@
106
106
  },
107
107
  {
108
108
  "name": "kanban_block",
109
- "description": "Stop work on this task and route it according to WHY you're stuck. Set ``kind`` to say which: 'dependency' (waiting on another task — goes to todo and auto-resumes when that task finishes, no human needed), 'needs_input' (you need a human decision/answer), 'capability' (a hard wall: no access, missing credentials, an action no agent can do), or 'transient' (a flaky failure that may clear). ``reason`` is shown to the human on the board. If a task keeps getting unblocked and re-blocked for the same reason, it is auto-escalated to triage. Use for genuine blockers only — don't block on things you can resolve yourself.",
109
+ "description": "Stop work on this task until one thing ends the wait, and name it: `on`, the task it waits on (it goes to todo and resumes when that task closes), or `waiting_on`, the id of the message you sent whose answer it waits for (it resumes when an answer to that message lands in your mailbox). An outside event is a task on the card of whoever makes it happen or watches for it. `reason` is shown on the board. The same block twice goes to triage.",
110
110
  "inputSchema": {
111
111
  "type": "object",
112
112
  "properties": {
@@ -118,15 +118,13 @@
118
118
  "type": "string",
119
119
  "description": "What you need answered or what stopped you, in one or two sentences. Don't paste the whole conversation; the human has the board and can ask follow-ups via comments."
120
120
  },
121
- "kind": {
121
+ "on": {
122
122
  "type": "string",
123
- "enum": [
124
- "dependency",
125
- "needs_input",
126
- "capability",
127
- "transient"
128
- ],
129
- "description": "Why you're blocked. 'dependency' waits in todo and resumes automatically; the others surface to a human. Omit only if none apply."
123
+ "description": "The task id this waits on."
124
+ },
125
+ "waiting_on": {
126
+ "type": "string",
127
+ "description": "The id of the message (m-… or a-…) whose answer this waits for."
130
128
  },
131
129
  "board": {
132
130
  "type": "string",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.57",
3
+ "version": "0.4.59",
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": {