omp-conductor 0.18.1 → 0.19.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/README.md +106 -41
- package/REFERENCE.md +866 -31
- package/agents/to-spec.md +6 -2
- package/package.json +1 -1
- package/schema/config.schema.json +32 -1
- package/src/admission.ts +212 -26
- package/src/arm-challenge.ts +250 -57
- package/src/ask.ts +288 -1
- package/src/briefs/orchestrator.md +27 -13
- package/src/briefs/to-spec.md +6 -2
- package/src/cli.ts +127 -2
- package/src/command-help.ts +9 -1
- package/src/command-manifest.ts +52 -8
- package/src/commands/arm.ts +6 -2
- package/src/commands/context.ts +2 -0
- package/src/commands/intake.ts +4 -19
- package/src/commands/message.ts +26 -2
- package/src/commands/reconcile-units.ts +104 -0
- package/src/commands/release-composition.ts +232 -0
- package/src/commands/resume.ts +2 -27
- package/src/commands/setup.ts +101 -16
- package/src/commands/stats.ts +11 -30
- package/src/commands/tail.ts +31 -1
- package/src/commands/upgrade.ts +20 -3
- package/src/commands/verb.ts +2 -1
- package/src/commands/watch.ts +4 -17
- package/src/config-schema.ts +38 -6
- package/src/config.ts +103 -8
- package/src/credential-class.ts +366 -0
- package/src/daemon.ts +1368 -529
- package/src/dashboard/app.js +504 -2
- package/src/dashboard/controls.ts +336 -0
- package/src/dashboard/index.html +30 -0
- package/src/dashboard/server.ts +271 -30
- package/src/dashboard/style.css +116 -0
- package/src/dashboard/transcript.ts +173 -0
- package/src/decisions.ts +19 -11
- package/src/doctor.ts +431 -148
- package/src/escalate.ts +22 -11
- package/src/failure-class.ts +59 -0
- package/src/fleet.ts +587 -230
- package/src/host.ts +6 -455
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +40 -56
- package/src/orchestrator-tick.ts +564 -121
- package/src/pause.ts +233 -0
- package/src/session-host.ts +6 -41
- package/src/settlement.ts +159 -2
- package/src/setup-answers.ts +97 -0
- package/src/setup-host.ts +343 -1160
- package/src/setup-install.ts +204 -27
- package/src/setup-wizard.ts +252 -51
- package/src/setup.ts +87 -4
- package/src/spend-telemetry.ts +117 -0
- package/src/stats.ts +35 -0
- package/src/status-render.ts +485 -19
- package/src/store.ts +1229 -55
- package/src/telegram-freshness.ts +269 -0
- package/src/to-spec.ts +50 -2
- package/src/types.ts +759 -10
- package/src/unblock.ts +22 -0
- package/src/unit-reconcile.ts +303 -0
- package/src/upgrade-verify.ts +8 -1
- package/src/upgrade.ts +299 -12
- package/src/verbs/actions.ts +124 -10
- package/src/verbs/protocol.ts +70 -2
- package/src/verbs/server.ts +485 -11
- package/src/wake.ts +48 -0
- package/src/worker.ts +401 -14
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
|
}
|
package/src/ask.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* decision — nothing here reopens that clock.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import type { DecisionRecord, InterruptCategory, Store } from "./types.ts";
|
|
30
|
+
import type { DecisionRecord, DecisionState, InterruptCategory, Store } from "./types.ts";
|
|
31
31
|
import { INTERRUPT_CATEGORIES } from "./types.ts";
|
|
32
32
|
|
|
33
33
|
/** The bounded ask tool this package registers on an orchestrator session. */
|
|
@@ -373,6 +373,34 @@ export function askParameterSchema(): Record<string, unknown> {
|
|
|
373
373
|
};
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
+
/**
|
|
377
|
+
* The questionnaire tool's arguments (#947): the spec it belongs to, plus items
|
|
378
|
+
* that are each exactly one {@link askParameterSchema}. Composed rather than
|
|
379
|
+
* restated, so an item can never accept something a single ask refuses.
|
|
380
|
+
*/
|
|
381
|
+
export function questionnaireParameterSchema(): Record<string, unknown> {
|
|
382
|
+
return {
|
|
383
|
+
type: "object",
|
|
384
|
+
properties: {
|
|
385
|
+
"spec-issue": {
|
|
386
|
+
type: "number",
|
|
387
|
+
description:
|
|
388
|
+
"The issue these questions spec out. The answers become that issue's provenance, so a later reader sees why a slice is shaped the way it is instead of re-litigating it.",
|
|
389
|
+
},
|
|
390
|
+
items: {
|
|
391
|
+
type: "array",
|
|
392
|
+
minItems: 1,
|
|
393
|
+
maxItems: MAX_QUESTIONNAIRE_ITEMS,
|
|
394
|
+
description:
|
|
395
|
+
"The judgement calls only — anything a repo read can answer is your own work, not an operator question. Delivered as one message, answerable in any order, each item resolving independently.",
|
|
396
|
+
items: askParameterSchema(),
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
required: ["spec-issue", "items"],
|
|
400
|
+
additionalProperties: false,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
376
404
|
/**
|
|
377
405
|
* The ceiling for one ask, in whole seconds. The model may name one, but the
|
|
378
406
|
* enforcement never depends on it: an ask without a `timeoutSeconds` gets the
|
|
@@ -411,6 +439,149 @@ export function askMessageFor(request: AskRequest): string {
|
|
|
411
439
|
return lines.join("\n");
|
|
412
440
|
}
|
|
413
441
|
|
|
442
|
+
/** The tool name a spec-out questionnaire arrives on (#947). */
|
|
443
|
+
export const QUESTIONNAIRE_TOOL = "conductor_questionnaire";
|
|
444
|
+
|
|
445
|
+
/** How many items one delivery may carry. A questionnaire longer than this is
|
|
446
|
+
* not a questionnaire, it is an interview the operator will abandon halfway —
|
|
447
|
+
* and the ceiling that bounds the whole group is one ask's, not one per item. */
|
|
448
|
+
export const MAX_QUESTIONNAIRE_ITEMS = 6;
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* One spec-out questionnaire (#947): several questions about ONE issue,
|
|
452
|
+
* delivered together and answerable in any order.
|
|
453
|
+
*
|
|
454
|
+
* It is deliberately N {@link AskRequest}s plus a spec binding rather than a
|
|
455
|
+
* new question shape. Every per-item semantic a spec-out needs — options, a
|
|
456
|
+
* recommendation, `auto-proceed` versus `park`, the shape ceiling, the
|
|
457
|
+
* auto-applied marker — already exists and is already tested here; what did not
|
|
458
|
+
* exist is asking six of them once. Re-deriving those semantics for a
|
|
459
|
+
* "questionnaire item" would be the same contract in two places, and the second
|
|
460
|
+
* copy is the one that drifts.
|
|
461
|
+
*/
|
|
462
|
+
export interface QuestionnaireRequest {
|
|
463
|
+
/** The issue these questions spec out; their answers are its provenance. */
|
|
464
|
+
specIssue: number;
|
|
465
|
+
/** The items, in delivery order. Each is an ordinary bounded ask. */
|
|
466
|
+
items: AskRequest[];
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
type QuestionnaireParse =
|
|
470
|
+
| { ok: true; request: QuestionnaireRequest }
|
|
471
|
+
| { ok: false; problem: string };
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Validate one questionnaire call. Strict exactly like {@link parseAskRequest},
|
|
475
|
+
* and every item is validated through it — including the shape ceiling, which
|
|
476
|
+
* refuses the **whole group** rather than truncating or dropping one item. A
|
|
477
|
+
* partially accepted questionnaire would deliver a question set that does not
|
|
478
|
+
* match the rows behind it, which is the one failure this whole feature exists
|
|
479
|
+
* to prevent.
|
|
480
|
+
*/
|
|
481
|
+
export function parseQuestionnaireRequest(raw: unknown): QuestionnaireParse {
|
|
482
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
483
|
+
return { ok: false, problem: `${QUESTIONNAIRE_TOOL} arguments must be an object` };
|
|
484
|
+
}
|
|
485
|
+
const record = raw as Record<string, unknown>;
|
|
486
|
+
const known = new Set(["spec-issue", "items"]);
|
|
487
|
+
for (const key of Object.keys(record)) {
|
|
488
|
+
if (!known.has(key)) {
|
|
489
|
+
return { ok: false, problem: `unknown argument "${key}" — expected spec-issue and items` };
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const specIssue = record["spec-issue"];
|
|
493
|
+
if (typeof specIssue !== "number" || !Number.isInteger(specIssue) || specIssue <= 0) {
|
|
494
|
+
return {
|
|
495
|
+
ok: false,
|
|
496
|
+
problem: 'spec-issue must be the issue number these questions spec out — a questionnaire whose answers belong to nothing is chat history, not provenance',
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
const items = record["items"];
|
|
500
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
501
|
+
return { ok: false, problem: "items must be a non-empty array of questions" };
|
|
502
|
+
}
|
|
503
|
+
if (items.length > MAX_QUESTIONNAIRE_ITEMS) {
|
|
504
|
+
return {
|
|
505
|
+
ok: false,
|
|
506
|
+
problem: `${items.length} items — over the ${MAX_QUESTIONNAIRE_ITEMS}-item ceiling. Ask the judgement calls only; anything a repo read answers is not an operator question`,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
const parsed: AskRequest[] = [];
|
|
510
|
+
for (const [index, item] of items.entries()) {
|
|
511
|
+
const one = parseAskRequest(item);
|
|
512
|
+
if (!one.ok) return { ok: false, problem: `item ${index + 1}: ${one.problem}` };
|
|
513
|
+
const shape = validateQuestionShape(one.request.question);
|
|
514
|
+
if (!shape.ok) return { ok: false, problem: `item ${index + 1}: ${shape.problem}` };
|
|
515
|
+
parsed.push(one.request);
|
|
516
|
+
}
|
|
517
|
+
return { ok: true, request: { specIssue, items: parsed } };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The questionnaire exactly as it is delivered: **one** message, its items
|
|
522
|
+
* numbered so an operator can answer them in any order by naming the number.
|
|
523
|
+
*
|
|
524
|
+
* One delivery is the point. N deliveries would be N interrupts for one
|
|
525
|
+
* decision, which is what the serial workaround already did and why nothing was
|
|
526
|
+
* ever answered as a set.
|
|
527
|
+
*/
|
|
528
|
+
export function questionnaireMessageFor(request: QuestionnaireRequest): string {
|
|
529
|
+
const lines = [`QUESTION: ${request.items.length} calls to spec out #${request.specIssue} — answer by number, in any order.`];
|
|
530
|
+
for (const [index, item] of request.items.entries()) {
|
|
531
|
+
const parts = [`${index + 1}. ${item.question}`];
|
|
532
|
+
if (item.options !== undefined && item.options.length > 0) {
|
|
533
|
+
parts.push(
|
|
534
|
+
` options: ${item.options
|
|
535
|
+
.map((option) => (option.description === undefined ? option.label : `${option.label} — ${option.description}`))
|
|
536
|
+
.join("; ")}`,
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
if (item.recommended !== undefined) parts.push(` rec: ${item.recommended}`);
|
|
540
|
+
// Silence is a real outcome, so it is stated per item rather than once for
|
|
541
|
+
// the group: a questionnaire may legitimately mix "proceed on my
|
|
542
|
+
// recommendation" with "this one waits for you".
|
|
543
|
+
parts.push(
|
|
544
|
+
` on silence: ${
|
|
545
|
+
item.onTimeout === "auto-proceed" ? `applies ${item.recommended ?? "the recommendation"}` : "stays open"
|
|
546
|
+
}`,
|
|
547
|
+
);
|
|
548
|
+
lines.push(parts.join("\n"));
|
|
549
|
+
}
|
|
550
|
+
return lines.join("\n");
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** What the ceiling did to one still-open item. */
|
|
554
|
+
export type QuestionnaireItemTimeout =
|
|
555
|
+
| { kind: "auto-applied"; id: string; resolution: string }
|
|
556
|
+
| { kind: "parked"; id: string };
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Apply the ceiling to a questionnaire, per item (#947).
|
|
560
|
+
*
|
|
561
|
+
* Pure, and per item on purpose. The group is one delivery but N decisions: an
|
|
562
|
+
* item the operator answered keeps that answer, an `auto-proceed` item resolves
|
|
563
|
+
* with the existing auto-applied marker so the record never reads as a human
|
|
564
|
+
* choice, and a `park` item stays open. Resolving the group atomically — all or
|
|
565
|
+
* nothing — would discard the answers the operator did give, which is the
|
|
566
|
+
* second silent fake this feature has to avoid.
|
|
567
|
+
*/
|
|
568
|
+
export function resolveQuestionnaireTimeouts(
|
|
569
|
+
items: readonly { id: string; request: AskRequest; answered: boolean }[],
|
|
570
|
+
): QuestionnaireItemTimeout[] {
|
|
571
|
+
const outcomes: QuestionnaireItemTimeout[] = [];
|
|
572
|
+
for (const item of items) {
|
|
573
|
+
if (item.answered) continue;
|
|
574
|
+
if (item.request.onTimeout === "auto-proceed" && item.request.recommended !== undefined) {
|
|
575
|
+
outcomes.push({ kind: "auto-applied", id: item.id, resolution: autoApplyResolution(item.request.recommended) });
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
// `park` — and the impossible-by-parse `auto-proceed` with no
|
|
579
|
+
// recommendation, which parks rather than inventing one.
|
|
580
|
+
outcomes.push({ kind: "parked", id: item.id });
|
|
581
|
+
}
|
|
582
|
+
return outcomes;
|
|
583
|
+
}
|
|
584
|
+
|
|
414
585
|
/**
|
|
415
586
|
* The interactive surface a bounded ask posts through when one is available
|
|
416
587
|
* (#722): the same Bot API `reply_markup` `telegram_ask` posts, whose taps are
|
|
@@ -776,3 +947,119 @@ export async function performAsk(request: AskRequest, deps: AskDeps): Promise<As
|
|
|
776
947
|
|
|
777
948
|
return { outcome, decisionId: row.id, text: `${delivered}\n${text}` };
|
|
778
949
|
}
|
|
950
|
+
|
|
951
|
+
/** What one {@link QUESTIONNAIRE_TOOL} call produced, per item and as text. */
|
|
952
|
+
export interface QuestionnaireResult {
|
|
953
|
+
/** The minted group id — the handle for `omp-conductor decision` on any item. */
|
|
954
|
+
groupId: string;
|
|
955
|
+
items: { id: string; question: string; state: DecisionState; resolution?: string }[];
|
|
956
|
+
text: string;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Ask one bounded spec-out questionnaire (#947).
|
|
961
|
+
*
|
|
962
|
+
* The shape mirrors {@link performAsk} deliberately — rows before delivery, one
|
|
963
|
+
* bounded wait, per-item timeout policy applied at the ceiling — with two
|
|
964
|
+
* differences that are the whole feature:
|
|
965
|
+
*
|
|
966
|
+
* 1. **One delivery.** The items go out as a single numbered message, so a
|
|
967
|
+
* questionnaire costs one interrupt rather than N.
|
|
968
|
+
* 2. **N independent resolutions.** The wait ends when every item is closed or
|
|
969
|
+
* the ceiling elapses, and the ceiling then applies each item's own policy.
|
|
970
|
+
* An item the operator answered keeps that answer.
|
|
971
|
+
*
|
|
972
|
+
* It uses the durable text path rather than posting N interactive keyboards,
|
|
973
|
+
* because N posts is exactly the thing it exists not to do. That means a prose
|
|
974
|
+
* reply is not itself a row resolution — the same contract a degraded single ask
|
|
975
|
+
* already has — so the result text names the ids and the command that resolves
|
|
976
|
+
* them.
|
|
977
|
+
*/
|
|
978
|
+
export async function performQuestionnaire(
|
|
979
|
+
request: QuestionnaireRequest,
|
|
980
|
+
deps: AskDeps,
|
|
981
|
+
): Promise<QuestionnaireResult> {
|
|
982
|
+
const now = deps.now ?? Date.now;
|
|
983
|
+
const wait = deps.wait ?? sleep;
|
|
984
|
+
|
|
985
|
+
// Every row durable, in one transaction, BEFORE the delivery: an operator must
|
|
986
|
+
// never be shown a question that no row is waiting on.
|
|
987
|
+
const rows = deps.store.createDecisionGroup(
|
|
988
|
+
request.items.map((item) => ({
|
|
989
|
+
project: deps.project,
|
|
990
|
+
question: item.question,
|
|
991
|
+
...(item.blocks === undefined ? {} : { blocks: item.blocks }),
|
|
992
|
+
at: now(),
|
|
993
|
+
})),
|
|
994
|
+
request.specIssue,
|
|
995
|
+
);
|
|
996
|
+
const groupId = rows[0]!.groupId!;
|
|
997
|
+
|
|
998
|
+
const category = request.items[0]!.category ?? "decision-needed";
|
|
999
|
+
const delivery = await deps.deliver(questionnaireMessageFor(request), category);
|
|
1000
|
+
|
|
1001
|
+
const ceilingSeconds = resolveAskCeilingSeconds(
|
|
1002
|
+
// One ceiling for the group, taken from the shortest item that named one:
|
|
1003
|
+
// the whole point is that a questionnaire cannot wait longer than an ask.
|
|
1004
|
+
request.items.reduce<number | undefined>(
|
|
1005
|
+
(shortest, item) =>
|
|
1006
|
+
item.timeoutSeconds === undefined ? shortest : Math.min(shortest ?? item.timeoutSeconds, item.timeoutSeconds),
|
|
1007
|
+
undefined,
|
|
1008
|
+
),
|
|
1009
|
+
deps.configuredCeilingSeconds,
|
|
1010
|
+
deps.turnBudgetSeconds ?? DEFAULT_ASK_TIMEOUT_SECONDS,
|
|
1011
|
+
);
|
|
1012
|
+
const deadline = now() + ceilingSeconds * 1_000;
|
|
1013
|
+
for (;;) {
|
|
1014
|
+
const live = deps.store.decisionGroup(groupId);
|
|
1015
|
+
if (live.every((row) => row.state !== "open")) break;
|
|
1016
|
+
if (now() >= deadline) break;
|
|
1017
|
+
await wait(ASK_POLL_MS);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// The ceiling, per item. `resolveDecision` refuses a second resolution, so an
|
|
1021
|
+
// answer that landed in the same instant as the deadline is never overwritten.
|
|
1022
|
+
const atCeiling = deps.store.decisionGroup(groupId);
|
|
1023
|
+
for (const outcome of resolveQuestionnaireTimeouts(
|
|
1024
|
+
atCeiling.map((row, index) => ({
|
|
1025
|
+
id: row.id,
|
|
1026
|
+
request: request.items[index]!,
|
|
1027
|
+
answered: row.state !== "open",
|
|
1028
|
+
})),
|
|
1029
|
+
)) {
|
|
1030
|
+
if (outcome.kind === "auto-applied") {
|
|
1031
|
+
deps.store.resolveDecision(outcome.id, "answered", outcome.resolution, now());
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const final = deps.store.decisionGroup(groupId);
|
|
1036
|
+
const items = final.map((row) => ({
|
|
1037
|
+
id: row.id,
|
|
1038
|
+
question: row.question,
|
|
1039
|
+
state: row.state,
|
|
1040
|
+
...(row.resolution === undefined ? {} : { resolution: row.resolution }),
|
|
1041
|
+
}));
|
|
1042
|
+
const open = items.filter((item) => item.state === "open");
|
|
1043
|
+
const deliveryLine =
|
|
1044
|
+
delivery.kind === "sent"
|
|
1045
|
+
? `The questionnaire went to the operator as one ${delivery.category} message.`
|
|
1046
|
+
: `The questionnaire is durably held (notice ${delivery.noticeId ?? "?"}, ${delivery.category}); the ` +
|
|
1047
|
+
"daemon releases it with the next digest or working-hours catch-up.";
|
|
1048
|
+
const perItem = items
|
|
1049
|
+
.map(
|
|
1050
|
+
(item, index) =>
|
|
1051
|
+
`${index + 1}. ${item.state}${item.resolution === undefined ? "" : ` — ${item.resolution}`} (${item.id})`,
|
|
1052
|
+
)
|
|
1053
|
+
.join("\n");
|
|
1054
|
+
const closing =
|
|
1055
|
+
open.length === 0
|
|
1056
|
+
? "Every item is resolved. Apply the answers to the spec now."
|
|
1057
|
+
: `${open.length} item(s) stayed open as declared: park the work each one blocks, and note them in your ` +
|
|
1058
|
+
"report. A prose reply does NOT resolve these rows — map it yourself with " +
|
|
1059
|
+
"`omp-conductor decision resolve <id> --answer \"…\"`.";
|
|
1060
|
+
return {
|
|
1061
|
+
groupId,
|
|
1062
|
+
items,
|
|
1063
|
+
text: `${deliveryLine} Ceiling ${ceilingSeconds}s; #${request.specIssue}.\n${perItem}\n${closing}`,
|
|
1064
|
+
};
|
|
1065
|
+
}
|