omp-conductor 0.18.1 → 0.18.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/README.md +1 -1
- package/REFERENCE.md +1 -1
- package/agents/to-spec.md +6 -2
- package/package.json +1 -1
- package/schema/config.schema.json +6 -1
- package/src/arm-challenge.ts +250 -57
- package/src/briefs/orchestrator.md +21 -8
- package/src/briefs/to-spec.md +6 -2
- package/src/cli.ts +122 -1
- package/src/command-manifest.ts +16 -5
- package/src/commands/arm.ts +1 -1
- package/src/commands/intake.ts +4 -19
- package/src/commands/watch.ts +4 -17
- package/src/config-schema.ts +19 -6
- package/src/config.ts +23 -8
- package/src/daemon.ts +150 -241
- package/src/decisions.ts +19 -11
- package/src/doctor.ts +55 -129
- package/src/escalate.ts +22 -11
- package/src/fleet.ts +93 -218
- package/src/host.ts +7 -332
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +11 -56
- package/src/orchestrator-tick.ts +225 -37
- package/src/session-host.ts +6 -41
- package/src/setup-host.ts +27 -10
- package/src/setup-wizard.ts +141 -1
- package/src/setup.ts +54 -4
- package/src/status-render.ts +138 -1
- package/src/to-spec.ts +23 -2
- package/src/types.ts +62 -6
- package/src/verbs/server.ts +38 -3
- package/src/worker.ts +0 -13
package/README.md
CHANGED
|
@@ -316,7 +316,7 @@ Two words, and one of them takes a flag:
|
|
|
316
316
|
| `stop` | paused | disarmed | stopped (systemctl-aware) | left running |
|
|
317
317
|
| `stop --pane` | paused | disarmed | stopped | stopped + recovery pinned off |
|
|
318
318
|
|
|
319
|
-
`resume` clears pause **and** any `stop --pane` recovery pin, and **never re-arms**. `arm` is proof-gated: it sends a Telegram challenge and writes the arm marker only after your reply
|
|
319
|
+
`resume` clears pause **and** any `stop --pane` recovery pin, and **never re-arms**. `arm` is proof-gated: it sends a Telegram challenge and writes the arm marker only after the orchestrator's inbound adapter has acknowledged your reply in conductor's own state — no transcript is read, so where (or whether) a session file lives does not matter (#614). `stop --pane` targets the configured conductor agent only — it does **not** run `systemctl stop herdr-fleet`. To bounce the daemon without stopping the fleet, use `restart`.
|
|
320
320
|
|
|
321
321
|
**Removed in 0.15.0**, each exiting `2` with a pointer: `halt` (now `stop`), `pause` (use `hold`), `release-pane` (now part of `resume`), and `graph-setup` (now [`setup graph`](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#code-graph-discovery)). Dropping `pause` cost one real capability, "stop claiming but keep ticking", which is now `hold --keep-ticks` rather than a fifth verb. It matters because disarming is the expensive half of a hold: re-arming sends a Telegram challenge and blocks until you answer it in the chat, so stopping claims for ten minutes otherwise costs a manual round trip to get the heartbeat back — while the workers a hold deliberately leaves running have nothing shepherding them.
|
|
322
322
|
|
package/REFERENCE.md
CHANGED
|
@@ -2317,7 +2317,7 @@ omp-conductor help
|
|
|
2317
2317
|
| `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). |
|
|
2318
2318
|
| `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. |
|
|
2319
2319
|
| `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`. |
|
|
2320
|
-
| `arm [--project NAME]` | fleet | Proof-gated: send a Telegram challenge and write this project's arm marker only after your reply
|
|
2320
|
+
| `arm [--project NAME]` | fleet | Proof-gated: send a Telegram challenge and write this project's arm marker only after the orchestrator's inbound adapter has acknowledged your reply in conductor's own state — no transcript is read, so where (or whether) a session file lives does not matter (#614). The challenge names the project, so a host running two fleets is not ambiguous. Never auto-armed by `resume` / `hold`. |
|
|
2321
2321
|
| `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. |
|
|
2322
2322
|
| `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>`. |
|
|
2323
2323
|
| `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. |
|
package/agents/to-spec.md
CHANGED
|
@@ -50,6 +50,9 @@ required and no extra keys are accepted:
|
|
|
50
50
|
exact ref, and `freshAt` = epoch milliseconds when you actually observed it.
|
|
51
51
|
Conductor refuses results whose source is older than 24 hours or missing
|
|
52
52
|
name/ref/freshAt — an unsourced verdict is not grooming, it is prose.
|
|
53
|
+
- `evidence` — the files/symbols that prove the verdict. Required for
|
|
54
|
+
`ALREADY DONE`: name the symbol/file that already does the work, never a
|
|
55
|
+
title match. Welcome on every other verdict.
|
|
53
56
|
- `laterWorkInvalidates` — boolean: did later work (an epic committed after
|
|
54
57
|
this candidate was filed) retire its premise?
|
|
55
58
|
- `laterWorkNote` — what you searched for that check and what you found. Even
|
|
@@ -63,8 +66,9 @@ required and no extra keys are accepted:
|
|
|
63
66
|
implementing, and how to prove it is not.
|
|
64
67
|
- `proofCommands` — the focused commands that prove the work, each with its
|
|
65
68
|
`cwd` when it matters.
|
|
66
|
-
- `fileLane` — the files and directories this slice writes
|
|
67
|
-
|
|
69
|
+
- `fileLane` — the files and directories this slice writes.
|
|
70
|
+
- `dependencies` — open prerequisite issue numbers, each a bare number
|
|
71
|
+
(`875`) or a string (`"875"`); `[]` when none.
|
|
68
72
|
- `proposedBrief` — required iff `verdict` is `PROMOTABLE`: the brief a worker
|
|
69
73
|
would be dispatched with, including the silent fake and the proof commands.
|
|
70
74
|
- `reasonNotToPromote` — required for every other verdict: why this must not
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.2",
|
|
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.",
|
|
@@ -387,10 +387,15 @@
|
|
|
387
387
|
"type": "integer",
|
|
388
388
|
"minimum": 1,
|
|
389
389
|
"maximum": 6
|
|
390
|
+
},
|
|
391
|
+
"adjudicator": {
|
|
392
|
+
"default": "task",
|
|
393
|
+
"type": "string",
|
|
394
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
|
390
395
|
}
|
|
391
396
|
},
|
|
392
397
|
"additionalProperties": false,
|
|
393
|
-
"description": "Review strictness
|
|
398
|
+
"description": "Review strictness, round ceiling and adjudicator role for green PRs"
|
|
394
399
|
},
|
|
395
400
|
"authority": {
|
|
396
401
|
"type": "object",
|
package/src/arm-challenge.ts
CHANGED
|
@@ -1,69 +1,154 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Authenticated pending-challenge state for the arming handshake (conductor
|
|
3
|
-
* #415).
|
|
3
|
+
* #415, transaction reworked by #614, storage hardened by review of #896).
|
|
4
4
|
*
|
|
5
5
|
* `armTicks` (fleet.ts) sends a short-lived `FLEET-…` code to the operator and
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* waits for the orchestrator to acknowledge it. The acknowledgement is
|
|
7
|
+
* conductor-owned state: the orchestrator's inbound user-turn adapter
|
|
8
|
+
* (orchestrator-tick.ts) writes it the moment the real reply arrives as a user
|
|
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.
|
|
9
11
|
*
|
|
10
12
|
* This module is the bridge between the two roles without an import cycle:
|
|
11
13
|
* `fleet.ts` imports `daemon.ts`, `daemon.ts` imports `orchestrator-tick.ts`,
|
|
12
14
|
* so `orchestrator-tick.ts` can never import `fleet.ts`. Both already import
|
|
13
|
-
* `config.ts`, so the
|
|
14
|
-
*
|
|
15
|
+
* `config.ts`, so the state lives next to the other state under `stateDir()`
|
|
16
|
+
* and both sides reach it through *this* leaf module.
|
|
17
|
+
*
|
|
18
|
+
* The handshake keeps no shared mutable state at all — every file is named by
|
|
19
|
+
* its own key, and every deletion is id-addressed, so two processes can
|
|
20
|
+
* neither lose each other's updates nor delete each other's proofs:
|
|
21
|
+
*
|
|
22
|
+
* - `arm-challenges/<project key>.json` — one pending challenge per project:
|
|
23
|
+
* `{ project, id, hash, sentAt, expiresAt }`. Host-owned: only `armTicks`
|
|
24
|
+
* writes it (record before the send, settle on consumption/timeout/send
|
|
25
|
+
* failure), always through the atomic tmp+rename write the admission ack
|
|
26
|
+
* uses. A re-armed project overwrites its own file — pruning only its own
|
|
27
|
+
* prior id's acknowledgement; a concurrent arm for a different project
|
|
28
|
+
* touches a different file.
|
|
29
|
+
* - `arm-challenge-acks/<challenge id>.json` — one file per acknowledgement:
|
|
30
|
+
* `{ challengeId, acknowledgedAt }`. The adapter's acknowledgement is a
|
|
31
|
+
* single-file create/overwrite via rename — it never reads or rewrites
|
|
32
|
+
* another transaction's record. Files that outlive their transaction (a
|
|
33
|
+
* crash between the adapter's write and the host's settle) are inert by
|
|
34
|
+
* construction and removed only by age, never by membership in any
|
|
35
|
+
* directory snapshot (see {@link gcAgedAcks}).
|
|
15
36
|
*
|
|
16
37
|
* Only the sha-256 of the code is ever persisted — never the code, whose
|
|
17
38
|
* plaintext appearance in the protected session transcript is already the
|
|
18
|
-
* backend proof and must not leak into a durable report, an issue comment, or
|
|
19
|
-
* diagnostic line the way a new artifact could. The
|
|
20
|
-
*
|
|
21
|
-
*
|
|
39
|
+
* backend proof and must not leak into a durable report, an issue comment, or
|
|
40
|
+
* a diagnostic line the way a new artifact could. The id is a random UUID cut
|
|
41
|
+
* with the challenge, so acknowledgement records are challenge-specific
|
|
42
|
+
* without carrying anything guessable. Neither side trusts a `FLEET-` prefix:
|
|
43
|
+
* a reply is classified by hashing its whitespace-separated tokens against
|
|
44
|
+
* the pending record, so nothing on the east side of the boundary can arm a
|
|
45
|
+
* fleet with a lookalike.
|
|
22
46
|
*/
|
|
23
47
|
|
|
24
|
-
import { createHash } from "node:crypto";
|
|
25
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
48
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
49
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
26
50
|
import { dirname, join } from "node:path";
|
|
27
51
|
import { stateDir } from "./config.ts";
|
|
28
52
|
|
|
29
|
-
const
|
|
53
|
+
const ARM_CHALLENGES_DIR = "arm-challenges";
|
|
54
|
+
const ARM_ACKS_DIR = "arm-challenge-acks";
|
|
30
55
|
|
|
31
56
|
interface PendingChallenge {
|
|
57
|
+
/** The project key this pending belongs to, mirrored for read-back checks. */
|
|
58
|
+
project: string;
|
|
59
|
+
/** Random transaction id, cut when this challenge was recorded. */
|
|
60
|
+
id: string;
|
|
32
61
|
/** sha-256 hex of the challenge code — never the code itself. */
|
|
33
62
|
hash: string;
|
|
63
|
+
/** Unix ms the challenge was recorded, just before the send. */
|
|
64
|
+
sentAt: number;
|
|
34
65
|
/** Unix ms after which a matching reply is no longer an active proof. */
|
|
35
66
|
expiresAt: number;
|
|
36
67
|
}
|
|
37
68
|
|
|
38
|
-
|
|
39
|
-
|
|
69
|
+
interface ArmAcknowledgement {
|
|
70
|
+
/** The exact challenge id this record satisfies. */
|
|
71
|
+
challengeId: string;
|
|
72
|
+
/** Unix ms the inbound adapter acknowledged the reply. */
|
|
73
|
+
acknowledgedAt: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function challengesDir(): string {
|
|
77
|
+
return join(stateDir(), ARM_CHALLENGES_DIR);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function acksDir(): string {
|
|
81
|
+
return join(stateDir(), ARM_ACKS_DIR);
|
|
82
|
+
}
|
|
40
83
|
|
|
41
|
-
function
|
|
42
|
-
return join(
|
|
84
|
+
function ackPath(challengeId: string): string {
|
|
85
|
+
return join(acksDir(), `${challengeId}.json`);
|
|
43
86
|
}
|
|
44
87
|
|
|
45
88
|
function projectKey(project?: string): string {
|
|
46
89
|
return project ?? "";
|
|
47
90
|
}
|
|
48
91
|
|
|
49
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Filesystem-safe, injective encoding of a project key. Raw names that need
|
|
94
|
+
* no escaping are used verbatim; everything else (including the empty key of
|
|
95
|
+
* an unstamped config) becomes `=` plus hex bytes — `=` never appears in the
|
|
96
|
+
* raw class, so the two namespaces cannot collide.
|
|
97
|
+
*/
|
|
98
|
+
function encodeProjectKey(key: string): string {
|
|
99
|
+
if (/^[A-Za-z0-9._-]{1,64}$/.test(key) && key !== "." && key !== "..") return key;
|
|
100
|
+
return `=${[...Buffer.from(key, "utf8")].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function pendingPath(key: string): string {
|
|
104
|
+
return join(challengesDir(), `${encodeProjectKey(key)}.json`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Atomic durable write, exactly like the admission ack: content lands at a
|
|
109
|
+
* unique temp path and is renamed into place, so every reader — including the
|
|
110
|
+
* one-shot inbound adapter racing the host — sees either the old file or the
|
|
111
|
+
* new one, never a mid-truncate parse.
|
|
112
|
+
*/
|
|
113
|
+
function writeFileAtomic(path: string, content: string): void {
|
|
114
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
115
|
+
const tmp = `${path}.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`;
|
|
50
116
|
try {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
117
|
+
// 0600 like the other conductor state an operator never shares; the
|
|
118
|
+
// records are hashes and random ids, but there is no reason to be less
|
|
119
|
+
// careful.
|
|
120
|
+
writeFileSync(tmp, content, { mode: 0o600 });
|
|
121
|
+
renameSync(tmp, path);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
rmSync(tmp, { force: true });
|
|
124
|
+
throw err;
|
|
57
125
|
}
|
|
58
|
-
return {};
|
|
59
126
|
}
|
|
60
127
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
128
|
+
/**
|
|
129
|
+
* This project's pending challenge parsed from its own file, with every field
|
|
130
|
+
* validated. Anything absent, torn, garbage, pre-#614, or naming another
|
|
131
|
+
* project reads as none — fail closed until the host's next transaction
|
|
132
|
+
* rewrites the file wholesale.
|
|
133
|
+
*/
|
|
134
|
+
function readPendingFor(key: string): PendingChallenge | undefined {
|
|
135
|
+
let parsed: unknown;
|
|
136
|
+
try {
|
|
137
|
+
parsed = JSON.parse(readFileSync(pendingPath(key), "utf8"));
|
|
138
|
+
} catch {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
if (parsed === null || typeof parsed !== "object") return undefined;
|
|
142
|
+
const pending = parsed as Partial<PendingChallenge>;
|
|
143
|
+
if (
|
|
144
|
+
pending.project !== key ||
|
|
145
|
+
typeof pending.id !== "string" ||
|
|
146
|
+
typeof pending.hash !== "string" ||
|
|
147
|
+
typeof pending.expiresAt !== "number"
|
|
148
|
+
) {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
return pending as PendingChallenge;
|
|
67
152
|
}
|
|
68
153
|
|
|
69
154
|
/** sha-256 hex of the challenge code — the persisted token, never the code. */
|
|
@@ -71,42 +156,150 @@ function challengeHash(code: string): string {
|
|
|
71
156
|
return createHash("sha256").update(code).digest("hex");
|
|
72
157
|
}
|
|
73
158
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
159
|
+
/**
|
|
160
|
+
* Acknowledgement files outliving their transaction — a crash between the
|
|
161
|
+
* adapter's write and the host's settle — are inert by construction: no
|
|
162
|
+
* waiter ever polls their id again, and ids are UUIDs, so no future
|
|
163
|
+
* transaction can collide with one. They are therefore not swept by
|
|
164
|
+
* membership (a directory listing captured before another process records is
|
|
165
|
+
* stale the moment it is taken, and acting on it deletes on-time proofs),
|
|
166
|
+
* only by age: a file older than {@link ACK_ORPHAN_GC_AFTER_MS} cannot belong
|
|
167
|
+
* to a live handshake under any configured window, so its mtime alone decides
|
|
168
|
+
* removal.
|
|
169
|
+
*/
|
|
170
|
+
const ACK_ORPHAN_GC_AFTER_MS = 24 * 60 * 60 * 1000;
|
|
171
|
+
|
|
172
|
+
function gcAgedAcks(now: number): void {
|
|
173
|
+
let names: string[] = [];
|
|
174
|
+
try {
|
|
175
|
+
names = readdirSync(acksDir());
|
|
176
|
+
} catch {
|
|
177
|
+
return; /* no acknowledgements yet */
|
|
178
|
+
}
|
|
179
|
+
for (const name of names) {
|
|
180
|
+
if (!name.endsWith(".json")) continue;
|
|
181
|
+
const path = join(acksDir(), name);
|
|
182
|
+
try {
|
|
183
|
+
if (statSync(path).mtimeMs >= now - ACK_ORPHAN_GC_AFTER_MS) continue;
|
|
184
|
+
} catch {
|
|
185
|
+
continue; /* raced away */
|
|
186
|
+
}
|
|
187
|
+
rmSync(path, { force: true });
|
|
188
|
+
}
|
|
79
189
|
}
|
|
80
190
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
191
|
+
/**
|
|
192
|
+
* Register a new active arming challenge for the project — overwriting the
|
|
193
|
+
* project's own pending file — and return the transaction id. Before the
|
|
194
|
+
* overwrite, the project's PRIOR id is read back and exactly that id's
|
|
195
|
+
* acknowledgement is pruned: project-local cleanup with no directory
|
|
196
|
+
* snapshot, so another project's handshake cannot lose its proof here no
|
|
197
|
+
* matter how the calls interleave.
|
|
198
|
+
*/
|
|
199
|
+
export function recordArmChallenge(
|
|
200
|
+
project: string | undefined,
|
|
201
|
+
code: string,
|
|
202
|
+
sentAt: number,
|
|
203
|
+
expiresAt: number,
|
|
204
|
+
): string {
|
|
84
205
|
const key = projectKey(project);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
206
|
+
// Read the prior record BEFORE the overwrite shadows it.
|
|
207
|
+
const prior = readPendingFor(key);
|
|
208
|
+
const id = randomUUID();
|
|
209
|
+
const record: PendingChallenge = { project: key, id, hash: challengeHash(code), sentAt, expiresAt };
|
|
210
|
+
writeFileAtomic(pendingPath(key), `${JSON.stringify(record)}\n`);
|
|
211
|
+
if (prior !== undefined) rmSync(ackPath(prior.id), { force: true });
|
|
212
|
+
gcAgedAcks(sentAt);
|
|
213
|
+
return id;
|
|
88
214
|
}
|
|
89
215
|
|
|
90
216
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* code to the model's other reads. An unsolicited lookalike that matches no
|
|
97
|
-
* active challenge, and a reply in the wrong project, both stay inert.
|
|
217
|
+
* The inbound adapter's acknowledgement (conductor #614): classify the reply
|
|
218
|
+
* against the project's non-expired pending challenge and, on a match,
|
|
219
|
+
* atomically write the challenge-id-specific acknowledgement record the host
|
|
220
|
+
* waits on. Returns whether the reply is an active arming proof, driving the
|
|
221
|
+
* existing deterministic UX path.
|
|
98
222
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
223
|
+
* Writes only this challenge's own file — it never reads or rewrites any other
|
|
224
|
+
* record — so a wrong token, wrong project, expired challenge, or a racing
|
|
225
|
+
* host settle all fail closed without touching anyone else's handshake, and
|
|
226
|
+
* two projects acknowledging concurrently cannot clobber each other.
|
|
101
227
|
*/
|
|
102
|
-
export function
|
|
103
|
-
const pending =
|
|
104
|
-
if (pending === undefined
|
|
105
|
-
|
|
228
|
+
export function acknowledgeArmReply(project: string | undefined, replyText: string, now: number): boolean {
|
|
229
|
+
const pending = readPendingFor(projectKey(project));
|
|
230
|
+
if (pending === undefined) return false;
|
|
231
|
+
if (now >= pending.expiresAt) return false;
|
|
106
232
|
// Challenge codes contain no whitespace, so tokenising on whitespace never
|
|
107
233
|
// splits one; empty replies simply yield no token.
|
|
234
|
+
const targetHash = pending.hash;
|
|
235
|
+
let matched = false;
|
|
108
236
|
for (const token of replyText.trim().split(/\s+/)) {
|
|
109
|
-
if (token.length > 0 && challengeHash(token) === targetHash)
|
|
237
|
+
if (token.length > 0 && challengeHash(token) === targetHash) {
|
|
238
|
+
matched = true;
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
110
241
|
}
|
|
111
|
-
return false;
|
|
242
|
+
if (!matched) return false;
|
|
243
|
+
// Keyed by the challenge id, so replays overwrite the one record the single
|
|
244
|
+
// live waiter consumes; a stale id's file can never be created here.
|
|
245
|
+
const record: ArmAcknowledgement = { challengeId: pending.id, acknowledgedAt: now };
|
|
246
|
+
writeFileAtomic(ackPath(pending.id), `${JSON.stringify(record)}\n`);
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The acknowledgement record for one exact challenge id, or undefined. */
|
|
251
|
+
export function readArmAcknowledgement(challengeId: string): ArmAcknowledgement | undefined {
|
|
252
|
+
let parsed: unknown;
|
|
253
|
+
try {
|
|
254
|
+
parsed = JSON.parse(readFileSync(ackPath(challengeId), "utf8"));
|
|
255
|
+
} catch {
|
|
256
|
+
return undefined; /* absent, torn or garbage — not an acknowledgement */
|
|
257
|
+
}
|
|
258
|
+
if (parsed === null || typeof parsed !== "object") return undefined;
|
|
259
|
+
const ack = parsed as { challengeId?: unknown; acknowledgedAt?: unknown };
|
|
260
|
+
if (ack.challengeId !== challengeId || typeof ack.acknowledgedAt !== "number") return undefined;
|
|
261
|
+
return { challengeId, acknowledgedAt: ack.acknowledgedAt };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Settle one arm transaction — consumption, timeout, or send failure: remove
|
|
266
|
+
* the project's pending file iff it still names this transaction, and drop
|
|
267
|
+
* its acknowledgement. Deletion is id-addressed only, so another project's
|
|
268
|
+
* records are untouchable by construction, and a same-project replacement
|
|
269
|
+
* (different id) survives the stale settle.
|
|
270
|
+
*/
|
|
271
|
+
export function clearArmTransaction(project: string | undefined, challengeId: string): void {
|
|
272
|
+
const key = projectKey(project);
|
|
273
|
+
const pending = readPendingFor(key);
|
|
274
|
+
if (pending !== undefined && pending.id === challengeId) rmSync(pendingPath(key), { force: true });
|
|
275
|
+
rmSync(ackPath(challengeId), { force: true });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface ArmChallengeSighting {
|
|
279
|
+
/** The pending transaction's id — random, not the challenge code. */
|
|
280
|
+
id: string;
|
|
281
|
+
/** Unix ms the challenge was recorded, when the record carries it. */
|
|
282
|
+
sentAt?: number;
|
|
283
|
+
/** Unix ms the challenge expires, when the record carries it. */
|
|
284
|
+
expiresAt?: number;
|
|
285
|
+
/** Present once the inbound adapter acknowledged this challenge id. */
|
|
286
|
+
acknowledgedAt?: number;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Doctor's view of the project's open transaction, or undefined when none is
|
|
291
|
+
* recorded. An expired-but-uncleared record is reported, not hidden: it is
|
|
292
|
+
* inert (a reply past expiry is refused) but lingers until the project's next
|
|
293
|
+
* arm replaces it.
|
|
294
|
+
*/
|
|
295
|
+
export function observeArmChallenge(project: string | undefined): ArmChallengeSighting | undefined {
|
|
296
|
+
const pending = readPendingFor(projectKey(project));
|
|
297
|
+
if (pending === undefined || typeof pending.id !== "string") return undefined;
|
|
298
|
+
const ack = readArmAcknowledgement(pending.id);
|
|
299
|
+
return {
|
|
300
|
+
id: pending.id,
|
|
301
|
+
...(typeof pending.sentAt === "number" ? { sentAt: pending.sentAt } : {}),
|
|
302
|
+
...(typeof pending.expiresAt === "number" ? { expiresAt: pending.expiresAt } : {}),
|
|
303
|
+
...(ack === undefined ? {} : { acknowledgedAt: ack.acknowledgedAt }),
|
|
304
|
+
};
|
|
112
305
|
}
|
|
@@ -254,18 +254,31 @@ Keep the queue worth draining.
|
|
|
254
254
|
source/ref/freshness is refused as blocked, never groomed. The quality bar
|
|
255
255
|
above does not move.
|
|
256
256
|
- **Every to-spec agent answers the same return contract**, or its output is
|
|
257
|
-
refused: the strict schema carries
|
|
258
|
-
|
|
257
|
+
refused: the strict schema carries exactly these fields — the generated task
|
|
258
|
+
schema and the persistence parser are that one contract, so an extra or
|
|
259
|
+
missing field is refused as malformed:
|
|
260
|
+
- `verdict` — `ALREADY DONE` / `PROMOTABLE` / `NEEDS DECOMPOSITION` / `BLOCKED` /
|
|
259
261
|
`NEEDS PRODUCT DECISION`
|
|
260
|
-
- routing — exactly one repo
|
|
261
|
-
-
|
|
262
|
-
-
|
|
263
|
-
- existing tests covering the behaviour, by path
|
|
264
|
-
- the one thing most likely to be silently faked
|
|
265
|
-
- source — where the code was read: the clone/ref and how fresh it is. A
|
|
262
|
+
- `routing` — exactly one `owner/repo`, or `MULTI`
|
|
263
|
+
- `routingSplit` — required iff `routing` is `MULTI`: what each slice goes to
|
|
264
|
+
- `source` — where the code was read: the clone/ref and how fresh it is. A
|
|
266
265
|
scout that cannot reach a source it trusts returns `BLOCKED` and says so;
|
|
267
266
|
silent fallback to an unnamed source is the failure mode of delegated
|
|
268
267
|
research — stale evidence reads exactly like good evidence.
|
|
268
|
+
- `evidence` — the files/symbols proving the verdict; required for
|
|
269
|
+
`ALREADY DONE` (the file or symbol that already does the work, never a
|
|
270
|
+
title match)
|
|
271
|
+
- `laterWorkInvalidates` — whether later work retired the premise
|
|
272
|
+
- `laterWorkNote` — what that check searched and found
|
|
273
|
+
- `entryPoints` — the 3-6 files to change or read first
|
|
274
|
+
- `existingTests` — existing tests covering the behaviour, by path
|
|
275
|
+
- `likelySilentFake` — the one thing most likely to be silently faked
|
|
276
|
+
- `proofCommands` — the focused commands that prove the work
|
|
277
|
+
- `fileLane` — the files and directories this slice writes
|
|
278
|
+
- `dependencies` — open prerequisite issue numbers, bare (`875`) or string
|
|
279
|
+
(`"875"`); `[]` when none
|
|
280
|
+
- `proposedBrief` — required iff `verdict` is `PROMOTABLE`
|
|
281
|
+
- `reasonNotToPromote` — required for every other verdict
|
|
269
282
|
- **Disqualifying an issue is a successful grooming outcome.** Measured on this
|
|
270
283
|
package's own fleet: four scouts over sixteen backlog issues promoted four and
|
|
271
284
|
*disqualified six* that looked promotable from their titles — four written
|
package/src/briefs/to-spec.md
CHANGED
|
@@ -44,6 +44,9 @@ required field and no extra keys. The fields are:
|
|
|
44
44
|
the exact ref, and `freshAt` = epoch milliseconds when you actually observed
|
|
45
45
|
it. Conductor refuses results whose source is older than 24 hours or missing
|
|
46
46
|
name/ref/freshAt — an unsourced verdict is not grooming, it is prose.
|
|
47
|
+
- `evidence` — the files/symbols that prove the verdict. Required for
|
|
48
|
+
`ALREADY DONE`: name the symbol/file that already does the work, never a
|
|
49
|
+
title match. Welcome on every other verdict.
|
|
47
50
|
- `laterWorkInvalidates` — boolean: did later work (an epic or issue committed
|
|
48
51
|
after this candidate was filed) retire its premise?
|
|
49
52
|
- `laterWorkNote` — what you searched for that check and what you found. Even
|
|
@@ -57,8 +60,9 @@ required field and no extra keys. The fields are:
|
|
|
57
60
|
implementing, and how to prove it is not.
|
|
58
61
|
- `proofCommands` — the focused commands that prove the work, each with its
|
|
59
62
|
`cwd` when it matters.
|
|
60
|
-
- `fileLane` — the files and directories this slice writes
|
|
61
|
-
|
|
63
|
+
- `fileLane` — the files and directories this slice writes.
|
|
64
|
+
- `dependencies` — open prerequisite issue numbers, each a bare number
|
|
65
|
+
(`875`) or a string (`"875"`); `[]` when none.
|
|
62
66
|
- `proposedBrief` — required iff `verdict` is `PROMOTABLE`: the brief a worker
|
|
63
67
|
would be dispatched with, including the silent fake and the proof commands.
|
|
64
68
|
- `reasonNotToPromote` — required for every other verdict: why this must not
|