omp-conductor 0.18.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -41
- package/REFERENCE.md +866 -31
- package/agents/to-spec.md +6 -2
- package/package.json +1 -1
- package/schema/config.schema.json +32 -1
- package/src/admission.ts +212 -26
- package/src/arm-challenge.ts +250 -57
- package/src/ask.ts +288 -1
- package/src/briefs/orchestrator.md +27 -13
- package/src/briefs/to-spec.md +6 -2
- package/src/cli.ts +127 -2
- package/src/command-help.ts +9 -1
- package/src/command-manifest.ts +52 -8
- package/src/commands/arm.ts +6 -2
- package/src/commands/context.ts +2 -0
- package/src/commands/intake.ts +4 -19
- package/src/commands/message.ts +26 -2
- package/src/commands/reconcile-units.ts +104 -0
- package/src/commands/release-composition.ts +232 -0
- package/src/commands/resume.ts +2 -27
- package/src/commands/setup.ts +101 -16
- package/src/commands/stats.ts +11 -30
- package/src/commands/tail.ts +31 -1
- package/src/commands/upgrade.ts +20 -3
- package/src/commands/verb.ts +2 -1
- package/src/commands/watch.ts +4 -17
- package/src/config-schema.ts +38 -6
- package/src/config.ts +103 -8
- package/src/credential-class.ts +366 -0
- package/src/daemon.ts +1368 -529
- package/src/dashboard/app.js +504 -2
- package/src/dashboard/controls.ts +336 -0
- package/src/dashboard/index.html +30 -0
- package/src/dashboard/server.ts +271 -30
- package/src/dashboard/style.css +116 -0
- package/src/dashboard/transcript.ts +173 -0
- package/src/decisions.ts +19 -11
- package/src/doctor.ts +431 -148
- package/src/escalate.ts +22 -11
- package/src/failure-class.ts +59 -0
- package/src/fleet.ts +587 -230
- package/src/host.ts +6 -455
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +40 -56
- package/src/orchestrator-tick.ts +564 -121
- package/src/pause.ts +233 -0
- package/src/session-host.ts +6 -41
- package/src/settlement.ts +159 -2
- package/src/setup-answers.ts +97 -0
- package/src/setup-host.ts +343 -1160
- package/src/setup-install.ts +204 -27
- package/src/setup-wizard.ts +252 -51
- package/src/setup.ts +87 -4
- package/src/spend-telemetry.ts +117 -0
- package/src/stats.ts +35 -0
- package/src/status-render.ts +485 -19
- package/src/store.ts +1229 -55
- package/src/telegram-freshness.ts +269 -0
- package/src/to-spec.ts +50 -2
- package/src/types.ts +759 -10
- package/src/unblock.ts +22 -0
- package/src/unit-reconcile.ts +303 -0
- package/src/upgrade-verify.ts +8 -1
- package/src/upgrade.ts +299 -12
- package/src/verbs/actions.ts +124 -10
- package/src/verbs/protocol.ts +70 -2
- package/src/verbs/server.ts +485 -11
- package/src/wake.ts +48 -0
- package/src/worker.ts +401 -14
package/src/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,
|
|
@@ -114,18 +121,22 @@ import {
|
|
|
114
121
|
type AskResult,
|
|
115
122
|
} from "./ask.ts";
|
|
116
123
|
import { deliverOperatorMessage } from "./reports.ts";
|
|
124
|
+
import { effectiveLabels } from "./routing.ts";
|
|
117
125
|
import { readTelegramToken, resolveProjectTopicId, telegramStateDir } from "./escalate.ts";
|
|
118
126
|
import type { FailureClass, RecoveryAction, RunRecord } from "./types.ts";
|
|
119
127
|
import { dbPath, openStore } from "./store.ts";
|
|
120
128
|
import { digestDue, localDayKey } from "./digest-schedule.ts";
|
|
121
129
|
import {
|
|
122
130
|
parseToSpecEvidence,
|
|
131
|
+
parseToSpecFailureEvidence,
|
|
123
132
|
recordToSpecGrooming,
|
|
124
133
|
TO_SPEC_MAX_SOURCE_AGE_MS,
|
|
125
134
|
TO_SPEC_SCHEMA,
|
|
135
|
+
type ToSpecFailure,
|
|
136
|
+
type ToSpecResult,
|
|
126
137
|
} from "./to-spec.ts";
|
|
127
138
|
import { heldNoticeId } from "./notices.ts";
|
|
128
|
-
import {
|
|
139
|
+
import { acknowledgeArmReply } from "./arm-challenge.ts";
|
|
129
140
|
|
|
130
141
|
/** The activation file. Absent means "this is not an orchestrator session". */
|
|
131
142
|
export const TICK_CONFIG_FILE = ".conductor-tick.json";
|
|
@@ -639,6 +650,21 @@ function groomingVerdictCounts(records: readonly GroomingRecord[]): string {
|
|
|
639
650
|
return [...counts.entries()].map(([verdict, count]) => `${verdict} ${count}`).join(", ");
|
|
640
651
|
}
|
|
641
652
|
|
|
653
|
+
/**
|
|
654
|
+
* The queue-label inventory read fresh from the tracker on one tick, with the
|
|
655
|
+
* pending label ops applied exactly as the dispatch pass judges eligibility
|
|
656
|
+
* (#848). Queue verdicts in the present tense ("empty", "running low") are
|
|
657
|
+
* only ever drawn from this observation; a dispatch summary alone is dated
|
|
658
|
+
* history and must never be rendered as the live queue.
|
|
659
|
+
*/
|
|
660
|
+
export interface QueueObservation {
|
|
661
|
+
/** Open issues carrying the queue label under the *effective* label set —
|
|
662
|
+
* the count dispatch would route against on its next pass. */
|
|
663
|
+
queued: number;
|
|
664
|
+
/** The instant the tracker observation was made, epoch ms. */
|
|
665
|
+
observedAt: number;
|
|
666
|
+
}
|
|
667
|
+
|
|
642
668
|
/**
|
|
643
669
|
* One line telling the orchestrator the routable queue is running dry (#181),
|
|
644
670
|
* or `undefined` when healthy — no dispatch recorded yet, or the routable count
|
|
@@ -651,6 +677,12 @@ function groomingVerdictCounts(records: readonly GroomingRecord[]): string {
|
|
|
651
677
|
* so it is told apart from the mechanical holds: it says "a batch is running",
|
|
652
678
|
* not "the lane cannot move", and counts neither as claimable nor as
|
|
653
679
|
* known-blocked.
|
|
680
|
+
*
|
|
681
|
+
* `queue` is the tracker observation the caller made THIS tick (#848). When
|
|
682
|
+
* present, the queue verdict reads in the present tense from that inventory,
|
|
683
|
+
* and dispatch-derived counts are only ever dated context. When absent the
|
|
684
|
+
* tracker could not be read, and this never asserts that the queue is empty:
|
|
685
|
+
* the dispatch row is rendered explicitly as-of instead.
|
|
654
686
|
*/
|
|
655
687
|
export function queueDigestLine(
|
|
656
688
|
summary: DispatchSummary | undefined,
|
|
@@ -658,10 +690,36 @@ export function queueDigestLine(
|
|
|
658
690
|
labelPrefix: string,
|
|
659
691
|
groomBelow: number,
|
|
660
692
|
grooming: readonly GroomingRecord[] = [],
|
|
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(),
|
|
661
699
|
): string | undefined {
|
|
662
700
|
if (summary === undefined) return undefined;
|
|
701
|
+
if (queue !== undefined) {
|
|
702
|
+
return liveQueueDigestLine(summary, queue, queueLabel, labelPrefix, groomBelow, grooming, now);
|
|
703
|
+
}
|
|
704
|
+
return datedQueueDigestLine(summary, queueLabel, labelPrefix, groomBelow, grooming, now);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** The dated rendering: the last dispatch row is the only evidence, so every
|
|
708
|
+
* count carries its snapshot date and nothing is asserted as current. */
|
|
709
|
+
function datedQueueDigestLine(
|
|
710
|
+
summary: DispatchSummary,
|
|
711
|
+
queueLabel: string,
|
|
712
|
+
labelPrefix: string,
|
|
713
|
+
groomBelow: number,
|
|
714
|
+
grooming: readonly GroomingRecord[],
|
|
715
|
+
now: number,
|
|
716
|
+
): string | undefined {
|
|
717
|
+
const dated = new Date(summary.completedAt).toISOString();
|
|
663
718
|
if (summary.ready === 0) {
|
|
664
|
-
|
|
719
|
+
if (summary.paused === true) {
|
|
720
|
+
return `Queue: unchecked — the last pass (${dated}) was a hold that counted no queue and admitted nothing; the tracker could not be re-read this tick, so the live queue is NOT claimed empty. Groom only after a fresh read.`;
|
|
721
|
+
}
|
|
722
|
+
return `Queue: empty (as of the last dispatch, ${dated}) — nobody carried "${queueLabel}" on that count, and the live tracker was not re-read this tick, so this is a snapshot, not a present-tense claim. Groom the backlog (Duty 2) once a fresh read confirms it.`;
|
|
665
723
|
}
|
|
666
724
|
if (summary.routed === 0) {
|
|
667
725
|
// `ready` counts claimed (in-flight) issues too; route() drops those with a
|
|
@@ -678,7 +736,7 @@ export function queueDigestLine(
|
|
|
678
736
|
const staleLifecycle = summary.holds
|
|
679
737
|
.filter((h) => h.reason === "stale-lifecycle")
|
|
680
738
|
.reduce((n, h) => n + h.count, 0);
|
|
681
|
-
let line = `Queue: ${summary.ready} ready, 0 spare — ${claimed} in flight`;
|
|
739
|
+
let line = `As of the last dispatch (${dated}): Queue: ${summary.ready} ready, 0 spare — ${claimed} in flight`;
|
|
682
740
|
if (unroutable > 0) {
|
|
683
741
|
line += `, ${unroutable} unroutable (each unroutable issue needs exactly one "${labelPrefix}<repo>" label)`;
|
|
684
742
|
}
|
|
@@ -693,6 +751,98 @@ export function queueDigestLine(
|
|
|
693
751
|
return line;
|
|
694
752
|
}
|
|
695
753
|
if (summary.routed >= groomBelow) return undefined;
|
|
754
|
+
return lowQueueTail(summary, groomBelow, grooming, `As of the last dispatch (${dated}): Queue: `, now);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** The live rendering: the tracker observation is the queue label inventory,
|
|
758
|
+
* the dispatch summary supplies the claimability reading, and the shared
|
|
759
|
+
* low-queue diagnostics are composed underneath (#848 review 2). The raw
|
|
760
|
+
* carrier count appears only as a present-tense inventory fact — never as
|
|
761
|
+
* the grooming threshold, which must be claimability (`summary.routed`, the
|
|
762
|
+
* same count the to-spec offer gates on), or parked/in-flight/state-labelled
|
|
763
|
+
* carriers would silence a queue that dispatch cannot move. */
|
|
764
|
+
function liveQueueDigestLine(
|
|
765
|
+
summary: DispatchSummary,
|
|
766
|
+
queue: QueueObservation,
|
|
767
|
+
queueLabel: string,
|
|
768
|
+
labelPrefix: string,
|
|
769
|
+
groomBelow: number,
|
|
770
|
+
grooming: readonly GroomingRecord[],
|
|
771
|
+
now: number,
|
|
772
|
+
): string | undefined {
|
|
773
|
+
const queued = queue.queued;
|
|
774
|
+
const observed = new Date(queue.observedAt).toISOString();
|
|
775
|
+
// The present-tense empty verdict is the tracker's own word: nothing open
|
|
776
|
+
// carries the queue label right now. No dispatch snapshot may produce a
|
|
777
|
+
// live "empty" claim — that is the whole defect (#848).
|
|
778
|
+
if (queued === 0) {
|
|
779
|
+
return (
|
|
780
|
+
`Queue: empty — nothing open carries "${queueLabel}" right now (tracker ${observed}). ` +
|
|
781
|
+
"Groom the backlog (Duty 2): promote or file the next issues, or say in this tick's report " +
|
|
782
|
+
"why there is nothing to do."
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
// The grooming threshold is claimability — summary.routed — never raw
|
|
786
|
+
// carrier volume. Four in-flight/parked/unroutable carriers must not
|
|
787
|
+
// suppress the low-claimable signal, because the to-spec offer gates on
|
|
788
|
+
// the very same routed count and the two would split.
|
|
789
|
+
if (summary.routed >= groomBelow) return undefined;
|
|
790
|
+
const dated = new Date(summary.completedAt).toISOString();
|
|
791
|
+
const inventory = `Queue: ${queued} open issue${queued === 1 ? "" : "s"} carry "${queueLabel}" right now (tracker ${observed}); `;
|
|
792
|
+
if (summary.routed === 0) {
|
|
793
|
+
// Dispatch's own claimability read: `ready` counts claimed (in-flight)
|
|
794
|
+
// issues too, so "0 routable" must not blanket-blame missing `repo:`
|
|
795
|
+
// labels (#228), and a residual lifecycle label is Duty 1 reconciliation
|
|
796
|
+
// work, not spare depth (#611).
|
|
797
|
+
if (summary.paused === true) {
|
|
798
|
+
return (
|
|
799
|
+
inventory +
|
|
800
|
+
`the last dispatch (${dated}) was a hold that admitted nothing — the queued issues above are the work that resumes when claiming reopens.`
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
const claimed = summary.claimed ?? 0;
|
|
804
|
+
let line = inventory + `at the last dispatch (${dated}) ${summary.ready} ready, 0 spare — ${claimed} in flight`;
|
|
805
|
+
const unroutable = summary.holds
|
|
806
|
+
.filter((h) => h.reason.startsWith("unroutable:"))
|
|
807
|
+
.reduce((n, h) => n + h.count, 0);
|
|
808
|
+
const staleLifecycle = summary.holds
|
|
809
|
+
.filter((h) => h.reason === "stale-lifecycle")
|
|
810
|
+
.reduce((n, h) => n + h.count, 0);
|
|
811
|
+
if (unroutable > 0) {
|
|
812
|
+
line += `, ${unroutable} unroutable (each unroutable issue needs exactly one "${labelPrefix}<repo>" label)`;
|
|
813
|
+
}
|
|
814
|
+
if (staleLifecycle > 0) {
|
|
815
|
+
line += `, ${staleLifecycle} with a residual lifecycle label (Duty 1: newest run terminal — reconcile the stale agent:in-progress/blocked/failed state)`;
|
|
816
|
+
}
|
|
817
|
+
if (unroutable === 0 && staleLifecycle === 0) {
|
|
818
|
+
line += `. Spare depth is what dispatch can actually claim: groom the backlog (Duty 2) before the live runs settle.`;
|
|
819
|
+
} else {
|
|
820
|
+
line += ".";
|
|
821
|
+
}
|
|
822
|
+
return line;
|
|
823
|
+
}
|
|
824
|
+
// routed > 0 below the trigger: the live inventory leads, then the same
|
|
825
|
+
// low-queue diagnostics the dated path ships — the claimable/known-blocked
|
|
826
|
+
// split, in-flight to-spec batches, the considered backlog and this pass's
|
|
827
|
+
// holds (#735, #777, #679) — so a tracker read never hides them.
|
|
828
|
+
return inventory + lowQueueTail(summary, groomBelow, grooming, "", now);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** The shared low-queue diagnostic tail (#735/#777/#679): the routable-count →
|
|
832
|
+
* claimable/known-blocked split, in-flight to-spec batches, the considered
|
|
833
|
+
* backlog and this pass's holds. `lead` is the sentence opener the caller
|
|
834
|
+
* chooses — the dated path opens with the dispatch timestamp and "Queue:",
|
|
835
|
+
* the live path with its tracker inventory (#848 review 2). Sharing one tail
|
|
836
|
+
* means a tracker read can never hide these distinctions. */
|
|
837
|
+
function lowQueueTail(
|
|
838
|
+
summary: DispatchSummary,
|
|
839
|
+
groomBelow: number,
|
|
840
|
+
grooming: readonly GroomingRecord[],
|
|
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,
|
|
845
|
+
): string {
|
|
696
846
|
// The durable per-issue verdicts, not this pass's one-shot hold groups: a
|
|
697
847
|
// lane-blocked runway must read as "cannot move" even after a restart, and
|
|
698
848
|
// the orchestrator's own prior verdicts (#679) must not be re-derived.
|
|
@@ -708,24 +858,33 @@ export function queueDigestLine(
|
|
|
708
858
|
const claimable = Math.max(0, summary.routed - knownBlocked.length);
|
|
709
859
|
const inFlightNames = inFlight.map((r) => `#${r.issue}`).join(", ");
|
|
710
860
|
const busy = inFlight.length === 0 ? "" : `, ${inFlight.length} in a to-spec batch (${inFlightNames})`;
|
|
711
|
-
let
|
|
861
|
+
let tail: string;
|
|
712
862
|
if (knownBlocked.length > 0 && claimable === 0) {
|
|
713
|
-
|
|
714
|
-
|
|
863
|
+
tail =
|
|
864
|
+
`${lead}running low — ${summary.routed} routable candidate(s), all known-blocked ` +
|
|
715
865
|
`(${groomingGroupCounts(knownBlocked)})${busy} — no grooming moves them; the holds clear by themselves` +
|
|
716
866
|
`${inFlight.length === 0 ? "" : " and the to-spec batch's results land when it settles"}.`;
|
|
717
867
|
} else if (knownBlocked.length > 0 || inFlight.length > 0) {
|
|
718
|
-
|
|
719
|
-
|
|
868
|
+
tail =
|
|
869
|
+
`${lead}running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}: ` +
|
|
720
870
|
`${claimable} claimable, ${knownBlocked.length} known-blocked (${groomingGroupCounts(knownBlocked)})` +
|
|
721
871
|
`${busy} — groom only the claimable.`;
|
|
722
872
|
} else {
|
|
723
|
-
|
|
873
|
+
tail = `${lead}running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
|
|
724
874
|
}
|
|
725
875
|
if (considered.length > 0) {
|
|
726
|
-
|
|
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;
|
|
883
|
+
tail +=
|
|
727
884
|
` Backlog already-considered: ${considered.length} (${groomingVerdictCounts(considered)}) — ` +
|
|
728
|
-
|
|
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.";
|
|
729
888
|
}
|
|
730
889
|
if (summary.admitted === 0 && summary.holds.length > 0) {
|
|
731
890
|
const held = summary.holds
|
|
@@ -734,9 +893,9 @@ export function queueDigestLine(
|
|
|
734
893
|
return `${h.reason} ${h.count}${first === undefined ? "" : ` (${first})`}`;
|
|
735
894
|
})
|
|
736
895
|
.join(", ");
|
|
737
|
-
|
|
896
|
+
tail += ` All held: ${held}.`;
|
|
738
897
|
}
|
|
739
|
-
return
|
|
898
|
+
return tail;
|
|
740
899
|
}
|
|
741
900
|
|
|
742
901
|
// ================================================================ to-spec
|
|
@@ -818,6 +977,51 @@ export const TO_SPEC_IN_FLIGHT_REASON = "in-flight";
|
|
|
818
977
|
*/
|
|
819
978
|
export const TO_SPEC_IN_FLIGHT_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
820
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
|
+
|
|
821
1025
|
/** The first line of every batch item's `task`, in the shape the gate parses:
|
|
822
1026
|
* `to-spec candidate: <owner/repo>#<issue> — <title>`. */
|
|
823
1027
|
export const TO_SPEC_ITEM_PREFIX = "to-spec candidate:";
|
|
@@ -906,6 +1110,12 @@ export function parseToSpecItem(task: unknown): ToSpecBatchItem | undefined {
|
|
|
906
1110
|
* re-running it would recompute a verdict that is still valid. New source
|
|
907
1111
|
* evidence reconsiders it: once the recorded `freshAt` crosses the ceiling
|
|
908
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);
|
|
909
1119
|
* - `active`: a run is in flight on the issue right now.
|
|
910
1120
|
*/
|
|
911
1121
|
export function toSpecCandidateExclusion(
|
|
@@ -922,14 +1132,23 @@ export function toSpecCandidateExclusion(
|
|
|
922
1132
|
} else if (row.reason === "file-lane" || row.reason === "depends-on") {
|
|
923
1133
|
return `#${candidate.issue} is mechanically blocked (${row.reason}) — the hold clears by itself`;
|
|
924
1134
|
} else {
|
|
925
|
-
const
|
|
926
|
-
if (
|
|
1135
|
+
const durable = toSpecDurableVerdict(row, now);
|
|
1136
|
+
if (durable !== undefined) {
|
|
927
1137
|
return (
|
|
928
|
-
`#${candidate.issue} was already groomed ${
|
|
929
|
-
`${
|
|
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 ` +
|
|
930
1140
|
"with new source evidence"
|
|
931
1141
|
);
|
|
932
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
|
+
}
|
|
933
1152
|
}
|
|
934
1153
|
}
|
|
935
1154
|
if (facts.active) return `#${candidate.issue} has a dispatched run in flight`;
|
|
@@ -1001,6 +1220,9 @@ export interface ToSpecLaunchBlock {
|
|
|
1001
1220
|
export interface ToSpecLaunchExclusions {
|
|
1002
1221
|
/** Candidates with a fresh, valid to-spec verdict already on the grooming table. */
|
|
1003
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[];
|
|
1004
1226
|
/** Candidates with an active to-spec batch. */
|
|
1005
1227
|
inFlight: string[];
|
|
1006
1228
|
/** Candidates under admission's durable lane/dependency holds. */
|
|
@@ -1094,21 +1316,28 @@ export async function offerToSpecLaunch(input: {
|
|
|
1094
1316
|
active: readonly { issue: number }[];
|
|
1095
1317
|
project: ProjectConfig;
|
|
1096
1318
|
trackerSeam: ToSpecTrackerSeam | undefined;
|
|
1319
|
+
/** An open-issue snapshot this tick already read from the tracker. Shared
|
|
1320
|
+
* with the queue digest so one tick cannot describe two queues (#848);
|
|
1321
|
+
* absent when that read failed, in which case the launch retries its own
|
|
1322
|
+
* read and fails closed on the same terms as before. */
|
|
1323
|
+
issues?: readonly ReadyIssue[];
|
|
1097
1324
|
now: number;
|
|
1098
1325
|
}): Promise<ToSpecLaunchBlock | undefined> {
|
|
1099
1326
|
if (input.summary === undefined) return undefined;
|
|
1100
1327
|
if (input.summary.routed >= input.groomBelow) return undefined;
|
|
1101
1328
|
const seam = input.trackerSeam;
|
|
1102
1329
|
if (seam === undefined) return undefined;
|
|
1103
|
-
let issues
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1330
|
+
let issues = input.issues;
|
|
1331
|
+
if (issues === undefined) {
|
|
1332
|
+
try {
|
|
1333
|
+
issues = await seam.listOpenIssues(input.project);
|
|
1334
|
+
} catch {
|
|
1335
|
+
// No authoritative snapshot, no launch: a batch offered without one would
|
|
1336
|
+
// make the model the selector, which is exactly the defect this slice
|
|
1337
|
+
// removes. The queue digest still names the grooming duty; the next tick
|
|
1338
|
+
// retries the read.
|
|
1339
|
+
return undefined;
|
|
1340
|
+
}
|
|
1112
1341
|
}
|
|
1113
1342
|
const views = toSpecPoolFromSnapshot(issues, input.project);
|
|
1114
1343
|
const groomingByIssue = new Map(input.grooming.map((row) => [row.issue, row]));
|
|
@@ -1184,17 +1413,35 @@ export function toSpecLaunchBlock(input: {
|
|
|
1184
1413
|
if (input.summary === undefined) return undefined;
|
|
1185
1414
|
if (input.summary.routed >= input.groomBelow) return undefined;
|
|
1186
1415
|
if (input.selected.length === 0) return undefined;
|
|
1187
|
-
const exclusions: ToSpecLaunchExclusions = {
|
|
1416
|
+
const exclusions: ToSpecLaunchExclusions = {
|
|
1417
|
+
groomed: [],
|
|
1418
|
+
refused: [],
|
|
1419
|
+
inFlight: [],
|
|
1420
|
+
mechanicallyBlocked: [],
|
|
1421
|
+
dispatched: [],
|
|
1422
|
+
};
|
|
1188
1423
|
for (const row of input.grooming) {
|
|
1189
1424
|
if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
|
|
1190
1425
|
if (input.now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) exclusions.inFlight.push(`#${row.issue}`);
|
|
1191
1426
|
} else if (row.reason === "file-lane" || row.reason === "depends-on") {
|
|
1192
1427
|
exclusions.mechanicallyBlocked.push(`#${row.issue} (${row.reason})`);
|
|
1193
1428
|
} else {
|
|
1194
|
-
|
|
1195
|
-
|
|
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) {
|
|
1196
1435
|
exclusions.groomed.push(
|
|
1197
|
-
`#${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()})`,
|
|
1198
1445
|
);
|
|
1199
1446
|
}
|
|
1200
1447
|
}
|
|
@@ -1217,10 +1464,11 @@ export function toSpecLaunchBlock(input: {
|
|
|
1217
1464
|
"The conductor selected this batch mechanically from the live open-issue snapshot " +
|
|
1218
1465
|
`(issues carrying \`${input.queueLabel}\`, the \`${input.parkLabel}\` park label, issues without exactly one ` +
|
|
1219
1466
|
`\`${input.labelPrefix}<repo>\` routing label, parent/epic issues with sub-issues, ` +
|
|
1220
|
-
"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):",
|
|
1221
1468
|
candidates,
|
|
1222
1469
|
"Excluded this tick — " +
|
|
1223
1470
|
`already-groomed: ${exclusions.groomed.length === 0 ? "none" : exclusions.groomed.join(", ")}; ` +
|
|
1471
|
+
`refused, cooldown still holding: ${exclusions.refused.length === 0 ? "none" : exclusions.refused.join(", ")}; ` +
|
|
1224
1472
|
`in-flight batches — ${exclusions.inFlight.length === 0 ? "none" : exclusions.inFlight.join(", ")}; ` +
|
|
1225
1473
|
`mechanically blocked: ${exclusions.mechanicallyBlocked.length === 0 ? "none" : exclusions.mechanicallyBlocked.join(", ")}; ` +
|
|
1226
1474
|
`dispatched now: ${exclusions.dispatched.length === 0 ? "none" : exclusions.dispatched.join(", ")}.`,
|
|
@@ -1247,6 +1495,51 @@ export function toSpecLaunchBlock(input: {
|
|
|
1247
1495
|
return { token, block: lines.join("\n"), items };
|
|
1248
1496
|
}
|
|
1249
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
|
+
|
|
1250
1543
|
export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScopeChoice]: string } = {
|
|
1251
1544
|
material: "Report material events per your brief.",
|
|
1252
1545
|
escalations:
|
|
@@ -1451,6 +1744,8 @@ export const TICK_ASK_RULE =
|
|
|
1451
1744
|
`On this locally injected tick, questions to your operator go through ${ASK_TOOL}: ` +
|
|
1452
1745
|
`call it with "on-timeout": "auto-proceed" or "park" (what to do when nobody answers within the ceiling) ` +
|
|
1453
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. ` +
|
|
1454
1749
|
`The ${TELEGRAM_APPROVAL_TOOL} tool is refused here: it would wait for your operator for as long as the answer ` +
|
|
1455
1750
|
`takes, and an unanswered question must never hold the loop.`;
|
|
1456
1751
|
|
|
@@ -2941,24 +3236,33 @@ function resolveAskProject(
|
|
|
2941
3236
|
* prior state, and executing it still fails closed when it cannot route.
|
|
2942
3237
|
*/
|
|
2943
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];
|
|
2944
3244
|
const current = pi.getActiveTools();
|
|
2945
|
-
const present = current.includes(
|
|
2946
|
-
|
|
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;
|
|
2947
3248
|
try {
|
|
2948
3249
|
await pi.setActiveTools(
|
|
2949
|
-
resolvable
|
|
3250
|
+
resolvable
|
|
3251
|
+
? [...current.filter((name) => !tools.includes(name)), ...tools]
|
|
3252
|
+
: current.filter((name) => !tools.includes(name)),
|
|
2950
3253
|
);
|
|
2951
3254
|
} catch (err) {
|
|
2952
3255
|
pi.logger.error(
|
|
2953
|
-
`[omp-conductor] could not ${resolvable ? "activate" : "deactivate"} ${
|
|
3256
|
+
`[omp-conductor] could not ${resolvable ? "activate" : "deactivate"} ${tools.join(" / ")}: ${
|
|
2954
3257
|
err instanceof Error ? err.message : String(err)
|
|
2955
3258
|
}`,
|
|
2956
3259
|
);
|
|
2957
3260
|
return false;
|
|
2958
3261
|
}
|
|
2959
3262
|
// An awaited call is not yet confirmation: the live membership read after the
|
|
2960
|
-
// reconciliation is what proves the
|
|
2961
|
-
|
|
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);
|
|
2962
3266
|
}
|
|
2963
3267
|
|
|
2964
3268
|
/**
|
|
@@ -3198,6 +3502,16 @@ async function tick(
|
|
|
3198
3502
|
// because the whole failure was a question surviving in context only.
|
|
3199
3503
|
const decisions = formatDecisionDigest(frictionStore.openDecisions(scope.projectName), now);
|
|
3200
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}`;
|
|
3201
3515
|
// What the daemon already fixed, so the session stops re-deriving that
|
|
3202
3516
|
// paragraph on every tick (#132). Two intervals wide rather than one: a
|
|
3203
3517
|
// tick that ran long must not drop the window it was meant to report.
|
|
@@ -3224,12 +3538,63 @@ async function tick(
|
|
|
3224
3538
|
// so the resolved name is in hand and an un-named lookup would refuse
|
|
3225
3539
|
// to guess on a host with a second project.
|
|
3226
3540
|
const project = findProject(loadConfig(), scope.projectName);
|
|
3541
|
+
const groomBelow = project.groomBelow ?? DEFAULT_GROOM_BELOW;
|
|
3542
|
+
const dispatch = frictionStore.latestDispatch(scope.projectName);
|
|
3543
|
+
const grooming = store.groomingVerdicts(projectName);
|
|
3544
|
+
// #848: "the queue is empty / running dry" is the tracker's word, not
|
|
3545
|
+
// the last dispatch pass's echo. One open-issue snapshot per tick,
|
|
3546
|
+
// read live through the same authoritative tracker surface the
|
|
3547
|
+
// to-spec launch uses, overlaid with the label_ops projection
|
|
3548
|
+
// exactly as the dispatch pass judges eligibility — so a promotion,
|
|
3549
|
+
// unblock or projection write after the last dispatch pass is
|
|
3550
|
+
// visible on the very next tick even while a drain holds claiming.
|
|
3551
|
+
// The digest consumes the overlay as the live inventory; the to-spec
|
|
3552
|
+
// offer shares the same raw snapshot so one tick cannot describe two
|
|
3553
|
+
// queues. If the tracker read fails, the digest falls back to
|
|
3554
|
+
// explicitly dated wording (never a present-tense empty claim),
|
|
3555
|
+
// and the offer retries its own read exactly as it did before.
|
|
3556
|
+
//
|
|
3557
|
+
// The read is made only for dispatch rows the digest could render:
|
|
3558
|
+
// a healthy row (routed at/above the grooming threshold) emits no
|
|
3559
|
+
// queue line at all, so no freshness check is spent on it and no
|
|
3560
|
+
// stale claim can leak from it. A zero-ready row (or any row below
|
|
3561
|
+
// the trigger) is exactly where the old wording lied, so every such
|
|
3562
|
+
// tick reads the live queue.
|
|
3563
|
+
const needsQueueRead =
|
|
3564
|
+
dispatch !== undefined &&
|
|
3565
|
+
(dispatch.ready === 0 || dispatch.routed === 0 || dispatch.routed < groomBelow);
|
|
3566
|
+
let queueObservation: QueueObservation | undefined;
|
|
3567
|
+
let openSnapshot: readonly ReadyIssue[] | undefined;
|
|
3568
|
+
if (toSpecTrackerSeam !== undefined && needsQueueRead) {
|
|
3569
|
+
try {
|
|
3570
|
+
const open = await toSpecTrackerSeam.listOpenIssues(project);
|
|
3571
|
+
openSnapshot = open;
|
|
3572
|
+
const effective = open.map((issue) => {
|
|
3573
|
+
const pending = store.pendingLabelOpsFor(projectName, issue.number);
|
|
3574
|
+
return pending.length === 0
|
|
3575
|
+
? issue
|
|
3576
|
+
: { ...issue, labels: effectiveLabels(issue.labels, pending) };
|
|
3577
|
+
});
|
|
3578
|
+
queueObservation = {
|
|
3579
|
+
queued: effective.filter((issue) => issue.labels.includes(project.queueLabel)).length,
|
|
3580
|
+
observedAt: now,
|
|
3581
|
+
};
|
|
3582
|
+
} catch {
|
|
3583
|
+
// No current-queue evidence this tick: the digest below renders
|
|
3584
|
+
// the dispatch row as dated history instead of claiming empty.
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3227
3587
|
const queue = queueDigestLine(
|
|
3228
|
-
|
|
3588
|
+
dispatch,
|
|
3229
3589
|
project.queueLabel,
|
|
3230
3590
|
project.routing.labelPrefix,
|
|
3231
|
-
|
|
3232
|
-
|
|
3591
|
+
groomBelow,
|
|
3592
|
+
grooming,
|
|
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,
|
|
3233
3598
|
);
|
|
3234
3599
|
if (queue !== undefined) content = `${content}\n${queue}`;
|
|
3235
3600
|
// #777: the mechanical to-spec launch boundary. The queue digest is
|
|
@@ -3243,11 +3608,13 @@ async function tick(
|
|
|
3243
3608
|
//
|
|
3244
3609
|
// Every tick owns its authorization fresh — the clears above ran
|
|
3245
3610
|
// before the reads, so the offer below can only mint for THIS tick.
|
|
3611
|
+
const toSpecActive = store.activeRuns(projectName);
|
|
3246
3612
|
const launch = await offerToSpecLaunch({
|
|
3247
|
-
summary:
|
|
3248
|
-
groomBelow
|
|
3249
|
-
grooming
|
|
3250
|
-
active:
|
|
3613
|
+
summary: dispatch,
|
|
3614
|
+
groomBelow,
|
|
3615
|
+
grooming,
|
|
3616
|
+
active: toSpecActive,
|
|
3617
|
+
issues: openSnapshot,
|
|
3251
3618
|
project,
|
|
3252
3619
|
trackerSeam: toSpecTrackerSeam,
|
|
3253
3620
|
now,
|
|
@@ -3261,6 +3628,19 @@ async function tick(
|
|
|
3261
3628
|
session.launchProject = project.name;
|
|
3262
3629
|
session.launchToken = launch.token;
|
|
3263
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}`;
|
|
3264
3644
|
}
|
|
3265
3645
|
// Pending intake is the same class of standing block as the friction
|
|
3266
3646
|
// and decisions read-outs: a store-backed duty the orchestrator must
|
|
@@ -4033,22 +4413,25 @@ export default function orchestratorTickExtension(
|
|
|
4033
4413
|
}
|
|
4034
4414
|
return;
|
|
4035
4415
|
}
|
|
4036
|
-
// The reply to an arming challenge lands here as an ordinary user turn.
|
|
4037
|
-
//
|
|
4038
|
-
//
|
|
4039
|
-
//
|
|
4040
|
-
//
|
|
4041
|
-
// #
|
|
4042
|
-
//
|
|
4043
|
-
// from the `FLEET-` prefix, so
|
|
4044
|
-
// active challenge stays inert
|
|
4416
|
+
// The reply to an arming challenge lands here as an ordinary user turn.
|
|
4417
|
+
// This adapter — not the model, and not any transcript scan — is the
|
|
4418
|
+
// sole producer of the arming acknowledgement: it classifies the turn
|
|
4419
|
+
// against persisted authenticated challenge state and, on a match,
|
|
4420
|
+
// atomically records the challenge-id-specific acknowledgement the
|
|
4421
|
+
// host-side `arm` waits on (conductor #614), all before normal model
|
|
4422
|
+
// handling. The classification derives from the pending challenge (hash
|
|
4423
|
+
// + expiry, keyed by this project), never from the `FLEET-` prefix, so
|
|
4424
|
+
// an unsolicited lookalike that matches no active challenge stays inert
|
|
4425
|
+
// and model behaviour cannot determine whether the host becomes armed.
|
|
4426
|
+
// The turn itself still reaches the transcript exactly as sent, with the
|
|
4427
|
+
// same trusted machine-readable steer as before (#415).
|
|
4045
4428
|
if (message.role === "user" && message.synthetic !== true && message.attribution !== "agent") {
|
|
4046
4429
|
const replyText =
|
|
4047
4430
|
message.content
|
|
4048
4431
|
?.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
4049
4432
|
.map((part) => part.text as string)
|
|
4050
4433
|
.join(" ") ?? "";
|
|
4051
|
-
const proof =
|
|
4434
|
+
const proof = acknowledgeArmReply(configuredProject, replyText, Date.now());
|
|
4052
4435
|
if (session.activeLocalTick !== undefined) {
|
|
4053
4436
|
session.activeLocalTick.humanWaiting = true;
|
|
4054
4437
|
if (proof) session.activeLocalTick.armingProof = true;
|
|
@@ -4112,6 +4495,71 @@ export default function orchestratorTickExtension(
|
|
|
4112
4495
|
// the project to file the decision row against and to resolve the delivery
|
|
4113
4496
|
// target. An unreadable/ambiguous config makes the tool say so and record
|
|
4114
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
|
+
|
|
4115
4563
|
pi.registerTool({
|
|
4116
4564
|
name: ASK_TOOL,
|
|
4117
4565
|
label: ASK_TOOL,
|
|
@@ -4140,43 +4588,16 @@ export default function orchestratorTickExtension(
|
|
|
4140
4588
|
if (!parsed.ok) {
|
|
4141
4589
|
return { content: [{ type: "text", text: parsed.problem }], isError: true };
|
|
4142
4590
|
}
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
{
|
|
4151
|
-
type: "text",
|
|
4152
|
-
text: `${ASK_TOOL}: not available in this session (no orchestrator tick); nothing was asked or recorded.`,
|
|
4153
|
-
},
|
|
4154
|
-
],
|
|
4155
|
-
isError: true,
|
|
4156
|
-
};
|
|
4157
|
-
}
|
|
4158
|
-
// Routing follows the *live* tick config, not the session-start stamp:
|
|
4159
|
-
// a restamp (un-stamped → stamped, or project A → B) must make the next
|
|
4160
|
-
// tick's toolbox land on the project the turn actually ticks for, and an
|
|
4161
|
-
// abandoned stamp must not keep recording against a project that is no
|
|
4162
|
-
// longer this fleet's. Only the ceiling stays startup-only (from
|
|
4163
|
-
// `config` below) — routing is re-read every call.
|
|
4164
|
-
const { cwd, config } = session;
|
|
4165
|
-
const routed = resolveAskProject(cwd, config);
|
|
4166
|
-
if (routed.kind === "error") {
|
|
4167
|
-
return {
|
|
4168
|
-
content: [
|
|
4169
|
-
{
|
|
4170
|
-
type: "text",
|
|
4171
|
-
text:
|
|
4172
|
-
`${ASK_TOOL}: conductor config unreadable (${routed.problem}); ` +
|
|
4173
|
-
"nothing was asked or recorded. Repair the config, do not ask through another path.",
|
|
4174
|
-
},
|
|
4175
|
-
],
|
|
4176
|
-
isError: true,
|
|
4177
|
-
};
|
|
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 };
|
|
4178
4598
|
}
|
|
4179
|
-
const projectConfig =
|
|
4599
|
+
const projectConfig = context.project;
|
|
4600
|
+
const config = context.config;
|
|
4180
4601
|
const store = openStore(dbPath());
|
|
4181
4602
|
let result: AskResult;
|
|
4182
4603
|
try {
|
|
@@ -4192,37 +4613,7 @@ export default function orchestratorTickExtension(
|
|
|
4192
4613
|
// inject a fake through the same seam.
|
|
4193
4614
|
interactive: options.ask?.interactive ?? interactiveAskSurface({ project: projectConfig, store }),
|
|
4194
4615
|
...(options.ask === undefined ? {} : { wait: options.ask.wait, now: options.ask.now }),
|
|
4195
|
-
deliver:
|
|
4196
|
-
const at = Date.now();
|
|
4197
|
-
const noticeId = randomUUID();
|
|
4198
|
-
try {
|
|
4199
|
-
const delivered = await deliverOperatorMessage(projectConfig, text, {
|
|
4200
|
-
store,
|
|
4201
|
-
at,
|
|
4202
|
-
noticeId,
|
|
4203
|
-
category,
|
|
4204
|
-
});
|
|
4205
|
-
return delivered.kind === "sent"
|
|
4206
|
-
? { kind: "sent", category: delivered.category }
|
|
4207
|
-
: { kind: "held", category: delivered.category, noticeId: delivered.noticeId };
|
|
4208
|
-
} catch (err) {
|
|
4209
|
-
// A failed immediate send must not drop the question: fall back
|
|
4210
|
-
// to the durable hold exactly like the gate's own hold path, and
|
|
4211
|
-
// let the daemon retry with the digest.
|
|
4212
|
-
store.addHeldNotice({
|
|
4213
|
-
id: noticeId,
|
|
4214
|
-
project: projectConfig.name,
|
|
4215
|
-
category,
|
|
4216
|
-
summary: text.split("\n", 1)[0]!.slice(0, 240),
|
|
4217
|
-
detail: text,
|
|
4218
|
-
createdAt: at,
|
|
4219
|
-
});
|
|
4220
|
-
pi.logger.error(
|
|
4221
|
-
`[omp-conductor] ${ASK_TOOL} could not deliver the ask directly (${err instanceof Error ? err.message : String(err)}); held durably`,
|
|
4222
|
-
);
|
|
4223
|
-
return { kind: "held", category, noticeId };
|
|
4224
|
-
}
|
|
4225
|
-
},
|
|
4616
|
+
deliver: operatorAskDelivery(ASK_TOOL, projectConfig, store),
|
|
4226
4617
|
});
|
|
4227
4618
|
} finally {
|
|
4228
4619
|
store.close();
|
|
@@ -4231,6 +4622,58 @@ export default function orchestratorTickExtension(
|
|
|
4231
4622
|
},
|
|
4232
4623
|
});
|
|
4233
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
|
+
|
|
4234
4677
|
// The async half of the to-spec result capture (#777). Registered at
|
|
4235
4678
|
// extension-factory time like {@link ASK_TOOL}, with the same routing
|
|
4236
4679
|
// contract: the state it needs (cwd + startup config) is filled by
|