@nledford/omp-coord 0.2.2 → 0.3.0

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.2.2",
10
+ "version": "0.3.0",
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,29 @@ Add each user-visible change under Unreleased as it lands, in one of: Added, Cha
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-10-27
10
+
11
+ ### Changed
12
+
13
+ - **Breaking:** while a session is the only one in its scope, spawns past the cap now wait their turn instead of being refused (`agents.queueSpawns`, on by default): the extension keeps omp's per-session spawn queue (`task.maxConcurrency`) at what the caps allow the session, so you can start as many agents as you need and those past the cap start by themselves as slots free. With other sessions in the scope, or while paused, spawns over the cap are refused and the session joins the line, as before. A `task.maxConcurrency` you set stays the ceiling; the override is never saved and is cleared when the session ends. Set `agents.queueSpawns: false` to keep refusals always. See [agent slots](docs/agent-slots.md#spawns-wait-their-turn).
14
+ - `coord_status` and `/coord` say whether this session's spawns wait in omp's queue right now, and if not, why (another session, paused, or every slot taken by agents outside the queue).
15
+
16
+ ### Fixed
17
+
18
+ - With `tagAgentCommands` on, a command that already starts with `export OMP_COORD_AGENT=…;` (an agent repeating an earlier, rewritten call it saw in the conversation) gets exactly one prefix for the agent running it, instead of one more each time.
19
+ - A release whose tag push GitHub delivers twice, or a release run again, no longer fails: release runs for one tag wait for each other, npm's current "already exists" (409) answer counts as already published once npm lists that version, and a GitHub release that exists already is left as it is.
20
+ - With `tagAgentCommands` on, a copied prefix no longer ends up inside a queued job's own command.
21
+
22
+ ## [0.2.3] - 2026-10-27
23
+
24
+ ### Added
25
+
26
+ - `tagAgentCommands` (off by default): each queued job records the agent whose command queued it, and the queue lines in `/coord` and `omp-coord status` read `session A/WaveImport`; `status --json` has `agent` on each job and the `job_queued` event carries it. With it on, the extension starts every agent bash command with `export OMP_COORD_AGENT=<agent id>;`, which `omp-coord run` reads; leave it off if you use omp's `bash.patterns` rules or another extension that rewrites bash commands. A board made by an older omp-coord gains the new column when this version opens it; while any session of the scope still runs an older omp-coord, its saves drop the recorded agents (display only).
27
+
28
+ ### Changed
29
+
30
+ - A queue line shows the job's key (for example `rebase-2204 ci`) instead of its command when the job has one; the command still shows when it doesn't, and `status --json` keeps both.
31
+
9
32
  ## [0.2.2] - 2026-10-27
10
33
 
11
34
  ### Added
@@ -53,7 +76,9 @@ Add each user-visible change under Unreleased as it lands, in one of: Added, Cha
53
76
  - An SQLite event log per scope, with retention.
54
77
  - Packaging as an omp plugin, with a marketplace catalog; queued commands run on omp's own Bun.
55
78
 
56
- [Unreleased]: https://github.com/nledford/omp-coord/compare/v0.2.2...HEAD
79
+ [Unreleased]: https://github.com/nledford/omp-coord/compare/v0.3.0...HEAD
80
+ [0.3.0]: https://github.com/nledford/omp-coord/compare/v0.2.3...v0.3.0
81
+ [0.2.3]: https://github.com/nledford/omp-coord/compare/v0.2.2...v0.2.3
57
82
  [0.2.2]: https://github.com/nledford/omp-coord/compare/v0.2.1...v0.2.2
58
83
  [0.2.1]: https://github.com/nledford/omp-coord/compare/v0.2.0...v0.2.1
59
84
  [0.2.0]: https://github.com/nledford/omp-coord/compare/v0.1.0...v0.2.0
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  An [omp](https://github.com/can1357/oh-my-pi) plugin, plus a small command-line runner, that lets several omp sessions on one machine, working in the same project, share one set of agent caps and build/test queues, and talk to each other.
9
9
 
10
- - **Shared agent caps.** Subagents count against pools (for example 8 `work` and 2 `short` slots) shared by every session in the project. A spawn over the cap is refused through omp's spawn check, so the cap does not depend on agents remembering to take and free slots.
10
+ - **Shared agent caps.** Subagents count against pools (for example 8 `work` and 2 `short` slots) shared by every session in the project. While a session is alone, spawns past the cap wait in omp's own queue and start by themselves as slots free; with other sessions, a spawn over the cap is refused through omp's spawn check and the session joins a fair line. The cap does not depend on agents remembering to take and free slots.
11
11
  - **A fair line.** A refused session joins the line; the next free slot is held for it, and it is told so.
12
12
  - **Subagents start only reviewers.** "No nested worker agents" is enforced rather than asked for.
13
13
  - **Build and test queues.** `omp-coord run <queue> -- <command>` waits for one of the queue's slots, then runs the command, within an optional time limit. Agents' matching bash commands are rewritten to go through it.
@@ -13,9 +13,22 @@ Subagents count against pools of slots (for example 8 `work` and 2 `short`) shar
13
13
  | An approved spawn never starts | Its slot frees after `agents.startTimeoutSeconds`. |
14
14
  | A session exits or its process dies | All its slots and its place in line go, within about 10 seconds. |
15
15
 
16
+ ## Spawns wait their turn
17
+
18
+ With `agents.queueSpawns` (on by default), while a session is the only one in its scope, the extension keeps omp's own per-session spawn queue, the `task.maxConcurrency` setting, at the number of the session's spawns the caps let run now: its top-level agents already running or starting, the default pool's free slots, and one held for it there. Start as many agents as you need: omp starts the ones that fit and keeps the rest waiting in its queue, each starting as one of the session's agents ends. A value you set yourself for `task.maxConcurrency` stays the ceiling (it is read when the session starts). The value is a runtime override, never saved, and it is cleared when the session ends.
19
+
20
+ When a second session joins the scope, or the scope is paused, the override is cleared and spawns over the cap are refused as described under The line below. omp re-reads its queue size only when the session asks for a spawn or one of its runs ends, and a spawn waiting in omp's queue never reaches the spawn check, so it would never join the line: across sessions, only refusals keep the line fair.
21
+
22
+ Limits:
23
+
24
+ - omp's queue holds every agent the main session starts, so a reviewer started by the main session waits its turn too, and an agent of another pool (for example `short`) starts only when a default-pool slot is free as well. Subagents read the same setting, so a worker's own reviewers queue under the same number; with `agents.nested.maxRunningPerParent` at 1 that never delays them. An eval workpool sizes itself from the same setting while its own workers take slots, so while a workpool's workers run the override is cleared and the spawn check decides.
25
+ - Agents started outside omp's queue (revived with `write agent://…`, eval `agent()` and workpool workers) take slots the queue doesn't count. When one of them ends, waiting spawns start at the session's next spawn or run end, not at once. If they fill every slot, the override is cleared and spawns over the cap are refused.
26
+ - A spawn approved but never started holds its slot until `agents.startTimeoutSeconds` runs out. If that leaves no free slot, the spawns waiting in omp's queue reach the spawn check one after another and are refused, until the slot is given back.
27
+ - omp re-reads the setting only when the session asks for a spawn or one of its runs ends: when a second session joins, spawns already waiting in omp's queue reach the spawn check at that next moment.
28
+
16
29
  ## The line
17
30
 
18
- A spawn over the cap is refused, and the session joins the line for that pool. When a slot frees, it is held for the session refused first (default 5 minutes, `agents.holdMinutes`) and that session is told so; other sessions' spawns are refused meanwhile. A session that does not use its held slot in time loses it to the next in line.
31
+ A spawn over the cap that reaches the spawn check is refused, and the session joins the line for that pool. When a slot frees, it is held for the session refused first (default 5 minutes, `agents.holdMinutes`) and that session is told so; other sessions' spawns are refused meanwhile. A session that does not use its held slot in time loses it to the next in line.
19
32
 
20
33
  The refusal, as the refused session sees it (here both `work` slots are in use by session B):
21
34
 
@@ -25,7 +38,7 @@ When one of B's agents ends, the slot is held for A, which is woken with a notic
25
38
 
26
39
  ![The omp-coord notice "A work slot is free and held for this session until 09:47 AM", followed by the restarted agent finishing](images/slot-held.png)
27
40
 
28
- omp gives the spawn check 30 seconds and then lets the spawn through, so the extension never waits inside it: it refuses at once and holds the next free slot instead. If the shared board cannot be read, spawns are let through with an error notice: the cap is an agreement between sessions, not a security boundary.
41
+ omp gives the spawn check 30 seconds and then lets the spawn through, so the extension never waits inside it: spawns wait in omp's queue before the check while the session is alone (above), and one that reaches it over the cap is refused at once, with the next free slot held. If the shared board cannot be read, spawns are let through with an error notice: the cap is an agreement between sessions, not a security boundary.
29
42
 
30
43
  While the machine's 1-minute load average is above a pool's `loadCaps` threshold, the lower cap applies. Running agents are never stopped; new ones wait until the total is under the lower cap.
31
44
 
package/docs/commands.md CHANGED
@@ -61,7 +61,7 @@ Every time is milliseconds since the epoch (`Date.now()`); a session is its lett
61
61
  | `line[]` | Sessions waiting for a slot: `pool`, `session`, `position` (from 1), `since`, `heldUntil` (`null` unless a freed slot is held for it). |
62
62
  | `agents[]` | One per agent holding a slot, by session letter then `since`: `id` (`null` for a spawn with no key and no first run yet), `type`, `parent` (`Main` or an agent id), `session`, `pool` (`null`: outside the caps), `state` (`starting` or `running`), `since`. |
63
63
  | `claims[]` | `kind` (`branch`, `worktree`, `pr`), `target`, `session`, `agent`, `note`, `since`. |
64
- | `queues[]` | `name`, `order`, `slots` (now), `configuredSlots`, `running[]` and `waiting[]` (in the order they will start), each job: `id`, `session`, `owner` (the owning session's id, or `null` outside omp), `lane`, `priority`, `key` (or `null`), `command`, `state`, `queuedAt`, `startedAt` (`null` while waiting), `position` (from 1 while waiting, `null` while running). |
64
+ | `queues[]` | `name`, `order`, `slots` (now), `configuredSlots`, `running[]` and `waiting[]` (in the order they will start), each job: `id`, `session`, `owner` (the owning session's id, or `null` outside omp), `lane`, `priority`, `key` (or `null`), `command`, `agent` (omp's id of the agent whose command queued it, `"Main"` for the main agent; `null` when `tagAgentCommands` is off, for a job started outside an agent's bash call, and while any session of the scope runs an omp-coord older than 0.2.3, whose saves drop it), `state`, `queuedAt`, `startedAt` (`null` while waiting), `position` (from 1 while waiting, `null` while running). |
65
65
  | `notes[]` | The latest three decisions, oldest first: `id`, `at`, `session` (the letter when it was written, kept after that session ends; `null` outside omp), `author`, `text`. |
66
66
 
67
67
  ```json
@@ -102,4 +102,5 @@ Exit codes: `0` success, the command's own for `run`, `75` a job not run, `124`
102
102
  | `OMP_COORD_SESSION` | the extension | The session that owns jobs started from its agents' commands. |
103
103
  | `OMP_COORD_PORTS` | the extension | The session's port block, e.g. `18700-18799`, when `resources.ports` is set. |
104
104
  | `OMP_COORD_SCRATCH` | the extension | The session's scratch folder, when `resources.scratch` is set. |
105
+ | `OMP_COORD_AGENT` | the extension, with `tagAgentCommands` | The agent whose command this is (`Main` for the main agent); `omp-coord run` records it on the job. |
105
106
  | `OMP_COORD_JOB`, `OMP_COORD_QUEUE` | `omp-coord run` | The running job's id and queue, for nested runs. |
@@ -12,6 +12,7 @@
12
12
  | `agents.nested.maxRunningPerParent` | `1` | How many of those one subagent may run at once. |
13
13
  | `agents.holdMinutes` | `5` | How long a freed slot stays held for the session first in line. |
14
14
  | `agents.startTimeoutSeconds` | `120` | How long an approved spawn may take to start before its slot frees. |
15
+ | `agents.queueSpawns` | `true` | While the session is the only one in the scope, keep omp's per-session spawn queue (`task.maxConcurrency`) at what the caps allow it, so spawns past the cap wait and start by themselves instead of being refused ([agent slots](agent-slots.md#spawns-wait-their-turn)); with other sessions, or paused, spawns over the cap are refused as before. A `task.maxConcurrency` you set yourself stays the ceiling. `false` keeps the refusals always. |
15
16
  | `messages.perMinute` | `10` | Messages one session may send per minute. It slows two sessions that keep answering each other; it does not stop them. Each delivered message starts a turn in an idle session. |
16
17
  | `commands` | `[]` | Rules for agents' bash commands, first match wins: `{ "pattern": "<regex>", "action": "refuse", "reason": "…" }`, or `{ "pattern", "action": "queue", "queue": "<name>", "lane"? }`. The user's own `!` commands are not affected. |
17
18
  | `queues.<name>.slots` | required | How many jobs of this queue run at once, across all processes in the scope. Queue names use letters, digits, `.`, `_`, `-`. |
@@ -21,6 +22,7 @@
21
22
  | `queues.<name>.onDuplicate` | `"refuse"` | `"refuse"` or `"supersede"` a waiting job with the same key. |
22
23
  | `queues.<name>.timeoutMinutes` | none | Stop a job's command after this long: SIGTERM to its process group, then SIGKILL to whatever of it is left 10 s later; it exits 124. `--timeout` overrides it per job. |
23
24
  | `queues.<name>.loadSlots` | `[]` | `[{ "above": 150, "slots": 1 }]`: fewer slots under load; `0` holds the queue. |
25
+ | `tagAgentCommands` | `false` | Start each agent bash command with `export OMP_COORD_AGENT=<agent id>;`, so each queued job records the agent that queued it (queue lines read `session A/<agent>`). Leave it off when you use omp's `bash.patterns` allow or deny rules (a rewritten command no longer matches them) or another extension that rewrites bash commands (the last rewrite wins). |
24
26
  | `resources.ports` | none | `{ "from", "to", "perSession", "perAgent" (10) }`: each session gets a block of `perSession` ports, each agent a block of `perAgent` from its session's. |
25
27
  | `resources.scratch` | none | Root of the scratch folders: `<scratch>/<letter>` per session, `<scratch>/<letter>/<agent id>` per agent. Relative to the config file. |
26
28
  | `history.retentionDays` | `30` | Events and decision-log notes older than this are deleted. |
package/docs/event-log.md CHANGED
@@ -14,7 +14,7 @@ These tables are rebuilt as sessions come and go; their layout is not part of th
14
14
  | `slots` | Agent slots held: `session_id`, `agent_id`, `parent_id`, `agent_type`, `pool` (null outside the caps), `state` (`starting` or `running`), `since`. |
15
15
  | `tickets` | The line: `session_id`, `pool`, `since`, `hold_until` (set while a slot is held for it), `notified`. |
16
16
  | `messages` | Messages not yet delivered. |
17
- | `jobs` | Queued and running build/test jobs: `id`, `queue`, `owner` (session id, or null outside omp), `lane`, `priority`, `key`, `command`, `pid`, `state` (`waiting`, `running`, `superseded`), `since`, `started_at`. |
17
+ | `jobs` | Queued and running build/test jobs: `id`, `queue`, `owner` (session id, or null outside omp), `lane`, `priority`, `key`, `command`, `agent` (null when not recorded), `pid`, `state` (`waiting`, `running`, `superseded`), `since`, `started_at`. |
18
18
  | `claims` | `kind` (`branch`, `worktree`, `pr`), `target`, `session_id`, `agent_id`, `note`, `since`. |
19
19
  | `pause` | One row while the scope is paused: `by_session` (null from a terminal), `reason`, `since`. |
20
20
  | `agent_ports` | Port blocks agents took: `session_id`, `agent_id`, `from_port`, `to_port`. |
@@ -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`), `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` 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`.
29
29
  - Control: `paused` (with `reason`), `resumed` (with `pausedMs`), `claimed`, `released`, `claim_refused` (with `holder`), `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
@@ -49,4 +49,6 @@ A command that puts its work in the background (`cargo test &`) holds the slot o
49
49
 
50
50
  When an agent's bash command matches a `queue` rule under `commands`, the extension rewrites it to run `omp-coord run <queue> --owner <session> -- sh -c '<command>'` with omp's own binary as Bun (`BUN_BE_BUN=1`), so it needs no separate Bun install. The waiting happens inside the bash call, which counts against the bash tool's timeout: agents should run long jobs as background (`async`) bash calls. The rewritten command runs in `sh`, so shell variables the agent set earlier without exporting them are not visible to it.
51
51
 
52
+ With `tagAgentCommands` on, the extension also starts every agent bash command with `export OMP_COORD_AGENT=<agent id>;`, and `omp-coord run` records that agent on the job: queue lines read `session A/WaveImport` instead of only `session A`. It is off by default because the rewritten command no longer matches omp's `bash.patterns` rules (they must match the whole command) and replaces any other extension's rewrite of the same call.
53
+
52
54
  A command matching a `refuse` rule is refused with the rule's reason.
@@ -31,14 +31,22 @@ There are no prereleases (`-rc.1` and the like): the marketplace catalog follows
31
31
 
32
32
  ## Cutting a release
33
33
 
34
- From `main`, with a clean work tree, not behind `origin/main`:
34
+ Start on `main`, with a clean work tree, not behind `origin/main`:
35
35
 
36
36
  ```sh
37
37
  just release-plan # commits and changelog since the last release; the next version for each kind of change
38
- just release fix # or feature, breaking, or an exact version such as 1.0.0
39
- just release-publish # asks, then pushes main and the tag; CI checks it and creates the GitHub release
38
+ just release fix # or feature, breaking, or an exact version such as 1.0.0: a release-vX.Y.Z branch and commit
39
+ just release-pr # (on that branch) pushes it and opens its pull request; merge it once CI passes
40
+ git switch main && git pull # main now holds the release
41
+ just release-publish # asks, then tags the release on main and pushes only the tag; CI creates the GitHub release
40
42
  ```
41
43
 
42
- `just release` refuses an empty Unreleased section in the changelog, sets the version in `package.json` and the marketplace catalog, dates the changelog, runs the checks, then commits `Release vX.Y.Z` and tags it, all locally. If anything fails before the commit, the files are put back. The version changes only in release commits, because omp upgrades marketplace installs only when the catalog's version goes up.
44
+ `main` and the release tags are protected by repository rulesets: `main` changes only by pull request with CI passing, never by force or deletion, and a `vX.Y.Z` tag, once pushed, can't be moved or deleted. So a release reaches `main` by its own pull request, and only the tag is pushed directly. Any merge method works.
43
45
 
44
- The release workflow checks that the tag is on `main` and matches the version files and the changelog, runs the checks on macOS, and creates the GitHub release with the changelog entry as its notes.
46
+ `just release` refuses an empty Unreleased section in the changelog, then creates the `release-vX.Y.Z` branch, sets the version in `package.json` and the marketplace catalog, dates the changelog, runs the checks and commits `Release vX.Y.Z`, all locally. If anything fails, the files are put back and you stay on (or return to) `main` with no branch left. The version changes only in release commits, because omp upgrades marketplace installs only when the catalog's version goes up.
47
+
48
+ `just release-publish` tags the commit on `main` that added the dated changelog section of the new version (the merge, squash or rebased commit), even if other pull requests merged after it, and refuses if `origin` already has that tag. A push that failed can be run again.
49
+
50
+ Once the release PR is merged, marketplace installs (which follow `main`) can see the new version before the tag and the GitHub release exist, so run `just release-publish` right after merging.
51
+
52
+ The release workflow checks that the tag is on `main` and matches the version files and the changelog, runs the checks on macOS, publishes the packages and creates the GitHub release with the changelog entry as its notes. If it fails for a passing reason (a registry or runner hiccup), re-run it: it skips what is already published. If the tagged code itself is broken, the version is used up (tags can't move): fix it and release the next patch version.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nledford/omp-coord",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "files": [
5
5
  "src",
6
6
  "docs",
@@ -249,6 +249,8 @@ async function run(args: string[]): Promise<number> {
249
249
  id,
250
250
  queue,
251
251
  owner: values.owner || process.env.OMP_COORD_SESSION || null,
252
+ // set per command by the extension's bash hook
253
+ ...(process.env.OMP_COORD_AGENT ? { agent: process.env.OMP_COORD_AGENT } : {}),
252
254
  ...(values.lane === undefined ? {} : { lane: values.lane }),
253
255
  priority,
254
256
  ...(values.key === undefined ? {} : { key: values.key }),
@@ -66,6 +66,8 @@ export interface ZodString {
66
66
 
67
67
  export interface ExtensionAPI {
68
68
  on<K extends keyof Events>(event: K, handler: Events[K]): void;
69
+ /** The host session; `settings` is the scope omp's typed setting handles take. */
70
+ pi?: { settings?: unknown };
69
71
  setLabel(label: string): void;
70
72
  registerTool<P>(tool: {
71
73
  name: string;
@@ -16,7 +16,8 @@ import { join, resolve } from "node:path";
16
16
  import { Coordinator } from "../../app/coordinator.ts";
17
17
  import type { Listener } from "../../app/ports.ts";
18
18
  import { type Config, ConfigError, findConfigFile, loadConfig } from "../../config.ts";
19
- import { decideCommand } from "../../domain/commands.ts";
19
+ import { spawnAllowance } from "../../domain/agents.ts";
20
+ import { decideCommand, withAgent, withoutAgentExports } from "../../domain/commands.ts";
20
21
  import type { Claim } from "../../domain/model.ts";
21
22
  import { osLoad } from "../os/load.ts";
22
23
  import { BoardStore, stateDir } from "../sqlite/store.ts";
@@ -31,6 +32,7 @@ import {
31
32
  } from "../text/report.ts";
32
33
  import { startBeacon, unixLiveness } from "../unix/beacon.ts";
33
34
  import type { ExtensionAPI, ExtensionContext, Timer, ToolResult } from "./api.ts";
35
+ import { loadSettingHandle, type SpawnQueue, spawnQueue } from "./spawn-queue.ts";
34
36
 
35
37
  /**
36
38
  * How agents' queued commands start the runner: the omp binary itself runs the CLI adapter as Bun
@@ -60,6 +62,12 @@ interface Runtime {
60
62
  ticks: number;
61
63
  footer: string | undefined;
62
64
  pruning: boolean;
65
+ /** Drives omp's spawn queue (`agents.queueSpawns`); undefined when off or unavailable. */
66
+ queue: SpawnQueue | undefined;
67
+ /** Whether spawns past the cap wait in omp's queue, or why not; shown in coord_status and /coord. */
68
+ queueNote: string;
69
+ /** Agents first started by an eval spawn (workpool workers): their later runs are marked "eval" too. */
70
+ evalAgents: Set<string>;
63
71
  }
64
72
 
65
73
  interface Shared {
@@ -68,13 +76,17 @@ interface Shared {
68
76
  inactive: string;
69
77
  /** Each subagent's binding, by agent id: a message for that subagent is sent through it. */
70
78
  bindings: Map<string, ExtensionAPI>;
79
+ /** Main-session bindings that shut down, so a `start` for one that awaited across its own shutdown gives up. */
80
+ shutDown: WeakSet<ExtensionAPI>;
71
81
  }
72
82
 
73
83
  /** Shared by every copy of this module in the process (omp re-imports extensions for isolated subagents). */
74
84
  const shared: Shared = (() => {
75
85
  const store = globalThis as { [key: symbol]: Shared | undefined };
76
86
  const key = Symbol.for("omp-coord.runtime");
77
- store[key] ??= { runtime: undefined, inactive: NO_CONFIG, bindings: new Map() };
87
+ store[key] ??= { runtime: undefined, inactive: NO_CONFIG, bindings: new Map(), shutDown: new WeakSet() };
88
+ // a copy made by an older version of this module in the same process has no set yet
89
+ store[key].shutDown ??= new WeakSet();
78
90
  return store[key];
79
91
  })();
80
92
 
@@ -99,23 +111,24 @@ export default function ompCoord(pi: ExtensionAPI): void {
99
111
  if (shared.bindings.get(ctx.agent.id) === pi) shared.bindings.delete(ctx.agent.id);
100
112
  return;
101
113
  }
114
+ shared.shutDown.add(pi);
102
115
  if (shared.runtime?.pi === pi) stop(shared.runtime);
103
116
  });
104
117
 
105
118
  pi.on("before_subagent_spawn", (event, ctx) => {
106
119
  const rt = shared.runtime;
107
120
  if (!rt) return undefined;
121
+ let decision: ReturnType<Coordinator["requestSpawn"]>;
108
122
  try {
109
- const decision = rt.coordinator.requestSpawn({
123
+ decision = rt.coordinator.requestSpawn({
110
124
  sessionId: rt.sessionId,
111
125
  parentAgentId: ctx.agent.id,
112
126
  parentIsSubagent: ctx.agent.kind === "sub",
113
127
  agentType: event.agent.toLowerCase(),
114
128
  ...(event.spawnKey === undefined ? {} : { spawnKey: event.spawnKey }),
129
+ ...(event.invocationKind === undefined ? {} : { invocationKind: event.invocationKind }),
115
130
  });
116
- if (decision.allowed) return undefined;
117
- notify(rt, `omp-coord refused ${event.agent} from ${ctx.agent.id}: ${decision.reason}`, "warning");
118
- return { block: true, reason: `omp-coord: ${decision.reason}` };
131
+ if (event.invocationKind === "eval" && event.spawnKey !== undefined) rt.evalAgents.add(event.spawnKey);
119
132
  } catch (error) {
120
133
  // The cap is a shared agreement, not a security boundary: a broken board must not stop all work.
121
134
  notify(
@@ -125,6 +138,11 @@ export default function ompCoord(pi: ExtensionAPI): void {
125
138
  );
126
139
  return undefined;
127
140
  }
141
+ // outside the decision: a failure here must never turn a refusal into a start
142
+ refreshQueue(rt);
143
+ if (decision.allowed) return undefined;
144
+ notify(rt, `omp-coord refused ${event.agent} from ${ctx.agent.id}: ${decision.reason}`, "warning");
145
+ return { block: true, reason: `omp-coord: ${decision.reason}` };
128
146
  });
129
147
 
130
148
  pi.on("agent_start", (_event, ctx) => {
@@ -138,7 +156,9 @@ export default function ompCoord(pi: ExtensionAPI): void {
138
156
  parentId,
139
157
  parentIsSubagent: parentId !== "Main",
140
158
  agentType: ctx.agent.name.toLowerCase(),
159
+ evalWorker: rt.evalAgents.has(ctx.agent.id),
141
160
  });
161
+ refreshQueue(rt);
142
162
  if (count && count.used > count.cap) {
143
163
  notify(
144
164
  rt,
@@ -152,9 +172,10 @@ export default function ompCoord(pi: ExtensionAPI): void {
152
172
  pi.on("agent_end", (_event, ctx) => {
153
173
  const rt = shared.runtime;
154
174
  if (!rt || !isTrackedSubagent(ctx)) return;
155
- guarded(rt, "free an agent slot", () =>
156
- rt.coordinator.agentEnded({ sessionId: rt.sessionId, agentId: ctx.agent.id }),
157
- );
175
+ guarded(rt, "free an agent slot", () => {
176
+ rt.coordinator.agentEnded({ sessionId: rt.sessionId, agentId: ctx.agent.id });
177
+ refreshQueue(rt);
178
+ });
158
179
  });
159
180
 
160
181
  pi.on("tool_call", (event, ctx) => {
@@ -184,13 +205,20 @@ export default function ompCoord(pi: ExtensionAPI): void {
184
205
  notify(rt, `omp-coord could not check claims: ${describe(error)}`, "error");
185
206
  }
186
207
 
187
- const decision = decideCommand(rt.config.commands, command, {
208
+ // a copied `export OMP_COORD_AGENT=…;` would otherwise end up inside a queued job's own command
209
+ const own = rt.config.tagAgentCommands ? withoutAgentExports(command) : command;
210
+ const decision = decideCommand(rt.config.commands, own, {
188
211
  ...RUNNER,
189
212
  owner: rt.sessionId,
190
213
  config: rt.config.source,
191
214
  });
192
215
  if (decision.kind === "refuse") return { block: true, reason: `omp-coord: ${decision.reason}` };
193
- if (decision.kind === "queue") return { input: { ...event.input, command: decision.command } };
216
+ const run = decision.kind === "queue" ? decision.command : own;
217
+ // Every agent of a session shares the omp process's environment, so the agent's id can reach `omp-coord run` (and
218
+ // the queue lines) only through the command itself; opt-in (`tagAgentCommands`), since the rewrite defeats omp's
219
+ // bash.patterns rules and other extensions' rewrites. Off, the hook changes only queued commands, as before.
220
+ if (rt.config.tagAgentCommands) return { input: { ...event.input, command: withAgent(ctx.agent.id, run) } };
221
+ if (decision.kind === "queue") return { input: { ...event.input, command: run } };
194
222
  return undefined;
195
223
  });
196
224
 
@@ -422,6 +450,8 @@ export default function ompCoord(pi: ExtensionAPI): void {
422
450
  }
423
451
 
424
452
  async function start(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
453
+ // a binding started again after an earlier shutdown starts fresh
454
+ shared.shutDown.delete(pi);
425
455
  const configPath = findConfigFile(ctx.cwd);
426
456
  if (!configPath) return;
427
457
  let config: Config;
@@ -433,6 +463,19 @@ async function start(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
433
463
  return;
434
464
  }
435
465
 
466
+ // whether spawns past the cap wait in omp's queue, and why not, for the session-start notice
467
+ let queue: SpawnQueue | undefined;
468
+ let queueNote = "Spawns past the cap are refused (agents.queueSpawns is off).";
469
+ if (config.agents.queueSpawns) {
470
+ const handle = await loadSettingHandle();
471
+ // this session ended (switched or reloaded) while the handle loaded: don't join the board for it
472
+ if (shared.shutDown.has(pi)) return;
473
+ const scope = pi.pi?.settings;
474
+ queue = typeof handle === "string" ? undefined : spawnQueue(handle, scope);
475
+ queueNote = queue
476
+ ? "While this is the only session, spawns past the cap wait in omp's queue and start as slots free; with others, they are refused and this session holds a place in line."
477
+ : `Spawns past the cap are refused: ${typeof handle === "string" ? handle : "omp gave the extension no settings scope"}.`;
478
+ }
436
479
  let joined: Pick<Runtime, "store" | "coordinator" | "listener" | "sessionId" | "label">;
437
480
  try {
438
481
  joined = openSession(config, ctx.cwd);
@@ -442,7 +485,19 @@ async function start(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
442
485
  return;
443
486
  }
444
487
 
445
- const rt: Runtime = { config, ...joined, pi, ctx, timer: undefined, ticks: 0, footer: undefined, pruning: false };
488
+ const rt: Runtime = {
489
+ config,
490
+ ...joined,
491
+ pi,
492
+ ctx,
493
+ timer: undefined,
494
+ ticks: 0,
495
+ footer: undefined,
496
+ pruning: false,
497
+ queue,
498
+ queueNote,
499
+ evalAgents: new Set(),
500
+ };
446
501
  shared.runtime = rt;
447
502
  // Commands this session's agents run (e.g. a `just` recipe calling `omp-coord run`) see these.
448
503
  process.env.OMP_COORD_SESSION = rt.sessionId;
@@ -451,6 +506,7 @@ async function start(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
451
506
  const scratch = scratchFor(rt);
452
507
  if (scratch) process.env.OMP_COORD_SCRATCH = scratch;
453
508
  rt.timer = ctx.setInterval(() => tick(rt), TICK_MS);
509
+ refreshQueue(rt);
454
510
  await prune(rt);
455
511
 
456
512
  const board = rt.coordinator.board();
@@ -463,7 +519,8 @@ async function start(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
463
519
  (others.length
464
520
  ? `Other sessions: ${others.map(s => `${s.label} (${s.cwd})`).join(", ")}. Agent caps are shared with them.`
465
521
  : "No other session is active.") +
466
- pausedNote,
522
+ pausedNote +
523
+ ` ${queueNote}`,
467
524
  "info",
468
525
  );
469
526
  if (differing.length) {
@@ -517,8 +574,18 @@ function scratchFor(rt: Runtime, agentId?: string): string | undefined {
517
574
  return dir;
518
575
  }
519
576
 
577
+ /** Sets omp's spawn queue to what the caps allow this session now (a no-op when the queue isn't driven). */
578
+ function refreshQueue(rt: Runtime): void {
579
+ if (!rt.queue) return;
580
+ const queue = rt.queue;
581
+ guarded(rt, "size omp's spawn queue", () =>
582
+ queue.apply(spawnAllowance(rt.coordinator.board(), rt.config.agents, rt.sessionId, Date.now())),
583
+ );
584
+ }
585
+
520
586
  function stop(rt: Runtime): void {
521
587
  shared.runtime = undefined;
588
+ rt.queue?.release();
522
589
  for (const key of ["OMP_COORD_SESSION", "OMP_COORD_PORTS", "OMP_COORD_SCRATCH"]) delete process.env[key];
523
590
  rt.ctx.clearTimer(rt.timer);
524
591
  try {
@@ -535,6 +602,7 @@ async function tick(rt: Runtime): Promise<void> {
535
602
  const now = Date.now();
536
603
  const { label, inbox, holds, board } = rt.coordinator.tick(identity(rt.sessionId, rt.ctx.cwd, rt.listener));
537
604
  rt.label = label;
605
+ rt.queue?.apply(spawnAllowance(board, rt.config.agents, rt.sessionId, now));
538
606
 
539
607
  for (const m of inbox) {
540
608
  const body =
@@ -583,7 +651,14 @@ function sendFrom(rt: Runtime, to: string, body: string): ToolResult {
583
651
  }
584
652
 
585
653
  function statusText(rt: Runtime): string {
586
- return formatStatus(rt.coordinator.board(), rt.config, rt.sessionId, Date.now());
654
+ const board = rt.coordinator.board();
655
+ // whether this session's spawns wait in omp's queue right now, and why not
656
+ const queueLine = !rt.queue
657
+ ? rt.queueNote
658
+ : spawnAllowance(board, rt.config.agents, rt.sessionId, Date.now()) !== undefined
659
+ ? "Spawns past the cap wait in omp's queue and start as slots free (this is the only session)."
660
+ : `Spawns over the cap are refused and put this session in line (${board.pause ? "the scope is paused" : board.sessions.length > 1 ? "other sessions are in the scope" : board.slots.some(x => x.sessionId === rt.sessionId && x.unqueued === "eval") ? "a workpool's workers are running" : "no slot is free for a new spawn"}).`;
661
+ return `${formatStatus(board, rt.config, rt.sessionId, Date.now())}\n${queueLine}`;
587
662
  }
588
663
 
589
664
  function deliver(binding: ExtensionAPI, content: string): void {
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The one module of omp's own that the extension imports at run time (see spawn-queue.ts). omp supplies it to
3
+ * extensions by rewriting the import as it loads them; it is not a package dependency, so its type is declared here.
4
+ */
5
+ declare module "@oh-my-pi/pi-coding-agent/config/registry" {
6
+ import type { SettingHandle } from "./spawn-queue.ts";
7
+ export function lookup(id: string): SettingHandle | undefined;
8
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Keeps omp's own per-session spawn queue (the `task.maxConcurrency` setting) at the number of this session's spawns
3
+ * the caps let run now (`spawnAllowance`), so spawns past it wait in omp's queue and start as places free, instead of
4
+ * reaching the spawn check and being refused.
5
+ *
6
+ * Driven only while the session is alone in its scope and not paused (see `spawnAllowance`); otherwise the override is
7
+ * cleared and spawns over the cap are refused at the spawn check. omp re-reads the setting only when the session asks
8
+ * for a spawn or one of its runs ends, so a changed value takes effect then.
9
+ *
10
+ * The setting is changed by a runtime override (never persisted) and cleared when the session stops. A value the user
11
+ * set is a ceiling the override never exceeds; it is read when the session starts, so a change to it takes effect in
12
+ * the next session (while the override is in place it hides the setting's other layers). If omp's settings registry can't be loaded (an omp without it), nothing
13
+ * changes and spawns are refused as before.
14
+ */
15
+
16
+ /** The slice of omp's typed setting handle used here (`@oh-my-pi/pi-coding-agent/config/registry`). */
17
+ export interface SettingHandle {
18
+ get(scope: unknown): unknown;
19
+ override(scope: unknown, value: number | undefined): void;
20
+ clearOverride(scope: unknown): void;
21
+ isConfigured(scope: unknown): boolean;
22
+ }
23
+
24
+ export interface SpawnQueue {
25
+ /**
26
+ * Sets the queue's size to `allowance`, capped by the user's own value, writing only when it changes; `undefined`
27
+ * gives the queue back to the user's own value, leaving spawns to the spawn check.
28
+ */
29
+ apply(allowance: number | undefined): void;
30
+ /** Removes the override, giving the session back the user's own value. */
31
+ release(): void;
32
+ }
33
+
34
+ export const SETTING_ID = "task.maxConcurrency";
35
+
36
+ /** A queue driver over `handle` in `scope`; `undefined` when either is missing. */
37
+ export function spawnQueue(handle: SettingHandle | undefined, scope: unknown): SpawnQueue | undefined {
38
+ if (!handle || scope === undefined) return undefined;
39
+ // the user's own value, read before the first override: a positive number is a ceiling; 0 (unlimited) or unset is none
40
+ const own = handle.isConfigured(scope) ? Number(handle.get(scope)) : 0;
41
+ const ceiling = Number.isFinite(own) && own > 0 ? Math.trunc(own) : Number.POSITIVE_INFINITY;
42
+ let last: number | undefined;
43
+ return {
44
+ apply(allowance) {
45
+ if (allowance === undefined) {
46
+ this.release();
47
+ return;
48
+ }
49
+ // never 0: omp reads 0 as unlimited
50
+ const value = Math.max(1, Math.min(Math.trunc(allowance), ceiling));
51
+ if (value === last) return;
52
+ handle.override(scope, value);
53
+ last = value;
54
+ },
55
+ release() {
56
+ if (last === undefined) return;
57
+ handle.clearOverride(scope);
58
+ last = undefined;
59
+ },
60
+ };
61
+ }
62
+
63
+ /** omp's handle for the setting, or why there is none (this omp has no settings registry to load it from). */
64
+ export async function loadSettingHandle(): Promise<SettingHandle | string> {
65
+ try {
66
+ // Dynamic on purpose: the host's registry exists only inside an omp that has it. A static import would fail the
67
+ // whole extension's load in an older omp, in the CLI and in tests, where this module is loaded without omp. The
68
+ // specifier stays a literal: omp maps host packages onto its bundled copies by rewriting the specifiers in an
69
+ // extension's source as it loads it, and a specifier held in a variable is missed (seen live: "Cannot find package").
70
+ const registry: { lookup?: (id: string) => SettingHandle | undefined } = await import(
71
+ "@oh-my-pi/pi-coding-agent/config/registry"
72
+ );
73
+ return registry.lookup?.(SETTING_ID) ?? `omp's settings registry has no ${SETTING_ID}`;
74
+ } catch (error) {
75
+ return `omp's settings registry can't be loaded (${error instanceof Error ? error.message : String(error)})`;
76
+ }
77
+ }
@@ -47,7 +47,8 @@ CREATE TABLE slots (
47
47
  agent_type TEXT NOT NULL,
48
48
  pool TEXT,
49
49
  state TEXT NOT NULL CHECK (state IN ('starting', 'running')),
50
- since INTEGER NOT NULL
50
+ since INTEGER NOT NULL,
51
+ unqueued TEXT CHECK (unqueued IN ('revived', 'eval'))
51
52
  ) STRICT;
52
53
  CREATE TABLE tickets (
53
54
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
@@ -77,6 +78,7 @@ CREATE TABLE jobs (
77
78
  priority INTEGER NOT NULL,
78
79
  key TEXT,
79
80
  command TEXT NOT NULL,
81
+ agent TEXT,
80
82
  socket TEXT NOT NULL,
81
83
  pid INTEGER NOT NULL,
82
84
  state TEXT NOT NULL CHECK (state IN ('waiting', 'running', 'superseded')),
@@ -229,7 +231,10 @@ export class BoardStore implements BoardRepository {
229
231
 
230
232
  #migrate(): void {
231
233
  const version = this.#db.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version ?? 0;
232
- if (version === SCHEMA_VERSION) return;
234
+ if (version === SCHEMA_VERSION) {
235
+ this.#addColumns();
236
+ return;
237
+ }
233
238
  if (version !== 0) {
234
239
  throw new Error(
235
240
  `the board database has schema ${version}, but this omp-coord reads schema ${SCHEMA_VERSION}; update omp-coord`,
@@ -239,6 +244,22 @@ export class BoardStore implements BoardRepository {
239
244
  this.#db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
240
245
  }
241
246
 
247
+ /**
248
+ * Nullable columns added after schema 1 was released, without a version bump: a board made by an older omp-coord
249
+ * gets them here, and an older omp-coord still running (an omp session not yet restarted) keeps reading and
250
+ * writing the board, ignoring them (its saves write null there, which readers show as unknown).
251
+ */
252
+ #addColumns(): void {
253
+ const has = (table: string, column: string) =>
254
+ this.#db
255
+ .query<{ name: string }, []>(`PRAGMA table_info(${table})`)
256
+ .all()
257
+ .some(c => c.name === column);
258
+ if (!has("jobs", "agent")) this.#db.exec("ALTER TABLE jobs ADD COLUMN agent TEXT");
259
+ if (!has("slots", "unqueued"))
260
+ this.#db.exec("ALTER TABLE slots ADD COLUMN unqueued TEXT CHECK (unqueued IN ('revived', 'eval'))");
261
+ }
262
+
242
263
  #maybePruneEvents(now: number): void {
243
264
  const last = Number(
244
265
  this.#db.query<{ value: string }, []>("SELECT value FROM meta WHERE key = 'events_pruned_at'").get()?.value ?? 0,
@@ -286,6 +307,7 @@ export class BoardStore implements BoardRepository {
286
307
  pool: string | null;
287
308
  state: "starting" | "running";
288
309
  since: number;
310
+ unqueued: "revived" | "eval" | null;
289
311
  },
290
312
  []
291
313
  >("SELECT * FROM slots ORDER BY id")
@@ -298,6 +320,7 @@ export class BoardStore implements BoardRepository {
298
320
  pool: r.pool,
299
321
  state: r.state,
300
322
  since: r.since,
323
+ ...(r.unqueued ? { unqueued: r.unqueued } : {}),
301
324
  })),
302
325
  waitlist: db
303
326
  .query<{ session_id: string; pool: string; since: number; hold_until: number | null; notified: number }, []>(
@@ -337,6 +360,7 @@ export class BoardStore implements BoardRepository {
337
360
  priority: number;
338
361
  key: string | null;
339
362
  command: string;
363
+ agent: string | null;
340
364
  socket: string;
341
365
  pid: number;
342
366
  state: "waiting" | "running" | "superseded";
@@ -354,6 +378,7 @@ export class BoardStore implements BoardRepository {
354
378
  priority: r.priority,
355
379
  key: r.key ?? undefined,
356
380
  command: r.command,
381
+ agent: r.agent ?? undefined,
357
382
  socket: r.socket,
358
383
  pid: r.pid,
359
384
  state: r.state,
@@ -442,10 +467,10 @@ export class BoardStore implements BoardRepository {
442
467
  s.ports?.to ?? null,
443
468
  );
444
469
  const slot = db.query(
445
- "INSERT INTO slots (session_id, agent_id, parent_id, agent_type, pool, state, since) VALUES (?, ?, ?, ?, ?, ?, ?)",
470
+ "INSERT INTO slots (session_id, agent_id, parent_id, agent_type, pool, state, since, unqueued) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
446
471
  );
447
472
  for (const s of board.slots)
448
- slot.run(s.sessionId, s.agentId ?? null, s.parentId, s.agentType, s.pool, s.state, s.since);
473
+ slot.run(s.sessionId, s.agentId ?? null, s.parentId, s.agentType, s.pool, s.state, s.since, s.unqueued ?? null);
449
474
  const ticket = db.query(
450
475
  "INSERT INTO tickets (session_id, pool, since, hold_until, notified) VALUES (?, ?, ?, ?, ?)",
451
476
  );
@@ -455,7 +480,7 @@ export class BoardStore implements BoardRepository {
455
480
  const sent = db.query("INSERT INTO sent (session_id, at) VALUES (?, ?)");
456
481
  for (const x of board.sent) sent.run(x.sessionId, x.at);
457
482
  const job = db.query(
458
- "INSERT INTO jobs (id, queue, owner, lane, priority, key, command, socket, pid, state, since, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
483
+ "INSERT INTO jobs (id, queue, owner, lane, priority, key, command, agent, socket, pid, state, since, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
459
484
  );
460
485
  for (const j of board.jobs) {
461
486
  job.run(
@@ -466,6 +491,7 @@ export class BoardStore implements BoardRepository {
466
491
  j.priority,
467
492
  j.key ?? null,
468
493
  j.command,
494
+ j.agent ?? null,
469
495
  j.socket,
470
496
  j.pid,
471
497
  j.state,
@@ -5,7 +5,7 @@ import { capOf, used } from "../../domain/agents.ts";
5
5
  import { pauseReason } from "../../domain/control.ts";
6
6
  import type { DayStats, InterruptedSession } from "../../domain/history.ts";
7
7
  import { currentLoad } from "../../domain/limits.ts";
8
- import { type AgentPolicy, type Board, labelOf, type Note } from "../../domain/model.ts";
8
+ import { type AgentPolicy, type Board, type Job, labelOf, type Note } from "../../domain/model.ts";
9
9
  import { agentList, LATEST_NOTES } from "../../domain/overview.ts";
10
10
  import { orderWaiting, type Queues, slotsOf } from "../../domain/queues.ts";
11
11
 
@@ -126,8 +126,13 @@ export function formatStatus(board: Board, config: Config, me: string, now: numb
126
126
  /** One block per queue: running jobs, then waiting jobs in the order they will start. */
127
127
  export function formatQueues(board: Board, queues: Queues, now: number): string[] {
128
128
  const lines: string[] = [];
129
- const who = (owner: string | null) =>
130
- owner === null ? "outside omp" : `session ${labelOf(board, owner) ?? "(ended)"}`;
129
+ // whose job: the session, and the agent when recorded ("session A/WaveImport")
130
+ const who = (j: Job) =>
131
+ (j.owner === null ? "outside omp" : `session ${labelOf(board, j.owner) ?? "(ended)"}`) +
132
+ (j.agent ? `/${j.agent}` : "");
133
+ // what it is: the job's key ("<checkout> <recipe> <args>") reads better than the command's full path; the command
134
+ // when there is no key
135
+ const what = (j: Job) => j.key || j.command;
131
136
  for (const [name, q] of Object.entries(queues)) {
132
137
  const running = board.jobs.filter(j => j.queue === name && j.state === "running");
133
138
  const waiting = orderWaiting(q, name, board, now);
@@ -135,14 +140,14 @@ export function formatQueues(board: Board, queues: Queues, now: number): string[
135
140
  const lowered = slots < q.slots && !board.pause ? ` (lowered from ${q.slots} under load)` : "";
136
141
  lines.push(` ${name} (${q.order}): ${running.length}/${slots} running${lowered}, ${waiting.length} waiting`);
137
142
  for (const j of running)
138
- lines.push(` running ${j.id} ${who(j.owner)} ${minutes(now - (j.startedAt ?? now))} ${j.command}`);
143
+ lines.push(` running ${j.id} ${who(j)} ${minutes(now - (j.startedAt ?? now))} ${what(j)}`);
139
144
  for (const [i, j] of waiting.entries()) {
140
145
  const extras = [
141
146
  j.lane === q.lanes.at(-1) ? "" : `lane ${j.lane}`,
142
147
  j.priority ? `priority ${j.priority}` : "",
143
148
  ].filter(Boolean);
144
149
  lines.push(
145
- ` #${i + 1} ${j.id} ${who(j.owner)} waited ${minutes(now - j.since)}${extras.length ? ` (${extras.join(", ")})` : ""} ${j.command}`,
150
+ ` #${i + 1} ${j.id} ${who(j)} waited ${minutes(now - j.since)}${extras.length ? ` (${extras.join(", ")})` : ""} ${what(j)}`,
146
151
  );
147
152
  }
148
153
  }
@@ -158,6 +158,7 @@ export class Coordinator {
158
158
  parentId: string;
159
159
  parentIsSubagent: boolean;
160
160
  agentType: string;
161
+ evalWorker?: boolean;
161
162
  }) {
162
163
  return this.repo.transact(b => agentStarted(b, this.config.agents, { ...a, now: this.clock.now() }));
163
164
  }
@@ -17,6 +17,8 @@ export interface JobSpec {
17
17
  id: string;
18
18
  queue: string;
19
19
  owner: string | null;
20
+ /** omp's id of the agent whose command this is, for display. */
21
+ agent?: string;
20
22
  lane?: string;
21
23
  priority: number;
22
24
  key?: string;
package/src/config.ts CHANGED
@@ -26,6 +26,12 @@ export interface Config {
26
26
  commands: CommandRule[];
27
27
  /** Build and test queues, machine-wide within the scope. */
28
28
  queues: Queues;
29
+ /**
30
+ * Start each agent bash command with `export OMP_COORD_AGENT=<agent id>;` so queued jobs record whose they are.
31
+ * Off by default: the rewritten command no longer matches omp's `bash.patterns` rules and replaces other
32
+ * extensions' rewrites of the same call.
33
+ */
34
+ tagAgentCommands: boolean;
29
35
  /** Port ranges and scratch folders handed to sessions and their agents. */
30
36
  resources: { ports: PortPolicy | undefined; scratch: string | undefined };
31
37
  /** Retention of the event log and the decision log. */
@@ -181,10 +187,12 @@ export function parseConfig(raw: unknown, source: string, hash = "none"): Config
181
187
  },
182
188
  holdMs: count(agents.holdMinutes ?? 5, "agents.holdMinutes", fail) * 60_000,
183
189
  startTimeoutMs: count(agents.startTimeoutSeconds ?? 120, "agents.startTimeoutSeconds", fail) * 1000,
190
+ queueSpawns: flag(agents.queueSpawns ?? true, "agents.queueSpawns", fail),
184
191
  messagesPerMinute: count(messages.perMinute ?? 10, "messages.perMinute", fail),
185
192
  },
186
193
  commands,
187
194
  queues,
195
+ tagAgentCommands: flag(root.tagAgentCommands ?? false, "tagAgentCommands", fail),
188
196
  resources: { ports, scratch },
189
197
  history: {
190
198
  maxAgeMs: count(history.retentionDays ?? 30, "history.retentionDays", fail) * 24 * 3_600_000,
@@ -205,6 +213,11 @@ function list(v: unknown, field: string, fail: Fail): unknown[] {
205
213
  return v;
206
214
  }
207
215
 
216
+ function flag(v: unknown, field: string, fail: Fail): boolean {
217
+ if (typeof v !== "boolean") fail(field, "must be true or false");
218
+ return v as boolean;
219
+ }
220
+
208
221
  function str(v: unknown, field: string, fail: Fail): string {
209
222
  if (typeof v !== "string" || v.trim() === "") return fail(field, "must be a non-empty string");
210
223
  return v;
@@ -35,6 +35,8 @@ export interface SpawnRequest {
35
35
  agentType: string;
36
36
  /** omp's spawn key: the new agent's id for `task`, eval `agent()` and workpools; only a label for some callers. */
37
37
  spawnKey?: string;
38
+ /** How omp starts it; "eval" (eval `agent()` and workpool workers) takes no place in omp's spawn queue. */
39
+ invocationKind?: string;
38
40
  now: number;
39
41
  }
40
42
 
@@ -123,6 +125,7 @@ function addStartingSlot(board: Board, req: SpawnRequest, pool: string | null):
123
125
  pool,
124
126
  state: "starting",
125
127
  since: req.now,
128
+ ...(req.invocationKind === "eval" ? { unqueued: "eval" as const } : {}),
126
129
  });
127
130
  emit(board, req.now, "spawn_approved", req.sessionId, {
128
131
  agentType: req.agentType,
@@ -146,6 +149,11 @@ export function agentStarted(
146
149
  parentId: string;
147
150
  parentIsSubagent: boolean;
148
151
  agentType: string;
152
+ /**
153
+ * A workpool worker's later run (its first run was an eval spawn): omp runs it outside the spawn check and the
154
+ * spawn queue, so its new slot is marked "eval", not "revived".
155
+ */
156
+ evalWorker?: boolean;
149
157
  now: number;
150
158
  },
151
159
  ): { pool: string; used: number; cap: number } | null {
@@ -185,6 +193,7 @@ export function agentStarted(
185
193
  pool: a.parentIsSubagent ? null : poolFor(policy, a.agentType),
186
194
  state: "running",
187
195
  since: a.now,
196
+ unqueued: a.evalWorker ? "eval" : "revived",
188
197
  };
189
198
  board.slots.push(slot);
190
199
  }
@@ -264,3 +273,33 @@ function listOr(items: string[]): string {
264
273
  if (items.length === 1) return items[0] as string;
265
274
  return `${items.slice(0, -1).join(", ")} or ${items.at(-1)}`;
266
275
  }
276
+
277
+ /**
278
+ * How many of this session's own (main-agent) spawns may run at once right now, for omp's per-session spawn queue
279
+ * (`task.maxConcurrency`), or `undefined` when the queue must not be driven and spawns are left to the spawn check.
280
+ *
281
+ * Driven only while this session is alone in the scope, not paused, and runs no eval workpool workers. omp re-reads its queue size only when this
282
+ * session asks for a spawn or one of its runs ends, and a spawn parked in that queue never reaches the spawn check, so
283
+ * it never joins the line: with other sessions, a slot one of them frees could not be held for this session fairly.
284
+ * There the refusal and the line decide, as they always did.
285
+ *
286
+ * Alone, the allowance is this session's top-level agents that hold a place in omp's queue (running or starting, not
287
+ * `unqueued` ones: revived agents and workpool workers), plus the default pool's free slots and one held for it there. Other pools'
288
+ * free slots are left out: omp's queue doesn't know a waiting spawn's pool, so counting them would let a default-pool
289
+ * spawn through to a refusal. `undefined` too when that comes to 0 (every slot taken by agents outside omp's queue):
290
+ * omp reads 0 as unlimited, and a floor of 1 would let each waiting spawn through to a refusal in turn.
291
+ */
292
+ export function spawnAllowance(board: Board, policy: AgentPolicy, sessionId: string, now: number): number | undefined {
293
+ if (board.pause || board.sessions.some(s => s.id !== sessionId)) return undefined;
294
+ // an eval workpool sizes itself from the same setting while its own workers take slots: one number can't size
295
+ // both queues, so while workers run the spawn check decides
296
+ if (board.slots.some(s => s.sessionId === sessionId && s.unqueued === "eval")) return undefined;
297
+ const pool = policy.defaultPool;
298
+ const queued = board.slots.filter(s => s.sessionId === sessionId && s.parentId === "Main" && !s.unqueued).length;
299
+ const held = board.waitlist.filter(
300
+ t => t.sessionId === sessionId && t.pool === pool && t.holdUntil !== undefined,
301
+ ).length;
302
+ const free = Math.max(0, capOf(board, policy, pool, now) - used(board, pool) - activeHolds(board, pool));
303
+ const allowance = queued + held + free;
304
+ return allowance > 0 ? allowance : undefined;
305
+ }
@@ -54,3 +54,27 @@ export function decideCommand(rules: CommandRule[], command: string, runner: Run
54
54
  function quote(word: string): string {
55
55
  return `'${word.replaceAll("'", `'\\''`)}'`;
56
56
  }
57
+
58
+ /**
59
+ * The command with the id of the agent running it exported first, so a queued job records whose it is
60
+ * (`omp-coord run` reads `OMP_COORD_AGENT`). An export, not a `VAR=… cmd` prefix, so it reaches every command of a
61
+ * compound line; each agent's next command sets it again.
62
+ */
63
+ export function withAgent(agentId: string, command: string): string {
64
+ return `export OMP_COORD_AGENT=${quote(agentId)}; ${withoutAgentExports(command)}`;
65
+ }
66
+
67
+ /** The command without the `export OMP_COORD_AGENT=…;` prefixes it starts with, if any. */
68
+ export function withoutAgentExports(command: string): string {
69
+ return command.replace(LEADING_AGENT_EXPORTS, "");
70
+ }
71
+
72
+ /**
73
+ * The `export OMP_COORD_AGENT=…;` prefixes already at the start of a command. The rewritten command is what the
74
+ * conversation shows, so an agent that copies its earlier calls sends them back; without stripping, each call would
75
+ * add one more. A value is single-quoted as `quote` writes it (`'\''` for a quote inside) or a bare word of
76
+ * shell-safe characters only, so a hand-typed value holding `\;`, `|`, `>` or a substitution is never mistaken for
77
+ * a prefix and nothing of the command itself is dropped. Only spaces and tabs may surround the assignment: in bash a
78
+ * newline ends a command, so an `export` alone on its line is a command of its own, not part of a prefix.
79
+ */
80
+ const LEADING_AGENT_EXPORTS = /^(?:\s*export[ \t]+OMP_COORD_AGENT=(?:'(?:[^']|'\\'')*'|[\w.@%+,:/=-]+)[ \t]*;)+\s*/;
@@ -22,6 +22,8 @@ export interface AgentPolicy {
22
22
  holdMs: number;
23
23
  /** How long an approved spawn may take to start its first run before its slot is given back. */
24
24
  startTimeoutMs: number;
25
+ /** Keep omp's per-session spawn queue (`task.maxConcurrency`) at what the caps allow, so spawns wait instead of being refused. */
26
+ queueSpawns: boolean;
25
27
  /** Messages one session may send per minute. */
26
28
  messagesPerMinute: number;
27
29
  }
@@ -94,6 +96,12 @@ export interface AgentSlot {
94
96
  /** `starting`: approved, first run not seen yet. `running`: in a run. */
95
97
  state: "starting" | "running";
96
98
  since: number;
99
+ /**
100
+ * Holds no place in omp's spawn queue, so `spawnAllowance` doesn't count it: started without a spawn this extension
101
+ * approved (revived with `write agent://…`, or running when this session joined), or a workpool worker (omp starts
102
+ * those outside the queue, as spawns of `invocationKind` "eval").
103
+ */
104
+ unqueued?: "revived" | "eval";
97
105
  }
98
106
 
99
107
  /** A session's place in line for a pool, after a refused spawn. */
@@ -158,6 +166,9 @@ export interface Job {
158
166
  queue: string;
159
167
  /** The omp session whose agent started it, or null (a terminal, a script). Fair share is per owner. */
160
168
  owner: string | null;
169
+ /** omp's id of the agent whose command queued it ("Main" for the main agent), or undefined (outside omp, or an
170
+ * older omp-coord that did not record it). For display only. */
171
+ agent: string | undefined;
161
172
  lane: string;
162
173
  priority: number;
163
174
  /** Jobs with the same key are duplicates (e.g. "<checkout> ci"). */
@@ -51,6 +51,8 @@ export interface JobView {
51
51
  priority: number;
52
52
  key: string | null;
53
53
  command: string;
54
+ /** omp's id of the agent whose command queued it ("Main" for the main agent); null outside omp or when not recorded. */
55
+ agent: string | null;
54
56
  state: "running" | "waiting";
55
57
  /** When it joined the line (a superseding job keeps the place of the one it replaced). */
56
58
  queuedAt: number;
@@ -136,6 +138,7 @@ export function statusView(board: Board, config: ScopeConfig, now: number): Stat
136
138
  priority: j.priority,
137
139
  key: j.key ?? null,
138
140
  command: j.command,
141
+ agent: j.agent ?? null,
139
142
  state: position === null ? "running" : "waiting",
140
143
  queuedAt: j.since,
141
144
  startedAt: j.startedAt ?? null,
@@ -43,6 +43,7 @@ export interface JobRequest {
43
43
  id: string;
44
44
  queue: string;
45
45
  owner: string | null;
46
+ agent?: string;
46
47
  lane?: string;
47
48
  priority: number;
48
49
  key?: string;
@@ -81,6 +82,7 @@ export function enqueueJob(board: Board, queues: Queues, req: JobRequest): Enque
81
82
  id: req.id,
82
83
  queue: req.queue,
83
84
  owner: req.owner,
85
+ agent: req.agent,
84
86
  lane,
85
87
  priority: req.priority,
86
88
  key: req.key,
@@ -97,6 +99,7 @@ export function enqueueJob(board: Board, queues: Queues, req: JobRequest): Enque
97
99
  lane,
98
100
  priority: req.priority,
99
101
  ...(req.key === undefined ? {} : { key: req.key }),
102
+ ...(req.agent === undefined ? {} : { agent: req.agent }),
100
103
  });
101
104
  schedule(board, queues, req.now);
102
105
  return { ok: true };