omp-conductor 0.18.0 → 0.18.2
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 +35 -1
- package/REFERENCE.md +61 -11
- package/agents/to-spec.md +94 -0
- package/package.json +2 -1
- package/schema/config.schema.json +35 -1
- package/src/admission.ts +204 -75
- package/src/arm-challenge.ts +250 -57
- package/src/ask.ts +268 -7
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +62 -21
- package/src/briefs/to-spec.md +88 -0
- package/src/briefs/worker.md +2 -1
- package/src/cli.ts +124 -1
- package/src/command-help.ts +11 -0
- package/src/command-manifest.ts +38 -5
- package/src/commands/arm.ts +1 -1
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/intake.ts +4 -19
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +51 -16
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +43 -6
- package/src/config.ts +65 -9
- package/src/daemon.ts +879 -41
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +243 -17
- package/src/diff-flags.ts +75 -1
- package/src/doctor.ts +60 -82
- package/src/escalate.ts +31 -14
- package/src/failure-class.ts +28 -2
- package/src/fleet.ts +239 -240
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +35 -1
- package/src/graph.ts +66 -1
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +242 -2
- package/src/lifecycle.ts +122 -1
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +183 -21
- package/src/orchestrator-tick.ts +1591 -32
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/session-host.ts +65 -6
- package/src/settlement.ts +69 -17
- package/src/setup-host.ts +1225 -9
- package/src/setup-install.ts +28 -0
- package/src/setup-wizard.ts +154 -3
- package/src/setup.ts +83 -17
- package/src/shell.ts +15 -0
- package/src/status-render.ts +216 -12
- package/src/store.ts +443 -42
- package/src/to-spec.ts +408 -0
- package/src/tracker/github.ts +104 -14
- package/src/types.ts +405 -19
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +765 -56
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +12 -2
- package/src/worktree.ts +29 -12
package/src/orchestrator-tick.ts
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
|
|
47
47
|
import { createHash, randomUUID } from "node:crypto";
|
|
48
48
|
import { spawnSync } from "node:child_process";
|
|
49
|
-
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
49
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
50
50
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
51
51
|
import { availabilityPrompt, interruptDisposition } from "./availability.ts";
|
|
52
52
|
import { findProject, loadConfig, resolveReleaseGrants, stateDir } from "./config.ts";
|
|
@@ -82,32 +82,51 @@ import {
|
|
|
82
82
|
type DispatchSummary,
|
|
83
83
|
type DigestBacklog,
|
|
84
84
|
type FrictionSignal,
|
|
85
|
+
type GroomingRecord,
|
|
85
86
|
type HeldNotice,
|
|
86
87
|
type InterruptCategory,
|
|
87
88
|
type IntakeItem,
|
|
89
|
+
type IssueState,
|
|
88
90
|
type MaterialEvent,
|
|
91
|
+
type ProjectConfig,
|
|
92
|
+
type ReadyIssue,
|
|
89
93
|
type ReportScopeChoice,
|
|
90
94
|
type ReportingPolicy,
|
|
91
95
|
type ResolvedGrants,
|
|
92
96
|
type Store,
|
|
93
97
|
} from "./types.ts";
|
|
98
|
+
import { repoSlugFor } from "./gitops.ts";
|
|
99
|
+
import { makeTracker } from "./tracker/github.ts";
|
|
94
100
|
import { formatDecisionDigest } from "./decisions.ts";
|
|
95
101
|
import {
|
|
96
102
|
ASK_TOOL,
|
|
103
|
+
askAnswerRowWrite,
|
|
97
104
|
askParameterSchema,
|
|
98
105
|
DEFAULT_ASK_TIMEOUT_SECONDS,
|
|
99
106
|
MAX_ASK_TIMEOUT_SECONDS,
|
|
100
107
|
MIN_ASK_TIMEOUT_SECONDS,
|
|
108
|
+
parseAskAnswerEnvelope,
|
|
101
109
|
parseAskRequest,
|
|
102
110
|
performAsk,
|
|
111
|
+
renderInteractiveAsk,
|
|
112
|
+
type AskAnswerEnvelope,
|
|
113
|
+
type AskInteractiveDelivery,
|
|
103
114
|
type AskResult,
|
|
104
115
|
} from "./ask.ts";
|
|
105
116
|
import { deliverOperatorMessage } from "./reports.ts";
|
|
106
|
-
import
|
|
117
|
+
import { effectiveLabels } from "./routing.ts";
|
|
118
|
+
import { readTelegramToken, resolveProjectTopicId, telegramStateDir } from "./escalate.ts";
|
|
119
|
+
import type { FailureClass, RecoveryAction, RunRecord } from "./types.ts";
|
|
107
120
|
import { dbPath, openStore } from "./store.ts";
|
|
108
121
|
import { digestDue, localDayKey } from "./digest-schedule.ts";
|
|
122
|
+
import {
|
|
123
|
+
parseToSpecEvidence,
|
|
124
|
+
recordToSpecGrooming,
|
|
125
|
+
TO_SPEC_MAX_SOURCE_AGE_MS,
|
|
126
|
+
TO_SPEC_SCHEMA,
|
|
127
|
+
} from "./to-spec.ts";
|
|
109
128
|
import { heldNoticeId } from "./notices.ts";
|
|
110
|
-
import {
|
|
129
|
+
import { acknowledgeArmReply } from "./arm-challenge.ts";
|
|
111
130
|
|
|
112
131
|
/** The activation file. Absent means "this is not an orchestrator session". */
|
|
113
132
|
export const TICK_CONFIG_FILE = ".conductor-tick.json";
|
|
@@ -603,20 +622,88 @@ export function recoveryDigestLine(
|
|
|
603
622
|
return lines.join("\n");
|
|
604
623
|
}
|
|
605
624
|
|
|
625
|
+
/** Per-reason counts for a verdict slice — "file-lane 2, depends-on 1". */
|
|
626
|
+
function groomingGroupCounts(records: readonly GroomingRecord[]): string {
|
|
627
|
+
const counts = new Map<string, number>();
|
|
628
|
+
for (const record of records) {
|
|
629
|
+
counts.set(record.reason, (counts.get(record.reason) ?? 0) + 1);
|
|
630
|
+
}
|
|
631
|
+
return [...counts.entries()].map(([reason, count]) => `${reason} ${count}`).join(", ");
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Per-verdict counts for a slice — "promotable 1, considered 1". */
|
|
635
|
+
function groomingVerdictCounts(records: readonly GroomingRecord[]): string {
|
|
636
|
+
const counts = new Map<string, number>();
|
|
637
|
+
for (const record of records) {
|
|
638
|
+
counts.set(record.verdict, (counts.get(record.verdict) ?? 0) + 1);
|
|
639
|
+
}
|
|
640
|
+
return [...counts.entries()].map(([verdict, count]) => `${verdict} ${count}`).join(", ");
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* The queue-label inventory read fresh from the tracker on one tick, with the
|
|
645
|
+
* pending label ops applied exactly as the dispatch pass judges eligibility
|
|
646
|
+
* (#848). Queue verdicts in the present tense ("empty", "running low") are
|
|
647
|
+
* only ever drawn from this observation; a dispatch summary alone is dated
|
|
648
|
+
* history and must never be rendered as the live queue.
|
|
649
|
+
*/
|
|
650
|
+
export interface QueueObservation {
|
|
651
|
+
/** Open issues carrying the queue label under the *effective* label set —
|
|
652
|
+
* the count dispatch would route against on its next pass. */
|
|
653
|
+
queued: number;
|
|
654
|
+
/** The instant the tracker observation was made, epoch ms. */
|
|
655
|
+
observedAt: number;
|
|
656
|
+
}
|
|
657
|
+
|
|
606
658
|
/**
|
|
607
659
|
* One line telling the orchestrator the routable queue is running dry (#181),
|
|
608
660
|
* or `undefined` when healthy — no dispatch recorded yet, or the routable count
|
|
609
|
-
* is at/above the grooming trigger.
|
|
661
|
+
* is at/above the grooming trigger. `grooming` is the durable per-issue verdict
|
|
662
|
+
* table (#735): `blocked` rows are admission's lane/dependency holds, so the
|
|
663
|
+
* line tells claimable candidates apart from a runway that cannot move —
|
|
664
|
+
* instead of inviting Duty 2 to groom work whose last pass held it, which is
|
|
665
|
+
* exactly the re-derivation this store exists to stop. A to-spec launch row
|
|
666
|
+
* (#777) carries the same `blocked` verdict as its durable in-flight marker,
|
|
667
|
+
* so it is told apart from the mechanical holds: it says "a batch is running",
|
|
668
|
+
* not "the lane cannot move", and counts neither as claimable nor as
|
|
669
|
+
* known-blocked.
|
|
670
|
+
*
|
|
671
|
+
* `queue` is the tracker observation the caller made THIS tick (#848). When
|
|
672
|
+
* present, the queue verdict reads in the present tense from that inventory,
|
|
673
|
+
* and dispatch-derived counts are only ever dated context. When absent the
|
|
674
|
+
* tracker could not be read, and this never asserts that the queue is empty:
|
|
675
|
+
* the dispatch row is rendered explicitly as-of instead.
|
|
610
676
|
*/
|
|
611
677
|
export function queueDigestLine(
|
|
612
678
|
summary: DispatchSummary | undefined,
|
|
613
679
|
queueLabel: string,
|
|
614
680
|
labelPrefix: string,
|
|
615
681
|
groomBelow: number,
|
|
682
|
+
grooming: readonly GroomingRecord[] = [],
|
|
683
|
+
queue: QueueObservation | undefined = undefined,
|
|
616
684
|
): string | undefined {
|
|
617
685
|
if (summary === undefined) return undefined;
|
|
686
|
+
if (queue !== undefined) {
|
|
687
|
+
return liveQueueDigestLine(summary, queue, queueLabel, labelPrefix, groomBelow, grooming);
|
|
688
|
+
}
|
|
689
|
+
return datedQueueDigestLine(summary, queueLabel, labelPrefix, groomBelow, grooming);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** The dated rendering: the last dispatch row is the only evidence, so every
|
|
693
|
+
* count carries its snapshot date and nothing is asserted as current. */
|
|
694
|
+
function datedQueueDigestLine(
|
|
695
|
+
summary: DispatchSummary,
|
|
696
|
+
queueLabel: string,
|
|
697
|
+
labelPrefix: string,
|
|
698
|
+
groomBelow: number,
|
|
699
|
+
grooming: readonly GroomingRecord[],
|
|
700
|
+
): string | undefined {
|
|
701
|
+
const dated = new Date(summary.completedAt).toISOString();
|
|
618
702
|
if (summary.ready === 0) {
|
|
619
|
-
|
|
703
|
+
if (summary.paused === true) {
|
|
704
|
+
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.`;
|
|
705
|
+
}
|
|
706
|
+
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.`;
|
|
620
707
|
}
|
|
621
708
|
if (summary.routed === 0) {
|
|
622
709
|
// `ready` counts claimed (in-flight) issues too; route() drops those with a
|
|
@@ -633,7 +720,7 @@ export function queueDigestLine(
|
|
|
633
720
|
const staleLifecycle = summary.holds
|
|
634
721
|
.filter((h) => h.reason === "stale-lifecycle")
|
|
635
722
|
.reduce((n, h) => n + h.count, 0);
|
|
636
|
-
let line = `Queue: ${summary.ready} ready, 0 spare — ${claimed} in flight`;
|
|
723
|
+
let line = `As of the last dispatch (${dated}): Queue: ${summary.ready} ready, 0 spare — ${claimed} in flight`;
|
|
637
724
|
if (unroutable > 0) {
|
|
638
725
|
line += `, ${unroutable} unroutable (each unroutable issue needs exactly one "${labelPrefix}<repo>" label)`;
|
|
639
726
|
}
|
|
@@ -648,7 +735,128 @@ export function queueDigestLine(
|
|
|
648
735
|
return line;
|
|
649
736
|
}
|
|
650
737
|
if (summary.routed >= groomBelow) return undefined;
|
|
651
|
-
|
|
738
|
+
return lowQueueTail(summary, groomBelow, grooming, `As of the last dispatch (${dated}): Queue: `);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** The live rendering: the tracker observation is the queue label inventory,
|
|
742
|
+
* the dispatch summary supplies the claimability reading, and the shared
|
|
743
|
+
* low-queue diagnostics are composed underneath (#848 review 2). The raw
|
|
744
|
+
* carrier count appears only as a present-tense inventory fact — never as
|
|
745
|
+
* the grooming threshold, which must be claimability (`summary.routed`, the
|
|
746
|
+
* same count the to-spec offer gates on), or parked/in-flight/state-labelled
|
|
747
|
+
* carriers would silence a queue that dispatch cannot move. */
|
|
748
|
+
function liveQueueDigestLine(
|
|
749
|
+
summary: DispatchSummary,
|
|
750
|
+
queue: QueueObservation,
|
|
751
|
+
queueLabel: string,
|
|
752
|
+
labelPrefix: string,
|
|
753
|
+
groomBelow: number,
|
|
754
|
+
grooming: readonly GroomingRecord[],
|
|
755
|
+
): string | undefined {
|
|
756
|
+
const queued = queue.queued;
|
|
757
|
+
const observed = new Date(queue.observedAt).toISOString();
|
|
758
|
+
// The present-tense empty verdict is the tracker's own word: nothing open
|
|
759
|
+
// carries the queue label right now. No dispatch snapshot may produce a
|
|
760
|
+
// live "empty" claim — that is the whole defect (#848).
|
|
761
|
+
if (queued === 0) {
|
|
762
|
+
return (
|
|
763
|
+
`Queue: empty — nothing open carries "${queueLabel}" right now (tracker ${observed}). ` +
|
|
764
|
+
"Groom the backlog (Duty 2): promote or file the next issues, or say in this tick's report " +
|
|
765
|
+
"why there is nothing to do."
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
// The grooming threshold is claimability — summary.routed — never raw
|
|
769
|
+
// carrier volume. Four in-flight/parked/unroutable carriers must not
|
|
770
|
+
// suppress the low-claimable signal, because the to-spec offer gates on
|
|
771
|
+
// the very same routed count and the two would split.
|
|
772
|
+
if (summary.routed >= groomBelow) return undefined;
|
|
773
|
+
const dated = new Date(summary.completedAt).toISOString();
|
|
774
|
+
const inventory = `Queue: ${queued} open issue${queued === 1 ? "" : "s"} carry "${queueLabel}" right now (tracker ${observed}); `;
|
|
775
|
+
if (summary.routed === 0) {
|
|
776
|
+
// Dispatch's own claimability read: `ready` counts claimed (in-flight)
|
|
777
|
+
// issues too, so "0 routable" must not blanket-blame missing `repo:`
|
|
778
|
+
// labels (#228), and a residual lifecycle label is Duty 1 reconciliation
|
|
779
|
+
// work, not spare depth (#611).
|
|
780
|
+
if (summary.paused === true) {
|
|
781
|
+
return (
|
|
782
|
+
inventory +
|
|
783
|
+
`the last dispatch (${dated}) was a hold that admitted nothing — the queued issues above are the work that resumes when claiming reopens.`
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
const claimed = summary.claimed ?? 0;
|
|
787
|
+
let line = inventory + `at the last dispatch (${dated}) ${summary.ready} ready, 0 spare — ${claimed} in flight`;
|
|
788
|
+
const unroutable = summary.holds
|
|
789
|
+
.filter((h) => h.reason.startsWith("unroutable:"))
|
|
790
|
+
.reduce((n, h) => n + h.count, 0);
|
|
791
|
+
const staleLifecycle = summary.holds
|
|
792
|
+
.filter((h) => h.reason === "stale-lifecycle")
|
|
793
|
+
.reduce((n, h) => n + h.count, 0);
|
|
794
|
+
if (unroutable > 0) {
|
|
795
|
+
line += `, ${unroutable} unroutable (each unroutable issue needs exactly one "${labelPrefix}<repo>" label)`;
|
|
796
|
+
}
|
|
797
|
+
if (staleLifecycle > 0) {
|
|
798
|
+
line += `, ${staleLifecycle} with a residual lifecycle label (Duty 1: newest run terminal — reconcile the stale agent:in-progress/blocked/failed state)`;
|
|
799
|
+
}
|
|
800
|
+
if (unroutable === 0 && staleLifecycle === 0) {
|
|
801
|
+
line += `. Spare depth is what dispatch can actually claim: groom the backlog (Duty 2) before the live runs settle.`;
|
|
802
|
+
} else {
|
|
803
|
+
line += ".";
|
|
804
|
+
}
|
|
805
|
+
return line;
|
|
806
|
+
}
|
|
807
|
+
// routed > 0 below the trigger: the live inventory leads, then the same
|
|
808
|
+
// low-queue diagnostics the dated path ships — the claimable/known-blocked
|
|
809
|
+
// split, in-flight to-spec batches, the considered backlog and this pass's
|
|
810
|
+
// holds (#735, #777, #679) — so a tracker read never hides them.
|
|
811
|
+
return inventory + lowQueueTail(summary, groomBelow, grooming, "");
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** The shared low-queue diagnostic tail (#735/#777/#679): the routable-count →
|
|
815
|
+
* claimable/known-blocked split, in-flight to-spec batches, the considered
|
|
816
|
+
* backlog and this pass's holds. `lead` is the sentence opener the caller
|
|
817
|
+
* chooses — the dated path opens with the dispatch timestamp and "Queue:",
|
|
818
|
+
* the live path with its tracker inventory (#848 review 2). Sharing one tail
|
|
819
|
+
* means a tracker read can never hide these distinctions. */
|
|
820
|
+
function lowQueueTail(
|
|
821
|
+
summary: DispatchSummary,
|
|
822
|
+
groomBelow: number,
|
|
823
|
+
grooming: readonly GroomingRecord[],
|
|
824
|
+
lead: string,
|
|
825
|
+
): string {
|
|
826
|
+
// The durable per-issue verdicts, not this pass's one-shot hold groups: a
|
|
827
|
+
// lane-blocked runway must read as "cannot move" even after a restart, and
|
|
828
|
+
// the orchestrator's own prior verdicts (#679) must not be re-derived.
|
|
829
|
+
//
|
|
830
|
+
// An in-flight to-spec launch (#777) is backlog inventory, not a routed
|
|
831
|
+
// ready candidate, a durable hold, or a verdict. Name it separately without
|
|
832
|
+
// subtracting it from the routed queue or printing lane/dependency guidance.
|
|
833
|
+
const inFlight = grooming.filter((g) => g.reason === TO_SPEC_IN_FLIGHT_REASON);
|
|
834
|
+
const knownBlocked = grooming.filter(
|
|
835
|
+
(g) => g.verdict === "blocked" && (g.reason === "file-lane" || g.reason === "depends-on"),
|
|
836
|
+
);
|
|
837
|
+
const considered = grooming.filter((g) => g.verdict === "promotable" || g.verdict === "considered");
|
|
838
|
+
const claimable = Math.max(0, summary.routed - knownBlocked.length);
|
|
839
|
+
const inFlightNames = inFlight.map((r) => `#${r.issue}`).join(", ");
|
|
840
|
+
const busy = inFlight.length === 0 ? "" : `, ${inFlight.length} in a to-spec batch (${inFlightNames})`;
|
|
841
|
+
let tail: string;
|
|
842
|
+
if (knownBlocked.length > 0 && claimable === 0) {
|
|
843
|
+
tail =
|
|
844
|
+
`${lead}running low — ${summary.routed} routable candidate(s), all known-blocked ` +
|
|
845
|
+
`(${groomingGroupCounts(knownBlocked)})${busy} — no grooming moves them; the holds clear by themselves` +
|
|
846
|
+
`${inFlight.length === 0 ? "" : " and the to-spec batch's results land when it settles"}.`;
|
|
847
|
+
} else if (knownBlocked.length > 0 || inFlight.length > 0) {
|
|
848
|
+
tail =
|
|
849
|
+
`${lead}running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}: ` +
|
|
850
|
+
`${claimable} claimable, ${knownBlocked.length} known-blocked (${groomingGroupCounts(knownBlocked)})` +
|
|
851
|
+
`${busy} — groom only the claimable.`;
|
|
852
|
+
} else {
|
|
853
|
+
tail = `${lead}running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
|
|
854
|
+
}
|
|
855
|
+
if (considered.length > 0) {
|
|
856
|
+
tail +=
|
|
857
|
+
` Backlog already-considered: ${considered.length} (${groomingVerdictCounts(considered)}) — ` +
|
|
858
|
+
`promote the promotable or groom new issues, never re-groom these.`;
|
|
859
|
+
}
|
|
652
860
|
if (summary.admitted === 0 && summary.holds.length > 0) {
|
|
653
861
|
const held = summary.holds
|
|
654
862
|
.map((h) => {
|
|
@@ -656,9 +864,524 @@ export function queueDigestLine(
|
|
|
656
864
|
return `${h.reason} ${h.count}${first === undefined ? "" : ` (${first})`}`;
|
|
657
865
|
})
|
|
658
866
|
.join(", ");
|
|
659
|
-
|
|
867
|
+
tail += ` All held: ${held}.`;
|
|
660
868
|
}
|
|
661
|
-
return
|
|
869
|
+
return tail;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ================================================================ to-spec
|
|
873
|
+
// launch lifecycle (#777). #772 shipped the strict to-spec contract
|
|
874
|
+
// (`TO_SPEC_SCHEMA` + `recordToSpecGrooming`); this section is the launch
|
|
875
|
+
// half: the mechanical candidate selection, the bounded prompt block that
|
|
876
|
+
// authorizes exactly one native `task` batch per low-queue tick, the
|
|
877
|
+
// tool-call gate that stamps the contract and records in-flight rows, and
|
|
878
|
+
// the result capture that routes every item's exact output through
|
|
879
|
+
// `recordToSpecGrooming` independently.
|
|
880
|
+
//
|
|
881
|
+
// Where each fact lives decides who enforces it — and every exclusion is
|
|
882
|
+
// enforced mechanically from authoritative data, never by prose:
|
|
883
|
+
//
|
|
884
|
+
// - Tracker facts — the open-issue pool, the park label, the parent/epic
|
|
885
|
+
// probe — are read ONCE, at launch composition time, through the existing
|
|
886
|
+
// Tracker adapter (`listOpenIssues`/`childrenOf`). The tick turns that
|
|
887
|
+
// snapshot into the mechanically selected batch: `offerToSpecLaunch`
|
|
888
|
+
// streams the snapshot through the store-side exclusions and the parent
|
|
889
|
+
// probe, and the returned block names the selected candidates as the ONLY
|
|
890
|
+
// batch this tick may launch. If the snapshot cannot be read, no batch is
|
|
891
|
+
// offered at all — the launch fails closed rather than trusting the model
|
|
892
|
+
// to self-filter.
|
|
893
|
+
// - Store facts — durable grooming rows (#735), admission's lane/dependency
|
|
894
|
+
// holds, active runs, in-flight launches — are enforced mechanically at
|
|
895
|
+
// composition time (the exclusion lines the block names) and again at
|
|
896
|
+
// `tool_call` time (the gate refuses an item the store contradicts).
|
|
897
|
+
// - The live source ref for each item cannot be known synchronously without
|
|
898
|
+
// a fresh per-repo read, so the model fetches it and declares it in the
|
|
899
|
+
// item's second contract line; the strict parser's 24h freshness ceiling
|
|
900
|
+
// re-vets the claim at persistence time, which is the boundary #772 set.
|
|
901
|
+
// - The gate additionally enforces the allowlist: a marker-bearing `task`
|
|
902
|
+
// call is refused unless every item's issue number AND routing repo match
|
|
903
|
+
// the batch this tick's token authorized. A parked or parent/epic
|
|
904
|
+
// candidate therefore cannot be stamped in-flight even if the model
|
|
905
|
+
// invents one — it was never on the list, and the list is the only thing
|
|
906
|
+
// the gate lets through.
|
|
907
|
+
//
|
|
908
|
+
// The pure selector (`selectToSpecBatch`) remains the single shared rule set:
|
|
909
|
+
// the offer feeds it the tracker-vetted pool and the gate runs the same
|
|
910
|
+
// exclusion on every item at call time, so a candidate excluded in the prompt
|
|
911
|
+
// is excluded in the gate for the same reason.
|
|
912
|
+
|
|
913
|
+
/** The `to-spec` agent every batch item runs under (shipped in
|
|
914
|
+
* `omp/agents/to-spec.md`, discovered through OMP's native task-agent
|
|
915
|
+
* discovery — never a custom process runtime). */
|
|
916
|
+
export const TO_SPEC_AGENT = "to-spec";
|
|
917
|
+
|
|
918
|
+
/** The maximum number of candidates one tick's batch may carry (#679's
|
|
919
|
+
* "small per-tick candidate limit"; the session-scoped task semaphore
|
|
920
|
+
* bounds concurrency underneath). */
|
|
921
|
+
export const TO_SPEC_BATCH_MAX = 3;
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* The `context` marker that identifies a conductor grooming batch to the
|
|
925
|
+
* `tool_call` gate. The launch block instructs the orchestrator to put
|
|
926
|
+
* `{@link TO_SPEC_BATCH_MARKER}: <token>` as the first line of the batch's
|
|
927
|
+
* shared `context`; the gate matches the marker and the exact token this tick
|
|
928
|
+
* issued, stamps the per-item contract (agent, strict schema), and persists
|
|
929
|
+
* the in-flight rows. A `task` call without this marker is the orchestrator's
|
|
930
|
+
* own and passes untouched.
|
|
931
|
+
*/
|
|
932
|
+
export const TO_SPEC_BATCH_MARKER = "conductor-to-spec-batch";
|
|
933
|
+
|
|
934
|
+
/** The grooming-table verdict row a launched-but-unfinished batch leaves behind
|
|
935
|
+
* (reason, on a `blocked` verdict): the durable in-flight marker that stops
|
|
936
|
+
* the next tick — or a restarted session — from re-launching the same item.
|
|
937
|
+
* `blocked` is deliberate: `recordToSpecGrooming` replaces the row when the
|
|
938
|
+
* result lands, and refusing-to-parse output must *not* be swallowed by the
|
|
939
|
+
* kept-prior path that protects prior `promotable`/`considered` verdicts. */
|
|
940
|
+
export const TO_SPEC_IN_FLIGHT_REASON = "in-flight";
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* How long a launch row may sit before it is treated as a dead batch and the
|
|
944
|
+
* candidate becomes eligible again. A batch that dies with the process (a
|
|
945
|
+
* daemon stop between `tool_call` and delivery) must not park a candidate
|
|
946
|
+
* forever; the 24h ceiling matches the source-freshness ceiling, so a
|
|
947
|
+
* relaunched pass always reads new source evidence anyway.
|
|
948
|
+
*/
|
|
949
|
+
export const TO_SPEC_IN_FLIGHT_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
950
|
+
|
|
951
|
+
/** The first line of every batch item's `task`, in the shape the gate parses:
|
|
952
|
+
* `to-spec candidate: <owner/repo>#<issue> — <title>`. */
|
|
953
|
+
export const TO_SPEC_ITEM_PREFIX = "to-spec candidate:";
|
|
954
|
+
|
|
955
|
+
/** The second line of every batch item's `task`, naming the authoritative
|
|
956
|
+
* source and the exact ref the item was groomed against:
|
|
957
|
+
* `to-spec source: <owner/repo>@<ref>`. `ref` is whatever the launch block
|
|
958
|
+
* told the orchestrator to fetch as the repo's current default-branch head. */
|
|
959
|
+
export const TO_SPEC_ITEM_SOURCE_PREFIX = "to-spec source:";
|
|
960
|
+
|
|
961
|
+
/** The tool the orchestrator calls to persist one completed item's exact raw
|
|
962
|
+
* output when the batch ran in the background (#777). Registered by this
|
|
963
|
+
* extension; subagents never see it — the `to-spec` agent's tool list is
|
|
964
|
+
* read-only and explicit. */
|
|
965
|
+
export const TO_SPEC_RESULT_TOOL = "conductor_to_spec_result";
|
|
966
|
+
|
|
967
|
+
/** One backlog candidate the mechanical gate can judge. The tracker facts
|
|
968
|
+
* (labels, epics) are read from the authoritative open-issue snapshot at
|
|
969
|
+
* launch composition time; they travel in this view so the selector stays
|
|
970
|
+
* deterministic and testable. */
|
|
971
|
+
export interface ToSpecCandidateView {
|
|
972
|
+
issue: number;
|
|
973
|
+
title: string;
|
|
974
|
+
/** The routing target — a routed `owner/repo` (from the issue's one
|
|
975
|
+
* `routing.labelPrefix<key>` label, resolved through `routing.repos`). */
|
|
976
|
+
routing: string;
|
|
977
|
+
/** Operator-parked (`project.stateLabels.backlog`); read from tracker labels. */
|
|
978
|
+
parked?: boolean;
|
|
979
|
+
/** A parent/epic with no independently runnable slice; read from the
|
|
980
|
+
* tracker's sub-issue probe. */
|
|
981
|
+
parent?: boolean;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/** One candidate the tick's token authorizes. The gate admits a batch item
|
|
985
|
+
* only when its issue number AND routing both match an entry here. */
|
|
986
|
+
export interface ToSpecLaunchItem {
|
|
987
|
+
issue: number;
|
|
988
|
+
/** The `owner/repo` the item's first contract line must name. */
|
|
989
|
+
routing: string;
|
|
990
|
+
/** The candidate title, as it appears in the item's first contract line. */
|
|
991
|
+
title: string;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** The machine-readable item contract a batch item must satisfy. */
|
|
995
|
+
export interface ToSpecBatchItem {
|
|
996
|
+
issue: number;
|
|
997
|
+
/** The `owner/repo` named by the item's first contract line. */
|
|
998
|
+
routing: string;
|
|
999
|
+
/** The `owner/repo@ref` named by the item's second contract line. */
|
|
1000
|
+
sourceRef: string;
|
|
1001
|
+
/** The item's full task text (the rendered brief plus the contract lines). */
|
|
1002
|
+
task: string;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Parse the two contract lines a batch item must start with
|
|
1007
|
+
* (`to-spec candidate: <repo>#<n> — <title>` / `to-spec source: <name>@<ref>`).
|
|
1008
|
+
* Anything else is not a conductor grooming item. Built from the shared
|
|
1009
|
+
* {@link TO_SPEC_ITEM_PREFIX}/{@link TO_SPEC_ITEM_SOURCE_PREFIX} constants so
|
|
1010
|
+
* the launch block's wording and the gate's parsing cannot drift apart.
|
|
1011
|
+
*/
|
|
1012
|
+
export function parseToSpecItem(task: unknown): ToSpecBatchItem | undefined {
|
|
1013
|
+
if (typeof task !== "string") return undefined;
|
|
1014
|
+
const lines = task.split("\n");
|
|
1015
|
+
const head = new RegExp(`^${TO_SPEC_ITEM_PREFIX}\\s+([\\w.-]+\\/[\\w.-]+)#(\\d+)(?:\\s+—\\s+.*)?$`).exec(
|
|
1016
|
+
lines[0]?.trim() ?? "",
|
|
1017
|
+
);
|
|
1018
|
+
const source = new RegExp(`^${TO_SPEC_ITEM_SOURCE_PREFIX}\\s+([\\w.-]+\\/[\\w.-]+)@(\\S+)$`).exec(
|
|
1019
|
+
lines[1]?.trim() ?? "",
|
|
1020
|
+
);
|
|
1021
|
+
if (head === null || source === null) return undefined;
|
|
1022
|
+
return { issue: Number(head[2]), routing: head[1]!, sourceRef: source[2]!, task };
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Why one candidate is not eligible for a batch right now, or undefined when
|
|
1027
|
+
* it is. The single rule source for the prompt-time exclusion list, the
|
|
1028
|
+
* `tool_call` gate, and the selection helper — one rule, three readers, so a
|
|
1029
|
+
* candidate excluded in prose is excluded in the gate for the same reason.
|
|
1030
|
+
*
|
|
1031
|
+
* - `in-flight`: a launch row recorded within the TTL (a dead batch's row
|
|
1032
|
+
* expires and the candidate becomes eligible again);
|
|
1033
|
+
* - `file-lane` / `depends-on`: admission's durable mechanical holds (#735);
|
|
1034
|
+
* - a fresh valid `to-spec` result in the grooming table: the candidate was
|
|
1035
|
+
* already groomed at an observed source within the freshness ceiling, so
|
|
1036
|
+
* re-running it would recompute a verdict that is still valid. New source
|
|
1037
|
+
* evidence reconsiders it: once the recorded `freshAt` crosses the ceiling
|
|
1038
|
+
* the row no longer reads as groomed, and a fresh pass overrides it;
|
|
1039
|
+
* - `active`: a run is in flight on the issue right now.
|
|
1040
|
+
*/
|
|
1041
|
+
export function toSpecCandidateExclusion(
|
|
1042
|
+
candidate: { issue: number },
|
|
1043
|
+
facts: { grooming: GroomingRecord | undefined; active: boolean },
|
|
1044
|
+
now: number,
|
|
1045
|
+
): string | undefined {
|
|
1046
|
+
const row = facts.grooming;
|
|
1047
|
+
if (row !== undefined) {
|
|
1048
|
+
if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
|
|
1049
|
+
if (now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) {
|
|
1050
|
+
return `#${candidate.issue} is already in a to-spec batch (launched ${new Date(row.recordedAt).toISOString()})`;
|
|
1051
|
+
}
|
|
1052
|
+
} else if (row.reason === "file-lane" || row.reason === "depends-on") {
|
|
1053
|
+
return `#${candidate.issue} is mechanically blocked (${row.reason}) — the hold clears by itself`;
|
|
1054
|
+
} else {
|
|
1055
|
+
const result = parseToSpecEvidence(row.evidence);
|
|
1056
|
+
if (result !== undefined && now - result.source.freshAt <= TO_SPEC_MAX_SOURCE_AGE_MS) {
|
|
1057
|
+
return (
|
|
1058
|
+
`#${candidate.issue} was already groomed ${result.verdict} (source ${result.source.name}@` +
|
|
1059
|
+
`${result.source.ref}, observed ${new Date(result.source.freshAt).toISOString()}) — re-groom only ` +
|
|
1060
|
+
"with new source evidence"
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
if (facts.active) return `#${candidate.issue} has a dispatched run in flight`;
|
|
1066
|
+
return undefined;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* The mechanical half of Duty 2's launch: deterministic, bounded selection of
|
|
1071
|
+
* the eligible candidates, smallest issue numbers first, never more than
|
|
1072
|
+
* {@link TO_SPEC_BATCH_MAX} per batch, nothing at/above the grooming trigger,
|
|
1073
|
+
* nothing before the first dispatch summary exists (queue health unknown —
|
|
1074
|
+
* the same gate the queue digest uses). Parked and parent views are honored
|
|
1075
|
+
* when the caller supplies them.
|
|
1076
|
+
*
|
|
1077
|
+
* Production reaches this selector through {@link offerToSpecLaunch}, which
|
|
1078
|
+
* fills the views from the authoritative tracker snapshot (park label from
|
|
1079
|
+
* the open-issue labels, parent/epic from the sub-issue probe) and streams
|
|
1080
|
+
* the store-side exclusions before the selector runs — so parked and parent
|
|
1081
|
+
* exclusions are enforced on the live path, not only on test inputs.
|
|
1082
|
+
*/
|
|
1083
|
+
export function selectToSpecBatch(input: {
|
|
1084
|
+
candidates: readonly ToSpecCandidateView[];
|
|
1085
|
+
summary: DispatchSummary | undefined;
|
|
1086
|
+
groomBelow: number;
|
|
1087
|
+
grooming: readonly GroomingRecord[];
|
|
1088
|
+
active: readonly { issue: number }[];
|
|
1089
|
+
now: number;
|
|
1090
|
+
}): ToSpecCandidateView[] {
|
|
1091
|
+
if (input.summary === undefined) return [];
|
|
1092
|
+
if (input.summary.routed >= input.groomBelow) return [];
|
|
1093
|
+
const groomingByIssue = new Map(input.grooming.map((row) => [row.issue, row]));
|
|
1094
|
+
const activeIssues = new Set(input.active.map((run) => run.issue));
|
|
1095
|
+
const selected: ToSpecCandidateView[] = [];
|
|
1096
|
+
for (const candidate of [...input.candidates].sort((a, b) => a.issue - b.issue)) {
|
|
1097
|
+
if (candidate.parked) continue;
|
|
1098
|
+
if (candidate.parent) continue;
|
|
1099
|
+
if (
|
|
1100
|
+
toSpecCandidateExclusion(
|
|
1101
|
+
{ issue: candidate.issue },
|
|
1102
|
+
{ grooming: groomingByIssue.get(candidate.issue), active: activeIssues.has(candidate.issue) },
|
|
1103
|
+
input.now,
|
|
1104
|
+
) !== undefined
|
|
1105
|
+
) {
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
selected.push(candidate);
|
|
1109
|
+
if (selected.length >= TO_SPEC_BATCH_MAX) break;
|
|
1110
|
+
}
|
|
1111
|
+
return selected;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* One tick's launch authorization: the token, the block text it carries, and
|
|
1116
|
+
* the mechanically selected batch the token authorizes. The gate admits a
|
|
1117
|
+
* marker-bearing `task` call only when every item matches an entry of
|
|
1118
|
+
* {@link ToSpecLaunchBlock.items} — the list is produced from the tracker
|
|
1119
|
+
* snapshot, never from model-supplied fields.
|
|
1120
|
+
*/
|
|
1121
|
+
export interface ToSpecLaunchBlock {
|
|
1122
|
+
/** The per-launch token the batch must echo in its `context` first line. */
|
|
1123
|
+
token: string;
|
|
1124
|
+
block: string;
|
|
1125
|
+
/** The mechanically selected candidates — the ONLY batch this token may
|
|
1126
|
+
* carry (issue number AND routing must both match). */
|
|
1127
|
+
items: ToSpecLaunchItem[];
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** Why candidates sit out, as one compact line each, for the launch block. */
|
|
1131
|
+
export interface ToSpecLaunchExclusions {
|
|
1132
|
+
/** Candidates with a fresh, valid to-spec verdict already on the grooming table. */
|
|
1133
|
+
groomed: string[];
|
|
1134
|
+
/** Candidates with an active to-spec batch. */
|
|
1135
|
+
inFlight: string[];
|
|
1136
|
+
/** Candidates under admission's durable lane/dependency holds. */
|
|
1137
|
+
mechanicallyBlocked: string[];
|
|
1138
|
+
/** Candidates with a dispatched run live right now. */
|
|
1139
|
+
dispatched: string[];
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* The authoritative tracker surface the launch selection is built from
|
|
1144
|
+
* (#777): the two reads through the existing Tracker adapter that answer
|
|
1145
|
+
* "which open backlog issues are actually eligible right now". Production
|
|
1146
|
+
* implements this with `makeTracker(...).listOpenIssues()` /
|
|
1147
|
+
* `.childrenOf(...)`; tests inject deterministic fakes. `listOpenIssues` is
|
|
1148
|
+
* the one open-issue snapshot with labels (#203) — it answers the park
|
|
1149
|
+
* label and the routing label mechanically — and `childrenOf` answers
|
|
1150
|
+
* whether a candidate is a parent/epic (its sub-issues exist, so it has no
|
|
1151
|
+
* independently runnable slice of its own). Both fail closed: an unreadable
|
|
1152
|
+
* snapshot means no batch is offered, while an unreadable parent probe skips
|
|
1153
|
+
* that candidate because eligibility was not mechanically established.
|
|
1154
|
+
*/
|
|
1155
|
+
export interface ToSpecTrackerSeam {
|
|
1156
|
+
/** Every open issue in the tracker repo, labels included
|
|
1157
|
+
* (`Tracker.listOpenIssues`). Throws when the tracker cannot be read. */
|
|
1158
|
+
listOpenIssues(project: ProjectConfig): Promise<ReadyIssue[]>;
|
|
1159
|
+
/** Sub-issues of one issue (`Tracker.childrenOf`). */
|
|
1160
|
+
childrenOf(project: ProjectConfig, issue: number): Promise<{ number: number; state: IssueState }[]>;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* The tracker half of the candidate views: map one open-issue snapshot onto
|
|
1165
|
+
* the pool the selector can judge. Issues still carrying the queue label are
|
|
1166
|
+
* already queued — not backlog — and leave the pool; issues with zero or
|
|
1167
|
+
* several `routing.labelPrefix` labels (or a label mapping to no configured
|
|
1168
|
+
* repo) cannot name an authoritative source to read and leave the pool,
|
|
1169
|
+
* exactly like admission's unroutable partition. The park label lands on the
|
|
1170
|
+
* view for the selector to drop; the parent/epic probe is separate (one
|
|
1171
|
+
* tracker read per candidate) and stays with the offer, which runs it only
|
|
1172
|
+
* for candidates the store-side exclusions did not already reject.
|
|
1173
|
+
*/
|
|
1174
|
+
function toSpecPoolFromSnapshot(
|
|
1175
|
+
issues: readonly ReadyIssue[],
|
|
1176
|
+
project: ProjectConfig,
|
|
1177
|
+
): ToSpecCandidateView[] {
|
|
1178
|
+
const queueLabel = project.queueLabel;
|
|
1179
|
+
const parkLabel = project.stateLabels.backlog;
|
|
1180
|
+
const { labelPrefix, repos } = project.routing;
|
|
1181
|
+
const views: ToSpecCandidateView[] = [];
|
|
1182
|
+
for (const issue of issues) {
|
|
1183
|
+
if (issue.labels.includes(queueLabel)) continue;
|
|
1184
|
+
const matched = [...new Set(issue.labels.filter((l) => l.startsWith(labelPrefix)))];
|
|
1185
|
+
if (matched.length !== 1) continue;
|
|
1186
|
+
const key = matched[0]!.slice(labelPrefix.length);
|
|
1187
|
+
const target = Object.hasOwn(repos, key) ? repos[key]! : undefined;
|
|
1188
|
+
if (target === undefined) continue;
|
|
1189
|
+
views.push({
|
|
1190
|
+
issue: issue.number,
|
|
1191
|
+
title: issue.title,
|
|
1192
|
+
routing: repoSlugFor(target),
|
|
1193
|
+
parked: issue.labels.includes(parkLabel) ? true : undefined,
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
views.sort((a, b) => a.issue - b.issue);
|
|
1197
|
+
return views;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* The production launch offer for one tick: the complete mechanical
|
|
1202
|
+
* selection, from the authoritative tracker snapshot through every
|
|
1203
|
+
* exclusion, ending in the token + block + allowlist, or `undefined` when no
|
|
1204
|
+
* batch may launch. All of the following yield `undefined`:
|
|
1205
|
+
*
|
|
1206
|
+
* - no dispatch row yet, or the routable queue is at/above the grooming
|
|
1207
|
+
* trigger (the same gate the queue digest uses);
|
|
1208
|
+
* - no tracker seam (the snapshot is unavailable);
|
|
1209
|
+
* - the snapshot cannot be read — the launch fails closed rather than
|
|
1210
|
+
* trusting the model to self-filter parked/parent/epic candidates;
|
|
1211
|
+
* - nothing survives the exclusions (parked, parent/epic, already groomed,
|
|
1212
|
+
* in-flight, lane/dependency holds, dispatched runs) — there is then no
|
|
1213
|
+
* batch to authorize, and a marker-bearing `task` call stays refused.
|
|
1214
|
+
*
|
|
1215
|
+
* The parent/epic probe runs only for candidates the store-side exclusions
|
|
1216
|
+
* have not already rejected, in issue order, and stops as soon as
|
|
1217
|
+
* {@link TO_SPEC_BATCH_MAX} candidates are selected — a bounded set of
|
|
1218
|
+
* tracker reads per low-queue tick, never one per open issue.
|
|
1219
|
+
*/
|
|
1220
|
+
export async function offerToSpecLaunch(input: {
|
|
1221
|
+
summary: DispatchSummary | undefined;
|
|
1222
|
+
groomBelow: number;
|
|
1223
|
+
grooming: readonly GroomingRecord[];
|
|
1224
|
+
active: readonly { issue: number }[];
|
|
1225
|
+
project: ProjectConfig;
|
|
1226
|
+
trackerSeam: ToSpecTrackerSeam | undefined;
|
|
1227
|
+
/** An open-issue snapshot this tick already read from the tracker. Shared
|
|
1228
|
+
* with the queue digest so one tick cannot describe two queues (#848);
|
|
1229
|
+
* absent when that read failed, in which case the launch retries its own
|
|
1230
|
+
* read and fails closed on the same terms as before. */
|
|
1231
|
+
issues?: readonly ReadyIssue[];
|
|
1232
|
+
now: number;
|
|
1233
|
+
}): Promise<ToSpecLaunchBlock | undefined> {
|
|
1234
|
+
if (input.summary === undefined) return undefined;
|
|
1235
|
+
if (input.summary.routed >= input.groomBelow) return undefined;
|
|
1236
|
+
const seam = input.trackerSeam;
|
|
1237
|
+
if (seam === undefined) return undefined;
|
|
1238
|
+
let issues = input.issues;
|
|
1239
|
+
if (issues === undefined) {
|
|
1240
|
+
try {
|
|
1241
|
+
issues = await seam.listOpenIssues(input.project);
|
|
1242
|
+
} catch {
|
|
1243
|
+
// No authoritative snapshot, no launch: a batch offered without one would
|
|
1244
|
+
// make the model the selector, which is exactly the defect this slice
|
|
1245
|
+
// removes. The queue digest still names the grooming duty; the next tick
|
|
1246
|
+
// retries the read.
|
|
1247
|
+
return undefined;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
const views = toSpecPoolFromSnapshot(issues, input.project);
|
|
1251
|
+
const groomingByIssue = new Map(input.grooming.map((row) => [row.issue, row]));
|
|
1252
|
+
const activeIssues = new Set(input.active.map((run) => run.issue));
|
|
1253
|
+
const pool: ToSpecCandidateView[] = [];
|
|
1254
|
+
for (const view of views) {
|
|
1255
|
+
if (view.parked) continue;
|
|
1256
|
+
if (
|
|
1257
|
+
toSpecCandidateExclusion(
|
|
1258
|
+
{ issue: view.issue },
|
|
1259
|
+
{ grooming: groomingByIssue.get(view.issue), active: activeIssues.has(view.issue) },
|
|
1260
|
+
input.now,
|
|
1261
|
+
) !== undefined
|
|
1262
|
+
) {
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
let children: { number: number; state: IssueState }[];
|
|
1266
|
+
try {
|
|
1267
|
+
children = await seam.childrenOf(input.project, view.issue);
|
|
1268
|
+
} catch {
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
if (children.length > 0) continue; // parent/epic with sub-issues: no runnable slice
|
|
1272
|
+
pool.push(view);
|
|
1273
|
+
if (pool.length >= TO_SPEC_BATCH_MAX) break;
|
|
1274
|
+
}
|
|
1275
|
+
// The one shared rule set re-runs on the tracker-vetted pool, so the pure
|
|
1276
|
+
// selector — not a prose instruction — answers what the block authorizes.
|
|
1277
|
+
const selected = selectToSpecBatch({
|
|
1278
|
+
candidates: pool,
|
|
1279
|
+
summary: input.summary,
|
|
1280
|
+
groomBelow: input.groomBelow,
|
|
1281
|
+
grooming: input.grooming,
|
|
1282
|
+
active: input.active,
|
|
1283
|
+
now: input.now,
|
|
1284
|
+
});
|
|
1285
|
+
if (selected.length === 0) return undefined;
|
|
1286
|
+
return toSpecLaunchBlock({
|
|
1287
|
+
summary: input.summary,
|
|
1288
|
+
groomBelow: input.groomBelow,
|
|
1289
|
+
grooming: input.grooming,
|
|
1290
|
+
active: input.active,
|
|
1291
|
+
tracker: input.project.tracker.repo,
|
|
1292
|
+
queueLabel: input.project.queueLabel,
|
|
1293
|
+
labelPrefix: input.project.routing.labelPrefix,
|
|
1294
|
+
parkLabel: input.project.stateLabels.backlog,
|
|
1295
|
+
selected,
|
|
1296
|
+
now: input.now,
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* The per-tick launch block. Present exactly when a batch may be launched:
|
|
1302
|
+
* the queue is below the grooming trigger (`summary.routed < groomBelow`),
|
|
1303
|
+
* a dispatch row exists, and the mechanical selection produced at least one
|
|
1304
|
+
* candidate. The block names the selected candidates as the ONLY batch this
|
|
1305
|
+
* tick authorizes and lists every store-proven exclusion the selection
|
|
1306
|
+
* already applied; the `tool_call` gate refuses any item outside the list.
|
|
1307
|
+
*/
|
|
1308
|
+
export function toSpecLaunchBlock(input: {
|
|
1309
|
+
summary: DispatchSummary | undefined;
|
|
1310
|
+
groomBelow: number;
|
|
1311
|
+
grooming: readonly GroomingRecord[];
|
|
1312
|
+
active: readonly { issue: number }[];
|
|
1313
|
+
tracker: string;
|
|
1314
|
+
queueLabel: string;
|
|
1315
|
+
labelPrefix: string;
|
|
1316
|
+
parkLabel: string;
|
|
1317
|
+
/** The mechanically selected candidates the block renders and authorizes. */
|
|
1318
|
+
selected: readonly ToSpecCandidateView[];
|
|
1319
|
+
now: number;
|
|
1320
|
+
}): ToSpecLaunchBlock | undefined {
|
|
1321
|
+
if (input.summary === undefined) return undefined;
|
|
1322
|
+
if (input.summary.routed >= input.groomBelow) return undefined;
|
|
1323
|
+
if (input.selected.length === 0) return undefined;
|
|
1324
|
+
const exclusions: ToSpecLaunchExclusions = { groomed: [], inFlight: [], mechanicallyBlocked: [], dispatched: [] };
|
|
1325
|
+
for (const row of input.grooming) {
|
|
1326
|
+
if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
|
|
1327
|
+
if (input.now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) exclusions.inFlight.push(`#${row.issue}`);
|
|
1328
|
+
} else if (row.reason === "file-lane" || row.reason === "depends-on") {
|
|
1329
|
+
exclusions.mechanicallyBlocked.push(`#${row.issue} (${row.reason})`);
|
|
1330
|
+
} else {
|
|
1331
|
+
const result = parseToSpecEvidence(row.evidence);
|
|
1332
|
+
if (result !== undefined && input.now - result.source.freshAt <= TO_SPEC_MAX_SOURCE_AGE_MS) {
|
|
1333
|
+
exclusions.groomed.push(
|
|
1334
|
+
`#${row.issue} (${result.verdict} @ ${result.source.ref}, observed ${new Date(result.source.freshAt).toISOString()})`,
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
for (const run of input.active) exclusions.dispatched.push(`#${run.issue}`);
|
|
1340
|
+
const token = randomUUID();
|
|
1341
|
+
const items: ToSpecLaunchItem[] = input.selected.map((candidate) => ({
|
|
1342
|
+
issue: candidate.issue,
|
|
1343
|
+
routing: candidate.routing,
|
|
1344
|
+
title: candidate.title,
|
|
1345
|
+
}));
|
|
1346
|
+
const candidates = items.map((item) => `- #${item.issue} (${item.routing}) — ${item.title}`).join("\n");
|
|
1347
|
+
const lines = [
|
|
1348
|
+
`## Bounded to-spec grooming batch (${TO_SPEC_BATCH_MARKER}, #777)`,
|
|
1349
|
+
"",
|
|
1350
|
+
`The routable queue is below the grooming trigger of ${input.groomBelow} (${input.summary.routed} routable). ` +
|
|
1351
|
+
"Duty 2's finding is now a strict contract: launch EXACTLY ONE native `task` batch this turn with the " +
|
|
1352
|
+
`\`${TO_SPEC_AGENT}\` agent — never a second batch, never an improvised scout.`,
|
|
1353
|
+
"",
|
|
1354
|
+
"The conductor selected this batch mechanically from the live open-issue snapshot " +
|
|
1355
|
+
`(issues carrying \`${input.queueLabel}\`, the \`${input.parkLabel}\` park label, issues without exactly one ` +
|
|
1356
|
+
`\`${input.labelPrefix}<repo>\` routing label, parent/epic issues with sub-issues, ` +
|
|
1357
|
+
"already-groomed, in-flight, lane/dependency-blocked and dispatched candidates were excluded):",
|
|
1358
|
+
candidates,
|
|
1359
|
+
"Excluded this tick — " +
|
|
1360
|
+
`already-groomed: ${exclusions.groomed.length === 0 ? "none" : exclusions.groomed.join(", ")}; ` +
|
|
1361
|
+
`in-flight batches — ${exclusions.inFlight.length === 0 ? "none" : exclusions.inFlight.join(", ")}; ` +
|
|
1362
|
+
`mechanically blocked: ${exclusions.mechanicallyBlocked.length === 0 ? "none" : exclusions.mechanicallyBlocked.join(", ")}; ` +
|
|
1363
|
+
`dispatched now: ${exclusions.dispatched.length === 0 ? "none" : exclusions.dispatched.join(", ")}.`,
|
|
1364
|
+
"",
|
|
1365
|
+
"To launch it:",
|
|
1366
|
+
"",
|
|
1367
|
+
`1. For each candidate above, render \`omp/src/briefs/to-spec.md\` with its placeholders — {{TRACKER_REPO}}, ` +
|
|
1368
|
+
"{{ISSUE_NUMBER}}, {{CANDIDATE_TITLE}}, {{ISSUE_BODY}} (read via `gh issue view <number> --repo " +
|
|
1369
|
+
`${input.tracker}\`, never from memory), {{SOURCE}} = the candidate's routed repo, {{SOURCE_REF}} = that repo's \`` +
|
|
1370
|
+
"current default-branch head, fetched now (`gh api repos/<repo>/branches/HEAD` or `git ls-remote`). Your item " +
|
|
1371
|
+
"`task` MUST start with the two contract lines `to-spec candidate: <owner/repo>#<issue> — <title>` and " +
|
|
1372
|
+
"`to-spec source: <owner/repo>@<ref>` — the `owner/repo` must match the routing named above.",
|
|
1373
|
+
`2. Call \`task\` once with \`context\` whose first line is exactly \`${TO_SPEC_BATCH_MARKER}: ${token}\`, one ` +
|
|
1374
|
+
`item per candidate above, in that order — no substitutes, no extra items (\`agent: "${TO_SPEC_AGENT}"\`; this ` +
|
|
1375
|
+
"extension stamps the exact outputSchema/schemaMode on the way in and refuses a second batch this turn).",
|
|
1376
|
+
`3. When each item completes, whether in the tool result or later as an async-result message, persist the agent's ` +
|
|
1377
|
+
`exact raw output through the \`${TO_SPEC_RESULT_TOOL}\` tool — one call per completed item (\`issue\` + \`input\`), ` +
|
|
1378
|
+
"success and failure alike: a malformed, source-less or stale result persists as blocked and must not discard " +
|
|
1379
|
+
"successful siblings. Read the full output from its `agent://<id>` artifact when the inline text is truncated.",
|
|
1380
|
+
`Never add \`${input.queueLabel}\`, never edit an issue or its labels. This batch produces verdicts only; ` +
|
|
1381
|
+
"promotion stays your decision — the tool_call gate and the store own persistence, you own the queue.",
|
|
1382
|
+
"",
|
|
1383
|
+
];
|
|
1384
|
+
return { token, block: lines.join("\n"), items };
|
|
662
1385
|
}
|
|
663
1386
|
|
|
664
1387
|
export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScopeChoice]: string } = {
|
|
@@ -2284,6 +3007,29 @@ interface TickSession {
|
|
|
2284
3007
|
legacyArmLogged: boolean;
|
|
2285
3008
|
/** The local tick whose agent loop is currently running, if any. */
|
|
2286
3009
|
activeLocalTick?: ActiveLocalTick;
|
|
3010
|
+
/**
|
|
3011
|
+
* The to-spec launch token the last low-queue tick authorized (#777). Set
|
|
3012
|
+
* when the tick appended a launch block; the `task` tool_call gate accepts
|
|
3013
|
+
* exactly one batch echoing it and refuses a marker-bearing call without
|
|
3014
|
+
* it (no authorized batch this tick), with a stale token, or after a batch
|
|
3015
|
+
* was already accepted. Cleared at the start of every tick — healthy and
|
|
3016
|
+
* low-queue alike — so a token minted on a low-queue tick can never be
|
|
3017
|
+
* spent on a later healthy-queue tick; the tick that mints the next token
|
|
3018
|
+
* sets it fresh with its own {@link TickSession.launchItems}.
|
|
3019
|
+
*/
|
|
3020
|
+
launchToken?: string;
|
|
3021
|
+
/** The project the {launchToken} authorization was minted for — the one
|
|
3022
|
+
* in-flight rows are recorded against. */
|
|
3023
|
+
launchProject?: string;
|
|
3024
|
+
/** The mechanically selected batch the current token authorizes: the gate
|
|
3025
|
+
* refuses any item whose issue or routing is not on this list, so parked,
|
|
3026
|
+
* parent/epic and other excluded candidates cannot be stamped in-flight
|
|
3027
|
+
* no matter what the model sends. */
|
|
3028
|
+
launchItems?: ToSpecLaunchItem[];
|
|
3029
|
+
/** The one batch this session's gate has let through, so a second attempt
|
|
3030
|
+
* this tick refuses, and the tool_result capture can match results to
|
|
3031
|
+
* items by index. */
|
|
3032
|
+
launchedBatch?: { toolCallId: string; items: ToSpecBatchItem[] };
|
|
2287
3033
|
}
|
|
2288
3034
|
|
|
2289
3035
|
/**
|
|
@@ -2357,7 +3103,20 @@ async function ensureAskSurface(pi: TickApi, resolvable: boolean): Promise<boole
|
|
|
2357
3103
|
* way. Skips are deliberately silent in the UI — a disarmed fleet would
|
|
2358
3104
|
* otherwise emit a notification every interval, forever.
|
|
2359
3105
|
*/
|
|
2360
|
-
async function tick(
|
|
3106
|
+
async function tick(
|
|
3107
|
+
pi: TickApi,
|
|
3108
|
+
ctx: TickContext,
|
|
3109
|
+
config: TickConfig,
|
|
3110
|
+
session: TickSession,
|
|
3111
|
+
toSpecTrackerSeam: ToSpecTrackerSeam | undefined,
|
|
3112
|
+
): Promise<void> {
|
|
3113
|
+
// A launch authorization belongs to one emitted tick only. Revoke it before
|
|
3114
|
+
// any gate, config, store or tracker read so a skipped/degraded/healthy next
|
|
3115
|
+
// tick cannot reuse a token minted by an earlier low-queue tick.
|
|
3116
|
+
session.launchedBatch = undefined;
|
|
3117
|
+
session.launchToken = undefined;
|
|
3118
|
+
session.launchProject = undefined;
|
|
3119
|
+
session.launchItems = undefined;
|
|
2361
3120
|
const live = currentConfig(ctx.cwd, config);
|
|
2362
3121
|
const arm = live.armedFile === undefined ? undefined : resolveArmState(live.armedFile, live.project);
|
|
2363
3122
|
if (arm?.legacy === "stranded" && !session.legacyArmLogged) {
|
|
@@ -2602,13 +3361,92 @@ async function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session:
|
|
|
2602
3361
|
// so the resolved name is in hand and an un-named lookup would refuse
|
|
2603
3362
|
// to guess on a host with a second project.
|
|
2604
3363
|
const project = findProject(loadConfig(), scope.projectName);
|
|
3364
|
+
const groomBelow = project.groomBelow ?? DEFAULT_GROOM_BELOW;
|
|
3365
|
+
const dispatch = frictionStore.latestDispatch(scope.projectName);
|
|
3366
|
+
const grooming = store.groomingVerdicts(projectName);
|
|
3367
|
+
// #848: "the queue is empty / running dry" is the tracker's word, not
|
|
3368
|
+
// the last dispatch pass's echo. One open-issue snapshot per tick,
|
|
3369
|
+
// read live through the same authoritative tracker surface the
|
|
3370
|
+
// to-spec launch uses, overlaid with the label_ops projection
|
|
3371
|
+
// exactly as the dispatch pass judges eligibility — so a promotion,
|
|
3372
|
+
// unblock or projection write after the last dispatch pass is
|
|
3373
|
+
// visible on the very next tick even while a drain holds claiming.
|
|
3374
|
+
// The digest consumes the overlay as the live inventory; the to-spec
|
|
3375
|
+
// offer shares the same raw snapshot so one tick cannot describe two
|
|
3376
|
+
// queues. If the tracker read fails, the digest falls back to
|
|
3377
|
+
// explicitly dated wording (never a present-tense empty claim),
|
|
3378
|
+
// and the offer retries its own read exactly as it did before.
|
|
3379
|
+
//
|
|
3380
|
+
// The read is made only for dispatch rows the digest could render:
|
|
3381
|
+
// a healthy row (routed at/above the grooming threshold) emits no
|
|
3382
|
+
// queue line at all, so no freshness check is spent on it and no
|
|
3383
|
+
// stale claim can leak from it. A zero-ready row (or any row below
|
|
3384
|
+
// the trigger) is exactly where the old wording lied, so every such
|
|
3385
|
+
// tick reads the live queue.
|
|
3386
|
+
const needsQueueRead =
|
|
3387
|
+
dispatch !== undefined &&
|
|
3388
|
+
(dispatch.ready === 0 || dispatch.routed === 0 || dispatch.routed < groomBelow);
|
|
3389
|
+
let queueObservation: QueueObservation | undefined;
|
|
3390
|
+
let openSnapshot: readonly ReadyIssue[] | undefined;
|
|
3391
|
+
if (toSpecTrackerSeam !== undefined && needsQueueRead) {
|
|
3392
|
+
try {
|
|
3393
|
+
const open = await toSpecTrackerSeam.listOpenIssues(project);
|
|
3394
|
+
openSnapshot = open;
|
|
3395
|
+
const effective = open.map((issue) => {
|
|
3396
|
+
const pending = store.pendingLabelOpsFor(projectName, issue.number);
|
|
3397
|
+
return pending.length === 0
|
|
3398
|
+
? issue
|
|
3399
|
+
: { ...issue, labels: effectiveLabels(issue.labels, pending) };
|
|
3400
|
+
});
|
|
3401
|
+
queueObservation = {
|
|
3402
|
+
queued: effective.filter((issue) => issue.labels.includes(project.queueLabel)).length,
|
|
3403
|
+
observedAt: now,
|
|
3404
|
+
};
|
|
3405
|
+
} catch {
|
|
3406
|
+
// No current-queue evidence this tick: the digest below renders
|
|
3407
|
+
// the dispatch row as dated history instead of claiming empty.
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
2605
3410
|
const queue = queueDigestLine(
|
|
2606
|
-
|
|
3411
|
+
dispatch,
|
|
2607
3412
|
project.queueLabel,
|
|
2608
3413
|
project.routing.labelPrefix,
|
|
2609
|
-
|
|
3414
|
+
groomBelow,
|
|
3415
|
+
grooming,
|
|
3416
|
+
queueObservation,
|
|
2610
3417
|
);
|
|
2611
3418
|
if (queue !== undefined) content = `${content}\n${queue}`;
|
|
3419
|
+
// #777: the mechanical to-spec launch boundary. The queue digest is
|
|
3420
|
+
// the trigger — the same below-`groomBelow` signal that already asks
|
|
3421
|
+
// the orchestrator to groom — so the launch offer is composed beside
|
|
3422
|
+
// it, from the same store read, and only when a dispatch row exists
|
|
3423
|
+
// to prove the queue is genuinely low. The token it carries is the
|
|
3424
|
+
// only authorization the `task` tool_call gate accepts this tick;
|
|
3425
|
+
// absent a block (healthy queue, no dispatch, unreadable snapshot,
|
|
3426
|
+
// nothing eligible) a task call with the batch marker is refused.
|
|
3427
|
+
//
|
|
3428
|
+
// Every tick owns its authorization fresh — the clears above ran
|
|
3429
|
+
// before the reads, so the offer below can only mint for THIS tick.
|
|
3430
|
+
const launch = await offerToSpecLaunch({
|
|
3431
|
+
summary: dispatch,
|
|
3432
|
+
groomBelow,
|
|
3433
|
+
grooming,
|
|
3434
|
+
active: store.activeRuns(projectName),
|
|
3435
|
+
issues: openSnapshot,
|
|
3436
|
+
project,
|
|
3437
|
+
trackerSeam: toSpecTrackerSeam,
|
|
3438
|
+
now,
|
|
3439
|
+
});
|
|
3440
|
+
if (launch !== undefined) {
|
|
3441
|
+
content = `${content}\n${launch.block}`;
|
|
3442
|
+
// A minted token authorizes exactly the batch this tick's block
|
|
3443
|
+
// describes: the project and the allowlist are captured so the
|
|
3444
|
+
// tool_call gate records in-flight rows against the same project
|
|
3445
|
+
// the prompt named and refuses any item off the list.
|
|
3446
|
+
session.launchProject = project.name;
|
|
3447
|
+
session.launchToken = launch.token;
|
|
3448
|
+
session.launchItems = launch.items;
|
|
3449
|
+
}
|
|
2612
3450
|
// Pending intake is the same class of standing block as the friction
|
|
2613
3451
|
// and decisions read-outs: a store-backed duty the orchestrator must
|
|
2614
3452
|
// not derive from memory. The store answers, the prompt instructs.
|
|
@@ -2619,7 +3457,7 @@ async function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session:
|
|
|
2619
3457
|
});
|
|
2620
3458
|
if (pendingIntake !== undefined) content = `${content}\n${pendingIntake}`;
|
|
2621
3459
|
} catch {
|
|
2622
|
-
// unreadable config: no queue digest or pending-intake block this tick
|
|
3460
|
+
// unreadable config: no queue digest, launch block, or pending-intake block this tick
|
|
2623
3461
|
}
|
|
2624
3462
|
} catch (err) {
|
|
2625
3463
|
frictionStore?.close();
|
|
@@ -2780,11 +3618,17 @@ function writeTickRuntimeStatus(pi: TickApi, cwd: string, intervalSeconds: numbe
|
|
|
2780
3618
|
* heartbeat interval keeps handing its promise to the harness, which routes
|
|
2781
3619
|
* rejections to the extension error channel on its own.
|
|
2782
3620
|
*/
|
|
2783
|
-
function armTickHeartbeat(
|
|
3621
|
+
function armTickHeartbeat(
|
|
3622
|
+
pi: TickApi,
|
|
3623
|
+
ctx: TickContext,
|
|
3624
|
+
config: TickConfig,
|
|
3625
|
+
session: TickSession,
|
|
3626
|
+
toSpecTrackerSeam: ToSpecTrackerSeam | undefined,
|
|
3627
|
+
): void {
|
|
2784
3628
|
writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
|
|
2785
3629
|
const runScheduledTick = async (): Promise<void> => {
|
|
2786
3630
|
try {
|
|
2787
|
-
await tick(pi, ctx, config, session);
|
|
3631
|
+
await tick(pi, ctx, config, session, toSpecTrackerSeam);
|
|
2788
3632
|
} finally {
|
|
2789
3633
|
writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
|
|
2790
3634
|
}
|
|
@@ -2840,7 +3684,7 @@ function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, ses
|
|
|
2840
3684
|
// call runs inside the session_start handler dispatch, which cannot see an
|
|
2841
3685
|
// async rejection — an escaped one reaches the process-level
|
|
2842
3686
|
// unhandledRejection handler and takes the session down.
|
|
2843
|
-
void tick(pi, ctx, config, session).catch((err) => {
|
|
3687
|
+
void tick(pi, ctx, config, session, toSpecTrackerSeam).catch((err) => {
|
|
2844
3688
|
pi.logger.error(
|
|
2845
3689
|
`[omp-conductor] arm-time tick failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2846
3690
|
);
|
|
@@ -2884,6 +3728,303 @@ function armTickGuard(pi: TickApi, ctx: TickContext, budgetMs: number): void {
|
|
|
2884
3728
|
});
|
|
2885
3729
|
}
|
|
2886
3730
|
|
|
3731
|
+
/**
|
|
3732
|
+
* The harness surface one to-spec batch travels through (#777): the `task`
|
|
3733
|
+
* tool call the orchestrator makes (with the launch marker in `context`) and
|
|
3734
|
+
* its result. `details` is the task tool's `TaskToolDetails` — declared here
|
|
3735
|
+
* rather than imported, exactly like {@link TickApi}, because the harness is
|
|
3736
|
+
* a peer dependency. A settled batch's `details.results` carries one entry
|
|
3737
|
+
* per item; a background launch returns an empty `results` array and delivers
|
|
3738
|
+
* each final result later as an async-result message, which the orchestrator
|
|
3739
|
+
* routes through {@link TO_SPEC_RESULT_TOOL}.
|
|
3740
|
+
*/
|
|
3741
|
+
interface ToSpecTaskToolEvent {
|
|
3742
|
+
toolName: string;
|
|
3743
|
+
toolCallId: string;
|
|
3744
|
+
input: Record<string, unknown>;
|
|
3745
|
+
details: unknown;
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3748
|
+
/** One settled batch item, as `details.results` carries it. */
|
|
3749
|
+
interface ToSpecSettledItem {
|
|
3750
|
+
/** The item's index inside the batch call. */
|
|
3751
|
+
index?: unknown;
|
|
3752
|
+
/** The agent's raw output — the exact text that must reach the parser. */
|
|
3753
|
+
output?: unknown;
|
|
3754
|
+
/** The harness's parsed structured output, when one exists. */
|
|
3755
|
+
structuredOutput?: unknown;
|
|
3756
|
+
}
|
|
3757
|
+
|
|
3758
|
+
/**
|
|
3759
|
+
* The launch gate and result capture for one to-spec batch (#777). Armed at
|
|
3760
|
+
* extension-factory time; inert until a low-queue tick mints a launch token.
|
|
3761
|
+
*
|
|
3762
|
+
* `tool_call` on `task`:
|
|
3763
|
+
* - a call without the {@link TO_SPEC_BATCH_MARKER} in `context` is the
|
|
3764
|
+
* orchestrator's own task use and passes untouched;
|
|
3765
|
+
* - a marker-bearing call is the conductor batch and is gated hard: it needs
|
|
3766
|
+
* this tick's exact token, the batch shape (1..{@link TO_SPEC_BATCH_MAX}
|
|
3767
|
+
* items, each starting with the two contract lines), unique items and
|
|
3768
|
+
* names, every item ON the tick's mechanically selected allowlist (issue
|
|
3769
|
+
* and routing — the parked/parent/epic exclusions are baked into that
|
|
3770
|
+
* list, so a forbidden candidate can never be stamped in-flight), and
|
|
3771
|
+
* store-side eligibility — the same {@link toSpecCandidateExclusion} rule
|
|
3772
|
+
* the prompt block's exclusion lines came from, so an item excluded in
|
|
3773
|
+
* prose is excluded in the gate for the same reason;
|
|
3774
|
+
* - on acceptance it stamps every item's `agent`, `outputSchema` and
|
|
3775
|
+
* `schemaMode` (the model never carries the schema itself), records the
|
|
3776
|
+
* durable in-flight rows through the #735 grooming table, and latches the
|
|
3777
|
+
* session so a second batch this tick refuses.
|
|
3778
|
+
*
|
|
3779
|
+
* `tool_result` on the accepted call captures a *settled* batch: every item
|
|
3780
|
+
* is routed through {@link recordToSpecGrooming} independently, so a
|
|
3781
|
+
* malformed or failed item persists `blocked` and its siblings survive. A
|
|
3782
|
+
* background launch settles with no results here — its completed items arrive
|
|
3783
|
+
* as async-result messages and are persisted through
|
|
3784
|
+
* {@link TO_SPEC_RESULT_TOOL}.
|
|
3785
|
+
*/
|
|
3786
|
+
function armToSpecGate(pi: TickApi, session: TickSession): void {
|
|
3787
|
+
const api = pi as TickApi & {
|
|
3788
|
+
on(
|
|
3789
|
+
event: "tool_call",
|
|
3790
|
+
handler: (
|
|
3791
|
+
event: { toolName: string; toolCallId: string; input: Record<string, unknown> },
|
|
3792
|
+
ctx: unknown,
|
|
3793
|
+
) => { block: true; reason: string } | { input: Record<string, unknown> } | undefined,
|
|
3794
|
+
): void;
|
|
3795
|
+
on(event: "tool_result", handler: (event: ToSpecTaskToolEvent, ctx: unknown) => void): void;
|
|
3796
|
+
};
|
|
3797
|
+
|
|
3798
|
+
api.on("tool_call", (event) => {
|
|
3799
|
+
if (event.toolName !== "task") return undefined;
|
|
3800
|
+
const input = event.input;
|
|
3801
|
+
const context = input["context"];
|
|
3802
|
+
if (typeof context !== "string" || !context.includes(TO_SPEC_BATCH_MARKER)) return undefined;
|
|
3803
|
+
// From here on this is a conductor grooming batch — the hard gate. Each
|
|
3804
|
+
// refusal names the correction, because a blocked batch costs the tick a
|
|
3805
|
+
// retry and the gate is meant to catch model error, not to hide it.
|
|
3806
|
+
if (session.launchToken === undefined) {
|
|
3807
|
+
return {
|
|
3808
|
+
block: true,
|
|
3809
|
+
reason:
|
|
3810
|
+
`task refused: no to-spec batch is authorized this tick (` +
|
|
3811
|
+
`only a low-queue tick's launch block names a ${TO_SPEC_BATCH_MARKER} token). ` +
|
|
3812
|
+
"Run no batch this turn.",
|
|
3813
|
+
};
|
|
3814
|
+
}
|
|
3815
|
+
if (session.launchedBatch !== undefined) {
|
|
3816
|
+
return {
|
|
3817
|
+
block: true,
|
|
3818
|
+
reason: "task refused: this tick already launched its one to-spec batch. Wait for the results.",
|
|
3819
|
+
};
|
|
3820
|
+
}
|
|
3821
|
+
const token = new RegExp(`${TO_SPEC_BATCH_MARKER}:\\s*(\\S+)`).exec(context)?.[1];
|
|
3822
|
+
if (token !== session.launchToken) {
|
|
3823
|
+
return {
|
|
3824
|
+
block: true,
|
|
3825
|
+
reason:
|
|
3826
|
+
"task refused: the context token does not match the batch this tick authorized. " +
|
|
3827
|
+
"Relaunch with the token named in this tick's launch block.",
|
|
3828
|
+
};
|
|
3829
|
+
}
|
|
3830
|
+
const tasks = input["tasks"];
|
|
3831
|
+
if (!Array.isArray(tasks) || tasks.length === 0 || tasks.length > TO_SPEC_BATCH_MAX) {
|
|
3832
|
+
return {
|
|
3833
|
+
block: true,
|
|
3834
|
+
reason:
|
|
3835
|
+
`task refused: a to-spec batch carries 1–${TO_SPEC_BATCH_MAX} items (one \`tasks[]\` call) — ` +
|
|
3836
|
+
`got ${Array.isArray(tasks) ? tasks.length : "none"}.`,
|
|
3837
|
+
};
|
|
3838
|
+
}
|
|
3839
|
+
const items: ToSpecBatchItem[] = [];
|
|
3840
|
+
const names = new Set<string>();
|
|
3841
|
+
for (const raw of tasks) {
|
|
3842
|
+
if (typeof raw !== "object" || raw === null) {
|
|
3843
|
+
return { block: true, reason: "task refused: every batch item must be an object." };
|
|
3844
|
+
}
|
|
3845
|
+
const item = parseToSpecItem((raw as Record<string, unknown>)["task"]);
|
|
3846
|
+
if (item === undefined) {
|
|
3847
|
+
return {
|
|
3848
|
+
block: true,
|
|
3849
|
+
reason:
|
|
3850
|
+
"task refused: every item's `task` must start with the two contract lines " +
|
|
3851
|
+
"`to-spec candidate: <owner/repo>#<issue> — <title>` and `to-spec source: <owner/repo>@<ref>`.",
|
|
3852
|
+
};
|
|
3853
|
+
}
|
|
3854
|
+
const name = (raw as Record<string, unknown>)["name"];
|
|
3855
|
+
if (typeof name === "string" && name.length > 0) {
|
|
3856
|
+
if (names.has(name)) {
|
|
3857
|
+
return { block: true, reason: `task refused: duplicate item name \`${name}\`.` };
|
|
3858
|
+
}
|
|
3859
|
+
names.add(name);
|
|
3860
|
+
}
|
|
3861
|
+
items.push(item);
|
|
3862
|
+
}
|
|
3863
|
+
for (let i = 1; i < items.length; i += 1) {
|
|
3864
|
+
for (let j = 0; j < i; j += 1) {
|
|
3865
|
+
if (items[j]!.issue === items[i]!.issue) {
|
|
3866
|
+
return {
|
|
3867
|
+
block: true,
|
|
3868
|
+
reason: `task refused: #${items[i]!.issue} appears twice in one batch.`,
|
|
3869
|
+
};
|
|
3870
|
+
}
|
|
3871
|
+
}
|
|
3872
|
+
}
|
|
3873
|
+
// The allowlist is the mechanical selection this tick's token authorized:
|
|
3874
|
+
// every item's issue number AND routing must match an entry the launch
|
|
3875
|
+
// offer produced from the authoritative tracker snapshot. Parked,
|
|
3876
|
+
// parent/epic, already-groomed, in-flight, held and dispatched candidates
|
|
3877
|
+
// were never on it, so no item carrying one can be stamped in-flight here
|
|
3878
|
+
// — the model cannot self-select past the conductor's selection (#805).
|
|
3879
|
+
const allowlist = session.launchItems;
|
|
3880
|
+
if (allowlist === undefined) {
|
|
3881
|
+
return {
|
|
3882
|
+
block: true,
|
|
3883
|
+
reason:
|
|
3884
|
+
"task refused: this tick's launch did not carry a mechanically selected batch — " +
|
|
3885
|
+
"re-tick before launching.",
|
|
3886
|
+
};
|
|
3887
|
+
}
|
|
3888
|
+
for (const item of items) {
|
|
3889
|
+
if (!allowlist.some((allowed) => allowed.issue === item.issue && allowed.routing === item.routing)) {
|
|
3890
|
+
const listed = allowlist.map((allowed) => `#${allowed.issue} (${allowed.routing})`).join(", ");
|
|
3891
|
+
return {
|
|
3892
|
+
block: true,
|
|
3893
|
+
reason:
|
|
3894
|
+
`task refused: #${item.issue} is not on this tick's mechanically selected to-spec batch ` +
|
|
3895
|
+
`(${listed}). Launch exactly the selected candidates, no substitutes.`,
|
|
3896
|
+
};
|
|
3897
|
+
}
|
|
3898
|
+
}
|
|
3899
|
+
const projectName = session.launchProject;
|
|
3900
|
+
if (projectName === undefined) {
|
|
3901
|
+
return {
|
|
3902
|
+
block: true,
|
|
3903
|
+
reason: "task refused: the batch was authorized without a project — re-tick before launching.",
|
|
3904
|
+
};
|
|
3905
|
+
}
|
|
3906
|
+
// Store-side eligibility, the same rule the prompt block's exclusion list
|
|
3907
|
+
// came from. Eligible items then become durable in-flight rows, so a
|
|
3908
|
+
// crashed batch still suppresses re-launch until the TTL expires.
|
|
3909
|
+
let store: Store | undefined;
|
|
3910
|
+
try {
|
|
3911
|
+
store = openStore(dbPath());
|
|
3912
|
+
const byIssue = new Map(
|
|
3913
|
+
store.groomingVerdicts(projectName).map((row) => [row.issue, row] as const),
|
|
3914
|
+
);
|
|
3915
|
+
const active = new Set(store.activeRuns(projectName).map((run) => run.issue));
|
|
3916
|
+
const now = Date.now();
|
|
3917
|
+
for (const item of items) {
|
|
3918
|
+
const reason = toSpecCandidateExclusion(
|
|
3919
|
+
{ issue: item.issue },
|
|
3920
|
+
{ grooming: byIssue.get(item.issue), active: active.has(item.issue) },
|
|
3921
|
+
now,
|
|
3922
|
+
);
|
|
3923
|
+
if (reason !== undefined) {
|
|
3924
|
+
return {
|
|
3925
|
+
block: true,
|
|
3926
|
+
reason: `task refused: ${reason}. Drop that item (and every other listed exclusion) from this batch and re-call.`,
|
|
3927
|
+
};
|
|
3928
|
+
}
|
|
3929
|
+
}
|
|
3930
|
+
const launchedEvidence = JSON.stringify({
|
|
3931
|
+
kind: "to-spec-in-flight",
|
|
3932
|
+
launchedAt: now,
|
|
3933
|
+
batch: token,
|
|
3934
|
+
agent: TO_SPEC_AGENT,
|
|
3935
|
+
});
|
|
3936
|
+
for (const item of items) {
|
|
3937
|
+
store.upsertGrooming({
|
|
3938
|
+
project: projectName,
|
|
3939
|
+
issue: item.issue,
|
|
3940
|
+
verdict: "blocked",
|
|
3941
|
+
reason: TO_SPEC_IN_FLIGHT_REASON,
|
|
3942
|
+
evidence: launchedEvidence,
|
|
3943
|
+
at: now,
|
|
3944
|
+
});
|
|
3945
|
+
}
|
|
3946
|
+
} catch (err) {
|
|
3947
|
+
pi.logger.error(
|
|
3948
|
+
`[omp-conductor] to-spec launch not recorded: ${err instanceof Error ? err.message : String(err)}`,
|
|
3949
|
+
);
|
|
3950
|
+
return {
|
|
3951
|
+
block: true,
|
|
3952
|
+
reason: `task refused: the launch could not be recorded durably (${
|
|
3953
|
+
err instanceof Error ? err.message : String(err)
|
|
3954
|
+
}); nothing was started.`,
|
|
3955
|
+
};
|
|
3956
|
+
} finally {
|
|
3957
|
+
store?.close();
|
|
3958
|
+
}
|
|
3959
|
+
const stampedTasks = tasks.map((raw) => ({
|
|
3960
|
+
...(raw as Record<string, unknown>),
|
|
3961
|
+
agent: TO_SPEC_AGENT,
|
|
3962
|
+
outputSchema: TO_SPEC_SCHEMA,
|
|
3963
|
+
schemaMode: "strict",
|
|
3964
|
+
}));
|
|
3965
|
+
session.launchedBatch = { toolCallId: event.toolCallId, items };
|
|
3966
|
+
pi.logger.info(
|
|
3967
|
+
`[omp-conductor] to-spec batch launched: ${items.map((item) => `#${item.issue}`).join(", ")} (${token})`,
|
|
3968
|
+
);
|
|
3969
|
+
return { input: { ...input, tasks: stampedTasks } };
|
|
3970
|
+
});
|
|
3971
|
+
|
|
3972
|
+
api.on("tool_result", (event) => {
|
|
3973
|
+
if (event.toolName !== "task") return;
|
|
3974
|
+
const batch = session.launchedBatch;
|
|
3975
|
+
if (batch === undefined || event.toolCallId !== batch.toolCallId) return;
|
|
3976
|
+
const projectName = session.launchProject;
|
|
3977
|
+
if (projectName === undefined) return;
|
|
3978
|
+
const details = event.details as { results?: unknown } | undefined;
|
|
3979
|
+
const results = details?.results;
|
|
3980
|
+
if (!Array.isArray(results) || results.length === 0) {
|
|
3981
|
+
// Background launch: the settled items arrive later through
|
|
3982
|
+
// TO_SPEC_RESULT_TOOL; the in-flight rows keep them out of any new batch
|
|
3983
|
+
// until then.
|
|
3984
|
+
return;
|
|
3985
|
+
}
|
|
3986
|
+
let store: Store | undefined;
|
|
3987
|
+
try {
|
|
3988
|
+
store = openStore(dbPath());
|
|
3989
|
+
for (const entry of results) {
|
|
3990
|
+
const settled = entry as ToSpecSettledItem;
|
|
3991
|
+
const item = batch.items[typeof settled.index === "number" ? settled.index : -1];
|
|
3992
|
+
if (item === undefined) continue;
|
|
3993
|
+
// The raw output is the contract input — exactly as returned, even
|
|
3994
|
+
// when the item failed: the strict parser turns anything unparseable
|
|
3995
|
+
// into a blocked row, and a failed sibling never touches the others.
|
|
3996
|
+
const raw = typeof settled.output === "string" ? settled.output : "";
|
|
3997
|
+
const structured = settled.structuredOutput;
|
|
3998
|
+
const fallback =
|
|
3999
|
+
structured !== null &&
|
|
4000
|
+
typeof structured === "object" &&
|
|
4001
|
+
typeof (structured as { data?: unknown }).data === "object"
|
|
4002
|
+
? JSON.stringify((structured as { data?: unknown }).data)
|
|
4003
|
+
: "";
|
|
4004
|
+
try {
|
|
4005
|
+
recordToSpecGrooming(store, {
|
|
4006
|
+
project: projectName,
|
|
4007
|
+
issue: item.issue,
|
|
4008
|
+
input: raw.trim().length > 0 ? raw : fallback,
|
|
4009
|
+
});
|
|
4010
|
+
} catch (err) {
|
|
4011
|
+
pi.logger.error(
|
|
4012
|
+
`[omp-conductor] to-spec result not persisted for #${item.issue}: ${
|
|
4013
|
+
err instanceof Error ? err.message : String(err)
|
|
4014
|
+
}`,
|
|
4015
|
+
);
|
|
4016
|
+
}
|
|
4017
|
+
}
|
|
4018
|
+
} catch (err) {
|
|
4019
|
+
pi.logger.error(
|
|
4020
|
+
`[omp-conductor] to-spec results not captured: ${err instanceof Error ? err.message : String(err)}`,
|
|
4021
|
+
);
|
|
4022
|
+
} finally {
|
|
4023
|
+
store?.close();
|
|
4024
|
+
}
|
|
4025
|
+
});
|
|
4026
|
+
}
|
|
4027
|
+
|
|
2887
4028
|
/**
|
|
2888
4029
|
* Factory-time seams for the extension, used by tests. Production runs
|
|
2889
4030
|
* `orchestratorTickExtension(pi)` with no options and gets the module's own
|
|
@@ -2892,7 +4033,21 @@ function armTickGuard(pi: TickApi, ctx: TickContext, budgetMs: number): void {
|
|
|
2892
4033
|
* through the real tool deterministically, without real timers (#683).
|
|
2893
4034
|
*/
|
|
2894
4035
|
export interface OrchestratorTickExtensionOptions {
|
|
2895
|
-
ask?: {
|
|
4036
|
+
ask?: {
|
|
4037
|
+
wait?: (ms: number) => Promise<void>;
|
|
4038
|
+
now?: () => number;
|
|
4039
|
+
/** Injected interactive delivery surface (#722); production builds its own. */
|
|
4040
|
+
interactive?: AskInteractiveDelivery;
|
|
4041
|
+
};
|
|
4042
|
+
/**
|
|
4043
|
+
* The authoritative tracker surface the low-queue to-spec launch reads
|
|
4044
|
+
* (#777). Production omits it and the extension builds the real seam over
|
|
4045
|
+
* the existing Tracker adapter for each project; tests inject deterministic
|
|
4046
|
+
* fakes so the mechanical selection is provable without a network. A
|
|
4047
|
+
* low-queue launch without a readable snapshot is refused entirely — the
|
|
4048
|
+
* launch fails closed rather than trusting the model to self-filter.
|
|
4049
|
+
*/
|
|
4050
|
+
toSpec?: { tracker?: ToSpecTrackerSeam };
|
|
2896
4051
|
}
|
|
2897
4052
|
|
|
2898
4053
|
export default function orchestratorTickExtension(
|
|
@@ -2919,6 +4074,22 @@ export default function orchestratorTickExtension(
|
|
|
2919
4074
|
let releaseGateArmed = false;
|
|
2920
4075
|
let availabilityGateArmed = false;
|
|
2921
4076
|
let guardArmed = false;
|
|
4077
|
+
// #777: the to-spec launch gate is factory-time like the tick guard. It acts
|
|
4078
|
+
// only when a low-queue tick has minted a launch token, so on any other
|
|
4079
|
+
// session — or any healthy queue — it observes `task` calls without touching
|
|
4080
|
+
// them.
|
|
4081
|
+
armToSpecGate(pi, session);
|
|
4082
|
+
// The authoritative tracker seam for the launch selection. Production runs
|
|
4083
|
+
// without options and builds the real seam lazily over the existing Tracker
|
|
4084
|
+
// adapter (one tracker per project read, per low-queue tick); tests inject a
|
|
4085
|
+
// deterministic fake. The seam is the ONLY tracker surface the launch path
|
|
4086
|
+
// touches — the tick never mutates tracker state and never lets the model
|
|
4087
|
+
// self-select candidates.
|
|
4088
|
+
const toSpecTrackerSeam: ToSpecTrackerSeam | undefined =
|
|
4089
|
+
options.toSpec?.tracker ?? {
|
|
4090
|
+
listOpenIssues: (project) => makeTracker(project).listOpenIssues(),
|
|
4091
|
+
childrenOf: (project, issue) => makeTracker(project).childrenOf(issue),
|
|
4092
|
+
};
|
|
2922
4093
|
// The bounded ask tool's session state. The tool itself is registered at
|
|
2923
4094
|
// extension-factory time, before any session exists, because OMP snapshots
|
|
2924
4095
|
// the extension's active tool set before it emits `session_start` — a tool
|
|
@@ -3047,22 +4218,25 @@ export default function orchestratorTickExtension(
|
|
|
3047
4218
|
}
|
|
3048
4219
|
return;
|
|
3049
4220
|
}
|
|
3050
|
-
// The reply to an arming challenge lands here as an ordinary user turn.
|
|
3051
|
-
//
|
|
3052
|
-
//
|
|
3053
|
-
//
|
|
3054
|
-
//
|
|
3055
|
-
// #
|
|
3056
|
-
//
|
|
3057
|
-
// from the `FLEET-` prefix, so
|
|
3058
|
-
// active challenge stays inert
|
|
4221
|
+
// The reply to an arming challenge lands here as an ordinary user turn.
|
|
4222
|
+
// This adapter — not the model, and not any transcript scan — is the
|
|
4223
|
+
// sole producer of the arming acknowledgement: it classifies the turn
|
|
4224
|
+
// against persisted authenticated challenge state and, on a match,
|
|
4225
|
+
// atomically records the challenge-id-specific acknowledgement the
|
|
4226
|
+
// host-side `arm` waits on (conductor #614), all before normal model
|
|
4227
|
+
// handling. The classification derives from the pending challenge (hash
|
|
4228
|
+
// + expiry, keyed by this project), never from the `FLEET-` prefix, so
|
|
4229
|
+
// an unsolicited lookalike that matches no active challenge stays inert
|
|
4230
|
+
// and model behaviour cannot determine whether the host becomes armed.
|
|
4231
|
+
// The turn itself still reaches the transcript exactly as sent, with the
|
|
4232
|
+
// same trusted machine-readable steer as before (#415).
|
|
3059
4233
|
if (message.role === "user" && message.synthetic !== true && message.attribution !== "agent") {
|
|
3060
4234
|
const replyText =
|
|
3061
4235
|
message.content
|
|
3062
4236
|
?.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
3063
4237
|
.map((part) => part.text as string)
|
|
3064
4238
|
.join(" ") ?? "";
|
|
3065
|
-
const proof =
|
|
4239
|
+
const proof = acknowledgeArmReply(configuredProject, replyText, Date.now());
|
|
3066
4240
|
if (session.activeLocalTick !== undefined) {
|
|
3067
4241
|
session.activeLocalTick.humanWaiting = true;
|
|
3068
4242
|
if (proof) session.activeLocalTick.armingProof = true;
|
|
@@ -3136,7 +4310,12 @@ export default function orchestratorTickExtension(
|
|
|
3136
4310
|
`delivers it to the operator per the reporting policy, and waits at most the ceiling ` +
|
|
3137
4311
|
`(default ${DEFAULT_ASK_TIMEOUT_SECONDS}s, capped at the turn budget; pass "timeoutSeconds" to ` +
|
|
3138
4312
|
`shorten or extend within ${MIN_ASK_TIMEOUT_SECONDS}–${MAX_ASK_TIMEOUT_SECONDS}s — an ask issued without ` +
|
|
3139
|
-
`one still gets the default). When
|
|
4313
|
+
`one still gets the default). When the Telegram surface can, the question posts as selectable ` +
|
|
4314
|
+
`buttons and a tap resolves the decision row with the chosen option; when it cannot, the ask ` +
|
|
4315
|
+
`degrades to plain text and the row records the degraded delivery. "recommended" is required ` +
|
|
4316
|
+
`whenever "on-timeout" is "auto-proceed" (the row must record what was auto-applied) and, when ` +
|
|
4317
|
+
`"options" are supplied, must be one of their labels — the label as delivered, never an index. ` +
|
|
4318
|
+
`When nobody answers, the declared "on-timeout" decides: ` +
|
|
3140
4319
|
`"auto-proceed" applies your recommended option and resolves the decision row naming the ` +
|
|
3141
4320
|
`auto-application ("<option> (auto-applied on ask timeout)"); "park" leaves the row open and ` +
|
|
3142
4321
|
`pending — re-surfaced in every tick prompt until answered or the seven-day expiry — and you then ` +
|
|
@@ -3196,8 +4375,11 @@ export default function orchestratorTickExtension(
|
|
|
3196
4375
|
turnBudgetSeconds: config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS,
|
|
3197
4376
|
// Test seam (#683): production omits `wait`/`now` and `performAsk`
|
|
3198
4377
|
// falls back to the module's real-time defaults; a test that wants
|
|
3199
|
-
// the timeout outcomes deterministically hands both in.
|
|
3200
|
-
|
|
4378
|
+
// the timeout outcomes deterministically hands both in. The
|
|
4379
|
+
// interactive surface (#722) is production's default; a test may
|
|
4380
|
+
// inject a fake through the same seam.
|
|
4381
|
+
interactive: options.ask?.interactive ?? interactiveAskSurface({ project: projectConfig, store }),
|
|
4382
|
+
...(options.ask === undefined ? {} : { wait: options.ask.wait, now: options.ask.now }),
|
|
3201
4383
|
deliver: async (text, category) => {
|
|
3202
4384
|
const at = Date.now();
|
|
3203
4385
|
const noticeId = randomUUID();
|
|
@@ -3237,6 +4419,114 @@ export default function orchestratorTickExtension(
|
|
|
3237
4419
|
},
|
|
3238
4420
|
});
|
|
3239
4421
|
|
|
4422
|
+
// The async half of the to-spec result capture (#777). Registered at
|
|
4423
|
+
// extension-factory time like {@link ASK_TOOL}, with the same routing
|
|
4424
|
+
// contract: the state it needs (cwd + startup config) is filled by
|
|
4425
|
+
// `session_start` for an accepted fleet session, and the live config is
|
|
4426
|
+
// re-read at execution so a restamp binds the current project. The tool
|
|
4427
|
+
// only ever writes the grooming table — never an issue, a label, or a
|
|
4428
|
+
// dispatch row — so the output of a background batch persists while no
|
|
4429
|
+
// candidate can become claimable from it.
|
|
4430
|
+
pi.registerTool({
|
|
4431
|
+
name: TO_SPEC_RESULT_TOOL,
|
|
4432
|
+
label: TO_SPEC_RESULT_TOOL,
|
|
4433
|
+
description:
|
|
4434
|
+
`Persist the exact raw output of one completed to-spec grooming item (#777). ` +
|
|
4435
|
+
`Call it once per completed batch item after the batch settles — in the tool ` +
|
|
4436
|
+
`result, or when an async-result message delivers the item — passing the issue ` +
|
|
4437
|
+
`number and the agent's EXACT raw output as \`input\` (read agent://<id> when the ` +
|
|
4438
|
+
`inline text is truncated; never paraphrase). The conductor parses the output ` +
|
|
4439
|
+
`against the strict to-spec contract and records the verdict durably: a valid ` +
|
|
4440
|
+
`result persists its verdict, anything malformed, source-less or stale persists ` +
|
|
4441
|
+
`as blocked, and a failing item never discards its siblings. One call per item, ` +
|
|
4442
|
+
`success and failure alike; never edits an issue or a label.`,
|
|
4443
|
+
parameters: {
|
|
4444
|
+
type: "object",
|
|
4445
|
+
properties: {
|
|
4446
|
+
issue: { type: "integer", description: "The tracker issue number the item groomed." },
|
|
4447
|
+
input: {
|
|
4448
|
+
type: "string",
|
|
4449
|
+
description: "The to-spec agent's exact raw output for this item.",
|
|
4450
|
+
},
|
|
4451
|
+
},
|
|
4452
|
+
required: ["issue", "input"],
|
|
4453
|
+
additionalProperties: false,
|
|
4454
|
+
},
|
|
4455
|
+
approval: "write",
|
|
4456
|
+
execute: async (_toolCallId, params) => {
|
|
4457
|
+
const fleet = askSession;
|
|
4458
|
+
if (fleet === undefined) {
|
|
4459
|
+
return {
|
|
4460
|
+
content: [
|
|
4461
|
+
{
|
|
4462
|
+
type: "text",
|
|
4463
|
+
text: `${TO_SPEC_RESULT_TOOL}: not available in this session (no orchestrator tick); nothing was persisted.`,
|
|
4464
|
+
},
|
|
4465
|
+
],
|
|
4466
|
+
isError: true,
|
|
4467
|
+
};
|
|
4468
|
+
}
|
|
4469
|
+
const issue = params["issue"];
|
|
4470
|
+
const input = params["input"];
|
|
4471
|
+
if (typeof issue !== "number" || !Number.isInteger(issue) || typeof input !== "string" || input.length === 0) {
|
|
4472
|
+
return {
|
|
4473
|
+
content: [
|
|
4474
|
+
{ type: "text", text: `${TO_SPEC_RESULT_TOOL}: expected an integer \`issue\` and a non-empty \`input\` string.` },
|
|
4475
|
+
],
|
|
4476
|
+
isError: true,
|
|
4477
|
+
};
|
|
4478
|
+
}
|
|
4479
|
+
const routed = resolveAskProject(fleet.cwd, fleet.config);
|
|
4480
|
+
if (routed.kind === "error") {
|
|
4481
|
+
return {
|
|
4482
|
+
content: [
|
|
4483
|
+
{
|
|
4484
|
+
type: "text",
|
|
4485
|
+
text:
|
|
4486
|
+
`${TO_SPEC_RESULT_TOOL}: conductor config unreadable (${routed.problem}); nothing was persisted. ` +
|
|
4487
|
+
"Keep the outputs in the transcript and persist after the config is repaired.",
|
|
4488
|
+
},
|
|
4489
|
+
],
|
|
4490
|
+
isError: true,
|
|
4491
|
+
};
|
|
4492
|
+
}
|
|
4493
|
+
const store = openStore(dbPath());
|
|
4494
|
+
try {
|
|
4495
|
+
const outcome = recordToSpecGrooming(store, {
|
|
4496
|
+
project: routed.project.name,
|
|
4497
|
+
issue,
|
|
4498
|
+
input,
|
|
4499
|
+
});
|
|
4500
|
+
const record = outcome.record;
|
|
4501
|
+
const kept = outcome.kind === "kept-prior" ? " (kept the prior valid verdict)" : "";
|
|
4502
|
+
return {
|
|
4503
|
+
content: [
|
|
4504
|
+
{
|
|
4505
|
+
type: "text",
|
|
4506
|
+
text:
|
|
4507
|
+
`${TO_SPEC_RESULT_TOOL}: #${issue} persisted as ${record.verdict} (${record.reason})${kept}. ` +
|
|
4508
|
+
"The grooming table now decides re-grooming; promotion stays yours.",
|
|
4509
|
+
},
|
|
4510
|
+
],
|
|
4511
|
+
};
|
|
4512
|
+
} catch (err) {
|
|
4513
|
+
return {
|
|
4514
|
+
content: [
|
|
4515
|
+
{
|
|
4516
|
+
type: "text",
|
|
4517
|
+
text: `${TO_SPEC_RESULT_TOOL}: could not persist #${issue}: ${
|
|
4518
|
+
err instanceof Error ? err.message : String(err)
|
|
4519
|
+
} — keep the output and retry the call.`,
|
|
4520
|
+
},
|
|
4521
|
+
],
|
|
4522
|
+
isError: true,
|
|
4523
|
+
};
|
|
4524
|
+
} finally {
|
|
4525
|
+
store.close();
|
|
4526
|
+
}
|
|
4527
|
+
},
|
|
4528
|
+
});
|
|
4529
|
+
|
|
3240
4530
|
pi.on("session_start", (_event, ctx) => {
|
|
3241
4531
|
if (decided) return;
|
|
3242
4532
|
|
|
@@ -3358,7 +4648,7 @@ export default function orchestratorTickExtension(
|
|
|
3358
4648
|
// not create or deliver decisions). `cwd` is for re-reading the live
|
|
3359
4649
|
// tick config at execution, `config` for the startup-only ceiling.
|
|
3360
4650
|
askSession = { cwd: ctx.cwd, config };
|
|
3361
|
-
armTickHeartbeat(pi, ctx, config, session);
|
|
4651
|
+
armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam);
|
|
3362
4652
|
if (!guardArmed) {
|
|
3363
4653
|
guardArmed = true;
|
|
3364
4654
|
armTickGuard(pi, ctx, (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1000);
|
|
@@ -3380,7 +4670,7 @@ export default function orchestratorTickExtension(
|
|
|
3380
4670
|
// why): a declined or unresolved session keeps `askSession` undefined and
|
|
3381
4671
|
// the tool fails closed.
|
|
3382
4672
|
askSession = { cwd: ctx.cwd, config };
|
|
3383
|
-
armTickHeartbeat(pi, ctx, config, session);
|
|
4673
|
+
armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam);
|
|
3384
4674
|
if (!guardArmed) {
|
|
3385
4675
|
guardArmed = true;
|
|
3386
4676
|
armTickGuard(pi, ctx, (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1000);
|
|
@@ -3398,3 +4688,272 @@ export default function orchestratorTickExtension(
|
|
|
3398
4688
|
);
|
|
3399
4689
|
});
|
|
3400
4690
|
}
|
|
4691
|
+
|
|
4692
|
+
/** One error's message, for a reason string — never a stack. */
|
|
4693
|
+
function errText(err: unknown): string {
|
|
4694
|
+
return err instanceof Error ? err.message : String(err);
|
|
4695
|
+
}
|
|
4696
|
+
|
|
4697
|
+
/**
|
|
4698
|
+
* Write one prompt-protocol file the way omp-telegram's `atomicJson` does:
|
|
4699
|
+
* temp file in the same directory, then rename. The bridge reads these files
|
|
4700
|
+
* on a hot path (every tap), so a half-written request must never be visible.
|
|
4701
|
+
*/
|
|
4702
|
+
function atomicallyWriteJson(path: string, value: unknown): void {
|
|
4703
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
4704
|
+
const tmp = `${path}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
|
|
4705
|
+
writeFileSync(tmp, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
|
4706
|
+
renameSync(tmp, path);
|
|
4707
|
+
}
|
|
4708
|
+
|
|
4709
|
+
/** Remove one prompt file; a missing file is not an error. */
|
|
4710
|
+
function removeFile(path: string): void {
|
|
4711
|
+
try {
|
|
4712
|
+
rmSync(path, { force: true });
|
|
4713
|
+
} catch {
|
|
4714
|
+
// best effort — a leftover prompt request dies with its owner process
|
|
4715
|
+
}
|
|
4716
|
+
}
|
|
4717
|
+
|
|
4718
|
+
/**
|
|
4719
|
+
* The interactive Telegram surface for one bounded ask (#722).
|
|
4720
|
+
*
|
|
4721
|
+
* `telegram_ask` posts its options as a Bot API inline keyboard whose taps the
|
|
4722
|
+
* running omp-telegram bridge acknowledges and answers through a documented
|
|
4723
|
+
* cross-process file protocol (guide.md: "prompts/ — Cross-process
|
|
4724
|
+
* selectable-question requests (live while their owning session is) and their
|
|
4725
|
+
* answers"): the asking process writes `<state>/prompts/<nonce>.json`, the
|
|
4726
|
+
* bridge validates the tap against that request (responder, chat, topic,
|
|
4727
|
+
* message id, owner-pid liveness) and writes `<nonce>.answer.json`; the asking
|
|
4728
|
+
* process reads the envelope and settles. `interactiveAskSurface` is that
|
|
4729
|
+
* asking-process half, nothing more: it posts the question with the same
|
|
4730
|
+
* `qa:<nonce>:s:<index>` callbacks `prompts.ts` routes, writes the request file
|
|
4731
|
+
* so the bridge recognizes the taps, and translates the envelope the bridge
|
|
4732
|
+
* writes into the decision row — the resolution is the chosen option's *label*,
|
|
4733
|
+
* never an index and never free text.
|
|
4734
|
+
*
|
|
4735
|
+
* The decision id doubles as the protocol nonce: it fits `[A-Za-z0-9_-]`, the
|
|
4736
|
+
* bridge's callback regex, and makes the pending question and its answer
|
|
4737
|
+
* addressable by the row that records them.
|
|
4738
|
+
*/
|
|
4739
|
+
export function interactiveAskSurface(deps: {
|
|
4740
|
+
project: ProjectConfig;
|
|
4741
|
+
store: Store;
|
|
4742
|
+
/** Injected Bot API transport (tests); production posts to api.telegram.org. */
|
|
4743
|
+
call?: (method: string, payload: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
4744
|
+
/** Injected state dir (tests); production resolves it like the token. */
|
|
4745
|
+
stateDir?: string;
|
|
4746
|
+
/** Injected bot token (tests); production reads the state dir's .env. */
|
|
4747
|
+
token?: string;
|
|
4748
|
+
now?: () => number;
|
|
4749
|
+
}): AskInteractiveDelivery {
|
|
4750
|
+
const surfaceStateDir = deps.stateDir ?? telegramStateDir();
|
|
4751
|
+
const call = deps.call ?? telegramCall(deps.token ?? readTelegramToken() ?? "");
|
|
4752
|
+
const now = deps.now ?? Date.now;
|
|
4753
|
+
// Where each pending question physically sits, so collect/close can settle
|
|
4754
|
+
// the right message without re-reading the request file.
|
|
4755
|
+
const posted = new Map<string, { chatId: string; messageId: number; settled: boolean }>();
|
|
4756
|
+
|
|
4757
|
+
const promptsDir = (): string => join(surfaceStateDir, "prompts");
|
|
4758
|
+
const requestPath = (nonce: string): string => join(promptsDir(), `${nonce}.json`);
|
|
4759
|
+
const answerPath = (nonce: string): string => join(promptsDir(), `${nonce}.answer.json`);
|
|
4760
|
+
|
|
4761
|
+
const ownerId = (): string | undefined => {
|
|
4762
|
+
let raw: string;
|
|
4763
|
+
try {
|
|
4764
|
+
raw = readFileSync(join(surfaceStateDir, "access.json"), "utf8");
|
|
4765
|
+
} catch {
|
|
4766
|
+
return undefined;
|
|
4767
|
+
}
|
|
4768
|
+
let access: { allowFrom?: unknown };
|
|
4769
|
+
try {
|
|
4770
|
+
access = JSON.parse(raw) as { allowFrom?: unknown };
|
|
4771
|
+
} catch {
|
|
4772
|
+
return undefined;
|
|
4773
|
+
}
|
|
4774
|
+
const allowFrom = access.allowFrom;
|
|
4775
|
+
if (!Array.isArray(allowFrom) || allowFrom.length !== 1 || typeof allowFrom[0] !== "string") {
|
|
4776
|
+
return undefined;
|
|
4777
|
+
}
|
|
4778
|
+
return allowFrom[0];
|
|
4779
|
+
};
|
|
4780
|
+
|
|
4781
|
+
const tokenAvailable = (): boolean =>
|
|
4782
|
+
deps.call !== undefined || deps.token !== undefined || readTelegramToken() !== undefined;
|
|
4783
|
+
|
|
4784
|
+
return {
|
|
4785
|
+
unavailableReason(request) {
|
|
4786
|
+
const chat = deps.project.escalation.telegramChatId;
|
|
4787
|
+
if (chat === undefined || chat === "") {
|
|
4788
|
+
return "no escalation.telegramChatId configured for this project";
|
|
4789
|
+
}
|
|
4790
|
+
if (!tokenAvailable()) {
|
|
4791
|
+
return "no Telegram bot token readable (install and configure omp-telegram, or set OMP_TELEGRAM_STATE_DIR)";
|
|
4792
|
+
}
|
|
4793
|
+
if (ownerId() === undefined) {
|
|
4794
|
+
return "no paired Telegram owner (omp-telegram access.json must name exactly one allowed user)";
|
|
4795
|
+
}
|
|
4796
|
+
// The interactive post is still a delivery under the reporting policy: a
|
|
4797
|
+
// question the policy would hold for the digest or the availability
|
|
4798
|
+
// window must not bypass that hold just because it has buttons.
|
|
4799
|
+
const disposition = interruptDisposition(
|
|
4800
|
+
deps.project.reporting,
|
|
4801
|
+
request.category ?? "decision-needed",
|
|
4802
|
+
now(),
|
|
4803
|
+
);
|
|
4804
|
+
if (disposition !== "interrupt") {
|
|
4805
|
+
return `the question defers under the reporting policy (${disposition}) — it must be held, not posted`;
|
|
4806
|
+
}
|
|
4807
|
+
return undefined;
|
|
4808
|
+
},
|
|
4809
|
+
|
|
4810
|
+
async post(request, decisionId) {
|
|
4811
|
+
const chat = deps.project.escalation.telegramChatId;
|
|
4812
|
+
const owner = ownerId();
|
|
4813
|
+
if (chat === undefined || owner === undefined) {
|
|
4814
|
+
return {
|
|
4815
|
+
ok: false,
|
|
4816
|
+
reason: "the interactive surface is not configured (no escalation.telegramChatId or no paired owner)",
|
|
4817
|
+
};
|
|
4818
|
+
}
|
|
4819
|
+
const render = renderInteractiveAsk(request, decisionId);
|
|
4820
|
+
const threadId = resolveProjectTopicId(deps.project);
|
|
4821
|
+
let result: Record<string, unknown>;
|
|
4822
|
+
try {
|
|
4823
|
+
result = await call("sendMessage", {
|
|
4824
|
+
chat_id: chat,
|
|
4825
|
+
...(threadId === undefined ? {} : { message_thread_id: threadId }),
|
|
4826
|
+
text: render.text,
|
|
4827
|
+
reply_markup: render.markup,
|
|
4828
|
+
});
|
|
4829
|
+
} catch (err) {
|
|
4830
|
+
return { ok: false, reason: `Telegram rejected the interactive question: ${errText(err)}` };
|
|
4831
|
+
}
|
|
4832
|
+
const messageId = result["message_id"];
|
|
4833
|
+
const chatType =
|
|
4834
|
+
typeof result["chat"] === "object" && result["chat"] !== null
|
|
4835
|
+
? String((result["chat"] as Record<string, unknown>)["type"] ?? "private")
|
|
4836
|
+
: "private";
|
|
4837
|
+
if (typeof messageId !== "number" || !Number.isSafeInteger(messageId)) {
|
|
4838
|
+
// The question went out, but unaddressable — take it back rather than
|
|
4839
|
+
// leaving a button row nothing can settle.
|
|
4840
|
+
await call("deleteMessage", { chat_id: chat, message_id: messageId as number }).catch(
|
|
4841
|
+
() => undefined,
|
|
4842
|
+
);
|
|
4843
|
+
return { ok: false, reason: "Telegram posted no usable message id" };
|
|
4844
|
+
}
|
|
4845
|
+
const recommended =
|
|
4846
|
+
request.recommended === undefined
|
|
4847
|
+
? undefined
|
|
4848
|
+
: (request.options ?? []).findIndex((option) => option.label === request.recommended);
|
|
4849
|
+
const requestFile = {
|
|
4850
|
+
version: 1,
|
|
4851
|
+
nonce: decisionId,
|
|
4852
|
+
responderId: owner,
|
|
4853
|
+
chatId: chat,
|
|
4854
|
+
chatType,
|
|
4855
|
+
threadId,
|
|
4856
|
+
page: 0,
|
|
4857
|
+
messageId,
|
|
4858
|
+
questions: [
|
|
4859
|
+
{
|
|
4860
|
+
id: "q1",
|
|
4861
|
+
question: request.question,
|
|
4862
|
+
options: request.options ?? [],
|
|
4863
|
+
...(recommended === undefined || recommended < 0 ? {} : { recommended }),
|
|
4864
|
+
},
|
|
4865
|
+
],
|
|
4866
|
+
questionIndex: 0,
|
|
4867
|
+
answers: [],
|
|
4868
|
+
selectedIndices: [],
|
|
4869
|
+
awaitingText: (request.options ?? []).length === 0,
|
|
4870
|
+
ownerPid: process.pid,
|
|
4871
|
+
};
|
|
4872
|
+
try {
|
|
4873
|
+
atomicallyWriteJson(requestPath(decisionId), requestFile as unknown);
|
|
4874
|
+
} catch (err) {
|
|
4875
|
+
// The buttons went out but nothing would ever answer them — take the
|
|
4876
|
+
// message back rather than leave a dead keyboard in the chat.
|
|
4877
|
+
await call("deleteMessage", { chat_id: chat, message_id: messageId }).catch(() => undefined);
|
|
4878
|
+
return { ok: false, reason: `could not register the interactive question: ${errText(err)}` };
|
|
4879
|
+
}
|
|
4880
|
+
posted.set(decisionId, { chatId: chat, messageId, settled: false });
|
|
4881
|
+
return { ok: true };
|
|
4882
|
+
},
|
|
4883
|
+
|
|
4884
|
+
collect(decisionId) {
|
|
4885
|
+
const state = posted.get(decisionId);
|
|
4886
|
+
if (state === undefined) return;
|
|
4887
|
+
let raw: string | undefined;
|
|
4888
|
+
try {
|
|
4889
|
+
raw = readFileSync(answerPath(decisionId), "utf8");
|
|
4890
|
+
} catch {
|
|
4891
|
+
return; // no answer yet
|
|
4892
|
+
}
|
|
4893
|
+
let parsed: AskAnswerEnvelope | undefined;
|
|
4894
|
+
try {
|
|
4895
|
+
parsed = parseAskAnswerEnvelope(JSON.parse(raw) as unknown);
|
|
4896
|
+
} catch {
|
|
4897
|
+
parsed = undefined;
|
|
4898
|
+
}
|
|
4899
|
+
if (parsed === undefined) return; // an unreadable envelope is "no answer yet"
|
|
4900
|
+
const write = askAnswerRowWrite(parsed);
|
|
4901
|
+
if (write === undefined) return; // expiry/abort: the bounded wait still owns the row
|
|
4902
|
+
deps.store.resolveDecision(decisionId, write.state, write.resolution, now());
|
|
4903
|
+
removeFile(requestPath(decisionId));
|
|
4904
|
+
removeFile(answerPath(decisionId));
|
|
4905
|
+
state.settled = true;
|
|
4906
|
+
const outcomeText =
|
|
4907
|
+
write.state === "withdrawn"
|
|
4908
|
+
? write.resolution
|
|
4909
|
+
: `User selected: ${write.resolution}`;
|
|
4910
|
+
// The bridge edits its own prompts' messages; this surface owns the edit
|
|
4911
|
+
// for its own, so an answered ask reads as answered and a stale tap
|
|
4912
|
+
// finds no keyboard.
|
|
4913
|
+
call("editMessageText", {
|
|
4914
|
+
chat_id: state.chatId,
|
|
4915
|
+
message_id: state.messageId,
|
|
4916
|
+
text: outcomeText,
|
|
4917
|
+
reply_markup: { inline_keyboard: [] },
|
|
4918
|
+
}).catch(() => undefined);
|
|
4919
|
+
},
|
|
4920
|
+
|
|
4921
|
+
close(decisionId) {
|
|
4922
|
+
const state = posted.get(decisionId);
|
|
4923
|
+
removeFile(requestPath(decisionId));
|
|
4924
|
+
removeFile(answerPath(decisionId));
|
|
4925
|
+
if (state !== undefined && !state.settled) {
|
|
4926
|
+
call("editMessageReplyMarkup", {
|
|
4927
|
+
chat_id: state.chatId,
|
|
4928
|
+
message_id: state.messageId,
|
|
4929
|
+
reply_markup: { inline_keyboard: [] },
|
|
4930
|
+
}).catch(() => undefined);
|
|
4931
|
+
state.settled = true;
|
|
4932
|
+
}
|
|
4933
|
+
},
|
|
4934
|
+
};
|
|
4935
|
+
}
|
|
4936
|
+
|
|
4937
|
+
/** The Bot API transport: one JSON POST per call, the `result` back. */
|
|
4938
|
+
function telegramCall(
|
|
4939
|
+
token: string,
|
|
4940
|
+
): (method: string, payload: Record<string, unknown>) => Promise<Record<string, unknown>> {
|
|
4941
|
+
return async (method, payload) => {
|
|
4942
|
+
if (token === "") throw new Error("no Telegram bot token");
|
|
4943
|
+
const url = `https://api.telegram.org/bot${token}/${method}`;
|
|
4944
|
+
const res = await fetch(url, {
|
|
4945
|
+
method: "POST",
|
|
4946
|
+
headers: { "content-type": "application/json" },
|
|
4947
|
+
body: JSON.stringify(payload),
|
|
4948
|
+
});
|
|
4949
|
+
const raw = await res.text();
|
|
4950
|
+
if (!res.ok) {
|
|
4951
|
+
throw new Error(`telegram ${method} failed: HTTP ${res.status} ${raw.slice(0, 200)}`);
|
|
4952
|
+
}
|
|
4953
|
+
const parsed = JSON.parse(raw) as { ok?: unknown; result?: unknown };
|
|
4954
|
+
if (parsed.ok !== true) {
|
|
4955
|
+
throw new Error(`telegram ${method} rejected: ${raw.slice(0, 200)}`);
|
|
4956
|
+
}
|
|
4957
|
+
return (parsed.result as Record<string, unknown>) ?? {};
|
|
4958
|
+
};
|
|
4959
|
+
}
|