@nledford/omp-coord 0.4.1 → 0.4.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.
@@ -7,7 +7,7 @@
7
7
  "name": "omp-coord",
8
8
  "source": "./",
9
9
  "description": "Shared subagent caps, build and test queues, claims, pause, a decision log and messages across omp sessions working in one project on one machine.",
10
- "version": "0.4.1",
10
+ "version": "0.4.2",
11
11
  "author": { "name": "Nathaniel Ledford" },
12
12
  "homepage": "https://github.com/nledford/omp-coord",
13
13
  "repository": "https://github.com/nledford/omp-coord",
package/CHANGELOG.md CHANGED
@@ -6,6 +6,13 @@ Add each user-visible change under Unreleased as it lands, in one of: Added, Cha
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.2] - 2026-11-02
10
+
11
+ ### Added
12
+
13
+ - `omp-coord run --meta k=v` (repeatable, at most 8; the key matches `[a-z][a-z0-9_-]{0,31}`, the value is at most 200 characters on one line) tags a job. The tags go into the `job_queued` and `job_ended` events as `meta`, and `job_ended` now also carries the job's `key`. A script can detect support with `omp-coord --help 2>&1 | grep -q -- '--meta'`. The jobs table gains a nullable `meta` column (no schema version bump).
14
+ - `omp-coord stats` and `/coord stats` add `; of the failures: N repeat an unchanged failure, M intended` to a queue's line when a failed job of that day had tags: a repeat is a failed job whose `fp` tag equals that of an earlier failed job in the same queue, with no successful job of that `fp` in between; intended is `intent=failfirst`.
15
+
9
16
  ## [0.4.1] - 2026-10-30
10
17
 
11
18
  ### Added
@@ -113,7 +120,8 @@ Add each user-visible change under Unreleased as it lands, in one of: Added, Cha
113
120
  - An SQLite event log per scope, with retention.
114
121
  - Packaging as an omp plugin, with a marketplace catalog; queued commands run on omp's own Bun.
115
122
 
116
- [Unreleased]: https://github.com/nledford/omp-coord/compare/v0.4.1...HEAD
123
+ [Unreleased]: https://github.com/nledford/omp-coord/compare/v0.4.2...HEAD
124
+ [0.4.2]: https://github.com/nledford/omp-coord/compare/v0.4.1...v0.4.2
117
125
  [0.4.1]: https://github.com/nledford/omp-coord/compare/v0.4.0...v0.4.1
118
126
  [0.4.0]: https://github.com/nledford/omp-coord/compare/v0.3.2...v0.4.0
119
127
  [0.3.2]: https://github.com/nledford/omp-coord/compare/v0.3.1...v0.3.2
package/docs/commands.md CHANGED
@@ -26,7 +26,7 @@
26
26
  ## `omp-coord`
27
27
 
28
28
  ```sh
29
- omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] -- <command> [args…]
29
+ omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] [--meta k=v]... -- <command> [args…]
30
30
  omp-coord status [--agents | --json] | stats [days] | interrupted [hours] | notes [count] | note <text…>
31
31
  omp-coord pause [reason…] | resume | doctor [--fix]
32
32
  ```
package/docs/event-log.md CHANGED
@@ -25,7 +25,7 @@ These tables are rebuilt as sessions come and go; their layout is not part of th
25
25
  `events` is an append-only log: `at` (ms since epoch), `type`, `session_id` (null for jobs from outside omp), `label`, and `data` (JSON). Types:
26
26
 
27
27
  - Sessions and agents: `session_joined`, `session_left`, `spawn_approved`, `spawn_refused` (with `position`, `used`, `cap`, or `nested` and `reason`), `run_started` (with `revived`), `run_ended` (with `ranMs`), `start_expired`, `hold_granted`, `hold_used` and `hold_expired` (with `waitedMs` since the first refusal), `message_sent` (recipients and length, never the text).
28
- - Jobs: `job_queued` (with `queue`, `lane`, `priority`, `key`, and `agent` when recorded), `job_refused` (with `reason`), `job_started` (with `waitedMs`), `job_ended` (with `exitCode`, `ranMs`, and `timedOut` when the time limit stopped it), `job_withdrawn` (left the line before starting), `job_superseded`, `job_lost` (its runner died), `job_reprioritized`.
28
+ - Jobs: `job_queued` (with `queue`, `lane`, `priority`, `key`, and `agent` and `meta` when recorded), `job_refused` (with `reason`), `job_started` (with `waitedMs`), `job_ended` (with `exitCode`, `ranMs`, `key` and `meta` when the job had them, and `timedOut` when the time limit stopped it), `job_withdrawn` (left the line before starting), `job_superseded`, `job_lost` (its runner died), `job_reprioritized`.
29
29
  - Control: `paused` (with `reason`), `resumed` (with `pausedMs`), `claimed`, `released`, `claim_refused` (with `holder`), `write_refused` (a file-tool write or a state-changing `git` command refused in a protected checkout: `agentId`, `tool` (`bash` for git), `paths` as the agent wrote them, empty for git), `note_added` (note number and length).
30
30
 
31
31
  `notes` is the decision log: `id`, `at`, `label`, `author`, `text`.
package/docs/queues.md CHANGED
@@ -3,7 +3,7 @@
3
3
  `omp-coord run <queue> -- <command>` waits for one of the queue's slots, shared by everything on this machine in the same scope, then runs the command. `just` recipes, scripts and terminals call it; agents' matching bash commands are rewritten to go through it. Queues are set under `queues` in the [configuration](configuration.md).
4
4
 
5
5
  ```sh
6
- omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] -- <command> [args…]
6
+ omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] [--meta k=v]... -- <command> [args…]
7
7
  ```
8
8
 
9
9
  ## Running a job
@@ -14,6 +14,7 @@ The command runs in its own process group and session, so a time limit stops eve
14
14
 
15
15
  - **Exit codes**: the command's own, `75` when the job was not run (a refused or superseded duplicate), `124` when its time limit stopped it, `2` for a usage or configuration error.
16
16
  - **Owner**: jobs belong to the omp session in `OMP_COORD_SESSION` (set by the extension, so commands an agent runs inherit it) or `--owner`; otherwise to no session ("outside omp").
17
+ - **Tags**: `--meta k=v` (repeatable, at most 8; a repeated key: the last wins) records tags on the job's `job_queued` and `job_ended` events. A key matches `[a-z][a-z0-9_-]{0,31}`; a value is at most 200 characters on one line. Scripts can tag a job with a fingerprint of the code state (`fp`) and mark a deliberate failure (`intent=failfirst`); `omp-coord stats` then adds `; of the failures: N repeat an unchanged failure, M intended` to the queue's line. A repeat is a failed job whose `fp` equals that of an earlier failed job in the same queue inside the stats window, with no successful job of that `fp` in between. Detect support with `omp-coord --help 2>&1 | grep -q -- '--meta'`.
17
18
  - **Duplicates**: jobs with the same `--key` (for example `"<checkout> ci"`) are duplicates. One that duplicates a running job is refused; one that duplicates a waiting job is refused or, with `onDuplicate: "supersede"`, replaces it in its place in line.
18
19
 
19
20
  ## Order
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nledford/omp-coord",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "files": [
5
5
  "src",
6
6
  "docs",
@@ -4,7 +4,7 @@
4
4
  * terminals, or agents' bash calls (which the extension rewrites to go through here when a rule queues them), and
5
5
  * reports on the scope.
6
6
  *
7
- * omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] -- <command> [args…]
7
+ * omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] [--meta k=v]... -- <command> [args…]
8
8
  * omp-coord status [--agents | --json] | stats [days] | interrupted [hours] | notes [count] | note <text…>
9
9
  * omp-coord pause [reason…] | resume | doctor [--fix]
10
10
  *
@@ -24,7 +24,7 @@ import { doctor } from "../../app/doctor.ts";
24
24
  import { EXIT_TIMED_OUT, runJob } from "../../app/job-runner.ts";
25
25
  import { type Config, ConfigError, findConfigFile, loadConfig } from "../../config.ts";
26
26
  import { statusView } from "../../domain/overview.ts";
27
- import { MAX_PRIORITY } from "../../domain/queues.ts";
27
+ import { MAX_PRIORITY, parseMeta } from "../../domain/queues.ts";
28
28
  import { agentOfOmpSession } from "../../domain/sessions.ts";
29
29
  import { osLoad } from "../os/load.ts";
30
30
  import { groupRunning } from "../os/process-group.ts";
@@ -45,7 +45,7 @@ const KILL_GRACE_MS = 10_000;
45
45
  const DAY = 86_400_000;
46
46
 
47
47
  const USAGE = `usage:
48
- omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] -- <command…>
48
+ omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] [--meta k=v]... -- <command…>
49
49
  omp-coord status [--agents | --json] | stats [days] | interrupted [hours] | notes [count] | note <text…>
50
50
  omp-coord pause [reason…] | resume | doctor [--fix]`;
51
51
 
@@ -206,6 +206,12 @@ async function run(args: string[]): Promise<number> {
206
206
  if (!Number.isSafeInteger(priority) || Math.abs(priority) > MAX_PRIORITY) {
207
207
  throw new UsageError(`omp-coord run: --priority must be a whole number from -${MAX_PRIORITY} to ${MAX_PRIORITY}`);
208
208
  }
209
+ let meta: ReturnType<typeof parseMeta>;
210
+ try {
211
+ meta = parseMeta(values.meta ?? []);
212
+ } catch (error) {
213
+ throw new UsageError(`omp-coord run: ${error instanceof Error ? error.message : String(error)}`);
214
+ }
209
215
  const timeoutMs = values.timeout === undefined ? undefined : parseDuration(values.timeout);
210
216
  const command = args.slice(split + 1);
211
217
 
@@ -258,6 +264,7 @@ async function run(args: string[]): Promise<number> {
258
264
  ...(values.lane === undefined ? {} : { lane: values.lane }),
259
265
  priority,
260
266
  ...(values.key === undefined ? {} : { key: values.key }),
267
+ ...(meta === undefined ? {} : { meta }),
261
268
  command: command.join(" "),
262
269
  pid: process.pid,
263
270
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
@@ -293,6 +300,7 @@ function parseRunFlags(args: string[]) {
293
300
  key: { type: "string" },
294
301
  owner: { type: "string" },
295
302
  timeout: { type: "string" },
303
+ meta: { type: "string", multiple: true },
296
304
  },
297
305
  });
298
306
  }
@@ -81,6 +81,7 @@ CREATE TABLE jobs (
81
81
  key TEXT,
82
82
  command TEXT NOT NULL,
83
83
  agent TEXT,
84
+ meta TEXT,
84
85
  socket TEXT NOT NULL,
85
86
  pid INTEGER NOT NULL,
86
87
  state TEXT NOT NULL CHECK (state IN ('waiting', 'running', 'superseded')),
@@ -258,6 +259,7 @@ export class BoardStore implements BoardRepository {
258
259
  .all()
259
260
  .some(c => c.name === column);
260
261
  if (!has("jobs", "agent")) this.#db.exec("ALTER TABLE jobs ADD COLUMN agent TEXT");
262
+ if (!has("jobs", "meta")) this.#db.exec("ALTER TABLE jobs ADD COLUMN meta TEXT");
261
263
  if (!has("slots", "unqueued"))
262
264
  this.#db.exec("ALTER TABLE slots ADD COLUMN unqueued TEXT CHECK (unqueued IN ('revived', 'eval'))");
263
265
  if (!has("sessions", "omp_session")) this.#db.exec("ALTER TABLE sessions ADD COLUMN omp_session TEXT");
@@ -369,6 +371,7 @@ export class BoardStore implements BoardRepository {
369
371
  key: string | null;
370
372
  command: string;
371
373
  agent: string | null;
374
+ meta: string | null;
372
375
  socket: string;
373
376
  pid: number;
374
377
  state: "waiting" | "running" | "superseded";
@@ -387,6 +390,7 @@ export class BoardStore implements BoardRepository {
387
390
  key: r.key ?? undefined,
388
391
  command: r.command,
389
392
  agent: r.agent ?? undefined,
393
+ meta: r.meta === null ? undefined : (JSON.parse(r.meta) as Record<string, string>),
390
394
  socket: r.socket,
391
395
  pid: r.pid,
392
396
  state: r.state,
@@ -499,7 +503,7 @@ export class BoardStore implements BoardRepository {
499
503
  const sent = db.query("INSERT INTO sent (session_id, at) VALUES (?, ?)");
500
504
  for (const x of board.sent) sent.run(x.sessionId, x.at);
501
505
  const job = db.query(
502
- "INSERT INTO jobs (id, queue, owner, lane, priority, key, command, agent, socket, pid, state, since, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
506
+ "INSERT INTO jobs (id, queue, owner, lane, priority, key, command, agent, meta, socket, pid, state, since, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
503
507
  );
504
508
  for (const j of board.jobs) {
505
509
  job.run(
@@ -511,6 +515,7 @@ export class BoardStore implements BoardRepository {
511
515
  j.key ?? null,
512
516
  j.command,
513
517
  j.agent ?? null,
518
+ j.meta === undefined ? null : JSON.stringify(j.meta),
514
519
  j.socket,
515
520
  j.pid,
516
521
  j.state,
@@ -212,7 +212,7 @@ export function formatStats(days: DayStats[]): string {
212
212
  );
213
213
  for (const [q, s] of Object.entries(d.queues)) {
214
214
  lines.push(
215
- ` ${q}: ${s.started} started, wait avg ${minutes(s.avgWaitMs)} / max ${minutes(s.maxWaitMs)}; ${s.ended} ended, ${s.failed} failed${s.timedOut ? ` (${s.timedOut} timed out)` : ""}`,
215
+ ` ${q}: ${s.started} started, wait avg ${minutes(s.avgWaitMs)} / max ${minutes(s.maxWaitMs)}; ${s.ended} ended, ${s.failed} failed${s.timedOut ? ` (${s.timedOut} timed out)` : ""}${s.ofFailures ? `; of the failures: ${s.ofFailures.repeats} repeat an unchanged failure, ${s.ofFailures.intended} intended` : ""}`,
216
216
  );
217
217
  }
218
218
  }
@@ -161,7 +161,7 @@ export class Coordinator {
161
161
  }
162
162
 
163
163
  stats(sinceMs: number): DayStats[] {
164
- return stats(this.repo.events(this.clock.now() - sinceMs), 0).days;
164
+ return stats(this.repo.events(), this.clock.now() - sinceMs).days;
165
165
  }
166
166
 
167
167
  requestSpawn(req: Omit<SpawnRequest, "now">): SpawnDecision {
@@ -22,6 +22,8 @@ export interface JobSpec {
22
22
  lane?: string;
23
23
  priority: number;
24
24
  key?: string;
25
+ /** Tags for the event log. */
26
+ meta?: Record<string, string>;
25
27
  /** For display. */
26
28
  command: string;
27
29
  pid: number;
@@ -67,6 +67,8 @@ export interface QueueDay {
67
67
  /** Ended with a nonzero exit code, a signal, or a timeout. */
68
68
  failed: number;
69
69
  timedOut: number;
70
+ /** Present when any failed job of the day carried `--meta` tags. */
71
+ ofFailures?: { repeats: number; intended: number };
70
72
  }
71
73
 
72
74
  export interface DayStats {
@@ -80,7 +82,16 @@ export interface DayStats {
80
82
  queues: Record<string, QueueDay>;
81
83
  }
82
84
 
85
+ /**
86
+ * Per queue and day: failures that repeat an unchanged failure (a failed job with `meta.fp` equal to that of an
87
+ * earlier failed job of the same queue in the window, with no successful job of that `fp` in between) and failures
88
+ * marked `meta.intent == "failfirst"`.
89
+ */
83
90
  export function stats(events: BoardEvent[], since: number): { days: DayStats[] } {
91
+ // Per queue: fingerprints whose latest outcome was a failure.
92
+ const failing = new Map<string, Set<string>>();
93
+ // Tags seen at queue time, for a job_ended that lost them (an older omp-coord still running rewrote the job without them).
94
+ const queuedMeta = new Map<string, unknown>();
84
95
  const days = new Map<string, DayStats & { waits: Record<string, number[]> }>();
85
96
  const dayOf = (at: number) => {
86
97
  const d = new Date(at);
@@ -96,6 +107,11 @@ export function stats(events: BoardEvent[], since: number): { days: DayStats[] }
96
107
  (s.queues[q] ??= { started: 0, avgWaitMs: 0, maxWaitMs: 0, ended: 0, failed: 0, timedOut: 0 });
97
108
 
98
109
  for (const e of events) {
110
+ // Tags are indexed from the whole log, so a job queued before the window still explains its end inside it.
111
+ if (e.type === "job_queued") {
112
+ if (e.data.meta === undefined) queuedMeta.delete(String(e.data.job));
113
+ else queuedMeta.set(String(e.data.job), e.data.meta);
114
+ }
99
115
  if (e.at < since) continue;
100
116
  const s = dayOf(e.at);
101
117
  const n = (k: string) => (typeof e.data[k] === "number" ? (e.data[k] as number) : 0);
@@ -117,8 +133,30 @@ export function stats(events: BoardEvent[], since: number): { days: DayStats[] }
117
133
  if (e.type === "job_ended") {
118
134
  const q = queueOf(s, String(e.data.queue));
119
135
  q.ended++;
120
- if (e.data.exitCode !== 0 || e.data.timedOut === true) q.failed++;
136
+ const failed = e.data.exitCode !== 0 || e.data.timedOut === true;
137
+ if (failed) q.failed++;
121
138
  if (e.data.timedOut === true) q.timedOut++;
139
+ const jobId = String(e.data.job);
140
+ const meta = e.data.meta ?? queuedMeta.get(jobId);
141
+ queuedMeta.delete(jobId);
142
+ if (typeof meta === "object" && meta !== null) {
143
+ const tags = meta as Record<string, unknown>;
144
+ const fp = typeof tags.fp === "string" ? tags.fp : undefined;
145
+ const queueName = String(e.data.queue);
146
+ const set = failing.get(queueName) ?? new Set<string>();
147
+ failing.set(queueName, set);
148
+ if (failed) {
149
+ const counts = q.ofFailures ?? { repeats: 0, intended: 0 };
150
+ q.ofFailures = counts;
151
+ if (fp !== undefined) {
152
+ if (set.has(fp)) counts.repeats++;
153
+ set.add(fp);
154
+ }
155
+ if (tags.intent === "failfirst") counts.intended++;
156
+ } else if (fp !== undefined) {
157
+ set.delete(fp);
158
+ }
159
+ }
122
160
  }
123
161
  }
124
162
  return {
@@ -181,6 +181,8 @@ export interface Job {
181
181
  priority: number;
182
182
  /** Jobs with the same key are duplicates (e.g. "<checkout> ci"). */
183
183
  key: string | undefined;
184
+ /** Tags the caller gave (`--meta k=v`), kept in the event log; undefined when none. */
185
+ meta?: Record<string, string> | undefined;
184
186
  /** What runs, for display. */
185
187
  command: string;
186
188
  /** The runner's liveness socket and process id. */
@@ -47,12 +47,38 @@ export interface JobRequest {
47
47
  lane?: string;
48
48
  priority: number;
49
49
  key?: string;
50
+ meta?: Record<string, string>;
50
51
  command: string;
51
52
  socket: string;
52
53
  pid: number;
53
54
  now: number;
54
55
  }
55
56
 
57
+ export const MAX_META = 8;
58
+ const META_KEY = /^[a-z][a-z0-9_-]{0,31}$/;
59
+ const MAX_META_VALUE = 200;
60
+
61
+ /**
62
+ * Parses repeated `--meta k=v` values into tags (a repeated key: the last wins). Throws an Error naming the problem:
63
+ * more than `MAX_META` pairs, no `=`, a key outside `[a-z][a-z0-9_-]{0,31}`, a value over 200 characters or with a
64
+ * newline. Returns undefined for no pairs.
65
+ */
66
+ export function parseMeta(pairs: string[]): Record<string, string> | undefined {
67
+ if (pairs.length > MAX_META) throw new Error(`--meta may be given at most ${MAX_META} times`);
68
+ const meta: Record<string, string> = {};
69
+ for (const pair of pairs) {
70
+ const eq = pair.indexOf("=");
71
+ if (eq < 0) throw new Error(`--meta takes k=v, not "${pair}"`);
72
+ const k = pair.slice(0, eq);
73
+ const v = pair.slice(eq + 1);
74
+ if (!META_KEY.test(k)) throw new Error(`--meta key "${k}" must match [a-z][a-z0-9_-]{0,31}`);
75
+ if (v.length > MAX_META_VALUE) throw new Error(`--meta ${k}: the value is over ${MAX_META_VALUE} characters`);
76
+ if (/[\r\n]/.test(v)) throw new Error(`--meta ${k}: the value must not contain a newline`);
77
+ meta[k] = v;
78
+ }
79
+ return Object.keys(meta).length ? meta : undefined;
80
+ }
81
+
56
82
  export type EnqueueResult = { ok: true } | { ok: false; reason: string };
57
83
 
58
84
  /** Puts a job in line (or starts it at once when a slot is free). */
@@ -86,6 +112,7 @@ export function enqueueJob(board: Board, queues: Queues, req: JobRequest): Enque
86
112
  lane,
87
113
  priority: req.priority,
88
114
  key: req.key,
115
+ meta: req.meta,
89
116
  command: req.command,
90
117
  socket: req.socket,
91
118
  pid: req.pid,
@@ -100,6 +127,7 @@ export function enqueueJob(board: Board, queues: Queues, req: JobRequest): Enque
100
127
  priority: req.priority,
101
128
  ...(req.key === undefined ? {} : { key: req.key }),
102
129
  ...(req.agent === undefined ? {} : { agent: req.agent }),
130
+ ...(req.meta === undefined ? {} : { meta: req.meta }),
103
131
  });
104
132
  schedule(board, queues, req.now);
105
133
  return { ok: true };
@@ -128,6 +156,8 @@ export function jobEnded(
128
156
  queue: job.queue,
129
157
  exitCode: j.exitCode,
130
158
  ranMs: j.now - (job.startedAt ?? j.now),
159
+ ...(job.key === undefined ? {} : { key: job.key }),
160
+ ...(job.meta === undefined ? {} : { meta: job.meta }),
131
161
  ...(j.timedOut ? { timedOut: true } : {}),
132
162
  });
133
163
  } else if (job.state === "waiting") {