omp-conductor 0.15.9 → 0.15.11
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 +273 -2543
- package/REFERENCE.md +2638 -0
- package/package.json +3 -2
- package/schema/config.schema.json +8 -23
- package/src/arm-challenge.ts +112 -0
- package/src/ask.ts +434 -0
- package/src/board.ts +81 -15
- package/src/brief-upgrade.ts +114 -8
- package/src/briefs/orchestrator.md +55 -29
- package/src/briefs/policy.md +14 -5
- package/src/briefs/worker.md +7 -1
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +190 -1391
- package/src/commands/arm.ts +21 -0
- package/src/commands/board.ts +23 -0
- package/src/commands/brief-upgrade.ts +186 -0
- package/src/commands/context.ts +49 -0
- package/src/commands/daemon.ts +71 -0
- package/src/commands/dashboard.ts +74 -0
- package/src/commands/decision.ts +103 -0
- package/src/commands/disarm.ts +21 -0
- package/src/commands/doctor.ts +98 -0
- package/src/commands/event.ts +62 -0
- package/src/commands/extend.ts +64 -0
- package/src/commands/friction.ts +56 -0
- package/src/commands/help.ts +9 -0
- package/src/commands/hold.ts +26 -0
- package/src/commands/intake.ts +134 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +48 -0
- package/src/commands/report.ts +170 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +93 -0
- package/src/commands/start.ts +23 -0
- package/src/commands/stats.ts +131 -0
- package/src/commands/status.ts +48 -0
- package/src/commands/stop.ts +51 -0
- package/src/commands/tail.ts +109 -0
- package/src/commands/unblock.ts +39 -0
- package/src/commands/upgrade-install.ts +31 -0
- package/src/commands/upgrade-rollback.ts +23 -0
- package/src/commands/upgrade.ts +25 -0
- package/src/commands/verb.ts +83 -0
- package/src/commands/version.ts +30 -0
- package/src/commands/worker.ts +100 -0
- package/src/config-schema.ts +38 -1
- package/src/config.ts +10 -3
- package/src/daemon.ts +613 -94
- package/src/dashboard/app.js +120 -0
- package/src/dashboard/index.html +34 -0
- package/src/dashboard/server.ts +267 -0
- package/src/dashboard/style.css +180 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +795 -0
- package/src/escalate.ts +60 -19
- package/src/failure-class.ts +29 -3
- package/src/fleet.ts +58 -1
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/notices.ts +9 -0
- package/src/omp.ts +2 -0
- package/src/orchestrator-tick.ts +315 -17
- package/src/release-policy.ts +135 -23
- package/src/reports.ts +19 -5
- package/src/setup-host.ts +420 -8
- package/src/setup-install.ts +69 -14
- package/src/setup-wizard.ts +199 -61
- package/src/setup.ts +131 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +206 -21
- package/src/tracker/github.ts +27 -4
- package/src/types.ts +144 -31
- package/src/unblock.ts +55 -11
- package/src/upgrade-journal.ts +220 -0
- package/src/upgrade-verify.ts +506 -0
- package/src/upgrade.ts +295 -26
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +7 -0
- package/systemd/recover-unit-test.sh +428 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
|
|
@@ -24,10 +24,11 @@
|
|
|
24
24
|
"systemd",
|
|
25
25
|
"schema",
|
|
26
26
|
"README.md",
|
|
27
|
+
"REFERENCE.md",
|
|
27
28
|
"LICENSE"
|
|
28
29
|
],
|
|
29
30
|
"scripts": {
|
|
30
|
-
"check": "tsc --noEmit",
|
|
31
|
+
"check": "tsc --noEmit && bun run src/check-trailing-newlines.ts",
|
|
31
32
|
"test": "bun test",
|
|
32
33
|
"schema": "bun run src/generate-schema.ts"
|
|
33
34
|
},
|
|
@@ -167,9 +167,6 @@
|
|
|
167
167
|
"default": "."
|
|
168
168
|
}
|
|
169
169
|
},
|
|
170
|
-
"required": [
|
|
171
|
-
"cwd"
|
|
172
|
-
],
|
|
173
170
|
"additionalProperties": false
|
|
174
171
|
}
|
|
175
172
|
},
|
|
@@ -317,10 +314,6 @@
|
|
|
317
314
|
]
|
|
318
315
|
}
|
|
319
316
|
},
|
|
320
|
-
"required": [
|
|
321
|
-
"fallbackToIssueComment",
|
|
322
|
-
"orchestrator"
|
|
323
|
-
],
|
|
324
317
|
"additionalProperties": false
|
|
325
318
|
},
|
|
326
319
|
"authority": {
|
|
@@ -341,12 +334,16 @@
|
|
|
341
334
|
"human",
|
|
342
335
|
"orchestrator"
|
|
343
336
|
]
|
|
337
|
+
},
|
|
338
|
+
"promotion": {
|
|
339
|
+
"default": "human",
|
|
340
|
+
"type": "string",
|
|
341
|
+
"enum": [
|
|
342
|
+
"human",
|
|
343
|
+
"orchestrator"
|
|
344
|
+
]
|
|
344
345
|
}
|
|
345
346
|
},
|
|
346
|
-
"required": [
|
|
347
|
-
"merge",
|
|
348
|
-
"release"
|
|
349
|
-
],
|
|
350
347
|
"additionalProperties": false
|
|
351
348
|
},
|
|
352
349
|
"releasePolicy": {
|
|
@@ -413,12 +410,6 @@
|
|
|
413
410
|
]
|
|
414
411
|
}
|
|
415
412
|
},
|
|
416
|
-
"required": [
|
|
417
|
-
"requiredChecks",
|
|
418
|
-
"baseFreshness",
|
|
419
|
-
"drafts",
|
|
420
|
-
"whenBehindBase"
|
|
421
|
-
],
|
|
422
413
|
"additionalProperties": false
|
|
423
414
|
},
|
|
424
415
|
"release": {
|
|
@@ -465,12 +456,6 @@
|
|
|
465
456
|
}
|
|
466
457
|
}
|
|
467
458
|
},
|
|
468
|
-
"required": [
|
|
469
|
-
"requires",
|
|
470
|
-
"requiredChecks",
|
|
471
|
-
"artefacts",
|
|
472
|
-
"environments"
|
|
473
|
-
],
|
|
474
459
|
"additionalProperties": false
|
|
475
460
|
}
|
|
476
461
|
},
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated pending-challenge state for the arming handshake (conductor
|
|
3
|
+
* #415).
|
|
4
|
+
*
|
|
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.
|
|
9
|
+
*
|
|
10
|
+
* This module is the bridge between the two roles without an import cycle:
|
|
11
|
+
* `fleet.ts` imports `daemon.ts`, `daemon.ts` imports `orchestrator-tick.ts`,
|
|
12
|
+
* 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
|
+
*
|
|
16
|
+
* Only the sha-256 of the code is ever persisted — never the code, whose
|
|
17
|
+
* 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.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { stateDir } from "./config.ts";
|
|
28
|
+
|
|
29
|
+
const ARM_CHALLENGE_FILE = "arm-challenge.json";
|
|
30
|
+
|
|
31
|
+
interface PendingChallenge {
|
|
32
|
+
/** sha-256 hex of the challenge code — never the code itself. */
|
|
33
|
+
hash: string;
|
|
34
|
+
/** Unix ms after which a matching reply is no longer an active proof. */
|
|
35
|
+
expiresAt: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Keyed by project name; `""` is the pre-multi-project (un-named) spelling. */
|
|
39
|
+
type PendingChallenges = Record<string, PendingChallenge>;
|
|
40
|
+
|
|
41
|
+
function challengesPath(): string {
|
|
42
|
+
return join(stateDir(), ARM_CHALLENGE_FILE);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function projectKey(project?: string): string {
|
|
46
|
+
return project ?? "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readChallenges(): PendingChallenges {
|
|
50
|
+
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 */
|
|
57
|
+
}
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
|
|
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 });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** sha-256 hex of the challenge code — the persisted token, never the code. */
|
|
70
|
+
function challengeHash(code: string): string {
|
|
71
|
+
return createHash("sha256").update(code).digest("hex");
|
|
72
|
+
}
|
|
73
|
+
|
|
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);
|
|
79
|
+
}
|
|
80
|
+
|
|
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();
|
|
84
|
+
const key = projectKey(project);
|
|
85
|
+
if (!(key in map)) return;
|
|
86
|
+
delete map[key];
|
|
87
|
+
writeChallenges(map);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
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.
|
|
98
|
+
*
|
|
99
|
+
* Clears nothing: the host (`armTicks`) owns clearance once its transcript
|
|
100
|
+
* proof lands, so the two sides cannot race for the record.
|
|
101
|
+
*/
|
|
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;
|
|
106
|
+
// Challenge codes contain no whitespace, so tokenising on whitespace never
|
|
107
|
+
// splits one; empty replies simply yield no token.
|
|
108
|
+
for (const token of replyText.trim().split(/\s+/)) {
|
|
109
|
+
if (token.length > 0 && challengeHash(token) === targetHash) return true;
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
package/src/ask.ts
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bounded ask surface for an orchestrator tick (#438).
|
|
3
|
+
*
|
|
4
|
+
* On 2026-08-16 the orchestrator called `telegram_ask` for a genuine tier-2
|
|
5
|
+
* contract decision (PR #432 / issue #368) and the operator was asleep. The
|
|
6
|
+
* tool does not time out: it blocked the turn for just over six hours, and a
|
|
7
|
+
* tick prompt timestamped 01:14Z did not execute until after the answer at
|
|
8
|
+
* 07:04Z. Every duty behind the ask — draining, merging, grooming — stopped
|
|
9
|
+
* with it. `telegram_ask` is omp-telegram's tool and this package cannot tell
|
|
10
|
+
* it how long it may wait, so an autonomous tick gets a different ask surface:
|
|
11
|
+
* {@link ASK_TOOL}. It records the question durably (a decision row, plus the
|
|
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:
|
|
15
|
+
*
|
|
16
|
+
* - `auto-proceed`: the recommended option is applied and the decision row is
|
|
17
|
+
* resolved with `"<option> (auto-applied on ask timeout)"`, so the record can
|
|
18
|
+
* never be read back as a human choice;
|
|
19
|
+
* - `park`: the row stays open and pending — re-surfaced in every tick prompt —
|
|
20
|
+
* and the caller is told to take the blocked item out of the claimable queue
|
|
21
|
+
* with its state recorded.
|
|
22
|
+
*
|
|
23
|
+
* A timeout is "nobody answered yet". It is never a cancelled ask, an errored
|
|
24
|
+
* ask, or an operator "no": the only writes it makes are the auto-apply
|
|
25
|
+
* resolution (which names itself as auto-applied) or none at all. The existing
|
|
26
|
+
* seven-day decision expiry bounds a parked row exactly like any other open
|
|
27
|
+
* decision — nothing here reopens that clock.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { DecisionRecord, InterruptCategory, Store } from "./types.ts";
|
|
31
|
+
import { INTERRUPT_CATEGORIES } from "./types.ts";
|
|
32
|
+
|
|
33
|
+
/** The bounded ask tool this package registers on an orchestrator session. */
|
|
34
|
+
export const ASK_TOOL = "conductor_ask";
|
|
35
|
+
|
|
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
|
+
/**
|
|
50
|
+
* The suffix a decision row carries when nobody human chose the answer. Fixed
|
|
51
|
+
* wording on purpose — the same reason `types.ts` gives for closed vocabularies
|
|
52
|
+
* everywhere else: a marker a tick or an operator has to match by reading it
|
|
53
|
+
* must be one no two spellings disagree on.
|
|
54
|
+
*/
|
|
55
|
+
export const AUTO_APPLIED_SUFFIX = "(auto-applied on ask timeout)";
|
|
56
|
+
|
|
57
|
+
/** How often the bounded wait re-reads the decision row while waiting. */
|
|
58
|
+
export const ASK_POLL_MS = 1_000;
|
|
59
|
+
|
|
60
|
+
/** One option the operator may pick, as rendered in the delivered question. */
|
|
61
|
+
export interface AskOption {
|
|
62
|
+
label: string;
|
|
63
|
+
description?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The validated argument surface of {@link ASK_TOOL}. */
|
|
67
|
+
export interface AskRequest {
|
|
68
|
+
/** The question verbatim, as it is archived on the decision row. */
|
|
69
|
+
question: string;
|
|
70
|
+
/** What the ask does when nobody answers within the ceiling. */
|
|
71
|
+
onTimeout: AskTimeoutOutcome;
|
|
72
|
+
/** Optional ceiling in whole seconds; defaults to the configured ceiling. */
|
|
73
|
+
timeoutSeconds?: number;
|
|
74
|
+
/** What is waiting on the answer, for the decision row and the digest. */
|
|
75
|
+
blocks?: string;
|
|
76
|
+
/** The option applied by `auto-proceed`; required exactly for that outcome. */
|
|
77
|
+
recommended?: string;
|
|
78
|
+
/** The choices shown to the operator, with {@link AskRequest.recommended} named. */
|
|
79
|
+
options?: AskOption[];
|
|
80
|
+
/** Escalation category for the delivered question; defaults to `decision-needed`. */
|
|
81
|
+
category?: InterruptCategory;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type AskParse =
|
|
85
|
+
| { ok: true; request: AskRequest }
|
|
86
|
+
| { ok: false; problem: string };
|
|
87
|
+
|
|
88
|
+
function isInterruptCategory(value: unknown): value is InterruptCategory {
|
|
89
|
+
return typeof value === "string" && (INTERRUPT_CATEGORIES as readonly string[]).includes(value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Validate one raw tool call. Strict like the verb arguments: an unknown shape
|
|
94
|
+
* is refused with the reason, never coerced into a default that could hide what
|
|
95
|
+
* the asker actually sent. The one tolerant field is the omitted `timeoutSeconds`
|
|
96
|
+
* — the issue's whole point is that the ceiling holds even when the model does
|
|
97
|
+
* not name one.
|
|
98
|
+
*/
|
|
99
|
+
export function parseAskRequest(raw: unknown): AskParse {
|
|
100
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
101
|
+
return { ok: false, problem: "conductor_ask arguments must be an object" };
|
|
102
|
+
}
|
|
103
|
+
const input = raw as Record<string, unknown>;
|
|
104
|
+
|
|
105
|
+
const question = input["question"];
|
|
106
|
+
if (typeof question !== "string" || question.trim().length === 0) {
|
|
107
|
+
return { ok: false, problem: `conductor_ask needs "question": the question you put to the operator` };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const onTimeoutRaw = input["on-timeout"];
|
|
111
|
+
if (
|
|
112
|
+
typeof onTimeoutRaw !== "string" ||
|
|
113
|
+
!(ASK_TIMEOUT_OUTCOMES as readonly string[]).includes(onTimeoutRaw)
|
|
114
|
+
) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
problem: `conductor_ask needs "on-timeout": one of ${ASK_TIMEOUT_OUTCOMES.join(" or ")} — what happens when nobody answers within the ceiling`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const onTimeout = onTimeoutRaw as AskTimeoutOutcome;
|
|
121
|
+
|
|
122
|
+
const recommendedRaw = input["recommended"];
|
|
123
|
+
const recommended =
|
|
124
|
+
typeof recommendedRaw === "string" && recommendedRaw.trim().length > 0
|
|
125
|
+
? recommendedRaw.trim()
|
|
126
|
+
: undefined;
|
|
127
|
+
if (onTimeout === "auto-proceed" && recommended === undefined) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
problem:
|
|
131
|
+
'conductor_ask with "on-timeout": "auto-proceed" needs "recommended": the option to apply when nobody answers, ' +
|
|
132
|
+
"because the decision row must record what was auto-applied",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const timeoutRaw = input["timeoutSeconds"];
|
|
137
|
+
let timeoutSeconds: number | undefined;
|
|
138
|
+
if (timeoutRaw !== undefined) {
|
|
139
|
+
if (typeof timeoutRaw !== "number" || !Number.isInteger(timeoutRaw)) {
|
|
140
|
+
return { ok: false, problem: "conductor_ask timeoutSeconds must be a whole number of seconds when present" };
|
|
141
|
+
}
|
|
142
|
+
if (timeoutRaw < MIN_ASK_TIMEOUT_SECONDS || timeoutRaw > MAX_ASK_TIMEOUT_SECONDS) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
problem: `conductor_ask timeoutSeconds must be between ${MIN_ASK_TIMEOUT_SECONDS} and ${MAX_ASK_TIMEOUT_SECONDS}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
timeoutSeconds = timeoutRaw;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const blocksRaw = input["blocks"];
|
|
152
|
+
const blocks = typeof blocksRaw === "string" && blocksRaw.trim().length > 0 ? blocksRaw.trim() : undefined;
|
|
153
|
+
|
|
154
|
+
const optionsRaw = input["options"];
|
|
155
|
+
let options: AskOption[] | undefined;
|
|
156
|
+
if (optionsRaw !== undefined) {
|
|
157
|
+
if (!Array.isArray(optionsRaw)) {
|
|
158
|
+
return { ok: false, problem: "conductor_ask options must be an array of { label, description? }" };
|
|
159
|
+
}
|
|
160
|
+
const parsedOptions: AskOption[] = [];
|
|
161
|
+
for (const entry of optionsRaw) {
|
|
162
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
163
|
+
return { ok: false, problem: "conductor_ask options entries must be objects with a label" };
|
|
164
|
+
}
|
|
165
|
+
const option = entry as Record<string, unknown>;
|
|
166
|
+
const label = option["label"];
|
|
167
|
+
if (typeof label !== "string" || label.trim().length === 0) {
|
|
168
|
+
return { ok: false, problem: "conductor_ask options entries need a non-empty label" };
|
|
169
|
+
}
|
|
170
|
+
const descriptionRaw = option["description"];
|
|
171
|
+
parsedOptions.push({
|
|
172
|
+
label: label.trim(),
|
|
173
|
+
...(typeof descriptionRaw === "string" && descriptionRaw.trim().length > 0
|
|
174
|
+
? { description: descriptionRaw.trim() }
|
|
175
|
+
: {}),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
options = parsedOptions;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const categoryRaw = input["category"];
|
|
182
|
+
if (categoryRaw !== undefined && !isInterruptCategory(categoryRaw)) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
problem: `conductor_ask category must be one of ${INTERRUPT_CATEGORIES.join(", ")} when present`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
ok: true,
|
|
191
|
+
request: {
|
|
192
|
+
question: question.trim(),
|
|
193
|
+
onTimeout,
|
|
194
|
+
...(timeoutSeconds === undefined ? {} : { timeoutSeconds }),
|
|
195
|
+
...(blocks === undefined ? {} : { blocks }),
|
|
196
|
+
...(recommended === undefined ? {} : { recommended }),
|
|
197
|
+
...(options === undefined ? {} : { options }),
|
|
198
|
+
...(categoryRaw === undefined ? {} : { category: categoryRaw as InterruptCategory }),
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The JSON Schema the harness advertises for {@link ASK_TOOL}, shaped like the
|
|
205
|
+
* verb parameter schemas: plain JSON Schema, `additionalProperties: false`,
|
|
206
|
+
* required lists spelled out.
|
|
207
|
+
*/
|
|
208
|
+
export function askParameterSchema(): Record<string, unknown> {
|
|
209
|
+
return {
|
|
210
|
+
type: "object",
|
|
211
|
+
properties: {
|
|
212
|
+
question: {
|
|
213
|
+
type: "string",
|
|
214
|
+
description:
|
|
215
|
+
"The question, written for a phone: one plain sentence, your recommendation, and the options with their consequences.",
|
|
216
|
+
},
|
|
217
|
+
"on-timeout": {
|
|
218
|
+
type: "string",
|
|
219
|
+
enum: [...ASK_TIMEOUT_OUTCOMES],
|
|
220
|
+
description:
|
|
221
|
+
"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.",
|
|
222
|
+
},
|
|
223
|
+
timeoutSeconds: {
|
|
224
|
+
type: "number",
|
|
225
|
+
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}.`,
|
|
226
|
+
},
|
|
227
|
+
recommended: {
|
|
228
|
+
type: "string",
|
|
229
|
+
description:
|
|
230
|
+
"The option applied on auto-proceed. Required when on-timeout is auto-proceed, because the row must record what was auto-applied.",
|
|
231
|
+
},
|
|
232
|
+
blocks: {
|
|
233
|
+
type: "string",
|
|
234
|
+
description: "What is waiting on the answer (an issue, a PR, a release) for the decision row.",
|
|
235
|
+
},
|
|
236
|
+
options: {
|
|
237
|
+
type: "array",
|
|
238
|
+
items: {
|
|
239
|
+
type: "object",
|
|
240
|
+
properties: {
|
|
241
|
+
label: { type: "string", description: "Short button label." },
|
|
242
|
+
description: { type: "string", description: "Optional tradeoff detail." },
|
|
243
|
+
},
|
|
244
|
+
required: ["label"],
|
|
245
|
+
additionalProperties: false,
|
|
246
|
+
},
|
|
247
|
+
description: "The choices shown to the operator; name the recommended one in `recommended`.",
|
|
248
|
+
},
|
|
249
|
+
category: {
|
|
250
|
+
type: "string",
|
|
251
|
+
enum: [...INTERRUPT_CATEGORIES],
|
|
252
|
+
description: "Escalation category for the delivered question. Optional; defaults to decision-needed.",
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
required: ["question", "on-timeout"],
|
|
256
|
+
additionalProperties: false,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The ceiling for one ask, in whole seconds. The model may name one, but the
|
|
262
|
+
* enforcement never depends on it: an ask without a `timeoutSeconds` gets the
|
|
263
|
+
* configured ceiling, and both are capped at the turn budget so the ask can
|
|
264
|
+
* never outlive the turn it runs in.
|
|
265
|
+
*/
|
|
266
|
+
export function resolveAskCeilingSeconds(
|
|
267
|
+
requestedSeconds: number | undefined,
|
|
268
|
+
configuredSeconds: number | undefined,
|
|
269
|
+
turnBudgetSeconds: number,
|
|
270
|
+
): number {
|
|
271
|
+
const base = requestedSeconds ?? configuredSeconds ?? DEFAULT_ASK_TIMEOUT_SECONDS;
|
|
272
|
+
return Math.min(base, Math.max(MIN_ASK_TIMEOUT_SECONDS, turnBudgetSeconds));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The row resolution for an auto-applied outcome — exactly the acceptance text. */
|
|
276
|
+
export function autoApplyResolution(recommended: string): string {
|
|
277
|
+
return `${recommended} ${AUTO_APPLIED_SUFFIX}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The question exactly as it is delivered, durable prefix and all. */
|
|
281
|
+
export function askMessageFor(request: AskRequest): string {
|
|
282
|
+
const lines = [`QUESTION: ${request.question}`];
|
|
283
|
+
if (request.options !== undefined && request.options.length > 0) {
|
|
284
|
+
lines.push(
|
|
285
|
+
`Options: ${request.options
|
|
286
|
+
.map((option) =>
|
|
287
|
+
option.description === undefined
|
|
288
|
+
? option.label
|
|
289
|
+
: `${option.label} — ${option.description}`,
|
|
290
|
+
)
|
|
291
|
+
.join("; ")}`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (request.recommended !== undefined) lines.push(`Recommended: ${request.recommended}`);
|
|
295
|
+
return lines.join("\n");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** One read of the decision row; `undefined` means the id is unknown. */
|
|
299
|
+
export type DecisionReader = (id: string) => DecisionRecord | undefined;
|
|
300
|
+
|
|
301
|
+
export interface BoundedAskDeps {
|
|
302
|
+
decisionId: string;
|
|
303
|
+
ceilingMs: number;
|
|
304
|
+
read: DecisionReader;
|
|
305
|
+
/** Advance one poll; injected so tests stay deterministic. */
|
|
306
|
+
wait: (ms: number) => Promise<void>;
|
|
307
|
+
now: () => number;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export type BoundedAskOutcome =
|
|
311
|
+
| { kind: "answered"; answer: string }
|
|
312
|
+
| { kind: "withdrawn" }
|
|
313
|
+
| { kind: "timed-out" };
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The wait itself: read the row, leave when someone closed it, time out at the
|
|
317
|
+
* deadline. Pure by construction — every clock and sleep is injected — so the
|
|
318
|
+
* whole "does the ask come back bounded" contract is a deterministic test, not
|
|
319
|
+
* a timer gamble.
|
|
320
|
+
*/
|
|
321
|
+
export async function runBoundedAsk(deps: BoundedAskDeps): Promise<BoundedAskOutcome> {
|
|
322
|
+
const deadline = deps.now() + deps.ceilingMs;
|
|
323
|
+
for (;;) {
|
|
324
|
+
const row = deps.read(deps.decisionId);
|
|
325
|
+
if (row !== undefined && row.state !== "open") {
|
|
326
|
+
return row.state === "answered"
|
|
327
|
+
? { kind: "answered", answer: row.resolution ?? "" }
|
|
328
|
+
: { kind: "withdrawn" };
|
|
329
|
+
}
|
|
330
|
+
if (deps.now() >= deadline) return { kind: "timed-out" };
|
|
331
|
+
await deps.wait(ASK_POLL_MS);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** What delivering the question to the operator returned, for the result text. */
|
|
336
|
+
export interface AskDeliveryResult {
|
|
337
|
+
kind: "sent" | "held";
|
|
338
|
+
category: InterruptCategory;
|
|
339
|
+
noticeId?: string;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** What one {@link ASK_TOOL} call produced, and the exact text the model reads. */
|
|
343
|
+
export interface AskResult {
|
|
344
|
+
outcome: BoundedAskOutcome;
|
|
345
|
+
decisionId: string;
|
|
346
|
+
text: string;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export interface AskDeps {
|
|
350
|
+
store: Store;
|
|
351
|
+
project: string;
|
|
352
|
+
/** The tick config's `askTimeoutSeconds`, already validated. */
|
|
353
|
+
configuredCeilingSeconds?: number;
|
|
354
|
+
/** The tick config's `budgetSeconds`, already validated. */
|
|
355
|
+
turnBudgetSeconds?: number;
|
|
356
|
+
/** Delivers the question through the sanctioned durable path. */
|
|
357
|
+
deliver(questionText: string, category: InterruptCategory): Promise<AskDeliveryResult>;
|
|
358
|
+
wait?: (ms: number) => Promise<void>;
|
|
359
|
+
now?: () => number;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Real-time default for the injected wait; tests hand their own. */
|
|
363
|
+
const sleep = (ms: number): Promise<void> => {
|
|
364
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
365
|
+
setTimeout(resolve, ms);
|
|
366
|
+
return promise;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* One bounded ask, end to end: durable row first, then delivery, then the wait,
|
|
371
|
+
* then the declared timeout outcome. The row exists before the delivery
|
|
372
|
+
* attempt, so a crash at any point leaves a recorded question — never one that
|
|
373
|
+
* lived only in the model's context.
|
|
374
|
+
*/
|
|
375
|
+
export async function performAsk(request: AskRequest, deps: AskDeps): Promise<AskResult> {
|
|
376
|
+
const now = deps.now ?? Date.now;
|
|
377
|
+
const wait = deps.wait ?? sleep;
|
|
378
|
+
|
|
379
|
+
const row = deps.store.createDecision({
|
|
380
|
+
project: deps.project,
|
|
381
|
+
question: request.question,
|
|
382
|
+
...(request.blocks === undefined ? {} : { blocks: request.blocks }),
|
|
383
|
+
at: now(),
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const delivery = await deps.deliver(askMessageFor(request), request.category ?? "decision-needed");
|
|
387
|
+
|
|
388
|
+
const ceilingSeconds = resolveAskCeilingSeconds(
|
|
389
|
+
request.timeoutSeconds,
|
|
390
|
+
deps.configuredCeilingSeconds,
|
|
391
|
+
deps.turnBudgetSeconds ?? DEFAULT_ASK_TIMEOUT_SECONDS,
|
|
392
|
+
);
|
|
393
|
+
const outcome = await runBoundedAsk({
|
|
394
|
+
decisionId: row.id,
|
|
395
|
+
ceilingMs: ceilingSeconds * 1_000,
|
|
396
|
+
read: (id) => deps.store.decision(id),
|
|
397
|
+
wait,
|
|
398
|
+
now,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
let text: string;
|
|
402
|
+
if (outcome.kind === "answered") {
|
|
403
|
+
text =
|
|
404
|
+
`conductor_ask ${row.id}: the operator answered — ${outcome.answer}. ` +
|
|
405
|
+
`The decision row is resolved with that answer. Proceed to apply it.`;
|
|
406
|
+
} else if (outcome.kind === "withdrawn") {
|
|
407
|
+
text =
|
|
408
|
+
`conductor_ask ${row.id}: the decision was withdrawn by whoever holds the row before the ` +
|
|
409
|
+
`ceiling elapsed. No answer was given and nothing was auto-applied. Treat it as "the question is ` +
|
|
410
|
+
`closed", not as an answer.`;
|
|
411
|
+
} else if (request.onTimeout === "auto-proceed") {
|
|
412
|
+
const resolution = autoApplyResolution(request.recommended!);
|
|
413
|
+
deps.store.resolveDecision(row.id, "answered", resolution, now());
|
|
414
|
+
text =
|
|
415
|
+
`conductor_ask ${row.id}: no answer within ${ceilingSeconds}s — auto-proceeding as declared. ` +
|
|
416
|
+
`The recommended option "${request.recommended}" is applied, and the decision row was resolved ` +
|
|
417
|
+
`with the answer "${resolution}" so the record shows nobody human chose it. Proceed to apply ` +
|
|
418
|
+
`that recommendation to the blocked work now.`;
|
|
419
|
+
} else {
|
|
420
|
+
text =
|
|
421
|
+
`conductor_ask ${row.id}: no answer within ${ceilingSeconds}s — parking as declared. ` +
|
|
422
|
+
`The decision row stays open and pending, re-surfaced in every tick prompt until answered or the ` +
|
|
423
|
+
`seven-day expiry. Park the blocked work now: take it out of the claimable queue and record its ` +
|
|
424
|
+
`state (e.g. unlabel it or move it to a parked state), then note it in your report.`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const delivered =
|
|
428
|
+
delivery.kind === "sent"
|
|
429
|
+
? `The question went to the operator as a ${delivery.category} message.`
|
|
430
|
+
: `The question is durably held (notice ${delivery.noticeId ?? "?"}, ${delivery.category}); the ` +
|
|
431
|
+
"daemon releases it with the next digest or working-hours catch-up.";
|
|
432
|
+
|
|
433
|
+
return { outcome, decisionId: row.id, text: `${delivered}\n${text}` };
|
|
434
|
+
}
|