@nledford/omp-coord 0.2.3 → 0.3.1
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 +22 -1
- package/README.md +1 -1
- package/docs/agent-slots.md +15 -2
- package/docs/configuration.md +1 -0
- package/docs/coordination.md +1 -1
- package/docs/versioning.md +13 -5
- package/package.json +1 -1
- package/src/adapters/omp/api.ts +2 -0
- package/src/adapters/omp/extension.ts +84 -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 +8 -3
- package/src/app/coordinator.ts +1 -0
- package/src/config.ts +1 -0
- package/src/domain/agents.ts +39 -0
- package/src/domain/commands.ts +16 -1
- package/src/domain/history.ts +29 -3
- package/src/domain/model.ts +8 -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.1",
|
|
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,25 @@ 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.1] - 2026-10-27
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- `omp-coord interrupted` and `/coord interrupted` no longer list an agent that was revived (`write agent://<id>`) since its session left, from a session in the same folder, nor its nested helpers, and leave out a session with none left; before, a session restarted after a crash kept being told to revive agents that had long finished. A new agent that only reuses the name doesn't count as a revival.
|
|
14
|
+
|
|
15
|
+
## [0.3.0] - 2026-10-27
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **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).
|
|
20
|
+
- `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).
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- 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.
|
|
25
|
+
- 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.
|
|
26
|
+
- With `tagAgentCommands` on, a copied prefix no longer ends up inside a queued job's own command.
|
|
27
|
+
|
|
9
28
|
## [0.2.3] - 2026-10-27
|
|
10
29
|
|
|
11
30
|
### Added
|
|
@@ -63,7 +82,9 @@ Add each user-visible change under Unreleased as it lands, in one of: Added, Cha
|
|
|
63
82
|
- An SQLite event log per scope, with retention.
|
|
64
83
|
- Packaging as an omp plugin, with a marketplace catalog; queued commands run on omp's own Bun.
|
|
65
84
|
|
|
66
|
-
[Unreleased]: https://github.com/nledford/omp-coord/compare/v0.
|
|
85
|
+
[Unreleased]: https://github.com/nledford/omp-coord/compare/v0.3.1...HEAD
|
|
86
|
+
[0.3.1]: https://github.com/nledford/omp-coord/compare/v0.3.0...v0.3.1
|
|
87
|
+
[0.3.0]: https://github.com/nledford/omp-coord/compare/v0.2.3...v0.3.0
|
|
67
88
|
[0.2.3]: https://github.com/nledford/omp-coord/compare/v0.2.2...v0.2.3
|
|
68
89
|
[0.2.2]: https://github.com/nledford/omp-coord/compare/v0.2.1...v0.2.2
|
|
69
90
|
[0.2.1]: https://github.com/nledford/omp-coord/compare/v0.2.0...v0.2.1
|
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/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, `.`, `_`, `-`. |
|
package/docs/coordination.md
CHANGED
|
@@ -36,6 +36,6 @@ With `resources.ports`, each session gets its own block of ports, in `OMP_COORD_
|
|
|
36
36
|
|
|
37
37
|
## After a crash
|
|
38
38
|
|
|
39
|
-
`/coord interrupted` (or `omp-coord interrupted`, which first drops sessions whose process is gone) lists, per session that left, the agents that were mid-run and the folder the session ran in. omp keeps their transcripts; revive each from a session in that folder with `write agent://<id>`.
|
|
39
|
+
`/coord interrupted` (or `omp-coord interrupted`, which first drops sessions whose process is gone) lists, per session that left, the agents that were mid-run and the folder the session ran in. omp keeps their transcripts; revive each from a session in that folder with `write agent://<id>`. An agent revived since from a session in that folder, and its nested helpers, are left out; a new agent that only reuses the name is not a revival.
|
|
40
40
|
|
|
41
41
|
`/coord agents` (or `omp-coord status --agents`) lists who runs: each agent's session, id, type, parent, pool and age, so a message to `B/<agent id>` goes to the agent you mean. `/coord stats` sums waits, refusals, peaks and job outcomes per day; `omp-coord doctor` checks the scope's health, and `doctor --fix` drops sessions and jobs whose process is gone.
|
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/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,15 @@ 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
|
-
const run = decision.kind === "queue" ? decision.command :
|
|
216
|
+
const run = decision.kind === "queue" ? decision.command : own;
|
|
194
217
|
// Every agent of a session shares the omp process's environment, so the agent's id can reach `omp-coord run` (and
|
|
195
218
|
// the queue lines) only through the command itself; opt-in (`tagAgentCommands`), since the rewrite defeats omp's
|
|
196
219
|
// bash.patterns rules and other extensions' rewrites. Off, the hook changes only queued commands, as before.
|
|
@@ -427,6 +450,8 @@ export default function ompCoord(pi: ExtensionAPI): void {
|
|
|
427
450
|
}
|
|
428
451
|
|
|
429
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);
|
|
430
455
|
const configPath = findConfigFile(ctx.cwd);
|
|
431
456
|
if (!configPath) return;
|
|
432
457
|
let config: Config;
|
|
@@ -438,6 +463,19 @@ async function start(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
|
438
463
|
return;
|
|
439
464
|
}
|
|
440
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
|
+
}
|
|
441
479
|
let joined: Pick<Runtime, "store" | "coordinator" | "listener" | "sessionId" | "label">;
|
|
442
480
|
try {
|
|
443
481
|
joined = openSession(config, ctx.cwd);
|
|
@@ -447,7 +485,19 @@ async function start(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
|
447
485
|
return;
|
|
448
486
|
}
|
|
449
487
|
|
|
450
|
-
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
|
+
};
|
|
451
501
|
shared.runtime = rt;
|
|
452
502
|
// Commands this session's agents run (e.g. a `just` recipe calling `omp-coord run`) see these.
|
|
453
503
|
process.env.OMP_COORD_SESSION = rt.sessionId;
|
|
@@ -456,6 +506,7 @@ async function start(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
|
456
506
|
const scratch = scratchFor(rt);
|
|
457
507
|
if (scratch) process.env.OMP_COORD_SCRATCH = scratch;
|
|
458
508
|
rt.timer = ctx.setInterval(() => tick(rt), TICK_MS);
|
|
509
|
+
refreshQueue(rt);
|
|
459
510
|
await prune(rt);
|
|
460
511
|
|
|
461
512
|
const board = rt.coordinator.board();
|
|
@@ -468,7 +519,8 @@ async function start(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
|
468
519
|
(others.length
|
|
469
520
|
? `Other sessions: ${others.map(s => `${s.label} (${s.cwd})`).join(", ")}. Agent caps are shared with them.`
|
|
470
521
|
: "No other session is active.") +
|
|
471
|
-
pausedNote
|
|
522
|
+
pausedNote +
|
|
523
|
+
` ${queueNote}`,
|
|
472
524
|
"info",
|
|
473
525
|
);
|
|
474
526
|
if (differing.length) {
|
|
@@ -522,8 +574,18 @@ function scratchFor(rt: Runtime, agentId?: string): string | undefined {
|
|
|
522
574
|
return dir;
|
|
523
575
|
}
|
|
524
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
|
+
|
|
525
586
|
function stop(rt: Runtime): void {
|
|
526
587
|
shared.runtime = undefined;
|
|
588
|
+
rt.queue?.release();
|
|
527
589
|
for (const key of ["OMP_COORD_SESSION", "OMP_COORD_PORTS", "OMP_COORD_SCRATCH"]) delete process.env[key];
|
|
528
590
|
rt.ctx.clearTimer(rt.timer);
|
|
529
591
|
try {
|
|
@@ -540,6 +602,7 @@ async function tick(rt: Runtime): Promise<void> {
|
|
|
540
602
|
const now = Date.now();
|
|
541
603
|
const { label, inbox, holds, board } = rt.coordinator.tick(identity(rt.sessionId, rt.ctx.cwd, rt.listener));
|
|
542
604
|
rt.label = label;
|
|
605
|
+
rt.queue?.apply(spawnAllowance(board, rt.config.agents, rt.sessionId, now));
|
|
543
606
|
|
|
544
607
|
for (const m of inbox) {
|
|
545
608
|
const body =
|
|
@@ -588,7 +651,14 @@ function sendFrom(rt: Runtime, to: string, body: string): ToolResult {
|
|
|
588
651
|
}
|
|
589
652
|
|
|
590
653
|
function statusText(rt: Runtime): string {
|
|
591
|
-
|
|
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}`;
|
|
592
662
|
}
|
|
593
663
|
|
|
594
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,
|
|
@@ -255,6 +256,8 @@ export class BoardStore implements BoardRepository {
|
|
|
255
256
|
.all()
|
|
256
257
|
.some(c => c.name === column);
|
|
257
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'))");
|
|
258
261
|
}
|
|
259
262
|
|
|
260
263
|
#maybePruneEvents(now: number): void {
|
|
@@ -304,6 +307,7 @@ export class BoardStore implements BoardRepository {
|
|
|
304
307
|
pool: string | null;
|
|
305
308
|
state: "starting" | "running";
|
|
306
309
|
since: number;
|
|
310
|
+
unqueued: "revived" | "eval" | null;
|
|
307
311
|
},
|
|
308
312
|
[]
|
|
309
313
|
>("SELECT * FROM slots ORDER BY id")
|
|
@@ -316,6 +320,7 @@ export class BoardStore implements BoardRepository {
|
|
|
316
320
|
pool: r.pool,
|
|
317
321
|
state: r.state,
|
|
318
322
|
since: r.since,
|
|
323
|
+
...(r.unqueued ? { unqueued: r.unqueued } : {}),
|
|
319
324
|
})),
|
|
320
325
|
waitlist: db
|
|
321
326
|
.query<{ session_id: string; pool: string; since: number; hold_until: number | null; notified: number }, []>(
|
|
@@ -462,10 +467,10 @@ export class BoardStore implements BoardRepository {
|
|
|
462
467
|
s.ports?.to ?? null,
|
|
463
468
|
);
|
|
464
469
|
const slot = db.query(
|
|
465
|
-
"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 (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
466
471
|
);
|
|
467
472
|
for (const s of board.slots)
|
|
468
|
-
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);
|
|
469
474
|
const ticket = db.query(
|
|
470
475
|
"INSERT INTO tickets (session_id, pool, since, hold_until, notified) VALUES (?, ?, ?, ?, ?)",
|
|
471
476
|
);
|
package/src/app/coordinator.ts
CHANGED
package/src/config.ts
CHANGED
|
@@ -187,6 +187,7 @@ export function parseConfig(raw: unknown, source: string, hash = "none"): Config
|
|
|
187
187
|
},
|
|
188
188
|
holdMs: count(agents.holdMinutes ?? 5, "agents.holdMinutes", fail) * 60_000,
|
|
189
189
|
startTimeoutMs: count(agents.startTimeoutSeconds ?? 120, "agents.startTimeoutSeconds", fail) * 1000,
|
|
190
|
+
queueSpawns: flag(agents.queueSpawns ?? true, "agents.queueSpawns", fail),
|
|
190
191
|
messagesPerMinute: count(messages.perMinute ?? 10, "messages.perMinute", fail),
|
|
191
192
|
},
|
|
192
193
|
commands,
|
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
|
@@ -61,5 +61,20 @@ function quote(word: string): string {
|
|
|
61
61
|
* compound line; each agent's next command sets it again.
|
|
62
62
|
*/
|
|
63
63
|
export function withAgent(agentId: string, command: string): string {
|
|
64
|
-
return `export OMP_COORD_AGENT=${quote(agentId)}; ${command}`;
|
|
64
|
+
return `export OMP_COORD_AGENT=${quote(agentId)}; ${withoutAgentExports(command)}`;
|
|
65
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/history.ts
CHANGED
|
@@ -11,18 +11,34 @@ export interface InterruptedSession {
|
|
|
11
11
|
/**
|
|
12
12
|
* Sessions that left (exited, or were found dead) at or after `since` while some of their agents were in a run:
|
|
13
13
|
* those runs started and never ended. Revive them with `write agent://<id>` from a session in the same folder.
|
|
14
|
+
*
|
|
15
|
+
* An agent revived since, from a session in the same folder, is no longer listed, and neither are its nested
|
|
16
|
+
* helpers (they aren't restarted; reviving the parent covers them); a session with nothing left is left out. A
|
|
17
|
+
* revival is a run that started without a spawn approval (`revived` in the event) by an id that session hadn't run
|
|
18
|
+
* before: a new agent that reuses the name was spawned, so it doesn't hide the old one. A session whose folder is
|
|
19
|
+
* unknown (its join event pruned) never hides anything.
|
|
14
20
|
*/
|
|
15
21
|
export function interrupted(events: BoardEvent[], since: number): InterruptedSession[] {
|
|
16
22
|
const cwd = new Map<string, string>();
|
|
17
23
|
const running = new Map<string, Map<string, { id: string; type: string; startedAt: number }>>();
|
|
24
|
+
const seen = new Map<string, Set<string>>();
|
|
25
|
+
// The latest revival per folder and agent id.
|
|
26
|
+
const revivedAt = new Map<string, number>();
|
|
18
27
|
const result: InterruptedSession[] = [];
|
|
19
28
|
for (const e of events) {
|
|
20
29
|
if (e.sessionId === null) continue;
|
|
21
30
|
const runs = running.get(e.sessionId) ?? new Map();
|
|
22
31
|
running.set(e.sessionId, runs);
|
|
23
32
|
if (e.type === "session_joined" && typeof e.data.cwd === "string") cwd.set(e.sessionId, e.data.cwd);
|
|
24
|
-
if (e.type === "run_started")
|
|
25
|
-
|
|
33
|
+
if (e.type === "run_started") {
|
|
34
|
+
const id = String(e.data.agentId);
|
|
35
|
+
runs.set(id, { id, type: String(e.data.agentType), startedAt: e.at });
|
|
36
|
+
const own = seen.get(e.sessionId) ?? new Set<string>();
|
|
37
|
+
seen.set(e.sessionId, own);
|
|
38
|
+
const folder = cwd.get(e.sessionId);
|
|
39
|
+
if (folder !== undefined && e.data.revived === true && !own.has(id)) revivedAt.set(`${folder}\u0000${id}`, e.at);
|
|
40
|
+
own.add(id);
|
|
41
|
+
}
|
|
26
42
|
if (e.type === "run_ended") runs.delete(String(e.data.agentId));
|
|
27
43
|
if (e.type === "session_left") {
|
|
28
44
|
if (e.at >= since && runs.size)
|
|
@@ -30,7 +46,17 @@ export function interrupted(events: BoardEvent[], since: number): InterruptedSes
|
|
|
30
46
|
running.delete(e.sessionId);
|
|
31
47
|
}
|
|
32
48
|
}
|
|
33
|
-
|
|
49
|
+
const revivedSince = (folder: string | undefined, id: string, leftAt: number): boolean =>
|
|
50
|
+
folder !== undefined && (revivedAt.get(`${folder}\u0000${id}`) ?? 0) > leftAt;
|
|
51
|
+
return result
|
|
52
|
+
.map(s => ({
|
|
53
|
+
...s,
|
|
54
|
+
agents: s.agents.filter(a => {
|
|
55
|
+
const top = a.id.split(".")[0] ?? a.id;
|
|
56
|
+
return !revivedSince(s.cwd, a.id, s.leftAt) && !revivedSince(s.cwd, top, s.leftAt);
|
|
57
|
+
}),
|
|
58
|
+
}))
|
|
59
|
+
.filter(s => s.agents.length > 0);
|
|
34
60
|
}
|
|
35
61
|
|
|
36
62
|
export interface QueueDay {
|
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. */
|