omp-conductor 0.18.2 → 0.19.1
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 +105 -40
- package/REFERENCE.md +865 -30
- package/package.json +1 -1
- package/schema/config.schema.json +26 -0
- package/src/admission.ts +212 -26
- package/src/ask.ts +288 -1
- package/src/briefs/orchestrator.md +6 -5
- package/src/cli.ts +5 -1
- package/src/command-help.ts +9 -1
- package/src/command-manifest.ts +36 -3
- package/src/commands/arm.ts +5 -1
- package/src/commands/context.ts +2 -0
- 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/config-schema.ts +19 -0
- package/src/config.ts +80 -0
- package/src/credential-class.ts +366 -0
- package/src/daemon.ts +1218 -288
- 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/doctor.ts +379 -22
- package/src/failure-class.ts +59 -0
- package/src/fleet.ts +511 -101
- package/src/host.ts +6 -130
- package/src/omp.ts +29 -0
- package/src/orchestrator-tick.ts +343 -88
- package/src/pause.ts +233 -0
- package/src/settlement.ts +159 -2
- package/src/setup-answers.ts +97 -0
- package/src/setup-host.ts +325 -1159
- package/src/setup-install.ts +204 -27
- package/src/setup-wizard.ts +111 -50
- package/src/setup.ts +33 -0
- package/src/spend-telemetry.ts +117 -0
- package/src/stats.ts +35 -0
- package/src/status-render.ts +348 -19
- package/src/store.ts +1229 -55
- package/src/telegram-freshness.ts +269 -0
- package/src/to-spec.ts +27 -0
- package/src/types.ts +697 -4
- package/src/unblock.ts +22 -0
- package/src/unit-reconcile.ts +303 -0
- package/src/upgrade-verify.ts +8 -1
- package/src/upgrade.ts +326 -47
- package/src/verbs/actions.ts +124 -10
- package/src/verbs/protocol.ts +70 -2
- package/src/verbs/server.ts +447 -8
- package/src/wake.ts +48 -0
- package/src/worker.ts +403 -3
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
|
+
}
|
|
@@ -238,11 +238,12 @@ Keep the queue worth draining.
|
|
|
238
238
|
When the queue is below the grooming trigger, the tick's **bounded to-spec
|
|
239
239
|
batch** block (#777) carries the mechanical contract: it names the launch
|
|
240
240
|
token, the candidates the conductor selected mechanically from the live
|
|
241
|
-
open-issue snapshot (parked, parent/epic, already-groomed,
|
|
242
|
-
lane/dependency-blocked and dispatched
|
|
243
|
-
by your judgement), the exclusions that
|
|
244
|
-
shape. Do exactly what it says — those
|
|
245
|
-
|
|
241
|
+
open-issue snapshot (parked, parent/epic, already-groomed, refused inside
|
|
242
|
+
their retry cooldown, in-flight, lane/dependency-blocked and dispatched
|
|
243
|
+
issues were excluded by code, never by your judgement), the exclusions that
|
|
244
|
+
selection applied, and the item shape. Do exactly what it says — those
|
|
245
|
+
candidates are the only batch this tick authorizes: render
|
|
246
|
+
`omp/src/briefs/to-spec.md` for each of them with
|
|
246
247
|
the current source head, launch exactly one `task` batch through the
|
|
247
248
|
`to-spec` agent with the listed items and no substitutes, and persist every
|
|
248
249
|
returned result with the `conductor_to_spec_result` tool. The `tool_call`
|
package/src/cli.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { intakeCommand } from "./commands/intake.ts";
|
|
|
28
28
|
import { ledgerCommand } from "./commands/ledger.ts";
|
|
29
29
|
import { messageCommand } from "./commands/message.ts";
|
|
30
30
|
import { reportCommand } from "./commands/report.ts";
|
|
31
|
+
import { releaseCompositionCommand } from "./commands/release-composition.ts";
|
|
31
32
|
import { restartCommand } from "./commands/restart.ts";
|
|
32
33
|
import { restoreDbCommand } from "./commands/restore-db.ts";
|
|
33
34
|
import { resumeCommand } from "./commands/resume.ts";
|
|
@@ -40,6 +41,7 @@ import { tailCommand } from "./commands/tail.ts";
|
|
|
40
41
|
import { unblockCommand } from "./commands/unblock.ts";
|
|
41
42
|
import { unfreezeCommand } from "./commands/unfreeze.ts";
|
|
42
43
|
import { upgradeInstallCommand } from "./commands/upgrade-install.ts";
|
|
44
|
+
import { reconcileUnitsCommand } from "./commands/reconcile-units.ts";
|
|
43
45
|
import { upgradeRollbackCommand } from "./commands/upgrade-rollback.ts";
|
|
44
46
|
import { upgradeCommand } from "./commands/upgrade.ts";
|
|
45
47
|
import { verbCommand } from "./commands/verb.ts";
|
|
@@ -265,6 +267,7 @@ export function commandHandlers(ctx: CommandContext): Record<string, CommandHand
|
|
|
265
267
|
setup: () => setupCommand(ctx),
|
|
266
268
|
upgrade: () => upgradeCommand(ctx),
|
|
267
269
|
"upgrade-install": () => upgradeInstallCommand(ctx),
|
|
270
|
+
"reconcile-units": () => reconcileUnitsCommand(ctx),
|
|
268
271
|
"upgrade-rollback": () => upgradeRollbackCommand(ctx),
|
|
269
272
|
daemon: () => daemonCommand(ctx),
|
|
270
273
|
start: () => startCommand(ctx),
|
|
@@ -284,8 +287,9 @@ export function commandHandlers(ctx: CommandContext): Record<string, CommandHand
|
|
|
284
287
|
extend: () => extendCommand(ctx),
|
|
285
288
|
worker: () => workerCommand(ctx),
|
|
286
289
|
unblock: () => unblockCommand(ctx),
|
|
287
|
-
|
|
290
|
+
"release-composition": () => releaseCompositionCommand(ctx),
|
|
288
291
|
verb: () => verbCommand(ctx),
|
|
292
|
+
unfreeze: () => unfreezeCommand(ctx),
|
|
289
293
|
event: () => eventCommand(ctx),
|
|
290
294
|
report: () => reportCommand(ctx),
|
|
291
295
|
message: () => messageCommand(ctx),
|
package/src/command-help.ts
CHANGED
|
@@ -10,6 +10,11 @@ export const COMMAND_DETAILS = ` setup interview, then write config.json, th
|
|
|
10
10
|
brief as one pinned release. Pauses only new claims, drains live
|
|
11
11
|
workers, reloads, verifies twice, and restores the prior dispatch
|
|
12
12
|
state. Run it from a shell outside the target Herdr session.
|
|
13
|
+
--bootstrap SHA --source PATH installs an exact commit instead of a
|
|
14
|
+
published version, for when the installed conductor is what is
|
|
15
|
+
broken: the source must be a checkout AT that sha, the package's own
|
|
16
|
+
checks run there first, and a check failure refuses rather than
|
|
17
|
+
installing a partly verified tree.
|
|
13
18
|
upgrade-install
|
|
14
19
|
the detached executor half of the fleet-installs-itself request:
|
|
15
20
|
the same transaction, journaled per surface, leaving verification
|
|
@@ -207,7 +212,10 @@ export const COMMAND_DETAILS = ` setup interview, then write config.json, th
|
|
|
207
212
|
re-stage the systemd unit and run the install behind one confirm.
|
|
208
213
|
The units are host-global; NAME (or --project NAME) says which
|
|
209
214
|
project's per-project tail (tick config, brief link) to write, and
|
|
210
|
-
is required on a host with several configured projects.
|
|
215
|
+
is required on a host with several configured projects. On a Herdr
|
|
216
|
+
host it restarts the session unit, so it must be run from a shell
|
|
217
|
+
outside that session: from a pane inside it, it refuses before
|
|
218
|
+
staging or installing anything.
|
|
211
219
|
setup graph
|
|
212
220
|
set up the code-graph indexes workers query instead of grepping, end
|
|
213
221
|
to end: check prerequisites, clone any missing index-only clone as
|
package/src/command-manifest.ts
CHANGED
|
@@ -59,12 +59,15 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
59
59
|
description: "interview, configure the fleet, or install host and graph surfaces",
|
|
60
60
|
scope: "project",
|
|
61
61
|
usage: [
|
|
62
|
-
"setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--project NAME]",
|
|
62
|
+
"setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--resume FILE] [--project NAME]",
|
|
63
63
|
"setup host [NAME] [--project NAME]",
|
|
64
64
|
"setup graph [--no-seed] [--print] [--project NAME]",
|
|
65
65
|
],
|
|
66
66
|
subcommands: [
|
|
67
|
-
{
|
|
67
|
+
{
|
|
68
|
+
name: "host",
|
|
69
|
+
description: "stage and install the host services (run it from a shell outside the Herdr session it restarts)",
|
|
70
|
+
},
|
|
68
71
|
{ name: "graph", description: "install and seed code-graph indexes" },
|
|
69
72
|
],
|
|
70
73
|
flags: [
|
|
@@ -117,7 +120,7 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
117
120
|
name: "upgrade",
|
|
118
121
|
description: "upgrade all installed conductor surfaces as one transaction",
|
|
119
122
|
scope: "host",
|
|
120
|
-
usage: ["upgrade [--to VERSION] [--project NAME]"],
|
|
123
|
+
usage: ["upgrade [--to VERSION] [--bootstrap SHA --source PATH] [--project NAME]"],
|
|
121
124
|
flags: [value("--to", "package version to install"), project()],
|
|
122
125
|
},
|
|
123
126
|
{
|
|
@@ -134,6 +137,18 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
134
137
|
usage: ["upgrade-rollback"],
|
|
135
138
|
flags: [],
|
|
136
139
|
},
|
|
140
|
+
{
|
|
141
|
+
name: "reconcile-units",
|
|
142
|
+
description: "install the host unit files this version renders (units only)",
|
|
143
|
+
scope: "host",
|
|
144
|
+
usage: ["reconcile-units [--yes] [--dry-run] [--protect PATH]... [--project NAME]"],
|
|
145
|
+
flags: [
|
|
146
|
+
toggle("--yes", "skip the confirm (the upgrade transaction's own consent)"),
|
|
147
|
+
toggle("--dry-run", "print what would be installed and run nothing"),
|
|
148
|
+
value("--protect", "a destination to leave alone (repeatable)"),
|
|
149
|
+
project(),
|
|
150
|
+
],
|
|
151
|
+
},
|
|
137
152
|
{
|
|
138
153
|
name: "board",
|
|
139
154
|
description: "open the live keyboard-driven fleet board",
|
|
@@ -292,6 +307,24 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
292
307
|
flags: [value("--reason", "reason for clearing the freeze"), project()],
|
|
293
308
|
positionals: [{ name: "repo" }],
|
|
294
309
|
},
|
|
310
|
+
{
|
|
311
|
+
name: "release-composition",
|
|
312
|
+
description: "declare, override, complete or cancel the active release",
|
|
313
|
+
scope: "project",
|
|
314
|
+
usage: [
|
|
315
|
+
"release-composition declare --campaign ID [--pr URL ...] [--reason TEXT] [--project NAME]",
|
|
316
|
+
"release-composition override --pr URL [--reason TEXT] [--project NAME]",
|
|
317
|
+
"release-composition complete [--reason TEXT] [--project NAME]",
|
|
318
|
+
"release-composition cancel [--reason TEXT] [--project NAME]",
|
|
319
|
+
"release-composition status [--project NAME]",
|
|
320
|
+
],
|
|
321
|
+
flags: [
|
|
322
|
+
value("--campaign", "campaign identifier for the release being assembled"),
|
|
323
|
+
value("--pr", "full PR URL allowed into (or overridden into) the release"),
|
|
324
|
+
value("--reason", "reason recorded with the transition"),
|
|
325
|
+
project(),
|
|
326
|
+
],
|
|
327
|
+
},
|
|
295
328
|
{
|
|
296
329
|
name: "verb",
|
|
297
330
|
description: "run a gated conductor_* verb as the orchestrator",
|
package/src/commands/arm.ts
CHANGED
|
@@ -15,10 +15,14 @@ for (const project of ctx.targetProjects()) {
|
|
|
15
15
|
// Proof-neutral wording: `claim-only` performs no Telegram send, so the
|
|
16
16
|
// progress line cannot promise a challenge that never goes out (#613). The
|
|
17
17
|
// result line names the proof that actually armed it.
|
|
18
|
+
// The pending-proof heartbeat goes straight to stdout (#861): a challenge
|
|
19
|
+
// waits on a human for up to five minutes, and `withProgress`'s single step
|
|
20
|
+
// line cannot say how much of that window is left. Written unconditionally —
|
|
21
|
+
// a piped or plain-UI run needs it more than a TTY does, not less.
|
|
18
22
|
const r = await withProgress(
|
|
19
23
|
"arm: verifying the arming proof…",
|
|
20
24
|
"Arming proof verified",
|
|
21
|
-
() => armTicks(project.name),
|
|
25
|
+
() => armTicks(project.name, { progress: (line) => process.stdout.write(`${line}\n`) }),
|
|
22
26
|
{ plainMessage: true },
|
|
23
27
|
);
|
|
24
28
|
process.stdout.write(
|
package/src/commands/context.ts
CHANGED
|
@@ -89,6 +89,7 @@ export const COMMAND_SCOPES: Readonly<Record<string, CommandScope>> = {
|
|
|
89
89
|
intake: "project",
|
|
90
90
|
ledger: "project",
|
|
91
91
|
message: "project",
|
|
92
|
+
"release-composition": "project",
|
|
92
93
|
report: "project",
|
|
93
94
|
setup: "project", // the wizard and `setup graph` configure one project
|
|
94
95
|
stats: "project",
|
|
@@ -106,6 +107,7 @@ export const COMMAND_SCOPES: Readonly<Record<string, CommandScope>> = {
|
|
|
106
107
|
upgrade: "host",
|
|
107
108
|
"upgrade-install": "host",
|
|
108
109
|
"upgrade-rollback": "host",
|
|
110
|
+
"reconcile-units": "host",
|
|
109
111
|
// fleet — every project by default, --project narrows
|
|
110
112
|
arm: "fleet",
|
|
111
113
|
board: "fleet",
|
package/src/commands/message.ts
CHANGED
|
@@ -101,6 +101,28 @@ export async function messageCommand(ctx: CommandContext): Promise<void> {
|
|
|
101
101
|
);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* A hold reaches nobody now, so it is the wrong path for an answer somebody is
|
|
106
|
+
* waiting for (#882).
|
|
107
|
+
*
|
|
108
|
+
* 2026-08-21T20:48Z: a tick was interrupted by an operator message, the mandated
|
|
109
|
+
* targetless `telegram_send` failed, and the session fell back to this command.
|
|
110
|
+
* It behaved correctly — the category was non-bypass, so the answer was held for
|
|
111
|
+
* the 23:30 digest — and the operator sat waiting in the topic they had written
|
|
112
|
+
* in. "nothing was sent" was already printed and was already true; what was
|
|
113
|
+
* missing is that the caller is *mid-conversation* and a live path exists.
|
|
114
|
+
*
|
|
115
|
+
* So every hold names it. Conditionally, because this process cannot know
|
|
116
|
+
* whether an inbound message is waiting: it is a separate process from the
|
|
117
|
+
* session, and inventing a guess here would either nag every legitimate
|
|
118
|
+
* digest-only note or, worse, quietly bypass availability policy on a hunch.
|
|
119
|
+
* The caller knows, and one sentence is enough to make holding a choice rather
|
|
120
|
+
* than a default.
|
|
121
|
+
*/
|
|
122
|
+
const LIVE_REPLY_HINT =
|
|
123
|
+
"if you are answering an operator who is waiting now, this is not the path: " +
|
|
124
|
+
"reply in their own topic with telegram_send (telegram_ask for a choice) — a held notice reaches nobody until then\n";
|
|
125
|
+
|
|
104
126
|
/** The held-notice line names the future that releases it, in words an
|
|
105
127
|
* operator can act on: the working-hours window opening, or the daily digest.
|
|
106
128
|
* The earlier spelling — "the next digest or working-hours catch-up" — read
|
|
@@ -118,7 +140,8 @@ function heldLine(
|
|
|
118
140
|
return (
|
|
119
141
|
`held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
|
|
120
142
|
`held until the working-hours window opens${opening})\n` +
|
|
121
|
-
"nothing was sent; the daemon releases it with the working-hours catch-up when your window opens\n"
|
|
143
|
+
"nothing was sent; the daemon releases it with the working-hours catch-up when your window opens\n" +
|
|
144
|
+
LIVE_REPLY_HINT
|
|
122
145
|
);
|
|
123
146
|
}
|
|
124
147
|
const digestAt = project.reporting?.digest.at;
|
|
@@ -126,6 +149,7 @@ function heldLine(
|
|
|
126
149
|
return (
|
|
127
150
|
`held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
|
|
128
151
|
`digest-only — held until ${when})\n` +
|
|
129
|
-
`nothing was sent; the daemon releases it with ${when}\n`
|
|
152
|
+
`nothing was sent; the daemon releases it with ${when}\n` +
|
|
153
|
+
LIVE_REPLY_HINT
|
|
130
154
|
);
|
|
131
155
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `reconcile-units` — install the host unit files this version renders, and
|
|
3
|
+
* nothing else (#905).
|
|
4
|
+
*
|
|
5
|
+
* Scope: `"host"`. The units are host-global (one daemon serves every
|
|
6
|
+
* configured project), so this verb takes no project and reconciles the shared
|
|
7
|
+
* destinations. The per-project tail — tick config, brief link — stays
|
|
8
|
+
* `setup host`'s.
|
|
9
|
+
*
|
|
10
|
+
* It exists as its own verb because the render is code, not data: the upgrade
|
|
11
|
+
* transaction replaces the package underneath itself, so only a freshly
|
|
12
|
+
* spawned process can render what the new release ships. `upgrade` runs this
|
|
13
|
+
* verb with `--yes` after its install phase; an operator can run it by hand
|
|
14
|
+
* any time `doctor` reports `systemd-unit` drift.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { CommandContext } from "./context.ts";
|
|
18
|
+
import { findProject, loadConfig, resolveCaps } from "../config.ts";
|
|
19
|
+
import { telegramStateDir } from "../fleet.ts";
|
|
20
|
+
import {
|
|
21
|
+
planHostRuntime,
|
|
22
|
+
RECOVER_SCRIPT_INSTALL_PATH,
|
|
23
|
+
SYSTEMD_UNIT_DIR,
|
|
24
|
+
totalConfiguredWorkers,
|
|
25
|
+
} from "../setup-host.ts";
|
|
26
|
+
import {
|
|
27
|
+
formatUnitReconcile,
|
|
28
|
+
planUnitReconcile,
|
|
29
|
+
reconcileUnits,
|
|
30
|
+
UNIT_RECONCILE_ANSWER_KEY,
|
|
31
|
+
} from "../unit-reconcile.ts";
|
|
32
|
+
import { answersUi } from "../setup-answers.ts";
|
|
33
|
+
import { terminalUi } from "../wizard-ui.ts";
|
|
34
|
+
|
|
35
|
+
/** Repeatable `--protect PATH`: destinations this run must not overwrite. */
|
|
36
|
+
function protectedPaths(argv: readonly string[]): string[] {
|
|
37
|
+
const paths: string[] = [];
|
|
38
|
+
for (const [index, arg] of argv.entries()) {
|
|
39
|
+
if (arg === "--protect") {
|
|
40
|
+
const value = argv[index + 1];
|
|
41
|
+
if (value !== undefined && !value.startsWith("--")) paths.push(value);
|
|
42
|
+
} else if (arg.startsWith("--protect=")) {
|
|
43
|
+
paths.push(arg.slice("--protect=".length));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return paths;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function reconcileUnitsCommand(ctx: CommandContext): Promise<void> {
|
|
50
|
+
const cfg = loadConfig();
|
|
51
|
+
// The units are host-global. A `--project` narrows only the per-project tail
|
|
52
|
+
// this verb deliberately does not write, so it is accepted (the recovery
|
|
53
|
+
// unit encodes one project's name on a single-project host) and ignored
|
|
54
|
+
// otherwise.
|
|
55
|
+
const project = ctx.projectFlag === undefined && cfg.projects.length !== 1
|
|
56
|
+
? undefined
|
|
57
|
+
: findProject(cfg, ctx.projectFlag);
|
|
58
|
+
const plan = planHostRuntime(
|
|
59
|
+
project,
|
|
60
|
+
project === undefined ? cfg.defaults : resolveCaps(project, cfg.defaults),
|
|
61
|
+
telegramStateDir(),
|
|
62
|
+
undefined,
|
|
63
|
+
totalConfiguredWorkers(cfg),
|
|
64
|
+
SYSTEMD_UNIT_DIR,
|
|
65
|
+
RECOVER_SCRIPT_INSTALL_PATH,
|
|
66
|
+
cfg.projects.length > 1,
|
|
67
|
+
);
|
|
68
|
+
const protect = protectedPaths(ctx.argv);
|
|
69
|
+
// `--no-refresh` retires without refreshing (#895). The upgrade transaction
|
|
70
|
+
// uses it when it could not read a pre-install drift baseline: there a
|
|
71
|
+
// refresh cannot tell the operator's own edits from the upgrade's, but the
|
|
72
|
+
// retirement is not a comparison against any render and must still happen —
|
|
73
|
+
// otherwise a host that cannot be baselined keeps its obsolete mount forever.
|
|
74
|
+
const refresh = ctx.argv.includes("--no-refresh") === false;
|
|
75
|
+
|
|
76
|
+
if (ctx.argv.includes("--dry-run")) {
|
|
77
|
+
const preview = planUnitReconcile(plan, protect, refresh);
|
|
78
|
+
process.stdout.write(
|
|
79
|
+
preview.steps.length === 0
|
|
80
|
+
? `${formatUnitReconcile({ kind: "current", protectedDrift: preview.protectedDrift, outOfScope: preview.outOfScope })}\n`
|
|
81
|
+
: `${[
|
|
82
|
+
"would reconcile:",
|
|
83
|
+
...preview.retire.map((path) => ` retire ${path}`),
|
|
84
|
+
...preview.refresh.map((d) => ` ${d.installed} (from ${d.source})`),
|
|
85
|
+
...preview.steps.map((s) => ` $ ${s.argv.join(" ")}`),
|
|
86
|
+
].join("\n")}\n`,
|
|
87
|
+
);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// `--yes` is the non-interactive consent the upgrade transaction carries:
|
|
92
|
+
// its operator already authorised the upgrade, and the detached installer
|
|
93
|
+
// has no terminal to prompt on. Interactive runs get the real confirm.
|
|
94
|
+
const ui = ctx.argv.includes("--yes")
|
|
95
|
+
? answersUi({ [UNIT_RECONCILE_ANSWER_KEY]: true })
|
|
96
|
+
: terminalUi();
|
|
97
|
+
try {
|
|
98
|
+
const outcome = await reconcileUnits(plan, ui, { protect, refresh });
|
|
99
|
+
process.stdout.write(`${formatUnitReconcile(outcome)}\n`);
|
|
100
|
+
if (outcome.kind === "failed") process.exitCode = 1;
|
|
101
|
+
} finally {
|
|
102
|
+
ui.close();
|
|
103
|
+
}
|
|
104
|
+
}
|