@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.
- package/.omp-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +26 -1
- package/README.md +1 -1
- package/docs/agent-slots.md +15 -2
- package/docs/commands.md +2 -1
- package/docs/configuration.md +2 -0
- package/docs/event-log.md +2 -2
- package/docs/queues.md +2 -0
- package/docs/versioning.md +13 -5
- package/package.json +1 -1
- package/src/adapters/cli/cli.ts +2 -0
- package/src/adapters/omp/api.ts +2 -0
- package/src/adapters/omp/extension.ts +89 -14
- package/src/adapters/omp/omp-host.d.ts +8 -0
- package/src/adapters/omp/spawn-queue.ts +77 -0
- package/src/adapters/sqlite/store.ts +31 -5
- package/src/adapters/text/report.ts +10 -5
- package/src/app/coordinator.ts +1 -0
- package/src/app/job-runner.ts +2 -0
- package/src/config.ts +13 -0
- package/src/domain/agents.ts +39 -0
- package/src/domain/commands.ts +24 -0
- package/src/domain/model.ts +11 -0
- package/src/domain/overview.ts +3 -0
- package/src/domain/queues.ts +3 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
package/docs/agent-slots.md
CHANGED
|
@@ -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
|

|
|
27
40
|
|
|
28
|
-
omp gives the spawn check 30 seconds and then lets the spawn through, so the extension never waits inside it: it
|
|
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. |
|
package/docs/configuration.md
CHANGED
|
@@ -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.
|
package/docs/versioning.md
CHANGED
|
@@ -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
|
-
|
|
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-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
package/src/adapters/cli/cli.ts
CHANGED
|
@@ -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 }),
|
package/src/adapters/omp/api.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
130
|
-
|
|
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
|
|
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
|
|
150
|
+
` #${i + 1} ${j.id} ${who(j)} waited ${minutes(now - j.since)}${extras.length ? ` (${extras.join(", ")})` : ""} ${what(j)}`,
|
|
146
151
|
);
|
|
147
152
|
}
|
|
148
153
|
}
|
package/src/app/coordinator.ts
CHANGED
package/src/app/job-runner.ts
CHANGED
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;
|
package/src/domain/agents.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/domain/commands.ts
CHANGED
|
@@ -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*/;
|
package/src/domain/model.ts
CHANGED
|
@@ -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"). */
|
package/src/domain/overview.ts
CHANGED
|
@@ -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,
|
package/src/domain/queues.ts
CHANGED
|
@@ -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 };
|