@volter/supercode-orchestrator 0.4.29 → 0.4.30
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 +65 -42
- package/board/dispatch.mjs +290 -274
- package/board/engine.mjs +151 -310
- package/board/expr.mjs +212 -0
- package/board/store.mjs +4 -2
- package/board/tools.mjs +17 -8
- package/board/workflow.mjs +442 -0
- package/board.mjs +8 -4
- package/kanban-worker.mjs +2 -1
- package/loop.mjs +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
// The board's workflow, interpreted (docs/architecture/orchestrator.md §2.9). The IR's `Orchestration.workflow`
|
|
2
|
+
// (crates/interchange/src/orchestration/workflow.rs; Hermes's dispatcher, hermes.workflow.yaml, when a home declares
|
|
3
|
+
// none) says every status a card can be in, what runs there, which events move a card and who may send them, what
|
|
4
|
+
// must hold and what happens; this module applies it to the board file and keeps no rule of its own. Its actions are
|
|
5
|
+
// the engine's primitives (the rows Hermes's tables hold); the I/O an action asks for (stopping a worker, closing a
|
|
6
|
+
// pane, sending a session a message, cleaning a workspace) is answered as effects the caller runs after commit.
|
|
7
|
+
import { event as record, json, now } from './store.mjs';
|
|
8
|
+
import { evaluate, holds, render } from './expr.mjs';
|
|
9
|
+
|
|
10
|
+
const loaded = new Map();
|
|
11
|
+
|
|
12
|
+
/** Read the home's workflow through the IR (and the params its `kanban:` keys override); cached per home. */
|
|
13
|
+
export async function loadWorkflow(root, { fresh = false } = {}) {
|
|
14
|
+
if (!fresh && loaded.has(root)) return loaded.get(root);
|
|
15
|
+
const { read } = await import('../orchestration.mjs');
|
|
16
|
+
const orchestration = await read(root);
|
|
17
|
+
const entry = workflowEntry(orchestration);
|
|
18
|
+
loaded.set(root, entry);
|
|
19
|
+
return entry;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The workflow entry for an orchestration value already read. */
|
|
23
|
+
export function workflowEntry(orchestration) {
|
|
24
|
+
const workflow = orchestration?.workflow;
|
|
25
|
+
if (!workflow?.statuses) throw new Error('the IR this supercode answers has no board workflow (Orchestration.workflow); update supercode');
|
|
26
|
+
const kanban = orchestration.profiles?.default?.residue?.config?.kanban ?? {};
|
|
27
|
+
return { workflow, params: { ...workflow.params, ...kanban }, profiles: orchestration.profiles ?? {} };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The workflow loaded for `root` (loadWorkflow first). */
|
|
31
|
+
export function workflowFor(root) {
|
|
32
|
+
const entry = loaded.get(root);
|
|
33
|
+
if (!entry) throw new Error(`the board workflow of ${root} is not loaded`);
|
|
34
|
+
return entry;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Use `entry` for `root` (a caller that read the IR itself). */
|
|
38
|
+
export function useWorkflow(root, entry) {
|
|
39
|
+
loaded.set(root, entry);
|
|
40
|
+
return entry;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ------------------------------------------------------------------ cards as the workflow sees them
|
|
44
|
+
|
|
45
|
+
const one = (db, sql, ...args) => db.prepare(sql).get(...args);
|
|
46
|
+
const all = (db, sql, ...args) => db.prepare(sql).all(...args);
|
|
47
|
+
const run = (db, sql, ...args) => db.prepare(sql).run(...args);
|
|
48
|
+
|
|
49
|
+
/** The card's workflow status: our exact name where it differs from what kanban.db stores, else the stored one. */
|
|
50
|
+
export function statusOf(db, task) {
|
|
51
|
+
return one(db, 'SELECT stage FROM supercode_cards WHERE task_id = ?', task.id)?.stage ?? task.status;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What kanban.db stores for `status`. */
|
|
55
|
+
export function storedAs(workflow, status) {
|
|
56
|
+
return workflow.statuses[status]?.stored_as ?? status;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Where a card interrupted in `status` resumes: its `exited` transition's unguarded target (data, not a rule here). */
|
|
60
|
+
export function resumeOf(workflow, status) {
|
|
61
|
+
const exited = workflow.statuses[status]?.on?.exited ?? [];
|
|
62
|
+
const fallback = [...exited].reverse().find((t) => !t.guard && t.target && !t.target.includes('{{'));
|
|
63
|
+
return fallback?.target ?? status;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// a regular expression written Python's way (a leading `(?i)`), as Hermes's patterns are
|
|
67
|
+
function pattern(p) {
|
|
68
|
+
const text = String(p ?? '');
|
|
69
|
+
return text.startsWith('(?i)') ? new RegExp(text.slice(4), 'i') : new RegExp(text);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The scope an expression is evaluated in: the card (with its relations), the event, params, time, extras. */
|
|
73
|
+
export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}) {
|
|
74
|
+
const status = statusOf(db, task);
|
|
75
|
+
const parents = all(db, 'SELECT t.id, t.status, t.title FROM tasks t JOIN task_links l ON l.parent_id = t.id WHERE l.child_id = ?', task.id);
|
|
76
|
+
const children = all(db, 'SELECT t.id, t.status FROM tasks t JOIN task_links l ON l.child_id = t.id WHERE l.parent_id = ?', task.id);
|
|
77
|
+
const lastRun = one(db, 'SELECT * FROM task_runs WHERE task_id = ? AND ended_at IS NOT NULL ORDER BY ended_at DESC, id DESC LIMIT 1', task.id);
|
|
78
|
+
const lastCompleted = one(db, "SELECT * FROM task_runs WHERE task_id = ? AND outcome = 'completed' ORDER BY ended_at DESC LIMIT 1", task.id);
|
|
79
|
+
const asked = json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'review_requested' ORDER BY id DESC LIMIT 1", task.id)?.payload, {}) ?? {};
|
|
80
|
+
const changes = json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'changes_requested' ORDER BY id DESC LIMIT 1", task.id)?.payload, {}) ?? {};
|
|
81
|
+
const interruption = json(one(db, `SELECT payload FROM task_events WHERE task_id = ? AND kind IN ('blocked', 'block_loop_detected', 'dependency_wait', 'gave_up',
|
|
82
|
+
'unblocked', 'changes_requested', 'review_reopened', 'status', 'reclaimed', 'stale', 'timed_out', 'crashed', 'spawn_failed', 'rate_limited', 'session_lost')
|
|
83
|
+
ORDER BY id DESC LIMIT 1`, task.id)?.payload, {}) ?? {};
|
|
84
|
+
const currentRun = task.current_run_id ? one(db, 'SELECT metadata FROM task_runs WHERE id = ?', task.current_run_id) : null;
|
|
85
|
+
const session = json(currentRun?.metadata, {})?.supercode ?? {};
|
|
86
|
+
const card = {
|
|
87
|
+
...task,
|
|
88
|
+
status,
|
|
89
|
+
stored_as: task.status,
|
|
90
|
+
board: board.slug,
|
|
91
|
+
parents,
|
|
92
|
+
children,
|
|
93
|
+
counters: { failures: Number(task.consecutive_failures || 0), block_recurrences: Number(task.block_recurrences || 0), goal_turns: Number(session.goal_turns || 0) },
|
|
94
|
+
goal_mode: Boolean(task.goal_mode),
|
|
95
|
+
implementer: asked.implementer ?? task.assignee,
|
|
96
|
+
last_reviewer: changes.reviewer ?? null,
|
|
97
|
+
arc: task.subtask_of ? one(db, 'SELECT id, status, title FROM tasks WHERE id = ?', task.subtask_of) ?? null : null,
|
|
98
|
+
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",
|
|
99
|
+
task.id, Number(one(db, 'SELECT since FROM supercode_cards WHERE task_id = ?', task.id)?.since ?? 0))?.payload, {})?.reason ?? null,
|
|
100
|
+
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,
|
|
101
|
+
resume_status: ['resume_status', 'retry_status', 'source_status'].some((k) => interruption[k] === 'review') ? 'review' : 'ready',
|
|
102
|
+
last_run: lastRun ?? {},
|
|
103
|
+
last_completed: lastCompleted ?? null,
|
|
104
|
+
events_since: (t, kinds = []) => all(db, `SELECT kind FROM task_events WHERE task_id = ? AND created_at >= ?${kinds.length ? ` AND kind IN (${kinds.map(() => '?').join(', ')})` : ''}`, task.id, t, ...kinds),
|
|
105
|
+
comments_since: (t) => all(db, 'SELECT author, body, created_at FROM task_comments WHERE task_id = ? AND created_at >= ?', task.id, t),
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
card,
|
|
109
|
+
event,
|
|
110
|
+
params,
|
|
111
|
+
now: now(),
|
|
112
|
+
board: board.label ?? board.slug,
|
|
113
|
+
matches: (text, p) => (text === null || text === undefined ? false : pattern(p).test(String(text))),
|
|
114
|
+
...extra,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ------------------------------------------------------------------ events
|
|
119
|
+
|
|
120
|
+
/** A template's value: literal text with `{{expr}}` parts; one whole `{{expr}}` gives the expression's own value. */
|
|
121
|
+
export function templateValue(template, scope) {
|
|
122
|
+
if (typeof template !== 'string') return template;
|
|
123
|
+
const whole = /^\{\{([\s\S]+)\}\}$/.exec(template.trim());
|
|
124
|
+
return whole && !whole[1].includes('}}') ? evaluate(whole[1], scope) : render(template, scope);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const argsValue = (args, scope) => {
|
|
128
|
+
if (Array.isArray(args)) return args.map((a) => argsValue(a, scope));
|
|
129
|
+
if (args && typeof args === 'object') return Object.fromEntries(Object.entries(args).map(([k, v]) => [k, argsValue(v, scope)]));
|
|
130
|
+
return templateValue(args, scope);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** The transitions `name` may take from `status`: the status's own, then the workflow's for every status. */
|
|
134
|
+
export function transitionsFor(workflow, status, name) {
|
|
135
|
+
return [...(workflow.statuses[status]?.on?.[name] ?? []), ...(workflow.on?.[name] ?? [])];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Apply event `name` to card `id` in the caller's transaction. `roles` are who sends it (a transition with `by`
|
|
140
|
+
* takes only a sender in one of its roles; `platform` for the dispatcher's own observations). Answers
|
|
141
|
+
* `{ from, to, effects }`; throws the refusal when no transition applies or a validator fails.
|
|
142
|
+
*/
|
|
143
|
+
export function fire(db, board, entry, id, name, { roles = [], payload = {}, extra = {} } = {}) {
|
|
144
|
+
const { workflow, params } = entry;
|
|
145
|
+
const task = cardOf(db, id);
|
|
146
|
+
const from = statusOf(db, task);
|
|
147
|
+
if (!workflow.statuses[from]) throw new Error(`${id} is ${from}, which the board's workflow does not declare`);
|
|
148
|
+
const candidates = transitionsFor(workflow, from, name);
|
|
149
|
+
if (!candidates.length) throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} does not apply there`);
|
|
150
|
+
// `sender`: the roles the event was sent with (a validator can ask who is asking)
|
|
151
|
+
const scope = scopeOf(db, board, task, { params, event: payload, extra: { lanes: lanesIn(entry), ...extra, sender: roles } });
|
|
152
|
+
const allowed = candidates.filter((t) => !t.by?.length || t.by.some((r) => roles.includes(r)));
|
|
153
|
+
if (!allowed.length) throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(candidates.flatMap((t) => t.by ?? []))].join(' or ')}`);
|
|
154
|
+
const taken = allowed.find((t) => holds(t.guard ?? null, scope));
|
|
155
|
+
if (!taken) { const none = new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} does not apply to it now`); none.noTransition = true; throw none; }
|
|
156
|
+
for (const v of taken.validate ?? []) {
|
|
157
|
+
if (!holds(v.expr, scope)) { const refusal = new Error(v.message); refusal.refusal = v.message; throw refusal; }
|
|
158
|
+
}
|
|
159
|
+
const effects = [];
|
|
160
|
+
const to = take(db, board, entry, task, from, taken, scope, effects);
|
|
161
|
+
settle(db, board, entry, id, effects);
|
|
162
|
+
return { from, to: statusOf(db, cardOf(db, id)), moved: to !== from, effects };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function cardOf(db, id) {
|
|
166
|
+
const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
|
|
167
|
+
if (!row) throw new Error(`no card ${id}`);
|
|
168
|
+
const ours = one(db, 'SELECT machine, subtask_of FROM supercode_cards WHERE task_id = ?', id);
|
|
169
|
+
return { ...row, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// one transition: the old status's exit actions, the move, the transition's actions, the new status's entry actions
|
|
173
|
+
function take(db, board, entry, task, from, transition, scope, effects) {
|
|
174
|
+
const { workflow } = entry;
|
|
175
|
+
const target = transition.target ? String(templateValue(transition.target, scope) ?? '') : null;
|
|
176
|
+
if (target && !workflow.statuses[target]) throw new Error(`the workflow moves ${task.id} to ${target}, which it does not declare`);
|
|
177
|
+
const moves = target && target !== from;
|
|
178
|
+
const ctx = { db, board, entry, id: task.id, scope: { ...scope, before: scope.card }, from, to: target ?? from, effects, runId: task.current_run_id ?? null };
|
|
179
|
+
if (moves) for (const a of workflow.statuses[from].exit ?? []) act(ctx, a);
|
|
180
|
+
if (moves) setStatus(db, workflow, task.id, target);
|
|
181
|
+
for (const a of transition.actions ?? []) act(ctx, a);
|
|
182
|
+
if (moves) {
|
|
183
|
+
for (const a of workflow.statuses[target].entry ?? []) act(ctx, a);
|
|
184
|
+
if (workflow.statuses[target].run) effects.push({ kind: 'run', id: task.id, status: target });
|
|
185
|
+
}
|
|
186
|
+
return target ?? from;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Put the card in `status`: kanban.db holds its `stored_as`, our table the exact name where they differ. */
|
|
190
|
+
export function setStatus(db, workflow, id, status) {
|
|
191
|
+
const stored = storedAs(workflow, status);
|
|
192
|
+
run(db, 'UPDATE tasks SET status = ? WHERE id = ?', stored, id);
|
|
193
|
+
run(db, `INSERT INTO supercode_cards (task_id, stage, since, after_fired) VALUES (?, ?, ?, NULL)
|
|
194
|
+
ON CONFLICT(task_id) DO UPDATE SET stage = excluded.stage, since = excluded.since, after_fired = NULL`, id, stored === status ? null : status, now());
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Take every `always` transition that holds, from wherever the card is, until none does. */
|
|
198
|
+
export function settle(db, board, entry, id, effects = []) {
|
|
199
|
+
const { workflow, params } = entry;
|
|
200
|
+
for (let hops = 0; hops < 16; hops += 1) {
|
|
201
|
+
const task = cardOf(db, id);
|
|
202
|
+
const from = statusOf(db, task);
|
|
203
|
+
const scope = scopeOf(db, board, task, { params, extra: { lanes: lanesIn(entry) } });
|
|
204
|
+
let t = (workflow.statuses[from]?.always ?? []).find((x) => holds(x.guard ?? null, scope));
|
|
205
|
+
if (!t) t = dueAfter(db, workflow, task, from, scope);
|
|
206
|
+
if (!t) return effects;
|
|
207
|
+
const to = take(db, board, entry, task, from, t, scope, effects);
|
|
208
|
+
if (to === from) return effects;
|
|
209
|
+
}
|
|
210
|
+
throw new Error(`${id}: the workflow's always transitions do not come to rest`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// the home's lanes (profiles with a worker), for expressions that ask whether a profile can run a card
|
|
214
|
+
const lanesIn = (entry) => Object.entries(entry.profiles ?? {}).filter(([, p]) => p?.worker?.harness).map(([name]) => name);
|
|
215
|
+
|
|
216
|
+
// the first `after` move of the status whose time in it has passed and whose guard holds, once per entry to the status
|
|
217
|
+
function dueAfter(db, workflow, task, status, scope) {
|
|
218
|
+
const moves = workflow.statuses[status]?.after ?? [];
|
|
219
|
+
if (!moves.length) return null;
|
|
220
|
+
const ours = one(db, 'SELECT since, after_fired FROM supercode_cards WHERE task_id = ?', task.id);
|
|
221
|
+
if (!ours?.since) {
|
|
222
|
+
// a card from before `since` was kept: its time in the status counts from now
|
|
223
|
+
run(db, 'INSERT INTO supercode_cards (task_id, since) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET since = excluded.since', task.id, now());
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
const fired = new Set(json(ours.after_fired, []) ?? []);
|
|
227
|
+
for (let i = 0; i < moves.length; i += 1) {
|
|
228
|
+
if (fired.has(i)) continue;
|
|
229
|
+
const seconds = Number(evaluate(moves[i].after, scope));
|
|
230
|
+
if (!(seconds >= 0) || now() - Number(ours.since) < seconds || !holds(moves[i].guard ?? null, scope)) continue;
|
|
231
|
+
fired.add(i);
|
|
232
|
+
run(db, 'UPDATE supercode_cards SET after_fired = ? WHERE task_id = ?', JSON.stringify([...fired]), task.id);
|
|
233
|
+
return moves[i];
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Where a new card starts: the first `initial` transition that holds (`event` is the create's own words). */
|
|
239
|
+
export function initialStatus(db, board, entry, task, event) {
|
|
240
|
+
const scope = scopeOf(db, board, task, { params: entry.params, event });
|
|
241
|
+
const t = entry.workflow.initial.find((x) => holds(x.guard ?? null, scope));
|
|
242
|
+
if (!t?.target) throw new Error('the workflow says nowhere for a new card to start');
|
|
243
|
+
return String(templateValue(t.target, scope));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ------------------------------------------------------------------ actions
|
|
247
|
+
|
|
248
|
+
// the task columns an `assign` may write (Hermes's table; anything else would be a typo in the workflow)
|
|
249
|
+
const ASSIGNABLE = new Set(['assignee', 'block_kind', 'model_override', 'provider_override', 'goal_mode', 'goal_max_turns', 'last_failure_error', 'priority', 'title', 'body', 'result']);
|
|
250
|
+
const COUNTER_COLUMNS = { failures: 'consecutive_failures', block_recurrences: 'block_recurrences' };
|
|
251
|
+
|
|
252
|
+
function act(ctx, action) {
|
|
253
|
+
const [name, raw] = Object.entries(action)[0] ?? [];
|
|
254
|
+
const primitive = ACTIONS[name];
|
|
255
|
+
if (!primitive) throw new Error(`the workflow names an action the engine does not have: ${name}`);
|
|
256
|
+
const scope = { ...ctx.scope, card: scopeOf(ctx.db, ctx.board, cardOf(ctx.db, ctx.id), { params: ctx.entry.params, event: ctx.scope.event }).card };
|
|
257
|
+
primitive(ctx, argsValue(raw, scope), scope);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// kanban.db's event format: the payload Hermes's readers expect for each kind, built from the event, the card as it
|
|
261
|
+
// was when the event arrived (`before`) and as it is now (`after`). A format, like the codec's for Hermes's files;
|
|
262
|
+
// what happens is the workflow's. A kind with no entry here carries the event's own fields.
|
|
263
|
+
const firstLine = (text) => String(text ?? '').trim().split('\n')[0].slice(0, 400) || null;
|
|
264
|
+
const FORMAT = {
|
|
265
|
+
completed: ({ e }) => {
|
|
266
|
+
const artifacts = Array.isArray(e.metadata?.artifacts) ? e.metadata.artifacts.filter((a) => typeof a === 'string' && a.trim()) : [];
|
|
267
|
+
return { result_len: e.result ? String(e.result).length : 0, summary: firstLine(e.summary ?? e.result), ...(artifacts.length ? { artifacts } : {}) };
|
|
268
|
+
},
|
|
269
|
+
blocked: ({ e, after, source }) => ({ reason: e.reason ?? null, kind: e.kind ?? null, recurrences: Number(after.block_recurrences || 0), source_status: source }),
|
|
270
|
+
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 }),
|
|
271
|
+
dependency_wait: ({ e, source }) => ({ reason: e.reason ?? null, kind: e.kind ?? null, source_status: source }),
|
|
272
|
+
review_requested: ({ e, before, after }) => ({ summary: firstLine(e.summary), implementer: before.assignee, reviewer: after.assignee !== before.assignee ? after.assignee : (e.reviewer ?? null) }),
|
|
273
|
+
changes_requested: ({ e, after }) => ({ reason: e.reason ?? null, reviewer: e.reviewer ?? null, implementer: after.assignee }),
|
|
274
|
+
review_reopened: ({ after, to }) => ({ status: to, implementer: after.assignee }),
|
|
275
|
+
unblocked: ({ to }) => (to !== 'ready' ? { status: to, resume_status: to } : null),
|
|
276
|
+
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 }),
|
|
277
|
+
promoted_manual: ({ e }) => ({ actor: e.actor ?? 'user', reason: e.reason ?? null, forced: Boolean(e.force) }),
|
|
278
|
+
promoted: ({ to }) => (to !== 'ready' ? { status: to } : null),
|
|
279
|
+
scheduled: ({ e }) => ({ reason: e.reason ?? null }),
|
|
280
|
+
specified: ({ e }) => ({ author: e.author ?? 'user', title_changed: Boolean(e.title), body_len: e.body ? String(e.body).length : 0 }),
|
|
281
|
+
decomposed: ({ e }) => ({ children: e.children ?? [], author: e.author ?? 'user' }),
|
|
282
|
+
edited: ({ e }) => ({ fields: ['result', 'summary', ...(e.metadata !== undefined ? ['metadata'] : [])], result_len: e.result ? String(e.result).length : 0, summary: firstLine(e.summary ?? e.result) }),
|
|
283
|
+
assigned: ({ after }) => ({ assignee: after.assignee ?? null }),
|
|
284
|
+
model_override_set: ({ e }) => ({ model: e.model || null, provider: e.provider || null }),
|
|
285
|
+
goal_set: ({ after }) => ({ goal_mode: Boolean(after.goal_mode), goal_max_turns: after.goal_mode ? after.goal_max_turns ?? null : null }),
|
|
286
|
+
heartbeat: ({ e }) => (e.note ? { note: e.note } : null),
|
|
287
|
+
commented: ({ e }) => ({ author: String(e.author ?? 'user').trim(), len: String(e.body ?? '').length }),
|
|
288
|
+
archived: () => null,
|
|
289
|
+
status: ({ e, before, to }) => ({ status: to, reason: e.reason ?? 'reopened', previous_status: before.stored_as }),
|
|
290
|
+
};
|
|
291
|
+
const RETRY_KINDS = new Set(['crashed', 'timed_out', 'stale', 'rate_limited', 'session_lost', 'reclaimed', 'reconciled', 'spawn_failed']);
|
|
292
|
+
|
|
293
|
+
const ACTIONS = {
|
|
294
|
+
record(ctx, args, scope) {
|
|
295
|
+
const kind = typeof args === 'string' ? args : args?.kind;
|
|
296
|
+
const given = typeof args === 'object' && args?.payload ? args.payload : null;
|
|
297
|
+
const after = cardOf(ctx.db, ctx.id);
|
|
298
|
+
const source = resumeOf(ctx.entry.workflow, ctx.from);
|
|
299
|
+
const e = scope.event ?? {};
|
|
300
|
+
const shape = FORMAT[kind];
|
|
301
|
+
let payload;
|
|
302
|
+
if (given) payload = given;
|
|
303
|
+
else if (shape) payload = shape({ e, before: ctx.scope.before, after, to: ctx.to, source, params: ctx.entry.params, outcome: ctx.lastOutcome ?? null });
|
|
304
|
+
else {
|
|
305
|
+
const { run_id: _r, force: _f, lock: _l, ...said } = e;
|
|
306
|
+
payload = { ...said, ...(RETRY_KINDS.has(kind) ? { retry_status: ctx.to } : { source_status: source }) };
|
|
307
|
+
}
|
|
308
|
+
record(ctx.db, ctx.id, kind, payload && Object.keys(payload).length ? payload : null, ctx.runId);
|
|
309
|
+
},
|
|
310
|
+
assign(ctx, { field, value }) {
|
|
311
|
+
if (!ASSIGNABLE.has(field)) throw new Error(`the workflow assigns ${field}, which is not a card field it may set`);
|
|
312
|
+
run(ctx.db, `UPDATE tasks SET ${field} = ? WHERE id = ?`, typeof value === 'boolean' ? Number(value) : value ?? null, ctx.id);
|
|
313
|
+
},
|
|
314
|
+
count(ctx, counter) {
|
|
315
|
+
const column = COUNTER_COLUMNS[counter];
|
|
316
|
+
if (column) {
|
|
317
|
+
run(ctx.db, `UPDATE tasks SET ${column} = ${column} + 1 WHERE id = ?`, ctx.id);
|
|
318
|
+
if (counter === 'failures' && ctx.scope.event?.error) run(ctx.db, 'UPDATE tasks SET last_failure_error = ? WHERE id = ?', String(ctx.scope.event.error).slice(0, 500), ctx.id);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
// a counter of the run's own (the goal loop's turns), kept with the session the dispatcher recorded
|
|
322
|
+
runSession(ctx, (s) => ({ ...s, [counter]: Number(s[counter] ?? 0) + 1 }));
|
|
323
|
+
},
|
|
324
|
+
reset(ctx, counters) {
|
|
325
|
+
for (const counter of [counters].flat()) {
|
|
326
|
+
const column = COUNTER_COLUMNS[counter];
|
|
327
|
+
if (column) run(ctx.db, `UPDATE tasks SET ${column} = 0 WHERE id = ?`, ctx.id);
|
|
328
|
+
else runSession(ctx, (s) => ({ ...s, [counter]: 0 }));
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
end_run(ctx, { outcome, summary = null, error = null } = {}) {
|
|
332
|
+
const row = one(ctx.db, 'SELECT current_run_id FROM tasks WHERE id = ?', ctx.id);
|
|
333
|
+
const t = now();
|
|
334
|
+
if (row?.current_run_id) {
|
|
335
|
+
const runId = Number(row.current_run_id);
|
|
336
|
+
run(ctx.db, `UPDATE task_runs SET status = ?, outcome = ?, summary = COALESCE(?, summary), error = COALESCE(?, error), ended_at = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL
|
|
337
|
+
WHERE id = ? AND ended_at IS NULL`, outcome, outcome, summary, error ?? ctx.scope.event?.error ?? null, t, runId);
|
|
338
|
+
ctx.runId = runId;
|
|
339
|
+
ctx.lastOutcome = outcome;
|
|
340
|
+
ctx.effects.push({ kind: 'run_ended', id: ctx.id, runId, outcome });
|
|
341
|
+
} else if (summary || ctx.scope.event?.metadata) {
|
|
342
|
+
// an outcome reached with no run open (a person completing a card nobody claimed)
|
|
343
|
+
const task = one(ctx.db, 'SELECT assignee FROM tasks WHERE id = ?', ctx.id);
|
|
344
|
+
const r = run(ctx.db, 'INSERT INTO task_runs (task_id, profile, status, started_at, ended_at, outcome, summary, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
345
|
+
ctx.id, task?.assignee ?? null, outcome, t, t, outcome, summary, ctx.scope.event?.metadata ? JSON.stringify(ctx.scope.event.metadata) : null);
|
|
346
|
+
ctx.runId = Number(r.lastInsertRowid);
|
|
347
|
+
}
|
|
348
|
+
run(ctx.db, 'UPDATE tasks SET current_run_id = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?', ctx.id);
|
|
349
|
+
},
|
|
350
|
+
claim(ctx, role, scope) {
|
|
351
|
+
const task = cardOf(ctx.db, ctx.id);
|
|
352
|
+
const t = now();
|
|
353
|
+
const ttl = Number(ctx.entry.params.claim_ttl_seconds) || 900;
|
|
354
|
+
const lock = ctx.scope.event?.lock;
|
|
355
|
+
if (!lock) throw new Error('a claim needs the dispatcher\'s lock');
|
|
356
|
+
if (task.current_run_id) {
|
|
357
|
+
run(ctx.db, "UPDATE task_runs SET status = 'reclaimed', outcome = 'reclaimed', summary = COALESCE(summary, 'invariant recovery on re-claim'), ended_at = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ? AND ended_at IS NULL", t, task.current_run_id);
|
|
358
|
+
}
|
|
359
|
+
const expires = t + ttl;
|
|
360
|
+
const profile = evaluate(ctx.entry.workflow.roles?.[role] ?? 'card.assignee', scope) ?? task.assignee;
|
|
361
|
+
run(ctx.db, 'UPDATE tasks SET claim_lock = ?, claim_expires = ?, started_at = COALESCE(started_at, ?) WHERE id = ?', lock, expires, t, ctx.id);
|
|
362
|
+
const r = run(ctx.db, "INSERT INTO task_runs (task_id, profile, step_key, status, claim_lock, claim_expires, max_runtime_seconds, started_at, metadata) VALUES (?, ?, ?, 'running', ?, ?, ?, ?, ?)",
|
|
363
|
+
ctx.id, profile, ctx.to, lock, expires, task.max_runtime_seconds, t, JSON.stringify({ supercode: { role, status: ctx.to, slot: ctx.entry.workflow.statuses[ctx.to]?.run?.session ?? 'worker' } }));
|
|
364
|
+
ctx.runId = Number(r.lastInsertRowid);
|
|
365
|
+
run(ctx.db, 'UPDATE tasks SET current_run_id = ? WHERE id = ?', ctx.runId, ctx.id);
|
|
366
|
+
record(ctx.db, ctx.id, 'claimed', { lock, expires, run_id: ctx.runId, source_status: resumeOf(ctx.entry.workflow, ctx.to) }, ctx.runId);
|
|
367
|
+
},
|
|
368
|
+
extend_claim(ctx) {
|
|
369
|
+
const ttl = Number(ctx.entry.params.claim_ttl_seconds) || 900;
|
|
370
|
+
const expires = now() + ttl;
|
|
371
|
+
run(ctx.db, 'UPDATE tasks SET claim_expires = ?, last_heartbeat_at = ? WHERE id = ?', expires, now(), ctx.id);
|
|
372
|
+
if (ctx.runId) run(ctx.db, 'UPDATE task_runs SET claim_expires = ?, last_heartbeat_at = ? WHERE id = ?', expires, now(), ctx.runId);
|
|
373
|
+
},
|
|
374
|
+
set_result(ctx, said) {
|
|
375
|
+
const e = said ?? {};
|
|
376
|
+
const summary = e.summary ?? null;
|
|
377
|
+
run(ctx.db, 'UPDATE tasks SET result = COALESCE(?, result), completed_at = COALESCE(completed_at, ?) WHERE id = ?', e.result ?? summary ?? null, now(), ctx.id);
|
|
378
|
+
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);
|
|
379
|
+
},
|
|
380
|
+
link(ctx, parent) {
|
|
381
|
+
run(ctx.db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', parent, ctx.id);
|
|
382
|
+
},
|
|
383
|
+
unlink(ctx, parent) {
|
|
384
|
+
run(ctx.db, 'DELETE FROM task_links WHERE parent_id = ? AND child_id = ?', parent, ctx.id);
|
|
385
|
+
},
|
|
386
|
+
specify(ctx, said) {
|
|
387
|
+
if (said?.title && String(said.title).trim()) run(ctx.db, 'UPDATE tasks SET title = ? WHERE id = ?', String(said.title).trim(), ctx.id);
|
|
388
|
+
if (said?.body !== undefined && said?.body !== null) run(ctx.db, 'UPDATE tasks SET body = ? WHERE id = ?', said.body, ctx.id);
|
|
389
|
+
},
|
|
390
|
+
decompose(ctx, said) {
|
|
391
|
+
// the children are filed by the caller before the event (they are cards of their own); the card waits on them
|
|
392
|
+
for (const child of said?.children ?? []) run(ctx.db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', child, ctx.id);
|
|
393
|
+
},
|
|
394
|
+
// card ids a handoff names that no card has (Hermes flags them the same way)
|
|
395
|
+
check_references(ctx, said) {
|
|
396
|
+
const named = [...new Set(`${said?.summary ?? ''} ${said?.result ?? ''}`.match(/\bt_[a-f0-9]{8,}\b/g) ?? [])];
|
|
397
|
+
const phantom = named.filter((t) => !one(ctx.db, 'SELECT 1 FROM tasks WHERE id = ?', t));
|
|
398
|
+
if (phantom.length) record(ctx.db, ctx.id, 'suspected_hallucinated_references', { phantom_refs: phantom, source: 'completion_summary' }, ctx.runId);
|
|
399
|
+
},
|
|
400
|
+
invalidate_descendants(ctx) {
|
|
401
|
+
ctx.effects.push({ kind: 'invalidate_descendants', id: ctx.id });
|
|
402
|
+
},
|
|
403
|
+
comment(ctx, body) {
|
|
404
|
+
run(ctx.db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', ctx.id, 'dispatcher', String(body), now());
|
|
405
|
+
},
|
|
406
|
+
cleanup_workspace(ctx) { ctx.effects.push({ kind: 'cleanup_workspace', id: ctx.id }); },
|
|
407
|
+
stop_run(ctx) { ctx.effects.push({ kind: 'stop_run', id: ctx.id, runId: ctx.runId }); },
|
|
408
|
+
close_session(ctx, slot) { ctx.effects.push({ kind: 'close_session', id: ctx.id, slot }); },
|
|
409
|
+
send_to_actor(ctx, text) { ctx.effects.push({ kind: 'send_to_actor', id: ctx.id, text }); },
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
function runSession(ctx, change) {
|
|
413
|
+
const runId = ctx.runId ?? one(ctx.db, 'SELECT current_run_id FROM tasks WHERE id = ?', ctx.id)?.current_run_id;
|
|
414
|
+
if (!runId) return;
|
|
415
|
+
const row = one(ctx.db, 'SELECT metadata FROM task_runs WHERE id = ?', runId);
|
|
416
|
+
const whole = json(row?.metadata, {}) ?? {};
|
|
417
|
+
whole.supercode = change(whole.supercode ?? {});
|
|
418
|
+
run(ctx.db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(whole), runId);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ------------------------------------------------------------------ doors
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* The events a sender in `role` may send a card in `status`, each as the command that sends it (`where` locates
|
|
425
|
+
* the board: `--root …` or `--at …`) and its transition's description: what a started session is told it can do.
|
|
426
|
+
*/
|
|
427
|
+
// the board tool a one-shot worker sends an event with (Hermes's kanban_* tools)
|
|
428
|
+
const TOOL_OF = { complete: 'kanban_complete', block: 'kanban_block', request_review: 'kanban_request_review', request_changes: 'kanban_request_changes', heartbeat: 'kanban_heartbeat', comment: 'kanban_comment' };
|
|
429
|
+
|
|
430
|
+
export function doors(workflow, status, role, id, where, surface = 'pane') {
|
|
431
|
+
const events = new Map();
|
|
432
|
+
for (const [name, ts] of [...Object.entries(workflow.statuses[status]?.on ?? {}), ...Object.entries(workflow.on ?? {})]) {
|
|
433
|
+
for (const t of ts) {
|
|
434
|
+
if (!t.by?.length || !t.by.includes(role)) continue;
|
|
435
|
+
if (!events.has(name)) events.set(name, t.description ?? null);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (!events.size) return '';
|
|
439
|
+
const lines = [...events].filter(([name]) => surface === 'pane' || TOOL_OF[name])
|
|
440
|
+
.map(([name, what]) => (surface === 'pane' ? `- \`supercode workflow ${name.replace(/_/g, '-')} ${id} … ${where}\`` : `- \`${TOOL_OF[name]}\``) + (what ? `: ${what}` : ''));
|
|
441
|
+
return ['What you may do with the card:', ...lines].join('\n');
|
|
442
|
+
}
|
package/board.mjs
CHANGED
|
@@ -12,6 +12,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
12
12
|
import { holdDispatcherLock } from './hermes-locks.mjs';
|
|
13
13
|
import { summary } from './board/cli.mjs';
|
|
14
14
|
import { notifyChats } from './board/dispatch.mjs';
|
|
15
|
+
import { workflowEntry } from './board/workflow.mjs';
|
|
16
|
+
import { evaluate, holds } from './board/expr.mjs';
|
|
15
17
|
|
|
16
18
|
const ENTRY = fileURLToPath(new URL('./bin/orchestrator.mjs', import.meta.url));
|
|
17
19
|
|
|
@@ -31,15 +33,17 @@ function dispatchOnce(root) {
|
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/** Start the board's dispatcher for the home at `root`; answers `{ tick, stop }`, or null when it does not dispatch. */
|
|
34
|
-
export async function startBoard({ root,
|
|
35
|
-
const kanban = config.kanban ?? {};
|
|
36
|
+
export async function startBoard({ root, orchestration = null, log = () => {}, adapters = {}, wake = null }) {
|
|
36
37
|
const off = String(process.env.HERMES_KANBAN_DISPATCH_IN_GATEWAY ?? '').trim().toLowerCase();
|
|
37
38
|
if (['0', 'false', 'no', 'off'].includes(off)) { log('info', 'board: not dispatched (HERMES_KANBAN_DISPATCH_IN_GATEWAY)'); return null; }
|
|
38
|
-
|
|
39
|
+
// whether and how often the served home dispatches is the board workflow's (`dispatch.serve`, `dispatch.tick`)
|
|
40
|
+
let entry;
|
|
41
|
+
try { entry = workflowEntry(orchestration); } catch (error) { log('warn', `board: not dispatched: ${error.message}`); return null; }
|
|
42
|
+
if (!holds(entry.workflow.dispatch.serve ?? null, { params: entry.params })) { log('info', 'board: not dispatched (the board workflow\'s dispatch.serve)'); return null; }
|
|
39
43
|
let lock;
|
|
40
44
|
try { lock = await holdDispatcherLock(root); }
|
|
41
45
|
catch (error) { log('info', `board: not dispatched: ${error.message}`); return null; }
|
|
42
|
-
const seconds = Math.max(Number(
|
|
46
|
+
const seconds = Math.max(Number(evaluate(entry.workflow.dispatch.tick, { params: entry.params })) || 60, 1);
|
|
43
47
|
let running = false;
|
|
44
48
|
let stopped = false;
|
|
45
49
|
const said = new Set();
|
package/kanban-worker.mjs
CHANGED
|
@@ -69,7 +69,8 @@ export async function runKanbanWorker(argv, env = process.env) {
|
|
|
69
69
|
const surfaceKey = `kanban|task|${taskId}`;
|
|
70
70
|
const runEnv = { HERMES_HOME: profile.dir, ...activationEnv({ root, profile, surfaceKey, vault }), ...route.env, ...task };
|
|
71
71
|
// the persona, the task protocol and the pinned skills, supercode's own (board/worker-prompt.mjs)
|
|
72
|
-
|
|
72
|
+
// the board workflow's own words for this run (its status's prompt and doors), after the task protocol
|
|
73
|
+
const instructions = [userHomePersona(profile), taskProtocol(taskId), env.SUPERCODE_BOARD_PROMPT ?? '', pinnedSkills(profile.dir, args.skills)].filter(Boolean).join('\n\n');
|
|
73
74
|
|
|
74
75
|
const client = new SupercodeHarnessClient(env.SUPERCODE_BIN ? { command: env.SUPERCODE_BIN } : {});
|
|
75
76
|
try {
|
package/loop.mjs
CHANGED
|
@@ -86,7 +86,7 @@ export class Orchestrator {
|
|
|
86
86
|
if (this.autoTick) { this.timer = setInterval(() => { this.tick().catch(() => undefined); }, TICK_MS); this.timer.unref?.(); }
|
|
87
87
|
// the home's board, dispatched as a Hermes gateway dispatches it (docs/plans/hermes-compat.md row 14)
|
|
88
88
|
if (this.autoTick) this.board = await startBoard({
|
|
89
|
-
root: this.state.root,
|
|
89
|
+
root: this.state.root, orchestration: this.state, log: this.log, adapters: this.adapters,
|
|
90
90
|
// a board notice as a turn in the subscribed chat's own session (Hermes's wake), sent as the subscriber
|
|
91
91
|
wake: (sub, text) => this.dispatchLogged(events.inbound({
|
|
92
92
|
profile: sub.notifier_profile || 'default', key: { platform: sub.platform, kind: sub.chat_type || 'dm', chat_id: sub.chat_id, ...(sub.thread_id ? { thread_id: sub.thread_id } : {}) },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.30",
|
|
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": {
|