omp-conductor 0.18.0 → 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.
Files changed (65) hide show
  1. package/README.md +35 -1
  2. package/REFERENCE.md +61 -11
  3. package/agents/to-spec.md +94 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +35 -1
  6. package/src/admission.ts +204 -75
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +268 -7
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +62 -21
  11. package/src/briefs/to-spec.md +88 -0
  12. package/src/briefs/worker.md +2 -1
  13. package/src/cli.ts +124 -1
  14. package/src/command-help.ts +11 -0
  15. package/src/command-manifest.ts +38 -5
  16. package/src/commands/arm.ts +1 -1
  17. package/src/commands/context.ts +1 -0
  18. package/src/commands/drain.ts +176 -0
  19. package/src/commands/extend.ts +6 -10
  20. package/src/commands/intake.ts +4 -19
  21. package/src/commands/status.ts +5 -1
  22. package/src/commands/watch.ts +51 -16
  23. package/src/commands/worker.ts +9 -10
  24. package/src/config-schema.ts +43 -6
  25. package/src/config.ts +65 -9
  26. package/src/daemon.ts +879 -41
  27. package/src/dashboard/app.js +4 -1
  28. package/src/dashboard/server.ts +5 -2
  29. package/src/decisions.ts +243 -17
  30. package/src/diff-flags.ts +75 -1
  31. package/src/doctor.ts +60 -82
  32. package/src/escalate.ts +31 -14
  33. package/src/failure-class.ts +28 -2
  34. package/src/fleet.ts +239 -240
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +35 -1
  37. package/src/graph.ts +66 -1
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +242 -2
  40. package/src/lifecycle.ts +122 -1
  41. package/src/omp-settings.ts +19 -0
  42. package/src/omp.ts +183 -21
  43. package/src/orchestrator-tick.ts +1591 -32
  44. package/src/orchestrator.ts +12 -0
  45. package/src/privileged.ts +1 -4
  46. package/src/release-policy.ts +503 -9
  47. package/src/session-host.ts +65 -6
  48. package/src/settlement.ts +69 -17
  49. package/src/setup-host.ts +1225 -9
  50. package/src/setup-install.ts +28 -0
  51. package/src/setup-wizard.ts +154 -3
  52. package/src/setup.ts +83 -17
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +216 -12
  55. package/src/store.ts +443 -42
  56. package/src/to-spec.ts +408 -0
  57. package/src/tracker/github.ts +104 -14
  58. package/src/types.ts +405 -19
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +765 -56
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +12 -2
  65. package/src/worktree.ts +29 -12
@@ -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
- * proves the reply by scanning the orchestrator session transcript. That reply
7
- * also lands in the orchestrator as an ordinary user turn, where the model once
8
- * ad-libbed pairing-safety prose because it had no trusted way to recognise it.
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 pending-challenge record lives next to the other state
14
- * under `stateDir()` and both sides reach it through *this* leaf module.
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 a
19
- * diagnostic line the way a new artifact could. The orchestrator classifies a
20
- * reply by hashing its tokens against this record, so nothing on the east side
21
- * of the boundary trusts a `FLEET-` prefix.
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 ARM_CHALLENGE_FILE = "arm-challenge.json";
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
- /** Keyed by project name; `""` is the pre-multi-project (un-named) spelling. */
39
- type PendingChallenges = Record<string, PendingChallenge>;
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 challengesPath(): string {
42
- return join(stateDir(), ARM_CHALLENGE_FILE);
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
- function readChallenges(): PendingChallenges {
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
- const parsed: unknown = JSON.parse(readFileSync(challengesPath(), "utf8"));
52
- if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
53
- return parsed as PendingChallenges;
54
- }
55
- } catch {
56
- /* absent or unreadable — no active challenge */
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
- function writeChallenges(map: PendingChallenges): void {
62
- const path = challengesPath();
63
- mkdirSync(dirname(path), { recursive: true });
64
- // 0600 like the other conductor state an operator never shares; the record is
65
- // only a hash, but there is no reason to be less careful with it.
66
- writeFileSync(path, `${JSON.stringify(map)}\n`, { mode: 0o600 });
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
- /** Register a new active arming challenge, replacing any prior one for the project. */
75
- export function recordArmChallenge(project: string | undefined, code: string, expiresAt: number): void {
76
- const map = readChallenges();
77
- map[projectKey(project)] = { hash: challengeHash(code), expiresAt };
78
- writeChallenges(map);
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
- /** Drop the active challenge for a project once arming completed or timed out. */
82
- export function clearArmChallenge(project: string | undefined): void {
83
- const map = readChallenges();
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
- if (!(key in map)) return;
86
- delete map[key];
87
- writeChallenges(map);
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
- * Authenticated classification for the orchestrator's turn adapter: a reply is
92
- * an active arming proof iff the project has a non-expired pending challenge
93
- * and one of the reply's whitespace-separated tokens hashes to it. The code is
94
- * matched by hash matching `transcriptHasUserCode`'s leniency (the code
95
- * appears as a token) without ever trusting a `FLEET-` prefix or exposing the
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
- * Clears nothing: the host (`armTicks`) owns clearance once its transcript
100
- * proof lands, so the two sides cannot race for the record.
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 isActiveArmProof(project: string | undefined, replyText: string, now: number): boolean {
103
- const pending = readChallenges()[projectKey(project)];
104
- if (pending === undefined || now >= pending.expiresAt) return false;
105
- const targetHash = pending.hash;
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) return true;
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
  }