omp-conductor 0.3.13 → 0.3.16
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/README.md +227 -70
- package/package.json +3 -2
- package/skills/conductor-update/SKILL.md +157 -0
- package/src/brief-upgrade.ts +1 -1
- package/src/briefs/orchestrator.md +3 -2
- package/src/briefs/worker.md +1 -1
- package/src/cli.ts +161 -40
- package/src/confinement.ts +123 -0
- package/src/daemon.ts +55 -2
- package/src/fleet.ts +1271 -0
- package/src/host.ts +90 -0
- package/src/lifecycle.ts +182 -77
- package/src/omp.ts +12 -0
- package/src/orchestrator-tick.ts +64 -3
- package/src/plugin.ts +110 -17
- package/src/tracker/github.ts +25 -1
- package/src/types.ts +10 -0
- package/src/worker.ts +2 -0
- package/systemd/omp-conductor.service.example +54 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: conductor-update
|
|
3
|
+
description: Safely update an installed omp-conductor fleet as one maintenance operation across both separately installed halves: the npm omp plugin and the GitHub herdr-conductor plugin. Use when the user asks to update, upgrade, refresh, reinstall, or deploy a newly published conductor version, or when installed conductor code is behind npm/main. Quiesces work, preserves exact pane identity, performs a whole-version swap, restarts through the supported lifecycle, re-arms through Telegram proof, and verifies the live fleet.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Update a conductor fleet
|
|
7
|
+
|
|
8
|
+
Treat an update as one operation across two independently installed plugins:
|
|
9
|
+
|
|
10
|
+
- `omp-conductor` comes from npm and owns dispatch, CLI, status, and heartbeat.
|
|
11
|
+
- `herdr-conductor` comes from `TerrifiedBug/conductor/herdr` and owns exact-pane recovery.
|
|
12
|
+
|
|
13
|
+
Updating only one half can leave a version that starts but cannot recover correctly.
|
|
14
|
+
This skill updates an installed fleet. It does **not** publish npm, merge a PR, tag a
|
|
15
|
+
release, or edit files in an install directory.
|
|
16
|
+
|
|
17
|
+
## Safety boundary
|
|
18
|
+
|
|
19
|
+
Run this procedure from an operator shell or maintenance omp session that is not
|
|
20
|
+
hosted by the target `herdr-fleet.service`. If the current working directory
|
|
21
|
+
contains `.conductor-tick.json`, or stopping that unit would kill the session
|
|
22
|
+
executing this skill, stop and move the update to an external maintenance
|
|
23
|
+
session. A self-terminating updater cannot produce trustworthy verification.
|
|
24
|
+
|
|
25
|
+
Use the configured target host. Do not assume a hostname, state directory, Herdr
|
|
26
|
+
session name, project name, or systemd availability. Read them from the existing
|
|
27
|
+
installation and `omp-conductor status`. Never print bot tokens, npm tokens, or
|
|
28
|
+
authentication files.
|
|
29
|
+
|
|
30
|
+
## 1. Establish the desired version
|
|
31
|
+
|
|
32
|
+
Read, do not guess:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
omp-conductor --version
|
|
36
|
+
version=$(npm view omp-conductor version)
|
|
37
|
+
gitHead=$(npm view "omp-conductor@$version" gitHead)
|
|
38
|
+
npm view "omp-conductor@$version" version gitHead --json
|
|
39
|
+
omp plugin list --json
|
|
40
|
+
omp-conductor status [--project NAME]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Use the registry latest as `$version` unless the user explicitly names another
|
|
44
|
+
published version, then query that exact version as shown. Record its `gitHead`
|
|
45
|
+
and require a full commit SHA: the npm spec and Herdr ref below are both pinned,
|
|
46
|
+
so a concurrent release cannot mix two versions. If npm has no newer version, do
|
|
47
|
+
not churn the fleet: report that it is current. Confirm that `herdr` and
|
|
48
|
+
`systemctl` are present before taking anything down when status says Herdr
|
|
49
|
+
manages the pane.
|
|
50
|
+
|
|
51
|
+
## 2. Quiesce without losing work
|
|
52
|
+
|
|
53
|
+
First stop new claims and heartbeat prompts:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
omp-conductor hold [--project NAME]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Read status until `active runs (none)`. Do not kill workers to make the update
|
|
60
|
+
faster. If a run does not drain, report the run and stop; ordinary update authority
|
|
61
|
+
does not include discarding work.
|
|
62
|
+
|
|
63
|
+
Then stop the exact conductor pane and dispatch daemon through the supported,
|
|
64
|
+
fail-closed path:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
omp-conductor halt --pane [--project NAME]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
This writes the recovery pin before stopping the exact configured Herdr agent. If
|
|
71
|
+
identity is invalid, missing, or ambiguous, it refuses rather than killing a guess.
|
|
72
|
+
Do not bypass that refusal with `pkill`.
|
|
73
|
+
|
|
74
|
+
When `herdr-fleet.service` is installed, stop it before replacing either plugin:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
systemctl stop herdr-fleet.service
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
At this point the daemon is stopped, the pane is stopped, and recovery remains
|
|
81
|
+
pinned. If any of those statements is false, do not modify the install.
|
|
82
|
+
|
|
83
|
+
## 3. Replace both installed halves
|
|
84
|
+
|
|
85
|
+
Refresh the exact npm version using omp's package installer:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
omp plugin install "omp-conductor@$version"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Then refresh the Herdr managed checkout from the exact npm release commit. Herdr
|
|
92
|
+
requires the GitHub source before its options:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
herdr plugin install TerrifiedBug/conductor/herdr --ref "$gitHead" --yes
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Do not use `git pull`, `scp`, a linked checkout, or edits under either plugin's
|
|
99
|
+
install root. This is a whole-version swap. If either install fails, leave the
|
|
100
|
+
fleet stopped and recovery pinned; report the failed command and do not continue
|
|
101
|
+
with a mixed live version.
|
|
102
|
+
|
|
103
|
+
## 4. Start through the new lifecycle
|
|
104
|
+
|
|
105
|
+
Run the newly installed CLI:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
omp-conductor --version
|
|
109
|
+
omp-conductor start [--project NAME]
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`start` clears the recovery pin, starts the optional `herdr-fleet.service`, and
|
|
113
|
+
starts the dispatch daemon after a real `/healthz` check. Herdr then recovers the
|
|
114
|
+
exact orchestrator pane and requests an immediate tick; the verification below,
|
|
115
|
+
not the `start` command alone, proves that recovery completed.
|
|
116
|
+
|
|
117
|
+
Wait until `omp-conductor status` reports the exact pane `live`; `start` returning
|
|
118
|
+
only proves the service and dispatch daemon. `hold` deliberately disarmed ticks,
|
|
119
|
+
so restore unattended operation through the existing Telegram proof:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
omp-conductor arm [--project NAME]
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Wait for the operator's inbound Telegram reply. Never create the arm marker by
|
|
126
|
+
hand and never treat an outbound challenge as proof.
|
|
127
|
+
|
|
128
|
+
## 5. Verify the live result
|
|
129
|
+
|
|
130
|
+
Require all of the following before reporting success:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
omp-conductor --version
|
|
134
|
+
npm view "omp-conductor@$version" version gitHead --json
|
|
135
|
+
omp plugin list --json
|
|
136
|
+
herdr plugin list
|
|
137
|
+
omp-conductor status [--project NAME]
|
|
138
|
+
systemctl is-active herdr-fleet.service
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
- installed version and the npm package entry both equal `$version`;
|
|
142
|
+
- the Herdr plugin source revision equals the npm release `gitHead`;
|
|
143
|
+
- `dispatch` is `running`;
|
|
144
|
+
- `ticks` is `armed` and a next tick is shown;
|
|
145
|
+
- `pane` is `live` for the exact configured Herdr agent;
|
|
146
|
+
- `recovery` is `clear`;
|
|
147
|
+
- `herdr` is `active` when managed;
|
|
148
|
+
- Telegram is `ok` (or its exact supported degraded state is reported);
|
|
149
|
+
- daemon `/healthz` is `ok`;
|
|
150
|
+
- no unexpected active runs appeared during maintenance.
|
|
151
|
+
|
|
152
|
+
Run a second status check after the immediate recovery tick is consumed. A single
|
|
153
|
+
healthy snapshot is not proof that recovery and heartbeat scheduling survived the
|
|
154
|
+
swap.
|
|
155
|
+
|
|
156
|
+
Report the old and new versions, both plugin refreshes, the final layered status,
|
|
157
|
+
and any supported degraded state. Do not report success for a partial update.
|
package/src/brief-upgrade.ts
CHANGED
|
@@ -573,7 +573,7 @@ export function formatBriefStatus(path: string, status: BriefStatus): string {
|
|
|
573
573
|
` floor package template → recomposed into ${status.orchestratorPath} each tick`,
|
|
574
574
|
` policy ${status.policyPath} (Learning loop / operator edits)`,
|
|
575
575
|
"",
|
|
576
|
-
"Protocol updates:
|
|
576
|
+
"Protocol updates: upgrade omp-conductor in this host's existing install root (same package manager), then restart the daemon — no brief-upgrade --apply.",
|
|
577
577
|
"Legacy migrate: omp-conductor brief-upgrade --migrate",
|
|
578
578
|
].join("\n");
|
|
579
579
|
}
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
This file is the **package floor**: duties, tiers, hard boundaries, and the
|
|
4
4
|
Learning loop. It ships inside `omp-conductor` and is re-rendered into your
|
|
5
5
|
workspace on every tick (composed with `POLICY.md` as `ORCHESTRATOR.md` for the
|
|
6
|
-
session). Protocol updates arrive
|
|
7
|
-
|
|
6
|
+
session). Protocol updates arrive when you upgrade the installed `omp-conductor`
|
|
7
|
+
package (same package manager / install root) and restart — you do not
|
|
8
|
+
brief-upgrade the floor.
|
|
8
9
|
|
|
9
10
|
Fleet-specific policy — Releases, Project context, Reporting, Amendments — lives
|
|
10
11
|
in **`POLICY.md`** beside this composed brief. The Learning loop edits
|
package/src/briefs/worker.md
CHANGED
|
@@ -15,7 +15,7 @@ inside your own worktree.
|
|
|
15
15
|
- **Code repo:** {{REPO}}
|
|
16
16
|
- **Your worktree (cwd):** `{{WORKTREE}}`
|
|
17
17
|
- **Your branch:** `{{BRANCH}}` — already created for you off the repo's default
|
|
18
|
-
branch. Never switch branches and never touch a path outside the worktree.
|
|
18
|
+
branch. Never switch branches and never touch a path outside the worktree (write/edit/read/grep/glob are also blocked mechanically outside this checkout; `bash` is still a must-not — do not use it to escape).
|
|
19
19
|
|
|
20
20
|
Read the issue first — it carries the acceptance criteria and any discussion the
|
|
21
21
|
dispatcher did not copy down:
|
package/src/cli.ts
CHANGED
|
@@ -21,12 +21,22 @@ import {
|
|
|
21
21
|
writeMergedBrief,
|
|
22
22
|
} from "./brief-upgrade.ts";
|
|
23
23
|
import { findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
|
|
24
|
-
import { dbPath,
|
|
24
|
+
import { dbPath, runDaemon, setPaused } from "./daemon.ts";
|
|
25
|
+
import {
|
|
26
|
+
armTicks,
|
|
27
|
+
clearPaneHalt,
|
|
28
|
+
disarmTicks,
|
|
29
|
+
halt,
|
|
30
|
+
haltWithPane,
|
|
31
|
+
hold,
|
|
32
|
+
releaseHold,
|
|
33
|
+
renderStatus,
|
|
34
|
+
startHerdrFleet,
|
|
35
|
+
} from "./fleet.ts";
|
|
25
36
|
import { formatGraphSetup, graphRepos, writeGraphSetup, type GraphSetupWrite } from "./graph.ts";
|
|
26
37
|
import {
|
|
27
38
|
clearRecord,
|
|
28
39
|
DEFAULT_PORT,
|
|
29
|
-
healthCheck,
|
|
30
40
|
livingDaemon,
|
|
31
41
|
restartDaemon,
|
|
32
42
|
startDaemon,
|
|
@@ -46,13 +56,29 @@ import { makeTracker } from "./tracker/github.ts";
|
|
|
46
56
|
import type { ProjectConfig } from "./types.ts";
|
|
47
57
|
import { formatUnblock, unblockIssue } from "./unblock.ts";
|
|
48
58
|
|
|
59
|
+
function packageVersion(): string {
|
|
60
|
+
const parsed = JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")) as {
|
|
61
|
+
version?: unknown;
|
|
62
|
+
};
|
|
63
|
+
if (typeof parsed.version !== "string" || parsed.version.length === 0) {
|
|
64
|
+
throw new Error("installed package.json has no version");
|
|
65
|
+
}
|
|
66
|
+
return parsed.version;
|
|
67
|
+
}
|
|
68
|
+
|
|
49
69
|
const USAGE = `omp-conductor — dispatch ready issues to omp coding sessions
|
|
50
70
|
|
|
51
71
|
usage:
|
|
52
72
|
omp-conductor start [--port N] [--project NAME]
|
|
73
|
+
omp-conductor --version
|
|
53
74
|
omp-conductor stop
|
|
54
75
|
omp-conductor restart [--port N] [--project NAME]
|
|
55
76
|
omp-conductor status [--project NAME]
|
|
77
|
+
omp-conductor hold [--project NAME]
|
|
78
|
+
omp-conductor halt [--pane] [--project NAME]
|
|
79
|
+
omp-conductor arm [--project NAME]
|
|
80
|
+
omp-conductor disarm [--project NAME]
|
|
81
|
+
omp-conductor release-pane [--project NAME]
|
|
56
82
|
omp-conductor tail <issue> [--project NAME]
|
|
57
83
|
omp-conductor unblock <issue> [--project NAME]
|
|
58
84
|
omp-conductor daemon [--once] [--port N] [--project NAME]
|
|
@@ -62,9 +88,9 @@ usage:
|
|
|
62
88
|
omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
|
|
63
89
|
omp-conductor help
|
|
64
90
|
|
|
65
|
-
start
|
|
66
|
-
|
|
67
|
-
|
|
91
|
+
start start the installed herdr-fleet.service when present, then run the
|
|
92
|
+
dispatch loop in the background and wait until it answers GET
|
|
93
|
+
/healthz on :8787 (override with --port). Refuses if one is running.
|
|
68
94
|
stop stop the running daemon. Uses systemctl when the omp-conductor
|
|
69
95
|
unit owns the process (so Restart=on-failure cannot bring it back);
|
|
70
96
|
otherwise SIGTERM then SIGKILL.
|
|
@@ -73,8 +99,23 @@ usage:
|
|
|
73
99
|
dirty live worktrees before orphaning those rows — see README
|
|
74
100
|
"Deploying a new package onto a busy fleet". Goes through systemctl
|
|
75
101
|
when the unit owns the live pid.
|
|
76
|
-
status
|
|
77
|
-
daemon
|
|
102
|
+
status layered fleet report: dispatch (running|paused|stopped), ticks and
|
|
103
|
+
next due time, pane, herdr, Telegram bot/API health, daemon, caps
|
|
104
|
+
and active runs.
|
|
105
|
+
hold soft stop: pause claiming AND disarm ticks. Daemon and pane stay up.
|
|
106
|
+
This is "stop the conductor overnight" without killing processes.
|
|
107
|
+
halt hold, then stop the dispatch daemon (systemctl-aware). Pane stays up
|
|
108
|
+
unless --pane is passed.
|
|
109
|
+
halt --pane
|
|
110
|
+
halt, then pin herdr-conductor recovery off for the conductor agent
|
|
111
|
+
only — does NOT stop herdr-fleet.service or any other herdr session.
|
|
112
|
+
Clear the pin with release-pane when you want recovery again.
|
|
113
|
+
arm proof-gated: send a Telegram challenge and write the arm marker only
|
|
114
|
+
after your reply appears as a user turn in the orchestrator transcript.
|
|
115
|
+
Never auto-armed by resume/hold.
|
|
116
|
+
disarm remove the arm marker so ticks skip. Processes untouched.
|
|
117
|
+
release-pane
|
|
118
|
+
clear the halt --pane recovery pin so herdr-conductor may resume again.
|
|
78
119
|
tail follow the newest run for <issue>: the worker's assistant text and
|
|
79
120
|
the tools it calls, printed as they land. Workers are sessions inside
|
|
80
121
|
the daemon rather than terminals, so this is the only way to watch
|
|
@@ -87,8 +128,8 @@ usage:
|
|
|
87
128
|
cost a worker.
|
|
88
129
|
daemon run the dispatch loop in the foreground; --once runs a single tick
|
|
89
130
|
and exits. This is what \`start\` launches.
|
|
90
|
-
pause stop claiming new work
|
|
91
|
-
resume
|
|
131
|
+
pause stop claiming new work only (ticks keep firing if armed). Prefer hold.
|
|
132
|
+
resume clear pause only — does NOT re-arm. Prefer hold's inverse: resume + arm.
|
|
92
133
|
graph-setup
|
|
93
134
|
print how to set up the code-graph indexes workers query instead of
|
|
94
135
|
grepping: the clone commands for any missing index-only clone, the
|
|
@@ -105,12 +146,22 @@ usage:
|
|
|
105
146
|
--apply still merges a bannered single-file brief. --file checks a
|
|
106
147
|
brief that is not where the wizard would have put it.
|
|
107
148
|
help print this text (also --help, -h).
|
|
149
|
+
--version
|
|
150
|
+
print the installed omp-conductor package version (also -V, version).
|
|
108
151
|
|
|
109
152
|
Pause is a flag file under the state directory, so it applies to every project
|
|
110
|
-
and survives a daemon restart.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
153
|
+
and survives a daemon restart. Hold also removes the arm marker the heartbeat
|
|
154
|
+
reads, so both brains go quiet without killing processes. A running daemon is
|
|
155
|
+
tracked by a pidfile under $OMP_CONDUCTOR_RUNTIME_DIR (default
|
|
156
|
+
~/.omp/run/daemons/omp-conductor), written whether it was started in the
|
|
157
|
+
background or in the foreground, and probed for liveness on every read — a
|
|
158
|
+
stale one never blocks a start.
|
|
159
|
+
|
|
160
|
+
Stop the conductor:
|
|
161
|
+
hold no claims, no tick sends (inspectable)
|
|
162
|
+
halt hold + stop dispatch daemon
|
|
163
|
+
halt --pane halt + pin conductor-pane recovery off
|
|
164
|
+
resume && arm clear pause, then prove inbound Telegram before ticks resume`;
|
|
114
165
|
|
|
115
166
|
/** Accepts both `--port 9000` and `--port=9000`; returns undefined when absent. */
|
|
116
167
|
function flag(argv: string[], name: string): string | undefined {
|
|
@@ -146,26 +197,6 @@ function humanDuration(ms: number): string {
|
|
|
146
197
|
return `${Math.floor(h / 24)}d ${String(h % 24).padStart(2, "0")}h`;
|
|
147
198
|
}
|
|
148
199
|
|
|
149
|
-
/**
|
|
150
|
-
* The daemon half of `status`. Kept separate from `formatStatus` because the
|
|
151
|
-
* pidfile and the endpoint are the CLI's business, not the dispatcher's, and
|
|
152
|
-
* because a pid without a `/healthz` answer is a distinct — and interesting —
|
|
153
|
-
* state: the process is up but the loop is not serving.
|
|
154
|
-
*/
|
|
155
|
-
async function daemonSection(): Promise<string> {
|
|
156
|
-
const rec = livingDaemon();
|
|
157
|
-
if (rec === undefined) return "daemon not running";
|
|
158
|
-
const health = await healthCheck(rec.port);
|
|
159
|
-
return [
|
|
160
|
-
"daemon",
|
|
161
|
-
` pid ${rec.pid}`,
|
|
162
|
-
` uptime ${humanDuration(Date.now() - rec.startedAt)}`,
|
|
163
|
-
` port ${rec.port}`,
|
|
164
|
-
...(rec.project === undefined ? [] : [` project ${rec.project}`]),
|
|
165
|
-
` healthz ${health.ok ? `ok ${health.body ?? ""}`.trimEnd() : "unreachable — the process is up but not serving"}`,
|
|
166
|
-
` log ${rec.logFile}`,
|
|
167
|
-
].join("\n");
|
|
168
|
-
}
|
|
169
200
|
|
|
170
201
|
/**
|
|
171
202
|
* The orchestrator half, and the one thing `status` has ever known about the
|
|
@@ -362,6 +393,11 @@ const cmd = argv[0];
|
|
|
362
393
|
|
|
363
394
|
try {
|
|
364
395
|
switch (cmd) {
|
|
396
|
+
case "--version":
|
|
397
|
+
case "-V":
|
|
398
|
+
case "version":
|
|
399
|
+
process.stdout.write(`${packageVersion()}\n`);
|
|
400
|
+
break;
|
|
365
401
|
case "daemon": {
|
|
366
402
|
// Until now only `lifecycle.startDaemon()` — the spawn path — wrote the
|
|
367
403
|
// pidfile, which left a daemon started in the foreground (which is how
|
|
@@ -413,10 +449,14 @@ try {
|
|
|
413
449
|
}
|
|
414
450
|
|
|
415
451
|
case "start": {
|
|
416
|
-
const
|
|
452
|
+
const project = flag(argv, "project");
|
|
453
|
+
const herdr = startHerdrFleet(project);
|
|
454
|
+
const rec = await startDaemon({ port: portFlag(argv), project });
|
|
417
455
|
process.stdout.write(
|
|
418
456
|
`started — pid ${rec.pid}, /healthz on :${rec.port}` +
|
|
419
|
-
`${rec.project === undefined ? "" : `, project ${rec.project}`}\
|
|
457
|
+
`${rec.project === undefined ? "" : `, project ${rec.project}`}\n` +
|
|
458
|
+
`herdr ${herdr.kind === "active" ? `active (${herdr.unit})${herdr.recoveryReleased ? "; recovery pin cleared" : ""}` : `unmanaged (${herdr.reason})`}\n` +
|
|
459
|
+
`log ${rec.logFile}\n`,
|
|
420
460
|
);
|
|
421
461
|
break;
|
|
422
462
|
}
|
|
@@ -456,9 +496,84 @@ try {
|
|
|
456
496
|
}
|
|
457
497
|
|
|
458
498
|
case "status": {
|
|
459
|
-
const
|
|
499
|
+
const project = flag(argv, "project");
|
|
500
|
+
const text = await renderStatus(project);
|
|
460
501
|
const stalled = stallLine();
|
|
461
|
-
process.stdout.write(`${
|
|
502
|
+
process.stdout.write(`${text}${stalled === undefined ? "\n" : `\n\n${stalled}\n`}`);
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
case "hold": {
|
|
507
|
+
const r = hold(flag(argv, "project"));
|
|
508
|
+
process.stdout.write(
|
|
509
|
+
`held — claiming paused` +
|
|
510
|
+
`${r.wasPaused ? " (already paused)" : ""}` +
|
|
511
|
+
`; ticks disarmed at ${r.disarmed.path}` +
|
|
512
|
+
`${r.disarmed.wasArmed ? "" : " (was already disarmed)"}\n` +
|
|
513
|
+
`daemon and pane left running; halt to stop the daemon\n`,
|
|
514
|
+
);
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
case "halt": {
|
|
519
|
+
const project = flag(argv, "project");
|
|
520
|
+
const withPane = argv.includes("--pane");
|
|
521
|
+
if (withPane) {
|
|
522
|
+
const r = await haltWithPane(project);
|
|
523
|
+
const stopLine =
|
|
524
|
+
r.stop.kind === "not-running"
|
|
525
|
+
? "daemon was not running"
|
|
526
|
+
: `daemon stopped — pid ${r.stop.pid}${r.stop.via === "systemctl" ? " (via systemctl)" : ""}`;
|
|
527
|
+
process.stdout.write(
|
|
528
|
+
`halted — claiming paused; ticks disarmed at ${r.hold.disarmed.path}\n` +
|
|
529
|
+
`${stopLine}\n` +
|
|
530
|
+
`pane recovery pinned at ${r.pane.pinPath}\n` +
|
|
531
|
+
`pane stop: ${r.pane.stopped} — ${r.pane.detail}\n` +
|
|
532
|
+
` (conductor agent "${r.pane.agentName}" only — herdr-fleet.service was NOT stopped;\n` +
|
|
533
|
+
` release-pane clears the pin when you want recovery again)\n`,
|
|
534
|
+
);
|
|
535
|
+
} else {
|
|
536
|
+
const r = await halt(project);
|
|
537
|
+
const stopLine =
|
|
538
|
+
r.stop.kind === "not-running"
|
|
539
|
+
? "daemon was not running"
|
|
540
|
+
: `daemon stopped — pid ${r.stop.pid}${r.stop.via === "systemctl" ? " (via systemctl)" : ""}`;
|
|
541
|
+
process.stdout.write(
|
|
542
|
+
`halted — claiming paused; ticks disarmed at ${r.hold.disarmed.path}\n` +
|
|
543
|
+
`${stopLine}\n` +
|
|
544
|
+
`pane left running (pass --pane to stop the conductor agent and pin recovery)\n`,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
case "arm": {
|
|
551
|
+
process.stdout.write("arm: sending inbound Telegram challenge…\n");
|
|
552
|
+
const r = await armTicks(flag(argv, "project"));
|
|
553
|
+
process.stdout.write(
|
|
554
|
+
`ARMED — inbound round-trip proved with owner ${r.owner}; ticks are now live.\n` +
|
|
555
|
+
`marker ${r.path}${r.alreadyArmed ? " (replaced previous marker)" : ""}\n`,
|
|
556
|
+
);
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
case "disarm": {
|
|
561
|
+
const r = disarmTicks(flag(argv, "project"));
|
|
562
|
+
process.stdout.write(
|
|
563
|
+
`disarmed — ticks will be skipped` +
|
|
564
|
+
`${r.wasArmed ? "" : " (was already disarmed)"}\n` +
|
|
565
|
+
`marker ${r.path}\n`,
|
|
566
|
+
);
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
case "release-pane": {
|
|
571
|
+
const r = clearPaneHalt(flag(argv, "project"));
|
|
572
|
+
process.stdout.write(
|
|
573
|
+
r.wasHalted
|
|
574
|
+
? `pane recovery pin cleared — ${r.path}\nherdr-conductor may resume the fleet agent again\n`
|
|
575
|
+
: `no pane recovery pin at ${r.path}\n`,
|
|
576
|
+
);
|
|
462
577
|
break;
|
|
463
578
|
}
|
|
464
579
|
|
|
@@ -484,12 +599,18 @@ try {
|
|
|
484
599
|
|
|
485
600
|
case "pause":
|
|
486
601
|
setPaused(true);
|
|
487
|
-
process.stdout.write(
|
|
602
|
+
process.stdout.write(
|
|
603
|
+
"paused — no new work will be claimed\n" +
|
|
604
|
+
"note: ticks keep firing if armed; use hold to silence both\n",
|
|
605
|
+
);
|
|
488
606
|
break;
|
|
489
607
|
|
|
490
608
|
case "resume":
|
|
491
|
-
|
|
492
|
-
process.stdout.write(
|
|
609
|
+
releaseHold();
|
|
610
|
+
process.stdout.write(
|
|
611
|
+
"resumed — claiming allowed on the next tick\n" +
|
|
612
|
+
"note: did NOT re-arm; run arm after an inbound Telegram proof to resume ticks\n",
|
|
613
|
+
);
|
|
493
614
|
break;
|
|
494
615
|
|
|
495
616
|
case "graph-setup": {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mechanical worktree confinement for worker sessions.
|
|
3
|
+
*
|
|
4
|
+
* The harness has no first-class fs-policy field, but `createAgentSession`
|
|
5
|
+
* accepts inline `extensions` that subscribe to `tool_call` and can return
|
|
6
|
+
* `{ block: true }` before a tool runs (see the harness `protected-paths`
|
|
7
|
+
* example). Workers get that gate for structured file tools; the orchestrator
|
|
8
|
+
* does not — it has to read the state directory and briefs.
|
|
9
|
+
*
|
|
10
|
+
* `bash` is deliberately not confined here: its input is an opaque shell
|
|
11
|
+
* string, and parsing it is a false-sense of security. Closing that gap is a
|
|
12
|
+
* least-privilege uid (deployment), documented beside this module's README
|
|
13
|
+
* section — not a regex over `rm -rf`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
17
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
18
|
+
|
|
19
|
+
/** Tools whose structured `path` (or path-like) field we can gate. */
|
|
20
|
+
const GATED = new Set(["write", "edit", "read", "grep", "glob"]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve `candidate` as a worker would, then ask whether it stays under
|
|
24
|
+
* `root`. Symlink-aware: existing path components are realpath'd so a link
|
|
25
|
+
* planted inside the worktree cannot escape by string-prefix tricks.
|
|
26
|
+
*
|
|
27
|
+
* A path that does not exist yet (a new write) realpaths the deepest existing
|
|
28
|
+
* ancestor and appends the rest — the same TOCTOU posture as the harness's
|
|
29
|
+
* own workspace confinement helper.
|
|
30
|
+
*/
|
|
31
|
+
export function isInsideWorktree(root: string, candidate: string): boolean {
|
|
32
|
+
if (candidate.length === 0 || candidate.includes("\0")) return false;
|
|
33
|
+
|
|
34
|
+
let rootReal: string;
|
|
35
|
+
try {
|
|
36
|
+
rootReal = realpathSync(resolve(root));
|
|
37
|
+
} catch {
|
|
38
|
+
rootReal = resolve(root);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const abs = resolve(rootReal, candidate);
|
|
42
|
+
|
|
43
|
+
const missing: string[] = [];
|
|
44
|
+
let probe = abs;
|
|
45
|
+
while (!existsSync(probe)) {
|
|
46
|
+
const parent = dirname(probe);
|
|
47
|
+
if (parent === probe) break;
|
|
48
|
+
missing.unshift(basename(probe));
|
|
49
|
+
probe = parent;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let base: string;
|
|
53
|
+
try {
|
|
54
|
+
base = realpathSync(probe);
|
|
55
|
+
} catch {
|
|
56
|
+
base = probe;
|
|
57
|
+
}
|
|
58
|
+
const resolved = missing.length === 0 ? base : join(base, ...missing);
|
|
59
|
+
|
|
60
|
+
const rel = relative(rootReal, resolved);
|
|
61
|
+
// Inside ⇒ "" or a relative path that does not climb out. Absolute `rel` is
|
|
62
|
+
// a Windows drive mismatch; anything starting with `..` has left the root.
|
|
63
|
+
return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Pull the path-like field a gated tool carries, if any. */
|
|
67
|
+
export function pathFromToolInput(toolName: string, input: Record<string, unknown>): string | undefined {
|
|
68
|
+
if (!GATED.has(toolName)) return undefined;
|
|
69
|
+
const path = input.path;
|
|
70
|
+
if (typeof path === "string" && path.length > 0) return path;
|
|
71
|
+
// glob / grep sometimes scope via target_directory / path_filter; only a
|
|
72
|
+
// concrete directory root is confinable without inventing a glob parser.
|
|
73
|
+
const target = input.target_directory ?? input.cwd;
|
|
74
|
+
if (typeof target === "string" && target.length > 0) return target;
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type ConfineDecision = { block: true; reason: string };
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Decide whether one tool_call may run. Undefined means "no opinion" (allow).
|
|
82
|
+
* Pure so tests pin the gate without standing up a harness session.
|
|
83
|
+
*/
|
|
84
|
+
export function confineToolCall(
|
|
85
|
+
root: string,
|
|
86
|
+
toolName: string,
|
|
87
|
+
input: Record<string, unknown>,
|
|
88
|
+
): ConfineDecision | undefined {
|
|
89
|
+
const path = pathFromToolInput(toolName, input);
|
|
90
|
+
if (path === undefined) return undefined;
|
|
91
|
+
if (isInsideWorktree(root, path)) return undefined;
|
|
92
|
+
return {
|
|
93
|
+
block: true,
|
|
94
|
+
reason:
|
|
95
|
+
`Blocked: ${toolName} path "${path}" is outside the worker worktree (${root}). ` +
|
|
96
|
+
`Structured file tools may only touch the assigned checkout; use paths under it.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Minimal extension surface this package needs. Kept duck-typed so the peer
|
|
102
|
+
* harness does not have to be on disk for `tsc` — same reason `omp.ts` exists.
|
|
103
|
+
*/
|
|
104
|
+
export interface ConfinementPi {
|
|
105
|
+
on(
|
|
106
|
+
event: "tool_call",
|
|
107
|
+
handler: (
|
|
108
|
+
event: { toolName: string; input: Record<string, unknown> },
|
|
109
|
+
ctx: unknown,
|
|
110
|
+
) => ConfineDecision | undefined | Promise<ConfineDecision | undefined>,
|
|
111
|
+
): void;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Inline extension factory for `createAgentSession({ extensions: [...] })`.
|
|
116
|
+
* Installs the worktree gate on every structured file tool_call.
|
|
117
|
+
*/
|
|
118
|
+
export function worktreeConfinement(root: string): (pi: ConfinementPi) => void {
|
|
119
|
+
const rootAbs = resolve(root);
|
|
120
|
+
return (pi) => {
|
|
121
|
+
pi.on("tool_call", (event) => confineToolCall(rootAbs, event.toolName, event.input));
|
|
122
|
+
};
|
|
123
|
+
}
|