omp-conductor 0.19.7 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/REFERENCE.md +10 -1
- package/agents/to-spec.md +76 -9
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
- package/src/arm-challenge.ts +204 -85
- package/src/ask.ts +130 -615
- package/src/board.ts +7 -1
- package/src/brief-upgrade.ts +24 -0
- package/src/briefs/console.md +253 -0
- package/src/briefs/correction.md +203 -0
- package/src/briefs/orchestrator.md +167 -97
- package/src/briefs/policy.md +19 -16
- package/src/briefs/to-spec.md +76 -9
- package/src/briefs/worker.md +50 -16
- package/src/cli.ts +4 -0
- package/src/command-manifest.ts +54 -8
- package/src/commands/arm.ts +113 -49
- package/src/commands/console.ts +70 -0
- package/src/commands/context.ts +2 -0
- package/src/commands/epic.ts +132 -0
- package/src/commands/extend.ts +9 -1
- package/src/commands/intake.ts +44 -14
- package/src/commands/stats.ts +19 -4
- package/src/commands/worker.ts +9 -1
- package/src/config-schema.ts +13 -0
- package/src/config.ts +27 -0
- package/src/daemon/ack.ts +159 -0
- package/src/daemon/admission-pass.ts +135 -0
- package/src/daemon/brief.ts +461 -0
- package/src/daemon/deps.ts +539 -0
- package/src/daemon/dispatch.ts +1779 -0
- package/src/daemon/drain.ts +185 -0
- package/src/daemon/groom-pass.ts +412 -0
- package/src/daemon/http.ts +417 -0
- package/src/daemon/integrity.ts +108 -0
- package/src/daemon/panes.ts +180 -0
- package/src/daemon/review.ts +1888 -0
- package/src/daemon/runtime.ts +736 -0
- package/src/daemon/settle-pass.ts +589 -0
- package/src/daemon/supervision.ts +438 -0
- package/src/daemon/tick.ts +968 -0
- package/src/daemon/views.ts +751 -0
- package/src/daemon.ts +105 -7923
- package/src/dashboard/app.js +58 -0
- package/src/dashboard/controls.ts +22 -3
- package/src/dashboard/server.ts +4 -0
- package/src/diff-flags.ts +24 -3
- package/src/failure-class.ts +75 -1
- package/src/fleet.ts +290 -164
- package/src/groom.ts +461 -0
- package/src/http-token.ts +142 -0
- package/src/knowledge.ts +229 -0
- package/src/mining.ts +316 -0
- package/src/orchestrator-tick.ts +428 -1681
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +72 -6
- package/src/setup-host.ts +32 -9
- package/src/setup-wizard.ts +55 -7
- package/src/setup.ts +229 -3
- package/src/stats.ts +257 -2
- package/src/status-render.ts +158 -7
- package/src/store.ts +604 -26
- package/src/to-spec.ts +194 -21
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +416 -15
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +330 -39
- package/src/wake.ts +19 -2
- package/src/worker.ts +456 -1
package/REFERENCE.md
CHANGED
|
@@ -2811,7 +2811,9 @@ omp-conductor drain status [--project NAME]
|
|
|
2811
2811
|
omp-conductor drain cancel [--project NAME]
|
|
2812
2812
|
omp-conductor stop [--pane] [--project NAME]
|
|
2813
2813
|
omp-conductor arm [--project NAME]
|
|
2814
|
+
omp-conductor arm --reply TEXT [--project NAME]
|
|
2814
2815
|
omp-conductor disarm [--project NAME]
|
|
2816
|
+
omp-conductor console [--project NAME]
|
|
2815
2817
|
omp-conductor tail <issue> [--project NAME]
|
|
2816
2818
|
omp-conductor extend <issue> --turns N [--project NAME]
|
|
2817
2819
|
omp-conductor worker pause <issue> [--project NAME]
|
|
@@ -2829,6 +2831,9 @@ omp-conductor decision withdraw <id> [--reason TEXT] [--project NAME]
|
|
|
2829
2831
|
omp-conductor decision list [--project NAME] [--json]
|
|
2830
2832
|
omp-conductor watch add --note TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
|
|
2831
2833
|
omp-conductor watch list [--project NAME] [--json]
|
|
2834
|
+
omp-conductor epic approve <issue> [--project NAME]
|
|
2835
|
+
omp-conductor epic revoke <issue> [--project NAME]
|
|
2836
|
+
omp-conductor epic list [--project NAME] [--json]
|
|
2832
2837
|
omp-conductor daemon [--once] [--port N] [--project NAME]
|
|
2833
2838
|
omp-conductor resume [--project NAME]
|
|
2834
2839
|
omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
|
|
@@ -2852,8 +2857,9 @@ omp-conductor help
|
|
|
2852
2857
|
| `hold [--keep-ticks] [--project NAME]` | fleet | Soft stop: pause claiming **and** disarm ticks. Daemon and pane stay up. Prefer this when the intent is "stop the conductor" without killing processes. `--keep-ticks` pauses claiming but leaves the arm marker, so the heartbeat keeps reporting and `resume` alone restores the fleet — no fresh arm challenge. See [Stop the conductor](README.md#stop-the-conductor-hold--stop). |
|
|
2853
2858
|
| `drain start --until ISO\|DURATION [--reason TEXT]` / `drain status` / `drain cancel` | project | Start, inspect, or cancel the project's self-expiring admission fence (#484): a durable, bounded alternative to queue-label churn before a release. `start` writes the project's drain record through the landed `createDrain` — new claims pause while active runs settle, and admission resumes automatically at the absolute deadline even if the orchestrator dies. A successful mediated `conductor_release` also clears the drain once the terminal release act completes — version-bump preparation stays latched until the tag is actually cut — so the release window ends with the release itself rather than latching until the deadline (#791). `--until` takes an ISO instant or a relative duration (`90s`, `45m`, `2h`, `1d`) that must be bounded and in the future; a missing, unparseable, unbounded, or past expiry exits `2` before any state changes. `--reason` (1–500 characters) is persisted on the record. `status` reports inactive, or the active drain's creation time, absolute expiry, reason, and remaining active runs from the structured status snapshot. `cancel` removes only the named project's drain and is idempotent. The drain never touches the pause sentinel, the arm marker, or any queue label — it is a file record, not a hold. |
|
|
2854
2859
|
| `stop [--pane] [--project NAME]` | fleet | Stop the conductor: pause claiming, disarm ticks, then stop the dispatch daemon (systemctl-aware). Pane stays up unless `--pane` is passed. `stop --pane` also pins herdr-conductor recovery off for the conductor agent only — it does **not** stop `herdr-fleet.service` or any other herdr session. Fail-closed: exits nonzero unless the agent is proven gone. To bounce the daemon without stopping the fleet, use `restart`. |
|
|
2855
|
-
| `arm [--project NAME]` | fleet |
|
|
2860
|
+
| `arm [--project NAME]` / `arm --reply TEXT [--project NAME]` | fleet | Two mechanical halves, because nothing waits inside a session any more: the operator's reply lands in the console, which runs no tick extension, so the in-session acknowledgement this replaces could never be satisfied. `arm` files a challenge, sends it to the owner, and returns immediately — **nothing is armed yet** — printing the challenge id, how long it stays valid, the markers a reply will write, and the exact follow-up command. `arm --reply "<the operator's message, verbatim>"` verifies that message against the recorded challenge, writes the arm marker for exactly the projects that challenge named, and settles the challenge so the same code can never arm anything twice. Still proof-gated on conductor's own state — no transcript is read, so where (or whether) a session file lives does not matter (#614). A bare or empty `--reply` is refused rather than silently sending a second challenge in place of a verification. One ceremony covers the whole fleet (#991), and the challenge names its projects, so a host running two fleets is not ambiguous; `--project X` is still one project, one challenge, one marker. When the plumbing verdict is `claim-only` no challenge is sent and `arm` arms directly, naming that proof instead. Never auto-armed by `resume` / `hold`. |
|
|
2856
2861
|
| `disarm [--project NAME]` | fleet | Remove this project's arm marker so its ticks skip; another project's ticks keep running. Also clears a pre-per-project shared `armed` marker while that marker is still what holds this fleet's gate open — otherwise the disarm would not disarm. Processes untouched. |
|
|
2862
|
+
| `console [--project NAME]` | project | Provision the operator console's workspace and print how to launch it: a dedicated cwd at `<state dir>/console/<project>`, this project's rendered console brief as `CONSOLE.md`, and an `AGENTS.md` symlink beside it so a session started there loads that brief. Idempotent — an already-current brief and a correct link are reported and left alone, and a *regular* `AGENTS.md` an operator wrote is never overwritten. It launches nothing: the printed pane command (`cd <cwd> && OMP_TELEGRAM_DM_OWNER=1 omp`, without the env prefix on a host configuring more than one project, which names the others and why) is what `herdr/bin/recover.sh` restores with the fleet, and what you run by hand otherwise. The console cwd deliberately holds no `.conductor-tick.json`, so the heartbeat extension cannot activate in it. |
|
|
2857
2863
|
| `tail <issue>` | project | Follow the newest run for that issue: the worker's assistant text as `assistant: …` and each tool it calls as `tool: <name>`, printed as they land. Workers are omp sessions inside the daemon rather than terminals, so this is the only way to watch one live — a herdr pane running it becomes an observation window. Starts from the top of the transcript, not the end, so attaching to a run that is already ten turns in shows those ten turns. Exits `1` with `no run recorded for #N` when the issue has never been dispatched, or `no transcript yet (state: …)` when the attempt has not opened one. Otherwise it runs until `Ctrl-C`, or until the run has finished and its transcript has been silent for five seconds, and prints `run ended: <state>`. |
|
|
2858
2864
|
| `extend <issue> --turns N [--project NAME]` | project | Raise a live worker's effective turn ceiling through its owning daemon without restarting its session. If the latest run is failed, killed, orphaned, or blocked and has no live controller, store a one-shot ceiling for that issue's next claimed attempt instead. A next-attempt value must exceed the project base, every extension must stay at or below `workerMaxTurnsCeiling`, and live extensions remain monotonic. The pending value appears in `status`, is recorded in `ledger`, and is consumed atomically by one claim. |
|
|
2859
2865
|
| `worker pause <issue>` / `worker resume <issue>` | project | Cooperatively park one live worker without changing its run state or lane. Pause aborts the active turn to harness idle and freezes the remaining wall-clock budget; resume continues the same session with a prompt to re-check its last action before repeating it. This is separate from fleet-level `hold`, which refuses new claims and work-starting mutations while allowing pre-pause completion work and releases. |
|
|
@@ -2869,6 +2875,9 @@ omp-conductor help
|
|
|
2869
2875
|
| `decision list [--json]` | project | Open questions, oldest first: id, age, what each blocks, whether its condition is met, and the question. `--json` emits `{ project, decisions }`; the empty state is an empty array. Prints `no open decisions` in text mode when there are none. Watches are not listed here — `watch list` shows those. |
|
|
2870
2876
|
| `watch add --note TEXT [--blocks TEXT] [--resolves-when COND]` | project | Record a condition or carry note the orchestrator set for itself, with no human in the loop (#459). `--resolves-when` attaches a machine-checkable condition the daemon checks for you; a met watch wakes the next tick exactly as a met question does. Renders under its own "Watches" heading, is never counted in `decisions N open`, and never expires while it is *waiting* — once a condition fires, the ordinary seven-day window runs from that instant (#966). |
|
|
2871
2877
|
| `watch list [--json]` | project | Open watches, oldest first: id, age, what each blocks, whether its condition is met, and the note. `--json` emits `{ project, watches }`; the empty state is an empty array. Prints `no watches` in text mode when there are none. |
|
|
2878
|
+
| `epic approve <issue>` | project | Record standing consent to an epic's scope (#1041): brief-complete children of it pass the promotion ready gate and are queued without a fresh operator decision. Its own durable fact, not a resolved decision row — an approval never expires, and the gate reads a boolean instead of parsing an answer somebody wrote in prose. Idempotent and first-write-wins: re-approving keeps the original timestamp and prints that nothing changed, so a second approve is never mistaken for one that failed to stick. Whether the number really names an epic is not checked here — parents and children are native GitHub sub-issues, which the daemon resolves at promotion time. |
|
|
2879
|
+
| `epic revoke <issue>` | project | Withdraw an epic's approval, so its children need a decision again. A no-op on an epic that was never approved, and it exits `0`: `revoke` states what is true afterwards, exactly as `drain cancel` does. |
|
|
2880
|
+
| `epic list [--json]` | project | Approved epics, issue-ascending: issue, when it was approved, and who approved it. `--json` emits `{ project, epics }`; the empty state is an empty array. Prints `no approved epics` in text mode when there are none. |
|
|
2872
2881
|
| `daemon` | host | Run the loop in the **foreground**, ticking every 5 minutes and serving `/healthz`. Admitted workers run in a tracked background pool, so settlement and capacity checks remain periodic while they work; shutdown drains the pool before closing the store. This is what `start` launches and what a systemd unit should call. |
|
|
2873
2882
|
| `daemon --once` | host | Run a single tick, wait for workers admitted by that tick, and exit. No HTTP server or pidfile — a drill must not register itself as the daemon, or the next reader believes it and the real daemon's in-flight runs get reconciled as orphans. |
|
|
2874
2883
|
| `--port N` | — | Accepted by `start`, `restart` and `daemon`. Both `--port 9000` and `--port=9000` work; missing or out of range exits `2` rather than falling back to the default, because probing the wrong endpoint is worse than a hard failure. |
|
package/agents/to-spec.md
CHANGED
|
@@ -29,15 +29,70 @@ Exactly one of these five strings, nothing else:
|
|
|
29
29
|
- `ALREADY DONE` — the work already exists in the source (a later epic retired
|
|
30
30
|
the issue's premise counts as done; prove it with the symbol/file, never the
|
|
31
31
|
title).
|
|
32
|
-
- `PROMOTABLE` — well-specified,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
- `PROMOTABLE` — well-specified, the acceptance criteria are checkable, and it
|
|
33
|
+
fits one worker budget *with the sizing evidence below to prove it*; carries
|
|
34
|
+
the proposed brief.
|
|
35
|
+
- `NEEDS DECOMPOSITION` — the plan is real but does not fit one budget; carries
|
|
36
|
+
the ordered children it splits into and why each is its own slice.
|
|
36
37
|
- `BLOCKED` — a named open prerequisite, lane, or credential gap stands in the
|
|
37
38
|
way.
|
|
38
39
|
- `NEEDS PRODUCT DECISION` — the issue cannot proceed until a human decides
|
|
39
40
|
product shape, slice order, or scope; state the one question that unblocks it.
|
|
40
41
|
|
|
42
|
+
## Sizing: the one-budget claim needs a falsifier
|
|
43
|
+
|
|
44
|
+
`PROMOTABLE` asserts that one worker finishes this slice inside one attempt —
|
|
45
|
+
a single session under the fleet's configured turn ceiling and its 90-minute
|
|
46
|
+
wall clock, with continuations held in reserve for operational restarts and
|
|
47
|
+
not for the rest of the work. That assertion is worth nothing on its own.
|
|
48
|
+
Issue #1035 was groomed `PROMOTABLE` with nine acceptance criteria across four
|
|
49
|
+
files; attempt 1 died at 181 of its 180 turns with the feature half-built. The
|
|
50
|
+
verdict had claimed one-budget fit. What it never carried was evidence that
|
|
51
|
+
could have falsified the claim.
|
|
52
|
+
|
|
53
|
+
`sizingEvidence` is that evidence, read out of the source you just read:
|
|
54
|
+
|
|
55
|
+
1. **The distinct behaviours.** Name each behaviour or state transition the
|
|
56
|
+
slice introduces — not each acceptance-criterion bullet. Two bullets about
|
|
57
|
+
one transition are one behaviour; one bullet hiding "create it, reconcile
|
|
58
|
+
it, clean it up" is three.
|
|
59
|
+
2. **What each behaviour costs.** For every behaviour, the production module
|
|
60
|
+
that implements it and the focused test surface that proves it. A behaviour
|
|
61
|
+
whose proof needs a new integration harness costs far more than one an
|
|
62
|
+
existing unit test already reaches.
|
|
63
|
+
3. **The sequencing between them.** Which behaviours write the same file, and
|
|
64
|
+
so cannot be discovered, implemented and re-proved independently inside one
|
|
65
|
+
session.
|
|
66
|
+
4. **The conclusion.** Why that inventory fits one attempt — or, when it does
|
|
67
|
+
not, `NEEDS DECOMPOSITION`.
|
|
68
|
+
|
|
69
|
+
**The multi-lifecycle shape is the one to refuse.** A candidate bundling core
|
|
70
|
+
topology (creating the thing), lifecycle reconciliation (duplicate, live, dead
|
|
71
|
+
and orphaned states converging) and daemon/restart integration (the same
|
|
72
|
+
behaviour surviving a process restart) is three independently provable slices.
|
|
73
|
+
That candidate is `NEEDS DECOMPOSITION` unless your verdict names the existing
|
|
74
|
+
deep seam that already makes them one bounded change — a single reconciler
|
|
75
|
+
both paths funnel through, say — by file and symbol. "They are related" is not
|
|
76
|
+
a seam.
|
|
77
|
+
|
|
78
|
+
**A small file count is not a small slice.** Four files can be 28,812 lines
|
|
79
|
+
(that was #1035's own inventory) when they are high-fanout lifecycle modules
|
|
80
|
+
with large integration suites. Weigh the fanout of what you touch and the size
|
|
81
|
+
of the suites you must keep green, never the length of the write lane.
|
|
82
|
+
|
|
83
|
+
No threshold decides this — not a line count, not a criterion count, not a
|
|
84
|
+
file count. A threshold would be gamed by rewriting prose, and the sizes that
|
|
85
|
+
matter are structural. The judgement is yours; the evidence is what makes it
|
|
86
|
+
reviewable.
|
|
87
|
+
|
|
88
|
+
**When it does not fit**, `decomposition` is a filing instruction and not a
|
|
89
|
+
regret: the children in the order they must land, each with the title it would
|
|
90
|
+
be filed under, the exact write lane it owns, what it waits on, the silent
|
|
91
|
+
fake it invites and the commands that prove it. Two children writing the same
|
|
92
|
+
core file must be serialised — the later one names the earlier in its
|
|
93
|
+
dependencies — because two workers in one module is the collision the file
|
|
94
|
+
lane exists to prevent.
|
|
95
|
+
|
|
41
96
|
## The return contract
|
|
42
97
|
|
|
43
98
|
Answer in **one fenced JSON block, nothing else after it**. Every field is
|
|
@@ -46,10 +101,12 @@ required and no extra keys are accepted:
|
|
|
46
101
|
- `verdict` — one of the five strings above.
|
|
47
102
|
- `routing` — exactly one `owner/repo`, or `"MULTI"`.
|
|
48
103
|
- `routingSplit` — required iff `routing` is `"MULTI"`: what each slice goes to.
|
|
49
|
-
- `source` — `{ name, ref
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
104
|
+
- `source` — `{ name, ref }`: the authoritative source you read and the exact
|
|
105
|
+
ref you read it at. Conductor refuses a result that does not name both — an
|
|
106
|
+
unsourced verdict is not grooming, it is prose. Do not send a `freshAt`:
|
|
107
|
+
conductor stamps the observation time itself from the batch window and
|
|
108
|
+
refuses a batch whose window exceeds 24 hours, so inventing a timestamp is
|
|
109
|
+
neither required nor read (#1000).
|
|
53
110
|
- `evidence` — the files/symbols that prove the verdict. Required for
|
|
54
111
|
`ALREADY DONE`: name the symbol/file that already does the work, never a
|
|
55
112
|
title match. Welcome on every other verdict.
|
|
@@ -69,6 +126,16 @@ required and no extra keys are accepted:
|
|
|
69
126
|
- `fileLane` — the files and directories this slice writes.
|
|
70
127
|
- `dependencies` — open prerequisite issue numbers, each a bare number
|
|
71
128
|
(`875`) or a string (`"875"`); `[]` when none.
|
|
129
|
+
- `sizingEvidence` — required iff `verdict` is `PROMOTABLE`: the one-budget
|
|
130
|
+
analysis from the sizing section above — the distinct behaviours, the
|
|
131
|
+
production module and focused test surface each one needs, the shared-file
|
|
132
|
+
sequencing between them, and why that fits one attempt.
|
|
133
|
+
- `decomposition` — required iff `verdict` is `NEEDS DECOMPOSITION`, and
|
|
134
|
+
accepted on no other verdict: the ordered children, first to last. Each is
|
|
135
|
+
an object with `title`, `writeLane` (its exact write lane), `dependsOn` (an
|
|
136
|
+
earlier child's title or an existing issue number, `[]` for the first),
|
|
137
|
+
`likelySilentFake` and `proofCommands`. Children sharing a write-lane path
|
|
138
|
+
must serialise through `dependsOn`.
|
|
72
139
|
- `proposedBrief` — required iff `verdict` is `PROMOTABLE`: the brief a worker
|
|
73
140
|
would be dispatched with, including the silent fake and the proof commands.
|
|
74
141
|
- `reasonNotToPromote` — required for every other verdict: why this must not
|
|
@@ -78,7 +145,7 @@ required and no extra keys are accepted:
|
|
|
78
145
|
|
|
79
146
|
- **Prose is not evidence.** A verdict without the source-backed contract is
|
|
80
147
|
refused as malformed: every field above is required, and `source` must name
|
|
81
|
-
the ref you read
|
|
148
|
+
the ref you actually read.
|
|
82
149
|
- **Stale source reads like good source.** Judge the candidate against the
|
|
83
150
|
stated ref as it is now; a verdict drawn from memory of a different clone is
|
|
84
151
|
stale and will be refused.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
|
package/src/arm-challenge.ts
CHANGED
|
@@ -1,48 +1,60 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Authenticated pending-challenge state for the arming handshake (conductor
|
|
3
|
-
* #415, transaction reworked by #614, storage hardened by review of #896
|
|
3
|
+
* #415, transaction reworked by #614, storage hardened by review of #896,
|
|
4
|
+
* targets recorded when the console took over verification in phase 1 of the
|
|
5
|
+
* orchestrator-workflow redesign).
|
|
4
6
|
*
|
|
5
7
|
* `armTicks` (fleet.ts) sends a short-lived `FLEET-…` code to the operator and
|
|
6
|
-
*
|
|
7
|
-
* conductor
|
|
8
|
-
*
|
|
9
|
-
* turn — never the model, and never a transcript scan, so the proof no longer
|
|
10
|
-
* depends on where (or whether) a session file lives.
|
|
8
|
+
* files the challenge here. Verification is a second, mechanical CLI step:
|
|
9
|
+
* `omp-conductor arm --reply "<the operator's message>"` classifies the
|
|
10
|
+
* message against these records and arms the projects the challenge named.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* Nothing waits for the reply any more. The console session owns the operator
|
|
13
|
+
* DM, and no tick extension runs there — so the in-session acknowledgement
|
|
14
|
+
* wait `armTicks` used to hold could never be satisfied. What survives is the
|
|
15
|
+
* durable half: the record that says which code is live, until when, and which
|
|
16
|
+
* projects it arms.
|
|
17
|
+
*
|
|
18
|
+
* This module stays a leaf so both roles can reach the state without an import
|
|
19
|
+
* cycle: `fleet.ts` imports `daemon.ts`, `daemon.ts` imports
|
|
20
|
+
* `orchestrator-tick.ts`, so `orchestrator-tick.ts` can never import
|
|
21
|
+
* `fleet.ts`. Everything here reads only `config.ts`.
|
|
17
22
|
*
|
|
18
23
|
* The handshake keeps no shared mutable state at all — every file is named by
|
|
19
24
|
* its own key, and every deletion is id-addressed, so two processes can
|
|
20
25
|
* neither lose each other's updates nor delete each other's proofs:
|
|
21
26
|
*
|
|
22
27
|
* - `arm-challenges/<project key>.json` — one pending challenge per project:
|
|
23
|
-
* `{ project, id, hash, sentAt, expiresAt }`. Host-owned:
|
|
24
|
-
* writes it (record before the send, settle on
|
|
25
|
-
* failure), always through the atomic tmp+rename write
|
|
26
|
-
* uses. A re-armed project overwrites its own file —
|
|
27
|
-
* prior id's acknowledgement; a concurrent arm for a
|
|
28
|
-
* touches a different file.
|
|
28
|
+
* `{ project, id, hash, sentAt, expiresAt, targets?, owner? }`. Host-owned:
|
|
29
|
+
* only the arm ceremony writes it (record before the send, settle on
|
|
30
|
+
* consumption or send failure), always through the atomic tmp+rename write
|
|
31
|
+
* the admission ack uses. A re-armed project overwrites its own file —
|
|
32
|
+
* pruning only its own prior id's acknowledgement; a concurrent arm for a
|
|
33
|
+
* different project touches a different file.
|
|
29
34
|
* - `arm-challenge-acks/<challenge id>.json` — one file per acknowledgement:
|
|
30
|
-
* `{ challengeId, acknowledgedAt }`. The
|
|
35
|
+
* `{ challengeId, acknowledgedAt }`. The reply step's acknowledgement is a
|
|
31
36
|
* single-file create/overwrite via rename — it never reads or rewrites
|
|
32
|
-
* another transaction's record
|
|
33
|
-
*
|
|
37
|
+
* another transaction's record, and `doctor` reads it to report a handshake
|
|
38
|
+
* that was answered but never settled. Files that outlive their transaction
|
|
39
|
+
* (a crash between the acknowledgement and the settle) are inert by
|
|
34
40
|
* construction and removed only by age, never by membership in any
|
|
35
41
|
* directory snapshot (see {@link gcAgedAcks}).
|
|
36
42
|
*
|
|
43
|
+
* `targets` is why the reply step never re-derives what to arm: the challenge
|
|
44
|
+
* the operator answered named specific projects, and the config could have
|
|
45
|
+
* changed between the send and the reply. Arming what the record says is the
|
|
46
|
+
* only reading that matches what the operator was asked. It is optional purely
|
|
47
|
+
* for backward tolerance — a record written before this field existed still
|
|
48
|
+
* verifies (see {@link resolveArmReply}).
|
|
49
|
+
*
|
|
37
50
|
* Only the sha-256 of the code is ever persisted — never the code, whose
|
|
38
|
-
* plaintext appearance in the
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* fleet with a lookalike.
|
|
51
|
+
* plaintext appearance in the chat is the whole proof and must not leak into a
|
|
52
|
+
* durable report, an issue comment, or a diagnostic line the way a new
|
|
53
|
+
* artifact could. The id is a random UUID cut with the challenge, so
|
|
54
|
+
* acknowledgement records are challenge-specific without carrying anything
|
|
55
|
+
* guessable. No `FLEET-` prefix is ever trusted: a reply is classified by
|
|
56
|
+
* hashing its whitespace-separated tokens against the pending record, so no
|
|
57
|
+
* lookalike can arm a fleet.
|
|
46
58
|
*/
|
|
47
59
|
|
|
48
60
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -53,6 +65,21 @@ import { stateDir } from "./config.ts";
|
|
|
53
65
|
const ARM_CHALLENGES_DIR = "arm-challenges";
|
|
54
66
|
const ARM_ACKS_DIR = "arm-challenge-acks";
|
|
55
67
|
|
|
68
|
+
/**
|
|
69
|
+
* One project a challenge arms: recorded with the challenge so the reply step
|
|
70
|
+
* arms exactly what the operator was asked about, never a re-derivation.
|
|
71
|
+
*/
|
|
72
|
+
export interface ArmTarget {
|
|
73
|
+
/**
|
|
74
|
+
* The configured project name, absent only for a legacy unstamped
|
|
75
|
+
* single-project fleet — the same value `resolveArmState` needs to decide
|
|
76
|
+
* whether the pre-per-project shared marker still speaks for this project.
|
|
77
|
+
*/
|
|
78
|
+
project?: string;
|
|
79
|
+
/** The arm marker path this project's heartbeat reads. */
|
|
80
|
+
armedFile: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
56
83
|
interface PendingChallenge {
|
|
57
84
|
/** The project key this pending belongs to, mirrored for read-back checks. */
|
|
58
85
|
project: string;
|
|
@@ -64,12 +91,24 @@ interface PendingChallenge {
|
|
|
64
91
|
sentAt: number;
|
|
65
92
|
/** Unix ms after which a matching reply is no longer an active proof. */
|
|
66
93
|
expiresAt: number;
|
|
94
|
+
/**
|
|
95
|
+
* The projects this challenge arms. Absent on a record written before the
|
|
96
|
+
* field existed; callers fall back rather than crash.
|
|
97
|
+
*/
|
|
98
|
+
targets?: ArmTarget[];
|
|
99
|
+
/**
|
|
100
|
+
* The chat the challenge was actually sent to, so the marker the reply step
|
|
101
|
+
* writes attributes the arm to the operator who was asked — not to whatever
|
|
102
|
+
* the paired channel happens to say minutes later. Absent on a pre-targets
|
|
103
|
+
* record.
|
|
104
|
+
*/
|
|
105
|
+
owner?: string;
|
|
67
106
|
}
|
|
68
107
|
|
|
69
108
|
interface ArmAcknowledgement {
|
|
70
109
|
/** The exact challenge id this record satisfies. */
|
|
71
110
|
challengeId: string;
|
|
72
|
-
/** Unix ms the
|
|
111
|
+
/** Unix ms the reply step classified the operator's message as this proof. */
|
|
73
112
|
acknowledgedAt: number;
|
|
74
113
|
}
|
|
75
114
|
|
|
@@ -125,6 +164,31 @@ function writeFileAtomic(path: string, content: string): void {
|
|
|
125
164
|
}
|
|
126
165
|
}
|
|
127
166
|
|
|
167
|
+
/**
|
|
168
|
+
* A recorded target list, validated element by element, or undefined when the
|
|
169
|
+
* record carries none. `invalid` is deliberately distinct from `absent`: a
|
|
170
|
+
* torn target list must fail the whole record closed (below) rather than
|
|
171
|
+
* silently degrade into the legacy fallback, which would arm a project the
|
|
172
|
+
* challenge may never have named.
|
|
173
|
+
*/
|
|
174
|
+
function readTargets(raw: unknown): { kind: "absent" } | { kind: "invalid" } | { kind: "ok"; targets: ArmTarget[] } {
|
|
175
|
+
if (raw === undefined) return { kind: "absent" };
|
|
176
|
+
if (!Array.isArray(raw) || raw.length === 0) return { kind: "invalid" };
|
|
177
|
+
const targets: ArmTarget[] = [];
|
|
178
|
+
for (const entry of raw) {
|
|
179
|
+
if (entry === null || typeof entry !== "object") return { kind: "invalid" };
|
|
180
|
+
const target = entry as { project?: unknown; armedFile?: unknown };
|
|
181
|
+
if (typeof target.armedFile !== "string" || target.armedFile.length === 0) return { kind: "invalid" };
|
|
182
|
+
if (target.project !== undefined && typeof target.project !== "string") return { kind: "invalid" };
|
|
183
|
+
targets.push(
|
|
184
|
+
target.project === undefined
|
|
185
|
+
? { armedFile: target.armedFile }
|
|
186
|
+
: { project: target.project, armedFile: target.armedFile },
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return { kind: "ok", targets };
|
|
190
|
+
}
|
|
191
|
+
|
|
128
192
|
/**
|
|
129
193
|
* This project's pending challenge parsed from its own file, with every field
|
|
130
194
|
* validated. Anything absent, torn, garbage, pre-#614, or naming another
|
|
@@ -144,11 +208,17 @@ function readPendingFor(key: string): PendingChallenge | undefined {
|
|
|
144
208
|
pending.project !== key ||
|
|
145
209
|
typeof pending.id !== "string" ||
|
|
146
210
|
typeof pending.hash !== "string" ||
|
|
147
|
-
typeof pending.expiresAt !== "number"
|
|
211
|
+
typeof pending.expiresAt !== "number" ||
|
|
212
|
+
(pending.owner !== undefined && typeof pending.owner !== "string")
|
|
148
213
|
) {
|
|
149
214
|
return undefined;
|
|
150
215
|
}
|
|
151
|
-
|
|
216
|
+
const targets = readTargets(pending.targets);
|
|
217
|
+
if (targets.kind === "invalid") return undefined;
|
|
218
|
+
return {
|
|
219
|
+
...(pending as PendingChallenge),
|
|
220
|
+
...(targets.kind === "ok" ? { targets: targets.targets } : {}),
|
|
221
|
+
};
|
|
152
222
|
}
|
|
153
223
|
|
|
154
224
|
/** sha-256 hex of the challenge code — the persisted token, never the code. */
|
|
@@ -182,14 +252,13 @@ export function looksLikeChallengeCode(token: string): boolean {
|
|
|
182
252
|
|
|
183
253
|
/**
|
|
184
254
|
* Acknowledgement files outliving their transaction — a crash between the
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* removal.
|
|
255
|
+
* classification and the settle — are inert by construction: nothing ever
|
|
256
|
+
* reads their id again, and ids are UUIDs, so no future transaction can
|
|
257
|
+
* collide with one. They are therefore not swept by membership (a directory
|
|
258
|
+
* listing captured before another process records is stale the moment it is
|
|
259
|
+
* taken, and acting on it deletes on-time proofs), only by age: a file older
|
|
260
|
+
* than {@link ACK_ORPHAN_GC_AFTER_MS} cannot belong to a live handshake under
|
|
261
|
+
* any configured window, so its mtime alone decides removal.
|
|
193
262
|
*/
|
|
194
263
|
const ACK_ORPHAN_GC_AFTER_MS = 24 * 60 * 60 * 1000;
|
|
195
264
|
|
|
@@ -212,6 +281,17 @@ function gcAgedAcks(now: number): void {
|
|
|
212
281
|
}
|
|
213
282
|
}
|
|
214
283
|
|
|
284
|
+
/**
|
|
285
|
+
* What a challenge arms, recorded with it: the projects the operator was told
|
|
286
|
+
* about and the chat they were asked in.
|
|
287
|
+
*/
|
|
288
|
+
export interface ArmChallengePlan {
|
|
289
|
+
/** Every project a matching reply arms — one for `arm --project X`, all of them for a fleet ceremony. */
|
|
290
|
+
targets: readonly ArmTarget[];
|
|
291
|
+
/** The chat the challenge is being sent to. */
|
|
292
|
+
owner: string;
|
|
293
|
+
}
|
|
294
|
+
|
|
215
295
|
/**
|
|
216
296
|
* Register a new active arming challenge for the project — overwriting the
|
|
217
297
|
* project's own pending file — and return the transaction id. Before the
|
|
@@ -219,18 +299,31 @@ function gcAgedAcks(now: number): void {
|
|
|
219
299
|
* acknowledgement is pruned: project-local cleanup with no directory
|
|
220
300
|
* snapshot, so another project's handshake cannot lose its proof here no
|
|
221
301
|
* matter how the calls interleave.
|
|
302
|
+
*
|
|
303
|
+
* `plan` is optional only so the pre-targets call shape stays valid for the
|
|
304
|
+
* surfaces (doctor's fixtures, the CLI's own tests) that record a challenge
|
|
305
|
+
* without meaning to arm anything through the reply step. The ceremony always
|
|
306
|
+
* passes one.
|
|
222
307
|
*/
|
|
223
308
|
export function recordArmChallenge(
|
|
224
309
|
project: string | undefined,
|
|
225
310
|
code: string,
|
|
226
311
|
sentAt: number,
|
|
227
312
|
expiresAt: number,
|
|
313
|
+
plan?: ArmChallengePlan,
|
|
228
314
|
): string {
|
|
229
315
|
const key = projectKey(project);
|
|
230
316
|
// Read the prior record BEFORE the overwrite shadows it.
|
|
231
317
|
const prior = readPendingFor(key);
|
|
232
318
|
const id = randomUUID();
|
|
233
|
-
const record: PendingChallenge = {
|
|
319
|
+
const record: PendingChallenge = {
|
|
320
|
+
project: key,
|
|
321
|
+
id,
|
|
322
|
+
hash: challengeHash(code),
|
|
323
|
+
sentAt,
|
|
324
|
+
expiresAt,
|
|
325
|
+
...(plan === undefined ? {} : { targets: [...plan.targets], owner: plan.owner }),
|
|
326
|
+
};
|
|
234
327
|
writeFileAtomic(pendingPath(key), `${JSON.stringify(record)}\n`);
|
|
235
328
|
if (prior !== undefined) rmSync(ackPath(prior.id), { force: true });
|
|
236
329
|
gcAgedAcks(sentAt);
|
|
@@ -238,35 +331,53 @@ export function recordArmChallenge(
|
|
|
238
331
|
}
|
|
239
332
|
|
|
240
333
|
/**
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
* atomically write the challenge-id-specific acknowledgement record the host
|
|
244
|
-
* waits on. Returns whether the reply is an active arming proof, driving the
|
|
245
|
-
* existing deterministic UX path.
|
|
246
|
-
*
|
|
247
|
-
* Writes only this challenge's own file — it never reads or rewrites any other
|
|
248
|
-
* record — so a wrong token, wrong project, expired challenge, or a racing
|
|
249
|
-
* host settle all fail closed without touching anyone else's handshake, and
|
|
250
|
-
* two projects acknowledging concurrently cannot clobber each other.
|
|
251
|
-
*/
|
|
252
|
-
/**
|
|
253
|
-
* What an inbound reply was, so the adapter can answer it (#991).
|
|
334
|
+
* What a reply was, so the console can answer it (#991, retained now that the
|
|
335
|
+
* console — not a tick session — does the answering).
|
|
254
336
|
*
|
|
255
|
-
* Before this, a non-matching or expired code was simply ignored: the
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
337
|
+
* Before this, a non-matching or expired code was simply ignored: the operator
|
|
338
|
+
* had no idea they had been heard, and the fastest way to be sure you held the
|
|
339
|
+
* current code was to scroll the chat. That is exactly the work
|
|
340
|
+
* conductor-owned verification exists to end.
|
|
259
341
|
*
|
|
260
342
|
* - `matched` — an active proof; the acknowledgement is written.
|
|
261
343
|
* - `expired` — a code that hashes to a pending record whose window has closed.
|
|
262
344
|
* - `unknown` — a challenge-shaped token that matches nothing readable here.
|
|
263
|
-
* - `none` — no challenge-shaped token at all: ordinary chat,
|
|
345
|
+
* - `none` — no challenge-shaped token at all: ordinary chat, arm nothing.
|
|
264
346
|
*/
|
|
265
347
|
export type ArmReplyVerdict = "matched" | "expired" | "unknown" | "none";
|
|
266
348
|
|
|
349
|
+
/** The transaction a matching reply proved, and what the challenge said it arms. */
|
|
350
|
+
export interface ArmReplyMatch {
|
|
351
|
+
/**
|
|
352
|
+
* The state key the matched record lives under — a project key (possibly the
|
|
353
|
+
* empty key of an unstamped config) or {@link FLEET_ARM_KEY}. Passed straight
|
|
354
|
+
* back to {@link clearArmTransaction}, so the settle is addressed to the
|
|
355
|
+
* record that was actually consumed.
|
|
356
|
+
*/
|
|
357
|
+
key: string;
|
|
358
|
+
/** The matched transaction's id; every deletion is addressed by it. */
|
|
359
|
+
id: string;
|
|
360
|
+
/**
|
|
361
|
+
* The projects the challenge recorded. Absent on a record written before
|
|
362
|
+
* targets existed — the caller falls back rather than crashing.
|
|
363
|
+
*/
|
|
364
|
+
targets?: ArmTarget[];
|
|
365
|
+
/** The chat the challenge was sent to. Absent on a pre-targets record. */
|
|
366
|
+
owner?: string;
|
|
367
|
+
}
|
|
368
|
+
|
|
267
369
|
/**
|
|
268
|
-
*
|
|
269
|
-
*
|
|
370
|
+
* Discriminated so a caller cannot reach for `match` on a verdict that has
|
|
371
|
+
* none: only `matched` carries the transaction, and only `matched` may write a
|
|
372
|
+
* marker.
|
|
373
|
+
*/
|
|
374
|
+
export type ArmReplyResolution =
|
|
375
|
+
| { verdict: "matched"; match: ArmReplyMatch }
|
|
376
|
+
| { verdict: Exclude<ArmReplyVerdict, "matched">; match?: undefined };
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Classify one operator reply against this host's own records and, on a match,
|
|
380
|
+
* write the challenge-id-specific acknowledgement.
|
|
270
381
|
*
|
|
271
382
|
* Two records are consulted and no others: this project's, and the fleet-wide
|
|
272
383
|
* one (#991). That bound is the non-disclosure property — a reply that matches
|
|
@@ -274,25 +385,35 @@ export type ArmReplyVerdict = "matched" | "expired" | "unknown" | "none";
|
|
|
274
385
|
* challenge, because no other project's record is ever read.
|
|
275
386
|
*
|
|
276
387
|
* Fail-closed ordering is unchanged: the project's own active challenge wins,
|
|
277
|
-
* then the fleet ceremony, and an expired record is never a proof.
|
|
388
|
+
* then the fleet ceremony, and an expired record is never a proof. The write
|
|
389
|
+
* touches only the matched challenge's own file, so a wrong token, a wrong
|
|
390
|
+
* project, an expired challenge or a racing settle all fail closed without
|
|
391
|
+
* touching anyone else's handshake.
|
|
392
|
+
*
|
|
393
|
+
* The acknowledgement record is no longer something a waiter polls — nothing
|
|
394
|
+
* waits any more. It is kept because it is the only durable evidence that a
|
|
395
|
+
* reply was seen, which is what `doctor`'s `arm-ack` finding reports when a
|
|
396
|
+
* ceremony was answered but never settled.
|
|
278
397
|
*/
|
|
279
|
-
export function
|
|
398
|
+
export function resolveArmReply(
|
|
280
399
|
project: string | undefined,
|
|
281
400
|
replyText: string,
|
|
282
401
|
now: number,
|
|
283
|
-
):
|
|
402
|
+
): ArmReplyResolution {
|
|
284
403
|
// Challenge codes contain no whitespace, so tokenising on whitespace never
|
|
285
404
|
// splits one; empty replies simply yield no token.
|
|
286
405
|
const tokens = replyText.trim().split(/\s+/).filter((token) => token.length > 0);
|
|
287
|
-
if (tokens.length === 0) return "none";
|
|
288
|
-
const candidates = [
|
|
289
|
-
|
|
290
|
-
|
|
406
|
+
if (tokens.length === 0) return { verdict: "none" };
|
|
407
|
+
const candidates: { key: string; pending: PendingChallenge }[] = [];
|
|
408
|
+
for (const key of [projectKey(project), FLEET_ARM_KEY]) {
|
|
409
|
+
const pending = readPendingFor(key);
|
|
410
|
+
if (pending !== undefined) candidates.push({ key, pending });
|
|
411
|
+
}
|
|
291
412
|
|
|
292
413
|
let expired = false;
|
|
293
414
|
for (const token of tokens) {
|
|
294
415
|
const hash = challengeHash(token);
|
|
295
|
-
for (const pending of candidates) {
|
|
416
|
+
for (const { key, pending } of candidates) {
|
|
296
417
|
if (pending.hash !== hash) continue;
|
|
297
418
|
if (now >= pending.expiresAt) {
|
|
298
419
|
// Keep looking: a fresh record for the same code is a proof, and only
|
|
@@ -300,27 +421,25 @@ export function classifyArmReply(
|
|
|
300
421
|
expired = true;
|
|
301
422
|
continue;
|
|
302
423
|
}
|
|
303
|
-
// Keyed by the challenge id, so replays overwrite the one record
|
|
304
|
-
//
|
|
305
|
-
// here.
|
|
424
|
+
// Keyed by the challenge id, so replays overwrite the one record this
|
|
425
|
+
// transaction owns; a stale id's file can never be created here.
|
|
306
426
|
const record: ArmAcknowledgement = { challengeId: pending.id, acknowledgedAt: now };
|
|
307
427
|
writeFileAtomic(ackPath(pending.id), `${JSON.stringify(record)}\n`);
|
|
308
|
-
return
|
|
428
|
+
return {
|
|
429
|
+
verdict: "matched",
|
|
430
|
+
match: {
|
|
431
|
+
key,
|
|
432
|
+
id: pending.id,
|
|
433
|
+
...(pending.targets === undefined ? {} : { targets: pending.targets }),
|
|
434
|
+
...(pending.owner === undefined ? {} : { owner: pending.owner }),
|
|
435
|
+
},
|
|
436
|
+
};
|
|
309
437
|
}
|
|
310
438
|
}
|
|
311
|
-
if (expired) return "expired";
|
|
312
|
-
// Shape only, and only to decide
|
|
313
|
-
//
|
|
314
|
-
return tokens.some((token) => looksLikeChallengeCode(token)) ? "unknown" : "none";
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* The inbound adapter's acknowledgement (conductor #614), kept as the boolean
|
|
319
|
-
* the availability gate already reads. {@link classifyArmReply} is the same
|
|
320
|
-
* pass with the non-matching cases named.
|
|
321
|
-
*/
|
|
322
|
-
export function acknowledgeArmReply(project: string | undefined, replyText: string, now: number): boolean {
|
|
323
|
-
return classifyArmReply(project, replyText, now) === "matched";
|
|
439
|
+
if (expired) return { verdict: "expired" };
|
|
440
|
+
// Shape only, and only to decide how the operator is answered. A token that
|
|
441
|
+
// is not challenge-shaped is ordinary chat.
|
|
442
|
+
return { verdict: tokens.some((token) => looksLikeChallengeCode(token)) ? "unknown" : "none" };
|
|
324
443
|
}
|
|
325
444
|
|
|
326
445
|
/** The acknowledgement record for one exact challenge id, or undefined. */
|