@nledford/omp-coord 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.omp-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +20 -1
- package/README.md +8 -3
- package/docs/commands.md +60 -1
- package/docs/coordination.md +1 -1
- package/docs/install.md +24 -17
- package/docs/versioning.md +1 -0
- package/package.json +2 -5
- package/src/adapters/cli/cli.ts +45 -31
- package/src/adapters/omp/extension.ts +12 -2
- package/src/adapters/os/process-group.ts +44 -0
- package/src/adapters/text/report.ts +33 -1
- package/src/domain/overview.ts +212 -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.2.
|
|
10
|
+
"version": "0.2.2",
|
|
11
11
|
"author": { "name": "Nathaniel Ledford" },
|
|
12
12
|
"homepage": "https://github.com/nledford/omp-coord",
|
|
13
13
|
"repository": "https://github.com/nledford/omp-coord",
|
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,23 @@ Add each user-visible change under Unreleased as it lands, in one of: Added, Cha
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.2] - 2026-10-27
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `omp-coord status --json` prints the board as one JSON object for scripts (scope, sessions, pools, line, agents, claims, queues with their running and waiting jobs, latest notes, load; times in epoch milliseconds). With a board that cannot be read it prints nothing to stdout and exits non-zero. The fields are documented in [docs/commands.md](docs/commands.md).
|
|
14
|
+
- A list of the running agents with their type, parent, pool, state and age: `/coord agents`, `omp-coord status --agents`, and `agents` in `status --json`.
|
|
15
|
+
|
|
16
|
+
## [0.2.1] - 2026-10-26
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Releases are published to npmjs.com as `@nledford/omp-coord`, with provenance: `omp plugin install @nledford/omp-coord` needs no token or `.npmrc`.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- On Linux, where `/proc` cannot be read (some hardened containers), a timed-out job now falls back to signalling its process group instead of failing.
|
|
25
|
+
|
|
9
26
|
## [0.2.0] - 2026-10-26
|
|
10
27
|
|
|
11
28
|
### Added
|
|
@@ -36,6 +53,8 @@ Add each user-visible change under Unreleased as it lands, in one of: Added, Cha
|
|
|
36
53
|
- An SQLite event log per scope, with retention.
|
|
37
54
|
- Packaging as an omp plugin, with a marketplace catalog; queued commands run on omp's own Bun.
|
|
38
55
|
|
|
39
|
-
[Unreleased]: https://github.com/nledford/omp-coord/compare/v0.2.
|
|
56
|
+
[Unreleased]: https://github.com/nledford/omp-coord/compare/v0.2.2...HEAD
|
|
57
|
+
[0.2.2]: https://github.com/nledford/omp-coord/compare/v0.2.1...v0.2.2
|
|
58
|
+
[0.2.1]: https://github.com/nledford/omp-coord/compare/v0.2.0...v0.2.1
|
|
40
59
|
[0.2.0]: https://github.com/nledford/omp-coord/compare/v0.1.0...v0.2.0
|
|
41
60
|
[0.1.0]: https://github.com/nledford/omp-coord/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# omp-coord
|
|
2
2
|
|
|
3
|
+
[](https://github.com/nledford/omp-coord/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@nledford/omp-coord)
|
|
5
|
+
[](https://github.com/can1357/oh-my-pi)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
3
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.
|
|
4
9
|
|
|
5
10
|
- **Shared agent caps.** Subagents count against pools (for example 8 `work` and 2 `short` slots) shared by every session in the project. A spawn over the cap is refused through omp's spawn check, so the cap does not depend on agents remembering to take and free slots.
|
|
@@ -16,7 +21,7 @@ Each session gets a letter and shows `coord B · work 5/8 · short 1/2` in omp's
|
|
|
16
21
|
## Quick start
|
|
17
22
|
|
|
18
23
|
```sh
|
|
19
|
-
omp plugin install
|
|
24
|
+
omp plugin install @nledford/omp-coord
|
|
20
25
|
```
|
|
21
26
|
|
|
22
27
|
Then put a `.omp-coord.json` in the project's top folder:
|
|
@@ -36,12 +41,12 @@ Every omp session started in that folder or below now shares 6 agent slots, and
|
|
|
36
41
|
|
|
37
42
|
| | |
|
|
38
43
|
|---|---|
|
|
39
|
-
| [Install](docs/install.md) |
|
|
44
|
+
| [Install](docs/install.md) | From npm, GitHub, the marketplace or GitHub Packages; updating; the `omp-coord` command. |
|
|
40
45
|
| [Configuration](docs/configuration.md) | Every `.omp-coord.json` field. |
|
|
41
46
|
| [Agent slots](docs/agent-slots.md) | How caps, the line, holds and nested agents work. |
|
|
42
47
|
| [Queues](docs/queues.md) | `omp-coord run`, order, lanes, time limits, agents' commands. |
|
|
43
48
|
| [Working across sessions](docs/coordination.md) | Messages, claims, pause, the decision log, ports, crash recovery. |
|
|
44
|
-
| [Commands](docs/commands.md) | `/coord`, agent tools, CLI, environment variables. |
|
|
49
|
+
| [Commands](docs/commands.md) | `/coord`, agent tools, CLI (`status --json` for scripts), environment variables. |
|
|
45
50
|
| [Event log](docs/event-log.md) | The SQLite tables and events, with example queries. |
|
|
46
51
|
| [Versioning](docs/versioning.md) | What the version promises; cutting a release. |
|
|
47
52
|
|
package/docs/commands.md
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
| Command | Does |
|
|
6
6
|
|---|---|
|
|
7
7
|
| `/coord` | Sessions, slots, the line, claims, queues, the latest decisions; marks a paused scope and sessions reading a different config file. |
|
|
8
|
+
| `/coord agents` | The agents holding slots, one line each: session, id, type, parent (`Main` or an agent id), pool or `outside the caps`, state and age. Use it to see who runs, and what type each is, before messaging them. |
|
|
8
9
|
| `/coord send <letter\|B/agent\|all> <text>` | Message other sessions or one of their subagents. |
|
|
9
10
|
| `/coord pause [reason]`, `/coord resume` | Stop and restart new agents and queued jobs in every session. |
|
|
10
11
|
| `/coord claim <branch\|worktree\|pr> <target> [note]`, `/coord release <kind> <target>` | Claim or release. |
|
|
@@ -26,12 +27,70 @@
|
|
|
26
27
|
|
|
27
28
|
```sh
|
|
28
29
|
omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] -- <command> [args…]
|
|
29
|
-
omp-coord status | stats [days] | interrupted [hours] | notes [count] | note <text…>
|
|
30
|
+
omp-coord status [--agents | --json] | stats [days] | interrupted [hours] | notes [count] | note <text…>
|
|
30
31
|
omp-coord pause [reason…] | resume | doctor [--fix]
|
|
31
32
|
```
|
|
32
33
|
|
|
33
34
|
`run` is described under [queues](queues.md). The others act on the scope whose `.omp-coord.json` is found from the current folder.
|
|
34
35
|
|
|
36
|
+
### `status`
|
|
37
|
+
|
|
38
|
+
`omp-coord status` prints the same overview as `/coord`. `--agents` prints only the running agents, as `/coord agents` does:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
Agents (3):
|
|
42
|
+
A Fixer task parent Main pool work running 5 min
|
|
43
|
+
A Fixer.Check reviewer parent Fixer outside the caps running 4 min
|
|
44
|
+
B Other task parent Main pool work running 2 min
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Each line is the session letter, the agent's id (`(no id yet)` for an approved spawn omp gave no key for), its type, its parent, its pool, its state (`running`, or `starting` until its first run is seen) and how long ago it took its slot.
|
|
48
|
+
|
|
49
|
+
`--json` prints the board as one JSON object on stdout, for scripts. It is built whole before anything is written: if the board cannot be read, nothing goes to stdout, the error goes to stderr, and the exit code is not `0` (`2` for a usage or configuration error, `1` otherwise). `--json` and `--agents` do not combine.
|
|
50
|
+
|
|
51
|
+
Every time is milliseconds since the epoch (`Date.now()`); a session is its letter, or `null` when there is none (a job started outside omp, or by a session that has ended). Fields are never omitted: a missing value is `null`, a list is `[]`. Removing or renaming a field is a breaking change ([versioning](versioning.md)); new fields may appear.
|
|
52
|
+
|
|
53
|
+
| Field | Meaning |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `generatedAt` | When the object was built. |
|
|
56
|
+
| `scope` | `{ name, source }`: the scope and the config file it was read from. |
|
|
57
|
+
| `paused` | `null`, or `{ by, byId, reason, since }`: `by` is the pausing session's letter, `byId` its id; both are `null` for a pause from a terminal, and only `by` once that session has ended. |
|
|
58
|
+
| `load` | The load average the limits act on, or `null` without a fresh reading. |
|
|
59
|
+
| `sessions[]` | `letter`, `id`, `folder`, `pid`, `since`, `agents` (`{ pools: { <pool>: slots held }, outsideCaps }`), `ports` (`{ from, to }` or `null`), `differentConfig` (read another config file than this scope's). By letter. |
|
|
60
|
+
| `pools[]` | `name`, `used`, `cap` (now: lowered under load, `0` while paused), `configuredCap`. |
|
|
61
|
+
| `line[]` | Sessions waiting for a slot: `pool`, `session`, `position` (from 1), `since`, `heldUntil` (`null` unless a freed slot is held for it). |
|
|
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
|
+
| `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). |
|
|
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
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"generatedAt": 1793000000000,
|
|
70
|
+
"scope": { "name": "demo", "source": "/work/demo/.omp-coord.json" },
|
|
71
|
+
"paused": null,
|
|
72
|
+
"load": 3.5,
|
|
73
|
+
"sessions": [
|
|
74
|
+
{ "letter": "A", "id": "7f3c…", "folder": "/work/demo", "pid": 4210, "since": 1792999000000,
|
|
75
|
+
"agents": { "pools": { "work": 1 }, "outsideCaps": 1 }, "ports": null, "differentConfig": false }
|
|
76
|
+
],
|
|
77
|
+
"pools": [{ "name": "work", "used": 1, "cap": 2, "configuredCap": 2 }],
|
|
78
|
+
"line": [],
|
|
79
|
+
"agents": [
|
|
80
|
+
{ "id": "Fixer", "type": "task", "parent": "Main", "session": "A", "pool": "work", "state": "running", "since": 1792999300000 },
|
|
81
|
+
{ "id": "Fixer.Check", "type": "reviewer", "parent": "Fixer", "session": "A", "pool": null, "state": "running", "since": 1792999400000 }
|
|
82
|
+
],
|
|
83
|
+
"claims": [],
|
|
84
|
+
"queues": [
|
|
85
|
+
{ "name": "heavy", "order": "fifo", "slots": 1, "configuredSlots": 1,
|
|
86
|
+
"running": [{ "id": "a1b2c3d4", "session": "A", "owner": "7f3c…", "lane": "normal", "priority": 0, "key": "demo ci",
|
|
87
|
+
"command": "cargo test", "state": "running", "queuedAt": 1792999500000, "startedAt": 1792999500000, "position": null }],
|
|
88
|
+
"waiting": [] }
|
|
89
|
+
],
|
|
90
|
+
"notes": []
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
35
94
|
Exit codes: `0` success, the command's own for `run`, `75` a job not run, `124` a job stopped by its time limit, `2` a usage or configuration error.
|
|
36
95
|
|
|
37
96
|
## Environment
|
package/docs/coordination.md
CHANGED
|
@@ -38,4 +38,4 @@ With `resources.ports`, each session gets its own block of ports, in `OMP_COORD_
|
|
|
38
38
|
|
|
39
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>`.
|
|
40
40
|
|
|
41
|
-
`/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.
|
|
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/install.md
CHANGED
|
@@ -2,43 +2,50 @@
|
|
|
2
2
|
|
|
3
3
|
Requires omp 18.12 or later. No dependencies at runtime: the extension and the runner use the Bun built into omp.
|
|
4
4
|
|
|
5
|
-
## From
|
|
5
|
+
## From npm
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
omp plugin install @nledford/omp-coord
|
|
9
|
+
omp plugin install @nledford/omp-coord@0.2.0 # a given release
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Releases are published to npmjs.com from the release workflow, with [provenance](https://docs.npmjs.com/generating-provenance-statements) that ties each package to the commit and workflow that built it (from 0.2.1 on). A machine set up for GitHub Packages (below) maps the `@nledford` scope to GitHub, so this command installs from there instead.
|
|
6
13
|
|
|
7
|
-
|
|
14
|
+
## From GitHub
|
|
8
15
|
|
|
9
16
|
```sh
|
|
10
|
-
|
|
11
|
-
cat >> ~/.npmrc <<EOF
|
|
12
|
-
@nledford:registry=https://npm.pkg.github.com
|
|
13
|
-
//npm.pkg.github.com/:_authToken=$(gh auth token)
|
|
14
|
-
EOF
|
|
17
|
+
omp plugin install github:nledford/omp-coord
|
|
15
18
|
```
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
Or through omp's marketplace, which this repository also serves:
|
|
18
21
|
|
|
19
22
|
```sh
|
|
20
|
-
omp plugin
|
|
21
|
-
omp plugin install @
|
|
23
|
+
omp plugin marketplace add nledford/omp-coord
|
|
24
|
+
omp plugin install omp-coord@omp-coord
|
|
22
25
|
```
|
|
23
26
|
|
|
24
|
-
## From GitHub
|
|
27
|
+
## From GitHub Packages
|
|
25
28
|
|
|
26
|
-
|
|
29
|
+
Each release is also published to GitHub Packages. GitHub Packages asks for a token to install any npm package, public ones included, so each machine needs one-time setup: a token that can read packages, and the `@nledford` scope pointed at GitHub's registry in `~/.npmrc`.
|
|
27
30
|
|
|
28
31
|
```sh
|
|
29
|
-
|
|
32
|
+
gh auth refresh --scopes read:packages # once: let the GitHub CLI's token read packages
|
|
33
|
+
cat >> ~/.npmrc <<EOF
|
|
34
|
+
@nledford:registry=https://npm.pkg.github.com
|
|
35
|
+
//npm.pkg.github.com/:_authToken=$(gh auth token)
|
|
36
|
+
EOF
|
|
30
37
|
```
|
|
31
38
|
|
|
32
|
-
|
|
39
|
+
Then install a release, or the newest one:
|
|
33
40
|
|
|
34
41
|
```sh
|
|
35
|
-
omp plugin
|
|
36
|
-
omp plugin install
|
|
42
|
+
omp plugin install @nledford/omp-coord@0.2.0
|
|
43
|
+
omp plugin install @nledford/omp-coord
|
|
37
44
|
```
|
|
38
45
|
|
|
39
46
|
## Updating
|
|
40
47
|
|
|
41
|
-
`omp plugin upgrade @nledford/omp-coord` moves a package or git
|
|
48
|
+
`omp plugin upgrade @nledford/omp-coord` moves a package or git install to the newest release or commit; `omp plugin upgrade omp-coord@omp-coord` moves a marketplace install once a release has raised the catalog's version. Bun remembers the commit a git install resolved to, so installing from git again can bring back an older commit; to pin a release, add its tag: `github:nledford/omp-coord#v0.2.0`. [CHANGELOG.md](../CHANGELOG.md) lists what each release changed.
|
|
42
49
|
|
|
43
50
|
## From a checkout
|
|
44
51
|
|
package/docs/versioning.md
CHANGED
|
@@ -8,6 +8,7 @@ The version covers:
|
|
|
8
8
|
|
|
9
9
|
- the `.omp-coord.json` fields, their defaults and what they accept ([configuration](configuration.md));
|
|
10
10
|
- the `omp-coord` subcommands, flags and exit codes (75, 124, 2) ([commands](commands.md));
|
|
11
|
+
- the fields of `omp-coord status --json` ([commands](commands.md#status));
|
|
11
12
|
- the agent tools (`coord_*`) and their parameters, and the `/coord` commands;
|
|
12
13
|
- the environment variables it sets or reads (`OMP_COORD_*`);
|
|
13
14
|
- the event types and fields in the event log, and the decision log ([event log](event-log.md));
|
package/package.json
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nledford/omp-coord",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"publishConfig": {
|
|
5
|
-
"registry": "https://npm.pkg.github.com"
|
|
6
|
-
},
|
|
3
|
+
"version": "0.2.2",
|
|
7
4
|
"files": [
|
|
8
5
|
"src",
|
|
9
6
|
"docs",
|
|
@@ -16,7 +13,7 @@
|
|
|
16
13
|
"author": "Nathaniel Ledford",
|
|
17
14
|
"repository": {
|
|
18
15
|
"type": "git",
|
|
19
|
-
"url": "https://github.com/nledford/omp-coord.git"
|
|
16
|
+
"url": "git+https://github.com/nledford/omp-coord.git"
|
|
20
17
|
},
|
|
21
18
|
"keywords": [
|
|
22
19
|
"omp",
|
package/src/adapters/cli/cli.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* terminals, or agents' bash calls (which the extension rewrites to go through here), and reports on the scope.
|
|
5
5
|
*
|
|
6
6
|
* omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] -- <command> [args…]
|
|
7
|
-
* omp-coord status | stats [days] | interrupted [hours] | notes [count] | note <text…>
|
|
7
|
+
* omp-coord status [--agents | --json] | stats [days] | interrupted [hours] | notes [count] | note <text…>
|
|
8
8
|
* omp-coord pause [reason…] | resume | doctor [--fix]
|
|
9
9
|
*
|
|
10
10
|
* `run` waits for a slot, runs the command with the terminal's stdin/stdout/stderr, and exits with its exit code.
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* 2 a usage or configuration error.
|
|
15
15
|
*/
|
|
16
16
|
import { randomBytes } from "node:crypto";
|
|
17
|
-
import { mkdirSync, readdirSync,
|
|
17
|
+
import { mkdirSync, readdirSync, statSync } from "node:fs";
|
|
18
18
|
import { constants, userInfo } from "node:os";
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { parseArgs } from "node:util";
|
|
@@ -22,10 +22,19 @@ import { Coordinator } from "../../app/coordinator.ts";
|
|
|
22
22
|
import { doctor } from "../../app/doctor.ts";
|
|
23
23
|
import { EXIT_TIMED_OUT, runJob } from "../../app/job-runner.ts";
|
|
24
24
|
import { type Config, ConfigError, findConfigFile, loadConfig } from "../../config.ts";
|
|
25
|
+
import { statusView } from "../../domain/overview.ts";
|
|
25
26
|
import { MAX_PRIORITY } from "../../domain/queues.ts";
|
|
26
27
|
import { osLoad } from "../os/load.ts";
|
|
28
|
+
import { groupRunning } from "../os/process-group.ts";
|
|
27
29
|
import { BoardStore, stateDir } from "../sqlite/store.ts";
|
|
28
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
formatAgents,
|
|
32
|
+
formatFindings,
|
|
33
|
+
formatInterrupted,
|
|
34
|
+
formatNotes,
|
|
35
|
+
formatStats,
|
|
36
|
+
formatStatus,
|
|
37
|
+
} from "../text/report.ts";
|
|
29
38
|
import { startBeacon, unixLiveness } from "../unix/beacon.ts";
|
|
30
39
|
|
|
31
40
|
const EXIT_USAGE = 2;
|
|
@@ -35,18 +44,49 @@ const DAY = 86_400_000;
|
|
|
35
44
|
|
|
36
45
|
const USAGE = `usage:
|
|
37
46
|
omp-coord run <queue> [--lane L] [--priority N] [--key K] [--owner SESSION] [--timeout 90m] -- <command…>
|
|
38
|
-
omp-coord status | stats [days] | interrupted [hours] | notes [count] | note <text…>
|
|
47
|
+
omp-coord status [--agents | --json] | stats [days] | interrupted [hours] | notes [count] | note <text…>
|
|
39
48
|
omp-coord pause [reason…] | resume | doctor [--fix]`;
|
|
40
49
|
|
|
41
50
|
class UsageError extends Error {}
|
|
42
51
|
|
|
52
|
+
/**
|
|
53
|
+
* `status` prints the board as text; `--agents` lists only the running agents; `--json` prints one JSON object. The
|
|
54
|
+
* output is built whole before anything is written, so a board that cannot be read leaves stdout empty.
|
|
55
|
+
*/
|
|
56
|
+
function status(args: string[]): Promise<number> {
|
|
57
|
+
let flags: ReturnType<typeof parseStatusFlags>;
|
|
58
|
+
try {
|
|
59
|
+
flags = parseStatusFlags(args);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throw new UsageError(`omp-coord status: ${error instanceof Error ? error.message : String(error)}`);
|
|
62
|
+
}
|
|
63
|
+
const { agents, json } = flags.values;
|
|
64
|
+
if (flags.positionals.length > 0 || (agents && json))
|
|
65
|
+
throw new UsageError("omp-coord status: give no arguments, or one of --agents and --json");
|
|
66
|
+
return withScope(({ config, coordinator }) => {
|
|
67
|
+
const board = coordinator.board();
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
if (json) return print(JSON.stringify(statusView(board, config, now), null, 2));
|
|
70
|
+
return print(agents ? formatAgents(board, now) : formatStatus(board, config, "", now));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseStatusFlags(args: string[]) {
|
|
75
|
+
return parseArgs({
|
|
76
|
+
args,
|
|
77
|
+
allowPositionals: true,
|
|
78
|
+
strict: true,
|
|
79
|
+
options: { agents: { type: "boolean" }, json: { type: "boolean" } },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
43
83
|
async function main(argv: string[]): Promise<number> {
|
|
44
84
|
const [sub, ...rest] = argv;
|
|
45
85
|
switch (sub) {
|
|
46
86
|
case "run":
|
|
47
87
|
return run(rest);
|
|
48
88
|
case "status":
|
|
49
|
-
return
|
|
89
|
+
return status(rest);
|
|
50
90
|
case "stats":
|
|
51
91
|
return withScope(({ coordinator }) =>
|
|
52
92
|
print(formatStats(coordinator.stats(wholeNumber(rest[0], 7, "days") * DAY))),
|
|
@@ -309,32 +349,6 @@ function signalNumber(signal: NodeJS.Signals): number {
|
|
|
309
349
|
return constants.signals[signal] ?? 1;
|
|
310
350
|
}
|
|
311
351
|
|
|
312
|
-
/**
|
|
313
|
-
* Whether process group `pgid` still has a member that has not exited. A signal to the group succeeds while it holds
|
|
314
|
-
* only zombies, which is what an exited command's children become in a container whose first process never reaps
|
|
315
|
-
* orphans; on Linux, /proc tells them apart. Elsewhere (macOS has no /proc; launchd reaps orphans) the signal decides.
|
|
316
|
-
*/
|
|
317
|
-
function groupRunning(pgid: number): boolean {
|
|
318
|
-
try {
|
|
319
|
-
process.kill(-pgid, 0);
|
|
320
|
-
} catch {
|
|
321
|
-
return false;
|
|
322
|
-
}
|
|
323
|
-
if (process.platform !== "linux") return true;
|
|
324
|
-
for (const entry of readdirSync("/proc")) {
|
|
325
|
-
if (!/^\d+$/.test(entry)) continue;
|
|
326
|
-
try {
|
|
327
|
-
// /proc/<pid>/stat: "pid (comm) state ppid pgrp …"; comm may contain spaces and parentheses.
|
|
328
|
-
const stat = readFileSync(`/proc/${entry}/stat`, "utf8");
|
|
329
|
-
const [state, , pgrp] = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
330
|
-
if (Number(pgrp) === pgid && state !== "Z") return true;
|
|
331
|
-
} catch {
|
|
332
|
-
// The process ended while being read.
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
return false;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
352
|
if (import.meta.main) {
|
|
339
353
|
try {
|
|
340
354
|
process.exitCode = await main(Bun.argv.slice(2));
|
|
@@ -20,7 +20,15 @@ import { decideCommand } from "../../domain/commands.ts";
|
|
|
20
20
|
import type { Claim } from "../../domain/model.ts";
|
|
21
21
|
import { osLoad } from "../os/load.ts";
|
|
22
22
|
import { BoardStore, stateDir } from "../sqlite/store.ts";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
clock,
|
|
25
|
+
formatAgents,
|
|
26
|
+
formatFooter,
|
|
27
|
+
formatInterrupted,
|
|
28
|
+
formatNotes,
|
|
29
|
+
formatStats,
|
|
30
|
+
formatStatus,
|
|
31
|
+
} from "../text/report.ts";
|
|
24
32
|
import { startBeacon, unixLiveness } from "../unix/beacon.ts";
|
|
25
33
|
import type { ExtensionAPI, ExtensionContext, Timer, ToolResult } from "./api.ts";
|
|
26
34
|
|
|
@@ -334,7 +342,7 @@ export default function ompCoord(pi: ExtensionAPI): void {
|
|
|
334
342
|
|
|
335
343
|
pi.registerCommand("coord", {
|
|
336
344
|
description:
|
|
337
|
-
"Coordination with other omp sessions: `/coord` status; `send <letter|B/agent|all> <text>`; `pause [reason]`, `resume`; " +
|
|
345
|
+
"Coordination with other omp sessions: `/coord` status; `agents`; `send <letter|B/agent|all> <text>`; `pause [reason]`, `resume`; " +
|
|
338
346
|
"`claim <branch|worktree|pr> <target> [note]`, `release <kind> <target>`; `note <text>`, `notes [n]`; `stats [days]`; `interrupted [hours]`",
|
|
339
347
|
async handler(args, ctx) {
|
|
340
348
|
const rt = shared.runtime;
|
|
@@ -349,6 +357,8 @@ export default function ompCoord(pi: ExtensionAPI): void {
|
|
|
349
357
|
switch (sub) {
|
|
350
358
|
case "":
|
|
351
359
|
return ctx.ui.notify(statusText(rt), "info");
|
|
360
|
+
case "agents":
|
|
361
|
+
return ctx.ui.notify(formatAgents(rt.coordinator.board(), Date.now()), "info");
|
|
352
362
|
case "send": {
|
|
353
363
|
const m = /^(\S+)\s+([\s\S]+)$/.exec(tail);
|
|
354
364
|
return say(
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a process group still has a member that has not exited, for stopping a timed-out job's whole group.
|
|
3
|
+
*
|
|
4
|
+
* A signal to the group succeeds while it holds only zombies, which is what an exited command's children become in a
|
|
5
|
+
* container whose first process never reaps orphans. On Linux, /proc tells them apart. Elsewhere (macOS has no /proc;
|
|
6
|
+
* launchd reaps orphans), or when /proc cannot be read, the signal decides.
|
|
7
|
+
*/
|
|
8
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
9
|
+
|
|
10
|
+
/** The state and process group of a process, from the text of `/proc/<pid>/stat`. */
|
|
11
|
+
export function parseProcStat(stat: string): { state: string; pgrp: number } | undefined {
|
|
12
|
+
// "pid (comm) state ppid pgrp …": comm may contain spaces and parentheses, so fields start after the last ")".
|
|
13
|
+
const close = stat.lastIndexOf(")");
|
|
14
|
+
if (close === -1) return undefined;
|
|
15
|
+
const [state, , pgrp] = stat.slice(close + 2).split(" ");
|
|
16
|
+
const group = Number(pgrp);
|
|
17
|
+
return state && Number.isInteger(group) ? { state, pgrp: group } : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function groupRunning(pgid: number): boolean {
|
|
21
|
+
try {
|
|
22
|
+
process.kill(-pgid, 0);
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
if (process.platform !== "linux") return true;
|
|
27
|
+
let pids: string[];
|
|
28
|
+
try {
|
|
29
|
+
pids = readdirSync("/proc").filter(entry => /^\d+$/.test(entry));
|
|
30
|
+
} catch {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
for (const pid of pids) {
|
|
34
|
+
let stat: string;
|
|
35
|
+
try {
|
|
36
|
+
stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
37
|
+
} catch {
|
|
38
|
+
continue; // ended while being read, or hidden
|
|
39
|
+
}
|
|
40
|
+
const p = parseProcStat(stat);
|
|
41
|
+
if (p?.pgrp === pgid && p.state !== "Z") return true;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
@@ -6,6 +6,7 @@ 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
8
|
import { type AgentPolicy, type Board, labelOf, type Note } from "../../domain/model.ts";
|
|
9
|
+
import { agentList, LATEST_NOTES } from "../../domain/overview.ts";
|
|
9
10
|
import { orderWaiting, type Queues, slotsOf } from "../../domain/queues.ts";
|
|
10
11
|
|
|
11
12
|
export function clock(ms: number): string {
|
|
@@ -14,6 +15,37 @@ export function clock(ms: number): string {
|
|
|
14
15
|
|
|
15
16
|
const minutes = (ms: number) => `${Math.round(ms / 60_000)} min`;
|
|
16
17
|
|
|
18
|
+
/** `45 s`, `12 min`, `2 h 5 min`. */
|
|
19
|
+
function age(ms: number): string {
|
|
20
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
21
|
+
if (seconds < 60) return `${seconds} s`;
|
|
22
|
+
const mins = Math.floor(seconds / 60);
|
|
23
|
+
return mins < 60 ? `${mins} min` : `${Math.floor(mins / 60)} h ${mins % 60} min`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The agents holding slots, one line each: session letter, id, type, parent, pool, state and how long ago the slot was
|
|
28
|
+
* taken. A starting agent's id is the spawn key omp gave, or `(no id yet)`.
|
|
29
|
+
*/
|
|
30
|
+
export function formatAgents(board: Board, now: number): string {
|
|
31
|
+
const agents = agentList(board);
|
|
32
|
+
if (agents.length === 0) return "No agents are running.";
|
|
33
|
+
const rows = agents.map(a => [
|
|
34
|
+
a.session ?? "?",
|
|
35
|
+
a.id ?? "(no id yet)",
|
|
36
|
+
a.type,
|
|
37
|
+
`parent ${a.parent}`,
|
|
38
|
+
a.pool === null ? "outside the caps" : `pool ${a.pool}`,
|
|
39
|
+
a.state,
|
|
40
|
+
age(now - a.since),
|
|
41
|
+
]);
|
|
42
|
+
const widths = rows[0]?.map((_, c) => Math.max(...rows.map(r => (r[c] as string).length))) ?? [];
|
|
43
|
+
return [
|
|
44
|
+
`Agents (${agents.length}):`,
|
|
45
|
+
...rows.map(r => ` ${r.map((cell, c) => (c === r.length - 1 ? cell : cell.padEnd(widths[c] ?? 0))).join(" ")}`),
|
|
46
|
+
].join("\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
17
49
|
/** Everything on the board, from the view of session `me` ("" outside omp). */
|
|
18
50
|
export function formatStatus(board: Board, config: Config, me: string, now: number): string {
|
|
19
51
|
const policy = config.agents;
|
|
@@ -80,7 +112,7 @@ export function formatStatus(board: Board, config: Config, me: string, now: numb
|
|
|
80
112
|
const queueLines = formatQueues(board, config.queues, now);
|
|
81
113
|
if (queueLines.length) lines.push("Build and test queues:", ...queueLines);
|
|
82
114
|
|
|
83
|
-
const recent = board.notes.slice(-
|
|
115
|
+
const recent = board.notes.slice(-LATEST_NOTES);
|
|
84
116
|
if (recent.length)
|
|
85
117
|
lines.push(
|
|
86
118
|
"Latest decisions (all: notes):",
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The board as plain data for readers: `omp-coord status --json` prints a `StatusView`, and the text reports format
|
|
3
|
+
* its `agents`. Pure: callers pass `now` and the policies. Every time is milliseconds since the epoch; a session is
|
|
4
|
+
* named by its letter, or null when there is none (a job started outside omp, or by a session that has ended).
|
|
5
|
+
*
|
|
6
|
+
* The field names are part of the contract (docs/commands.md, docs/versioning.md): rename or remove one only as a
|
|
7
|
+
* breaking change.
|
|
8
|
+
*/
|
|
9
|
+
import { capOf, used } from "./agents.ts";
|
|
10
|
+
import { currentLoad } from "./limits.ts";
|
|
11
|
+
import { type AgentPolicy, type Board, labelOf } from "./model.ts";
|
|
12
|
+
import { orderWaiting, type Queues, slotsOf } from "./queues.ts";
|
|
13
|
+
|
|
14
|
+
/** What the view needs of the scope's configuration. `Config` satisfies it. */
|
|
15
|
+
export interface ScopeConfig {
|
|
16
|
+
scope: string;
|
|
17
|
+
/** The config file the scope was read from. */
|
|
18
|
+
source: string;
|
|
19
|
+
/** Hash of that file; sessions that read another one are flagged. */
|
|
20
|
+
hash: string;
|
|
21
|
+
agents: AgentPolicy;
|
|
22
|
+
queues: Queues;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** How many of the latest notes the view carries. */
|
|
26
|
+
export const LATEST_NOTES = 3;
|
|
27
|
+
|
|
28
|
+
export interface AgentView {
|
|
29
|
+
/** omp's agent id; null for an approved spawn whose first run has not started and that omp gave no key for. */
|
|
30
|
+
id: string | null;
|
|
31
|
+
type: string;
|
|
32
|
+
/** `Main` or the id of the subagent that started it. */
|
|
33
|
+
parent: string;
|
|
34
|
+
/** Letter of the session it runs in. */
|
|
35
|
+
session: string | null;
|
|
36
|
+
/** The pool holding its slot; null outside the caps (exempt types and nested reviewers). */
|
|
37
|
+
pool: string | null;
|
|
38
|
+
/** `starting`: approved, first run not seen yet. */
|
|
39
|
+
state: "starting" | "running";
|
|
40
|
+
/** When it took its slot. */
|
|
41
|
+
since: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface JobView {
|
|
45
|
+
id: string;
|
|
46
|
+
/** Letter of the session whose agent started it; null outside omp or once that session ended. */
|
|
47
|
+
session: string | null;
|
|
48
|
+
/** The owning session's id; null outside omp. */
|
|
49
|
+
owner: string | null;
|
|
50
|
+
lane: string;
|
|
51
|
+
priority: number;
|
|
52
|
+
key: string | null;
|
|
53
|
+
command: string;
|
|
54
|
+
state: "running" | "waiting";
|
|
55
|
+
/** When it joined the line (a superseding job keeps the place of the one it replaced). */
|
|
56
|
+
queuedAt: number;
|
|
57
|
+
/** When it started; null while waiting. */
|
|
58
|
+
startedAt: number | null;
|
|
59
|
+
/** Place in line, from 1, in the order jobs will start; null while running. */
|
|
60
|
+
position: number | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface StatusView {
|
|
64
|
+
/** When the view was built. */
|
|
65
|
+
generatedAt: number;
|
|
66
|
+
scope: { name: string; source: string };
|
|
67
|
+
/** `by` is the pausing session's letter; null from a terminal, or once that session ended (`byId` tells which). */
|
|
68
|
+
paused: { by: string | null; byId: string | null; reason: string; since: number } | null;
|
|
69
|
+
/** The load average the limits act on; null without a fresh reading. */
|
|
70
|
+
load: number | null;
|
|
71
|
+
sessions: {
|
|
72
|
+
letter: string;
|
|
73
|
+
id: string;
|
|
74
|
+
folder: string;
|
|
75
|
+
pid: number;
|
|
76
|
+
since: number;
|
|
77
|
+
/** Slots held per configured pool, and agents outside the caps. */
|
|
78
|
+
agents: { pools: Record<string, number>; outsideCaps: number };
|
|
79
|
+
ports: { from: number; to: number } | null;
|
|
80
|
+
/** Whether it read a different config file than this scope's. */
|
|
81
|
+
differentConfig: boolean;
|
|
82
|
+
}[];
|
|
83
|
+
pools: { name: string; used: number; cap: number; configuredCap: number }[];
|
|
84
|
+
/** Sessions waiting for a slot, per pool, first in line first. */
|
|
85
|
+
line: { pool: string; session: string | null; position: number; since: number; heldUntil: number | null }[];
|
|
86
|
+
agents: AgentView[];
|
|
87
|
+
claims: {
|
|
88
|
+
kind: string;
|
|
89
|
+
target: string;
|
|
90
|
+
session: string | null;
|
|
91
|
+
agent: string;
|
|
92
|
+
note: string;
|
|
93
|
+
since: number;
|
|
94
|
+
}[];
|
|
95
|
+
queues: {
|
|
96
|
+
name: string;
|
|
97
|
+
order: string;
|
|
98
|
+
/** Slots now: lowered under load, 0 while paused. */
|
|
99
|
+
slots: number;
|
|
100
|
+
configuredSlots: number;
|
|
101
|
+
running: JobView[];
|
|
102
|
+
waiting: JobView[];
|
|
103
|
+
}[];
|
|
104
|
+
/** The latest notes, oldest first. `session` is the letter when the note was written, kept after that session ends. */
|
|
105
|
+
notes: { id: number; at: number; session: string | null; author: string; text: string }[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Every agent holding a slot, by session letter then start. */
|
|
109
|
+
export function agentList(board: Board): AgentView[] {
|
|
110
|
+
return board.slots
|
|
111
|
+
.map(s => ({
|
|
112
|
+
id: s.agentId ?? null,
|
|
113
|
+
type: s.agentType,
|
|
114
|
+
parent: s.parentId,
|
|
115
|
+
session: labelOf(board, s.sessionId) ?? null,
|
|
116
|
+
pool: s.pool,
|
|
117
|
+
state: s.state,
|
|
118
|
+
since: s.since,
|
|
119
|
+
}))
|
|
120
|
+
.sort(
|
|
121
|
+
(a, b) =>
|
|
122
|
+
(a.session ?? "").localeCompare(b.session ?? "") || a.since - b.since || (a.id ?? "").localeCompare(b.id ?? ""),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function statusView(board: Board, config: ScopeConfig, now: number): StatusView {
|
|
127
|
+
const policy = config.agents;
|
|
128
|
+
const letter = (sessionId: string | null) => (sessionId === null ? null : (labelOf(board, sessionId) ?? null));
|
|
129
|
+
const pools = Object.keys(policy.pools);
|
|
130
|
+
|
|
131
|
+
const job = (j: Board["jobs"][number], position: number | null): JobView => ({
|
|
132
|
+
id: j.id,
|
|
133
|
+
session: letter(j.owner),
|
|
134
|
+
owner: j.owner,
|
|
135
|
+
lane: j.lane,
|
|
136
|
+
priority: j.priority,
|
|
137
|
+
key: j.key ?? null,
|
|
138
|
+
command: j.command,
|
|
139
|
+
state: position === null ? "running" : "waiting",
|
|
140
|
+
queuedAt: j.since,
|
|
141
|
+
startedAt: j.startedAt ?? null,
|
|
142
|
+
position,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const paused = board.pause;
|
|
146
|
+
return {
|
|
147
|
+
generatedAt: now,
|
|
148
|
+
scope: { name: config.scope, source: config.source },
|
|
149
|
+
paused: paused ? { by: letter(paused.by), byId: paused.by, reason: paused.reason, since: paused.since } : null,
|
|
150
|
+
load: currentLoad(board, now) ?? null,
|
|
151
|
+
sessions: [...board.sessions]
|
|
152
|
+
.sort((a, b) => a.label.localeCompare(b.label))
|
|
153
|
+
.map(s => {
|
|
154
|
+
const held = board.slots.filter(x => x.sessionId === s.id);
|
|
155
|
+
return {
|
|
156
|
+
letter: s.label,
|
|
157
|
+
id: s.id,
|
|
158
|
+
folder: s.cwd,
|
|
159
|
+
pid: s.pid,
|
|
160
|
+
since: s.startedAt,
|
|
161
|
+
agents: {
|
|
162
|
+
pools: Object.fromEntries(pools.map(p => [p, held.filter(x => x.pool === p).length])),
|
|
163
|
+
outsideCaps: held.filter(x => x.pool === null).length,
|
|
164
|
+
},
|
|
165
|
+
ports: s.ports ? { from: s.ports.from, to: s.ports.to } : null,
|
|
166
|
+
differentConfig: s.configHash !== config.hash,
|
|
167
|
+
};
|
|
168
|
+
}),
|
|
169
|
+
pools: pools.map(name => ({
|
|
170
|
+
name,
|
|
171
|
+
used: used(board, name),
|
|
172
|
+
cap: capOf(board, policy, name, now),
|
|
173
|
+
configuredCap: policy.pools[name]?.cap ?? 0,
|
|
174
|
+
})),
|
|
175
|
+
line: pools.flatMap(pool =>
|
|
176
|
+
board.waitlist
|
|
177
|
+
.filter(t => t.pool === pool)
|
|
178
|
+
.sort((a, b) => a.since - b.since)
|
|
179
|
+
.map((t, i) => ({
|
|
180
|
+
pool,
|
|
181
|
+
session: letter(t.sessionId),
|
|
182
|
+
position: i + 1,
|
|
183
|
+
since: t.since,
|
|
184
|
+
heldUntil: t.holdUntil ?? null,
|
|
185
|
+
})),
|
|
186
|
+
),
|
|
187
|
+
agents: agentList(board),
|
|
188
|
+
claims: board.claims.map(c => ({
|
|
189
|
+
kind: c.kind,
|
|
190
|
+
target: c.target,
|
|
191
|
+
session: letter(c.sessionId),
|
|
192
|
+
agent: c.agentId,
|
|
193
|
+
note: c.note,
|
|
194
|
+
since: c.since,
|
|
195
|
+
})),
|
|
196
|
+
queues: Object.entries(config.queues).map(([name, q]) => ({
|
|
197
|
+
name,
|
|
198
|
+
order: q.order,
|
|
199
|
+
slots: slotsOf(board, q, now),
|
|
200
|
+
configuredSlots: q.slots,
|
|
201
|
+
running: board.jobs.filter(j => j.queue === name && j.state === "running").map(j => job(j, null)),
|
|
202
|
+
waiting: orderWaiting(q, name, board, now).map((j, i) => job(j, i + 1)),
|
|
203
|
+
})),
|
|
204
|
+
notes: board.notes.slice(-LATEST_NOTES).map(n => ({
|
|
205
|
+
id: n.id,
|
|
206
|
+
at: n.at,
|
|
207
|
+
session: n.label,
|
|
208
|
+
author: n.author,
|
|
209
|
+
text: n.text,
|
|
210
|
+
})),
|
|
211
|
+
};
|
|
212
|
+
}
|