omp-conductor 0.18.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/REFERENCE.md +60 -10
- package/agents/to-spec.md +90 -0
- package/package.json +2 -1
- package/schema/config.schema.json +29 -0
- package/src/admission.ts +204 -75
- package/src/ask.ts +268 -7
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +42 -14
- package/src/briefs/to-spec.md +84 -0
- package/src/briefs/worker.md +2 -1
- package/src/cli.ts +2 -0
- package/src/command-help.ts +11 -0
- package/src/command-manifest.ts +22 -0
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +50 -2
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +24 -0
- package/src/config.ts +42 -1
- package/src/daemon.ts +965 -36
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +235 -17
- package/src/diff-flags.ts +75 -1
- package/src/doctor.ts +52 -0
- package/src/escalate.ts +9 -3
- package/src/failure-class.ts +28 -2
- package/src/fleet.ts +146 -22
- 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 +567 -2
- package/src/lifecycle.ts +122 -1
- package/src/omp.ts +227 -20
- package/src/orchestrator-tick.ts +1386 -15
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/session-host.ts +99 -5
- package/src/settlement.ts +69 -17
- package/src/setup-host.ts +1205 -6
- package/src/setup-install.ts +28 -0
- package/src/setup-wizard.ts +13 -2
- package/src/setup.ts +29 -13
- package/src/shell.ts +15 -0
- package/src/status-render.ts +78 -11
- package/src/store.ts +443 -42
- package/src/to-spec.ts +387 -0
- package/src/tracker/github.ts +104 -14
- package/src/types.ts +343 -13
- 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 +730 -56
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +25 -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,30 +82,48 @@ 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 { readTelegramToken, resolveProjectTopicId, telegramStateDir } from "./escalate.ts";
|
|
118
|
+
import type { FailureClass, RecoveryAction, RunRecord } from "./types.ts";
|
|
107
119
|
import { dbPath, openStore } from "./store.ts";
|
|
108
120
|
import { digestDue, localDayKey } from "./digest-schedule.ts";
|
|
121
|
+
import {
|
|
122
|
+
parseToSpecEvidence,
|
|
123
|
+
recordToSpecGrooming,
|
|
124
|
+
TO_SPEC_MAX_SOURCE_AGE_MS,
|
|
125
|
+
TO_SPEC_SCHEMA,
|
|
126
|
+
} from "./to-spec.ts";
|
|
109
127
|
import { heldNoticeId } from "./notices.ts";
|
|
110
128
|
import { isActiveArmProof } from "./arm-challenge.ts";
|
|
111
129
|
|
|
@@ -603,16 +621,43 @@ export function recoveryDigestLine(
|
|
|
603
621
|
return lines.join("\n");
|
|
604
622
|
}
|
|
605
623
|
|
|
624
|
+
/** Per-reason counts for a verdict slice — "file-lane 2, depends-on 1". */
|
|
625
|
+
function groomingGroupCounts(records: readonly GroomingRecord[]): string {
|
|
626
|
+
const counts = new Map<string, number>();
|
|
627
|
+
for (const record of records) {
|
|
628
|
+
counts.set(record.reason, (counts.get(record.reason) ?? 0) + 1);
|
|
629
|
+
}
|
|
630
|
+
return [...counts.entries()].map(([reason, count]) => `${reason} ${count}`).join(", ");
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Per-verdict counts for a slice — "promotable 1, considered 1". */
|
|
634
|
+
function groomingVerdictCounts(records: readonly GroomingRecord[]): string {
|
|
635
|
+
const counts = new Map<string, number>();
|
|
636
|
+
for (const record of records) {
|
|
637
|
+
counts.set(record.verdict, (counts.get(record.verdict) ?? 0) + 1);
|
|
638
|
+
}
|
|
639
|
+
return [...counts.entries()].map(([verdict, count]) => `${verdict} ${count}`).join(", ");
|
|
640
|
+
}
|
|
641
|
+
|
|
606
642
|
/**
|
|
607
643
|
* One line telling the orchestrator the routable queue is running dry (#181),
|
|
608
644
|
* or `undefined` when healthy — no dispatch recorded yet, or the routable count
|
|
609
|
-
* is at/above the grooming trigger.
|
|
645
|
+
* is at/above the grooming trigger. `grooming` is the durable per-issue verdict
|
|
646
|
+
* table (#735): `blocked` rows are admission's lane/dependency holds, so the
|
|
647
|
+
* line tells claimable candidates apart from a runway that cannot move —
|
|
648
|
+
* instead of inviting Duty 2 to groom work whose last pass held it, which is
|
|
649
|
+
* exactly the re-derivation this store exists to stop. A to-spec launch row
|
|
650
|
+
* (#777) carries the same `blocked` verdict as its durable in-flight marker,
|
|
651
|
+
* so it is told apart from the mechanical holds: it says "a batch is running",
|
|
652
|
+
* not "the lane cannot move", and counts neither as claimable nor as
|
|
653
|
+
* known-blocked.
|
|
610
654
|
*/
|
|
611
655
|
export function queueDigestLine(
|
|
612
656
|
summary: DispatchSummary | undefined,
|
|
613
657
|
queueLabel: string,
|
|
614
658
|
labelPrefix: string,
|
|
615
659
|
groomBelow: number,
|
|
660
|
+
grooming: readonly GroomingRecord[] = [],
|
|
616
661
|
): string | undefined {
|
|
617
662
|
if (summary === undefined) return undefined;
|
|
618
663
|
if (summary.ready === 0) {
|
|
@@ -648,7 +693,40 @@ export function queueDigestLine(
|
|
|
648
693
|
return line;
|
|
649
694
|
}
|
|
650
695
|
if (summary.routed >= groomBelow) return undefined;
|
|
651
|
-
|
|
696
|
+
// The durable per-issue verdicts, not this pass's one-shot hold groups: a
|
|
697
|
+
// lane-blocked runway must read as "cannot move" even after a restart, and
|
|
698
|
+
// the orchestrator's own prior verdicts (#679) must not be re-derived.
|
|
699
|
+
//
|
|
700
|
+
// An in-flight to-spec launch (#777) is backlog inventory, not a routed
|
|
701
|
+
// ready candidate, a durable hold, or a verdict. Name it separately without
|
|
702
|
+
// subtracting it from the routed queue or printing lane/dependency guidance.
|
|
703
|
+
const inFlight = grooming.filter((g) => g.reason === TO_SPEC_IN_FLIGHT_REASON);
|
|
704
|
+
const knownBlocked = grooming.filter(
|
|
705
|
+
(g) => g.verdict === "blocked" && (g.reason === "file-lane" || g.reason === "depends-on"),
|
|
706
|
+
);
|
|
707
|
+
const considered = grooming.filter((g) => g.verdict === "promotable" || g.verdict === "considered");
|
|
708
|
+
const claimable = Math.max(0, summary.routed - knownBlocked.length);
|
|
709
|
+
const inFlightNames = inFlight.map((r) => `#${r.issue}`).join(", ");
|
|
710
|
+
const busy = inFlight.length === 0 ? "" : `, ${inFlight.length} in a to-spec batch (${inFlightNames})`;
|
|
711
|
+
let line: string;
|
|
712
|
+
if (knownBlocked.length > 0 && claimable === 0) {
|
|
713
|
+
line =
|
|
714
|
+
`Queue: running low — ${summary.routed} routable candidate(s), all known-blocked ` +
|
|
715
|
+
`(${groomingGroupCounts(knownBlocked)})${busy} — no grooming moves them; the holds clear by themselves` +
|
|
716
|
+
`${inFlight.length === 0 ? "" : " and the to-spec batch's results land when it settles"}.`;
|
|
717
|
+
} else if (knownBlocked.length > 0 || inFlight.length > 0) {
|
|
718
|
+
line =
|
|
719
|
+
`Queue: running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}: ` +
|
|
720
|
+
`${claimable} claimable, ${knownBlocked.length} known-blocked (${groomingGroupCounts(knownBlocked)})` +
|
|
721
|
+
`${busy} — groom only the claimable.`;
|
|
722
|
+
} else {
|
|
723
|
+
line = `Queue: running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
|
|
724
|
+
}
|
|
725
|
+
if (considered.length > 0) {
|
|
726
|
+
line +=
|
|
727
|
+
` Backlog already-considered: ${considered.length} (${groomingVerdictCounts(considered)}) — ` +
|
|
728
|
+
`promote the promotable or groom new issues, never re-groom these.`;
|
|
729
|
+
}
|
|
652
730
|
if (summary.admitted === 0 && summary.holds.length > 0) {
|
|
653
731
|
const held = summary.holds
|
|
654
732
|
.map((h) => {
|
|
@@ -661,6 +739,514 @@ export function queueDigestLine(
|
|
|
661
739
|
return line;
|
|
662
740
|
}
|
|
663
741
|
|
|
742
|
+
// ================================================================ to-spec
|
|
743
|
+
// launch lifecycle (#777). #772 shipped the strict to-spec contract
|
|
744
|
+
// (`TO_SPEC_SCHEMA` + `recordToSpecGrooming`); this section is the launch
|
|
745
|
+
// half: the mechanical candidate selection, the bounded prompt block that
|
|
746
|
+
// authorizes exactly one native `task` batch per low-queue tick, the
|
|
747
|
+
// tool-call gate that stamps the contract and records in-flight rows, and
|
|
748
|
+
// the result capture that routes every item's exact output through
|
|
749
|
+
// `recordToSpecGrooming` independently.
|
|
750
|
+
//
|
|
751
|
+
// Where each fact lives decides who enforces it — and every exclusion is
|
|
752
|
+
// enforced mechanically from authoritative data, never by prose:
|
|
753
|
+
//
|
|
754
|
+
// - Tracker facts — the open-issue pool, the park label, the parent/epic
|
|
755
|
+
// probe — are read ONCE, at launch composition time, through the existing
|
|
756
|
+
// Tracker adapter (`listOpenIssues`/`childrenOf`). The tick turns that
|
|
757
|
+
// snapshot into the mechanically selected batch: `offerToSpecLaunch`
|
|
758
|
+
// streams the snapshot through the store-side exclusions and the parent
|
|
759
|
+
// probe, and the returned block names the selected candidates as the ONLY
|
|
760
|
+
// batch this tick may launch. If the snapshot cannot be read, no batch is
|
|
761
|
+
// offered at all — the launch fails closed rather than trusting the model
|
|
762
|
+
// to self-filter.
|
|
763
|
+
// - Store facts — durable grooming rows (#735), admission's lane/dependency
|
|
764
|
+
// holds, active runs, in-flight launches — are enforced mechanically at
|
|
765
|
+
// composition time (the exclusion lines the block names) and again at
|
|
766
|
+
// `tool_call` time (the gate refuses an item the store contradicts).
|
|
767
|
+
// - The live source ref for each item cannot be known synchronously without
|
|
768
|
+
// a fresh per-repo read, so the model fetches it and declares it in the
|
|
769
|
+
// item's second contract line; the strict parser's 24h freshness ceiling
|
|
770
|
+
// re-vets the claim at persistence time, which is the boundary #772 set.
|
|
771
|
+
// - The gate additionally enforces the allowlist: a marker-bearing `task`
|
|
772
|
+
// call is refused unless every item's issue number AND routing repo match
|
|
773
|
+
// the batch this tick's token authorized. A parked or parent/epic
|
|
774
|
+
// candidate therefore cannot be stamped in-flight even if the model
|
|
775
|
+
// invents one — it was never on the list, and the list is the only thing
|
|
776
|
+
// the gate lets through.
|
|
777
|
+
//
|
|
778
|
+
// The pure selector (`selectToSpecBatch`) remains the single shared rule set:
|
|
779
|
+
// the offer feeds it the tracker-vetted pool and the gate runs the same
|
|
780
|
+
// exclusion on every item at call time, so a candidate excluded in the prompt
|
|
781
|
+
// is excluded in the gate for the same reason.
|
|
782
|
+
|
|
783
|
+
/** The `to-spec` agent every batch item runs under (shipped in
|
|
784
|
+
* `omp/agents/to-spec.md`, discovered through OMP's native task-agent
|
|
785
|
+
* discovery — never a custom process runtime). */
|
|
786
|
+
export const TO_SPEC_AGENT = "to-spec";
|
|
787
|
+
|
|
788
|
+
/** The maximum number of candidates one tick's batch may carry (#679's
|
|
789
|
+
* "small per-tick candidate limit"; the session-scoped task semaphore
|
|
790
|
+
* bounds concurrency underneath). */
|
|
791
|
+
export const TO_SPEC_BATCH_MAX = 3;
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* The `context` marker that identifies a conductor grooming batch to the
|
|
795
|
+
* `tool_call` gate. The launch block instructs the orchestrator to put
|
|
796
|
+
* `{@link TO_SPEC_BATCH_MARKER}: <token>` as the first line of the batch's
|
|
797
|
+
* shared `context`; the gate matches the marker and the exact token this tick
|
|
798
|
+
* issued, stamps the per-item contract (agent, strict schema), and persists
|
|
799
|
+
* the in-flight rows. A `task` call without this marker is the orchestrator's
|
|
800
|
+
* own and passes untouched.
|
|
801
|
+
*/
|
|
802
|
+
export const TO_SPEC_BATCH_MARKER = "conductor-to-spec-batch";
|
|
803
|
+
|
|
804
|
+
/** The grooming-table verdict row a launched-but-unfinished batch leaves behind
|
|
805
|
+
* (reason, on a `blocked` verdict): the durable in-flight marker that stops
|
|
806
|
+
* the next tick — or a restarted session — from re-launching the same item.
|
|
807
|
+
* `blocked` is deliberate: `recordToSpecGrooming` replaces the row when the
|
|
808
|
+
* result lands, and refusing-to-parse output must *not* be swallowed by the
|
|
809
|
+
* kept-prior path that protects prior `promotable`/`considered` verdicts. */
|
|
810
|
+
export const TO_SPEC_IN_FLIGHT_REASON = "in-flight";
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* How long a launch row may sit before it is treated as a dead batch and the
|
|
814
|
+
* candidate becomes eligible again. A batch that dies with the process (a
|
|
815
|
+
* daemon stop between `tool_call` and delivery) must not park a candidate
|
|
816
|
+
* forever; the 24h ceiling matches the source-freshness ceiling, so a
|
|
817
|
+
* relaunched pass always reads new source evidence anyway.
|
|
818
|
+
*/
|
|
819
|
+
export const TO_SPEC_IN_FLIGHT_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
820
|
+
|
|
821
|
+
/** The first line of every batch item's `task`, in the shape the gate parses:
|
|
822
|
+
* `to-spec candidate: <owner/repo>#<issue> — <title>`. */
|
|
823
|
+
export const TO_SPEC_ITEM_PREFIX = "to-spec candidate:";
|
|
824
|
+
|
|
825
|
+
/** The second line of every batch item's `task`, naming the authoritative
|
|
826
|
+
* source and the exact ref the item was groomed against:
|
|
827
|
+
* `to-spec source: <owner/repo>@<ref>`. `ref` is whatever the launch block
|
|
828
|
+
* told the orchestrator to fetch as the repo's current default-branch head. */
|
|
829
|
+
export const TO_SPEC_ITEM_SOURCE_PREFIX = "to-spec source:";
|
|
830
|
+
|
|
831
|
+
/** The tool the orchestrator calls to persist one completed item's exact raw
|
|
832
|
+
* output when the batch ran in the background (#777). Registered by this
|
|
833
|
+
* extension; subagents never see it — the `to-spec` agent's tool list is
|
|
834
|
+
* read-only and explicit. */
|
|
835
|
+
export const TO_SPEC_RESULT_TOOL = "conductor_to_spec_result";
|
|
836
|
+
|
|
837
|
+
/** One backlog candidate the mechanical gate can judge. The tracker facts
|
|
838
|
+
* (labels, epics) are read from the authoritative open-issue snapshot at
|
|
839
|
+
* launch composition time; they travel in this view so the selector stays
|
|
840
|
+
* deterministic and testable. */
|
|
841
|
+
export interface ToSpecCandidateView {
|
|
842
|
+
issue: number;
|
|
843
|
+
title: string;
|
|
844
|
+
/** The routing target — a routed `owner/repo` (from the issue's one
|
|
845
|
+
* `routing.labelPrefix<key>` label, resolved through `routing.repos`). */
|
|
846
|
+
routing: string;
|
|
847
|
+
/** Operator-parked (`project.stateLabels.backlog`); read from tracker labels. */
|
|
848
|
+
parked?: boolean;
|
|
849
|
+
/** A parent/epic with no independently runnable slice; read from the
|
|
850
|
+
* tracker's sub-issue probe. */
|
|
851
|
+
parent?: boolean;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** One candidate the tick's token authorizes. The gate admits a batch item
|
|
855
|
+
* only when its issue number AND routing both match an entry here. */
|
|
856
|
+
export interface ToSpecLaunchItem {
|
|
857
|
+
issue: number;
|
|
858
|
+
/** The `owner/repo` the item's first contract line must name. */
|
|
859
|
+
routing: string;
|
|
860
|
+
/** The candidate title, as it appears in the item's first contract line. */
|
|
861
|
+
title: string;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** The machine-readable item contract a batch item must satisfy. */
|
|
865
|
+
export interface ToSpecBatchItem {
|
|
866
|
+
issue: number;
|
|
867
|
+
/** The `owner/repo` named by the item's first contract line. */
|
|
868
|
+
routing: string;
|
|
869
|
+
/** The `owner/repo@ref` named by the item's second contract line. */
|
|
870
|
+
sourceRef: string;
|
|
871
|
+
/** The item's full task text (the rendered brief plus the contract lines). */
|
|
872
|
+
task: string;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Parse the two contract lines a batch item must start with
|
|
877
|
+
* (`to-spec candidate: <repo>#<n> — <title>` / `to-spec source: <name>@<ref>`).
|
|
878
|
+
* Anything else is not a conductor grooming item. Built from the shared
|
|
879
|
+
* {@link TO_SPEC_ITEM_PREFIX}/{@link TO_SPEC_ITEM_SOURCE_PREFIX} constants so
|
|
880
|
+
* the launch block's wording and the gate's parsing cannot drift apart.
|
|
881
|
+
*/
|
|
882
|
+
export function parseToSpecItem(task: unknown): ToSpecBatchItem | undefined {
|
|
883
|
+
if (typeof task !== "string") return undefined;
|
|
884
|
+
const lines = task.split("\n");
|
|
885
|
+
const head = new RegExp(`^${TO_SPEC_ITEM_PREFIX}\\s+([\\w.-]+\\/[\\w.-]+)#(\\d+)(?:\\s+—\\s+.*)?$`).exec(
|
|
886
|
+
lines[0]?.trim() ?? "",
|
|
887
|
+
);
|
|
888
|
+
const source = new RegExp(`^${TO_SPEC_ITEM_SOURCE_PREFIX}\\s+([\\w.-]+\\/[\\w.-]+)@(\\S+)$`).exec(
|
|
889
|
+
lines[1]?.trim() ?? "",
|
|
890
|
+
);
|
|
891
|
+
if (head === null || source === null) return undefined;
|
|
892
|
+
return { issue: Number(head[2]), routing: head[1]!, sourceRef: source[2]!, task };
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Why one candidate is not eligible for a batch right now, or undefined when
|
|
897
|
+
* it is. The single rule source for the prompt-time exclusion list, the
|
|
898
|
+
* `tool_call` gate, and the selection helper — one rule, three readers, so a
|
|
899
|
+
* candidate excluded in prose is excluded in the gate for the same reason.
|
|
900
|
+
*
|
|
901
|
+
* - `in-flight`: a launch row recorded within the TTL (a dead batch's row
|
|
902
|
+
* expires and the candidate becomes eligible again);
|
|
903
|
+
* - `file-lane` / `depends-on`: admission's durable mechanical holds (#735);
|
|
904
|
+
* - a fresh valid `to-spec` result in the grooming table: the candidate was
|
|
905
|
+
* already groomed at an observed source within the freshness ceiling, so
|
|
906
|
+
* re-running it would recompute a verdict that is still valid. New source
|
|
907
|
+
* evidence reconsiders it: once the recorded `freshAt` crosses the ceiling
|
|
908
|
+
* the row no longer reads as groomed, and a fresh pass overrides it;
|
|
909
|
+
* - `active`: a run is in flight on the issue right now.
|
|
910
|
+
*/
|
|
911
|
+
export function toSpecCandidateExclusion(
|
|
912
|
+
candidate: { issue: number },
|
|
913
|
+
facts: { grooming: GroomingRecord | undefined; active: boolean },
|
|
914
|
+
now: number,
|
|
915
|
+
): string | undefined {
|
|
916
|
+
const row = facts.grooming;
|
|
917
|
+
if (row !== undefined) {
|
|
918
|
+
if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
|
|
919
|
+
if (now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) {
|
|
920
|
+
return `#${candidate.issue} is already in a to-spec batch (launched ${new Date(row.recordedAt).toISOString()})`;
|
|
921
|
+
}
|
|
922
|
+
} else if (row.reason === "file-lane" || row.reason === "depends-on") {
|
|
923
|
+
return `#${candidate.issue} is mechanically blocked (${row.reason}) — the hold clears by itself`;
|
|
924
|
+
} else {
|
|
925
|
+
const result = parseToSpecEvidence(row.evidence);
|
|
926
|
+
if (result !== undefined && now - result.source.freshAt <= TO_SPEC_MAX_SOURCE_AGE_MS) {
|
|
927
|
+
return (
|
|
928
|
+
`#${candidate.issue} was already groomed ${result.verdict} (source ${result.source.name}@` +
|
|
929
|
+
`${result.source.ref}, observed ${new Date(result.source.freshAt).toISOString()}) — re-groom only ` +
|
|
930
|
+
"with new source evidence"
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
if (facts.active) return `#${candidate.issue} has a dispatched run in flight`;
|
|
936
|
+
return undefined;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* The mechanical half of Duty 2's launch: deterministic, bounded selection of
|
|
941
|
+
* the eligible candidates, smallest issue numbers first, never more than
|
|
942
|
+
* {@link TO_SPEC_BATCH_MAX} per batch, nothing at/above the grooming trigger,
|
|
943
|
+
* nothing before the first dispatch summary exists (queue health unknown —
|
|
944
|
+
* the same gate the queue digest uses). Parked and parent views are honored
|
|
945
|
+
* when the caller supplies them.
|
|
946
|
+
*
|
|
947
|
+
* Production reaches this selector through {@link offerToSpecLaunch}, which
|
|
948
|
+
* fills the views from the authoritative tracker snapshot (park label from
|
|
949
|
+
* the open-issue labels, parent/epic from the sub-issue probe) and streams
|
|
950
|
+
* the store-side exclusions before the selector runs — so parked and parent
|
|
951
|
+
* exclusions are enforced on the live path, not only on test inputs.
|
|
952
|
+
*/
|
|
953
|
+
export function selectToSpecBatch(input: {
|
|
954
|
+
candidates: readonly ToSpecCandidateView[];
|
|
955
|
+
summary: DispatchSummary | undefined;
|
|
956
|
+
groomBelow: number;
|
|
957
|
+
grooming: readonly GroomingRecord[];
|
|
958
|
+
active: readonly { issue: number }[];
|
|
959
|
+
now: number;
|
|
960
|
+
}): ToSpecCandidateView[] {
|
|
961
|
+
if (input.summary === undefined) return [];
|
|
962
|
+
if (input.summary.routed >= input.groomBelow) return [];
|
|
963
|
+
const groomingByIssue = new Map(input.grooming.map((row) => [row.issue, row]));
|
|
964
|
+
const activeIssues = new Set(input.active.map((run) => run.issue));
|
|
965
|
+
const selected: ToSpecCandidateView[] = [];
|
|
966
|
+
for (const candidate of [...input.candidates].sort((a, b) => a.issue - b.issue)) {
|
|
967
|
+
if (candidate.parked) continue;
|
|
968
|
+
if (candidate.parent) continue;
|
|
969
|
+
if (
|
|
970
|
+
toSpecCandidateExclusion(
|
|
971
|
+
{ issue: candidate.issue },
|
|
972
|
+
{ grooming: groomingByIssue.get(candidate.issue), active: activeIssues.has(candidate.issue) },
|
|
973
|
+
input.now,
|
|
974
|
+
) !== undefined
|
|
975
|
+
) {
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
selected.push(candidate);
|
|
979
|
+
if (selected.length >= TO_SPEC_BATCH_MAX) break;
|
|
980
|
+
}
|
|
981
|
+
return selected;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* One tick's launch authorization: the token, the block text it carries, and
|
|
986
|
+
* the mechanically selected batch the token authorizes. The gate admits a
|
|
987
|
+
* marker-bearing `task` call only when every item matches an entry of
|
|
988
|
+
* {@link ToSpecLaunchBlock.items} — the list is produced from the tracker
|
|
989
|
+
* snapshot, never from model-supplied fields.
|
|
990
|
+
*/
|
|
991
|
+
export interface ToSpecLaunchBlock {
|
|
992
|
+
/** The per-launch token the batch must echo in its `context` first line. */
|
|
993
|
+
token: string;
|
|
994
|
+
block: string;
|
|
995
|
+
/** The mechanically selected candidates — the ONLY batch this token may
|
|
996
|
+
* carry (issue number AND routing must both match). */
|
|
997
|
+
items: ToSpecLaunchItem[];
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** Why candidates sit out, as one compact line each, for the launch block. */
|
|
1001
|
+
export interface ToSpecLaunchExclusions {
|
|
1002
|
+
/** Candidates with a fresh, valid to-spec verdict already on the grooming table. */
|
|
1003
|
+
groomed: string[];
|
|
1004
|
+
/** Candidates with an active to-spec batch. */
|
|
1005
|
+
inFlight: string[];
|
|
1006
|
+
/** Candidates under admission's durable lane/dependency holds. */
|
|
1007
|
+
mechanicallyBlocked: string[];
|
|
1008
|
+
/** Candidates with a dispatched run live right now. */
|
|
1009
|
+
dispatched: string[];
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* The authoritative tracker surface the launch selection is built from
|
|
1014
|
+
* (#777): the two reads through the existing Tracker adapter that answer
|
|
1015
|
+
* "which open backlog issues are actually eligible right now". Production
|
|
1016
|
+
* implements this with `makeTracker(...).listOpenIssues()` /
|
|
1017
|
+
* `.childrenOf(...)`; tests inject deterministic fakes. `listOpenIssues` is
|
|
1018
|
+
* the one open-issue snapshot with labels (#203) — it answers the park
|
|
1019
|
+
* label and the routing label mechanically — and `childrenOf` answers
|
|
1020
|
+
* whether a candidate is a parent/epic (its sub-issues exist, so it has no
|
|
1021
|
+
* independently runnable slice of its own). Both fail closed: an unreadable
|
|
1022
|
+
* snapshot means no batch is offered, while an unreadable parent probe skips
|
|
1023
|
+
* that candidate because eligibility was not mechanically established.
|
|
1024
|
+
*/
|
|
1025
|
+
export interface ToSpecTrackerSeam {
|
|
1026
|
+
/** Every open issue in the tracker repo, labels included
|
|
1027
|
+
* (`Tracker.listOpenIssues`). Throws when the tracker cannot be read. */
|
|
1028
|
+
listOpenIssues(project: ProjectConfig): Promise<ReadyIssue[]>;
|
|
1029
|
+
/** Sub-issues of one issue (`Tracker.childrenOf`). */
|
|
1030
|
+
childrenOf(project: ProjectConfig, issue: number): Promise<{ number: number; state: IssueState }[]>;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* The tracker half of the candidate views: map one open-issue snapshot onto
|
|
1035
|
+
* the pool the selector can judge. Issues still carrying the queue label are
|
|
1036
|
+
* already queued — not backlog — and leave the pool; issues with zero or
|
|
1037
|
+
* several `routing.labelPrefix` labels (or a label mapping to no configured
|
|
1038
|
+
* repo) cannot name an authoritative source to read and leave the pool,
|
|
1039
|
+
* exactly like admission's unroutable partition. The park label lands on the
|
|
1040
|
+
* view for the selector to drop; the parent/epic probe is separate (one
|
|
1041
|
+
* tracker read per candidate) and stays with the offer, which runs it only
|
|
1042
|
+
* for candidates the store-side exclusions did not already reject.
|
|
1043
|
+
*/
|
|
1044
|
+
function toSpecPoolFromSnapshot(
|
|
1045
|
+
issues: readonly ReadyIssue[],
|
|
1046
|
+
project: ProjectConfig,
|
|
1047
|
+
): ToSpecCandidateView[] {
|
|
1048
|
+
const queueLabel = project.queueLabel;
|
|
1049
|
+
const parkLabel = project.stateLabels.backlog;
|
|
1050
|
+
const { labelPrefix, repos } = project.routing;
|
|
1051
|
+
const views: ToSpecCandidateView[] = [];
|
|
1052
|
+
for (const issue of issues) {
|
|
1053
|
+
if (issue.labels.includes(queueLabel)) continue;
|
|
1054
|
+
const matched = [...new Set(issue.labels.filter((l) => l.startsWith(labelPrefix)))];
|
|
1055
|
+
if (matched.length !== 1) continue;
|
|
1056
|
+
const key = matched[0]!.slice(labelPrefix.length);
|
|
1057
|
+
const target = Object.hasOwn(repos, key) ? repos[key]! : undefined;
|
|
1058
|
+
if (target === undefined) continue;
|
|
1059
|
+
views.push({
|
|
1060
|
+
issue: issue.number,
|
|
1061
|
+
title: issue.title,
|
|
1062
|
+
routing: repoSlugFor(target),
|
|
1063
|
+
parked: issue.labels.includes(parkLabel) ? true : undefined,
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
views.sort((a, b) => a.issue - b.issue);
|
|
1067
|
+
return views;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* The production launch offer for one tick: the complete mechanical
|
|
1072
|
+
* selection, from the authoritative tracker snapshot through every
|
|
1073
|
+
* exclusion, ending in the token + block + allowlist, or `undefined` when no
|
|
1074
|
+
* batch may launch. All of the following yield `undefined`:
|
|
1075
|
+
*
|
|
1076
|
+
* - no dispatch row yet, or the routable queue is at/above the grooming
|
|
1077
|
+
* trigger (the same gate the queue digest uses);
|
|
1078
|
+
* - no tracker seam (the snapshot is unavailable);
|
|
1079
|
+
* - the snapshot cannot be read — the launch fails closed rather than
|
|
1080
|
+
* trusting the model to self-filter parked/parent/epic candidates;
|
|
1081
|
+
* - nothing survives the exclusions (parked, parent/epic, already groomed,
|
|
1082
|
+
* in-flight, lane/dependency holds, dispatched runs) — there is then no
|
|
1083
|
+
* batch to authorize, and a marker-bearing `task` call stays refused.
|
|
1084
|
+
*
|
|
1085
|
+
* The parent/epic probe runs only for candidates the store-side exclusions
|
|
1086
|
+
* have not already rejected, in issue order, and stops as soon as
|
|
1087
|
+
* {@link TO_SPEC_BATCH_MAX} candidates are selected — a bounded set of
|
|
1088
|
+
* tracker reads per low-queue tick, never one per open issue.
|
|
1089
|
+
*/
|
|
1090
|
+
export async function offerToSpecLaunch(input: {
|
|
1091
|
+
summary: DispatchSummary | undefined;
|
|
1092
|
+
groomBelow: number;
|
|
1093
|
+
grooming: readonly GroomingRecord[];
|
|
1094
|
+
active: readonly { issue: number }[];
|
|
1095
|
+
project: ProjectConfig;
|
|
1096
|
+
trackerSeam: ToSpecTrackerSeam | undefined;
|
|
1097
|
+
now: number;
|
|
1098
|
+
}): Promise<ToSpecLaunchBlock | undefined> {
|
|
1099
|
+
if (input.summary === undefined) return undefined;
|
|
1100
|
+
if (input.summary.routed >= input.groomBelow) return undefined;
|
|
1101
|
+
const seam = input.trackerSeam;
|
|
1102
|
+
if (seam === undefined) return undefined;
|
|
1103
|
+
let issues: ReadyIssue[];
|
|
1104
|
+
try {
|
|
1105
|
+
issues = await seam.listOpenIssues(input.project);
|
|
1106
|
+
} catch {
|
|
1107
|
+
// No authoritative snapshot, no launch: a batch offered without one would
|
|
1108
|
+
// make the model the selector, which is exactly the defect this slice
|
|
1109
|
+
// removes. The queue digest still names the grooming duty; the next tick
|
|
1110
|
+
// retries the read.
|
|
1111
|
+
return undefined;
|
|
1112
|
+
}
|
|
1113
|
+
const views = toSpecPoolFromSnapshot(issues, input.project);
|
|
1114
|
+
const groomingByIssue = new Map(input.grooming.map((row) => [row.issue, row]));
|
|
1115
|
+
const activeIssues = new Set(input.active.map((run) => run.issue));
|
|
1116
|
+
const pool: ToSpecCandidateView[] = [];
|
|
1117
|
+
for (const view of views) {
|
|
1118
|
+
if (view.parked) continue;
|
|
1119
|
+
if (
|
|
1120
|
+
toSpecCandidateExclusion(
|
|
1121
|
+
{ issue: view.issue },
|
|
1122
|
+
{ grooming: groomingByIssue.get(view.issue), active: activeIssues.has(view.issue) },
|
|
1123
|
+
input.now,
|
|
1124
|
+
) !== undefined
|
|
1125
|
+
) {
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
let children: { number: number; state: IssueState }[];
|
|
1129
|
+
try {
|
|
1130
|
+
children = await seam.childrenOf(input.project, view.issue);
|
|
1131
|
+
} catch {
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
if (children.length > 0) continue; // parent/epic with sub-issues: no runnable slice
|
|
1135
|
+
pool.push(view);
|
|
1136
|
+
if (pool.length >= TO_SPEC_BATCH_MAX) break;
|
|
1137
|
+
}
|
|
1138
|
+
// The one shared rule set re-runs on the tracker-vetted pool, so the pure
|
|
1139
|
+
// selector — not a prose instruction — answers what the block authorizes.
|
|
1140
|
+
const selected = selectToSpecBatch({
|
|
1141
|
+
candidates: pool,
|
|
1142
|
+
summary: input.summary,
|
|
1143
|
+
groomBelow: input.groomBelow,
|
|
1144
|
+
grooming: input.grooming,
|
|
1145
|
+
active: input.active,
|
|
1146
|
+
now: input.now,
|
|
1147
|
+
});
|
|
1148
|
+
if (selected.length === 0) return undefined;
|
|
1149
|
+
return toSpecLaunchBlock({
|
|
1150
|
+
summary: input.summary,
|
|
1151
|
+
groomBelow: input.groomBelow,
|
|
1152
|
+
grooming: input.grooming,
|
|
1153
|
+
active: input.active,
|
|
1154
|
+
tracker: input.project.tracker.repo,
|
|
1155
|
+
queueLabel: input.project.queueLabel,
|
|
1156
|
+
labelPrefix: input.project.routing.labelPrefix,
|
|
1157
|
+
parkLabel: input.project.stateLabels.backlog,
|
|
1158
|
+
selected,
|
|
1159
|
+
now: input.now,
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* The per-tick launch block. Present exactly when a batch may be launched:
|
|
1165
|
+
* the queue is below the grooming trigger (`summary.routed < groomBelow`),
|
|
1166
|
+
* a dispatch row exists, and the mechanical selection produced at least one
|
|
1167
|
+
* candidate. The block names the selected candidates as the ONLY batch this
|
|
1168
|
+
* tick authorizes and lists every store-proven exclusion the selection
|
|
1169
|
+
* already applied; the `tool_call` gate refuses any item outside the list.
|
|
1170
|
+
*/
|
|
1171
|
+
export function toSpecLaunchBlock(input: {
|
|
1172
|
+
summary: DispatchSummary | undefined;
|
|
1173
|
+
groomBelow: number;
|
|
1174
|
+
grooming: readonly GroomingRecord[];
|
|
1175
|
+
active: readonly { issue: number }[];
|
|
1176
|
+
tracker: string;
|
|
1177
|
+
queueLabel: string;
|
|
1178
|
+
labelPrefix: string;
|
|
1179
|
+
parkLabel: string;
|
|
1180
|
+
/** The mechanically selected candidates the block renders and authorizes. */
|
|
1181
|
+
selected: readonly ToSpecCandidateView[];
|
|
1182
|
+
now: number;
|
|
1183
|
+
}): ToSpecLaunchBlock | undefined {
|
|
1184
|
+
if (input.summary === undefined) return undefined;
|
|
1185
|
+
if (input.summary.routed >= input.groomBelow) return undefined;
|
|
1186
|
+
if (input.selected.length === 0) return undefined;
|
|
1187
|
+
const exclusions: ToSpecLaunchExclusions = { groomed: [], inFlight: [], mechanicallyBlocked: [], dispatched: [] };
|
|
1188
|
+
for (const row of input.grooming) {
|
|
1189
|
+
if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
|
|
1190
|
+
if (input.now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) exclusions.inFlight.push(`#${row.issue}`);
|
|
1191
|
+
} else if (row.reason === "file-lane" || row.reason === "depends-on") {
|
|
1192
|
+
exclusions.mechanicallyBlocked.push(`#${row.issue} (${row.reason})`);
|
|
1193
|
+
} else {
|
|
1194
|
+
const result = parseToSpecEvidence(row.evidence);
|
|
1195
|
+
if (result !== undefined && input.now - result.source.freshAt <= TO_SPEC_MAX_SOURCE_AGE_MS) {
|
|
1196
|
+
exclusions.groomed.push(
|
|
1197
|
+
`#${row.issue} (${result.verdict} @ ${result.source.ref}, observed ${new Date(result.source.freshAt).toISOString()})`,
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
for (const run of input.active) exclusions.dispatched.push(`#${run.issue}`);
|
|
1203
|
+
const token = randomUUID();
|
|
1204
|
+
const items: ToSpecLaunchItem[] = input.selected.map((candidate) => ({
|
|
1205
|
+
issue: candidate.issue,
|
|
1206
|
+
routing: candidate.routing,
|
|
1207
|
+
title: candidate.title,
|
|
1208
|
+
}));
|
|
1209
|
+
const candidates = items.map((item) => `- #${item.issue} (${item.routing}) — ${item.title}`).join("\n");
|
|
1210
|
+
const lines = [
|
|
1211
|
+
`## Bounded to-spec grooming batch (${TO_SPEC_BATCH_MARKER}, #777)`,
|
|
1212
|
+
"",
|
|
1213
|
+
`The routable queue is below the grooming trigger of ${input.groomBelow} (${input.summary.routed} routable). ` +
|
|
1214
|
+
"Duty 2's finding is now a strict contract: launch EXACTLY ONE native `task` batch this turn with the " +
|
|
1215
|
+
`\`${TO_SPEC_AGENT}\` agent — never a second batch, never an improvised scout.`,
|
|
1216
|
+
"",
|
|
1217
|
+
"The conductor selected this batch mechanically from the live open-issue snapshot " +
|
|
1218
|
+
`(issues carrying \`${input.queueLabel}\`, the \`${input.parkLabel}\` park label, issues without exactly one ` +
|
|
1219
|
+
`\`${input.labelPrefix}<repo>\` routing label, parent/epic issues with sub-issues, ` +
|
|
1220
|
+
"already-groomed, in-flight, lane/dependency-blocked and dispatched candidates were excluded):",
|
|
1221
|
+
candidates,
|
|
1222
|
+
"Excluded this tick — " +
|
|
1223
|
+
`already-groomed: ${exclusions.groomed.length === 0 ? "none" : exclusions.groomed.join(", ")}; ` +
|
|
1224
|
+
`in-flight batches — ${exclusions.inFlight.length === 0 ? "none" : exclusions.inFlight.join(", ")}; ` +
|
|
1225
|
+
`mechanically blocked: ${exclusions.mechanicallyBlocked.length === 0 ? "none" : exclusions.mechanicallyBlocked.join(", ")}; ` +
|
|
1226
|
+
`dispatched now: ${exclusions.dispatched.length === 0 ? "none" : exclusions.dispatched.join(", ")}.`,
|
|
1227
|
+
"",
|
|
1228
|
+
"To launch it:",
|
|
1229
|
+
"",
|
|
1230
|
+
`1. For each candidate above, render \`omp/src/briefs/to-spec.md\` with its placeholders — {{TRACKER_REPO}}, ` +
|
|
1231
|
+
"{{ISSUE_NUMBER}}, {{CANDIDATE_TITLE}}, {{ISSUE_BODY}} (read via `gh issue view <number> --repo " +
|
|
1232
|
+
`${input.tracker}\`, never from memory), {{SOURCE}} = the candidate's routed repo, {{SOURCE_REF}} = that repo's \`` +
|
|
1233
|
+
"current default-branch head, fetched now (`gh api repos/<repo>/branches/HEAD` or `git ls-remote`). Your item " +
|
|
1234
|
+
"`task` MUST start with the two contract lines `to-spec candidate: <owner/repo>#<issue> — <title>` and " +
|
|
1235
|
+
"`to-spec source: <owner/repo>@<ref>` — the `owner/repo` must match the routing named above.",
|
|
1236
|
+
`2. Call \`task\` once with \`context\` whose first line is exactly \`${TO_SPEC_BATCH_MARKER}: ${token}\`, one ` +
|
|
1237
|
+
`item per candidate above, in that order — no substitutes, no extra items (\`agent: "${TO_SPEC_AGENT}"\`; this ` +
|
|
1238
|
+
"extension stamps the exact outputSchema/schemaMode on the way in and refuses a second batch this turn).",
|
|
1239
|
+
`3. When each item completes, whether in the tool result or later as an async-result message, persist the agent's ` +
|
|
1240
|
+
`exact raw output through the \`${TO_SPEC_RESULT_TOOL}\` tool — one call per completed item (\`issue\` + \`input\`), ` +
|
|
1241
|
+
"success and failure alike: a malformed, source-less or stale result persists as blocked and must not discard " +
|
|
1242
|
+
"successful siblings. Read the full output from its `agent://<id>` artifact when the inline text is truncated.",
|
|
1243
|
+
`Never add \`${input.queueLabel}\`, never edit an issue or its labels. This batch produces verdicts only; ` +
|
|
1244
|
+
"promotion stays your decision — the tool_call gate and the store own persistence, you own the queue.",
|
|
1245
|
+
"",
|
|
1246
|
+
];
|
|
1247
|
+
return { token, block: lines.join("\n"), items };
|
|
1248
|
+
}
|
|
1249
|
+
|
|
664
1250
|
export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScopeChoice]: string } = {
|
|
665
1251
|
material: "Report material events per your brief.",
|
|
666
1252
|
escalations:
|
|
@@ -2284,6 +2870,29 @@ interface TickSession {
|
|
|
2284
2870
|
legacyArmLogged: boolean;
|
|
2285
2871
|
/** The local tick whose agent loop is currently running, if any. */
|
|
2286
2872
|
activeLocalTick?: ActiveLocalTick;
|
|
2873
|
+
/**
|
|
2874
|
+
* The to-spec launch token the last low-queue tick authorized (#777). Set
|
|
2875
|
+
* when the tick appended a launch block; the `task` tool_call gate accepts
|
|
2876
|
+
* exactly one batch echoing it and refuses a marker-bearing call without
|
|
2877
|
+
* it (no authorized batch this tick), with a stale token, or after a batch
|
|
2878
|
+
* was already accepted. Cleared at the start of every tick — healthy and
|
|
2879
|
+
* low-queue alike — so a token minted on a low-queue tick can never be
|
|
2880
|
+
* spent on a later healthy-queue tick; the tick that mints the next token
|
|
2881
|
+
* sets it fresh with its own {@link TickSession.launchItems}.
|
|
2882
|
+
*/
|
|
2883
|
+
launchToken?: string;
|
|
2884
|
+
/** The project the {launchToken} authorization was minted for — the one
|
|
2885
|
+
* in-flight rows are recorded against. */
|
|
2886
|
+
launchProject?: string;
|
|
2887
|
+
/** The mechanically selected batch the current token authorizes: the gate
|
|
2888
|
+
* refuses any item whose issue or routing is not on this list, so parked,
|
|
2889
|
+
* parent/epic and other excluded candidates cannot be stamped in-flight
|
|
2890
|
+
* no matter what the model sends. */
|
|
2891
|
+
launchItems?: ToSpecLaunchItem[];
|
|
2892
|
+
/** The one batch this session's gate has let through, so a second attempt
|
|
2893
|
+
* this tick refuses, and the tool_result capture can match results to
|
|
2894
|
+
* items by index. */
|
|
2895
|
+
launchedBatch?: { toolCallId: string; items: ToSpecBatchItem[] };
|
|
2287
2896
|
}
|
|
2288
2897
|
|
|
2289
2898
|
/**
|
|
@@ -2357,7 +2966,20 @@ async function ensureAskSurface(pi: TickApi, resolvable: boolean): Promise<boole
|
|
|
2357
2966
|
* way. Skips are deliberately silent in the UI — a disarmed fleet would
|
|
2358
2967
|
* otherwise emit a notification every interval, forever.
|
|
2359
2968
|
*/
|
|
2360
|
-
async function tick(
|
|
2969
|
+
async function tick(
|
|
2970
|
+
pi: TickApi,
|
|
2971
|
+
ctx: TickContext,
|
|
2972
|
+
config: TickConfig,
|
|
2973
|
+
session: TickSession,
|
|
2974
|
+
toSpecTrackerSeam: ToSpecTrackerSeam | undefined,
|
|
2975
|
+
): Promise<void> {
|
|
2976
|
+
// A launch authorization belongs to one emitted tick only. Revoke it before
|
|
2977
|
+
// any gate, config, store or tracker read so a skipped/degraded/healthy next
|
|
2978
|
+
// tick cannot reuse a token minted by an earlier low-queue tick.
|
|
2979
|
+
session.launchedBatch = undefined;
|
|
2980
|
+
session.launchToken = undefined;
|
|
2981
|
+
session.launchProject = undefined;
|
|
2982
|
+
session.launchItems = undefined;
|
|
2361
2983
|
const live = currentConfig(ctx.cwd, config);
|
|
2362
2984
|
const arm = live.armedFile === undefined ? undefined : resolveArmState(live.armedFile, live.project);
|
|
2363
2985
|
if (arm?.legacy === "stranded" && !session.legacyArmLogged) {
|
|
@@ -2607,8 +3229,39 @@ async function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session:
|
|
|
2607
3229
|
project.queueLabel,
|
|
2608
3230
|
project.routing.labelPrefix,
|
|
2609
3231
|
project.groomBelow ?? DEFAULT_GROOM_BELOW,
|
|
3232
|
+
store.groomingVerdicts(projectName),
|
|
2610
3233
|
);
|
|
2611
3234
|
if (queue !== undefined) content = `${content}\n${queue}`;
|
|
3235
|
+
// #777: the mechanical to-spec launch boundary. The queue digest is
|
|
3236
|
+
// the trigger — the same below-`groomBelow` signal that already asks
|
|
3237
|
+
// the orchestrator to groom — so the launch offer is composed beside
|
|
3238
|
+
// it, from the same store read, and only when a dispatch row exists
|
|
3239
|
+
// to prove the queue is genuinely low. The token it carries is the
|
|
3240
|
+
// only authorization the `task` tool_call gate accepts this tick;
|
|
3241
|
+
// absent a block (healthy queue, no dispatch, unreadable snapshot,
|
|
3242
|
+
// nothing eligible) a task call with the batch marker is refused.
|
|
3243
|
+
//
|
|
3244
|
+
// Every tick owns its authorization fresh — the clears above ran
|
|
3245
|
+
// before the reads, so the offer below can only mint for THIS tick.
|
|
3246
|
+
const launch = await offerToSpecLaunch({
|
|
3247
|
+
summary: frictionStore.latestDispatch(scope.projectName),
|
|
3248
|
+
groomBelow: project.groomBelow ?? DEFAULT_GROOM_BELOW,
|
|
3249
|
+
grooming: store.groomingVerdicts(projectName),
|
|
3250
|
+
active: store.activeRuns(projectName),
|
|
3251
|
+
project,
|
|
3252
|
+
trackerSeam: toSpecTrackerSeam,
|
|
3253
|
+
now,
|
|
3254
|
+
});
|
|
3255
|
+
if (launch !== undefined) {
|
|
3256
|
+
content = `${content}\n${launch.block}`;
|
|
3257
|
+
// A minted token authorizes exactly the batch this tick's block
|
|
3258
|
+
// describes: the project and the allowlist are captured so the
|
|
3259
|
+
// tool_call gate records in-flight rows against the same project
|
|
3260
|
+
// the prompt named and refuses any item off the list.
|
|
3261
|
+
session.launchProject = project.name;
|
|
3262
|
+
session.launchToken = launch.token;
|
|
3263
|
+
session.launchItems = launch.items;
|
|
3264
|
+
}
|
|
2612
3265
|
// Pending intake is the same class of standing block as the friction
|
|
2613
3266
|
// and decisions read-outs: a store-backed duty the orchestrator must
|
|
2614
3267
|
// not derive from memory. The store answers, the prompt instructs.
|
|
@@ -2619,7 +3272,7 @@ async function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session:
|
|
|
2619
3272
|
});
|
|
2620
3273
|
if (pendingIntake !== undefined) content = `${content}\n${pendingIntake}`;
|
|
2621
3274
|
} catch {
|
|
2622
|
-
// unreadable config: no queue digest or pending-intake block this tick
|
|
3275
|
+
// unreadable config: no queue digest, launch block, or pending-intake block this tick
|
|
2623
3276
|
}
|
|
2624
3277
|
} catch (err) {
|
|
2625
3278
|
frictionStore?.close();
|
|
@@ -2780,11 +3433,17 @@ function writeTickRuntimeStatus(pi: TickApi, cwd: string, intervalSeconds: numbe
|
|
|
2780
3433
|
* heartbeat interval keeps handing its promise to the harness, which routes
|
|
2781
3434
|
* rejections to the extension error channel on its own.
|
|
2782
3435
|
*/
|
|
2783
|
-
function armTickHeartbeat(
|
|
3436
|
+
function armTickHeartbeat(
|
|
3437
|
+
pi: TickApi,
|
|
3438
|
+
ctx: TickContext,
|
|
3439
|
+
config: TickConfig,
|
|
3440
|
+
session: TickSession,
|
|
3441
|
+
toSpecTrackerSeam: ToSpecTrackerSeam | undefined,
|
|
3442
|
+
): void {
|
|
2784
3443
|
writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
|
|
2785
3444
|
const runScheduledTick = async (): Promise<void> => {
|
|
2786
3445
|
try {
|
|
2787
|
-
await tick(pi, ctx, config, session);
|
|
3446
|
+
await tick(pi, ctx, config, session, toSpecTrackerSeam);
|
|
2788
3447
|
} finally {
|
|
2789
3448
|
writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
|
|
2790
3449
|
}
|
|
@@ -2840,7 +3499,7 @@ function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, ses
|
|
|
2840
3499
|
// call runs inside the session_start handler dispatch, which cannot see an
|
|
2841
3500
|
// async rejection — an escaped one reaches the process-level
|
|
2842
3501
|
// unhandledRejection handler and takes the session down.
|
|
2843
|
-
void tick(pi, ctx, config, session).catch((err) => {
|
|
3502
|
+
void tick(pi, ctx, config, session, toSpecTrackerSeam).catch((err) => {
|
|
2844
3503
|
pi.logger.error(
|
|
2845
3504
|
`[omp-conductor] arm-time tick failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2846
3505
|
);
|
|
@@ -2884,6 +3543,303 @@ function armTickGuard(pi: TickApi, ctx: TickContext, budgetMs: number): void {
|
|
|
2884
3543
|
});
|
|
2885
3544
|
}
|
|
2886
3545
|
|
|
3546
|
+
/**
|
|
3547
|
+
* The harness surface one to-spec batch travels through (#777): the `task`
|
|
3548
|
+
* tool call the orchestrator makes (with the launch marker in `context`) and
|
|
3549
|
+
* its result. `details` is the task tool's `TaskToolDetails` — declared here
|
|
3550
|
+
* rather than imported, exactly like {@link TickApi}, because the harness is
|
|
3551
|
+
* a peer dependency. A settled batch's `details.results` carries one entry
|
|
3552
|
+
* per item; a background launch returns an empty `results` array and delivers
|
|
3553
|
+
* each final result later as an async-result message, which the orchestrator
|
|
3554
|
+
* routes through {@link TO_SPEC_RESULT_TOOL}.
|
|
3555
|
+
*/
|
|
3556
|
+
interface ToSpecTaskToolEvent {
|
|
3557
|
+
toolName: string;
|
|
3558
|
+
toolCallId: string;
|
|
3559
|
+
input: Record<string, unknown>;
|
|
3560
|
+
details: unknown;
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
/** One settled batch item, as `details.results` carries it. */
|
|
3564
|
+
interface ToSpecSettledItem {
|
|
3565
|
+
/** The item's index inside the batch call. */
|
|
3566
|
+
index?: unknown;
|
|
3567
|
+
/** The agent's raw output — the exact text that must reach the parser. */
|
|
3568
|
+
output?: unknown;
|
|
3569
|
+
/** The harness's parsed structured output, when one exists. */
|
|
3570
|
+
structuredOutput?: unknown;
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
/**
|
|
3574
|
+
* The launch gate and result capture for one to-spec batch (#777). Armed at
|
|
3575
|
+
* extension-factory time; inert until a low-queue tick mints a launch token.
|
|
3576
|
+
*
|
|
3577
|
+
* `tool_call` on `task`:
|
|
3578
|
+
* - a call without the {@link TO_SPEC_BATCH_MARKER} in `context` is the
|
|
3579
|
+
* orchestrator's own task use and passes untouched;
|
|
3580
|
+
* - a marker-bearing call is the conductor batch and is gated hard: it needs
|
|
3581
|
+
* this tick's exact token, the batch shape (1..{@link TO_SPEC_BATCH_MAX}
|
|
3582
|
+
* items, each starting with the two contract lines), unique items and
|
|
3583
|
+
* names, every item ON the tick's mechanically selected allowlist (issue
|
|
3584
|
+
* and routing — the parked/parent/epic exclusions are baked into that
|
|
3585
|
+
* list, so a forbidden candidate can never be stamped in-flight), and
|
|
3586
|
+
* store-side eligibility — the same {@link toSpecCandidateExclusion} rule
|
|
3587
|
+
* the prompt block's exclusion lines came from, so an item excluded in
|
|
3588
|
+
* prose is excluded in the gate for the same reason;
|
|
3589
|
+
* - on acceptance it stamps every item's `agent`, `outputSchema` and
|
|
3590
|
+
* `schemaMode` (the model never carries the schema itself), records the
|
|
3591
|
+
* durable in-flight rows through the #735 grooming table, and latches the
|
|
3592
|
+
* session so a second batch this tick refuses.
|
|
3593
|
+
*
|
|
3594
|
+
* `tool_result` on the accepted call captures a *settled* batch: every item
|
|
3595
|
+
* is routed through {@link recordToSpecGrooming} independently, so a
|
|
3596
|
+
* malformed or failed item persists `blocked` and its siblings survive. A
|
|
3597
|
+
* background launch settles with no results here — its completed items arrive
|
|
3598
|
+
* as async-result messages and are persisted through
|
|
3599
|
+
* {@link TO_SPEC_RESULT_TOOL}.
|
|
3600
|
+
*/
|
|
3601
|
+
function armToSpecGate(pi: TickApi, session: TickSession): void {
|
|
3602
|
+
const api = pi as TickApi & {
|
|
3603
|
+
on(
|
|
3604
|
+
event: "tool_call",
|
|
3605
|
+
handler: (
|
|
3606
|
+
event: { toolName: string; toolCallId: string; input: Record<string, unknown> },
|
|
3607
|
+
ctx: unknown,
|
|
3608
|
+
) => { block: true; reason: string } | { input: Record<string, unknown> } | undefined,
|
|
3609
|
+
): void;
|
|
3610
|
+
on(event: "tool_result", handler: (event: ToSpecTaskToolEvent, ctx: unknown) => void): void;
|
|
3611
|
+
};
|
|
3612
|
+
|
|
3613
|
+
api.on("tool_call", (event) => {
|
|
3614
|
+
if (event.toolName !== "task") return undefined;
|
|
3615
|
+
const input = event.input;
|
|
3616
|
+
const context = input["context"];
|
|
3617
|
+
if (typeof context !== "string" || !context.includes(TO_SPEC_BATCH_MARKER)) return undefined;
|
|
3618
|
+
// From here on this is a conductor grooming batch — the hard gate. Each
|
|
3619
|
+
// refusal names the correction, because a blocked batch costs the tick a
|
|
3620
|
+
// retry and the gate is meant to catch model error, not to hide it.
|
|
3621
|
+
if (session.launchToken === undefined) {
|
|
3622
|
+
return {
|
|
3623
|
+
block: true,
|
|
3624
|
+
reason:
|
|
3625
|
+
`task refused: no to-spec batch is authorized this tick (` +
|
|
3626
|
+
`only a low-queue tick's launch block names a ${TO_SPEC_BATCH_MARKER} token). ` +
|
|
3627
|
+
"Run no batch this turn.",
|
|
3628
|
+
};
|
|
3629
|
+
}
|
|
3630
|
+
if (session.launchedBatch !== undefined) {
|
|
3631
|
+
return {
|
|
3632
|
+
block: true,
|
|
3633
|
+
reason: "task refused: this tick already launched its one to-spec batch. Wait for the results.",
|
|
3634
|
+
};
|
|
3635
|
+
}
|
|
3636
|
+
const token = new RegExp(`${TO_SPEC_BATCH_MARKER}:\\s*(\\S+)`).exec(context)?.[1];
|
|
3637
|
+
if (token !== session.launchToken) {
|
|
3638
|
+
return {
|
|
3639
|
+
block: true,
|
|
3640
|
+
reason:
|
|
3641
|
+
"task refused: the context token does not match the batch this tick authorized. " +
|
|
3642
|
+
"Relaunch with the token named in this tick's launch block.",
|
|
3643
|
+
};
|
|
3644
|
+
}
|
|
3645
|
+
const tasks = input["tasks"];
|
|
3646
|
+
if (!Array.isArray(tasks) || tasks.length === 0 || tasks.length > TO_SPEC_BATCH_MAX) {
|
|
3647
|
+
return {
|
|
3648
|
+
block: true,
|
|
3649
|
+
reason:
|
|
3650
|
+
`task refused: a to-spec batch carries 1–${TO_SPEC_BATCH_MAX} items (one \`tasks[]\` call) — ` +
|
|
3651
|
+
`got ${Array.isArray(tasks) ? tasks.length : "none"}.`,
|
|
3652
|
+
};
|
|
3653
|
+
}
|
|
3654
|
+
const items: ToSpecBatchItem[] = [];
|
|
3655
|
+
const names = new Set<string>();
|
|
3656
|
+
for (const raw of tasks) {
|
|
3657
|
+
if (typeof raw !== "object" || raw === null) {
|
|
3658
|
+
return { block: true, reason: "task refused: every batch item must be an object." };
|
|
3659
|
+
}
|
|
3660
|
+
const item = parseToSpecItem((raw as Record<string, unknown>)["task"]);
|
|
3661
|
+
if (item === undefined) {
|
|
3662
|
+
return {
|
|
3663
|
+
block: true,
|
|
3664
|
+
reason:
|
|
3665
|
+
"task refused: every item's `task` must start with the two contract lines " +
|
|
3666
|
+
"`to-spec candidate: <owner/repo>#<issue> — <title>` and `to-spec source: <owner/repo>@<ref>`.",
|
|
3667
|
+
};
|
|
3668
|
+
}
|
|
3669
|
+
const name = (raw as Record<string, unknown>)["name"];
|
|
3670
|
+
if (typeof name === "string" && name.length > 0) {
|
|
3671
|
+
if (names.has(name)) {
|
|
3672
|
+
return { block: true, reason: `task refused: duplicate item name \`${name}\`.` };
|
|
3673
|
+
}
|
|
3674
|
+
names.add(name);
|
|
3675
|
+
}
|
|
3676
|
+
items.push(item);
|
|
3677
|
+
}
|
|
3678
|
+
for (let i = 1; i < items.length; i += 1) {
|
|
3679
|
+
for (let j = 0; j < i; j += 1) {
|
|
3680
|
+
if (items[j]!.issue === items[i]!.issue) {
|
|
3681
|
+
return {
|
|
3682
|
+
block: true,
|
|
3683
|
+
reason: `task refused: #${items[i]!.issue} appears twice in one batch.`,
|
|
3684
|
+
};
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
// The allowlist is the mechanical selection this tick's token authorized:
|
|
3689
|
+
// every item's issue number AND routing must match an entry the launch
|
|
3690
|
+
// offer produced from the authoritative tracker snapshot. Parked,
|
|
3691
|
+
// parent/epic, already-groomed, in-flight, held and dispatched candidates
|
|
3692
|
+
// were never on it, so no item carrying one can be stamped in-flight here
|
|
3693
|
+
// — the model cannot self-select past the conductor's selection (#805).
|
|
3694
|
+
const allowlist = session.launchItems;
|
|
3695
|
+
if (allowlist === undefined) {
|
|
3696
|
+
return {
|
|
3697
|
+
block: true,
|
|
3698
|
+
reason:
|
|
3699
|
+
"task refused: this tick's launch did not carry a mechanically selected batch — " +
|
|
3700
|
+
"re-tick before launching.",
|
|
3701
|
+
};
|
|
3702
|
+
}
|
|
3703
|
+
for (const item of items) {
|
|
3704
|
+
if (!allowlist.some((allowed) => allowed.issue === item.issue && allowed.routing === item.routing)) {
|
|
3705
|
+
const listed = allowlist.map((allowed) => `#${allowed.issue} (${allowed.routing})`).join(", ");
|
|
3706
|
+
return {
|
|
3707
|
+
block: true,
|
|
3708
|
+
reason:
|
|
3709
|
+
`task refused: #${item.issue} is not on this tick's mechanically selected to-spec batch ` +
|
|
3710
|
+
`(${listed}). Launch exactly the selected candidates, no substitutes.`,
|
|
3711
|
+
};
|
|
3712
|
+
}
|
|
3713
|
+
}
|
|
3714
|
+
const projectName = session.launchProject;
|
|
3715
|
+
if (projectName === undefined) {
|
|
3716
|
+
return {
|
|
3717
|
+
block: true,
|
|
3718
|
+
reason: "task refused: the batch was authorized without a project — re-tick before launching.",
|
|
3719
|
+
};
|
|
3720
|
+
}
|
|
3721
|
+
// Store-side eligibility, the same rule the prompt block's exclusion list
|
|
3722
|
+
// came from. Eligible items then become durable in-flight rows, so a
|
|
3723
|
+
// crashed batch still suppresses re-launch until the TTL expires.
|
|
3724
|
+
let store: Store | undefined;
|
|
3725
|
+
try {
|
|
3726
|
+
store = openStore(dbPath());
|
|
3727
|
+
const byIssue = new Map(
|
|
3728
|
+
store.groomingVerdicts(projectName).map((row) => [row.issue, row] as const),
|
|
3729
|
+
);
|
|
3730
|
+
const active = new Set(store.activeRuns(projectName).map((run) => run.issue));
|
|
3731
|
+
const now = Date.now();
|
|
3732
|
+
for (const item of items) {
|
|
3733
|
+
const reason = toSpecCandidateExclusion(
|
|
3734
|
+
{ issue: item.issue },
|
|
3735
|
+
{ grooming: byIssue.get(item.issue), active: active.has(item.issue) },
|
|
3736
|
+
now,
|
|
3737
|
+
);
|
|
3738
|
+
if (reason !== undefined) {
|
|
3739
|
+
return {
|
|
3740
|
+
block: true,
|
|
3741
|
+
reason: `task refused: ${reason}. Drop that item (and every other listed exclusion) from this batch and re-call.`,
|
|
3742
|
+
};
|
|
3743
|
+
}
|
|
3744
|
+
}
|
|
3745
|
+
const launchedEvidence = JSON.stringify({
|
|
3746
|
+
kind: "to-spec-in-flight",
|
|
3747
|
+
launchedAt: now,
|
|
3748
|
+
batch: token,
|
|
3749
|
+
agent: TO_SPEC_AGENT,
|
|
3750
|
+
});
|
|
3751
|
+
for (const item of items) {
|
|
3752
|
+
store.upsertGrooming({
|
|
3753
|
+
project: projectName,
|
|
3754
|
+
issue: item.issue,
|
|
3755
|
+
verdict: "blocked",
|
|
3756
|
+
reason: TO_SPEC_IN_FLIGHT_REASON,
|
|
3757
|
+
evidence: launchedEvidence,
|
|
3758
|
+
at: now,
|
|
3759
|
+
});
|
|
3760
|
+
}
|
|
3761
|
+
} catch (err) {
|
|
3762
|
+
pi.logger.error(
|
|
3763
|
+
`[omp-conductor] to-spec launch not recorded: ${err instanceof Error ? err.message : String(err)}`,
|
|
3764
|
+
);
|
|
3765
|
+
return {
|
|
3766
|
+
block: true,
|
|
3767
|
+
reason: `task refused: the launch could not be recorded durably (${
|
|
3768
|
+
err instanceof Error ? err.message : String(err)
|
|
3769
|
+
}); nothing was started.`,
|
|
3770
|
+
};
|
|
3771
|
+
} finally {
|
|
3772
|
+
store?.close();
|
|
3773
|
+
}
|
|
3774
|
+
const stampedTasks = tasks.map((raw) => ({
|
|
3775
|
+
...(raw as Record<string, unknown>),
|
|
3776
|
+
agent: TO_SPEC_AGENT,
|
|
3777
|
+
outputSchema: TO_SPEC_SCHEMA,
|
|
3778
|
+
schemaMode: "strict",
|
|
3779
|
+
}));
|
|
3780
|
+
session.launchedBatch = { toolCallId: event.toolCallId, items };
|
|
3781
|
+
pi.logger.info(
|
|
3782
|
+
`[omp-conductor] to-spec batch launched: ${items.map((item) => `#${item.issue}`).join(", ")} (${token})`,
|
|
3783
|
+
);
|
|
3784
|
+
return { input: { ...input, tasks: stampedTasks } };
|
|
3785
|
+
});
|
|
3786
|
+
|
|
3787
|
+
api.on("tool_result", (event) => {
|
|
3788
|
+
if (event.toolName !== "task") return;
|
|
3789
|
+
const batch = session.launchedBatch;
|
|
3790
|
+
if (batch === undefined || event.toolCallId !== batch.toolCallId) return;
|
|
3791
|
+
const projectName = session.launchProject;
|
|
3792
|
+
if (projectName === undefined) return;
|
|
3793
|
+
const details = event.details as { results?: unknown } | undefined;
|
|
3794
|
+
const results = details?.results;
|
|
3795
|
+
if (!Array.isArray(results) || results.length === 0) {
|
|
3796
|
+
// Background launch: the settled items arrive later through
|
|
3797
|
+
// TO_SPEC_RESULT_TOOL; the in-flight rows keep them out of any new batch
|
|
3798
|
+
// until then.
|
|
3799
|
+
return;
|
|
3800
|
+
}
|
|
3801
|
+
let store: Store | undefined;
|
|
3802
|
+
try {
|
|
3803
|
+
store = openStore(dbPath());
|
|
3804
|
+
for (const entry of results) {
|
|
3805
|
+
const settled = entry as ToSpecSettledItem;
|
|
3806
|
+
const item = batch.items[typeof settled.index === "number" ? settled.index : -1];
|
|
3807
|
+
if (item === undefined) continue;
|
|
3808
|
+
// The raw output is the contract input — exactly as returned, even
|
|
3809
|
+
// when the item failed: the strict parser turns anything unparseable
|
|
3810
|
+
// into a blocked row, and a failed sibling never touches the others.
|
|
3811
|
+
const raw = typeof settled.output === "string" ? settled.output : "";
|
|
3812
|
+
const structured = settled.structuredOutput;
|
|
3813
|
+
const fallback =
|
|
3814
|
+
structured !== null &&
|
|
3815
|
+
typeof structured === "object" &&
|
|
3816
|
+
typeof (structured as { data?: unknown }).data === "object"
|
|
3817
|
+
? JSON.stringify((structured as { data?: unknown }).data)
|
|
3818
|
+
: "";
|
|
3819
|
+
try {
|
|
3820
|
+
recordToSpecGrooming(store, {
|
|
3821
|
+
project: projectName,
|
|
3822
|
+
issue: item.issue,
|
|
3823
|
+
input: raw.trim().length > 0 ? raw : fallback,
|
|
3824
|
+
});
|
|
3825
|
+
} catch (err) {
|
|
3826
|
+
pi.logger.error(
|
|
3827
|
+
`[omp-conductor] to-spec result not persisted for #${item.issue}: ${
|
|
3828
|
+
err instanceof Error ? err.message : String(err)
|
|
3829
|
+
}`,
|
|
3830
|
+
);
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
} catch (err) {
|
|
3834
|
+
pi.logger.error(
|
|
3835
|
+
`[omp-conductor] to-spec results not captured: ${err instanceof Error ? err.message : String(err)}`,
|
|
3836
|
+
);
|
|
3837
|
+
} finally {
|
|
3838
|
+
store?.close();
|
|
3839
|
+
}
|
|
3840
|
+
});
|
|
3841
|
+
}
|
|
3842
|
+
|
|
2887
3843
|
/**
|
|
2888
3844
|
* Factory-time seams for the extension, used by tests. Production runs
|
|
2889
3845
|
* `orchestratorTickExtension(pi)` with no options and gets the module's own
|
|
@@ -2892,7 +3848,21 @@ function armTickGuard(pi: TickApi, ctx: TickContext, budgetMs: number): void {
|
|
|
2892
3848
|
* through the real tool deterministically, without real timers (#683).
|
|
2893
3849
|
*/
|
|
2894
3850
|
export interface OrchestratorTickExtensionOptions {
|
|
2895
|
-
ask?: {
|
|
3851
|
+
ask?: {
|
|
3852
|
+
wait?: (ms: number) => Promise<void>;
|
|
3853
|
+
now?: () => number;
|
|
3854
|
+
/** Injected interactive delivery surface (#722); production builds its own. */
|
|
3855
|
+
interactive?: AskInteractiveDelivery;
|
|
3856
|
+
};
|
|
3857
|
+
/**
|
|
3858
|
+
* The authoritative tracker surface the low-queue to-spec launch reads
|
|
3859
|
+
* (#777). Production omits it and the extension builds the real seam over
|
|
3860
|
+
* the existing Tracker adapter for each project; tests inject deterministic
|
|
3861
|
+
* fakes so the mechanical selection is provable without a network. A
|
|
3862
|
+
* low-queue launch without a readable snapshot is refused entirely — the
|
|
3863
|
+
* launch fails closed rather than trusting the model to self-filter.
|
|
3864
|
+
*/
|
|
3865
|
+
toSpec?: { tracker?: ToSpecTrackerSeam };
|
|
2896
3866
|
}
|
|
2897
3867
|
|
|
2898
3868
|
export default function orchestratorTickExtension(
|
|
@@ -2919,6 +3889,22 @@ export default function orchestratorTickExtension(
|
|
|
2919
3889
|
let releaseGateArmed = false;
|
|
2920
3890
|
let availabilityGateArmed = false;
|
|
2921
3891
|
let guardArmed = false;
|
|
3892
|
+
// #777: the to-spec launch gate is factory-time like the tick guard. It acts
|
|
3893
|
+
// only when a low-queue tick has minted a launch token, so on any other
|
|
3894
|
+
// session — or any healthy queue — it observes `task` calls without touching
|
|
3895
|
+
// them.
|
|
3896
|
+
armToSpecGate(pi, session);
|
|
3897
|
+
// The authoritative tracker seam for the launch selection. Production runs
|
|
3898
|
+
// without options and builds the real seam lazily over the existing Tracker
|
|
3899
|
+
// adapter (one tracker per project read, per low-queue tick); tests inject a
|
|
3900
|
+
// deterministic fake. The seam is the ONLY tracker surface the launch path
|
|
3901
|
+
// touches — the tick never mutates tracker state and never lets the model
|
|
3902
|
+
// self-select candidates.
|
|
3903
|
+
const toSpecTrackerSeam: ToSpecTrackerSeam | undefined =
|
|
3904
|
+
options.toSpec?.tracker ?? {
|
|
3905
|
+
listOpenIssues: (project) => makeTracker(project).listOpenIssues(),
|
|
3906
|
+
childrenOf: (project, issue) => makeTracker(project).childrenOf(issue),
|
|
3907
|
+
};
|
|
2922
3908
|
// The bounded ask tool's session state. The tool itself is registered at
|
|
2923
3909
|
// extension-factory time, before any session exists, because OMP snapshots
|
|
2924
3910
|
// the extension's active tool set before it emits `session_start` — a tool
|
|
@@ -3136,7 +4122,12 @@ export default function orchestratorTickExtension(
|
|
|
3136
4122
|
`delivers it to the operator per the reporting policy, and waits at most the ceiling ` +
|
|
3137
4123
|
`(default ${DEFAULT_ASK_TIMEOUT_SECONDS}s, capped at the turn budget; pass "timeoutSeconds" to ` +
|
|
3138
4124
|
`shorten or extend within ${MIN_ASK_TIMEOUT_SECONDS}–${MAX_ASK_TIMEOUT_SECONDS}s — an ask issued without ` +
|
|
3139
|
-
`one still gets the default). When
|
|
4125
|
+
`one still gets the default). When the Telegram surface can, the question posts as selectable ` +
|
|
4126
|
+
`buttons and a tap resolves the decision row with the chosen option; when it cannot, the ask ` +
|
|
4127
|
+
`degrades to plain text and the row records the degraded delivery. "recommended" is required ` +
|
|
4128
|
+
`whenever "on-timeout" is "auto-proceed" (the row must record what was auto-applied) and, when ` +
|
|
4129
|
+
`"options" are supplied, must be one of their labels — the label as delivered, never an index. ` +
|
|
4130
|
+
`When nobody answers, the declared "on-timeout" decides: ` +
|
|
3140
4131
|
`"auto-proceed" applies your recommended option and resolves the decision row naming the ` +
|
|
3141
4132
|
`auto-application ("<option> (auto-applied on ask timeout)"); "park" leaves the row open and ` +
|
|
3142
4133
|
`pending — re-surfaced in every tick prompt until answered or the seven-day expiry — and you then ` +
|
|
@@ -3196,8 +4187,11 @@ export default function orchestratorTickExtension(
|
|
|
3196
4187
|
turnBudgetSeconds: config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS,
|
|
3197
4188
|
// Test seam (#683): production omits `wait`/`now` and `performAsk`
|
|
3198
4189
|
// falls back to the module's real-time defaults; a test that wants
|
|
3199
|
-
// the timeout outcomes deterministically hands both in.
|
|
3200
|
-
|
|
4190
|
+
// the timeout outcomes deterministically hands both in. The
|
|
4191
|
+
// interactive surface (#722) is production's default; a test may
|
|
4192
|
+
// inject a fake through the same seam.
|
|
4193
|
+
interactive: options.ask?.interactive ?? interactiveAskSurface({ project: projectConfig, store }),
|
|
4194
|
+
...(options.ask === undefined ? {} : { wait: options.ask.wait, now: options.ask.now }),
|
|
3201
4195
|
deliver: async (text, category) => {
|
|
3202
4196
|
const at = Date.now();
|
|
3203
4197
|
const noticeId = randomUUID();
|
|
@@ -3237,6 +4231,114 @@ export default function orchestratorTickExtension(
|
|
|
3237
4231
|
},
|
|
3238
4232
|
});
|
|
3239
4233
|
|
|
4234
|
+
// The async half of the to-spec result capture (#777). Registered at
|
|
4235
|
+
// extension-factory time like {@link ASK_TOOL}, with the same routing
|
|
4236
|
+
// contract: the state it needs (cwd + startup config) is filled by
|
|
4237
|
+
// `session_start` for an accepted fleet session, and the live config is
|
|
4238
|
+
// re-read at execution so a restamp binds the current project. The tool
|
|
4239
|
+
// only ever writes the grooming table — never an issue, a label, or a
|
|
4240
|
+
// dispatch row — so the output of a background batch persists while no
|
|
4241
|
+
// candidate can become claimable from it.
|
|
4242
|
+
pi.registerTool({
|
|
4243
|
+
name: TO_SPEC_RESULT_TOOL,
|
|
4244
|
+
label: TO_SPEC_RESULT_TOOL,
|
|
4245
|
+
description:
|
|
4246
|
+
`Persist the exact raw output of one completed to-spec grooming item (#777). ` +
|
|
4247
|
+
`Call it once per completed batch item after the batch settles — in the tool ` +
|
|
4248
|
+
`result, or when an async-result message delivers the item — passing the issue ` +
|
|
4249
|
+
`number and the agent's EXACT raw output as \`input\` (read agent://<id> when the ` +
|
|
4250
|
+
`inline text is truncated; never paraphrase). The conductor parses the output ` +
|
|
4251
|
+
`against the strict to-spec contract and records the verdict durably: a valid ` +
|
|
4252
|
+
`result persists its verdict, anything malformed, source-less or stale persists ` +
|
|
4253
|
+
`as blocked, and a failing item never discards its siblings. One call per item, ` +
|
|
4254
|
+
`success and failure alike; never edits an issue or a label.`,
|
|
4255
|
+
parameters: {
|
|
4256
|
+
type: "object",
|
|
4257
|
+
properties: {
|
|
4258
|
+
issue: { type: "integer", description: "The tracker issue number the item groomed." },
|
|
4259
|
+
input: {
|
|
4260
|
+
type: "string",
|
|
4261
|
+
description: "The to-spec agent's exact raw output for this item.",
|
|
4262
|
+
},
|
|
4263
|
+
},
|
|
4264
|
+
required: ["issue", "input"],
|
|
4265
|
+
additionalProperties: false,
|
|
4266
|
+
},
|
|
4267
|
+
approval: "write",
|
|
4268
|
+
execute: async (_toolCallId, params) => {
|
|
4269
|
+
const fleet = askSession;
|
|
4270
|
+
if (fleet === undefined) {
|
|
4271
|
+
return {
|
|
4272
|
+
content: [
|
|
4273
|
+
{
|
|
4274
|
+
type: "text",
|
|
4275
|
+
text: `${TO_SPEC_RESULT_TOOL}: not available in this session (no orchestrator tick); nothing was persisted.`,
|
|
4276
|
+
},
|
|
4277
|
+
],
|
|
4278
|
+
isError: true,
|
|
4279
|
+
};
|
|
4280
|
+
}
|
|
4281
|
+
const issue = params["issue"];
|
|
4282
|
+
const input = params["input"];
|
|
4283
|
+
if (typeof issue !== "number" || !Number.isInteger(issue) || typeof input !== "string" || input.length === 0) {
|
|
4284
|
+
return {
|
|
4285
|
+
content: [
|
|
4286
|
+
{ type: "text", text: `${TO_SPEC_RESULT_TOOL}: expected an integer \`issue\` and a non-empty \`input\` string.` },
|
|
4287
|
+
],
|
|
4288
|
+
isError: true,
|
|
4289
|
+
};
|
|
4290
|
+
}
|
|
4291
|
+
const routed = resolveAskProject(fleet.cwd, fleet.config);
|
|
4292
|
+
if (routed.kind === "error") {
|
|
4293
|
+
return {
|
|
4294
|
+
content: [
|
|
4295
|
+
{
|
|
4296
|
+
type: "text",
|
|
4297
|
+
text:
|
|
4298
|
+
`${TO_SPEC_RESULT_TOOL}: conductor config unreadable (${routed.problem}); nothing was persisted. ` +
|
|
4299
|
+
"Keep the outputs in the transcript and persist after the config is repaired.",
|
|
4300
|
+
},
|
|
4301
|
+
],
|
|
4302
|
+
isError: true,
|
|
4303
|
+
};
|
|
4304
|
+
}
|
|
4305
|
+
const store = openStore(dbPath());
|
|
4306
|
+
try {
|
|
4307
|
+
const outcome = recordToSpecGrooming(store, {
|
|
4308
|
+
project: routed.project.name,
|
|
4309
|
+
issue,
|
|
4310
|
+
input,
|
|
4311
|
+
});
|
|
4312
|
+
const record = outcome.record;
|
|
4313
|
+
const kept = outcome.kind === "kept-prior" ? " (kept the prior valid verdict)" : "";
|
|
4314
|
+
return {
|
|
4315
|
+
content: [
|
|
4316
|
+
{
|
|
4317
|
+
type: "text",
|
|
4318
|
+
text:
|
|
4319
|
+
`${TO_SPEC_RESULT_TOOL}: #${issue} persisted as ${record.verdict} (${record.reason})${kept}. ` +
|
|
4320
|
+
"The grooming table now decides re-grooming; promotion stays yours.",
|
|
4321
|
+
},
|
|
4322
|
+
],
|
|
4323
|
+
};
|
|
4324
|
+
} catch (err) {
|
|
4325
|
+
return {
|
|
4326
|
+
content: [
|
|
4327
|
+
{
|
|
4328
|
+
type: "text",
|
|
4329
|
+
text: `${TO_SPEC_RESULT_TOOL}: could not persist #${issue}: ${
|
|
4330
|
+
err instanceof Error ? err.message : String(err)
|
|
4331
|
+
} — keep the output and retry the call.`,
|
|
4332
|
+
},
|
|
4333
|
+
],
|
|
4334
|
+
isError: true,
|
|
4335
|
+
};
|
|
4336
|
+
} finally {
|
|
4337
|
+
store.close();
|
|
4338
|
+
}
|
|
4339
|
+
},
|
|
4340
|
+
});
|
|
4341
|
+
|
|
3240
4342
|
pi.on("session_start", (_event, ctx) => {
|
|
3241
4343
|
if (decided) return;
|
|
3242
4344
|
|
|
@@ -3358,7 +4460,7 @@ export default function orchestratorTickExtension(
|
|
|
3358
4460
|
// not create or deliver decisions). `cwd` is for re-reading the live
|
|
3359
4461
|
// tick config at execution, `config` for the startup-only ceiling.
|
|
3360
4462
|
askSession = { cwd: ctx.cwd, config };
|
|
3361
|
-
armTickHeartbeat(pi, ctx, config, session);
|
|
4463
|
+
armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam);
|
|
3362
4464
|
if (!guardArmed) {
|
|
3363
4465
|
guardArmed = true;
|
|
3364
4466
|
armTickGuard(pi, ctx, (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1000);
|
|
@@ -3380,7 +4482,7 @@ export default function orchestratorTickExtension(
|
|
|
3380
4482
|
// why): a declined or unresolved session keeps `askSession` undefined and
|
|
3381
4483
|
// the tool fails closed.
|
|
3382
4484
|
askSession = { cwd: ctx.cwd, config };
|
|
3383
|
-
armTickHeartbeat(pi, ctx, config, session);
|
|
4485
|
+
armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam);
|
|
3384
4486
|
if (!guardArmed) {
|
|
3385
4487
|
guardArmed = true;
|
|
3386
4488
|
armTickGuard(pi, ctx, (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1000);
|
|
@@ -3398,3 +4500,272 @@ export default function orchestratorTickExtension(
|
|
|
3398
4500
|
);
|
|
3399
4501
|
});
|
|
3400
4502
|
}
|
|
4503
|
+
|
|
4504
|
+
/** One error's message, for a reason string — never a stack. */
|
|
4505
|
+
function errText(err: unknown): string {
|
|
4506
|
+
return err instanceof Error ? err.message : String(err);
|
|
4507
|
+
}
|
|
4508
|
+
|
|
4509
|
+
/**
|
|
4510
|
+
* Write one prompt-protocol file the way omp-telegram's `atomicJson` does:
|
|
4511
|
+
* temp file in the same directory, then rename. The bridge reads these files
|
|
4512
|
+
* on a hot path (every tap), so a half-written request must never be visible.
|
|
4513
|
+
*/
|
|
4514
|
+
function atomicallyWriteJson(path: string, value: unknown): void {
|
|
4515
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
4516
|
+
const tmp = `${path}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
|
|
4517
|
+
writeFileSync(tmp, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
|
4518
|
+
renameSync(tmp, path);
|
|
4519
|
+
}
|
|
4520
|
+
|
|
4521
|
+
/** Remove one prompt file; a missing file is not an error. */
|
|
4522
|
+
function removeFile(path: string): void {
|
|
4523
|
+
try {
|
|
4524
|
+
rmSync(path, { force: true });
|
|
4525
|
+
} catch {
|
|
4526
|
+
// best effort — a leftover prompt request dies with its owner process
|
|
4527
|
+
}
|
|
4528
|
+
}
|
|
4529
|
+
|
|
4530
|
+
/**
|
|
4531
|
+
* The interactive Telegram surface for one bounded ask (#722).
|
|
4532
|
+
*
|
|
4533
|
+
* `telegram_ask` posts its options as a Bot API inline keyboard whose taps the
|
|
4534
|
+
* running omp-telegram bridge acknowledges and answers through a documented
|
|
4535
|
+
* cross-process file protocol (guide.md: "prompts/ — Cross-process
|
|
4536
|
+
* selectable-question requests (live while their owning session is) and their
|
|
4537
|
+
* answers"): the asking process writes `<state>/prompts/<nonce>.json`, the
|
|
4538
|
+
* bridge validates the tap against that request (responder, chat, topic,
|
|
4539
|
+
* message id, owner-pid liveness) and writes `<nonce>.answer.json`; the asking
|
|
4540
|
+
* process reads the envelope and settles. `interactiveAskSurface` is that
|
|
4541
|
+
* asking-process half, nothing more: it posts the question with the same
|
|
4542
|
+
* `qa:<nonce>:s:<index>` callbacks `prompts.ts` routes, writes the request file
|
|
4543
|
+
* so the bridge recognizes the taps, and translates the envelope the bridge
|
|
4544
|
+
* writes into the decision row — the resolution is the chosen option's *label*,
|
|
4545
|
+
* never an index and never free text.
|
|
4546
|
+
*
|
|
4547
|
+
* The decision id doubles as the protocol nonce: it fits `[A-Za-z0-9_-]`, the
|
|
4548
|
+
* bridge's callback regex, and makes the pending question and its answer
|
|
4549
|
+
* addressable by the row that records them.
|
|
4550
|
+
*/
|
|
4551
|
+
export function interactiveAskSurface(deps: {
|
|
4552
|
+
project: ProjectConfig;
|
|
4553
|
+
store: Store;
|
|
4554
|
+
/** Injected Bot API transport (tests); production posts to api.telegram.org. */
|
|
4555
|
+
call?: (method: string, payload: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
4556
|
+
/** Injected state dir (tests); production resolves it like the token. */
|
|
4557
|
+
stateDir?: string;
|
|
4558
|
+
/** Injected bot token (tests); production reads the state dir's .env. */
|
|
4559
|
+
token?: string;
|
|
4560
|
+
now?: () => number;
|
|
4561
|
+
}): AskInteractiveDelivery {
|
|
4562
|
+
const surfaceStateDir = deps.stateDir ?? telegramStateDir();
|
|
4563
|
+
const call = deps.call ?? telegramCall(deps.token ?? readTelegramToken() ?? "");
|
|
4564
|
+
const now = deps.now ?? Date.now;
|
|
4565
|
+
// Where each pending question physically sits, so collect/close can settle
|
|
4566
|
+
// the right message without re-reading the request file.
|
|
4567
|
+
const posted = new Map<string, { chatId: string; messageId: number; settled: boolean }>();
|
|
4568
|
+
|
|
4569
|
+
const promptsDir = (): string => join(surfaceStateDir, "prompts");
|
|
4570
|
+
const requestPath = (nonce: string): string => join(promptsDir(), `${nonce}.json`);
|
|
4571
|
+
const answerPath = (nonce: string): string => join(promptsDir(), `${nonce}.answer.json`);
|
|
4572
|
+
|
|
4573
|
+
const ownerId = (): string | undefined => {
|
|
4574
|
+
let raw: string;
|
|
4575
|
+
try {
|
|
4576
|
+
raw = readFileSync(join(surfaceStateDir, "access.json"), "utf8");
|
|
4577
|
+
} catch {
|
|
4578
|
+
return undefined;
|
|
4579
|
+
}
|
|
4580
|
+
let access: { allowFrom?: unknown };
|
|
4581
|
+
try {
|
|
4582
|
+
access = JSON.parse(raw) as { allowFrom?: unknown };
|
|
4583
|
+
} catch {
|
|
4584
|
+
return undefined;
|
|
4585
|
+
}
|
|
4586
|
+
const allowFrom = access.allowFrom;
|
|
4587
|
+
if (!Array.isArray(allowFrom) || allowFrom.length !== 1 || typeof allowFrom[0] !== "string") {
|
|
4588
|
+
return undefined;
|
|
4589
|
+
}
|
|
4590
|
+
return allowFrom[0];
|
|
4591
|
+
};
|
|
4592
|
+
|
|
4593
|
+
const tokenAvailable = (): boolean =>
|
|
4594
|
+
deps.call !== undefined || deps.token !== undefined || readTelegramToken() !== undefined;
|
|
4595
|
+
|
|
4596
|
+
return {
|
|
4597
|
+
unavailableReason(request) {
|
|
4598
|
+
const chat = deps.project.escalation.telegramChatId;
|
|
4599
|
+
if (chat === undefined || chat === "") {
|
|
4600
|
+
return "no escalation.telegramChatId configured for this project";
|
|
4601
|
+
}
|
|
4602
|
+
if (!tokenAvailable()) {
|
|
4603
|
+
return "no Telegram bot token readable (install and configure omp-telegram, or set OMP_TELEGRAM_STATE_DIR)";
|
|
4604
|
+
}
|
|
4605
|
+
if (ownerId() === undefined) {
|
|
4606
|
+
return "no paired Telegram owner (omp-telegram access.json must name exactly one allowed user)";
|
|
4607
|
+
}
|
|
4608
|
+
// The interactive post is still a delivery under the reporting policy: a
|
|
4609
|
+
// question the policy would hold for the digest or the availability
|
|
4610
|
+
// window must not bypass that hold just because it has buttons.
|
|
4611
|
+
const disposition = interruptDisposition(
|
|
4612
|
+
deps.project.reporting,
|
|
4613
|
+
request.category ?? "decision-needed",
|
|
4614
|
+
now(),
|
|
4615
|
+
);
|
|
4616
|
+
if (disposition !== "interrupt") {
|
|
4617
|
+
return `the question defers under the reporting policy (${disposition}) — it must be held, not posted`;
|
|
4618
|
+
}
|
|
4619
|
+
return undefined;
|
|
4620
|
+
},
|
|
4621
|
+
|
|
4622
|
+
async post(request, decisionId) {
|
|
4623
|
+
const chat = deps.project.escalation.telegramChatId;
|
|
4624
|
+
const owner = ownerId();
|
|
4625
|
+
if (chat === undefined || owner === undefined) {
|
|
4626
|
+
return {
|
|
4627
|
+
ok: false,
|
|
4628
|
+
reason: "the interactive surface is not configured (no escalation.telegramChatId or no paired owner)",
|
|
4629
|
+
};
|
|
4630
|
+
}
|
|
4631
|
+
const render = renderInteractiveAsk(request, decisionId);
|
|
4632
|
+
const threadId = resolveProjectTopicId(deps.project);
|
|
4633
|
+
let result: Record<string, unknown>;
|
|
4634
|
+
try {
|
|
4635
|
+
result = await call("sendMessage", {
|
|
4636
|
+
chat_id: chat,
|
|
4637
|
+
...(threadId === undefined ? {} : { message_thread_id: threadId }),
|
|
4638
|
+
text: render.text,
|
|
4639
|
+
reply_markup: render.markup,
|
|
4640
|
+
});
|
|
4641
|
+
} catch (err) {
|
|
4642
|
+
return { ok: false, reason: `Telegram rejected the interactive question: ${errText(err)}` };
|
|
4643
|
+
}
|
|
4644
|
+
const messageId = result["message_id"];
|
|
4645
|
+
const chatType =
|
|
4646
|
+
typeof result["chat"] === "object" && result["chat"] !== null
|
|
4647
|
+
? String((result["chat"] as Record<string, unknown>)["type"] ?? "private")
|
|
4648
|
+
: "private";
|
|
4649
|
+
if (typeof messageId !== "number" || !Number.isSafeInteger(messageId)) {
|
|
4650
|
+
// The question went out, but unaddressable — take it back rather than
|
|
4651
|
+
// leaving a button row nothing can settle.
|
|
4652
|
+
await call("deleteMessage", { chat_id: chat, message_id: messageId as number }).catch(
|
|
4653
|
+
() => undefined,
|
|
4654
|
+
);
|
|
4655
|
+
return { ok: false, reason: "Telegram posted no usable message id" };
|
|
4656
|
+
}
|
|
4657
|
+
const recommended =
|
|
4658
|
+
request.recommended === undefined
|
|
4659
|
+
? undefined
|
|
4660
|
+
: (request.options ?? []).findIndex((option) => option.label === request.recommended);
|
|
4661
|
+
const requestFile = {
|
|
4662
|
+
version: 1,
|
|
4663
|
+
nonce: decisionId,
|
|
4664
|
+
responderId: owner,
|
|
4665
|
+
chatId: chat,
|
|
4666
|
+
chatType,
|
|
4667
|
+
threadId,
|
|
4668
|
+
page: 0,
|
|
4669
|
+
messageId,
|
|
4670
|
+
questions: [
|
|
4671
|
+
{
|
|
4672
|
+
id: "q1",
|
|
4673
|
+
question: request.question,
|
|
4674
|
+
options: request.options ?? [],
|
|
4675
|
+
...(recommended === undefined || recommended < 0 ? {} : { recommended }),
|
|
4676
|
+
},
|
|
4677
|
+
],
|
|
4678
|
+
questionIndex: 0,
|
|
4679
|
+
answers: [],
|
|
4680
|
+
selectedIndices: [],
|
|
4681
|
+
awaitingText: (request.options ?? []).length === 0,
|
|
4682
|
+
ownerPid: process.pid,
|
|
4683
|
+
};
|
|
4684
|
+
try {
|
|
4685
|
+
atomicallyWriteJson(requestPath(decisionId), requestFile as unknown);
|
|
4686
|
+
} catch (err) {
|
|
4687
|
+
// The buttons went out but nothing would ever answer them — take the
|
|
4688
|
+
// message back rather than leave a dead keyboard in the chat.
|
|
4689
|
+
await call("deleteMessage", { chat_id: chat, message_id: messageId }).catch(() => undefined);
|
|
4690
|
+
return { ok: false, reason: `could not register the interactive question: ${errText(err)}` };
|
|
4691
|
+
}
|
|
4692
|
+
posted.set(decisionId, { chatId: chat, messageId, settled: false });
|
|
4693
|
+
return { ok: true };
|
|
4694
|
+
},
|
|
4695
|
+
|
|
4696
|
+
collect(decisionId) {
|
|
4697
|
+
const state = posted.get(decisionId);
|
|
4698
|
+
if (state === undefined) return;
|
|
4699
|
+
let raw: string | undefined;
|
|
4700
|
+
try {
|
|
4701
|
+
raw = readFileSync(answerPath(decisionId), "utf8");
|
|
4702
|
+
} catch {
|
|
4703
|
+
return; // no answer yet
|
|
4704
|
+
}
|
|
4705
|
+
let parsed: AskAnswerEnvelope | undefined;
|
|
4706
|
+
try {
|
|
4707
|
+
parsed = parseAskAnswerEnvelope(JSON.parse(raw) as unknown);
|
|
4708
|
+
} catch {
|
|
4709
|
+
parsed = undefined;
|
|
4710
|
+
}
|
|
4711
|
+
if (parsed === undefined) return; // an unreadable envelope is "no answer yet"
|
|
4712
|
+
const write = askAnswerRowWrite(parsed);
|
|
4713
|
+
if (write === undefined) return; // expiry/abort: the bounded wait still owns the row
|
|
4714
|
+
deps.store.resolveDecision(decisionId, write.state, write.resolution, now());
|
|
4715
|
+
removeFile(requestPath(decisionId));
|
|
4716
|
+
removeFile(answerPath(decisionId));
|
|
4717
|
+
state.settled = true;
|
|
4718
|
+
const outcomeText =
|
|
4719
|
+
write.state === "withdrawn"
|
|
4720
|
+
? write.resolution
|
|
4721
|
+
: `User selected: ${write.resolution}`;
|
|
4722
|
+
// The bridge edits its own prompts' messages; this surface owns the edit
|
|
4723
|
+
// for its own, so an answered ask reads as answered and a stale tap
|
|
4724
|
+
// finds no keyboard.
|
|
4725
|
+
call("editMessageText", {
|
|
4726
|
+
chat_id: state.chatId,
|
|
4727
|
+
message_id: state.messageId,
|
|
4728
|
+
text: outcomeText,
|
|
4729
|
+
reply_markup: { inline_keyboard: [] },
|
|
4730
|
+
}).catch(() => undefined);
|
|
4731
|
+
},
|
|
4732
|
+
|
|
4733
|
+
close(decisionId) {
|
|
4734
|
+
const state = posted.get(decisionId);
|
|
4735
|
+
removeFile(requestPath(decisionId));
|
|
4736
|
+
removeFile(answerPath(decisionId));
|
|
4737
|
+
if (state !== undefined && !state.settled) {
|
|
4738
|
+
call("editMessageReplyMarkup", {
|
|
4739
|
+
chat_id: state.chatId,
|
|
4740
|
+
message_id: state.messageId,
|
|
4741
|
+
reply_markup: { inline_keyboard: [] },
|
|
4742
|
+
}).catch(() => undefined);
|
|
4743
|
+
state.settled = true;
|
|
4744
|
+
}
|
|
4745
|
+
},
|
|
4746
|
+
};
|
|
4747
|
+
}
|
|
4748
|
+
|
|
4749
|
+
/** The Bot API transport: one JSON POST per call, the `result` back. */
|
|
4750
|
+
function telegramCall(
|
|
4751
|
+
token: string,
|
|
4752
|
+
): (method: string, payload: Record<string, unknown>) => Promise<Record<string, unknown>> {
|
|
4753
|
+
return async (method, payload) => {
|
|
4754
|
+
if (token === "") throw new Error("no Telegram bot token");
|
|
4755
|
+
const url = `https://api.telegram.org/bot${token}/${method}`;
|
|
4756
|
+
const res = await fetch(url, {
|
|
4757
|
+
method: "POST",
|
|
4758
|
+
headers: { "content-type": "application/json" },
|
|
4759
|
+
body: JSON.stringify(payload),
|
|
4760
|
+
});
|
|
4761
|
+
const raw = await res.text();
|
|
4762
|
+
if (!res.ok) {
|
|
4763
|
+
throw new Error(`telegram ${method} failed: HTTP ${res.status} ${raw.slice(0, 200)}`);
|
|
4764
|
+
}
|
|
4765
|
+
const parsed = JSON.parse(raw) as { ok?: unknown; result?: unknown };
|
|
4766
|
+
if (parsed.ok !== true) {
|
|
4767
|
+
throw new Error(`telegram ${method} rejected: ${raw.slice(0, 200)}`);
|
|
4768
|
+
}
|
|
4769
|
+
return (parsed.result as Record<string, unknown>) ?? {};
|
|
4770
|
+
};
|
|
4771
|
+
}
|