@worca/app 1.1.1 → 1.2.0-rc.2

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/src/core/db.mjs CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { createRequire } from 'node:module';
20
20
  import { mkdirSync, existsSync } from 'node:fs';
21
- import { join } from 'node:path';
21
+ import { join, dirname } from 'node:path';
22
22
  import { worcaHome } from './projects.mjs';
23
23
  import { maybeMigrateFromFs } from './migrate-fs-to-db.mjs';
24
24
  import { SEED_TEMPLATES, NODE_ID_MAP, FB_WIRE_MAP } from './graph/seed-templates.mjs';
@@ -54,7 +54,7 @@ const OPEN_BACKOFF_MS = 15;
54
54
  /** Latest schema version. Bump + append a new migration step when the DDL grows.
55
55
  * Exported so migration tests assert "reached the module's current version"
56
56
  * instead of hardcoding the number — a schema bump then touches no test file. */
57
- export const SCHEMA_VERSION = 25;
57
+ export const SCHEMA_VERSION = 27;
58
58
 
59
59
  /** Absolute path to the database file: <worcaHome>/worca-cc.db. */
60
60
  export function dbPath() {
@@ -103,15 +103,22 @@ function _openConfiguredMigrated() {
103
103
  }
104
104
 
105
105
  /**
106
- * True when err is a transient SQLite lock/busy that retrying can clear. Prefers the
107
- * structured errcode (5 = SQLITE_BUSY, 6 = SQLITE_LOCKED) and falls back to the message
108
- * so a lock is still caught on any node:sqlite build that doesn't populate errcode. A
109
- * false positive only costs a bounded retry that still re-throws the original error.
106
+ * True when err is a transient SQLite error that retrying the open can clear. Prefers
107
+ * the structured errcode 5 = SQLITE_BUSY, 6 = SQLITE_LOCKED, and primary code 10 =
108
+ * SQLITE_IOERR (extended codes carry it in the low byte) and falls back to the
109
+ * message so a lock is still caught on any node:sqlite build that doesn't populate
110
+ * errcode. IOERR is here for the first-launch race on Windows: while one process
111
+ * performs the journal_mode=WAL switch, a competitor opening the same file can get
112
+ * "disk I/O error" from the -wal/-shm files being created and unlinked under it
113
+ * (seen on the Windows 11 VM with 12 concurrent openers). A persistent I/O error
114
+ * still surfaces: the retry is bounded and re-throws the original error. A false
115
+ * positive only costs that bounded retry.
110
116
  */
111
117
  function _isBusyError(err) {
112
118
  if (err && (err.errcode === 5 || err.errcode === 6)) return true;
119
+ if (err && Number.isInteger(err.errcode) && (err.errcode & 0xff) === 10) return true;
113
120
  const msg = err && err.message ? err.message : String(err);
114
- return /locked|busy/i.test(msg);
121
+ return /locked|busy|disk I\/O error/i.test(msg);
115
122
  }
116
123
 
117
124
  /** Synchronous sleep (node:sqlite is sync; we must block this thread, not yield it). */
@@ -745,6 +752,8 @@ const INCREMENTAL_COLUMNS = {
745
752
  workflows: { domain: 'TEXT', origin: 'TEXT', graph: 'TEXT', archived_at: 'TEXT' },
746
753
  config_workflow_nodes: { ask_questions: 'INTEGER', subagent_model: 'TEXT' }, // v25: sub-agent model policy
747
754
  ask_run_links: { comment_ids: 'TEXT' }, // v22: JSON array of dc_ ids pending at launch
755
+ ask_attachments: { kind: "TEXT NOT NULL DEFAULT 'text'", // v27: text | image | binary (#398)
756
+ mime: 'TEXT' }, // v27: sniffed mime; NULL on pre-v27 rows (= text)
748
757
  };
749
758
 
750
759
  /** v23: per-loop-wire cycle budgets, the graph-engine twin of
@@ -1080,6 +1089,77 @@ function applySchemaV25(db) {
1080
1089
  repairSchemaGaps(db, schemaGaps(db));
1081
1090
  }
1082
1091
 
1092
+ /** v26 (Fable 5.1 replaces Fable 5 in PREDEFINED_MODELS): a pin left on the
1093
+ * retired id would render as "(default model)" in every picker — its option is
1094
+ * gone — and be rejected on the next write (config.mjs `unknown model
1095
+ * "claude-fable-5"`), while the run itself kept passing the old id to
1096
+ * `claude --model`. So every stored pin moves to the successor: the
1097
+ * config_workflow_nodes.model column, the per-role project_config.steps JSON,
1098
+ * and node defaults inside workflows.graph (nodes[].config.model is the shape
1099
+ * workflows.mjs validates; a bare nodes[].model is covered too). Ids match
1100
+ * case-insensitively, as config.mjs compares them. History (pipelines,
1101
+ * sub_agents.run_model, ask_*) records what actually ran and is left alone.
1102
+ * A one-word rename is reversible, so unlike V24 it takes no backup. JSON
1103
+ * that does not parse is left exactly as found — a broken row must not take
1104
+ * the ladder down. */
1105
+ const V26_MODEL_RENAMES = [['claude-fable-5', 'claude-fable-5-1']];
1106
+
1107
+ function applySchemaV26(db) {
1108
+ for (const [from, to] of V26_MODEL_RENAMES) renameStoredModelPins(db, from, to);
1109
+ }
1110
+
1111
+ /** v27 (Ask Worca binary attachments, #398): ask_attachments.kind/mime — plain
1112
+ * additive columns declared in INCREMENTAL_COLUMNS, applySchemaV25's shape: this
1113
+ * repairSchemaGaps call is what CREATES them on the ladder path (a DB stamped
1114
+ * exactly 26), reconcileSchema covers the fast path. Existing rows keep the
1115
+ * column DEFAULT 'text', which is exactly what every pre-v27 attachment is. */
1116
+ function applySchemaV27(db) {
1117
+ repairSchemaGaps(db, schemaGaps(db));
1118
+ }
1119
+
1120
+ /** Move every stored pin on model id `from` (lower-case) to `to`. Each table
1121
+ * is guarded like V24's: hand-seeded upgrade fixtures (and a DB from before the
1122
+ * fs->db import) reach this step without some of them. */
1123
+ function renameStoredModelPins(db, from, to) {
1124
+ const hasTable = (t) => hasSqliteTable(db, t);
1125
+ const isFrom = (v) => typeof v === 'string' && v.trim().toLowerCase() === from;
1126
+ const renameIn = (sel) => {
1127
+ if (!sel || typeof sel !== 'object' || !isFrom(sel.model)) return false;
1128
+ sel.model = to;
1129
+ return true;
1130
+ };
1131
+ if (hasTable('config_workflow_nodes')) {
1132
+ db.prepare('UPDATE config_workflow_nodes SET model = ? WHERE lower(trim(model)) = ?').run(to, from);
1133
+ }
1134
+
1135
+ const like = `%${from}%`; // cheap pre-filter; the JSON walk below decides
1136
+ const setSteps = hasTable('project_config') && db.prepare('UPDATE project_config SET steps = ? WHERE project_key = ?');
1137
+ for (const row of setSteps ? db.prepare('SELECT project_key, steps FROM project_config WHERE steps LIKE ?').all(like) : []) {
1138
+ let steps;
1139
+ try { steps = JSON.parse(row.steps); } catch { continue; }
1140
+ if (!steps || typeof steps !== 'object' || Array.isArray(steps)) continue;
1141
+ let changed = false;
1142
+ for (const sel of Object.values(steps)) changed = renameIn(sel) || changed;
1143
+ if (changed) setSteps.run(JSON.stringify(steps), row.project_key);
1144
+ }
1145
+
1146
+ const hasGraphColumn = () => db.prepare('PRAGMA table_info(workflows)').all().some((c) => c.name === 'graph');
1147
+ const setGraph = hasTable('workflows') && hasGraphColumn()
1148
+ && db.prepare('UPDATE workflows SET graph = ? WHERE id = ?');
1149
+ for (const row of setGraph ? db.prepare('SELECT id, graph FROM workflows WHERE graph LIKE ?').all(like) : []) {
1150
+ let graph;
1151
+ try { graph = JSON.parse(row.graph); } catch { continue; }
1152
+ if (!graph || typeof graph !== 'object' || !Array.isArray(graph.nodes)) continue;
1153
+ let changed = false;
1154
+ for (const node of graph.nodes) {
1155
+ if (!node || typeof node !== 'object') continue;
1156
+ changed = renameIn(node.config) || changed;
1157
+ changed = renameIn(node) || changed;
1158
+ }
1159
+ if (changed) setGraph.run(JSON.stringify(graph), row.id);
1160
+ }
1161
+ }
1162
+
1083
1163
  /** Audit channel for V24 (dev convention: one console.warn per decision). */
1084
1164
  const auditV24 = (msg) => console.warn(`[worca] V24: ${msg}`);
1085
1165
 
@@ -1111,7 +1191,8 @@ function usableBackup(bak) {
1111
1191
  }
1112
1192
 
1113
1193
  /**
1114
- * V24 is the ONLY ladder step that rewrites user data, so an existing DB is
1194
+ * V24 is the only ladder step that rewrites user data destructively (V26 renames
1195
+ * one model id, reversibly), so an existing DB is
1115
1196
  * snapshotted BEFORE the transaction opens (`VACUUM INTO` cannot run inside one
1116
1197
  * — measured: "cannot VACUUM from within a transaction"). Skipped for a fresh
1117
1198
  * file (nothing to lose) and for `:memory:` (PRAGMA database_list gives file '').
@@ -1141,7 +1222,7 @@ function backupBeforeV24(db) {
1141
1222
  throw new Error(`worca cannot take the pre-v24 database backup at ${bak}: `
1142
1223
  + `${err && err.message ? err.message : err}. The v2 upgrade rewrites saved `
1143
1224
  + 'pipelines, so it refuses to run without one — free disk space or make '
1144
- + `${file.replace(/\/[^/]*$/, '')} writable and start worca again.`, { cause: err });
1225
+ + `${dirname(file)} writable and start worca again.`, { cause: err });
1145
1226
  }
1146
1227
  }
1147
1228
 
@@ -1417,6 +1498,8 @@ export function migrate(db) {
1417
1498
  if (current < 23) applySchemaV23(db); // graph columns + config_workflow_wires
1418
1499
  if (current < 24) applySchemaV24(db, { existing: current >= 1 }); // the v2 break
1419
1500
  if (current < 25) applySchemaV25(db); // sub-agent model policy + recorded child model
1501
+ if (current < 26) applySchemaV26(db); // Fable 5 pins -> Fable 5.1 (catalog swap)
1502
+ if (current < 27) applySchemaV27(db); // ask_attachments.kind/mime (#398)
1420
1503
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
1421
1504
  db.exec('COMMIT');
1422
1505
  } catch (err) {
@@ -0,0 +1,201 @@
1
+ // failure-policy.mjs — the ONE place that decides what a failure does to a run.
2
+ //
3
+ // Every site in the engine that can see a failure — the per-node retry loop, the
4
+ // flow-card dispatcher, the budget gate, run()/resume()'s setup and shell catch
5
+ // blocks — asks `resolveFailure()` for a VERDICT and then enacts it with its own
6
+ // mechanics (throwing the pause sentinel through the scheduler, picking a resume
7
+ // point, stamping a setup replay). No site makes the decision itself, so shifting
8
+ // a case from "terminal error" to "pause" (or back) is a one-cell edit in
9
+ // FAILURE_POLICY below plus its row in test/failure-policy.test.mjs.
10
+ //
11
+ // Inputs (all plain values — this module is pure and imports nothing):
12
+ // site where the failure surfaced (SITES)
13
+ // cls classifyError()'s class, or null for an unclassified error; the
14
+ // budget gate passes its cost code
15
+ // auto --yes / headless (true) or interactive (false)
16
+ // attempt 1-based attempt number, for bounded retries
17
+ // answer the recovery prompt's answer once the user gave one ('retry'|'giveup')
18
+ //
19
+ // Verdicts:
20
+ // { outcome: 'retry' } try again (the site backs off)
21
+ // { outcome: 'prompt', options: [...] } ask the user (interactive only)
22
+ // { outcome: 'pause', reason: ReasonCode } park the run, resumable
23
+ // { outcome: 'error' } end the run as a terminal error
24
+ //
25
+ // Control-flow signals — a PauseError, an AbortError, a pause already requested,
26
+ // the user's Stop — are NOT failures and never reach this table; every site guards
27
+ // for them first. Stop is user-only and always ends the run as 'stopped'.
28
+ //
29
+ // A verdict is issued ONCE. When a site enacts 'error' it marks the error
30
+ // terminal (markTerminal) so every enclosing catch — the flow dispatcher, the
31
+ // shell — enacts that same verdict instead of re-deciding at its own site.
32
+
33
+ /** Where a failure can surface. */
34
+ export const SITES = Object.freeze(['node', 'flow', 'budget', 'setup', 'launch', 'shell', 'resume']);
35
+
36
+ /** Machine-readable pause reasons. The human text rides `pauseDetail`. */
37
+ export const REASON = Object.freeze({
38
+ USAGE_LIMIT: 'usage_limit', // a session/usage cap that clears after a multi-hour reset
39
+ RECOVERABLE: 'recoverable', // a classified (auth/quota/rate_limit/network) error the run could not outwait
40
+ ERROR: 'error', // a failure that would otherwise have ended the run
41
+ COST_PIPELINE: 'cost_pipeline', // the per-pipeline cost cap
42
+ COST_TOTAL: 'cost_total', // the total (weekly/monthly) cost cap
43
+ });
44
+ export const REASON_CODES = Object.freeze(Object.values(REASON));
45
+
46
+ /** Max auto-mode retries for a recoverable error before the row's `then` verdict. */
47
+ export const RECOVERY_MAX_AUTO_ATTEMPTS = (() => {
48
+ const n = Number(process.env.WORCA_RECOVERY_MAX_ATTEMPTS);
49
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 3;
50
+ })();
51
+
52
+ // ── verdict constructors ──────────────────────────────────────────────────────
53
+ const pause = (reason) => Object.freeze({ outcome: 'pause', reason });
54
+ const error = () => Object.freeze({ outcome: 'error' });
55
+ /** Bounded retries (auto mode): `max` retries, then the `then` verdict. */
56
+ const retry = (max, then) => Object.freeze({ outcome: 'retry', max, then });
57
+ /** Interactive prompt: Retry re-runs in place; the give-up option enacts `giveUp`. */
58
+ const prompt = (giveUp) => Object.freeze({ outcome: 'prompt', giveUp });
59
+
60
+ /** One matrix cell: the verdict per run mode. */
61
+ const cell = (auto, interactive) => Object.freeze({ auto, interactive });
62
+ /** The same verdict in both modes. */
63
+ const both = (v) => cell(v, v);
64
+
65
+ // ── THE MATRIX ────────────────────────────────────────────────────────────────
66
+ // Rows are keyed by site, then by error class; '*' is the row for any class the
67
+ // site has no specific row for. Edit a cell to shift a case.
68
+ //
69
+ // A note on the two contested rows (PR #415 vs. the policy PR #412 shipped):
70
+ // node/'*' an UNCLASSIFIED error (often a genuine bug) pauses instead of ending
71
+ // the run; resume retries the node in place. Flip to error() to
72
+ // restore "a bug ends the run".
73
+ // node/auth… an interactive recovery prompt's give-up option PAUSES (no Abort
74
+ // verdict). Flip prompt(error()) to offer Abort as a terminal error.
75
+ export const FAILURE_POLICY = Object.freeze({
76
+ node: Object.freeze({
77
+ usage_limit: both(pause(REASON.USAGE_LIMIT)),
78
+ // auth/quota are user-fixable but never time-fixable — a 1s/2s/4s backoff
79
+ // cannot re-login or top up a balance — so auto mode pauses on the first hit.
80
+ // A self-parked auto run pauses as RECOVERABLE (the class is kept: "resume when
81
+ // it clears"); a user who gives up on the prompt pauses as ERROR (a verdict).
82
+ auth: cell(pause(REASON.RECOVERABLE), prompt(pause(REASON.ERROR))),
83
+ quota: cell(pause(REASON.RECOVERABLE), prompt(pause(REASON.ERROR))),
84
+ rate_limit: cell(retry(RECOVERY_MAX_AUTO_ATTEMPTS, pause(REASON.RECOVERABLE)), prompt(pause(REASON.ERROR))),
85
+ network: cell(retry(RECOVERY_MAX_AUTO_ATTEMPTS, pause(REASON.RECOVERABLE)), prompt(pause(REASON.ERROR))),
86
+ '*': both(pause(REASON.ERROR)),
87
+ }),
88
+ // A flow card, the questions loop, _afterExecution, a composite shell mode, an
89
+ // allocation failure — engine-side throws around an execution.
90
+ flow: Object.freeze({ '*': both(pause(REASON.ERROR)) }),
91
+ // The step-boundary budget gate (not an error: a cap was reached).
92
+ budget: Object.freeze({
93
+ cost_pipeline: both(pause(REASON.COST_PIPELINE)),
94
+ cost_total: both(pause(REASON.COST_TOTAL)),
95
+ }),
96
+ // run()'s setup — checkout, graph build, skills gate — failed with the pipeline
97
+ // row already created. A pause here stamps `setupIncomplete`; resume replays it.
98
+ setup: Object.freeze({ '*': both(pause(REASON.ERROR)) }),
99
+ // Before the pipeline row exists (topology, preflight, tool detection) there is
100
+ // nothing to resume into: a launch error is the only enactable verdict.
101
+ launch: Object.freeze({ '*': both(error()) }),
102
+ // Anything that escaped the engine after setup (a scheduler throw, a persist
103
+ // failure, a bookkeeping bug).
104
+ shell: Object.freeze({ '*': both(pause(REASON.ERROR)) }),
105
+ // resume() could not REHYDRATE the paused run — the checkout is gone, run.json
106
+ // is corrupt, a guardrail set or agent prompt no longer loads. The point on disk
107
+ // is already the best the run can offer: parking it again would re-persist the
108
+ // same point (and re-notify the task source) on every attempt, forever. A
109
+ // structurally unrecoverable resume ends the run.
110
+ resume: Object.freeze({ '*': both(error()) }),
111
+ });
112
+
113
+ /** The recovery prompt's options, derived from the row: what Retry does is fixed;
114
+ * the give-up option's id is the wire `decision` value and names its verdict. */
115
+ export function promptOptions(giveUp) {
116
+ const giveUpId = giveUp.outcome === 'pause' ? 'pause' : 'abort';
117
+ return Object.freeze([
118
+ Object.freeze({ id: 'retry', label: 'Retry' }),
119
+ giveUpId === 'pause'
120
+ ? Object.freeze({ id: 'pause', label: 'Pause the run (nothing is discarded — resume later)' })
121
+ : Object.freeze({ id: 'abort', label: 'Abort the run' }),
122
+ ]);
123
+ }
124
+
125
+ /** The give-up option of a prompt's options (the CLI/UI/chat render its label and
126
+ * send its id back as the decision). Falls back to the pause option for a prompt
127
+ * payload that predates options. */
128
+ export function giveUpOption(options) {
129
+ const found = Array.isArray(options) ? options.find((o) => o && o.id !== 'retry') : null;
130
+ return found || promptOptions({ outcome: 'pause' })[1];
131
+ }
132
+
133
+ /** A recovery answer's `decision` wire value → the policy answer. 'abort' is the
134
+ * pre-policy wire value (older UI tabs, chat /abort) and means give up too. */
135
+ export function answerFromDecision(decision) {
136
+ return decision === 'retry' ? 'retry' : 'giveup';
137
+ }
138
+
139
+ /**
140
+ * Decide what a failure does. Pure.
141
+ * @param {{site:string, cls?:string|null, auto?:boolean, attempt?:number, answer?:'retry'|'giveup'}} f
142
+ * @returns {{outcome:'retry'|'prompt'|'pause'|'error', reason?:string, options?:readonly object[]}}
143
+ */
144
+ export function resolveFailure({ site, cls = null, auto = false, attempt = 1, answer } = {}) {
145
+ const rows = FAILURE_POLICY[site];
146
+ if (!rows) throw new Error(`failure-policy: unknown site '${site}'`);
147
+ const row = (cls != null && rows[cls]) || rows['*'];
148
+ if (!row) throw new Error(`failure-policy: no row for ${site}/${cls}`);
149
+ let v = auto ? row.auto : row.interactive;
150
+ if (v.outcome === 'retry') {
151
+ v = attempt > v.max ? v.then : { outcome: 'retry' };
152
+ }
153
+ if (v.outcome === 'prompt') {
154
+ if (answer === undefined) return { outcome: 'prompt', options: promptOptions(v.giveUp) };
155
+ v = answer === 'retry' ? { outcome: 'retry' } : v.giveUp;
156
+ }
157
+ return v.outcome === 'pause' ? { outcome: 'pause', reason: v.reason } : { outcome: v.outcome };
158
+ }
159
+
160
+ // ── terminal-verdict stamp ────────────────────────────────────────────────────
161
+ const TERMINAL = Symbol.for('worca.failure.terminal');
162
+ /** Stamp an error whose verdict is 'error' so enclosing sites enact, not re-decide. */
163
+ export function markTerminal(err) {
164
+ if (err && typeof err === 'object') { try { err[TERMINAL] = true; } catch { /* frozen */ } }
165
+ return err;
166
+ }
167
+ export function isTerminal(err) {
168
+ return !!(err && typeof err === 'object' && err[TERMINAL] === true);
169
+ }
170
+
171
+ // ── consequences of a pause, keyed on its reason ──────────────────────────────
172
+ // What each surface does with a parked run is a function of the reason code, not
173
+ // of ad hoc string checks. `null` is a manual pause (the user pressed Pause).
174
+ const CONSEQUENCES = Object.freeze({
175
+ manual: { reportsToSource: false, stagesResults: false, severity: 'info', notifyPref: 'paused', exitInteractive: 0, label: null },
176
+ [REASON.USAGE_LIMIT]: { reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'session/usage limit reached' },
177
+ [REASON.RECOVERABLE]: { reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'recoverable error — resume to retry' },
178
+ [REASON.COST_PIPELINE]:{ reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'pipeline cost limit reached' },
179
+ [REASON.COST_TOTAL]: { reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'total cost limit reached' },
180
+ [REASON.ERROR]: { reportsToSource: true, stagesResults: true, severity: 'error', notifyPref: 'error', exitInteractive: 1, label: 'a step failed' },
181
+ });
182
+
183
+ /** The consequences row for a pause reason (unknown/legacy free-text reasons read
184
+ * as a forced pause with a warning severity — every reasoned pause is forced). */
185
+ export function pauseConsequences(reason) {
186
+ if (reason == null || reason === '') return CONSEQUENCES.manual;
187
+ return CONSEQUENCES[reason] || CONSEQUENCES[REASON.USAGE_LIMIT];
188
+ }
189
+
190
+ /** CLI exit code for a paused run. Under --yes every pause is the run parking
191
+ * ITSELF with nobody left to resume: 3, so a wrapper can tell a resumable pause
192
+ * from a hard error (1) and a usage error (2). Interactive: 0 when the user asked
193
+ * for it or a cap/limit holds the run; 1 when an error forced it. */
194
+ export function pauseExitCode(reason, auto) {
195
+ return auto ? 3 : pauseConsequences(reason).exitInteractive;
196
+ }
197
+
198
+ /** Human label for a reason code, or null for a manual pause / unknown code. */
199
+ export function describePauseReason(reason) {
200
+ return pauseConsequences(reason).label;
201
+ }
@@ -393,6 +393,12 @@ export function createScheduler(opts) {
393
393
  async function runComposite(h) {
394
394
  const portId = h.entry.expandsPort;
395
395
  const expanded = await execute({ ...h.args, composite: 'expand', expandsPort: portId });
396
+ // A pause raised INSIDE the expansion (the adapter converted a throw) settles the
397
+ // shell row here, with its expands binding intact. Falling through to
398
+ // runUnexpanded would strip `expandsPort`/the binding from the very entry the
399
+ // resume re-invokes, so the node would re-run once as a plain execution and the
400
+ // decomposition would never be re-read.
401
+ if (expanded?.paused === true) return { paused: true };
396
402
  const phases = Array.isArray(expanded?.phases) ? expanded.phases : [];
397
403
  if (!phases.length) return runUnexpanded(h, portId);
398
404
 
@@ -444,7 +450,8 @@ export function createScheduler(opts) {
444
450
  const tasks = Array.isArray(ph.tasks) ? ph.tasks : [];
445
451
  const phaseAbort = new AbortController();
446
452
  let firstError = null;
447
- await execute({ ...h.args, composite: 'phase', phase: ph.ordinal, phaseStatus: 'running' });
453
+ const opened = await execute({ ...h.args, composite: 'phase', phase: ph.ordinal, phaseStatus: 'running' });
454
+ if (opened?.paused === true) return { paused: true }; // the phase bookkeeping paused the run: no slice launches
448
455
 
449
456
  const results = await Promise.allSettled(tasks.map((task, index) =>
450
457
  runSlice(h, portId, ph, task, index, phaseAbort).catch((err) => {
@@ -0,0 +1,271 @@
1
+ // src/core/host-guard.mjs
2
+ // The host-process guard. Every agent worca spawns carries this file as a
3
+ // PreToolUse hook on Bash (see buildSettingsPayload in claude-runner.mjs), so
4
+ // no agent — predefined role, custom agent, plugin agent, or their in-process
5
+ // sub-agents — can kill the worca server that runs it.
6
+ //
7
+ // Born of the 2026-08-31 incident: an implementer cleaning up stray test
8
+ // servers ran `ps aux | grep '[n]ode --disable' | awk '{print $2}' | while
9
+ // read p; do kill $p; done` and took down its own host mid-run (the pattern
10
+ // matches the production server argv exactly).
11
+ //
12
+ // Policy — deny when the command:
13
+ // - invokes `pkill` or `killall` (pattern kills, unbounded blast radius);
14
+ // - invokes `kill` with anything but literal numeric PIDs or `%N` jobspecs
15
+ // (variables, substitutions, piped/xargs input, `-PGID` group targets —
16
+ // all of these are how a kill reaches processes nobody named);
17
+ // - names the host PID (WORCA_HOST_PID) as a literal kill target;
18
+ // - wraps a kill in a nested `sh -c` / `bash -c` payload.
19
+ // Everything else is allowed, so an agent can still stop processes it spawned:
20
+ // look PIDs up first (ps is read-only and always allowed), then kill the
21
+ // literal numbers. Forcing literal PIDs is the point — every literal target
22
+ // passes through the host-PID check.
23
+ //
24
+ // The hook CLI is deliberately fail-open on malformed input: a broken payload
25
+ // must not brick every Bash call of every agent. The DECISION is fail-closed.
26
+ import { fileURLToPath, pathToFileURL } from 'node:url';
27
+
28
+ /** ON unless WORCA_HOST_GUARD is "0"/"false" — the one kill-switch for the
29
+ * hook, the WORCA_HOST_PID env var, and the system-prompt preamble alike. */
30
+ export function hostGuardEnabled() {
31
+ const v = process.env.WORCA_HOST_GUARD;
32
+ return !(v === '0' || String(v ?? '').toLowerCase() === 'false');
33
+ }
34
+
35
+ /** The PreToolUse hook entry buildSettingsPayload merges into --settings.
36
+ * Runs THIS file with the server's own node; JSON.stringify double-quotes
37
+ * both paths for the shell. */
38
+ export function hostGuardHookEntry() {
39
+ // Forward slashes on purpose: JSON.stringify would double every Windows
40
+ // backslash and the hook shell's unescaping is unverified there, while
41
+ // CreateProcess and Git Bash both accept forward-slash paths. POSIX node
42
+ // paths never contain backslashes, so the replace is a no-op off Windows.
43
+ const q = (s) => JSON.stringify(String(s).replaceAll('\\', '/'));
44
+ return {
45
+ matcher: 'Bash',
46
+ hooks: [{ type: 'command', command: `${q(process.execPath)} ${q(fileURLToPath(import.meta.url))}` }],
47
+ };
48
+ }
49
+
50
+ /** The system-prompt preamble every real spawn carries (runReal prepends it). */
51
+ export function hostGuardSystemPrompt(pid) {
52
+ return [
53
+ '## Host process protection',
54
+ `You run under the worca app server (PID ${pid}, \`node ui/server.mjs\`). Never kill it, and never kill any process you did not spawn yourself.`,
55
+ 'Pattern kills are forbidden and a PreToolUse hook blocks them on every OS: `pkill`, `killall`, `xargs kill`, `taskkill /IM`, `Stop-Process -Name`, `wmic process … delete`, and any `kill` fed from a pipe, variable, or substitution (e.g. `ps aux | grep … | while read p; do kill $p; done`).',
56
+ 'To stop a process you started: record its PID when you spawn it (or list PIDs with `ps`, which is always allowed), then run `kill <literal pid>`.',
57
+ ].join('\n');
58
+ }
59
+
60
+ /** Command words that may prefix the one we care about. */
61
+ const SKIP_WORDS = new Set([
62
+ 'do', 'then', 'else', 'elif', 'if', 'while', 'until',
63
+ 'exec', 'command', 'builtin', 'nohup', 'time', 'sudo', 'env',
64
+ ]);
65
+
66
+ const NESTED_SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'powershell', 'pwsh', 'cmd']);
67
+
68
+ /** Any killer, POSIX or Windows, appearing anywhere in a nested-shell payload. */
69
+ const KILLER_WORD_RE = /\b(?:p?kill|killall|taskkill|stop-process|spps|wmic)\b/i;
70
+
71
+ /** Killers that xargs / find -exec can hand targets to. */
72
+ const KILLER_CMDS = new Set(['kill', 'pkill', 'killall', 'taskkill']);
73
+ const isKillerToken = (t) => KILLER_CMDS.has(basename(t).toLowerCase().replace(/\.exe$/, ''));
74
+
75
+ /** Replace quoted spans so quoted data ("fix; killall handling") never looks
76
+ * like a command, and substitutions so `kill $(…)` / `kill \`…\`` surface as a
77
+ * non-literal target instead of vanishing into the segment split; the RAW
78
+ * text is still consulted for nested-shell payloads. The `{}` placeholder
79
+ * (xargs -I{}, find -exec … {}) becomes a token BEFORE the split eats the
80
+ * braces — otherwise `xargs -I{} kill {}` splits into an xargs segment with
81
+ * no kill word and a kill segment with no targets, and both pass. */
82
+ function stripQuotes(s) {
83
+ return s
84
+ .replace(/'[^']*'/g, ' __q__ ')
85
+ .replace(/"(?:[^"\\]|\\.)*"/g, ' __q__ ')
86
+ .replace(/\$\([^()]*\)/g, ' __sub__ ')
87
+ .replace(/`[^`]*`/g, ' __sub__ ')
88
+ .replace(/\{\}/g, ' __ph__ ');
89
+ }
90
+
91
+ const basename = (w) => w.slice(w.lastIndexOf('/') + 1);
92
+
93
+ /** Leading env assignments and wrapper words stripped off a token list. */
94
+ function commandWord(tokens) {
95
+ let i = 0;
96
+ while (i < tokens.length && (SKIP_WORDS.has(tokens[i]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]))) i++;
97
+ return { cmd: basename(tokens[i] ?? ''), rest: tokens.slice(i + 1) };
98
+ }
99
+
100
+ /** kill's targets: signal options consumed, redirections ignored. */
101
+ function killTargets(rest) {
102
+ const targets = [];
103
+ let sawSignal = false;
104
+ let afterDashDash = false;
105
+ for (let i = 0; i < rest.length; i++) {
106
+ const t = rest[i];
107
+ if (t.startsWith('#')) break; // trailing comment
108
+ if (/^\d*(?:>>?|<<?|>&|&>>?)$/.test(t)) { i++; continue; } // spaced redirect: skip its operand too
109
+ if (/^\d*[<>]/.test(t) || t.includes('>') || t.includes('<')) continue; // attached redirection
110
+ if (!afterDashDash && t === '--') { afterDashDash = true; continue; }
111
+ if (!afterDashDash && t.startsWith('-')) {
112
+ if (t === '-s' || t === '-n') { i++; sawSignal = true; continue; } // -s TERM / -n 15
113
+ if (!sawSignal && /^-(?:\d+|[A-Za-z]+\d*)$/.test(t)) { sawSignal = true; continue; } // first -9/-TERM
114
+ targets.push(t); // second dash token = a target
115
+ continue;
116
+ }
117
+ targets.push(t);
118
+ }
119
+ return targets;
120
+ }
121
+
122
+ /**
123
+ * The pure decision. `null` = allow; a non-empty string = deny, with the reason
124
+ * the agent will read (hook exit 2 feeds stderr back to the model).
125
+ * @param {string} command the Bash tool's command text
126
+ * @param {number} [hostPid] the protected server PID (WORCA_HOST_PID); pattern
127
+ * bans apply even without it
128
+ * @returns {string|null}
129
+ */
130
+ export function evaluateKillCommand(command, hostPid) {
131
+ const raw = String(command ?? '');
132
+ if (!raw.trim()) return null;
133
+ const pidNote = Number.isFinite(hostPid)
134
+ ? ` The worca app server (PID ${hostPid}) runs this agent and must survive.`
135
+ : ' The worca app server runs this agent and must survive.';
136
+ const advice = ' To stop a process you spawned: list PIDs first (ps is always allowed), then `kill <literal pid>`.';
137
+
138
+ const segments = stripQuotes(raw).split(/(?:\|\||&&|;|\||&|\n|\$\(|`|[(){}])+/);
139
+ for (const seg of segments) {
140
+ const tokens = seg.trim().split(/\s+/).filter(Boolean);
141
+ if (!tokens.length) continue;
142
+ const { cmd: cmdRaw, rest } = commandWord(tokens);
143
+ // Windows commands are case-insensitive and may carry .exe (`TASKKILL`,
144
+ // `taskkill.exe`) — normalize once; POSIX names pass through unchanged.
145
+ const cmd = cmdRaw.toLowerCase().replace(/\.exe$/, '');
146
+
147
+ if (cmd === 'pkill' || cmd === 'killall') {
148
+ return `host guard: blocked \`${cmd}\` — pattern kills are forbidden.${pidNote}${advice}`;
149
+ }
150
+ if (cmd === 'xargs' && rest.some(isKillerToken)) {
151
+ return `host guard: blocked \`xargs kill\` — kill may only take literal numeric PIDs you name yourself.${pidNote}${advice}`;
152
+ }
153
+ // find spawns its -exec/-ok payload itself, so the kill never surfaces as
154
+ // a command word of its own segment; a killer anywhere in find's arguments
155
+ // alongside an -exec-family flag is the same unbounded fan-out as xargs.
156
+ if (cmd === 'find'
157
+ && rest.some((t) => /^-(?:exec|ok)(?:dir)?$/.test(t))
158
+ && rest.some(isKillerToken)) {
159
+ return `host guard: blocked \`find -exec kill\` — kill may only take literal numeric PIDs you name yourself.${pidNote}${advice}`;
160
+ }
161
+ // A nested shell is suspicious only when it carries an INLINE payload
162
+ // (-c / /c / -Command); `bash scripts/kill-dev-server.sh` is a script FILE
163
+ // and stays allowed (deliberate evasion via files is out of the threat
164
+ // model). PowerShell is the exception: its bare argument IS an inline
165
+ // command (`powershell "Stop-Process -Name node"`), so it is always scanned.
166
+ if (NESTED_SHELLS.has(cmd)
167
+ && (cmd === 'powershell' || cmd === 'pwsh' || rest.some((t) => /^(?:-c|\/c|-command|--command)$/i.test(t)))
168
+ && KILLER_WORD_RE.test(raw)) {
169
+ return `host guard: blocked a kill inside a nested \`${cmd}\` invocation — run the kill directly with literal PIDs so it can be checked.${pidNote}${advice}`;
170
+ }
171
+
172
+ // ── Windows-native killers (reachable from Git Bash on Windows) ──────────
173
+ if (cmd === 'taskkill') {
174
+ for (let i = 0; i < rest.length; i++) {
175
+ const flag = rest[i].toLowerCase().replace(/^[/-]+/, '');
176
+ if (flag === 'im') {
177
+ return `host guard: blocked \`taskkill /IM\` — killing by image name is a pattern kill.${pidNote}${advice}`;
178
+ }
179
+ if (flag === 'pid') {
180
+ const target = rest[++i] ?? '';
181
+ if (!/^\d+$/.test(target)) {
182
+ return `host guard: blocked \`taskkill /PID ${target}\` — only literal numeric PIDs are allowed.${pidNote}${advice}`;
183
+ }
184
+ if (Number.isFinite(hostPid) && Number(target) === hostPid) {
185
+ return `host guard: blocked taskkill of PID ${hostPid} — that is the worca app server this agent runs under. Kill only processes you spawned yourself.`;
186
+ }
187
+ }
188
+ }
189
+ continue;
190
+ }
191
+ if (cmd === 'stop-process' || cmd === 'spps') {
192
+ let sawId = false;
193
+ for (let i = 0; i < rest.length; i++) {
194
+ const t = rest[i].toLowerCase();
195
+ if (t === '-name' || t.startsWith('-name:')) {
196
+ return `host guard: blocked \`Stop-Process -Name\` — killing by process name is a pattern kill.${pidNote}${advice}`;
197
+ }
198
+ if (t === '-id' || t.startsWith('-id:')) {
199
+ sawId = true;
200
+ const value = t.includes(':') ? t.split(':')[1] : (rest[++i] ?? '');
201
+ for (const part of value.split(',')) {
202
+ if (!/^\d+$/.test(part)) {
203
+ return `host guard: blocked \`Stop-Process -Id ${part}\` — only literal numeric PIDs are allowed.${pidNote}${advice}`;
204
+ }
205
+ if (Number.isFinite(hostPid) && Number(part) === hostPid) {
206
+ return `host guard: blocked Stop-Process of PID ${hostPid} — that is the worca app server this agent runs under. Kill only processes you spawned yourself.`;
207
+ }
208
+ }
209
+ }
210
+ }
211
+ if (!sawId) {
212
+ return `host guard: blocked \`Stop-Process\` with no literal -Id — pipeline-fed or bare Stop-Process is a pattern kill.${pidNote}${advice}`;
213
+ }
214
+ continue;
215
+ }
216
+ if (cmd === 'wmic') {
217
+ // Checked against the RAW command: a parenthesized WHERE clause splits
218
+ // the segment (`where (name="node.exe") delete`), hiding the verb from
219
+ // this segment's tokens. A kill-verb elsewhere in a compound command can
220
+ // false-positive here — acceptable, the reason explains itself.
221
+ if (/\bprocess\b/i.test(raw) && /\b(?:delete|terminate)\b/i.test(raw)) {
222
+ return `host guard: blocked \`wmic process … delete\` — pattern kills are forbidden.${pidNote}${advice}`;
223
+ }
224
+ continue; // read-only wmic queries stay allowed
225
+ }
226
+
227
+ if (cmd !== 'kill') continue;
228
+
229
+ for (const t of killTargets(rest)) {
230
+ if (/^\d+$/.test(t)) {
231
+ if (Number.isFinite(hostPid) && Number(t) === hostPid) {
232
+ return `host guard: blocked kill of PID ${hostPid} — that is the worca app server this agent runs under. Kill only processes you spawned yourself.`;
233
+ }
234
+ continue;
235
+ }
236
+ if (/^%\d+$/.test(t)) continue; // this shell's own job
237
+ if (t.startsWith('-')) {
238
+ return `host guard: blocked \`kill ${t}\` — process-group/broadcast kills are forbidden.${pidNote}${advice}`;
239
+ }
240
+ return `host guard: blocked \`kill ${t}\` — kill accepts only literal numeric PIDs (no variables, substitutions, or piped input).${pidNote}${advice}`;
241
+ }
242
+ }
243
+ return null;
244
+ }
245
+
246
+ // ── hook CLI ─────────────────────────────────────────────────────────────────
247
+ // stdin: the PreToolUse payload ({ tool_name, tool_input: { command } }).
248
+ // exit 0 = allow; exit 2 = block, stderr is shown to the agent.
249
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
250
+ if (isMain) {
251
+ let raw = '';
252
+ process.stdin.setEncoding('utf8');
253
+ process.stdin.on('data', (d) => { raw += d; });
254
+ process.stdin.on('end', () => {
255
+ let command = '';
256
+ try {
257
+ const payload = JSON.parse(raw);
258
+ if (payload?.tool_name !== 'Bash') process.exit(0);
259
+ command = String(payload?.tool_input?.command ?? '');
260
+ } catch {
261
+ process.exit(0); // fail-open: a malformed payload must not brick Bash
262
+ }
263
+ const pid = Number(process.env.WORCA_HOST_PID);
264
+ const reason = evaluateKillCommand(command, Number.isFinite(pid) && pid > 0 ? pid : undefined);
265
+ if (reason) {
266
+ process.stderr.write(`${reason}\n`);
267
+ process.exit(2);
268
+ }
269
+ process.exit(0);
270
+ });
271
+ }