@opsee/cli 0.11.9

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.
Files changed (85) hide show
  1. package/README.md +1962 -0
  2. package/bin/opsee.js +28 -0
  3. package/package.json +40 -0
  4. package/skills/README.md +3 -0
  5. package/skills/to-issues/SKILL.md +92 -0
  6. package/skills/to-issues/agents/openai.yaml +5 -0
  7. package/skills/to-spec/SKILL.md +79 -0
  8. package/skills/to-spec/agents/openai.yaml +5 -0
  9. package/skills/wayfinder/SKILL.md +138 -0
  10. package/skills/wayfinder/agents/openai.yaml +5 -0
  11. package/src/args.ts +676 -0
  12. package/src/cli.ts +341 -0
  13. package/src/commands/account.ts +121 -0
  14. package/src/commands/deps.ts +11 -0
  15. package/src/commands/foreman-control.ts +242 -0
  16. package/src/commands/foreman-debug.ts +131 -0
  17. package/src/commands/foreman-plan.ts +213 -0
  18. package/src/commands/foreman-service.ts +186 -0
  19. package/src/commands/foreman-up.ts +165 -0
  20. package/src/commands/foreman-views.ts +398 -0
  21. package/src/commands/foreman.ts +465 -0
  22. package/src/commands/init.ts +176 -0
  23. package/src/commands/initiative.ts +192 -0
  24. package/src/commands/login.ts +24 -0
  25. package/src/commands/whoami.ts +15 -0
  26. package/src/foreman/account-store.ts +96 -0
  27. package/src/foreman/account.ts +474 -0
  28. package/src/foreman/claude-worker-adapter.ts +412 -0
  29. package/src/foreman/codex-worker-adapter.ts +472 -0
  30. package/src/foreman/completion-report.ts +153 -0
  31. package/src/foreman/core/context.ts +169 -0
  32. package/src/foreman/core/defects.ts +280 -0
  33. package/src/foreman/core/exec.ts +20 -0
  34. package/src/foreman/core/gates.ts +493 -0
  35. package/src/foreman/core/handoff.ts +163 -0
  36. package/src/foreman/core/install.ts +109 -0
  37. package/src/foreman/core/learnings.ts +368 -0
  38. package/src/foreman/core/outbox-tracker.ts +192 -0
  39. package/src/foreman/core/pin.ts +226 -0
  40. package/src/foreman/core/plan-context.ts +238 -0
  41. package/src/foreman/core/process-table.ts +535 -0
  42. package/src/foreman/core/reconcile.ts +227 -0
  43. package/src/foreman/core/report.ts +60 -0
  44. package/src/foreman/core/run.ts +2836 -0
  45. package/src/foreman/core/scheduler.ts +244 -0
  46. package/src/foreman/core/summary.ts +166 -0
  47. package/src/foreman/core/text.ts +97 -0
  48. package/src/foreman/core/transcripts.ts +38 -0
  49. package/src/foreman/core/triage.ts +138 -0
  50. package/src/foreman/core/verifier.ts +800 -0
  51. package/src/foreman/core/views.ts +940 -0
  52. package/src/foreman/core/work-contract.ts +152 -0
  53. package/src/foreman/core/workspace.ts +335 -0
  54. package/src/foreman/fake-handoff.ts +33 -0
  55. package/src/foreman/fake-learnings.ts +26 -0
  56. package/src/foreman/fake-remote-api.ts +70 -0
  57. package/src/foreman/fake-tracker-adapter.ts +355 -0
  58. package/src/foreman/fake-worker-adapter.ts +221 -0
  59. package/src/foreman/host.ts +75 -0
  60. package/src/foreman/local-dir.ts +28 -0
  61. package/src/foreman/opsee-tracker-adapter.ts +612 -0
  62. package/src/foreman/process-group.ts +160 -0
  63. package/src/foreman/remote-api.ts +283 -0
  64. package/src/foreman/run-recipe.ts +274 -0
  65. package/src/foreman/service-unit.ts +257 -0
  66. package/src/foreman/tracker-adapter.ts +298 -0
  67. package/src/foreman/triage-draft.ts +40 -0
  68. package/src/foreman/vendor.ts +23 -0
  69. package/src/foreman/verdict.ts +120 -0
  70. package/src/foreman/worker-adapter.ts +177 -0
  71. package/src/foreman/worker-process.ts +488 -0
  72. package/src/identity.ts +49 -0
  73. package/src/index.ts +3 -0
  74. package/src/init/managed.ts +84 -0
  75. package/src/init/mcp-config.ts +77 -0
  76. package/src/init/paths.ts +16 -0
  77. package/src/init/pointer-block.ts +45 -0
  78. package/src/init/project.ts +22 -0
  79. package/src/init/prompt.ts +45 -0
  80. package/src/init/run-recipe-config.ts +133 -0
  81. package/src/init/skills.ts +38 -0
  82. package/src/init/text.ts +22 -0
  83. package/src/init/tracker-doc.ts +106 -0
  84. package/src/opsee-config.ts +116 -0
  85. package/templates/issue-tracker.md +162 -0
@@ -0,0 +1,535 @@
1
+ /**
2
+ * The Process Table (see ../../../CONTEXT.md; ADR-0009): the Foreman's local record of live
3
+ * Workers, OS process, vendor session id, Account, last output time, kept in SQLite on the machine
4
+ * that runs them, with an outbox for Run Record writes that could not reach Opsee. Nothing here is
5
+ * a fact about the work: those go to the Run Record first (ADR-0009), and deleting this file loses
6
+ * nothing about a Run except the ability to reattach to processes that are already gone (story 52).
7
+ *
8
+ * Three tables:
9
+ *
10
+ * - `workers`: one row per Worker the Foreman has running or believes it has (a crash leaves rows
11
+ * behind; Reconcile is what settles them). `session_id` is written the moment the adapter's
12
+ * `started` event arrives, before any further output, so a crash right after launch is resumable.
13
+ * - `outbox`: Run Record event batches that could not be appended, in id order, which is the order
14
+ * they must reach Opsee in. `drain` stops at the first failure so order is never broken.
15
+ * - `runs`: Run requests `foreman run` hands the daemon (story 13); the daemon serves the oldest
16
+ * pending one per tick, skipping those of a paused Initiative, and puts a request back to
17
+ * pending when its Initiative is paused mid-Run. One row per request, whatever the Slots the Run
18
+ * it starts then fills.
19
+ * - `pauses`: the Initiatives whose Run a human has paused (story 55): no new dispatch until
20
+ * resumed, in-flight Workers finish. One row per paused Initiative.
21
+ *
22
+ * A Worker row also carries a `control` mark, the machine-local side of the human's steering
23
+ * (stories 53-56; the Run Record gets a `control` event): `attached` while a human has the session
24
+ * as an attended turn (Reconcile leaves the row alone), `released` once handed back (the next tick
25
+ * resumes it unattended), `cancelled` once `foreman cancel` stopped it (whoever owns the turn, or
26
+ * the next tick once the pid is dead, closes the attempt). The command that sets the mark is the
27
+ * one that stops the process and clears the pid it stopped; the process that owns the turn reads
28
+ * the mark when the stream ends and settles accordingly, and Reconcile closes a cancelled row only
29
+ * when nothing is alive to own it, so the attempt is recorded once.
30
+ *
31
+ * The driver is `node:sqlite`, built into the Node the CLI runs on (see the decision on Initiative
32
+ * 17): no native build, no dependency. One connection per process; the daemon and a `foreman run`
33
+ * from another terminal share the file through SQLite's own locking, and every statement here is
34
+ * short.
35
+ */
36
+ import { chmodSync, mkdirSync } from "node:fs";
37
+ import { dirname } from "node:path";
38
+ import { DatabaseSync } from "node:sqlite";
39
+ import { fromJson, toJson, type JsonValue } from "@bufbuild/protobuf";
40
+ import { RunEventInputSchema, type RunEventInput } from "@opsee/mcp-server/gen/api/v1/initiative_pb.js";
41
+ import type { Vendor } from "../vendor.js";
42
+
43
+ /** The human's steering mark on a Worker row (see the module comment). */
44
+ export type WorkerControl = "attached" | "released" | "cancelled";
45
+
46
+ export interface WorkerRow {
47
+ taskId: number;
48
+ initiativeId: number;
49
+ /** Human identifier (OPS-267), for the transcript file name and the log. */
50
+ identifier: string;
51
+ account: string;
52
+ vendor: Vendor;
53
+ /** Unset until the adapter's `started` event; a row without one cannot be resumed, only restarted. */
54
+ sessionId?: string;
55
+ /** The Worker's OS pid, when the adapter exposes one; unset for adapters that do not (the fake).
56
+ * During a Verifier round it is the Verifier turn's pid (core/verifier.ts). */
57
+ pid?: number;
58
+ /** The pid (and, spawned detached, the process group) of the app a Verifier round has running
59
+ * for this Task; unset outside a round. `foreman cancel` stops it with the Worker, and
60
+ * Reconcile stops one a dead Foreman left running. */
61
+ appPid?: number;
62
+ workspace: string;
63
+ branch: string;
64
+ /** Epoch milliseconds. */
65
+ startedAt: number;
66
+ lastOutputAt: number;
67
+ attempt: number;
68
+ /** Set by `foreman attach`, `release` and `cancel`; unset for a Worker nobody has steered. */
69
+ control?: WorkerControl;
70
+ /** Epoch milliseconds of the last control change. */
71
+ controlAt?: number;
72
+ }
73
+
74
+ export interface OutboxRow {
75
+ id: number;
76
+ initiativeId: number;
77
+ events: RunEventInput[];
78
+ createdAt: number;
79
+ /** How many drains have failed on this batch. */
80
+ attempts: number;
81
+ }
82
+
83
+ export type RunRequestStatus = "pending" | "running" | "done" | "failed";
84
+
85
+ export interface RunRequestRow {
86
+ id: number;
87
+ initiativeId: number;
88
+ account?: string;
89
+ once: boolean;
90
+ taskId?: number;
91
+ /** The checkout the request was made from; the daemon serves only its own. */
92
+ repoRoot?: string;
93
+ maxTurns?: number;
94
+ stallTimeoutMs?: number;
95
+ requestedAt: number;
96
+ startedAt?: number;
97
+ finishedAt?: number;
98
+ status: RunRequestStatus;
99
+ error?: string;
100
+ }
101
+
102
+ export interface NewRunRequest {
103
+ initiativeId: number;
104
+ account?: string;
105
+ once?: boolean;
106
+ taskId?: number;
107
+ repoRoot?: string;
108
+ maxTurns?: number;
109
+ stallTimeoutMs?: number;
110
+ }
111
+
112
+ /** The seam the Run loop and Reconcile use; `ProcessTable` is the SQLite implementation. */
113
+ export interface ProcessTableApi {
114
+ upsertWorker(row: WorkerRow): void;
115
+ /** Records the vendor session id (and pid) the moment `started` arrives. */
116
+ setSession(taskId: number, sessionId: string, pid?: number): void;
117
+ /** Records the OS pid the moment the launch returns one, before the vendor has said anything:
118
+ * a row with a pid and no session is a Worker that may still be running. */
119
+ setPid(taskId: number, pid: number): void;
120
+ /** Forgets the pid once the process it named is known to be gone (`foreman attach` and `cancel`
121
+ * stopped it), so a later check never mistakes a recycled pid for the Worker. */
122
+ clearPid(taskId: number): void;
123
+ /** Records the pid of the app a Verifier round started for the Task, or forgets it (undefined)
124
+ * once the app is stopped. */
125
+ setAppPid(taskId: number, pid: number | undefined): void;
126
+ /** Bumps `lastOutputAt`; callers throttle. */
127
+ touch(taskId: number, at: number): void;
128
+ removeWorker(taskId: number): void;
129
+ liveWorkers(filter?: { initiativeId?: number; account?: string }): WorkerRow[];
130
+ worker(taskId: number): WorkerRow | undefined;
131
+ /** Sets or clears (undefined) the control mark on a row; false when there is no such row. */
132
+ setControl(taskId: number, control: WorkerControl | undefined, at: number): boolean;
133
+ /** Whether a human has paused new dispatch for the Initiative (story 55). */
134
+ isPaused(initiativeId: number): boolean;
135
+ enqueue(initiativeId: number, events: RunEventInput[]): number;
136
+ pending(filter?: { initiativeId?: number }): OutboxRow[];
137
+ /** Sends pending batches in id order through `sender`; stops at the first failure (the batch
138
+ * stays, its attempt count grows) and reports how many went and what stopped it. A failure
139
+ * `discard` accepts (the backend refused the batch and would refuse it forever) drops the batch
140
+ * instead, so one dead batch does not hold every later one; `discarded` counts those. */
141
+ drain(sender: (initiativeId: number, events: RunEventInput[]) => Promise<unknown>, options?: DrainOptions): Promise<DrainResult>;
142
+ }
143
+
144
+ export interface DrainOptions {
145
+ initiativeId?: number;
146
+ discard?: (error: unknown) => boolean;
147
+ }
148
+
149
+ export interface DrainResult {
150
+ sent: number;
151
+ remaining: number;
152
+ /** Batches dropped because the backend refused them; each is logged by the caller. */
153
+ discarded: OutboxRow[];
154
+ /** The error that stopped the drain, when one did. */
155
+ error?: Error;
156
+ }
157
+
158
+ const SCHEMA = `
159
+ CREATE TABLE IF NOT EXISTS workers (
160
+ task_id INTEGER PRIMARY KEY,
161
+ initiative_id INTEGER NOT NULL,
162
+ identifier TEXT NOT NULL,
163
+ account TEXT NOT NULL,
164
+ vendor TEXT NOT NULL,
165
+ session_id TEXT,
166
+ pid INTEGER,
167
+ workspace TEXT NOT NULL,
168
+ branch TEXT NOT NULL,
169
+ started_at INTEGER NOT NULL,
170
+ last_output_at INTEGER NOT NULL,
171
+ attempt INTEGER NOT NULL,
172
+ settled_at INTEGER
173
+ );
174
+ CREATE TABLE IF NOT EXISTS outbox (
175
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
176
+ initiative_id INTEGER NOT NULL,
177
+ events_json TEXT NOT NULL,
178
+ created_at INTEGER NOT NULL,
179
+ attempts INTEGER NOT NULL DEFAULT 0
180
+ );
181
+ CREATE TABLE IF NOT EXISTS runs (
182
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
183
+ initiative_id INTEGER NOT NULL,
184
+ account TEXT,
185
+ once INTEGER NOT NULL DEFAULT 0,
186
+ task_id INTEGER,
187
+ repo_root TEXT,
188
+ max_turns INTEGER,
189
+ stall_timeout_ms INTEGER,
190
+ requested_at INTEGER NOT NULL,
191
+ started_at INTEGER,
192
+ finished_at INTEGER,
193
+ status TEXT NOT NULL DEFAULT 'pending',
194
+ error TEXT
195
+ );
196
+ CREATE TABLE IF NOT EXISTS pauses (
197
+ initiative_id INTEGER PRIMARY KEY,
198
+ paused_at INTEGER NOT NULL
199
+ );
200
+ `;
201
+
202
+ /** Columns added after the first release; a table created before them gets them on open. */
203
+ const WORKER_COLUMNS_ADDED: ReadonlyArray<{ name: string; ddl: string }> = [
204
+ { name: "control", ddl: "control TEXT" },
205
+ { name: "control_at", ddl: "control_at INTEGER" },
206
+ { name: "app_pid", ddl: "app_pid INTEGER" },
207
+ ];
208
+
209
+ type Row = Record<string, string | number | bigint | null | Uint8Array>;
210
+
211
+ const num = (v: Row[string]): number => Number(v);
212
+ const opt = <T>(v: Row[string], map: (x: Exclude<Row[string], null>) => T): T | undefined => (v === null || v === undefined ? undefined : map(v));
213
+
214
+ function workerOf(r: Row): WorkerRow {
215
+ return {
216
+ taskId: num(r.task_id),
217
+ initiativeId: num(r.initiative_id),
218
+ identifier: String(r.identifier),
219
+ account: String(r.account),
220
+ vendor: String(r.vendor) as Vendor,
221
+ sessionId: opt(r.session_id, String),
222
+ pid: opt(r.pid, num),
223
+ appPid: opt(r.app_pid, num),
224
+ workspace: String(r.workspace),
225
+ branch: String(r.branch),
226
+ startedAt: num(r.started_at),
227
+ lastOutputAt: num(r.last_output_at),
228
+ attempt: num(r.attempt),
229
+ control: opt(r.control, (v) => String(v) as WorkerControl),
230
+ controlAt: opt(r.control_at, num),
231
+ };
232
+ }
233
+
234
+ function runOf(r: Row): RunRequestRow {
235
+ return {
236
+ id: num(r.id),
237
+ initiativeId: num(r.initiative_id),
238
+ account: opt(r.account, String),
239
+ once: num(r.once) === 1,
240
+ taskId: opt(r.task_id, num),
241
+ repoRoot: opt(r.repo_root, String),
242
+ maxTurns: opt(r.max_turns, num),
243
+ stallTimeoutMs: opt(r.stall_timeout_ms, num),
244
+ requestedAt: num(r.requested_at),
245
+ startedAt: opt(r.started_at, num),
246
+ finishedAt: opt(r.finished_at, num),
247
+ status: String(r.status) as RunRequestStatus,
248
+ error: opt(r.error, String),
249
+ };
250
+ }
251
+
252
+ export function serializeEvents(events: RunEventInput[]): string {
253
+ return JSON.stringify(events.map((e) => toJson(RunEventInputSchema, e)));
254
+ }
255
+
256
+ export function deserializeEvents(json: string): RunEventInput[] {
257
+ return (JSON.parse(json) as JsonValue[]).map((j) => fromJson(RunEventInputSchema, j));
258
+ }
259
+
260
+ export class ProcessTable implements ProcessTableApi {
261
+ private readonly db: DatabaseSync;
262
+
263
+ /** The clock every row's timestamp is taken from. Injectable so a test can put worker rows and
264
+ * Run requests on one timeline: mixing a frozen clock for the first with the real one for the
265
+ * second makes an ordering assertion depend on the wall-clock date it is run on. */
266
+ private readonly now: () => number;
267
+
268
+ private constructor(readonly path: string, now: () => number = Date.now) {
269
+ this.now = now;
270
+ if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
271
+ this.db = new DatabaseSync(path);
272
+ // Owner-only, like the credentials and Accounts files beside it; the driver takes no mode.
273
+ if (path !== ":memory:") chmodSync(path, 0o600);
274
+ this.db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;");
275
+ this.db.exec(SCHEMA);
276
+ const have = new Set((this.db.prepare("PRAGMA table_info(workers)").all() as Row[]).map((c) => String(c.name)));
277
+ for (const column of WORKER_COLUMNS_ADDED) if (!have.has(column.name)) this.db.exec(`ALTER TABLE workers ADD COLUMN ${column.ddl}`);
278
+ }
279
+
280
+ /** Opens (creating when missing) the table at `path`; `:memory:` for tests that need no file. */
281
+ static open(path: string, options: { now?: () => number } = {}): ProcessTable {
282
+ return new ProcessTable(path, options.now);
283
+ }
284
+
285
+ close(): void {
286
+ this.db.close();
287
+ }
288
+
289
+ // --- workers ---
290
+
291
+ upsertWorker(row: WorkerRow): void {
292
+ this.db
293
+ .prepare(
294
+ `INSERT INTO workers (task_id, initiative_id, identifier, account, vendor, session_id, pid, app_pid, workspace, branch, started_at, last_output_at, attempt, control, control_at)
295
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
296
+ ON CONFLICT(task_id) DO UPDATE SET initiative_id = excluded.initiative_id, identifier = excluded.identifier, account = excluded.account,
297
+ vendor = excluded.vendor, session_id = excluded.session_id, pid = excluded.pid, app_pid = excluded.app_pid, workspace = excluded.workspace, branch = excluded.branch,
298
+ started_at = excluded.started_at, last_output_at = excluded.last_output_at, attempt = excluded.attempt, control = excluded.control, control_at = excluded.control_at`,
299
+ )
300
+ .run(
301
+ row.taskId,
302
+ row.initiativeId,
303
+ row.identifier,
304
+ row.account,
305
+ row.vendor,
306
+ row.sessionId ?? null,
307
+ row.pid ?? null,
308
+ row.appPid ?? null,
309
+ row.workspace,
310
+ row.branch,
311
+ row.startedAt,
312
+ row.lastOutputAt,
313
+ row.attempt,
314
+ row.control ?? null,
315
+ row.controlAt ?? null,
316
+ );
317
+ }
318
+
319
+ setControl(taskId: number, control: WorkerControl | undefined, at: number): boolean {
320
+ return Number(this.db.prepare("UPDATE workers SET control = ?, control_at = ? WHERE task_id = ?").run(control ?? null, at, taskId).changes) > 0;
321
+ }
322
+
323
+ setSession(taskId: number, sessionId: string, pid?: number): void {
324
+ this.db.prepare("UPDATE workers SET session_id = ?, pid = COALESCE(?, pid) WHERE task_id = ?").run(sessionId, pid ?? null, taskId);
325
+ }
326
+
327
+ setPid(taskId: number, pid: number): void {
328
+ this.db.prepare("UPDATE workers SET pid = ? WHERE task_id = ?").run(pid, taskId);
329
+ }
330
+
331
+ clearPid(taskId: number): void {
332
+ this.db.prepare("UPDATE workers SET pid = NULL WHERE task_id = ?").run(taskId);
333
+ }
334
+
335
+ setAppPid(taskId: number, pid: number | undefined): void {
336
+ this.db.prepare("UPDATE workers SET app_pid = ? WHERE task_id = ?").run(pid ?? null, taskId);
337
+ }
338
+
339
+ touch(taskId: number, at: number): void {
340
+ this.db.prepare("UPDATE workers SET last_output_at = ? WHERE task_id = ?").run(at, taskId);
341
+ }
342
+
343
+ removeWorker(taskId: number): void {
344
+ this.db.prepare("DELETE FROM workers WHERE task_id = ?").run(taskId);
345
+ }
346
+
347
+ worker(taskId: number): WorkerRow | undefined {
348
+ const r = this.db.prepare("SELECT * FROM workers WHERE task_id = ?").get(taskId) as Row | undefined;
349
+ return r ? workerOf(r) : undefined;
350
+ }
351
+
352
+ liveWorkers(filter: { initiativeId?: number; account?: string } = {}): WorkerRow[] {
353
+ const where: string[] = [];
354
+ const args: (number | string)[] = [];
355
+ if (filter.initiativeId !== undefined) {
356
+ where.push("initiative_id = ?");
357
+ args.push(filter.initiativeId);
358
+ }
359
+ if (filter.account !== undefined) {
360
+ where.push("account = ?");
361
+ args.push(filter.account);
362
+ }
363
+ const sql = `SELECT * FROM workers${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY started_at, task_id`;
364
+ return (this.db.prepare(sql).all(...args) as Row[]).map(workerOf);
365
+ }
366
+
367
+ // --- outbox ---
368
+
369
+ enqueue(initiativeId: number, events: RunEventInput[]): number {
370
+ const result = this.db.prepare("INSERT INTO outbox (initiative_id, events_json, created_at) VALUES (?, ?, ?)").run(initiativeId, serializeEvents(events), this.now());
371
+ return Number(result.lastInsertRowid);
372
+ }
373
+
374
+ pending(filter: { initiativeId?: number } = {}): OutboxRow[] {
375
+ const rows =
376
+ filter.initiativeId === undefined
377
+ ? (this.db.prepare("SELECT * FROM outbox ORDER BY id").all() as Row[])
378
+ : (this.db.prepare("SELECT * FROM outbox WHERE initiative_id = ? ORDER BY id").all(filter.initiativeId) as Row[]);
379
+ return rows.map((r) => ({ id: num(r.id), initiativeId: num(r.initiative_id), events: deserializeEvents(String(r.events_json)), createdAt: num(r.created_at), attempts: num(r.attempts) }));
380
+ }
381
+
382
+ async drain(sender: (initiativeId: number, events: RunEventInput[]) => Promise<unknown>, options: DrainOptions = {}): Promise<DrainResult> {
383
+ const filter = options.initiativeId === undefined ? {} : { initiativeId: options.initiativeId };
384
+ let sent = 0;
385
+ const discarded: OutboxRow[] = [];
386
+ for (const batch of this.pending(filter)) {
387
+ try {
388
+ await sender(batch.initiativeId, batch.events);
389
+ } catch (error) {
390
+ if (options.discard?.(error)) {
391
+ this.db.prepare("DELETE FROM outbox WHERE id = ?").run(batch.id);
392
+ discarded.push(batch);
393
+ continue;
394
+ }
395
+ this.db.prepare("UPDATE outbox SET attempts = attempts + 1 WHERE id = ?").run(batch.id);
396
+ return { sent, remaining: this.pending(filter).length, discarded, error: error instanceof Error ? error : new Error(String(error)) };
397
+ }
398
+ this.db.prepare("DELETE FROM outbox WHERE id = ?").run(batch.id);
399
+ sent++;
400
+ }
401
+ return { sent, remaining: 0, discarded };
402
+ }
403
+
404
+ // --- runs ---
405
+
406
+ requestRun(request: NewRunRequest): RunRequestRow {
407
+ const result = this.db
408
+ .prepare("INSERT INTO runs (initiative_id, account, once, task_id, repo_root, max_turns, stall_timeout_ms, requested_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
409
+ .run(request.initiativeId, request.account ?? null, request.once ? 1 : 0, request.taskId ?? null, request.repoRoot ?? null, request.maxTurns ?? null, request.stallTimeoutMs ?? null, this.now());
410
+ return this.run(Number(result.lastInsertRowid))!;
411
+ }
412
+
413
+ run(id: number): RunRequestRow | undefined {
414
+ const r = this.db.prepare("SELECT * FROM runs WHERE id = ?").get(id) as Row | undefined;
415
+ return r ? runOf(r) : undefined;
416
+ }
417
+
418
+ /** The oldest pending Run request, marked running, or undefined when none waits. A request for
419
+ * a paused Initiative (story 55) is left pending: serving it would only find the pause again, so
420
+ * it waits for `foreman resume` while the daemon serves the others. */
421
+ takeRun(): RunRequestRow | undefined {
422
+ const r = this.db
423
+ .prepare("SELECT * FROM runs WHERE status = 'pending' AND initiative_id NOT IN (SELECT initiative_id FROM pauses) ORDER BY id LIMIT 1")
424
+ .get() as Row | undefined;
425
+ if (!r) return undefined;
426
+ this.db.prepare("UPDATE runs SET status = 'running', started_at = ? WHERE id = ?").run(this.now(), num(r.id));
427
+ return this.run(num(r.id));
428
+ }
429
+
430
+ finishRun(id: number, status: "done" | "failed", error?: string): void {
431
+ this.db.prepare("UPDATE runs SET status = ?, finished_at = ?, error = ? WHERE id = ?").run(status, this.now(), error ?? null, id);
432
+ }
433
+
434
+ /** Puts a request the daemon started back to pending, with a note saying why (its Initiative was
435
+ * paused mid-Run); the daemon takes it again once the pause is lifted. */
436
+ requeueRun(id: number, note: string): void {
437
+ this.db.prepare("UPDATE runs SET status = 'pending', started_at = NULL, finished_at = NULL, error = ? WHERE id = ?").run(note, id);
438
+ }
439
+
440
+ /** Requests left `running` by a daemon that died; the next daemon puts them back to pending. */
441
+ requeueRunning(): number {
442
+ return Number(this.db.prepare("UPDATE runs SET status = 'pending', started_at = NULL WHERE status = 'running'").run().changes);
443
+ }
444
+
445
+ // --- pauses ---
446
+
447
+ /** Pauses new dispatch for the Initiative; idempotent, keeps the first pause time. */
448
+ pause(initiativeId: number, at: number): void {
449
+ this.db.prepare("INSERT INTO pauses (initiative_id, paused_at) VALUES (?, ?) ON CONFLICT(initiative_id) DO NOTHING").run(initiativeId, at);
450
+ }
451
+
452
+ /** Lifts the pause; false when the Initiative was not paused. */
453
+ resume(initiativeId: number): boolean {
454
+ return Number(this.db.prepare("DELETE FROM pauses WHERE initiative_id = ?").run(initiativeId).changes) > 0;
455
+ }
456
+
457
+ isPaused(initiativeId: number): boolean {
458
+ return this.pausedAt(initiativeId) !== undefined;
459
+ }
460
+
461
+ /** Every Initiative a human has paused, oldest pause first: what `foreman status` shows so a
462
+ * fleet that has stopped dispatching says why. */
463
+ pausedInitiatives(): Array<{ initiativeId: number; pausedAt: number }> {
464
+ return (this.db.prepare("SELECT initiative_id, paused_at FROM pauses ORDER BY paused_at, initiative_id").all() as Row[]).map((r) => ({
465
+ initiativeId: num(r.initiative_id),
466
+ pausedAt: num(r.paused_at),
467
+ }));
468
+ }
469
+
470
+ /** The Initiatives this machine has anything of, most recently touched first: one with a live
471
+ * Worker row, or one a Run request named. What `foreman review` defaults to when exactly one
472
+ * comes back, so the usual "what happened last night" needs no id. */
473
+ knownInitiatives(): number[] {
474
+ const rows = this.db
475
+ .prepare(
476
+ `SELECT initiative_id, MAX(at) AS at FROM (
477
+ SELECT initiative_id, last_output_at AS at FROM workers
478
+ UNION ALL
479
+ SELECT initiative_id, COALESCE(finished_at, started_at, requested_at) AS at FROM runs
480
+ ) GROUP BY initiative_id ORDER BY at DESC, initiative_id DESC`,
481
+ )
482
+ .all() as Row[];
483
+ return rows.map((r) => num(r.initiative_id));
484
+ }
485
+
486
+ /** When the Initiative was paused, epoch milliseconds; undefined when it is not. */
487
+ pausedAt(initiativeId: number): number | undefined {
488
+ const r = this.db.prepare("SELECT paused_at FROM pauses WHERE initiative_id = ?").get(initiativeId) as Row | undefined;
489
+ return r ? num(r.paused_at) : undefined;
490
+ }
491
+ }
492
+
493
+ /** How long a row with no pid yet is read as a Foreman mid-dispatch rather than as leftover state.
494
+ * The row exists from the moment the dispatch is committed and gets its pid when the adapter
495
+ * launches (core/run.ts), so there is a window in which a live Foreman on an Account is invisible
496
+ * to a pid check. Short, because the other reading of a pid-less row is a Foreman that died in that
497
+ * same window, and one of those must not cost an Account its Failover for the rest of the day. */
498
+ export const DISPATCH_PID_GRACE_MS = 60_000;
499
+
500
+ /** The Workers another Foreman has on an Account right now (ADR-0013: the cap is one vendor
501
+ * identity's, and Slots to enforce it are a Run's, so a second Run opening its own Slots here would
502
+ * double it).
503
+ *
504
+ * A row counts when its pid is alive, and also when it has no pid but was written inside
505
+ * `DISPATCH_PID_GRACE_MS` — that is the dispatch-commit-to-launch window, where a Foreman really is
506
+ * starting a Worker on this Account and no pid check can see it. Rows the caller owns are its own
507
+ * turns, not another Foreman's, and rows whose Worker is provably gone are what Reconcile exists to
508
+ * settle: neither is held against the Account.
509
+ */
510
+ export function otherForemanWorkersOn(
511
+ table: Pick<ProcessTableApi, "liveWorkers">,
512
+ account: string,
513
+ at: number,
514
+ options: { isAlive?: (pid: number | undefined) => boolean; owns?: (taskId: number) => boolean } = {},
515
+ ): WorkerRow[] {
516
+ const isAlive = options.isAlive ?? pidIsAlive;
517
+ return table.liveWorkers({ account }).filter((row) => {
518
+ if (options.owns?.(row.taskId)) return false;
519
+ if (row.pid !== undefined) return isAlive(row.pid);
520
+ return at - row.startedAt < DISPATCH_PID_GRACE_MS;
521
+ });
522
+ }
523
+
524
+ /** True when a process with this pid exists and this user may signal it. Unset means unknown,
525
+ * which Reconcile treats as gone: a row without a pid can only come from an adapter that never
526
+ * gave one, and then there is nothing to wait for. */
527
+ export function pidIsAlive(pid: number | undefined): boolean {
528
+ if (pid === undefined) return false;
529
+ try {
530
+ process.kill(pid, 0);
531
+ return true;
532
+ } catch (error) {
533
+ return (error as NodeJS.ErrnoException).code === "EPERM";
534
+ }
535
+ }