omp-conductor 0.19.6 → 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 +27 -2
- 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 -7832
- 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/doctor.ts +17 -12
- package/src/escalate.ts +39 -21
- package/src/failure-class.ts +75 -1
- package/src/fleet.ts +1218 -304
- 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 +646 -26
- package/src/to-spec.ts +194 -21
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +435 -15
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +384 -12
- package/src/wake.ts +19 -2
- package/src/worker.ts +456 -1
package/src/ask.ts
CHANGED
|
@@ -1,51 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The durable, parking ask surface for an orchestrator tick (#438).
|
|
3
3
|
*
|
|
4
4
|
* On 2026-08-16 the orchestrator called `telegram_ask` for a genuine tier-2
|
|
5
5
|
* contract decision (PR #432 / issue #368) and the operator was asleep. The
|
|
6
6
|
* tool does not time out: it blocked the turn for just over six hours, and a
|
|
7
7
|
* tick prompt timestamped 01:14Z did not execute until after the answer at
|
|
8
8
|
* 07:04Z. Every duty behind the ask — draining, merging, grooming — stopped
|
|
9
|
-
* with it.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* same delivery path the `message` command uses), waits at most a configured
|
|
13
|
-
* ceiling — minutes by default, never hours — and then resolves the declared
|
|
14
|
-
* non-answer outcome itself:
|
|
9
|
+
* with it. The first fix bounded the wait to minutes. This surface removes the
|
|
10
|
+
* wait entirely, because the session split made waiting pointless as well as
|
|
11
|
+
* dangerous:
|
|
15
12
|
*
|
|
16
|
-
* -
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* -
|
|
20
|
-
*
|
|
21
|
-
*
|
|
13
|
+
* - the tick brain is **headless** — it never receives operator turns, so an
|
|
14
|
+
* answer could not land in the asking session even if the operator were
|
|
15
|
+
* awake, and a duty cycle must never block on a human;
|
|
16
|
+
* - operator conversation lives in a separate 24/7 **console** session, which
|
|
17
|
+
* answers by resolving the decision row (`omp-conductor decision resolve
|
|
18
|
+
* <id> --answer "…"`) — the same store write the dashboard and the CLI use,
|
|
19
|
+
* and 409-safe by `resolveDecision`'s open-only guard.
|
|
22
20
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
21
|
+
* So the ask does exactly three things: it records the question durably (a
|
|
22
|
+
* decision row, verbatim), delivers it once through the sanctioned path, and
|
|
23
|
+
* returns immediately with the row id, the resolve command, and the
|
|
24
|
+
* instruction to park the blocked work in the same turn. It never resolves a
|
|
25
|
+
* row itself: there is no timeout to auto-apply, so nothing here can ever be
|
|
26
|
+
* read back as a human choice that never happened.
|
|
27
|
+
*
|
|
28
|
+
* A parked row is bounded without any new machinery: `expireDueDecisions`
|
|
29
|
+
* (daemon.ts) sweeps unanswered decisions after seven days, exactly as it does
|
|
30
|
+
* for every other open question.
|
|
28
31
|
*/
|
|
29
32
|
|
|
30
|
-
import type {
|
|
33
|
+
import type { InterruptCategory, Store } from "./types.ts";
|
|
31
34
|
import { INTERRUPT_CATEGORIES } from "./types.ts";
|
|
32
35
|
|
|
33
|
-
/** The
|
|
36
|
+
/** The durable ask tool this package registers on an orchestrator session. */
|
|
34
37
|
export const ASK_TOOL = "conductor_ask";
|
|
35
38
|
|
|
36
|
-
/** The two legal non-answer outcomes, declared by the ask, never inferred. */
|
|
37
|
-
export const ASK_TIMEOUT_OUTCOMES = ["auto-proceed", "park"] as const;
|
|
38
|
-
export type AskTimeoutOutcome = (typeof ASK_TIMEOUT_OUTCOMES)[number];
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* The ceiling bounds. Minutes by default, never hours; an ask that is issued
|
|
42
|
-
* without a `timeoutSeconds` gets {@link DEFAULT_ASK_TIMEOUT_SECONDS}, and one
|
|
43
|
-
* that names a ceiling outside the bounds is refused rather than obeyed.
|
|
44
|
-
*/
|
|
45
|
-
export const MIN_ASK_TIMEOUT_SECONDS = 60;
|
|
46
|
-
export const MAX_ASK_TIMEOUT_SECONDS = 3_600;
|
|
47
|
-
export const DEFAULT_ASK_TIMEOUT_SECONDS = 300;
|
|
48
|
-
|
|
49
39
|
/**
|
|
50
40
|
* The shape ceilings for a decision row's question (#593). A question is the
|
|
51
41
|
* bare ask an operator reads on a phone — one sentence, the recommendation, the
|
|
@@ -66,36 +56,6 @@ export type QuestionShape =
|
|
|
66
56
|
| { ok: true }
|
|
67
57
|
| { ok: false; problem: string };
|
|
68
58
|
|
|
69
|
-
/**
|
|
70
|
-
* The suffix a decision row carries when nobody human chose the answer. Fixed
|
|
71
|
-
* wording on purpose — the same reason `types.ts` gives for closed vocabularies
|
|
72
|
-
* everywhere else: a marker a tick or an operator has to match by reading it
|
|
73
|
-
* must be one no two spellings disagree on.
|
|
74
|
-
*/
|
|
75
|
-
export const AUTO_APPLIED_SUFFIX = "(auto-applied on ask timeout)";
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* The fixed sentence a decision row carries when the question could not be
|
|
79
|
-
* posted through a selectable surface and went out as plain text instead
|
|
80
|
-
* (#722). The row is the record a later reader judges "did the operator answer
|
|
81
|
-
* this?" from, so it must say plainly that a prose reply is not a selection:
|
|
82
|
-
* nothing in this pipeline ever maps free text back to an option, and the
|
|
83
|
-
* marker is what stops a reader from treating a chat reply as the row's
|
|
84
|
-
* answer. It rides the row's `blocks` free-text slot — the one place a row
|
|
85
|
-
* carry durable per-row prose that renders in every digest — without
|
|
86
|
-
* rewriting the archived question (which must stay verbatim, #741).
|
|
87
|
-
*/
|
|
88
|
-
export const DEGRADED_DELIVERY_MARKER =
|
|
89
|
-
"question delivered as plain text (no selectable surface); a reply does not resolve it — resolve or withdraw the row by hand";
|
|
90
|
-
|
|
91
|
-
/** The `blocks` text for a degraded ask: the block list plus the marker. */
|
|
92
|
-
export function degradedAskBlocks(blocks: string | undefined): string | undefined {
|
|
93
|
-
return blocks === undefined ? DEGRADED_DELIVERY_MARKER : `${blocks}; ${DEGRADED_DELIVERY_MARKER}`;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** How often the bounded wait re-reads the decision row while waiting. */
|
|
97
|
-
export const ASK_POLL_MS = 1_000;
|
|
98
|
-
|
|
99
59
|
/** One option the operator may pick, as rendered in the delivered question. */
|
|
100
60
|
export interface AskOption {
|
|
101
61
|
label: string;
|
|
@@ -106,16 +66,12 @@ export interface AskOption {
|
|
|
106
66
|
export interface AskRequest {
|
|
107
67
|
/** The question verbatim, as it is archived on the decision row. */
|
|
108
68
|
question: string;
|
|
109
|
-
/** What the ask does when nobody answers within the ceiling. */
|
|
110
|
-
onTimeout: AskTimeoutOutcome;
|
|
111
|
-
/** Optional ceiling in whole seconds; defaults to the configured ceiling. */
|
|
112
|
-
timeoutSeconds?: number;
|
|
113
69
|
/** What is waiting on the answer, for the decision row and the digest. */
|
|
114
70
|
blocks?: string;
|
|
115
71
|
/**
|
|
116
|
-
* The option
|
|
117
|
-
*
|
|
118
|
-
*
|
|
72
|
+
* The option you recommend, rendered in the delivered question. When
|
|
73
|
+
* {@link AskRequest.options} are supplied it must name one of their labels —
|
|
74
|
+
* the label as delivered, never an index.
|
|
119
75
|
*/
|
|
120
76
|
recommended?: string;
|
|
121
77
|
/** The choices shown to the operator, with {@link AskRequest.recommended} named. */
|
|
@@ -132,6 +88,16 @@ function isInterruptCategory(value: unknown): value is InterruptCategory {
|
|
|
132
88
|
return typeof value === "string" && (INTERRUPT_CATEGORIES as readonly string[]).includes(value);
|
|
133
89
|
}
|
|
134
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The command that answers one row. One spelling for every result text and
|
|
93
|
+
* prompt line, for the reason `types.ts` gives for closed vocabularies
|
|
94
|
+
* everywhere else: a command an operator or a console session copies verbatim
|
|
95
|
+
* must not exist in two shapes, one of which the CLI refuses.
|
|
96
|
+
*/
|
|
97
|
+
export function decisionResolveCommand(id: string): string {
|
|
98
|
+
return `omp-conductor decision resolve ${id} --answer "<their answer>"`;
|
|
99
|
+
}
|
|
100
|
+
|
|
135
101
|
/**
|
|
136
102
|
* Refuse a question that outgrew the phone-sized decision row. Counts the
|
|
137
103
|
* trimmed question — the exact text archived on the row — against
|
|
@@ -165,9 +131,7 @@ export function validateQuestionShape(question: string): QuestionShape {
|
|
|
165
131
|
/**
|
|
166
132
|
* Validate one raw tool call. Strict like the verb arguments: an unknown shape
|
|
167
133
|
* is refused with the reason, never coerced into a default that could hide what
|
|
168
|
-
* the asker actually sent.
|
|
169
|
-
* — the issue's whole point is that the ceiling holds even when the model does
|
|
170
|
-
* not name one.
|
|
134
|
+
* the asker actually sent.
|
|
171
135
|
*/
|
|
172
136
|
export function parseAskRequest(raw: unknown): AskParse {
|
|
173
137
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
@@ -190,46 +154,11 @@ export function parseAskRequest(raw: unknown): AskParse {
|
|
|
190
154
|
};
|
|
191
155
|
}
|
|
192
156
|
|
|
193
|
-
const onTimeoutRaw = input["on-timeout"];
|
|
194
|
-
if (
|
|
195
|
-
typeof onTimeoutRaw !== "string" ||
|
|
196
|
-
!(ASK_TIMEOUT_OUTCOMES as readonly string[]).includes(onTimeoutRaw)
|
|
197
|
-
) {
|
|
198
|
-
return {
|
|
199
|
-
ok: false,
|
|
200
|
-
problem: `conductor_ask needs "on-timeout": one of ${ASK_TIMEOUT_OUTCOMES.join(" or ")} — what happens when nobody answers within the ceiling`,
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
const onTimeout = onTimeoutRaw as AskTimeoutOutcome;
|
|
204
|
-
|
|
205
157
|
const recommendedRaw = input["recommended"];
|
|
206
158
|
const recommended =
|
|
207
159
|
typeof recommendedRaw === "string" && recommendedRaw.trim().length > 0
|
|
208
160
|
? recommendedRaw.trim()
|
|
209
161
|
: undefined;
|
|
210
|
-
if (onTimeout === "auto-proceed" && recommended === undefined) {
|
|
211
|
-
return {
|
|
212
|
-
ok: false,
|
|
213
|
-
problem:
|
|
214
|
-
'conductor_ask with "on-timeout": "auto-proceed" needs "recommended": the option to apply when nobody answers, ' +
|
|
215
|
-
"because the decision row must record what was auto-applied",
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const timeoutRaw = input["timeoutSeconds"];
|
|
220
|
-
let timeoutSeconds: number | undefined;
|
|
221
|
-
if (timeoutRaw !== undefined) {
|
|
222
|
-
if (typeof timeoutRaw !== "number" || !Number.isInteger(timeoutRaw)) {
|
|
223
|
-
return { ok: false, problem: "conductor_ask timeoutSeconds must be a whole number of seconds when present" };
|
|
224
|
-
}
|
|
225
|
-
if (timeoutRaw < MIN_ASK_TIMEOUT_SECONDS || timeoutRaw > MAX_ASK_TIMEOUT_SECONDS) {
|
|
226
|
-
return {
|
|
227
|
-
ok: false,
|
|
228
|
-
problem: `conductor_ask timeoutSeconds must be between ${MIN_ASK_TIMEOUT_SECONDS} and ${MAX_ASK_TIMEOUT_SECONDS}`,
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
timeoutSeconds = timeoutRaw;
|
|
232
|
-
}
|
|
233
162
|
|
|
234
163
|
const blocksRaw = input["blocks"];
|
|
235
164
|
const blocks = typeof blocksRaw === "string" && blocksRaw.trim().length > 0 ? blocksRaw.trim() : undefined;
|
|
@@ -265,9 +194,8 @@ export function parseAskRequest(raw: unknown): AskParse {
|
|
|
265
194
|
// label exactly as the operator reads it — never an index (that is
|
|
266
195
|
// telegram_ask's convention, not this tool's) and never free text when a
|
|
267
196
|
// menu exists. Refused at parse time so the incoherent ask leaves no trace:
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
// was never a choice (#740).
|
|
197
|
+
// the recommendation is delivered as one of the choices, and a value naming
|
|
198
|
+
// none of them is a question nobody can answer by picking (#740).
|
|
271
199
|
if (options !== undefined && recommended !== undefined) {
|
|
272
200
|
const labels = options.map((option) => option.label);
|
|
273
201
|
if (!labels.includes(recommended)) {
|
|
@@ -292,8 +220,6 @@ export function parseAskRequest(raw: unknown): AskParse {
|
|
|
292
220
|
ok: true,
|
|
293
221
|
request: {
|
|
294
222
|
question: question.trim(),
|
|
295
|
-
onTimeout,
|
|
296
|
-
...(timeoutSeconds === undefined ? {} : { timeoutSeconds }),
|
|
297
223
|
...(blocks === undefined ? {} : { blocks }),
|
|
298
224
|
...(recommended === undefined ? {} : { recommended }),
|
|
299
225
|
...(options === undefined ? {} : { options }),
|
|
@@ -306,6 +232,10 @@ export function parseAskRequest(raw: unknown): AskParse {
|
|
|
306
232
|
* The JSON Schema the harness advertises for {@link ASK_TOOL}, shaped like the
|
|
307
233
|
* verb parameter schemas: plain JSON Schema, `additionalProperties: false`,
|
|
308
234
|
* required lists spelled out.
|
|
235
|
+
*
|
|
236
|
+
* No timeout property of any kind, and deliberately no conditional
|
|
237
|
+
* requirement: the ask has exactly one behaviour — file, deliver, park — so
|
|
238
|
+
* the model has nothing to declare about what happens when nobody answers.
|
|
309
239
|
*/
|
|
310
240
|
export function askParameterSchema(): Record<string, unknown> {
|
|
311
241
|
return {
|
|
@@ -316,20 +246,10 @@ export function askParameterSchema(): Record<string, unknown> {
|
|
|
316
246
|
description:
|
|
317
247
|
"The question, written for a phone: one plain sentence, your recommendation, and the options with their consequences.",
|
|
318
248
|
},
|
|
319
|
-
"on-timeout": {
|
|
320
|
-
type: "string",
|
|
321
|
-
enum: [...ASK_TIMEOUT_OUTCOMES],
|
|
322
|
-
description:
|
|
323
|
-
"What happens when nobody answers within the ceiling: auto-proceed applies the recommended option and resolves the decision row naming the auto-application; park leaves the row open and pending, re-surfaced every tick.",
|
|
324
|
-
},
|
|
325
|
-
timeoutSeconds: {
|
|
326
|
-
type: "number",
|
|
327
|
-
description: `Ceiling before the ask times out. Optional — an ask issued without one still gets the default ceiling (${DEFAULT_ASK_TIMEOUT_SECONDS}s, capped at the turn budget). Range ${MIN_ASK_TIMEOUT_SECONDS}–${MAX_ASK_TIMEOUT_SECONDS}.`,
|
|
328
|
-
},
|
|
329
249
|
recommended: {
|
|
330
250
|
type: "string",
|
|
331
251
|
description:
|
|
332
|
-
"The option
|
|
252
|
+
"The option you recommend, rendered in the delivered question. When options are supplied this must be one of their labels — the label as delivered to the operator, not an index.",
|
|
333
253
|
},
|
|
334
254
|
blocks: {
|
|
335
255
|
type: "string",
|
|
@@ -354,22 +274,8 @@ export function askParameterSchema(): Record<string, unknown> {
|
|
|
354
274
|
description: "Escalation category for the delivered question. Optional; defaults to decision-needed.",
|
|
355
275
|
},
|
|
356
276
|
},
|
|
357
|
-
required: ["question"
|
|
277
|
+
required: ["question"],
|
|
358
278
|
additionalProperties: false,
|
|
359
|
-
// The one conditional requirement the tool has (#722): an ask that
|
|
360
|
-
// declares auto-proceed must name the option it would auto-apply, because
|
|
361
|
-
// the decision row must record what was applied and who applied it. Stated
|
|
362
|
-
// in the schema (not only at call time) so the model sees it next to the
|
|
363
|
-
// "on-timeout" property it conditions on.
|
|
364
|
-
allOf: [
|
|
365
|
-
{
|
|
366
|
-
if: {
|
|
367
|
-
properties: { "on-timeout": { const: "auto-proceed" } },
|
|
368
|
-
required: ["on-timeout"],
|
|
369
|
-
},
|
|
370
|
-
then: { required: ["recommended"] },
|
|
371
|
-
},
|
|
372
|
-
],
|
|
373
279
|
};
|
|
374
280
|
}
|
|
375
281
|
|
|
@@ -401,26 +307,6 @@ export function questionnaireParameterSchema(): Record<string, unknown> {
|
|
|
401
307
|
};
|
|
402
308
|
}
|
|
403
309
|
|
|
404
|
-
/**
|
|
405
|
-
* The ceiling for one ask, in whole seconds. The model may name one, but the
|
|
406
|
-
* enforcement never depends on it: an ask without a `timeoutSeconds` gets the
|
|
407
|
-
* configured ceiling, and both are capped at the turn budget so the ask can
|
|
408
|
-
* never outlive the turn it runs in.
|
|
409
|
-
*/
|
|
410
|
-
export function resolveAskCeilingSeconds(
|
|
411
|
-
requestedSeconds: number | undefined,
|
|
412
|
-
configuredSeconds: number | undefined,
|
|
413
|
-
turnBudgetSeconds: number,
|
|
414
|
-
): number {
|
|
415
|
-
const base = requestedSeconds ?? configuredSeconds ?? DEFAULT_ASK_TIMEOUT_SECONDS;
|
|
416
|
-
return Math.min(base, Math.max(MIN_ASK_TIMEOUT_SECONDS, turnBudgetSeconds));
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/** The row resolution for an auto-applied outcome — exactly the acceptance text. */
|
|
420
|
-
export function autoApplyResolution(recommended: string): string {
|
|
421
|
-
return `${recommended} ${AUTO_APPLIED_SUFFIX}`;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
310
|
/** The question exactly as it is delivered, durable prefix and all. */
|
|
425
311
|
export function askMessageFor(request: AskRequest): string {
|
|
426
312
|
const lines = [`QUESTION: ${request.question}`];
|
|
@@ -443,8 +329,7 @@ export function askMessageFor(request: AskRequest): string {
|
|
|
443
329
|
export const QUESTIONNAIRE_TOOL = "conductor_questionnaire";
|
|
444
330
|
|
|
445
331
|
/** 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. */
|
|
332
|
+
* not a questionnaire, it is an interview the operator will abandon halfway. */
|
|
448
333
|
export const MAX_QUESTIONNAIRE_ITEMS = 6;
|
|
449
334
|
|
|
450
335
|
/**
|
|
@@ -453,16 +338,15 @@ export const MAX_QUESTIONNAIRE_ITEMS = 6;
|
|
|
453
338
|
*
|
|
454
339
|
* It is deliberately N {@link AskRequest}s plus a spec binding rather than a
|
|
455
340
|
* new question shape. Every per-item semantic a spec-out needs — options, a
|
|
456
|
-
* recommendation,
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
* copy is the one that drifts.
|
|
341
|
+
* recommendation, the shape ceiling, the durable row — already exists and is
|
|
342
|
+
* already tested here; what did not exist is asking six of them once.
|
|
343
|
+
* Re-deriving those semantics for a "questionnaire item" would be the same
|
|
344
|
+
* contract in two places, and the second copy is the one that drifts.
|
|
461
345
|
*/
|
|
462
346
|
export interface QuestionnaireRequest {
|
|
463
347
|
/** The issue these questions spec out; their answers are its provenance. */
|
|
464
348
|
specIssue: number;
|
|
465
|
-
/** The items, in delivery order. Each is an ordinary
|
|
349
|
+
/** The items, in delivery order. Each is an ordinary durable ask. */
|
|
466
350
|
items: AskRequest[];
|
|
467
351
|
}
|
|
468
352
|
|
|
@@ -537,266 +421,11 @@ export function questionnaireMessageFor(request: QuestionnaireRequest): string {
|
|
|
537
421
|
);
|
|
538
422
|
}
|
|
539
423
|
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
424
|
lines.push(parts.join("\n"));
|
|
549
425
|
}
|
|
550
426
|
return lines.join("\n");
|
|
551
427
|
}
|
|
552
428
|
|
|
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
|
-
|
|
585
|
-
/**
|
|
586
|
-
* The interactive surface a bounded ask posts through when one is available
|
|
587
|
-
* (#722): the same Bot API `reply_markup` `telegram_ask` posts, whose taps are
|
|
588
|
-
* routed by the running omp-telegram bridge. The bridge acknowledges the tap
|
|
589
|
-
* and writes an answer envelope into the shared prompts directory; this handle
|
|
590
|
-
* exists only to post the question, translate inbound envelopes into a decision
|
|
591
|
-
* row resolution, and clean up. It is optional by design — a caller without
|
|
592
|
-
* one degrades exactly as before, to the durable plain-text path with the
|
|
593
|
-
* degraded marker on the row.
|
|
594
|
-
*/
|
|
595
|
-
export interface AskInteractiveDelivery {
|
|
596
|
-
/**
|
|
597
|
-
* Why no interactive surface is available for this ask, or undefined when it
|
|
598
|
-
* can post. Pure: no files, no network, no side effects — the caller decides
|
|
599
|
-
* the row note before it creates the row.
|
|
600
|
-
*/
|
|
601
|
-
unavailableReason(request: AskRequest): string | undefined;
|
|
602
|
-
/**
|
|
603
|
-
* Post the selectable question to the operator. Never throws: every failure
|
|
604
|
-
* comes back as `{ ok: false, reason }` so the caller can fall back to the
|
|
605
|
-
* durable text path. `decisionId` is the row id and the protocol nonce, so
|
|
606
|
-
* the pending request and its answer are addressable by the row.
|
|
607
|
-
*/
|
|
608
|
-
post(request: AskRequest, decisionId: string): Promise<{ ok: true } | { ok: false; reason: string }>;
|
|
609
|
-
/**
|
|
610
|
-
* Translate an inbound answer (a button tap, or a reply to a free-text ask)
|
|
611
|
-
* into the decision row. Called on every poll while the ask waits; no-op
|
|
612
|
-
* until an answer exists, idempotent afterwards (the row's double-resolve
|
|
613
|
-
* guard makes the second write a no-op anyway).
|
|
614
|
-
*/
|
|
615
|
-
collect(decisionId: string): void;
|
|
616
|
-
/**
|
|
617
|
-
* Best-effort cleanup once the wait ends, any outcome: remove the prompt
|
|
618
|
-
* request and its answer from the shared dir, and strip the keyboard off the
|
|
619
|
-
* posted message so a stale tap reads as expired rather than live.
|
|
620
|
-
*/
|
|
621
|
-
close(decisionId: string): void;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
/** One inline-keyboard button, as the Bot API renders it. */
|
|
625
|
-
export interface AskKeyboardButton {
|
|
626
|
-
text: string;
|
|
627
|
-
callback_data: string;
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/** The `reply_markup` of one interactive ask, one option per row plus Cancel. */
|
|
631
|
-
export type AskKeyboardMarkup = { inline_keyboard: AskKeyboardButton[][] };
|
|
632
|
-
|
|
633
|
-
/** The rendered interactive ask: the message text and its keyboard. */
|
|
634
|
-
export interface AskInteractiveRender {
|
|
635
|
-
text: string;
|
|
636
|
-
markup: AskKeyboardMarkup;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
/** Shorten a button label to Telegram's inline-button limit, like omp-telegram. */
|
|
640
|
-
function clipButton(text: string, max: number): string {
|
|
641
|
-
const clean = text.replace(/[\r\n]+/g, " ").trim();
|
|
642
|
-
return clean.length <= max ? clean : `${clean.slice(0, max - 1)}…`;
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
/**
|
|
646
|
-
* The interactive render of one bounded ask: the question as a normal ask
|
|
647
|
-
* message (numbered options with their consequences when descriptions exist,
|
|
648
|
-
* the same shape `telegram_ask` posts), and a keyboard whose callbacks speak
|
|
649
|
-
* the shared `qa:<nonce>:<action>[:<index>]` protocol (`prompts.ts` in
|
|
650
|
-
* omp-telegram), so the bridge answers the ask with the *label* of the tapped
|
|
651
|
-
* option — never an index and never the model's numbering. Cancel closes the
|
|
652
|
-
* ask as withdrawn, exactly like a `decision withdraw`.
|
|
653
|
-
*/
|
|
654
|
-
export function renderInteractiveAsk(request: AskRequest, nonce: string): AskInteractiveRender {
|
|
655
|
-
const options = request.options ?? [];
|
|
656
|
-
const lines = [request.question.trim()];
|
|
657
|
-
if (options.length === 0) {
|
|
658
|
-
lines.push("", "Reply with your answer as the next message, or /cancel.");
|
|
659
|
-
} else if (options.some((option) => option.description !== undefined)) {
|
|
660
|
-
lines.push("");
|
|
661
|
-
options.forEach((option, index) => {
|
|
662
|
-
const recommended = option.label === request.recommended ? " (recommended)" : "";
|
|
663
|
-
const description = option.description === undefined ? "" : ` — ${option.description}`;
|
|
664
|
-
lines.push(`${index + 1}. ${option.label}${recommended}${description}`);
|
|
665
|
-
});
|
|
666
|
-
}
|
|
667
|
-
const rows = options.map((option, index) => [
|
|
668
|
-
{ text: clipButton(option.label, 60), callback_data: `qa:${nonce}:s:${index}` },
|
|
669
|
-
]);
|
|
670
|
-
rows.push([{ text: "Cancel", callback_data: `qa:${nonce}:x` }]);
|
|
671
|
-
return { text: lines.join("\n"), markup: { inline_keyboard: rows } };
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
/** One answer within an answered envelope — the bridge's prompt answer shape. */
|
|
675
|
-
export interface AskAnswer {
|
|
676
|
-
id: string;
|
|
677
|
-
question: string;
|
|
678
|
-
/** The chosen option labels — the tap resolves the row with these, verbatim. */
|
|
679
|
-
selectedOptions: string[];
|
|
680
|
-
/** The reply to a free-text ask, when the question had no options. */
|
|
681
|
-
customInput?: string;
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
/** One answer envelope from the shared prompts dir (`<nonce>.answer.json`). */
|
|
685
|
-
export interface AskAnswerEnvelope {
|
|
686
|
-
outcome:
|
|
687
|
-
| { status: "answered"; answers: AskAnswer[] }
|
|
688
|
-
| { status: "cancelled" | "expired" | "aborted" };
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
/**
|
|
692
|
-
* Parse one prompt answer envelope. Malformed or unreadable input is
|
|
693
|
-
* undefined — "no answer yet" is never a crash.
|
|
694
|
-
*/
|
|
695
|
-
export function parseAskAnswerEnvelope(raw: unknown): AskAnswerEnvelope | undefined {
|
|
696
|
-
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
697
|
-
const outcome = (raw as Record<string, unknown>)["outcome"];
|
|
698
|
-
if (outcome === null || typeof outcome !== "object" || Array.isArray(outcome)) return undefined;
|
|
699
|
-
const record = outcome as Record<string, unknown>;
|
|
700
|
-
const status = record["status"];
|
|
701
|
-
if (status === "answered") {
|
|
702
|
-
const answers = record["answers"];
|
|
703
|
-
if (!Array.isArray(answers)) return undefined;
|
|
704
|
-
const parsed: AskAnswer[] = [];
|
|
705
|
-
for (const answer of answers) {
|
|
706
|
-
if (answer === null || typeof answer !== "object" || Array.isArray(answer)) return undefined;
|
|
707
|
-
const a = answer as Record<string, unknown>;
|
|
708
|
-
if (typeof a["id"] !== "string" || typeof a["question"] !== "string") return undefined;
|
|
709
|
-
const selected = a["selectedOptions"];
|
|
710
|
-
if (!Array.isArray(selected) || selected.some((label) => typeof label !== "string")) {
|
|
711
|
-
return undefined;
|
|
712
|
-
}
|
|
713
|
-
const customInput = a["customInput"];
|
|
714
|
-
if (customInput !== undefined && typeof customInput !== "string") return undefined;
|
|
715
|
-
parsed.push({
|
|
716
|
-
id: a["id"],
|
|
717
|
-
question: a["question"],
|
|
718
|
-
selectedOptions: selected as string[],
|
|
719
|
-
...(customInput === undefined ? {} : { customInput }),
|
|
720
|
-
});
|
|
721
|
-
}
|
|
722
|
-
return { outcome: { status: "answered", answers: parsed } };
|
|
723
|
-
}
|
|
724
|
-
if (status === "cancelled" || status === "expired" || status === "aborted") {
|
|
725
|
-
return { outcome: { status } as AskAnswerEnvelope["outcome"] };
|
|
726
|
-
}
|
|
727
|
-
return undefined;
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
/**
|
|
731
|
-
* The decision-row write one envelope calls for, or none. An answer becomes a
|
|
732
|
-
* row resolution carrying the option *label* (or the free-text reply to a
|
|
733
|
-
* free-text ask) — the identity the operator actually chose; cancel becomes a
|
|
734
|
-
* withdrawal, so a deliberate close is never an answer; expiry/abort mean
|
|
735
|
-
* "nobody answered yet" and leave the row open for the bounded wait to decide.
|
|
736
|
-
*/
|
|
737
|
-
export function askAnswerRowWrite(
|
|
738
|
-
envelope: AskAnswerEnvelope,
|
|
739
|
-
): { state: "answered" | "withdrawn"; resolution: string } | undefined {
|
|
740
|
-
if (envelope.outcome.status === "cancelled") {
|
|
741
|
-
return { state: "withdrawn", resolution: "Question cancelled by the operator" };
|
|
742
|
-
}
|
|
743
|
-
if (envelope.outcome.status !== "answered") return undefined;
|
|
744
|
-
const first = envelope.outcome.answers[0];
|
|
745
|
-
if (first === undefined) return undefined;
|
|
746
|
-
const resolution =
|
|
747
|
-
first.customInput !== undefined && first.customInput.trim() !== ""
|
|
748
|
-
? first.customInput.trim()
|
|
749
|
-
: first.selectedOptions.join(", ");
|
|
750
|
-
if (resolution.trim() === "") return undefined;
|
|
751
|
-
return { state: "answered", resolution };
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
/** One read of the decision row; `undefined` means the id is unknown. */
|
|
755
|
-
export type DecisionReader = (id: string) => DecisionRecord | undefined;
|
|
756
|
-
|
|
757
|
-
export interface BoundedAskDeps {
|
|
758
|
-
decisionId: string;
|
|
759
|
-
ceilingMs: number;
|
|
760
|
-
read: DecisionReader;
|
|
761
|
-
/** Advance one poll; injected so tests stay deterministic. */
|
|
762
|
-
wait: (ms: number) => Promise<void>;
|
|
763
|
-
now: () => number;
|
|
764
|
-
/**
|
|
765
|
-
* Called once per poll, before the row read: the seam through which an
|
|
766
|
-
* interactive delivery surface (a Telegram button tap, #722) translates an
|
|
767
|
-
* inbound answer into a decision-row write the read then sees. Optional and
|
|
768
|
-
* synchronous on purpose — the wait must never depend on anything slower
|
|
769
|
-
* than the row itself.
|
|
770
|
-
*/
|
|
771
|
-
settle?: () => void;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
export type BoundedAskOutcome =
|
|
775
|
-
| { kind: "answered"; answer: string }
|
|
776
|
-
| { kind: "withdrawn" }
|
|
777
|
-
| { kind: "timed-out" };
|
|
778
|
-
|
|
779
|
-
/**
|
|
780
|
-
* The wait itself: read the row, leave when someone closed it, time out at the
|
|
781
|
-
* deadline. Pure by construction — every clock and sleep is injected — so the
|
|
782
|
-
* whole "does the ask come back bounded" contract is a deterministic test, not
|
|
783
|
-
* a timer gamble.
|
|
784
|
-
*/
|
|
785
|
-
export async function runBoundedAsk(deps: BoundedAskDeps): Promise<BoundedAskOutcome> {
|
|
786
|
-
const deadline = deps.now() + deps.ceilingMs;
|
|
787
|
-
for (;;) {
|
|
788
|
-
deps.settle?.();
|
|
789
|
-
const row = deps.read(deps.decisionId);
|
|
790
|
-
if (row !== undefined && row.state !== "open") {
|
|
791
|
-
return row.state === "answered"
|
|
792
|
-
? { kind: "answered", answer: row.resolution ?? "" }
|
|
793
|
-
: { kind: "withdrawn" };
|
|
794
|
-
}
|
|
795
|
-
if (deps.now() >= deadline) return { kind: "timed-out" };
|
|
796
|
-
await deps.wait(ASK_POLL_MS);
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
|
|
800
429
|
/** What delivering the question to the operator returned, for the result text. */
|
|
801
430
|
export interface AskDeliveryResult {
|
|
802
431
|
kind: "sent" | "held";
|
|
@@ -806,7 +435,6 @@ export interface AskDeliveryResult {
|
|
|
806
435
|
|
|
807
436
|
/** What one {@link ASK_TOOL} call produced, and the exact text the model reads. */
|
|
808
437
|
export interface AskResult {
|
|
809
|
-
outcome: BoundedAskOutcome;
|
|
810
438
|
decisionId: string;
|
|
811
439
|
text: string;
|
|
812
440
|
}
|
|
@@ -814,173 +442,105 @@ export interface AskResult {
|
|
|
814
442
|
export interface AskDeps {
|
|
815
443
|
store: Store;
|
|
816
444
|
project: string;
|
|
817
|
-
/** The tick config's `askTimeoutSeconds`, already validated. */
|
|
818
|
-
configuredCeilingSeconds?: number;
|
|
819
|
-
/** The tick config's `budgetSeconds`, already validated. */
|
|
820
|
-
turnBudgetSeconds?: number;
|
|
821
445
|
/** Delivers the question through the sanctioned durable path. */
|
|
822
446
|
deliver(questionText: string, category: InterruptCategory): Promise<AskDeliveryResult>;
|
|
823
|
-
/**
|
|
824
|
-
* The selectable delivery surface (#722): the same Bot API buttons
|
|
825
|
-
* `telegram_ask` uses, whose taps resolve the decision row with the chosen
|
|
826
|
-
* option label. Absent or unavailable → the ask degrades to the durable
|
|
827
|
-
* text path, and the row records the degraded delivery.
|
|
828
|
-
*/
|
|
829
|
-
interactive?: AskInteractiveDelivery;
|
|
830
|
-
wait?: (ms: number) => Promise<void>;
|
|
831
447
|
now?: () => number;
|
|
832
448
|
}
|
|
833
449
|
|
|
834
|
-
/**
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
450
|
+
/**
|
|
451
|
+
* One sentence naming what became of the delivery attempt. A throw is its own
|
|
452
|
+
* case rather than an escape: the row is already filed, so the honest report is
|
|
453
|
+
* "recorded, not sent" — and swallowing the throw is what lets the caller learn
|
|
454
|
+
* the row id at all, which is exactly what it needs in order to park the work.
|
|
455
|
+
*/
|
|
456
|
+
function deliveryLine(delivery: AskDeliveryResult | undefined, failure: string | undefined): string {
|
|
457
|
+
if (delivery === undefined) {
|
|
458
|
+
return (
|
|
459
|
+
`Delivery FAILED (${failure ?? "unknown error"}): nothing was sent, and the question is held only as ` +
|
|
460
|
+
`the row itself. Say so in your report, and deliver it by hand with \`omp-conductor message\` if it ` +
|
|
461
|
+
`cannot wait for the next digest.`
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
return delivery.kind === "sent"
|
|
465
|
+
? `It went to the operator as a ${delivery.category} message.`
|
|
466
|
+
: `It is durably held (notice ${delivery.noticeId ?? "?"}, ${delivery.category}); the daemon releases it ` +
|
|
467
|
+
`with the next digest or working-hours catch-up.`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Deliver once, never throwing: the row outlives any transport failure. */
|
|
471
|
+
async function deliverOnce(
|
|
472
|
+
deps: AskDeps,
|
|
473
|
+
text: string,
|
|
474
|
+
category: InterruptCategory,
|
|
475
|
+
): Promise<{ delivery?: AskDeliveryResult; failure?: string }> {
|
|
476
|
+
try {
|
|
477
|
+
return { delivery: await deps.deliver(text, category) };
|
|
478
|
+
} catch (err) {
|
|
479
|
+
return { failure: err instanceof Error ? err.message : String(err) };
|
|
480
|
+
}
|
|
481
|
+
}
|
|
840
482
|
|
|
841
483
|
/**
|
|
842
|
-
* One
|
|
843
|
-
*
|
|
844
|
-
* attempt, so a crash at any point leaves a
|
|
845
|
-
* lived only in the model's context.
|
|
484
|
+
* One ask, end to end: durable row, one delivery, park.
|
|
485
|
+
*
|
|
486
|
+
* The row exists before the delivery attempt, so a crash at any point leaves a
|
|
487
|
+
* recorded question — never one that lived only in the model's context. Then
|
|
488
|
+
* the call returns. Nothing polls, nothing waits, and this function never
|
|
489
|
+
* resolves the row: the console session does that when the operator answers.
|
|
846
490
|
*/
|
|
847
491
|
export async function performAsk(request: AskRequest, deps: AskDeps): Promise<AskResult> {
|
|
848
492
|
const now = deps.now ?? Date.now;
|
|
849
|
-
const wait = deps.wait ?? sleep;
|
|
850
|
-
|
|
851
|
-
// The delivery mode is decided before the row exists: the degraded marker
|
|
852
|
-
// must ride the row's `blocks` from birth, and the row must exist before any
|
|
853
|
-
// delivery attempt (a crash at any point leaves a recorded question). The
|
|
854
|
-
// interactive preview is pure — no files, no network — so it can run here
|
|
855
|
-
// without violating that ordering.
|
|
856
|
-
const degradedReason =
|
|
857
|
-
deps.interactive === undefined
|
|
858
|
-
? "no interactive delivery surface is mounted for this session"
|
|
859
|
-
: deps.interactive.unavailableReason(request);
|
|
860
|
-
const degraded = degradedReason !== undefined;
|
|
861
493
|
|
|
862
494
|
const row = deps.store.createDecision({
|
|
863
495
|
project: deps.project,
|
|
864
496
|
question: request.question,
|
|
865
|
-
...(request.blocks === undefined
|
|
866
|
-
? {}
|
|
867
|
-
: { blocks: degraded ? degradedAskBlocks(request.blocks) : request.blocks }),
|
|
497
|
+
...(request.blocks === undefined ? {} : { blocks: request.blocks }),
|
|
868
498
|
at: now(),
|
|
869
499
|
});
|
|
870
500
|
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
// the row has no update surface by design.
|
|
876
|
-
let interactivePosted = false;
|
|
877
|
-
let postFailure: string | undefined;
|
|
878
|
-
if (!degraded) {
|
|
879
|
-
const posted = await deps.interactive!.post(request, row.id);
|
|
880
|
-
interactivePosted = posted.ok;
|
|
881
|
-
if (!posted.ok) postFailure = posted.reason;
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
let delivery: AskDeliveryResult | undefined;
|
|
885
|
-
if (!interactivePosted) {
|
|
886
|
-
delivery = await deps.deliver(askMessageFor(request), request.category ?? "decision-needed");
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
const ceilingSeconds = resolveAskCeilingSeconds(
|
|
890
|
-
request.timeoutSeconds,
|
|
891
|
-
deps.configuredCeilingSeconds,
|
|
892
|
-
deps.turnBudgetSeconds ?? DEFAULT_ASK_TIMEOUT_SECONDS,
|
|
501
|
+
const { delivery, failure } = await deliverOnce(
|
|
502
|
+
deps,
|
|
503
|
+
askMessageFor(request),
|
|
504
|
+
request.category ?? "decision-needed",
|
|
893
505
|
);
|
|
894
|
-
const outcome = await runBoundedAsk({
|
|
895
|
-
decisionId: row.id,
|
|
896
|
-
ceilingMs: ceilingSeconds * 1_000,
|
|
897
|
-
read: (id) => deps.store.decision(id),
|
|
898
|
-
wait,
|
|
899
|
-
now,
|
|
900
|
-
settle: interactivePosted ? () => deps.interactive!.collect(row.id) : undefined,
|
|
901
|
-
});
|
|
902
|
-
if (interactivePosted) deps.interactive!.close(row.id);
|
|
903
506
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
text
|
|
907
|
-
`conductor_ask ${row.id}: the
|
|
908
|
-
`
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
`
|
|
912
|
-
`
|
|
913
|
-
`
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
text =
|
|
918
|
-
`conductor_ask ${row.id}: no answer within ${ceilingSeconds}s — auto-proceeding as declared. ` +
|
|
919
|
-
`The recommended option "${request.recommended}" is applied, and the decision row was resolved ` +
|
|
920
|
-
`with the answer "${resolution}" so the record shows nobody human chose it. Proceed to apply ` +
|
|
921
|
-
`that recommendation to the blocked work now.`;
|
|
922
|
-
} else {
|
|
923
|
-
text =
|
|
924
|
-
`conductor_ask ${row.id}: no answer within ${ceilingSeconds}s — parking as declared. ` +
|
|
925
|
-
`The decision row stays open and pending, re-surfaced in every tick prompt until answered or the ` +
|
|
926
|
-
`seven-day expiry. Park the blocked work now: take it out of the claimable queue and record its ` +
|
|
927
|
-
`state (e.g. unlabel it or move it to a parked state), then note it in your report.`;
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
const deliveryLine =
|
|
931
|
-
delivery === undefined
|
|
932
|
-
? ""
|
|
933
|
-
: delivery.kind === "sent"
|
|
934
|
-
? ` The question went to the operator as a ${delivery.category} message.`
|
|
935
|
-
: ` The question is durably held (notice ${delivery.noticeId ?? "?"}, ${delivery.category}); the ` +
|
|
936
|
-
"daemon releases it with the next digest or working-hours catch-up.";
|
|
937
|
-
const delivered = interactivePosted
|
|
938
|
-
? "The question went to the operator as selectable options (button taps); a tap resolves " +
|
|
939
|
-
"the decision row with the chosen option, and a free-text ask is answered by replying."
|
|
940
|
-
: degraded
|
|
941
|
-
? `The interactive surface is unavailable for this call (${degradedReason}), so the decision row ` +
|
|
942
|
-
`records the degraded delivery: the operator's prose reply is NOT an answer to this row and ` +
|
|
943
|
-
`does not resolve it — if they answer in prose, resolve or withdraw the row by hand ` +
|
|
944
|
-
`(omp-conductor decision resolve|withdraw ${row.id}).${deliveryLine}`
|
|
945
|
-
: `The interactive question could not be posted (${postFailure}) and the ask fell back to the ` +
|
|
946
|
-
`durable text path.${deliveryLine}`;
|
|
947
|
-
|
|
948
|
-
return { outcome, decisionId: row.id, text: `${delivered}\n${text}` };
|
|
507
|
+
return {
|
|
508
|
+
decisionId: row.id,
|
|
509
|
+
text:
|
|
510
|
+
`conductor_ask ${row.id}: the question is filed as open decision row ${row.id}. ` +
|
|
511
|
+
`${deliveryLine(delivery, failure)}\n` +
|
|
512
|
+
`Nothing waits for the answer here: this tick is headless, and the operator answers in the console ` +
|
|
513
|
+
`session, which resolves the row with \`${decisionResolveCommand(row.id)}\`. Until then the row stays ` +
|
|
514
|
+
`open and pending — re-surfaced in every tick prompt — until it is answered, withdrawn, or swept by ` +
|
|
515
|
+
`the seven-day decision expiry.\n` +
|
|
516
|
+
`Park the blocked work NOW, in this turn: take it out of the claimable queue and record its state ` +
|
|
517
|
+
`(e.g. unlabel it or move it to a parked state), then note the parked work and row ${row.id} in your ` +
|
|
518
|
+
`report. Do not wait, do not re-ask, and do not act as though the recommendation were approved.`,
|
|
519
|
+
};
|
|
949
520
|
}
|
|
950
521
|
|
|
951
|
-
/** What one {@link QUESTIONNAIRE_TOOL} call produced,
|
|
522
|
+
/** What one {@link QUESTIONNAIRE_TOOL} call produced, and its text. */
|
|
952
523
|
export interface QuestionnaireResult {
|
|
953
|
-
/** The minted group id — the handle for `omp-conductor decision` on
|
|
524
|
+
/** The minted group id — the handle for `omp-conductor decision` on the set. */
|
|
954
525
|
groupId: string;
|
|
955
|
-
items: { id: string; question: string
|
|
526
|
+
items: { id: string; question: string }[];
|
|
956
527
|
text: string;
|
|
957
528
|
}
|
|
958
529
|
|
|
959
530
|
/**
|
|
960
|
-
* Ask one
|
|
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.
|
|
531
|
+
* Ask one spec-out questionnaire (#947).
|
|
971
532
|
*
|
|
972
|
-
*
|
|
973
|
-
*
|
|
974
|
-
*
|
|
975
|
-
*
|
|
976
|
-
*
|
|
533
|
+
* The shape mirrors {@link performAsk} deliberately — rows before delivery,
|
|
534
|
+
* then park — with the one difference that is the whole feature: the items go
|
|
535
|
+
* out as a **single** numbered message, so a questionnaire costs one interrupt
|
|
536
|
+
* rather than N. Each item is still its own row, resolved independently and in
|
|
537
|
+
* any order by the console.
|
|
977
538
|
*/
|
|
978
539
|
export async function performQuestionnaire(
|
|
979
540
|
request: QuestionnaireRequest,
|
|
980
541
|
deps: AskDeps,
|
|
981
542
|
): Promise<QuestionnaireResult> {
|
|
982
543
|
const now = deps.now ?? Date.now;
|
|
983
|
-
const wait = deps.wait ?? sleep;
|
|
984
544
|
|
|
985
545
|
// Every row durable, in one transaction, BEFORE the delivery: an operator must
|
|
986
546
|
// never be shown a question that no row is waiting on.
|
|
@@ -995,71 +555,26 @@ export async function performQuestionnaire(
|
|
|
995
555
|
);
|
|
996
556
|
const groupId = rows[0]!.groupId!;
|
|
997
557
|
|
|
998
|
-
const
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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,
|
|
558
|
+
const { delivery, failure } = await deliverOnce(
|
|
559
|
+
deps,
|
|
560
|
+
questionnaireMessageFor(request),
|
|
561
|
+
request.items[0]!.category ?? "decision-needed",
|
|
1011
562
|
);
|
|
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
563
|
|
|
1035
|
-
const
|
|
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.";
|
|
564
|
+
const items = rows.map((row) => ({ id: row.id, question: row.question }));
|
|
1048
565
|
const perItem = items
|
|
1049
|
-
.map(
|
|
1050
|
-
(item, index) =>
|
|
1051
|
-
`${index + 1}. ${item.state}${item.resolution === undefined ? "" : ` — ${item.resolution}`} (${item.id})`,
|
|
1052
|
-
)
|
|
566
|
+
.map((item, index) => `${index + 1}. ${item.id} — ${decisionResolveCommand(item.id)}`)
|
|
1053
567
|
.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
568
|
return {
|
|
1061
569
|
groupId,
|
|
1062
570
|
items,
|
|
1063
|
-
text:
|
|
571
|
+
text:
|
|
572
|
+
`conductor_questionnaire ${groupId}: ${items.length} questions about #${request.specIssue}, each filed ` +
|
|
573
|
+
`as its own open decision row, delivered as one message. ${deliveryLine(delivery, failure)}\n` +
|
|
574
|
+
`${perItem}\n` +
|
|
575
|
+
`Nothing waits for the answers here: the console session resolves each row as the operator answers ` +
|
|
576
|
+
`it, in any order, and an unanswered row is swept by the seven-day decision expiry. Park the work ` +
|
|
577
|
+
`every item blocks NOW, in this turn — out of the claimable queue, state recorded — and note group ` +
|
|
578
|
+
`${groupId} in your report.`,
|
|
1064
579
|
};
|
|
1065
580
|
}
|