omp-conductor 0.18.2 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +377 -20
- package/src/failure-class.ts +59 -0
- package/src/fleet.ts +497 -15
- 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 +321 -1155
- 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 +299 -12
- 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/orchestrator-tick.ts
CHANGED
|
@@ -98,8 +98,15 @@ import {
|
|
|
98
98
|
import { repoSlugFor } from "./gitops.ts";
|
|
99
99
|
import { makeTracker } from "./tracker/github.ts";
|
|
100
100
|
import { formatDecisionDigest } from "./decisions.ts";
|
|
101
|
+
import { installSurfaceMismatch } from "./status-render.ts";
|
|
101
102
|
import {
|
|
102
103
|
ASK_TOOL,
|
|
104
|
+
type AskDeliveryResult,
|
|
105
|
+
MAX_QUESTIONNAIRE_ITEMS,
|
|
106
|
+
QUESTIONNAIRE_TOOL,
|
|
107
|
+
parseQuestionnaireRequest,
|
|
108
|
+
performQuestionnaire,
|
|
109
|
+
questionnaireParameterSchema,
|
|
103
110
|
askAnswerRowWrite,
|
|
104
111
|
askParameterSchema,
|
|
105
112
|
DEFAULT_ASK_TIMEOUT_SECONDS,
|
|
@@ -121,9 +128,12 @@ import { dbPath, openStore } from "./store.ts";
|
|
|
121
128
|
import { digestDue, localDayKey } from "./digest-schedule.ts";
|
|
122
129
|
import {
|
|
123
130
|
parseToSpecEvidence,
|
|
131
|
+
parseToSpecFailureEvidence,
|
|
124
132
|
recordToSpecGrooming,
|
|
125
133
|
TO_SPEC_MAX_SOURCE_AGE_MS,
|
|
126
134
|
TO_SPEC_SCHEMA,
|
|
135
|
+
type ToSpecFailure,
|
|
136
|
+
type ToSpecResult,
|
|
127
137
|
} from "./to-spec.ts";
|
|
128
138
|
import { heldNoticeId } from "./notices.ts";
|
|
129
139
|
import { acknowledgeArmReply } from "./arm-challenge.ts";
|
|
@@ -681,12 +691,17 @@ export function queueDigestLine(
|
|
|
681
691
|
groomBelow: number,
|
|
682
692
|
grooming: readonly GroomingRecord[] = [],
|
|
683
693
|
queue: QueueObservation | undefined = undefined,
|
|
694
|
+
/** Observation time for the durability of the grooming rows this line
|
|
695
|
+
* describes — the same clock the selection is offered against, so the
|
|
696
|
+
* inventory and the batch cannot disagree about what is still groomed
|
|
697
|
+
* (#887). */
|
|
698
|
+
now: number = Date.now(),
|
|
684
699
|
): string | undefined {
|
|
685
700
|
if (summary === undefined) return undefined;
|
|
686
701
|
if (queue !== undefined) {
|
|
687
|
-
return liveQueueDigestLine(summary, queue, queueLabel, labelPrefix, groomBelow, grooming);
|
|
702
|
+
return liveQueueDigestLine(summary, queue, queueLabel, labelPrefix, groomBelow, grooming, now);
|
|
688
703
|
}
|
|
689
|
-
return datedQueueDigestLine(summary, queueLabel, labelPrefix, groomBelow, grooming);
|
|
704
|
+
return datedQueueDigestLine(summary, queueLabel, labelPrefix, groomBelow, grooming, now);
|
|
690
705
|
}
|
|
691
706
|
|
|
692
707
|
/** The dated rendering: the last dispatch row is the only evidence, so every
|
|
@@ -697,6 +712,7 @@ function datedQueueDigestLine(
|
|
|
697
712
|
labelPrefix: string,
|
|
698
713
|
groomBelow: number,
|
|
699
714
|
grooming: readonly GroomingRecord[],
|
|
715
|
+
now: number,
|
|
700
716
|
): string | undefined {
|
|
701
717
|
const dated = new Date(summary.completedAt).toISOString();
|
|
702
718
|
if (summary.ready === 0) {
|
|
@@ -735,7 +751,7 @@ function datedQueueDigestLine(
|
|
|
735
751
|
return line;
|
|
736
752
|
}
|
|
737
753
|
if (summary.routed >= groomBelow) return undefined;
|
|
738
|
-
return lowQueueTail(summary, groomBelow, grooming, `As of the last dispatch (${dated}): Queue:
|
|
754
|
+
return lowQueueTail(summary, groomBelow, grooming, `As of the last dispatch (${dated}): Queue: `, now);
|
|
739
755
|
}
|
|
740
756
|
|
|
741
757
|
/** The live rendering: the tracker observation is the queue label inventory,
|
|
@@ -752,6 +768,7 @@ function liveQueueDigestLine(
|
|
|
752
768
|
labelPrefix: string,
|
|
753
769
|
groomBelow: number,
|
|
754
770
|
grooming: readonly GroomingRecord[],
|
|
771
|
+
now: number,
|
|
755
772
|
): string | undefined {
|
|
756
773
|
const queued = queue.queued;
|
|
757
774
|
const observed = new Date(queue.observedAt).toISOString();
|
|
@@ -808,7 +825,7 @@ function liveQueueDigestLine(
|
|
|
808
825
|
// low-queue diagnostics the dated path ships — the claimable/known-blocked
|
|
809
826
|
// split, in-flight to-spec batches, the considered backlog and this pass's
|
|
810
827
|
// holds (#735, #777, #679) — so a tracker read never hides them.
|
|
811
|
-
return inventory + lowQueueTail(summary, groomBelow, grooming, "");
|
|
828
|
+
return inventory + lowQueueTail(summary, groomBelow, grooming, "", now);
|
|
812
829
|
}
|
|
813
830
|
|
|
814
831
|
/** The shared low-queue diagnostic tail (#735/#777/#679): the routable-count →
|
|
@@ -822,6 +839,9 @@ function lowQueueTail(
|
|
|
822
839
|
groomBelow: number,
|
|
823
840
|
grooming: readonly GroomingRecord[],
|
|
824
841
|
lead: string,
|
|
842
|
+
/** The clock the durability of each verdict is judged against — the same
|
|
843
|
+
* one the batch offer uses (#887). */
|
|
844
|
+
now: number,
|
|
825
845
|
): string {
|
|
826
846
|
// The durable per-issue verdicts, not this pass's one-shot hold groups: a
|
|
827
847
|
// lane-blocked runway must read as "cannot move" even after a restart, and
|
|
@@ -853,9 +873,18 @@ function lowQueueTail(
|
|
|
853
873
|
tail = `${lead}running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
|
|
854
874
|
}
|
|
855
875
|
if (considered.length > 0) {
|
|
876
|
+
// "Already considered" is not "will not be re-groomed": selection only
|
|
877
|
+
// withholds a candidate whose verdict is still durable, so the inventory
|
|
878
|
+
// says which of the two each row is. Claiming "never re-groom these" over
|
|
879
|
+
// rows the mechanical selection was simultaneously offering is the #887
|
|
880
|
+
// defect — the same predicate now answers both.
|
|
881
|
+
const durable = considered.filter((g) => toSpecDurableVerdict(g, now) !== undefined);
|
|
882
|
+
const regroomable = considered.length - durable.length;
|
|
856
883
|
tail +=
|
|
857
884
|
` Backlog already-considered: ${considered.length} (${groomingVerdictCounts(considered)}) — ` +
|
|
858
|
-
|
|
885
|
+
`${durable.length} still durable (never re-groom these), ${regroomable} re-groomable ` +
|
|
886
|
+
"(no readable to-spec source, or observed past the freshness ceiling). " +
|
|
887
|
+
"Promote the promotable or groom new issues.";
|
|
859
888
|
}
|
|
860
889
|
if (summary.admitted === 0 && summary.holds.length > 0) {
|
|
861
890
|
const held = summary.holds
|
|
@@ -948,6 +977,51 @@ export const TO_SPEC_IN_FLIGHT_REASON = "in-flight";
|
|
|
948
977
|
*/
|
|
949
978
|
export const TO_SPEC_IN_FLIGHT_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
950
979
|
|
|
980
|
+
/**
|
|
981
|
+
* How long a refused pass parks its candidate before another batch may be
|
|
982
|
+
* spent on it. Deliberately the same 24h number as the source-freshness
|
|
983
|
+
* ceiling and the in-flight TTL — one granularity for this whole lifecycle,
|
|
984
|
+
* not a third threshold to keep in sync: within that window neither the
|
|
985
|
+
* authoritative source nor the issue has produced new evidence, so a retry
|
|
986
|
+
* re-runs the identical prompt and refuses the identical way.
|
|
987
|
+
*
|
|
988
|
+
* Without it, a candidate whose delegated pass returns malformed,
|
|
989
|
+
* source-less or stale output is immediately eligible again, so every
|
|
990
|
+
* low-queue tick spends a full delegated batch re-grooming it — measured on
|
|
991
|
+
* this fleet as five permanently-refused rows (#295, #296, #297, #679, #806)
|
|
992
|
+
* re-offered on every pass, and as #807 groomed twice seven minutes apart
|
|
993
|
+
* (#887).
|
|
994
|
+
*/
|
|
995
|
+
export const TO_SPEC_REFUSED_RETRY_COOLDOWN_MS = TO_SPEC_MAX_SOURCE_AGE_MS;
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* The one durability rule for a grooming row: the validated to-spec result it
|
|
999
|
+
* carries when that result is still fresh, or `undefined` when the row is not
|
|
1000
|
+
* durable grooming at all (no to-spec payload — a hand-edited or pre-#772
|
|
1001
|
+
* row — or a source observed past the freshness ceiling).
|
|
1002
|
+
*
|
|
1003
|
+
* Every reader of "is this issue already groomed?" MUST go through this:
|
|
1004
|
+
* {@link toSpecCandidateExclusion} (selection and the `tool_call` gate), the
|
|
1005
|
+
* launch block's `already-groomed` list, and the queue digest's
|
|
1006
|
+
* already-considered inventory. Two readers with two predicates is exactly
|
|
1007
|
+
* the #887 defect — the digest told the orchestrator "never re-groom these"
|
|
1008
|
+
* about rows the mechanical selection was simultaneously offering.
|
|
1009
|
+
*/
|
|
1010
|
+
export function toSpecDurableVerdict(row: GroomingRecord, now: number): ToSpecResult | undefined {
|
|
1011
|
+
const result = parseToSpecEvidence(row.evidence);
|
|
1012
|
+
if (result === undefined) return undefined;
|
|
1013
|
+
return now - result.source.freshAt <= TO_SPEC_MAX_SOURCE_AGE_MS ? result : undefined;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** The refusal a row records when its pass produced nothing usable, while the
|
|
1017
|
+
* cooldown above still holds it out of a new batch; `undefined` for any
|
|
1018
|
+
* other row, including a refusal whose cooldown has expired. */
|
|
1019
|
+
export function toSpecRefusalOnCooldown(row: GroomingRecord, now: number): ToSpecFailure | undefined {
|
|
1020
|
+
const failure = parseToSpecFailureEvidence(row.evidence);
|
|
1021
|
+
if (failure === undefined) return undefined;
|
|
1022
|
+
return now - row.recordedAt <= TO_SPEC_REFUSED_RETRY_COOLDOWN_MS ? failure : undefined;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
951
1025
|
/** The first line of every batch item's `task`, in the shape the gate parses:
|
|
952
1026
|
* `to-spec candidate: <owner/repo>#<issue> — <title>`. */
|
|
953
1027
|
export const TO_SPEC_ITEM_PREFIX = "to-spec candidate:";
|
|
@@ -1036,6 +1110,12 @@ export function parseToSpecItem(task: unknown): ToSpecBatchItem | undefined {
|
|
|
1036
1110
|
* re-running it would recompute a verdict that is still valid. New source
|
|
1037
1111
|
* evidence reconsiders it: once the recorded `freshAt` crosses the ceiling
|
|
1038
1112
|
* the row no longer reads as groomed, and a fresh pass overrides it;
|
|
1113
|
+
* - a refused pass inside {@link TO_SPEC_REFUSED_RETRY_COOLDOWN_MS}: a full
|
|
1114
|
+
* delegated batch was already spent and produced nothing usable
|
|
1115
|
+
* (malformed, source-less or stale output). Retrying inside the cooldown
|
|
1116
|
+
* re-runs the identical prompt against the same source and refuses the
|
|
1117
|
+
* same way, which is how one broken candidate consumed a batch on every
|
|
1118
|
+
* low-queue tick (#887);
|
|
1039
1119
|
* - `active`: a run is in flight on the issue right now.
|
|
1040
1120
|
*/
|
|
1041
1121
|
export function toSpecCandidateExclusion(
|
|
@@ -1052,14 +1132,23 @@ export function toSpecCandidateExclusion(
|
|
|
1052
1132
|
} else if (row.reason === "file-lane" || row.reason === "depends-on") {
|
|
1053
1133
|
return `#${candidate.issue} is mechanically blocked (${row.reason}) — the hold clears by itself`;
|
|
1054
1134
|
} else {
|
|
1055
|
-
const
|
|
1056
|
-
if (
|
|
1135
|
+
const durable = toSpecDurableVerdict(row, now);
|
|
1136
|
+
if (durable !== undefined) {
|
|
1057
1137
|
return (
|
|
1058
|
-
`#${candidate.issue} was already groomed ${
|
|
1059
|
-
`${
|
|
1138
|
+
`#${candidate.issue} was already groomed ${durable.verdict} (source ${durable.source.name}@` +
|
|
1139
|
+
`${durable.source.ref}, observed ${new Date(durable.source.freshAt).toISOString()}) — re-groom only ` +
|
|
1060
1140
|
"with new source evidence"
|
|
1061
1141
|
);
|
|
1062
1142
|
}
|
|
1143
|
+
const refusal = toSpecRefusalOnCooldown(row, now);
|
|
1144
|
+
if (refusal !== undefined) {
|
|
1145
|
+
const retryAt = new Date(row.recordedAt + TO_SPEC_REFUSED_RETRY_COOLDOWN_MS).toISOString();
|
|
1146
|
+
return (
|
|
1147
|
+
`#${candidate.issue} already spent a to-spec batch that was refused as ${refusal.kind} ` +
|
|
1148
|
+
`(${new Date(row.recordedAt).toISOString()}) — eligible again after ${retryAt}, or once the ` +
|
|
1149
|
+
"issue or its source changes"
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1063
1152
|
}
|
|
1064
1153
|
}
|
|
1065
1154
|
if (facts.active) return `#${candidate.issue} has a dispatched run in flight`;
|
|
@@ -1131,6 +1220,9 @@ export interface ToSpecLaunchBlock {
|
|
|
1131
1220
|
export interface ToSpecLaunchExclusions {
|
|
1132
1221
|
/** Candidates with a fresh, valid to-spec verdict already on the grooming table. */
|
|
1133
1222
|
groomed: string[];
|
|
1223
|
+
/** Candidates whose last pass was refused and whose retry cooldown still
|
|
1224
|
+
* holds ({@link TO_SPEC_REFUSED_RETRY_COOLDOWN_MS}). */
|
|
1225
|
+
refused: string[];
|
|
1134
1226
|
/** Candidates with an active to-spec batch. */
|
|
1135
1227
|
inFlight: string[];
|
|
1136
1228
|
/** Candidates under admission's durable lane/dependency holds. */
|
|
@@ -1321,17 +1413,35 @@ export function toSpecLaunchBlock(input: {
|
|
|
1321
1413
|
if (input.summary === undefined) return undefined;
|
|
1322
1414
|
if (input.summary.routed >= input.groomBelow) return undefined;
|
|
1323
1415
|
if (input.selected.length === 0) return undefined;
|
|
1324
|
-
const exclusions: ToSpecLaunchExclusions = {
|
|
1416
|
+
const exclusions: ToSpecLaunchExclusions = {
|
|
1417
|
+
groomed: [],
|
|
1418
|
+
refused: [],
|
|
1419
|
+
inFlight: [],
|
|
1420
|
+
mechanicallyBlocked: [],
|
|
1421
|
+
dispatched: [],
|
|
1422
|
+
};
|
|
1325
1423
|
for (const row of input.grooming) {
|
|
1326
1424
|
if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
|
|
1327
1425
|
if (input.now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) exclusions.inFlight.push(`#${row.issue}`);
|
|
1328
1426
|
} else if (row.reason === "file-lane" || row.reason === "depends-on") {
|
|
1329
1427
|
exclusions.mechanicallyBlocked.push(`#${row.issue} (${row.reason})`);
|
|
1330
1428
|
} else {
|
|
1331
|
-
|
|
1332
|
-
|
|
1429
|
+
// The same two predicates the selection and the gate apply, so a
|
|
1430
|
+
// candidate this block advertises as excluded is one selection actually
|
|
1431
|
+
// withheld — and one it does not advertise is one selection may offer
|
|
1432
|
+
// (#887).
|
|
1433
|
+
const durable = toSpecDurableVerdict(row, input.now);
|
|
1434
|
+
if (durable !== undefined) {
|
|
1333
1435
|
exclusions.groomed.push(
|
|
1334
|
-
`#${row.issue} (${
|
|
1436
|
+
`#${row.issue} (${durable.verdict} @ ${durable.source.ref}, observed ${new Date(durable.source.freshAt).toISOString()})`,
|
|
1437
|
+
);
|
|
1438
|
+
continue;
|
|
1439
|
+
}
|
|
1440
|
+
const refusal = toSpecRefusalOnCooldown(row, input.now);
|
|
1441
|
+
if (refusal !== undefined) {
|
|
1442
|
+
exclusions.refused.push(
|
|
1443
|
+
`#${row.issue} (${refusal.kind}, refused ${new Date(row.recordedAt).toISOString()}, retry after ` +
|
|
1444
|
+
`${new Date(row.recordedAt + TO_SPEC_REFUSED_RETRY_COOLDOWN_MS).toISOString()})`,
|
|
1335
1445
|
);
|
|
1336
1446
|
}
|
|
1337
1447
|
}
|
|
@@ -1354,10 +1464,11 @@ export function toSpecLaunchBlock(input: {
|
|
|
1354
1464
|
"The conductor selected this batch mechanically from the live open-issue snapshot " +
|
|
1355
1465
|
`(issues carrying \`${input.queueLabel}\`, the \`${input.parkLabel}\` park label, issues without exactly one ` +
|
|
1356
1466
|
`\`${input.labelPrefix}<repo>\` routing label, parent/epic issues with sub-issues, ` +
|
|
1357
|
-
"already-groomed, in-flight, lane/dependency-blocked and dispatched candidates were excluded):",
|
|
1467
|
+
"already-groomed, refused-on-cooldown, in-flight, lane/dependency-blocked and dispatched candidates were excluded):",
|
|
1358
1468
|
candidates,
|
|
1359
1469
|
"Excluded this tick — " +
|
|
1360
1470
|
`already-groomed: ${exclusions.groomed.length === 0 ? "none" : exclusions.groomed.join(", ")}; ` +
|
|
1471
|
+
`refused, cooldown still holding: ${exclusions.refused.length === 0 ? "none" : exclusions.refused.join(", ")}; ` +
|
|
1361
1472
|
`in-flight batches — ${exclusions.inFlight.length === 0 ? "none" : exclusions.inFlight.join(", ")}; ` +
|
|
1362
1473
|
`mechanically blocked: ${exclusions.mechanicallyBlocked.length === 0 ? "none" : exclusions.mechanicallyBlocked.join(", ")}; ` +
|
|
1363
1474
|
`dispatched now: ${exclusions.dispatched.length === 0 ? "none" : exclusions.dispatched.join(", ")}.`,
|
|
@@ -1384,6 +1495,51 @@ export function toSpecLaunchBlock(input: {
|
|
|
1384
1495
|
return { token, block: lines.join("\n"), items };
|
|
1385
1496
|
}
|
|
1386
1497
|
|
|
1498
|
+
/**
|
|
1499
|
+
* The accounting a low-queue tick owes Duty 2 when the mechanical selection
|
|
1500
|
+
* produced no batch at all. Without it a fully-excluded backlog is silence,
|
|
1501
|
+
* and silence is what gets re-derived by hand: the orchestrator cannot tell
|
|
1502
|
+
* "the queue is low and nothing is groomable" from "the launch machinery did
|
|
1503
|
+
* not run". Every count comes from the same predicates the selection and the
|
|
1504
|
+
* `tool_call` gate apply (#887).
|
|
1505
|
+
*
|
|
1506
|
+
* `undefined` when no dispatch row exists or the queue is at/above the
|
|
1507
|
+
* grooming trigger — the same gate the offer itself uses, so this line and a
|
|
1508
|
+
* launch block are mutually exclusive.
|
|
1509
|
+
*/
|
|
1510
|
+
export function toSpecNoBatchLine(input: {
|
|
1511
|
+
summary: DispatchSummary | undefined;
|
|
1512
|
+
groomBelow: number;
|
|
1513
|
+
grooming: readonly GroomingRecord[];
|
|
1514
|
+
active: readonly { issue: number }[];
|
|
1515
|
+
now: number;
|
|
1516
|
+
}): string | undefined {
|
|
1517
|
+
if (input.summary === undefined) return undefined;
|
|
1518
|
+
if (input.summary.routed >= input.groomBelow) return undefined;
|
|
1519
|
+
let durable = 0;
|
|
1520
|
+
let refused = 0;
|
|
1521
|
+
let inFlight = 0;
|
|
1522
|
+
let held = 0;
|
|
1523
|
+
for (const row of input.grooming) {
|
|
1524
|
+
if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
|
|
1525
|
+
if (input.now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) inFlight += 1;
|
|
1526
|
+
} else if (row.reason === "file-lane" || row.reason === "depends-on") {
|
|
1527
|
+
held += 1;
|
|
1528
|
+
} else if (toSpecDurableVerdict(row, input.now) !== undefined) {
|
|
1529
|
+
durable += 1;
|
|
1530
|
+
} else if (toSpecRefusalOnCooldown(row, input.now) !== undefined) {
|
|
1531
|
+
refused += 1;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
return (
|
|
1535
|
+
`No to-spec batch this tick: the mechanical selection found nothing eligible in the open-issue ` +
|
|
1536
|
+
`snapshot — ${durable} durable verdict(s), ${refused} refused inside the retry cooldown, ${inFlight} ` +
|
|
1537
|
+
`in flight, ${held} lane/dependency-blocked, ${input.active.length} dispatched. A \`task\` call carrying ` +
|
|
1538
|
+
`the ${TO_SPEC_BATCH_MARKER} marker is refused this turn; file or promote from what the backlog already ` +
|
|
1539
|
+
"says instead of re-grooming it."
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1387
1543
|
export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScopeChoice]: string } = {
|
|
1388
1544
|
material: "Report material events per your brief.",
|
|
1389
1545
|
escalations:
|
|
@@ -1588,6 +1744,8 @@ export const TICK_ASK_RULE =
|
|
|
1588
1744
|
`On this locally injected tick, questions to your operator go through ${ASK_TOOL}: ` +
|
|
1589
1745
|
`call it with "on-timeout": "auto-proceed" or "park" (what to do when nobody answers within the ceiling) ` +
|
|
1590
1746
|
`and optionally "timeoutSeconds" — an ask issued without one still gets the default ceiling, capped at the turn budget. ` +
|
|
1747
|
+
`Several judgement calls about ONE issue go as a single ${QUESTIONNAIRE_TOOL} instead: one delivery, one ceiling, ` +
|
|
1748
|
+
`each item a durable row bound to that issue, resolved independently and in any order. ` +
|
|
1591
1749
|
`The ${TELEGRAM_APPROVAL_TOOL} tool is refused here: it would wait for your operator for as long as the answer ` +
|
|
1592
1750
|
`takes, and an unanswered question must never hold the loop.`;
|
|
1593
1751
|
|
|
@@ -3078,24 +3236,33 @@ function resolveAskProject(
|
|
|
3078
3236
|
* prior state, and executing it still fails closed when it cannot route.
|
|
3079
3237
|
*/
|
|
3080
3238
|
async function ensureAskSurface(pi: TickApi, resolvable: boolean): Promise<boolean> {
|
|
3239
|
+
// Both operator-question surfaces flip together (#947): they route through the
|
|
3240
|
+
// same resolution and fail closed the same way, so a beat that can carry one
|
|
3241
|
+
// can carry the other, and a degraded beat must hide both — an ask surface that
|
|
3242
|
+
// is half-present is a model choosing between a working tool and a broken one.
|
|
3243
|
+
const tools = [ASK_TOOL, QUESTIONNAIRE_TOOL];
|
|
3081
3244
|
const current = pi.getActiveTools();
|
|
3082
|
-
const present = current.includes(
|
|
3083
|
-
|
|
3245
|
+
const present = tools.every((tool) => current.includes(tool));
|
|
3246
|
+
const absent = tools.every((tool) => !current.includes(tool));
|
|
3247
|
+
if (resolvable ? present : absent) return true;
|
|
3084
3248
|
try {
|
|
3085
3249
|
await pi.setActiveTools(
|
|
3086
|
-
resolvable
|
|
3250
|
+
resolvable
|
|
3251
|
+
? [...current.filter((name) => !tools.includes(name)), ...tools]
|
|
3252
|
+
: current.filter((name) => !tools.includes(name)),
|
|
3087
3253
|
);
|
|
3088
3254
|
} catch (err) {
|
|
3089
3255
|
pi.logger.error(
|
|
3090
|
-
`[omp-conductor] could not ${resolvable ? "activate" : "deactivate"} ${
|
|
3256
|
+
`[omp-conductor] could not ${resolvable ? "activate" : "deactivate"} ${tools.join(" / ")}: ${
|
|
3091
3257
|
err instanceof Error ? err.message : String(err)
|
|
3092
3258
|
}`,
|
|
3093
3259
|
);
|
|
3094
3260
|
return false;
|
|
3095
3261
|
}
|
|
3096
3262
|
// An awaited call is not yet confirmation: the live membership read after the
|
|
3097
|
-
// reconciliation is what proves the
|
|
3098
|
-
|
|
3263
|
+
// reconciliation is what proves the tools are really model-visible.
|
|
3264
|
+
const after = pi.getActiveTools();
|
|
3265
|
+
return tools.every((tool) => after.includes(tool) === resolvable);
|
|
3099
3266
|
}
|
|
3100
3267
|
|
|
3101
3268
|
/**
|
|
@@ -3335,6 +3502,16 @@ async function tick(
|
|
|
3335
3502
|
// because the whole failure was a question surviving in context only.
|
|
3336
3503
|
const decisions = formatDecisionDigest(frictionStore.openDecisions(scope.projectName), now);
|
|
3337
3504
|
if (decisions.length > 0) content = `${content}\n${decisions}`;
|
|
3505
|
+
// A host running two different releases at once, from the row the
|
|
3506
|
+
// dispatch pass recorded (#919). Read only — probing the three surfaces
|
|
3507
|
+
// here spawns three children per tick and took this file's own suite
|
|
3508
|
+
// from 8.4s to 83.4s, which is why the observation is recorded. Only a
|
|
3509
|
+
// proven mismatch appears: an absent surface, a `local:` herdr link and
|
|
3510
|
+
// an unverifiable pin are steady states someone chose, and a warning
|
|
3511
|
+
// repeated every fifteen minutes trains an operator to ignore the line
|
|
3512
|
+
// that matters. `doctor` keeps that nuance.
|
|
3513
|
+
const surfaces = installSurfaceMismatch(frictionStore.installSurfaces());
|
|
3514
|
+
if (surfaces !== undefined) content = `${content}\n${surfaces}`;
|
|
3338
3515
|
// What the daemon already fixed, so the session stops re-deriving that
|
|
3339
3516
|
// paragraph on every tick (#132). Two intervals wide rather than one: a
|
|
3340
3517
|
// tick that ran long must not drop the window it was meant to report.
|
|
@@ -3414,6 +3591,10 @@ async function tick(
|
|
|
3414
3591
|
groomBelow,
|
|
3415
3592
|
grooming,
|
|
3416
3593
|
queueObservation,
|
|
3594
|
+
// One clock for the inventory and the offer below: the digest must
|
|
3595
|
+
// never call a verdict durable that the same tick's selection is
|
|
3596
|
+
// about to re-groom (#887).
|
|
3597
|
+
now,
|
|
3417
3598
|
);
|
|
3418
3599
|
if (queue !== undefined) content = `${content}\n${queue}`;
|
|
3419
3600
|
// #777: the mechanical to-spec launch boundary. The queue digest is
|
|
@@ -3427,11 +3608,12 @@ async function tick(
|
|
|
3427
3608
|
//
|
|
3428
3609
|
// Every tick owns its authorization fresh — the clears above ran
|
|
3429
3610
|
// before the reads, so the offer below can only mint for THIS tick.
|
|
3611
|
+
const toSpecActive = store.activeRuns(projectName);
|
|
3430
3612
|
const launch = await offerToSpecLaunch({
|
|
3431
3613
|
summary: dispatch,
|
|
3432
3614
|
groomBelow,
|
|
3433
3615
|
grooming,
|
|
3434
|
-
active:
|
|
3616
|
+
active: toSpecActive,
|
|
3435
3617
|
issues: openSnapshot,
|
|
3436
3618
|
project,
|
|
3437
3619
|
trackerSeam: toSpecTrackerSeam,
|
|
@@ -3446,6 +3628,19 @@ async function tick(
|
|
|
3446
3628
|
session.launchProject = project.name;
|
|
3447
3629
|
session.launchToken = launch.token;
|
|
3448
3630
|
session.launchItems = launch.items;
|
|
3631
|
+
} else if (openSnapshot !== undefined) {
|
|
3632
|
+
// A low queue with nothing eligible is a finding, not silence: the
|
|
3633
|
+
// snapshot read succeeded, so the exclusions — and only they — are
|
|
3634
|
+
// why no batch is offered. Rendered only when the read succeeded,
|
|
3635
|
+
// so a tracker failure never masquerades as "nothing eligible".
|
|
3636
|
+
const noBatch = toSpecNoBatchLine({
|
|
3637
|
+
summary: dispatch,
|
|
3638
|
+
groomBelow,
|
|
3639
|
+
grooming,
|
|
3640
|
+
active: toSpecActive,
|
|
3641
|
+
now,
|
|
3642
|
+
});
|
|
3643
|
+
if (noBatch !== undefined) content = `${content}\n${noBatch}`;
|
|
3449
3644
|
}
|
|
3450
3645
|
// Pending intake is the same class of standing block as the friction
|
|
3451
3646
|
// and decisions read-outs: a store-backed duty the orchestrator must
|
|
@@ -4300,6 +4495,71 @@ export default function orchestratorTickExtension(
|
|
|
4300
4495
|
// the project to file the decision row against and to resolve the delivery
|
|
4301
4496
|
// target. An unreadable/ambiguous config makes the tool say so and record
|
|
4302
4497
|
// nothing, which is the same fail-closed posture the autonomous gate takes.
|
|
4498
|
+
/**
|
|
4499
|
+
* The plumbing both operator-question tools share (#947): which project this
|
|
4500
|
+
* call records against, and the one sanctioned delivery path.
|
|
4501
|
+
*
|
|
4502
|
+
* Extracted rather than copied, because the questionnaire needs exactly the
|
|
4503
|
+
* live-config routing, the fail-closed refusals and the held-notice fallback
|
|
4504
|
+
* the single ask already got right — and a second copy of the fallback is how
|
|
4505
|
+
* one surface quietly starts dropping questions the other one holds.
|
|
4506
|
+
*/
|
|
4507
|
+
const resolveOperatorAskContext = (
|
|
4508
|
+
tool: string,
|
|
4509
|
+
): { ok: true; project: ProjectConfig; config: TickConfig } | { ok: false; text: string } => {
|
|
4510
|
+
const session = askSession;
|
|
4511
|
+
if (session === undefined) {
|
|
4512
|
+
return {
|
|
4513
|
+
ok: false,
|
|
4514
|
+
text: `${tool}: not available in this session (no orchestrator tick); nothing was asked or recorded.`,
|
|
4515
|
+
};
|
|
4516
|
+
}
|
|
4517
|
+
const routed = resolveAskProject(session.cwd, session.config);
|
|
4518
|
+
if (routed.kind === "error") {
|
|
4519
|
+
return {
|
|
4520
|
+
ok: false,
|
|
4521
|
+
text:
|
|
4522
|
+
`${tool}: conductor config unreadable (${routed.problem}); ` +
|
|
4523
|
+
"nothing was asked or recorded. Repair the config, do not ask through another path.",
|
|
4524
|
+
};
|
|
4525
|
+
}
|
|
4526
|
+
return { ok: true, project: routed.project, config: session.config };
|
|
4527
|
+
};
|
|
4528
|
+
|
|
4529
|
+
const operatorAskDelivery =
|
|
4530
|
+
(tool: string, projectConfig: ProjectConfig, store: Store) =>
|
|
4531
|
+
async (text: string, category: InterruptCategory): Promise<AskDeliveryResult> => {
|
|
4532
|
+
const at = Date.now();
|
|
4533
|
+
const noticeId = randomUUID();
|
|
4534
|
+
try {
|
|
4535
|
+
const delivered = await deliverOperatorMessage(projectConfig, text, {
|
|
4536
|
+
store,
|
|
4537
|
+
at,
|
|
4538
|
+
noticeId,
|
|
4539
|
+
category,
|
|
4540
|
+
});
|
|
4541
|
+
return delivered.kind === "sent"
|
|
4542
|
+
? { kind: "sent", category: delivered.category }
|
|
4543
|
+
: { kind: "held", category: delivered.category, noticeId: delivered.noticeId };
|
|
4544
|
+
} catch (err) {
|
|
4545
|
+
// A failed immediate send must not drop the question: fall back to the
|
|
4546
|
+
// durable hold exactly like the gate's own hold path, and let the daemon
|
|
4547
|
+
// retry with the digest.
|
|
4548
|
+
store.addHeldNotice({
|
|
4549
|
+
id: noticeId,
|
|
4550
|
+
project: projectConfig.name,
|
|
4551
|
+
category,
|
|
4552
|
+
summary: text.split("\n", 1)[0]!.slice(0, 240),
|
|
4553
|
+
detail: text,
|
|
4554
|
+
createdAt: at,
|
|
4555
|
+
});
|
|
4556
|
+
pi.logger.error(
|
|
4557
|
+
`[omp-conductor] ${tool} could not deliver the ask directly (${err instanceof Error ? err.message : String(err)}); held durably`,
|
|
4558
|
+
);
|
|
4559
|
+
return { kind: "held", category, noticeId };
|
|
4560
|
+
}
|
|
4561
|
+
};
|
|
4562
|
+
|
|
4303
4563
|
pi.registerTool({
|
|
4304
4564
|
name: ASK_TOOL,
|
|
4305
4565
|
label: ASK_TOOL,
|
|
@@ -4328,43 +4588,16 @@ export default function orchestratorTickExtension(
|
|
|
4328
4588
|
if (!parsed.ok) {
|
|
4329
4589
|
return { content: [{ type: "text", text: parsed.problem }], isError: true };
|
|
4330
4590
|
}
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
{
|
|
4339
|
-
type: "text",
|
|
4340
|
-
text: `${ASK_TOOL}: not available in this session (no orchestrator tick); nothing was asked or recorded.`,
|
|
4341
|
-
},
|
|
4342
|
-
],
|
|
4343
|
-
isError: true,
|
|
4344
|
-
};
|
|
4591
|
+
// Routing follows the *live* tick config, not the session-start stamp: a
|
|
4592
|
+
// restamp (un-stamped → stamped, or project A → B) must make the next
|
|
4593
|
+
// tick's toolbox land on the project the turn actually ticks for. Only the
|
|
4594
|
+
// ceiling stays startup-only — routing is re-read every call.
|
|
4595
|
+
const context = resolveOperatorAskContext(ASK_TOOL);
|
|
4596
|
+
if (!context.ok) {
|
|
4597
|
+
return { content: [{ type: "text", text: context.text }], isError: true };
|
|
4345
4598
|
}
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
// tick's toolbox land on the project the turn actually ticks for, and an
|
|
4349
|
-
// abandoned stamp must not keep recording against a project that is no
|
|
4350
|
-
// longer this fleet's. Only the ceiling stays startup-only (from
|
|
4351
|
-
// `config` below) — routing is re-read every call.
|
|
4352
|
-
const { cwd, config } = session;
|
|
4353
|
-
const routed = resolveAskProject(cwd, config);
|
|
4354
|
-
if (routed.kind === "error") {
|
|
4355
|
-
return {
|
|
4356
|
-
content: [
|
|
4357
|
-
{
|
|
4358
|
-
type: "text",
|
|
4359
|
-
text:
|
|
4360
|
-
`${ASK_TOOL}: conductor config unreadable (${routed.problem}); ` +
|
|
4361
|
-
"nothing was asked or recorded. Repair the config, do not ask through another path.",
|
|
4362
|
-
},
|
|
4363
|
-
],
|
|
4364
|
-
isError: true,
|
|
4365
|
-
};
|
|
4366
|
-
}
|
|
4367
|
-
const projectConfig = routed.project;
|
|
4599
|
+
const projectConfig = context.project;
|
|
4600
|
+
const config = context.config;
|
|
4368
4601
|
const store = openStore(dbPath());
|
|
4369
4602
|
let result: AskResult;
|
|
4370
4603
|
try {
|
|
@@ -4380,37 +4613,7 @@ export default function orchestratorTickExtension(
|
|
|
4380
4613
|
// inject a fake through the same seam.
|
|
4381
4614
|
interactive: options.ask?.interactive ?? interactiveAskSurface({ project: projectConfig, store }),
|
|
4382
4615
|
...(options.ask === undefined ? {} : { wait: options.ask.wait, now: options.ask.now }),
|
|
4383
|
-
deliver:
|
|
4384
|
-
const at = Date.now();
|
|
4385
|
-
const noticeId = randomUUID();
|
|
4386
|
-
try {
|
|
4387
|
-
const delivered = await deliverOperatorMessage(projectConfig, text, {
|
|
4388
|
-
store,
|
|
4389
|
-
at,
|
|
4390
|
-
noticeId,
|
|
4391
|
-
category,
|
|
4392
|
-
});
|
|
4393
|
-
return delivered.kind === "sent"
|
|
4394
|
-
? { kind: "sent", category: delivered.category }
|
|
4395
|
-
: { kind: "held", category: delivered.category, noticeId: delivered.noticeId };
|
|
4396
|
-
} catch (err) {
|
|
4397
|
-
// A failed immediate send must not drop the question: fall back
|
|
4398
|
-
// to the durable hold exactly like the gate's own hold path, and
|
|
4399
|
-
// let the daemon retry with the digest.
|
|
4400
|
-
store.addHeldNotice({
|
|
4401
|
-
id: noticeId,
|
|
4402
|
-
project: projectConfig.name,
|
|
4403
|
-
category,
|
|
4404
|
-
summary: text.split("\n", 1)[0]!.slice(0, 240),
|
|
4405
|
-
detail: text,
|
|
4406
|
-
createdAt: at,
|
|
4407
|
-
});
|
|
4408
|
-
pi.logger.error(
|
|
4409
|
-
`[omp-conductor] ${ASK_TOOL} could not deliver the ask directly (${err instanceof Error ? err.message : String(err)}); held durably`,
|
|
4410
|
-
);
|
|
4411
|
-
return { kind: "held", category, noticeId };
|
|
4412
|
-
}
|
|
4413
|
-
},
|
|
4616
|
+
deliver: operatorAskDelivery(ASK_TOOL, projectConfig, store),
|
|
4414
4617
|
});
|
|
4415
4618
|
} finally {
|
|
4416
4619
|
store.close();
|
|
@@ -4419,6 +4622,58 @@ export default function orchestratorTickExtension(
|
|
|
4419
4622
|
},
|
|
4420
4623
|
});
|
|
4421
4624
|
|
|
4625
|
+
// The batched spec-out questionnaire (#947): the same durable contract as the
|
|
4626
|
+
// single ask, asked once for several judgement calls about one issue. Mounted
|
|
4627
|
+
// and routed through exactly the shared helpers above, so the two surfaces
|
|
4628
|
+
// cannot come to disagree about which project a question belongs to or what
|
|
4629
|
+
// happens when delivery fails.
|
|
4630
|
+
pi.registerTool({
|
|
4631
|
+
name: QUESTIONNAIRE_TOOL,
|
|
4632
|
+
label: QUESTIONNAIRE_TOOL,
|
|
4633
|
+
defaultInactive: true,
|
|
4634
|
+
description:
|
|
4635
|
+
`Ask your operator several bounded questions about ONE issue as a single message, and wait up ` +
|
|
4636
|
+
`to one ask ceiling for the answers. Every item is recorded as its own durable decision row ` +
|
|
4637
|
+
`before anything is delivered, all bound to the issue you name in "spec-issue" — so the answers ` +
|
|
4638
|
+
`become that issue's provenance and a later reader sees why a slice is shaped the way it is. ` +
|
|
4639
|
+
`Items resolve independently and in any order: an item the operator answers keeps that answer, ` +
|
|
4640
|
+
`and at the ceiling each unanswered item takes its own declared "on-timeout" ("auto-proceed" ` +
|
|
4641
|
+
`applies its recommendation and records that nobody human chose it; "park" leaves the row open ` +
|
|
4642
|
+
`and pending, and you then park the work it blocks). Ask ONLY the judgement calls that genuinely ` +
|
|
4643
|
+
`belong to your operator — anything a repo read can answer is your own work — and at most ` +
|
|
4644
|
+
`${MAX_QUESTIONNAIRE_ITEMS} items. This path is plain text by design (one message, not ` +
|
|
4645
|
+
`${MAX_QUESTIONNAIRE_ITEMS} button posts), so a prose reply does not itself resolve a row: map ` +
|
|
4646
|
+
`it with \`omp-conductor decision resolve <id> --answer "…"\`. Use conductor_ask for a single ` +
|
|
4647
|
+
`question.`,
|
|
4648
|
+
parameters: questionnaireParameterSchema(),
|
|
4649
|
+
approval: "write",
|
|
4650
|
+
execute: async (_toolCallId, params) => {
|
|
4651
|
+
const parsed = parseQuestionnaireRequest(params);
|
|
4652
|
+
if (!parsed.ok) {
|
|
4653
|
+
return { content: [{ type: "text", text: parsed.problem }], isError: true };
|
|
4654
|
+
}
|
|
4655
|
+
const context = resolveOperatorAskContext(QUESTIONNAIRE_TOOL);
|
|
4656
|
+
if (!context.ok) {
|
|
4657
|
+
return { content: [{ type: "text", text: context.text }], isError: true };
|
|
4658
|
+
}
|
|
4659
|
+
const projectConfig = context.project;
|
|
4660
|
+
const store = openStore(dbPath());
|
|
4661
|
+
try {
|
|
4662
|
+
const result = await performQuestionnaire(parsed.request, {
|
|
4663
|
+
store,
|
|
4664
|
+
project: projectConfig.name,
|
|
4665
|
+
configuredCeilingSeconds: context.config.askTimeoutSeconds,
|
|
4666
|
+
turnBudgetSeconds: context.config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS,
|
|
4667
|
+
...(options.ask === undefined ? {} : { wait: options.ask.wait, now: options.ask.now }),
|
|
4668
|
+
deliver: operatorAskDelivery(QUESTIONNAIRE_TOOL, projectConfig, store),
|
|
4669
|
+
});
|
|
4670
|
+
return { content: [{ type: "text", text: result.text }] };
|
|
4671
|
+
} finally {
|
|
4672
|
+
store.close();
|
|
4673
|
+
}
|
|
4674
|
+
},
|
|
4675
|
+
});
|
|
4676
|
+
|
|
4422
4677
|
// The async half of the to-spec result capture (#777). Registered at
|
|
4423
4678
|
// extension-factory time like {@link ASK_TOOL}, with the same routing
|
|
4424
4679
|
// contract: the state it needs (cwd + startup config) is filled by
|