omp-conductor 0.19.7 → 0.20.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/REFERENCE.md +10 -1
- package/agents/to-spec.md +76 -9
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
- package/src/admission.ts +58 -14
- package/src/arm-challenge.ts +255 -85
- package/src/ask.ts +130 -615
- package/src/board.ts +7 -1
- package/src/brief-upgrade.ts +24 -0
- package/src/briefs/console.md +258 -0
- package/src/briefs/correction.md +203 -0
- package/src/briefs/orchestrator.md +167 -97
- package/src/briefs/policy.md +19 -16
- package/src/briefs/to-spec.md +76 -9
- package/src/briefs/worker.md +50 -16
- package/src/cli.ts +4 -0
- package/src/command-manifest.ts +54 -8
- package/src/commands/arm.ts +115 -49
- package/src/commands/console.ts +70 -0
- package/src/commands/context.ts +2 -0
- package/src/commands/epic.ts +132 -0
- package/src/commands/extend.ts +9 -1
- package/src/commands/intake.ts +44 -14
- package/src/commands/stats.ts +19 -4
- package/src/commands/worker.ts +9 -1
- package/src/config-schema.ts +13 -0
- package/src/config.ts +27 -0
- package/src/daemon/ack.ts +159 -0
- package/src/daemon/admission-pass.ts +135 -0
- package/src/daemon/brief.ts +461 -0
- package/src/daemon/deps.ts +539 -0
- package/src/daemon/dispatch.ts +1779 -0
- package/src/daemon/drain.ts +185 -0
- package/src/daemon/groom-pass.ts +422 -0
- package/src/daemon/http.ts +417 -0
- package/src/daemon/integrity.ts +108 -0
- package/src/daemon/panes.ts +180 -0
- package/src/daemon/review.ts +1888 -0
- package/src/daemon/runtime.ts +788 -0
- package/src/daemon/settle-pass.ts +606 -0
- package/src/daemon/supervision.ts +438 -0
- package/src/daemon/tick.ts +968 -0
- package/src/daemon/views.ts +751 -0
- package/src/daemon.ts +105 -7923
- package/src/dashboard/app.js +58 -0
- package/src/dashboard/controls.ts +22 -3
- package/src/dashboard/server.ts +4 -0
- package/src/diff-flags.ts +135 -9
- package/src/doctor.ts +2 -2
- package/src/failure-class.ts +257 -2
- package/src/fleet.ts +295 -176
- package/src/groom.ts +461 -0
- package/src/http-token.ts +142 -0
- package/src/knowledge.ts +229 -0
- package/src/mining.ts +316 -0
- package/src/orchestrator-tick.ts +689 -1670
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +107 -11
- package/src/setup-host.ts +32 -9
- package/src/setup-wizard.ts +55 -7
- package/src/setup.ts +229 -3
- package/src/stats.ts +257 -2
- package/src/status-render.ts +169 -14
- package/src/store.ts +618 -28
- package/src/to-spec.ts +426 -44
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +434 -18
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +330 -39
- package/src/wake.ts +19 -2
- package/src/worker.ts +570 -1
package/src/orchestrator-tick.ts
CHANGED
|
@@ -102,7 +102,6 @@ import {
|
|
|
102
102
|
type ResolvedGrants,
|
|
103
103
|
type Store,
|
|
104
104
|
} from "./types.ts";
|
|
105
|
-
import { repoSlugFor } from "./gitops.ts";
|
|
106
105
|
import { makeTracker } from "./tracker/github.ts";
|
|
107
106
|
import { formatDecisionDigest } from "./decisions.ts";
|
|
108
107
|
import { installSurfaceMismatch } from "./status-render.ts";
|
|
@@ -114,36 +113,39 @@ import {
|
|
|
114
113
|
parseQuestionnaireRequest,
|
|
115
114
|
performQuestionnaire,
|
|
116
115
|
questionnaireParameterSchema,
|
|
117
|
-
askAnswerRowWrite,
|
|
118
116
|
askParameterSchema,
|
|
119
|
-
DEFAULT_ASK_TIMEOUT_SECONDS,
|
|
120
|
-
MAX_ASK_TIMEOUT_SECONDS,
|
|
121
|
-
MIN_ASK_TIMEOUT_SECONDS,
|
|
122
|
-
parseAskAnswerEnvelope,
|
|
123
117
|
parseAskRequest,
|
|
124
118
|
performAsk,
|
|
125
|
-
renderInteractiveAsk,
|
|
126
|
-
type AskAnswerEnvelope,
|
|
127
|
-
type AskInteractiveDelivery,
|
|
128
119
|
type AskResult,
|
|
129
120
|
} from "./ask.ts";
|
|
130
121
|
import { deliverOperatorMessage } from "./reports.ts";
|
|
131
122
|
import { effectiveLabels } from "./routing.ts";
|
|
132
|
-
import { readTelegramToken, resolveProjectTopicId, telegramStateDir } from "./escalate.ts";
|
|
133
123
|
import type { FailureClass, RecoveryAction, RunRecord } from "./types.ts";
|
|
134
124
|
import { dbPath, openStore } from "./store.ts";
|
|
135
125
|
import { digestDue, localDayKey } from "./digest-schedule.ts";
|
|
136
126
|
import {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
type
|
|
143
|
-
|
|
144
|
-
} from "./to-spec.ts";
|
|
127
|
+
DEFAULT_GROOM_BELOW,
|
|
128
|
+
groomingDue,
|
|
129
|
+
parseReadyGateRejection,
|
|
130
|
+
TO_SPEC_IN_FLIGHT_REASON,
|
|
131
|
+
toSpecDurableVerdict,
|
|
132
|
+
type ToSpecTrackerSeam,
|
|
133
|
+
} from "./groom.ts";
|
|
145
134
|
import { heldNoticeId } from "./notices.ts";
|
|
146
|
-
import {
|
|
135
|
+
import {
|
|
136
|
+
clearArmTransaction,
|
|
137
|
+
expiredArmChallenges,
|
|
138
|
+
FLEET_ARM_KEY,
|
|
139
|
+
resolveArmReply,
|
|
140
|
+
type ArmChallengeSighting,
|
|
141
|
+
type ArmReplyMatch,
|
|
142
|
+
} from "./arm-challenge.ts";
|
|
143
|
+
import {
|
|
144
|
+
readTelegramChannel,
|
|
145
|
+
readTelegramToken,
|
|
146
|
+
resolveProjectTopicId,
|
|
147
|
+
sendTelegram,
|
|
148
|
+
} from "./escalate.ts";
|
|
147
149
|
|
|
148
150
|
/** The activation file. Absent means "this is not an orchestrator session". */
|
|
149
151
|
export const TICK_CONFIG_FILE = ".conductor-tick.json";
|
|
@@ -151,47 +153,6 @@ export const TICK_CONFIG_FILE = ".conductor-tick.json";
|
|
|
151
153
|
/** Namespaced so a renderer or a session-log reader can pick ticks out. */
|
|
152
154
|
export const TICK_CUSTOM_TYPE = "omp-conductor.tick";
|
|
153
155
|
|
|
154
|
-
/**
|
|
155
|
-
* A deterministic machine-readable steer the availability gate sends when an
|
|
156
|
-
* inbound user turn is an active arming proof (conductor #415). It names the
|
|
157
|
-
* turn for what it is so the model acknowledges receipt and hands completion to
|
|
158
|
-
* the host-side `arm` instead of ad-libbing pairing-safety prose at the bare
|
|
159
|
-
* `FLEET-…` token — and it never quotes the code, which has no place outside
|
|
160
|
-
* the protected session transcript.
|
|
161
|
-
*/
|
|
162
|
-
export const ARM_PROOF_CUSTOM_TYPE = "omp-conductor.arm-proof";
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* The type carried by an answer to a code that was **not** a proof (#991).
|
|
166
|
-
*
|
|
167
|
-
* Deliberately not {@link ARM_PROOF_CUSTOM_TYPE}: that type means "an arming
|
|
168
|
-
* proof landed", and the availability gate and every reader that filters on it
|
|
169
|
-
* would otherwise see a refusal as a proof. A refusal is the opposite fact.
|
|
170
|
-
*/
|
|
171
|
-
export const ARM_REPLY_CUSTOM_TYPE = "omp-conductor.arm-reply";
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* What the operator is told when their code arrived too late (#991).
|
|
175
|
-
*
|
|
176
|
-
* Never the token, and never a hint about any other project: an expired record
|
|
177
|
-
* is the one thing this session can state about a code it could not accept.
|
|
178
|
-
*/
|
|
179
|
-
export const ARM_EXPIRED_REPLY_TEXT =
|
|
180
|
-
"That arming code has expired — nothing was armed. Run `omp-conductor arm` again for a fresh code.";
|
|
181
|
-
|
|
182
|
-
/** What the operator is told when the code matches nothing here (#991). Worded
|
|
183
|
-
* so it cannot be read as a statement about another project's ceremony. */
|
|
184
|
-
export const ARM_UNKNOWN_REPLY_TEXT =
|
|
185
|
-
"No arming is in progress here, so that code was not accepted. Run `omp-conductor arm` to start one.";
|
|
186
|
-
|
|
187
|
-
/** The model-ready wording for {@link ARM_PROOF_CUSTOM_TYPE}. Kept fixed so the
|
|
188
|
-
* orchestrator's handling of an arming proof is deterministic, whichever
|
|
189
|
-
* challenge it was cut for. */
|
|
190
|
-
export const ARM_PROOF_ACK_TEXT =
|
|
191
|
-
"The message you just received was an active omp conductor arming confirmation. " +
|
|
192
|
-
"Acknowledge that the arming confirmation was received; the host-side `arm` command " +
|
|
193
|
-
"owns completion of the arming handshake. No pairing or access change is required.";
|
|
194
|
-
|
|
195
156
|
/**
|
|
196
157
|
* A tick costs a whole turn of a frontier model, and the orchestrator's loop is
|
|
197
158
|
* about minutes of latency, not seconds. Anything under a minute is a
|
|
@@ -245,22 +206,6 @@ export const STALL_TICKS = 2;
|
|
|
245
206
|
const DEFAULT_TICK_BUDGET_SECONDS = 600;
|
|
246
207
|
/** Grace before a queued operator message preempts the turn's tool calls. */
|
|
247
208
|
const PENDING_MESSAGE_GRACE_MS = 60_000;
|
|
248
|
-
/** Routable candidates below which the queue digest tells the orchestrator to groom (#181). */
|
|
249
|
-
const DEFAULT_GROOM_BELOW = 4;
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Whether the count-based half of the grooming duty fires for one dispatch
|
|
253
|
-
* row's routable count (#988): below a numeric threshold as always, while
|
|
254
|
-
* `"always"` leaves this gate open on purpose — the selectors then answer
|
|
255
|
-
* "is anything left to groom?" from candidate state (ungroomed, unrefused,
|
|
256
|
-
* unparked), and the launch block or no-batch line renders the truthful
|
|
257
|
-
* finding either way. Never substitute a large number for `"always"`: a big
|
|
258
|
-
* queue must not silence the duty.
|
|
259
|
-
*/
|
|
260
|
-
function groomingDue(routed: number, groomBelow: GroomTrigger): boolean {
|
|
261
|
-
return groomBelow === "always" || routed < groomBelow;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
209
|
/**
|
|
265
210
|
* Written by herdr-conductor `recover.sh` *before* `agent start`, and by the
|
|
266
211
|
* dispatch daemon when a watched decision condition transitions false→true
|
|
@@ -481,15 +426,6 @@ export interface TickConfig {
|
|
|
481
426
|
* before the next tick).
|
|
482
427
|
*/
|
|
483
428
|
budgetSeconds?: number;
|
|
484
|
-
/**
|
|
485
|
-
* Seconds one {@link ASK_TOOL} call waits for the operator before its
|
|
486
|
-
* declared timeout outcome fires (#438). Optional; defaults to
|
|
487
|
-
* {@link DEFAULT_ASK_TIMEOUT_SECONDS} (5 minutes) and is always capped at the
|
|
488
|
-
* turn budget, so an ask can never outlive the turn it runs in. The ceiling
|
|
489
|
-
* is the tool's, not the model's: an ask issued without a `timeoutSeconds`
|
|
490
|
-
* gets this value all the same.
|
|
491
|
-
*/
|
|
492
|
-
askTimeoutSeconds?: number;
|
|
493
429
|
/**
|
|
494
430
|
* The herdr agent name this fleet's orchestrator pane is registered under, and
|
|
495
431
|
* the whole of {@link resolveTickOwnership}'s identity test under herdr.
|
|
@@ -567,6 +503,10 @@ const AUTONOMOUS_RECOVERY_ACTIONS: Record<RecoveryAction, boolean> = {
|
|
|
567
503
|
"rerun-checks": true,
|
|
568
504
|
settle: true,
|
|
569
505
|
escalate: false,
|
|
506
|
+
// An observation row never stamps `recoveredAt` (the recovery IS the later
|
|
507
|
+
// sweep pass), so it never lands in this digest; `false` is the honest value
|
|
508
|
+
// if one ever did — it is not "already handled", it is waiting (#1068).
|
|
509
|
+
observe: false,
|
|
570
510
|
hold: false,
|
|
571
511
|
none: false,
|
|
572
512
|
};
|
|
@@ -718,10 +658,10 @@ export interface QueueObservation {
|
|
|
718
658
|
* line tells claimable candidates apart from a runway that cannot move —
|
|
719
659
|
* instead of inviting Duty 2 to groom work whose last pass held it, which is
|
|
720
660
|
* exactly the re-derivation this store exists to stop. A to-spec launch row
|
|
721
|
-
* (#777
|
|
722
|
-
*
|
|
723
|
-
*
|
|
724
|
-
* known-blocked.
|
|
661
|
+
* (#777, written by the daemon's grooming launcher since #1041) carries the
|
|
662
|
+
* same `blocked` verdict as its durable in-flight marker, so it is told apart
|
|
663
|
+
* from the mechanical holds: it says "a batch is running", not "the lane
|
|
664
|
+
* cannot move", and counts neither as claimable nor as known-blocked.
|
|
725
665
|
*
|
|
726
666
|
* `queue` is the tracker observation the caller made THIS tick (#848). When
|
|
727
667
|
* present, the queue verdict reads in the present tense from that inventory,
|
|
@@ -737,9 +677,9 @@ export function queueDigestLine(
|
|
|
737
677
|
grooming: readonly GroomingRecord[] = [],
|
|
738
678
|
queue: QueueObservation | undefined = undefined,
|
|
739
679
|
/** Observation time for the durability of the grooming rows this line
|
|
740
|
-
* describes — the same clock the selection
|
|
741
|
-
* inventory and the
|
|
742
|
-
* (#887). */
|
|
680
|
+
* describes — the same clock the daemon's selection judges them against, so
|
|
681
|
+
* the inventory and the next grooming pass cannot disagree about what is
|
|
682
|
+
* still groomed (#887). */
|
|
743
683
|
now: number = Date.now(),
|
|
744
684
|
): string | undefined {
|
|
745
685
|
if (summary === undefined) return undefined;
|
|
@@ -823,8 +763,8 @@ function liveQueueDigestLine(
|
|
|
823
763
|
if (queued === 0) {
|
|
824
764
|
return (
|
|
825
765
|
`Queue: empty — nothing open carries "${queueLabel}" right now (tracker ${observed}). ` +
|
|
826
|
-
"Groom the backlog (Duty 2):
|
|
827
|
-
"why there is nothing to do."
|
|
766
|
+
"Groom the backlog (Duty 2): the daemon runs the to-spec passes and promotes what its ready gate " +
|
|
767
|
+
"passes, so file or sharpen the next issues, or say in this tick's report why there is nothing to do."
|
|
828
768
|
);
|
|
829
769
|
}
|
|
830
770
|
// The grooming threshold is claimability — summary.routed — never raw
|
|
@@ -884,8 +824,8 @@ function lowQueueTail(
|
|
|
884
824
|
groomBelow: GroomTrigger,
|
|
885
825
|
grooming: readonly GroomingRecord[],
|
|
886
826
|
lead: string,
|
|
887
|
-
/** The clock the durability of each verdict is judged against — the same
|
|
888
|
-
*
|
|
827
|
+
/** The clock the durability of each verdict is judged against — the same one
|
|
828
|
+
* the daemon's selection uses (#887). */
|
|
889
829
|
now: number,
|
|
890
830
|
): string {
|
|
891
831
|
// The durable per-issue verdicts, not this pass's one-shot hold groups: a
|
|
@@ -910,20 +850,23 @@ function lowQueueTail(
|
|
|
910
850
|
`(${groomingGroupCounts(knownBlocked)})${busy} — no grooming moves them; the holds clear by themselves` +
|
|
911
851
|
`${inFlight.length === 0 ? "" : " and the to-spec batch's results land when it settles"}.`;
|
|
912
852
|
} else if (knownBlocked.length > 0 || inFlight.length > 0) {
|
|
853
|
+
// Who grooms is the daemon's business now (#1041): the tail reports which
|
|
854
|
+
// candidates the pipeline can actually move, and stops instructing a
|
|
855
|
+
// session that no longer holds the launch.
|
|
913
856
|
tail =
|
|
914
857
|
groomBelow === "always"
|
|
915
|
-
? `${lead}grooming runs every
|
|
858
|
+
? `${lead}grooming runs every pass ("always") — ${summary.routed} routable candidate(s): ` +
|
|
916
859
|
`${claimable} claimable, ${knownBlocked.length} known-blocked (${groomingGroupCounts(knownBlocked)})` +
|
|
917
|
-
`${busy} —
|
|
860
|
+
`${busy} — only the claimable are groomable.`
|
|
918
861
|
: `${lead}running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}: ` +
|
|
919
862
|
`${claimable} claimable, ${knownBlocked.length} known-blocked (${groomingGroupCounts(knownBlocked)})` +
|
|
920
|
-
`${busy} —
|
|
863
|
+
`${busy} — only the claimable are groomable.`;
|
|
921
864
|
} else if (groomBelow === "always") {
|
|
922
865
|
// No numeric trigger exists to be "below", so the tail states the actual
|
|
923
866
|
// condition and what remains ungroomed (#988) instead of inventing one.
|
|
924
867
|
tail =
|
|
925
|
-
`${lead}grooming runs every
|
|
926
|
-
"
|
|
868
|
+
`${lead}grooming runs every pass ("always") — ${summary.routed} routable candidate(s); ` +
|
|
869
|
+
"the daemon keeps grooming while ungroomed, unrefused, unparked candidates remain.";
|
|
927
870
|
} else {
|
|
928
871
|
tail = `${lead}running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
|
|
929
872
|
}
|
|
@@ -939,7 +882,7 @@ function lowQueueTail(
|
|
|
939
882
|
` Backlog already-considered: ${considered.length} (${groomingVerdictCounts(considered)}) — ` +
|
|
940
883
|
`${durable.length} still durable (never re-groom these), ${regroomable} re-groomable ` +
|
|
941
884
|
"(no readable to-spec source, or observed past the freshness ceiling). " +
|
|
942
|
-
"
|
|
885
|
+
"The daemon promotes what its ready gate passes; audit those promotions and sharpen what it rejected.";
|
|
943
886
|
}
|
|
944
887
|
if (summary.admitted === 0 && summary.holds.length > 0) {
|
|
945
888
|
const held = summary.holds
|
|
@@ -953,657 +896,115 @@ function lowQueueTail(
|
|
|
953
896
|
return tail;
|
|
954
897
|
}
|
|
955
898
|
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
//
|
|
963
|
-
//
|
|
964
|
-
//
|
|
965
|
-
// Where each fact lives decides who enforces it — and every exclusion is
|
|
966
|
-
// enforced mechanically from authoritative data, never by prose:
|
|
967
|
-
//
|
|
968
|
-
// - Tracker facts — the open-issue pool, the park label, the parent/epic
|
|
969
|
-
// probe — are read ONCE, at launch composition time, through the existing
|
|
970
|
-
// Tracker adapter (`listOpenIssues`/`childrenOf`). The tick turns that
|
|
971
|
-
// snapshot into the mechanically selected batch: `offerToSpecLaunch`
|
|
972
|
-
// streams the snapshot through the store-side exclusions and the parent
|
|
973
|
-
// probe, and the returned block names the selected candidates as the ONLY
|
|
974
|
-
// batch this tick may launch. If the snapshot cannot be read, no batch is
|
|
975
|
-
// offered at all — the launch fails closed rather than trusting the model
|
|
976
|
-
// to self-filter.
|
|
977
|
-
// - Store facts — durable grooming rows (#735), admission's lane/dependency
|
|
978
|
-
// holds, active runs, in-flight launches — are enforced mechanically at
|
|
979
|
-
// composition time (the exclusion lines the block names) and again at
|
|
980
|
-
// `tool_call` time (the gate refuses an item the store contradicts).
|
|
981
|
-
// - The live source ref for each item cannot be known synchronously without
|
|
982
|
-
// a fresh per-repo read, so the model fetches it and declares it in the
|
|
983
|
-
// item's second contract line; the strict parser's 24h freshness ceiling
|
|
984
|
-
// re-vets the claim at persistence time, which is the boundary #772 set.
|
|
985
|
-
// - The gate additionally enforces the allowlist: a marker-bearing `task`
|
|
986
|
-
// call is refused unless every item's issue number AND routing repo match
|
|
987
|
-
// the batch this tick's token authorized. A parked or parent/epic
|
|
988
|
-
// candidate therefore cannot be stamped in-flight even if the model
|
|
989
|
-
// invents one — it was never on the list, and the list is the only thing
|
|
990
|
-
// the gate lets through.
|
|
899
|
+
// ============================================================ promotion audit
|
|
900
|
+
// (#1041/#1040). #777 gave the tick a per-tick capability token: a launch block
|
|
901
|
+
// naming the mechanically selected batch, a `task` tool-call gate that redeemed
|
|
902
|
+
// exactly one batch, and promotion as an orchestrator act. That made grooming
|
|
903
|
+
// tick-bound — one batch per low-queue tick, and an operator message that
|
|
904
|
+
// consumed the tick consumed the batch (#1040) — so the whole mechanism is
|
|
905
|
+
// gone. The daemon watches depth, launches its own to-spec sessions, and
|
|
906
|
+
// promotes a PROMOTABLE verdict whose spec passes the pure ready gate.
|
|
991
907
|
//
|
|
992
|
-
//
|
|
993
|
-
// the
|
|
994
|
-
//
|
|
995
|
-
//
|
|
996
|
-
|
|
997
|
-
/** The `to-spec` agent every batch item runs under (shipped in
|
|
998
|
-
* `omp/agents/to-spec.md`, discovered through OMP's native task-agent
|
|
999
|
-
* discovery — never a custom process runtime). */
|
|
1000
|
-
export const TO_SPEC_AGENT = "to-spec";
|
|
1001
|
-
|
|
1002
|
-
/** The maximum number of candidates one tick's batch may carry (#679's
|
|
1003
|
-
* "small per-tick candidate limit"; the session-scoped task semaphore
|
|
1004
|
-
* bounds concurrency underneath). */
|
|
1005
|
-
export const TO_SPEC_BATCH_MAX = 3;
|
|
1006
|
-
|
|
1007
|
-
/**
|
|
1008
|
-
* The `context` marker that identifies a conductor grooming batch to the
|
|
1009
|
-
* `tool_call` gate. The launch block instructs the orchestrator to put
|
|
1010
|
-
* `{@link TO_SPEC_BATCH_MARKER}: <token>` as the first line of the batch's
|
|
1011
|
-
* shared `context`; the gate matches the marker and the exact token this tick
|
|
1012
|
-
* issued, stamps the per-item contract (agent, strict schema), and persists
|
|
1013
|
-
* the in-flight rows. A `task` call without this marker is the orchestrator's
|
|
1014
|
-
* own and passes untouched.
|
|
1015
|
-
*/
|
|
1016
|
-
export const TO_SPEC_BATCH_MARKER = "conductor-to-spec-batch";
|
|
1017
|
-
|
|
1018
|
-
/** The grooming-table verdict row a launched-but-unfinished batch leaves behind
|
|
1019
|
-
* (reason, on a `blocked` verdict): the durable in-flight marker that stops
|
|
1020
|
-
* the next tick — or a restarted session — from re-launching the same item.
|
|
1021
|
-
* `blocked` is deliberate: `recordToSpecGrooming` replaces the row when the
|
|
1022
|
-
* result lands, and refusing-to-parse output must *not* be swallowed by the
|
|
1023
|
-
* kept-prior path that protects prior `promotable`/`considered` verdicts. */
|
|
1024
|
-
export const TO_SPEC_IN_FLIGHT_REASON = "in-flight";
|
|
1025
|
-
|
|
1026
|
-
/**
|
|
1027
|
-
* How long a launch row may sit before it is treated as a dead batch and the
|
|
1028
|
-
* candidate becomes eligible again. A batch that dies with the process (a
|
|
1029
|
-
* daemon stop between `tool_call` and delivery) must not park a candidate
|
|
1030
|
-
* forever; the 24h ceiling matches the source-freshness ceiling, so a
|
|
1031
|
-
* relaunched pass always reads new source evidence anyway.
|
|
1032
|
-
*/
|
|
1033
|
-
export const TO_SPEC_IN_FLIGHT_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
1034
|
-
|
|
1035
|
-
/**
|
|
1036
|
-
* How long a refused pass parks its candidate before another batch may be
|
|
1037
|
-
* spent on it. Deliberately the same 24h number as the source-freshness
|
|
1038
|
-
* ceiling and the in-flight TTL — one granularity for this whole lifecycle,
|
|
1039
|
-
* not a third threshold to keep in sync: within that window neither the
|
|
1040
|
-
* authoritative source nor the issue has produced new evidence, so a retry
|
|
1041
|
-
* re-runs the identical prompt and refuses the identical way.
|
|
1042
|
-
*
|
|
1043
|
-
* Without it, a candidate whose delegated pass returns malformed,
|
|
1044
|
-
* source-less or stale output is immediately eligible again, so every
|
|
1045
|
-
* low-queue tick spends a full delegated batch re-grooming it — measured on
|
|
1046
|
-
* this fleet as five permanently-refused rows (#295, #296, #297, #679, #806)
|
|
1047
|
-
* re-offered on every pass, and as #807 groomed twice seven minutes apart
|
|
1048
|
-
* (#887).
|
|
1049
|
-
*/
|
|
1050
|
-
export const TO_SPEC_REFUSED_RETRY_COOLDOWN_MS = TO_SPEC_MAX_SOURCE_AGE_MS;
|
|
1051
|
-
|
|
1052
|
-
/**
|
|
1053
|
-
* The one durability rule for a grooming row: the validated to-spec result it
|
|
1054
|
-
* carries when that result is still fresh, or `undefined` when the row is not
|
|
1055
|
-
* durable grooming at all (no to-spec payload — a hand-edited or pre-#772
|
|
1056
|
-
* row — or a source observed past the freshness ceiling).
|
|
1057
|
-
*
|
|
1058
|
-
* Every reader of "is this issue already groomed?" MUST go through this:
|
|
1059
|
-
* {@link toSpecCandidateExclusion} (selection and the `tool_call` gate), the
|
|
1060
|
-
* launch block's `already-groomed` list, and the queue digest's
|
|
1061
|
-
* already-considered inventory. Two readers with two predicates is exactly
|
|
1062
|
-
* the #887 defect — the digest told the orchestrator "never re-groom these"
|
|
1063
|
-
* about rows the mechanical selection was simultaneously offering.
|
|
1064
|
-
*/
|
|
1065
|
-
export function toSpecDurableVerdict(row: GroomingRecord, now: number): ToSpecResult | undefined {
|
|
1066
|
-
const result = parseToSpecEvidence(row.evidence);
|
|
1067
|
-
if (result === undefined) return undefined;
|
|
1068
|
-
return now - result.source.freshAt <= TO_SPEC_MAX_SOURCE_AGE_MS ? result : undefined;
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
/** The refusal a row records when its pass produced nothing usable, while the
|
|
1072
|
-
* cooldown above still holds it out of a new batch; `undefined` for any
|
|
1073
|
-
* other row, including a refusal whose cooldown has expired. */
|
|
1074
|
-
export function toSpecRefusalOnCooldown(row: GroomingRecord, now: number): ToSpecFailure | undefined {
|
|
1075
|
-
const failure = parseToSpecFailureEvidence(row.evidence);
|
|
1076
|
-
if (failure === undefined) return undefined;
|
|
1077
|
-
return now - row.recordedAt <= TO_SPEC_REFUSED_RETRY_COOLDOWN_MS ? failure : undefined;
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
/** The first line of every batch item's `task`, in the shape the gate parses:
|
|
1081
|
-
* `to-spec candidate: <owner/repo>#<issue> — <title>`. */
|
|
1082
|
-
export const TO_SPEC_ITEM_PREFIX = "to-spec candidate:";
|
|
1083
|
-
|
|
1084
|
-
/** The second line of every batch item's `task`, naming the authoritative
|
|
1085
|
-
* source and the exact ref the item was groomed against:
|
|
1086
|
-
* `to-spec source: <owner/repo>@<ref>`. `ref` is whatever the launch block
|
|
1087
|
-
* told the orchestrator to fetch as the repo's current default-branch head. */
|
|
1088
|
-
export const TO_SPEC_ITEM_SOURCE_PREFIX = "to-spec source:";
|
|
1089
|
-
|
|
1090
|
-
/** The tool the orchestrator calls to persist one completed item's exact raw
|
|
1091
|
-
* output when the batch ran in the background (#777). Registered by this
|
|
1092
|
-
* extension; subagents never see it — the `to-spec` agent's tool list is
|
|
1093
|
-
* read-only and explicit. */
|
|
1094
|
-
export const TO_SPEC_RESULT_TOOL = "conductor_to_spec_result";
|
|
1095
|
-
|
|
1096
|
-
/** One backlog candidate the mechanical gate can judge. The tracker facts
|
|
1097
|
-
* (labels, epics) are read from the authoritative open-issue snapshot at
|
|
1098
|
-
* launch composition time; they travel in this view so the selector stays
|
|
1099
|
-
* deterministic and testable. */
|
|
1100
|
-
export interface ToSpecCandidateView {
|
|
1101
|
-
issue: number;
|
|
1102
|
-
title: string;
|
|
1103
|
-
/** The routing target — a routed `owner/repo` (from the issue's one
|
|
1104
|
-
* `routing.labelPrefix<key>` label, resolved through `routing.repos`). */
|
|
1105
|
-
routing: string;
|
|
1106
|
-
/** Operator-parked (`project.stateLabels.backlog`); read from tracker labels. */
|
|
1107
|
-
parked?: boolean;
|
|
1108
|
-
/** A parent/epic with no independently runnable slice; read from the
|
|
1109
|
-
* tracker's sub-issue probe. */
|
|
1110
|
-
parent?: boolean;
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
/** One candidate the tick's token authorizes. The gate admits a batch item
|
|
1114
|
-
* only when its issue number AND routing both match an entry here. */
|
|
1115
|
-
export interface ToSpecLaunchItem {
|
|
1116
|
-
issue: number;
|
|
1117
|
-
/** The `owner/repo` the item's first contract line must name. */
|
|
1118
|
-
routing: string;
|
|
1119
|
-
/** The candidate title, as it appears in the item's first contract line. */
|
|
1120
|
-
title: string;
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
/** The machine-readable item contract a batch item must satisfy. */
|
|
1124
|
-
export interface ToSpecBatchItem {
|
|
1125
|
-
issue: number;
|
|
1126
|
-
/** The `owner/repo` named by the item's first contract line. */
|
|
1127
|
-
routing: string;
|
|
1128
|
-
/** The `owner/repo@ref` named by the item's second contract line. */
|
|
1129
|
-
sourceRef: string;
|
|
1130
|
-
/** The item's full task text (the rendered brief plus the contract lines). */
|
|
1131
|
-
task: string;
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
/**
|
|
1135
|
-
* Parse the two contract lines a batch item must start with
|
|
1136
|
-
* (`to-spec candidate: <repo>#<n> — <title>` / `to-spec source: <name>@<ref>`).
|
|
1137
|
-
* Anything else is not a conductor grooming item. Built from the shared
|
|
1138
|
-
* {@link TO_SPEC_ITEM_PREFIX}/{@link TO_SPEC_ITEM_SOURCE_PREFIX} constants so
|
|
1139
|
-
* the launch block's wording and the gate's parsing cannot drift apart.
|
|
1140
|
-
*/
|
|
1141
|
-
export function parseToSpecItem(task: unknown): ToSpecBatchItem | undefined {
|
|
1142
|
-
if (typeof task !== "string") return undefined;
|
|
1143
|
-
const lines = task.split("\n");
|
|
1144
|
-
const head = new RegExp(`^${TO_SPEC_ITEM_PREFIX}\\s+([\\w.-]+\\/[\\w.-]+)#(\\d+)(?:\\s+—\\s+.*)?$`).exec(
|
|
1145
|
-
lines[0]?.trim() ?? "",
|
|
1146
|
-
);
|
|
1147
|
-
const source = new RegExp(`^${TO_SPEC_ITEM_SOURCE_PREFIX}\\s+([\\w.-]+\\/[\\w.-]+)@(\\S+)$`).exec(
|
|
1148
|
-
lines[1]?.trim() ?? "",
|
|
1149
|
-
);
|
|
1150
|
-
if (head === null || source === null) return undefined;
|
|
1151
|
-
return { issue: Number(head[2]), routing: head[1]!, sourceRef: source[2]!, task };
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
/**
|
|
1155
|
-
* Why one candidate is not eligible for a batch right now, or undefined when
|
|
1156
|
-
* it is. The single rule source for the prompt-time exclusion list, the
|
|
1157
|
-
* `tool_call` gate, and the selection helper — one rule, three readers, so a
|
|
1158
|
-
* candidate excluded in prose is excluded in the gate for the same reason.
|
|
1159
|
-
*
|
|
1160
|
-
* - `in-flight`: a launch row recorded within the TTL (a dead batch's row
|
|
1161
|
-
* expires and the candidate becomes eligible again);
|
|
1162
|
-
* - `file-lane` / `depends-on`: admission's durable mechanical holds (#735);
|
|
1163
|
-
* - a fresh valid `to-spec` result in the grooming table: the candidate was
|
|
1164
|
-
* already groomed at an observed source within the freshness ceiling, so
|
|
1165
|
-
* re-running it would recompute a verdict that is still valid. New source
|
|
1166
|
-
* evidence reconsiders it: once the recorded `freshAt` crosses the ceiling
|
|
1167
|
-
* the row no longer reads as groomed, and a fresh pass overrides it;
|
|
1168
|
-
* - a refused pass inside {@link TO_SPEC_REFUSED_RETRY_COOLDOWN_MS}: a full
|
|
1169
|
-
* delegated batch was already spent and produced nothing usable
|
|
1170
|
-
* (malformed, source-less or stale output). Retrying inside the cooldown
|
|
1171
|
-
* re-runs the identical prompt against the same source and refuses the
|
|
1172
|
-
* same way, which is how one broken candidate consumed a batch on every
|
|
1173
|
-
* low-queue tick (#887);
|
|
1174
|
-
* - `active`: a run is in flight on the issue right now.
|
|
1175
|
-
*/
|
|
1176
|
-
export function toSpecCandidateExclusion(
|
|
1177
|
-
candidate: { issue: number },
|
|
1178
|
-
facts: { grooming: GroomingRecord | undefined; active: boolean },
|
|
1179
|
-
now: number,
|
|
1180
|
-
): string | undefined {
|
|
1181
|
-
const row = facts.grooming;
|
|
1182
|
-
if (row !== undefined) {
|
|
1183
|
-
if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
|
|
1184
|
-
if (now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) {
|
|
1185
|
-
return `#${candidate.issue} is already in a to-spec batch (launched ${new Date(row.recordedAt).toISOString()})`;
|
|
1186
|
-
}
|
|
1187
|
-
} else if (row.reason === "file-lane" || row.reason === "depends-on") {
|
|
1188
|
-
return `#${candidate.issue} is mechanically blocked (${row.reason}) — the hold clears by itself`;
|
|
1189
|
-
} else {
|
|
1190
|
-
const durable = toSpecDurableVerdict(row, now);
|
|
1191
|
-
if (durable !== undefined) {
|
|
1192
|
-
return (
|
|
1193
|
-
`#${candidate.issue} was already groomed ${durable.verdict} (source ${durable.source.name}@` +
|
|
1194
|
-
`${durable.source.ref}, observed ${new Date(durable.source.freshAt).toISOString()}) — re-groom only ` +
|
|
1195
|
-
"with new source evidence"
|
|
1196
|
-
);
|
|
1197
|
-
}
|
|
1198
|
-
const refusal = toSpecRefusalOnCooldown(row, now);
|
|
1199
|
-
if (refusal !== undefined) {
|
|
1200
|
-
const retryAt = new Date(row.recordedAt + TO_SPEC_REFUSED_RETRY_COOLDOWN_MS).toISOString();
|
|
1201
|
-
return (
|
|
1202
|
-
`#${candidate.issue} already spent a to-spec batch that was refused as ${refusal.kind} ` +
|
|
1203
|
-
`(${new Date(row.recordedAt).toISOString()}) — eligible again after ${retryAt}, or once the ` +
|
|
1204
|
-
"issue or its source changes"
|
|
1205
|
-
);
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
if (facts.active) return `#${candidate.issue} has a dispatched run in flight`;
|
|
1210
|
-
return undefined;
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
/**
|
|
1214
|
-
* The mechanical half of Duty 2's launch: deterministic, bounded selection of
|
|
1215
|
-
* the eligible candidates, smallest issue numbers first, never more than
|
|
1216
|
-
* {@link TO_SPEC_BATCH_MAX} per batch, nothing at/above a numeric grooming
|
|
1217
|
-
* trigger (`"always"` opens that gate and lets candidate state decide, #988),
|
|
1218
|
-
* nothing before the first dispatch summary exists (queue health unknown —
|
|
1219
|
-
* the same gate the queue digest uses). Parked and parent views are honored
|
|
1220
|
-
* when the caller supplies them.
|
|
1221
|
-
*
|
|
1222
|
-
* Production reaches this selector through {@link offerToSpecLaunch}, which
|
|
1223
|
-
* fills the views from the authoritative tracker snapshot (park label from
|
|
1224
|
-
* the open-issue labels, parent/epic from the sub-issue probe) and streams
|
|
1225
|
-
* the store-side exclusions before the selector runs — so parked and parent
|
|
1226
|
-
* exclusions are enforced on the live path, not only on test inputs.
|
|
1227
|
-
*/
|
|
1228
|
-
export function selectToSpecBatch(input: {
|
|
1229
|
-
candidates: readonly ToSpecCandidateView[];
|
|
1230
|
-
summary: DispatchSummary | undefined;
|
|
1231
|
-
groomBelow: GroomTrigger;
|
|
1232
|
-
grooming: readonly GroomingRecord[];
|
|
1233
|
-
active: readonly { issue: number }[];
|
|
1234
|
-
now: number;
|
|
1235
|
-
}): ToSpecCandidateView[] {
|
|
1236
|
-
if (input.summary === undefined) return [];
|
|
1237
|
-
if (!groomingDue(input.summary.routed, input.groomBelow)) return [];
|
|
1238
|
-
const groomingByIssue = new Map(input.grooming.map((row) => [row.issue, row]));
|
|
1239
|
-
const activeIssues = new Set(input.active.map((run) => run.issue));
|
|
1240
|
-
const selected: ToSpecCandidateView[] = [];
|
|
1241
|
-
for (const candidate of [...input.candidates].sort((a, b) => a.issue - b.issue)) {
|
|
1242
|
-
if (candidate.parked) continue;
|
|
1243
|
-
if (candidate.parent) continue;
|
|
1244
|
-
if (
|
|
1245
|
-
toSpecCandidateExclusion(
|
|
1246
|
-
{ issue: candidate.issue },
|
|
1247
|
-
{ grooming: groomingByIssue.get(candidate.issue), active: activeIssues.has(candidate.issue) },
|
|
1248
|
-
input.now,
|
|
1249
|
-
) !== undefined
|
|
1250
|
-
) {
|
|
1251
|
-
continue;
|
|
1252
|
-
}
|
|
1253
|
-
selected.push(candidate);
|
|
1254
|
-
if (selected.length >= TO_SPEC_BATCH_MAX) break;
|
|
1255
|
-
}
|
|
1256
|
-
return selected;
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
/**
|
|
1260
|
-
* One tick's launch authorization: the token, the block text it carries, and
|
|
1261
|
-
* the mechanically selected batch the token authorizes. The gate admits a
|
|
1262
|
-
* marker-bearing `task` call only when every item matches an entry of
|
|
1263
|
-
* {@link ToSpecLaunchBlock.items} — the list is produced from the tracker
|
|
1264
|
-
* snapshot, never from model-supplied fields.
|
|
1265
|
-
*/
|
|
1266
|
-
export interface ToSpecLaunchBlock {
|
|
1267
|
-
/** The per-launch token the batch must echo in its `context` first line. */
|
|
1268
|
-
token: string;
|
|
1269
|
-
block: string;
|
|
1270
|
-
/** The mechanically selected candidates — the ONLY batch this token may
|
|
1271
|
-
* carry (issue number AND routing must both match). */
|
|
1272
|
-
items: ToSpecLaunchItem[];
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
/** Why candidates sit out, as one compact line each, for the launch block. */
|
|
1276
|
-
export interface ToSpecLaunchExclusions {
|
|
1277
|
-
/** Candidates with a fresh, valid to-spec verdict already on the grooming table. */
|
|
1278
|
-
groomed: string[];
|
|
1279
|
-
/** Candidates whose last pass was refused and whose retry cooldown still
|
|
1280
|
-
* holds ({@link TO_SPEC_REFUSED_RETRY_COOLDOWN_MS}). */
|
|
1281
|
-
refused: string[];
|
|
1282
|
-
/** Candidates with an active to-spec batch. */
|
|
1283
|
-
inFlight: string[];
|
|
1284
|
-
/** Candidates under admission's durable lane/dependency holds. */
|
|
1285
|
-
mechanicallyBlocked: string[];
|
|
1286
|
-
/** Candidates with a dispatched run live right now. */
|
|
1287
|
-
dispatched: string[];
|
|
1288
|
-
}
|
|
908
|
+
// What the tick owes Duty 2 instead is review, and review needs exactly two
|
|
909
|
+
// facts the store already holds: what the daemon queued unattended since the
|
|
910
|
+
// last beat, and which specs its gate refused. Neither is derivable from
|
|
911
|
+
// session memory, and both must arrive with the command that acts on them —
|
|
912
|
+
// an audit line an operator cannot act on is a line that gets skipped.
|
|
1289
913
|
|
|
1290
|
-
/**
|
|
1291
|
-
*
|
|
1292
|
-
*
|
|
1293
|
-
|
|
1294
|
-
* implements this with `makeTracker(...).listOpenIssues()` /
|
|
1295
|
-
* `.childrenOf(...)`; tests inject deterministic fakes. `listOpenIssues` is
|
|
1296
|
-
* the one open-issue snapshot with labels (#203) — it answers the park
|
|
1297
|
-
* label and the routing label mechanically — and `childrenOf` answers
|
|
1298
|
-
* whether a candidate is a parent/epic (its sub-issues exist, so it has no
|
|
1299
|
-
* independently runnable slice of its own). Both fail closed: an unreadable
|
|
1300
|
-
* snapshot means no batch is offered, while an unreadable parent probe skips
|
|
1301
|
-
* that candidate because eligibility was not mechanically established.
|
|
1302
|
-
*/
|
|
1303
|
-
export interface ToSpecTrackerSeam {
|
|
1304
|
-
/** Every open issue in the tracker repo, labels included
|
|
1305
|
-
* (`Tracker.listOpenIssues`). Throws when the tracker cannot be read. */
|
|
1306
|
-
listOpenIssues(project: ProjectConfig): Promise<ReadyIssue[]>;
|
|
1307
|
-
/** Sub-issues of one issue (`Tracker.childrenOf`). */
|
|
1308
|
-
childrenOf(project: ProjectConfig, issue: number): Promise<{ number: number; state: IssueState }[]>;
|
|
1309
|
-
}
|
|
914
|
+
/** How many promotions and gate rejections one audit block names. A tick's
|
|
915
|
+
* prompt is a budget, and an unbounded burst of promotions must not push the
|
|
916
|
+
* duties off the end of it; the count always states the full total. */
|
|
917
|
+
const PROMOTION_AUDIT_LIMIT = 6;
|
|
1310
918
|
|
|
1311
919
|
/**
|
|
1312
|
-
* The
|
|
1313
|
-
*
|
|
1314
|
-
*
|
|
1315
|
-
* several `routing.labelPrefix` labels (or a label mapping to no configured
|
|
1316
|
-
* repo) cannot name an authoritative source to read and leave the pool,
|
|
1317
|
-
* exactly like admission's unroutable partition. The park label lands on the
|
|
1318
|
-
* view for the selector to drop; the parent/epic probe is separate (one
|
|
1319
|
-
* tracker read per candidate) and stays with the offer, which runs it only
|
|
1320
|
-
* for candidates the store-side exclusions did not already reject.
|
|
1321
|
-
*/
|
|
1322
|
-
function toSpecPoolFromSnapshot(
|
|
1323
|
-
issues: readonly ReadyIssue[],
|
|
1324
|
-
project: ProjectConfig,
|
|
1325
|
-
): ToSpecCandidateView[] {
|
|
1326
|
-
const queueLabel = project.queueLabel;
|
|
1327
|
-
const parkLabel = project.stateLabels.backlog;
|
|
1328
|
-
const { labelPrefix, repos } = project.routing;
|
|
1329
|
-
const views: ToSpecCandidateView[] = [];
|
|
1330
|
-
for (const issue of issues) {
|
|
1331
|
-
if (issue.labels.includes(queueLabel)) continue;
|
|
1332
|
-
const matched = [...new Set(issue.labels.filter((l) => l.startsWith(labelPrefix)))];
|
|
1333
|
-
if (matched.length !== 1) continue;
|
|
1334
|
-
const key = matched[0]!.slice(labelPrefix.length);
|
|
1335
|
-
const target = Object.hasOwn(repos, key) ? repos[key]! : undefined;
|
|
1336
|
-
if (target === undefined) continue;
|
|
1337
|
-
views.push({
|
|
1338
|
-
issue: issue.number,
|
|
1339
|
-
title: issue.title,
|
|
1340
|
-
routing: repoSlugFor(target),
|
|
1341
|
-
parked: issue.labels.includes(parkLabel) ? true : undefined,
|
|
1342
|
-
});
|
|
1343
|
-
}
|
|
1344
|
-
views.sort((a, b) => a.issue - b.issue);
|
|
1345
|
-
return views;
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
/**
|
|
1349
|
-
* The production launch offer for one tick: the complete mechanical
|
|
1350
|
-
* selection, from the authoritative tracker snapshot through every
|
|
1351
|
-
* exclusion, ending in the token + block + allowlist, or `undefined` when no
|
|
1352
|
-
* batch may launch. All of the following yield `undefined`:
|
|
920
|
+
* The Duty-2 promotion audit: what the daemon promoted on its own since the
|
|
921
|
+
* previous tick, each with the one command that undoes it, and the specs its
|
|
922
|
+
* ready gate refused, each with what the gate found missing.
|
|
1353
923
|
*
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
*
|
|
1358
|
-
* - the snapshot cannot be read — the launch fails closed rather than
|
|
1359
|
-
* trusting the model to self-filter parked/parent/epic candidates;
|
|
1360
|
-
* - nothing survives the exclusions (parked, parent/epic, already groomed,
|
|
1361
|
-
* in-flight, lane/dependency holds, dispatched runs) — there is then no
|
|
1362
|
-
* batch to authorize, and a marker-bearing `task` call stays refused.
|
|
924
|
+
* `undefined` when there is nothing to say — the common state of a fleet whose
|
|
925
|
+
* queue is deep enough that no grooming pass ran. A block that reported "0
|
|
926
|
+
* promotions" every beat would be permanent noise, the same convention
|
|
927
|
+
* {@link queueDigestLine} and {@link formatPendingIntake} keep.
|
|
1363
928
|
*
|
|
1364
|
-
* The
|
|
1365
|
-
*
|
|
1366
|
-
*
|
|
1367
|
-
*
|
|
1368
|
-
|
|
1369
|
-
export async function offerToSpecLaunch(input: {
|
|
1370
|
-
summary: DispatchSummary | undefined;
|
|
1371
|
-
groomBelow: GroomTrigger;
|
|
1372
|
-
grooming: readonly GroomingRecord[];
|
|
1373
|
-
active: readonly { issue: number }[];
|
|
1374
|
-
project: ProjectConfig;
|
|
1375
|
-
trackerSeam: ToSpecTrackerSeam | undefined;
|
|
1376
|
-
/** An open-issue snapshot this tick already read from the tracker. Shared
|
|
1377
|
-
* with the queue digest so one tick cannot describe two queues (#848);
|
|
1378
|
-
* absent when that read failed, in which case the launch retries its own
|
|
1379
|
-
* read and fails closed on the same terms as before. */
|
|
1380
|
-
issues?: readonly ReadyIssue[];
|
|
1381
|
-
now: number;
|
|
1382
|
-
}): Promise<ToSpecLaunchBlock | undefined> {
|
|
1383
|
-
if (input.summary === undefined) return undefined;
|
|
1384
|
-
if (!groomingDue(input.summary.routed, input.groomBelow)) return undefined;
|
|
1385
|
-
const seam = input.trackerSeam;
|
|
1386
|
-
if (seam === undefined) return undefined;
|
|
1387
|
-
let issues = input.issues;
|
|
1388
|
-
if (issues === undefined) {
|
|
1389
|
-
try {
|
|
1390
|
-
issues = await seam.listOpenIssues(input.project);
|
|
1391
|
-
} catch {
|
|
1392
|
-
// No authoritative snapshot, no launch: a batch offered without one would
|
|
1393
|
-
// make the model the selector, which is exactly the defect this slice
|
|
1394
|
-
// removes. The queue digest still names the grooming duty; the next tick
|
|
1395
|
-
// retries the read.
|
|
1396
|
-
return undefined;
|
|
1397
|
-
}
|
|
1398
|
-
}
|
|
1399
|
-
const views = toSpecPoolFromSnapshot(issues, input.project);
|
|
1400
|
-
const groomingByIssue = new Map(input.grooming.map((row) => [row.issue, row]));
|
|
1401
|
-
const activeIssues = new Set(input.active.map((run) => run.issue));
|
|
1402
|
-
const pool: ToSpecCandidateView[] = [];
|
|
1403
|
-
for (const view of views) {
|
|
1404
|
-
if (view.parked) continue;
|
|
1405
|
-
if (
|
|
1406
|
-
toSpecCandidateExclusion(
|
|
1407
|
-
{ issue: view.issue },
|
|
1408
|
-
{ grooming: groomingByIssue.get(view.issue), active: activeIssues.has(view.issue) },
|
|
1409
|
-
input.now,
|
|
1410
|
-
) !== undefined
|
|
1411
|
-
) {
|
|
1412
|
-
continue;
|
|
1413
|
-
}
|
|
1414
|
-
let children: { number: number; state: IssueState }[];
|
|
1415
|
-
try {
|
|
1416
|
-
children = await seam.childrenOf(input.project, view.issue);
|
|
1417
|
-
} catch {
|
|
1418
|
-
continue;
|
|
1419
|
-
}
|
|
1420
|
-
if (children.length > 0) continue; // parent/epic with sub-issues: no runnable slice
|
|
1421
|
-
pool.push(view);
|
|
1422
|
-
if (pool.length >= TO_SPEC_BATCH_MAX) break;
|
|
1423
|
-
}
|
|
1424
|
-
// The one shared rule set re-runs on the tracker-vetted pool, so the pure
|
|
1425
|
-
// selector — not a prose instruction — answers what the block authorizes.
|
|
1426
|
-
const selected = selectToSpecBatch({
|
|
1427
|
-
candidates: pool,
|
|
1428
|
-
summary: input.summary,
|
|
1429
|
-
groomBelow: input.groomBelow,
|
|
1430
|
-
grooming: input.grooming,
|
|
1431
|
-
active: input.active,
|
|
1432
|
-
now: input.now,
|
|
1433
|
-
});
|
|
1434
|
-
if (selected.length === 0) return undefined;
|
|
1435
|
-
return toSpecLaunchBlock({
|
|
1436
|
-
summary: input.summary,
|
|
1437
|
-
groomBelow: input.groomBelow,
|
|
1438
|
-
grooming: input.grooming,
|
|
1439
|
-
active: input.active,
|
|
1440
|
-
tracker: input.project.tracker.repo,
|
|
1441
|
-
queueLabel: input.project.queueLabel,
|
|
1442
|
-
labelPrefix: input.project.routing.labelPrefix,
|
|
1443
|
-
parkLabel: input.project.stateLabels.backlog,
|
|
1444
|
-
selected,
|
|
1445
|
-
now: input.now,
|
|
1446
|
-
});
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
/**
|
|
1450
|
-
* The per-tick launch block. Present exactly when a batch may be launched:
|
|
1451
|
-
* the grooming duty is due (below a numeric trigger, or `"always"` — #988),
|
|
1452
|
-
* a dispatch row exists, and the mechanical selection produced at least one
|
|
1453
|
-
* candidate. The block names the selected candidates as the ONLY batch this
|
|
1454
|
-
* tick authorizes and lists every store-proven exclusion the selection
|
|
1455
|
-
* already applied; the `tool_call` gate refuses any item outside the list.
|
|
929
|
+
* The demote command is spelled from the real verb surface rather than
|
|
930
|
+
* invented: `conductor_label` (verbs/protocol.ts) takes the full issue URL,
|
|
931
|
+
* the label, `action`, and a `reason` from {@link LABEL_REASONS} — `needs-human`
|
|
932
|
+
* is the member that means "parked: it needs a decision only a human can
|
|
933
|
+
* make", which is exactly what an overruled auto-promotion is.
|
|
1456
934
|
*/
|
|
1457
|
-
export function
|
|
1458
|
-
|
|
1459
|
-
|
|
935
|
+
export function formatPromotionAudit(input: {
|
|
936
|
+
/** Grooming rows promoted since {@link since}, as `Store.promotionsSince`
|
|
937
|
+
* returns them (newest or oldest first — the block does not reorder, so the
|
|
938
|
+
* store's order is the audit's order). */
|
|
939
|
+
promotions: readonly GroomingRecord[];
|
|
940
|
+
/** Every current grooming row, from which the gate rejections are read. The
|
|
941
|
+
* full set rather than a window: a spec the gate refused three ticks ago is
|
|
942
|
+
* still unqueued work, and dropping it would let a rejected spec go quiet. */
|
|
1460
943
|
grooming: readonly GroomingRecord[];
|
|
1461
|
-
|
|
944
|
+
project: string;
|
|
1462
945
|
tracker: string;
|
|
1463
946
|
queueLabel: string;
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
selected: readonly ToSpecCandidateView[];
|
|
1468
|
-
now: number;
|
|
1469
|
-
}): ToSpecLaunchBlock | undefined {
|
|
1470
|
-
if (input.summary === undefined) return undefined;
|
|
1471
|
-
if (!groomingDue(input.summary.routed, input.groomBelow)) return undefined;
|
|
1472
|
-
if (input.selected.length === 0) return undefined;
|
|
1473
|
-
const exclusions: ToSpecLaunchExclusions = {
|
|
1474
|
-
groomed: [],
|
|
1475
|
-
refused: [],
|
|
1476
|
-
inFlight: [],
|
|
1477
|
-
mechanicallyBlocked: [],
|
|
1478
|
-
dispatched: [],
|
|
1479
|
-
};
|
|
1480
|
-
for (const row of input.grooming) {
|
|
1481
|
-
if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
|
|
1482
|
-
if (input.now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) exclusions.inFlight.push(`#${row.issue}`);
|
|
1483
|
-
} else if (row.reason === "file-lane" || row.reason === "depends-on") {
|
|
1484
|
-
exclusions.mechanicallyBlocked.push(`#${row.issue} (${row.reason})`);
|
|
1485
|
-
} else {
|
|
1486
|
-
// The same two predicates the selection and the gate apply, so a
|
|
1487
|
-
// candidate this block advertises as excluded is one selection actually
|
|
1488
|
-
// withheld — and one it does not advertise is one selection may offer
|
|
1489
|
-
// (#887).
|
|
1490
|
-
const durable = toSpecDurableVerdict(row, input.now);
|
|
1491
|
-
if (durable !== undefined) {
|
|
1492
|
-
exclusions.groomed.push(
|
|
1493
|
-
`#${row.issue} (${durable.verdict} @ ${durable.source.ref}, observed ${new Date(durable.source.freshAt).toISOString()})`,
|
|
1494
|
-
);
|
|
1495
|
-
continue;
|
|
1496
|
-
}
|
|
1497
|
-
const refusal = toSpecRefusalOnCooldown(row, input.now);
|
|
1498
|
-
if (refusal !== undefined) {
|
|
1499
|
-
exclusions.refused.push(
|
|
1500
|
-
`#${row.issue} (${refusal.kind}, refused ${new Date(row.recordedAt).toISOString()}, retry after ` +
|
|
1501
|
-
`${new Date(row.recordedAt + TO_SPEC_REFUSED_RETRY_COOLDOWN_MS).toISOString()})`,
|
|
1502
|
-
);
|
|
1503
|
-
}
|
|
1504
|
-
}
|
|
1505
|
-
}
|
|
1506
|
-
for (const run of input.active) exclusions.dispatched.push(`#${run.issue}`);
|
|
1507
|
-
const token = randomUUID();
|
|
1508
|
-
const items: ToSpecLaunchItem[] = input.selected.map((candidate) => ({
|
|
1509
|
-
issue: candidate.issue,
|
|
1510
|
-
routing: candidate.routing,
|
|
1511
|
-
title: candidate.title,
|
|
1512
|
-
}));
|
|
1513
|
-
const candidates = items.map((item) => `- #${item.issue} (${item.routing}) — ${item.title}`).join("\n");
|
|
1514
|
-
// The opening sentence names the condition that authorized this batch:
|
|
1515
|
-
// below a numeric trigger as before, or the always-on mode in its own
|
|
1516
|
-
// words (#988) — never a threshold the configuration does not have.
|
|
1517
|
-
const trigger =
|
|
1518
|
-
input.groomBelow === "always"
|
|
1519
|
-
? `Grooming is configured \`groomBelow: "always"\` — Duty 2 grooms every tick while ungroomed candidates remain ` +
|
|
1520
|
-
`(${input.summary.routed} routable right now). `
|
|
1521
|
-
: `The routable queue is below the grooming trigger of ${input.groomBelow} (${input.summary.routed} routable). `;
|
|
1522
|
-
const lines = [
|
|
1523
|
-
`## Bounded to-spec grooming batch (${TO_SPEC_BATCH_MARKER}, #777)`,
|
|
1524
|
-
"",
|
|
1525
|
-
trigger +
|
|
1526
|
-
"Duty 2's finding is now a strict contract: launch EXACTLY ONE native `task` batch this turn with the " +
|
|
1527
|
-
`\`${TO_SPEC_AGENT}\` agent — never a second batch, never an improvised scout.`,
|
|
1528
|
-
"",
|
|
1529
|
-
"The conductor selected this batch mechanically from the live open-issue snapshot " +
|
|
1530
|
-
`(issues carrying \`${input.queueLabel}\`, the \`${input.parkLabel}\` park label, issues without exactly one ` +
|
|
1531
|
-
`\`${input.labelPrefix}<repo>\` routing label, parent/epic issues with sub-issues, ` +
|
|
1532
|
-
"already-groomed, refused-on-cooldown, in-flight, lane/dependency-blocked and dispatched candidates were excluded):",
|
|
1533
|
-
candidates,
|
|
1534
|
-
"Excluded this tick — " +
|
|
1535
|
-
`already-groomed: ${exclusions.groomed.length === 0 ? "none" : exclusions.groomed.join(", ")}; ` +
|
|
1536
|
-
`refused, cooldown still holding: ${exclusions.refused.length === 0 ? "none" : exclusions.refused.join(", ")}; ` +
|
|
1537
|
-
`in-flight batches — ${exclusions.inFlight.length === 0 ? "none" : exclusions.inFlight.join(", ")}; ` +
|
|
1538
|
-
`mechanically blocked: ${exclusions.mechanicallyBlocked.length === 0 ? "none" : exclusions.mechanicallyBlocked.join(", ")}; ` +
|
|
1539
|
-
`dispatched now: ${exclusions.dispatched.length === 0 ? "none" : exclusions.dispatched.join(", ")}.`,
|
|
1540
|
-
"",
|
|
1541
|
-
"To launch it:",
|
|
1542
|
-
"",
|
|
1543
|
-
`1. For each candidate above, render \`omp/src/briefs/to-spec.md\` with its placeholders — {{TRACKER_REPO}}, ` +
|
|
1544
|
-
"{{ISSUE_NUMBER}}, {{CANDIDATE_TITLE}}, {{ISSUE_BODY}} (read via `gh issue view <number> --repo " +
|
|
1545
|
-
`${input.tracker}\`, never from memory), {{SOURCE}} = the candidate's routed repo, {{SOURCE_REF}} = that repo's \`` +
|
|
1546
|
-
"current default-branch head, fetched now (`gh api repos/<repo>/branches/HEAD` or `git ls-remote`). Your item " +
|
|
1547
|
-
"`task` MUST start with the two contract lines `to-spec candidate: <owner/repo>#<issue> — <title>` and " +
|
|
1548
|
-
"`to-spec source: <owner/repo>@<ref>` — the `owner/repo` must match the routing named above.",
|
|
1549
|
-
`2. Call \`task\` once with \`context\` whose first line is exactly \`${TO_SPEC_BATCH_MARKER}: ${token}\`, one ` +
|
|
1550
|
-
`item per candidate above, in that order — no substitutes, no extra items (\`agent: "${TO_SPEC_AGENT}"\`; this ` +
|
|
1551
|
-
"extension stamps the exact outputSchema/schemaMode on the way in and refuses a second batch this turn).",
|
|
1552
|
-
`3. When each item completes, whether in the tool result or later as an async-result message, persist the agent's ` +
|
|
1553
|
-
`exact raw output through the \`${TO_SPEC_RESULT_TOOL}\` tool — one call per completed item (\`issue\` + \`input\`), ` +
|
|
1554
|
-
"success and failure alike: a malformed, source-less or stale result persists as blocked and must not discard " +
|
|
1555
|
-
"successful siblings. Read the full output from its `agent://<id>` artifact when the inline text is truncated.",
|
|
1556
|
-
`Never add \`${input.queueLabel}\`, never edit an issue or its labels. This batch produces verdicts only; ` +
|
|
1557
|
-
"promotion stays your decision — the tool_call gate and the store own persistence, you own the queue.",
|
|
1558
|
-
"",
|
|
1559
|
-
];
|
|
1560
|
-
return { token, block: lines.join("\n"), items };
|
|
1561
|
-
}
|
|
1562
|
-
|
|
1563
|
-
/**
|
|
1564
|
-
* The accounting a low-queue tick owes Duty 2 when the mechanical selection
|
|
1565
|
-
* produced no batch at all. Without it a fully-excluded backlog is silence,
|
|
1566
|
-
* and silence is what gets re-derived by hand: the orchestrator cannot tell
|
|
1567
|
-
* "the queue is low and nothing is groomable" from "the launch machinery did
|
|
1568
|
-
* not run". Every count comes from the same predicates the selection and the
|
|
1569
|
-
* `tool_call` gate apply (#887).
|
|
1570
|
-
*
|
|
1571
|
-
* `undefined` when no dispatch row exists or the queue is at/above a numeric
|
|
1572
|
-
* grooming trigger (`"always"` opens that gate; candidate state decides, #988)
|
|
1573
|
-
* — the same gate the offer itself uses, so this line and a launch block are
|
|
1574
|
-
* mutually exclusive.
|
|
1575
|
-
*/
|
|
1576
|
-
export function toSpecNoBatchLine(input: {
|
|
1577
|
-
summary: DispatchSummary | undefined;
|
|
1578
|
-
groomBelow: GroomTrigger;
|
|
1579
|
-
grooming: readonly GroomingRecord[];
|
|
1580
|
-
active: readonly { issue: number }[];
|
|
1581
|
-
now: number;
|
|
947
|
+
/** The lower bound the promotions were read from, named so the orchestrator
|
|
948
|
+
* can tell "nothing happened" from "a narrow window". */
|
|
949
|
+
since: number;
|
|
1582
950
|
}): string | undefined {
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
951
|
+
const rejected = input.grooming.flatMap((row) => {
|
|
952
|
+
const rejection = parseReadyGateRejection(row.evidence);
|
|
953
|
+
return rejection === undefined ? [] : [{ row, rejection }];
|
|
954
|
+
});
|
|
955
|
+
if (input.promotions.length === 0 && rejected.length === 0) return undefined;
|
|
956
|
+
const lines: string[] = [
|
|
957
|
+
`Promotion audit (Duty 2) — ${input.promotions.length} auto-promotion(s) since ` +
|
|
958
|
+
`${new Date(input.since).toISOString()}, ${rejected.length} spec(s) the ready gate refused:`,
|
|
959
|
+
];
|
|
960
|
+
for (const row of input.promotions.slice(0, PROMOTION_AUDIT_LIMIT)) {
|
|
961
|
+
const at = row.promotedAt === undefined ? "unknown time" : new Date(row.promotedAt).toISOString();
|
|
962
|
+
const by = row.promotedBy ?? "unrecorded";
|
|
963
|
+
// The gate that passed is named from the provenance the promoter stored,
|
|
964
|
+
// never guessed: the audit exists to review what the daemon did
|
|
965
|
+
// unattended, and a guessed attribution would make that review circular.
|
|
966
|
+
const gate =
|
|
967
|
+
by === "daemon"
|
|
968
|
+
? "ready gate passed"
|
|
969
|
+
: by === "orchestrator"
|
|
970
|
+
? "promoted by an earlier tick"
|
|
971
|
+
: "promoted by an operator";
|
|
972
|
+
lines.push(
|
|
973
|
+
`- #${row.issue} — verdict ${row.verdict} (${row.reason}); ${gate}; queued ${at} by ${by}. ` +
|
|
974
|
+
`Demote: omp-conductor verb conductor_label --project ${input.project} ` +
|
|
975
|
+
`--arg issueUrl=https://github.com/${input.tracker}/issues/${row.issue} ` +
|
|
976
|
+
`--arg label=${input.queueLabel} --arg action=remove --arg reason=needs-human ` +
|
|
977
|
+
`--arg rationale="<why this spec is not ready>"`,
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
if (input.promotions.length > PROMOTION_AUDIT_LIMIT) {
|
|
981
|
+
lines.push(`- … ${input.promotions.length - PROMOTION_AUDIT_LIMIT} further promotion(s) not shown.`);
|
|
982
|
+
}
|
|
983
|
+
if (rejected.length > 0) {
|
|
984
|
+
lines.push(
|
|
985
|
+
"Gate-rejected specs — never queued, and the pass that produced them is already spent, so the " +
|
|
986
|
+
"issue itself is what has to change. These repeat every tick until they are fixed, which is why " +
|
|
987
|
+
"each carries the date its gate ran:",
|
|
988
|
+
);
|
|
989
|
+
// Oldest rejection first: a spec refused days ago and never edited is the
|
|
990
|
+
// one the audit keeps re-printing, so it leads rather than sinking under
|
|
991
|
+
// whatever the last pass produced.
|
|
992
|
+
const ordered = [...rejected].sort((a, b) => a.rejection.checkedAt - b.rejection.checkedAt);
|
|
993
|
+
for (const { row, rejection } of ordered.slice(0, PROMOTION_AUDIT_LIMIT)) {
|
|
994
|
+
const checked =
|
|
995
|
+
rejection.checkedAt === 0 ? "unknown date" : new Date(rejection.checkedAt).toISOString();
|
|
996
|
+
lines.push(`- #${row.issue} (gate ran ${checked}) missing: ${rejection.missing.join("; ")}`);
|
|
997
|
+
}
|
|
998
|
+
if (rejected.length > PROMOTION_AUDIT_LIMIT) {
|
|
999
|
+
lines.push(`- … ${rejected.length - PROMOTION_AUDIT_LIMIT} further rejected spec(s) not shown.`);
|
|
1598
1000
|
}
|
|
1599
1001
|
}
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
`the ${TO_SPEC_BATCH_MARKER} marker is refused this turn; file or promote from what the backlog already ` +
|
|
1605
|
-
"says instead of re-grooming it."
|
|
1002
|
+
lines.push(
|
|
1003
|
+
"Duty 2 is now three things and no launch: propose net-new scope through a decision row, audit the " +
|
|
1004
|
+
"promotions above (demote what you disagree with, using the command on its line), and edit the " +
|
|
1005
|
+
"gate-rejected issues until the missing fields are there — the daemon re-runs the gate by itself.",
|
|
1606
1006
|
);
|
|
1007
|
+
return lines.join("\n");
|
|
1607
1008
|
}
|
|
1608
1009
|
|
|
1609
1010
|
export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScopeChoice]: string } = {
|
|
@@ -1616,6 +1017,19 @@ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScopeChoice]: string
|
|
|
1616
1017
|
"Interrupt only for: tier2, fleet-stopped, confirmed-failure; everything else waits for the daily digest.",
|
|
1617
1018
|
};
|
|
1618
1019
|
|
|
1020
|
+
/**
|
|
1021
|
+
* Collapses one durable row's prose to a single bounded prompt line.
|
|
1022
|
+
*
|
|
1023
|
+
* Shared by every appended block rather than re-declared inside each: they all
|
|
1024
|
+
* render operator- or model-authored text into a prompt where one pathological
|
|
1025
|
+
* row must not push the duties out of the window, and three private copies of
|
|
1026
|
+
* the same four lines is how one of them ends up unbounded.
|
|
1027
|
+
*/
|
|
1028
|
+
function flattened(text: string, limit: number): string {
|
|
1029
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
1030
|
+
return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1619
1033
|
/** Bounded, durable source material for one due digest. Row ids are part of the
|
|
1620
1034
|
* handoff contract: the report and exactly the rows it consumed settle in one
|
|
1621
1035
|
* SQLite transaction, so a crash cannot lose an outcome between those writes. */
|
|
@@ -1624,21 +1038,17 @@ export function formatDigestLedger(
|
|
|
1624
1038
|
notices: readonly HeldNotice[],
|
|
1625
1039
|
backlog: DigestBacklog,
|
|
1626
1040
|
): string {
|
|
1627
|
-
const oneLine = (text: string, limit: number): string => {
|
|
1628
|
-
const flat = text.replace(/\s+/g, " ").trim();
|
|
1629
|
-
return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
|
|
1630
|
-
};
|
|
1631
1041
|
const lines = [
|
|
1632
1042
|
`Durable digest ledger — ${backlog.materialCount} material event(s), ${backlog.heldNoticeCount} held notice(s); oldest first:`,
|
|
1633
1043
|
...events.map(
|
|
1634
1044
|
(event) =>
|
|
1635
1045
|
` event ${event.id} | ${new Date(event.occurredAt).toISOString()} | ${event.category} | ` +
|
|
1636
|
-
`${
|
|
1046
|
+
`${flattened(event.summary, 180)} | evidence: ${flattened(event.evidence, 240)}`,
|
|
1637
1047
|
),
|
|
1638
1048
|
...notices.map(
|
|
1639
1049
|
(notice) =>
|
|
1640
1050
|
` notice ${notice.id} | ${new Date(notice.createdAt).toISOString()} | ${notice.category} | ` +
|
|
1641
|
-
`${
|
|
1051
|
+
`${flattened(notice.summary, 180)} | detail: ${flattened(notice.detail, 240)}`,
|
|
1642
1052
|
),
|
|
1643
1053
|
];
|
|
1644
1054
|
if (backlog.materialCount > events.length) {
|
|
@@ -1658,6 +1068,113 @@ export function formatDigestLedger(
|
|
|
1658
1068
|
return lines.join("\n");
|
|
1659
1069
|
}
|
|
1660
1070
|
|
|
1071
|
+
/**
|
|
1072
|
+
* Reserved `material_events` category for a mechanical amendment the
|
|
1073
|
+
* orchestrator applied on its own authority (Phase 4 — the floor's
|
|
1074
|
+
* mechanical-vs-judgment split).
|
|
1075
|
+
*
|
|
1076
|
+
* There is no amendments table, and neither other durable candidate can carry
|
|
1077
|
+
* this audit honestly. `POLICY.md`'s own `## Amendments` section is written by
|
|
1078
|
+
* step 4 of the floor's *judgment* protocol only — a mechanical fix skips
|
|
1079
|
+
* straight to applying and reporting, so it never lands there — and that
|
|
1080
|
+
* section's line shape (date, trigger, one-sentence summary) has nowhere to put
|
|
1081
|
+
* a revert command. `friction_rollups` counts admission holds and report
|
|
1082
|
+
* classifications and knows nothing about policy edits. `material_events` is
|
|
1083
|
+
* the append-only outbox the floor already uses for every outcome that is not
|
|
1084
|
+
* sent as an interrupt, its category is a free lowercase slug (`event record`
|
|
1085
|
+
* validates `^[a-z0-9][a-z0-9-]{0,31}$`), and both its text columns are
|
|
1086
|
+
* mandatory — `--summary` 1-240 characters, `--evidence` 1-500 — which makes
|
|
1087
|
+
* `evidence` exactly the right home for a one-line revert. So the audit is a
|
|
1088
|
+
* category convention over the ledger that already exists, and needs no schema
|
|
1089
|
+
* change. Note where the cap bites: a revert longer than 500 characters is
|
|
1090
|
+
* refused by `event record` at write time rather than truncated by the renderer
|
|
1091
|
+
* below, so an over-long one is a revert to shorten, never a silently clipped
|
|
1092
|
+
* command an operator would paste.
|
|
1093
|
+
*
|
|
1094
|
+
* The consequence to keep in view: this surface holds what the orchestrator
|
|
1095
|
+
* wrote to it and nothing else. An amendment applied without the ledger write
|
|
1096
|
+
* is invisible here, which is why the floor's mechanical bullet and the
|
|
1097
|
+
* friction digest both name the exact command rather than leaving the shape to
|
|
1098
|
+
* be guessed.
|
|
1099
|
+
*/
|
|
1100
|
+
export const AMENDMENT_EVENT_CATEGORY = "amendment";
|
|
1101
|
+
|
|
1102
|
+
/** Amendment rows rendered in one audit. A window with more self-applied policy
|
|
1103
|
+
* edits than this is a fleet problem the prompt cannot fix by listing them. */
|
|
1104
|
+
const AMENDMENT_AUDIT_LIMIT = 5;
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* How deep into the outbox the audit reads.
|
|
1108
|
+
*
|
|
1109
|
+
* Deliberately far past {@link DIGEST_BACKLOG_LIMIT}, which bounds one digest's
|
|
1110
|
+
* worth of source material: an audit inheriting that bound would hide a
|
|
1111
|
+
* self-applied policy edit behind twenty merge events, and that row is the one
|
|
1112
|
+
* that must never go unseen.
|
|
1113
|
+
*/
|
|
1114
|
+
export const AMENDMENT_AUDIT_SCAN_LIMIT = 200;
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* The one sentence in the tick prompt that states the amendment cap.
|
|
1118
|
+
*
|
|
1119
|
+
* A constant, and used in exactly one rendered block, because the cap is the
|
|
1120
|
+
* clause the floor just re-scoped: it counts *judgment* proposals, and a
|
|
1121
|
+
* mechanical drift fix is not a proposal at all. A second copy of this sentence
|
|
1122
|
+
* anywhere in the prompt is how the old undifferentiated wording comes back —
|
|
1123
|
+
* the audit block below therefore states the duty and never restates the cap.
|
|
1124
|
+
*/
|
|
1125
|
+
export const AMENDMENT_THROTTLE_LINE =
|
|
1126
|
+
"The cap is at most one new orchestrator-originated judgment amendment proposal per autonomous tick, and it " +
|
|
1127
|
+
"counts judgment proposals only — duties, boundaries, release policy, caps, reporting scope. A mechanical " +
|
|
1128
|
+
"drift fix is not a proposal and is not throttled: apply it, report it with its revert, and apply the next one.";
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* Mechanical amendments the orchestrator applied without asking, read back to
|
|
1132
|
+
* it before they reach its operator (Phase 4).
|
|
1133
|
+
*
|
|
1134
|
+
* The floor's mechanical branch trades an ask for a promise — apply now, then
|
|
1135
|
+
* say in the next report what changed, why, and how to revert it. This block is
|
|
1136
|
+
* what makes that promise auditable by something other than session memory,
|
|
1137
|
+
* which is exactly the memory the whole ledger discipline exists to distrust.
|
|
1138
|
+
*
|
|
1139
|
+
* The window is "recorded and not yet carried into a delivered digest", not
|
|
1140
|
+
* "since the previous tick". That is the honest predicate rather than the
|
|
1141
|
+
* approximate one: the tick has no durable record of its own previous beat, and
|
|
1142
|
+
* an interval-width window silently drops the audit when a turn runs long or a
|
|
1143
|
+
* digest fails — the two occasions where an unreviewed policy edit is most
|
|
1144
|
+
* likely. Undigested is also self-clearing: composing the report retires the
|
|
1145
|
+
* row, so a reported amendment stops appearing without anyone marking it.
|
|
1146
|
+
*
|
|
1147
|
+
* On a due-digest tick this deliberately overlaps {@link formatDigestLedger},
|
|
1148
|
+
* which lists the same row for a different purpose — the ledger hands over row
|
|
1149
|
+
* ids to consume, the audit hands over a policy edit to re-justify or revert.
|
|
1150
|
+
* Suppressing one because the other rendered would mean a self-applied
|
|
1151
|
+
* amendment goes unreviewed precisely on the tick that ships it.
|
|
1152
|
+
*/
|
|
1153
|
+
export function formatAmendmentAudit(events: readonly MaterialEvent[]): string | undefined {
|
|
1154
|
+
const amendments = events.filter((event) => event.category === AMENDMENT_EVENT_CATEGORY);
|
|
1155
|
+
if (amendments.length === 0) return undefined;
|
|
1156
|
+
const shown = amendments.slice(0, AMENDMENT_AUDIT_LIMIT);
|
|
1157
|
+
const lines = [
|
|
1158
|
+
`Mechanical amendments you applied without asking — ${amendments.length}, not yet carried into a report. ` +
|
|
1159
|
+
"Each one is a POLICY.md edit made on your own authority, so the audit is yours before it is your operator's:",
|
|
1160
|
+
...shown.map(
|
|
1161
|
+
(event) =>
|
|
1162
|
+
`- ${new Date(event.occurredAt).toISOString()} | ${flattened(event.summary, 180)} | ` +
|
|
1163
|
+
`revert: ${flattened(event.evidence, 240)}`,
|
|
1164
|
+
),
|
|
1165
|
+
];
|
|
1166
|
+
if (amendments.length > shown.length) {
|
|
1167
|
+
lines.push(`- … ${amendments.length - shown.length} further amendment(s) not shown.`);
|
|
1168
|
+
}
|
|
1169
|
+
lines.push(
|
|
1170
|
+
"Re-check each against the source that made it mechanical — the file, config key or ledger row that proves " +
|
|
1171
|
+
"the value. Still true: carry the line into your next report exactly as it reads here, revert command " +
|
|
1172
|
+
"included. No longer true, or a source you can no longer state: run the revert now, and propose the change " +
|
|
1173
|
+
"as a judgment amendment if it is still worth making.",
|
|
1174
|
+
);
|
|
1175
|
+
return lines.join("\n");
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1661
1178
|
/**
|
|
1662
1179
|
* The reporting constraint appended to a default tick prompt (#229, #242).
|
|
1663
1180
|
*
|
|
@@ -1779,7 +1296,11 @@ export { TELEGRAM_APPROVAL_TOOL };
|
|
|
1779
1296
|
*
|
|
1780
1297
|
* The last clause is the actual hazard #114 exposed: a turn that knows
|
|
1781
1298
|
* it must ask, and cannot, is one inference away from recording an approval
|
|
1782
|
-
* nobody gave.
|
|
1299
|
+
* nobody gave. Phase 4 narrows its noun to a *judgment* amendment, and only its
|
|
1300
|
+
* noun: a mechanical drift fix is applied without an ask by design, so an
|
|
1301
|
+
* undifferentiated "never apply an amendment without an answer" here would
|
|
1302
|
+
* contradict the floor on the branch that has no ask to be missing. The hazard
|
|
1303
|
+
* this rule guards — recording an unanswered yes/no as approved — is unchanged.
|
|
1783
1304
|
*
|
|
1784
1305
|
* Unlike {@link TICK_DELIVERY_RULE} this is appended to a configured `message`
|
|
1785
1306
|
* too. An operator's prompt owns the reporting contract and is theirs to get
|
|
@@ -1788,29 +1309,35 @@ export { TELEGRAM_APPROVAL_TOOL };
|
|
|
1788
1309
|
*/
|
|
1789
1310
|
export const TICK_APPROVAL_UNAVAILABLE_RULE =
|
|
1790
1311
|
`The ${TELEGRAM_APPROVAL_TOOL} tool is NOT mounted on this tick, so the package floor's yes/no amendment approval cannot be asked here. ` +
|
|
1791
|
-
`If you have
|
|
1312
|
+
`If you have a judgment amendment to propose, deliver the question with \`omp-conductor message --category decision-needed --text "<the question>"\` — ` +
|
|
1792
1313
|
`it resolves this project's own Telegram chat and topic, records the question as an open decision row, and applies the ` +
|
|
1793
1314
|
`same availability policy as the tick: an unanswered yes/no stays pending — re-surfaced in every tick until answered ` +
|
|
1794
1315
|
`or the seven-day expiry, never recorded as approved. ` +
|
|
1795
1316
|
`Wait for the operator's reply on a later turn, then resolve the row with \`omp-conductor decision resolve <id> --answer "..."\`. ` +
|
|
1796
1317
|
`A returned answer proves an answer, not Telegram delivery. ` +
|
|
1797
|
-
`Never apply
|
|
1318
|
+
`Never apply a judgment amendment, or record one as approved, without an explicit answer you actually received.`;
|
|
1798
1319
|
|
|
1799
1320
|
/**
|
|
1800
1321
|
* Appended to every tick — the shipped prompt or the operator's own — because
|
|
1801
|
-
* the
|
|
1322
|
+
* the durable ask surface is the mechanical contract of a local tick (#438).
|
|
1802
1323
|
*
|
|
1803
|
-
*
|
|
1804
|
-
*
|
|
1805
|
-
*
|
|
1806
|
-
*
|
|
1807
|
-
*
|
|
1324
|
+
* No ask waits any more. This session is the headless tick brain: operator
|
|
1325
|
+
* turns land in the 24/7 console session, so an answer physically cannot
|
|
1326
|
+
* arrive here, and a duty cycle that waited would stop the fleet for nothing —
|
|
1327
|
+
* on 2026-08-16 that was six stopped hours. So the rule states the whole
|
|
1328
|
+
* contract up front: the question becomes a durable row, the console resolves
|
|
1329
|
+
* it later, and parking the blocked work is part of the same turn rather than
|
|
1330
|
+
* something the model discovers when a wait expires.
|
|
1808
1331
|
*/
|
|
1809
1332
|
export const TICK_ASK_RULE =
|
|
1810
1333
|
`On this locally injected tick, questions to your operator go through ${ASK_TOOL}: ` +
|
|
1811
|
-
`call it with
|
|
1812
|
-
`
|
|
1813
|
-
`
|
|
1334
|
+
`call it with the question, its options and your recommendation. It does NOT wait — nothing here does. ` +
|
|
1335
|
+
`The question is filed as a durable decision row and delivered, and the answer arrives later, when your ` +
|
|
1336
|
+
`operator resolves that row from the console session (\`omp-conductor decision resolve <id> --answer "..."\`). ` +
|
|
1337
|
+
`So in the SAME turn you ask, park the work the question blocks: take it out of the claimable queue, record ` +
|
|
1338
|
+
`its state, and name the row id in your report. Never wait for a reply, never re-ask on the next tick, and ` +
|
|
1339
|
+
`never proceed as though your recommendation had been approved. ` +
|
|
1340
|
+
`Several judgement calls about ONE issue go as a single ${QUESTIONNAIRE_TOOL} instead: one delivery, ` +
|
|
1814
1341
|
`each item a durable row bound to that issue, resolved independently and in any order. ` +
|
|
1815
1342
|
`The ${TELEGRAM_APPROVAL_TOOL} tool is refused here: it would wait for your operator for as long as the answer ` +
|
|
1816
1343
|
`takes, and an unanswered question must never hold the loop.`;
|
|
@@ -1824,8 +1351,8 @@ export const TICK_ASK_RULE =
|
|
|
1824
1351
|
export const ASK_DIVERSION_REASON =
|
|
1825
1352
|
`Blocked: on this locally injected tick, ${TELEGRAM_APPROVAL_TOOL} would wait for your operator for as long as ` +
|
|
1826
1353
|
`the answer takes, and an unanswered question must never stop the loop (#438). Nothing was sent or recorded ` +
|
|
1827
|
-
`by this call. Ask instead with ${ASK_TOOL}
|
|
1828
|
-
`
|
|
1354
|
+
`by this call. Ask instead with ${ASK_TOOL} — it records the question as a durable decision row, delivers it, ` +
|
|
1355
|
+
`and returns immediately with the row id and the command your operator's console resolves it with.`;
|
|
1829
1356
|
|
|
1830
1357
|
/**
|
|
1831
1358
|
* Appended to every tick — the shipped prompt or the operator's own — composed
|
|
@@ -1866,7 +1393,17 @@ function frictionLabel(kind: FrictionSignal["kind"]): string {
|
|
|
1866
1393
|
return "tick reports classified as surprising";
|
|
1867
1394
|
}
|
|
1868
1395
|
|
|
1869
|
-
/**
|
|
1396
|
+
/**
|
|
1397
|
+
* Bounded evidence for the existing approval protocol — never an automatic edit.
|
|
1398
|
+
*
|
|
1399
|
+
* The closing instruction is the one place the tick prompt routes into the
|
|
1400
|
+
* Learning loop, so it is also where the floor's mechanical-vs-judgment split
|
|
1401
|
+
* has to be visible (Phase 4): the gate sentence is unchanged — friction with no
|
|
1402
|
+
* safe POLICY.md remedy still leaves policy alone — and the two branches under
|
|
1403
|
+
* it say which kind of amendment the remedy is, and what a mechanical one owes
|
|
1404
|
+
* the ledger. It carries {@link AMENDMENT_THROTTLE_LINE} and is the only block
|
|
1405
|
+
* that does.
|
|
1406
|
+
*/
|
|
1870
1407
|
export function formatFrictionDigest(signals: readonly FrictionSignal[]): string {
|
|
1871
1408
|
const shown = signals.slice(0, FRICTION_DIGEST_LIMIT);
|
|
1872
1409
|
const lines = [
|
|
@@ -1884,12 +1421,24 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
|
|
|
1884
1421
|
if (signals.length > shown.length) lines.push(`- ${signals.length - shown.length} more signal(s) deferred`);
|
|
1885
1422
|
lines.push(
|
|
1886
1423
|
"After the tick duties, investigate at most one signal. Use the existing Learning loop only if the recurring cause has a safe POLICY.md remedy; otherwise leave policy unchanged and report or file the underlying product/infra issue through the existing rules.",
|
|
1424
|
+
"When there is a remedy, route it by kind. A drift fact you can verify against the repo, the config or " +
|
|
1425
|
+
"this fleet's own ledger — a renamed label, a moved path, a changed schedule, a caps figure the config " +
|
|
1426
|
+
"already carries — is a MECHANICAL amendment: edit POLICY.md now, then record it with " +
|
|
1427
|
+
'`omp-conductor event record --category ' +
|
|
1428
|
+
AMENDMENT_EVENT_CATEGORY +
|
|
1429
|
+
' --summary "<what changed and why>" --evidence "<the one-line revert>"`, which is what puts it in the ' +
|
|
1430
|
+
"next tick's amendment audit and in your next report. A remedy that is a choice about how the fleet " +
|
|
1431
|
+
"should behave is a JUDGMENT amendment and goes through the proposal protocol unchanged. " +
|
|
1432
|
+
AMENDMENT_THROTTLE_LINE,
|
|
1887
1433
|
);
|
|
1888
1434
|
return lines.join("\n");
|
|
1889
1435
|
}
|
|
1890
1436
|
|
|
1891
1437
|
/** Bounds one pending idea's text in the tick prompt, like the digest ledger. */
|
|
1892
1438
|
const INTAKE_TEXT_LIMIT = 160;
|
|
1439
|
+
/** Bounds a mined item's provenance key beside its text. Shorter than the text
|
|
1440
|
+
* because a source is an identifier, not prose — a longer one is malformed. */
|
|
1441
|
+
const INTAKE_SOURCE_LIMIT = 80;
|
|
1893
1442
|
|
|
1894
1443
|
/**
|
|
1895
1444
|
* Pending intake items, each waiting to become an issue (#300).
|
|
@@ -1903,19 +1452,31 @@ const INTAKE_TEXT_LIMIT = 160;
|
|
|
1903
1452
|
* "0 pending" every tick would be permanent noise — the same convention as
|
|
1904
1453
|
* {@link queueDigestLine}. Project-specific grooming taste (priority scales,
|
|
1905
1454
|
* template wording) stays in POLICY.md; this block carries only the floor duty.
|
|
1455
|
+
*
|
|
1456
|
+
* Provenance is rendered per item because the two kinds are not the same work
|
|
1457
|
+
* (Phase 4 signal mining). An item with no `source` is an idea a person typed:
|
|
1458
|
+
* it is scope, and grooming it means writing down what they asked for. A mined
|
|
1459
|
+
* item is a machine's inference from settlement flags, failure classes or
|
|
1460
|
+
* friction — evidence to check against the code before anything is filed, and
|
|
1461
|
+
* the one kind that can be honestly dismissed on the orchestrator's own
|
|
1462
|
+
* judgement when the evidence does not hold up. Rendering them
|
|
1463
|
+
* indistinguishably invited exactly one error: filing a mined guess as though
|
|
1464
|
+
* an operator had asked for it.
|
|
1906
1465
|
*/
|
|
1907
1466
|
export function formatPendingIntake(
|
|
1908
1467
|
items: readonly IntakeItem[],
|
|
1909
1468
|
instruction: { tracker: string; queueLabel: string; labelPrefix: string },
|
|
1910
1469
|
): string | undefined {
|
|
1911
1470
|
if (items.length === 0) return undefined;
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1471
|
+
// Bounded like the text: a mined source is a key, and a pathological one must
|
|
1472
|
+
// not push the grooming instruction out of the prompt window.
|
|
1473
|
+
const provenance = (item: IntakeItem): string =>
|
|
1474
|
+
item.source === undefined
|
|
1475
|
+
? "your operator's own idea"
|
|
1476
|
+
: `mined signal ${flattened(item.source, INTAKE_SOURCE_LIMIT)}`;
|
|
1916
1477
|
const lines = [
|
|
1917
1478
|
`Pending intake — ${items.length} idea(s) captured, waiting to be groomed into issues, oldest first:`,
|
|
1918
|
-
...items.map((item) => `- ${item.id} — ${
|
|
1479
|
+
...items.map((item) => `- ${item.id} (${provenance(item)}) — ${flattened(item.text, INTAKE_TEXT_LIMIT)}`),
|
|
1919
1480
|
"",
|
|
1920
1481
|
`Groom each into exactly one issue on ${instruction.tracker}: a title stating the problem; a body ` +
|
|
1921
1482
|
`carrying the product rationale and the acceptance criteria as a checklist; the routing ` +
|
|
@@ -1927,6 +1488,12 @@ export function formatPendingIntake(
|
|
|
1927
1488
|
'intake --summary "groomed intake <id> → #<issue-number>" --evidence <url>` so the digest names ' +
|
|
1928
1489
|
"the grooming. An item that is malformed or empty is dismissed with `omp-conductor intake dismiss " +
|
|
1929
1490
|
"<id>` and noted in the digest rather than filed.",
|
|
1491
|
+
"The two provenances are groomed differently. Your operator's own idea is scope: file what they asked " +
|
|
1492
|
+
"for, and never dismiss it on your own judgement — if it looks wrong, that is a question for them. A " +
|
|
1493
|
+
"mined signal is evidence, not scope: open what its source names, confirm from the code that the " +
|
|
1494
|
+
"problem is real, and file the issue against what you actually find — its wording is a machine's " +
|
|
1495
|
+
"inference and yours to correct. A mined signal the evidence does not support is dismissed, with the " +
|
|
1496
|
+
"reason in the digest.",
|
|
1930
1497
|
];
|
|
1931
1498
|
return lines.join("\n");
|
|
1932
1499
|
}
|
|
@@ -2089,6 +1656,20 @@ export function resolveArmState(armedFile: string, projectName?: string): ArmSta
|
|
|
2089
1656
|
return shared ? { armed: true } : { armed: true, legacy: "honoured" };
|
|
2090
1657
|
}
|
|
2091
1658
|
|
|
1659
|
+
/**
|
|
1660
|
+
* The one armed-marker write both settlement paths share — the CLI's
|
|
1661
|
+
* `arm --reply` half and the orchestrator session's mechanical reply consumer
|
|
1662
|
+
* (#1061): same content, same mode, and the same restamp of the pre-per-project
|
|
1663
|
+
* shared marker the heartbeat still honours (#316). It lives here, beside
|
|
1664
|
+
* {@link resolveArmState}, so the session consumer can write the gate it reads
|
|
1665
|
+
* — the reverse import (into `fleet.ts`) would cycle.
|
|
1666
|
+
*/
|
|
1667
|
+
export function writeArmedMarker(path: string, owner: string, arm: ArmState): void {
|
|
1668
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1669
|
+
writeFileSync(path, `armed ${new Date().toISOString()} owner=${owner}\n`, { mode: 0o600 });
|
|
1670
|
+
if (arm.legacy === "honoured") rmSync(legacyArmedMarkerPath(), { force: true });
|
|
1671
|
+
}
|
|
1672
|
+
|
|
2092
1673
|
/**
|
|
2093
1674
|
* Best-effort recompose of `ORCHESTRATOR.md` from the installed package floor +
|
|
2094
1675
|
* live `POLICY.md`.
|
|
@@ -2165,18 +1746,11 @@ export function readTickConfig(cwd: string): TickConfigResult {
|
|
|
2165
1746
|
? budgetRaw
|
|
2166
1747
|
: undefined;
|
|
2167
1748
|
|
|
2168
|
-
//
|
|
2169
|
-
//
|
|
2170
|
-
//
|
|
2171
|
-
//
|
|
2172
|
-
|
|
2173
|
-
const askTimeoutSeconds =
|
|
2174
|
-
typeof askRaw === "number" &&
|
|
2175
|
-
Number.isInteger(askRaw) &&
|
|
2176
|
-
askRaw >= MIN_ASK_TIMEOUT_SECONDS &&
|
|
2177
|
-
askRaw <= MAX_ASK_TIMEOUT_SECONDS
|
|
2178
|
-
? askRaw
|
|
2179
|
-
: undefined;
|
|
1749
|
+
// No ask ceiling is read from the config any more: the ask does not wait at
|
|
1750
|
+
// all (#438 as re-cut for the console split), so a stale `askTimeoutSeconds`
|
|
1751
|
+
// on a deployed fleet's `.conductor-tick.json` is simply an unknown key —
|
|
1752
|
+
// ignored like every other one, so an old file on disk can never fail a
|
|
1753
|
+
// restart.
|
|
2180
1754
|
|
|
2181
1755
|
// Relative paths resolve against the session cwd, so the files can sit beside
|
|
2182
1756
|
// the config that names them (`state/armed`) without hard-coding a deploy path.
|
|
@@ -2222,7 +1796,6 @@ export function readTickConfig(cwd: string): TickConfigResult {
|
|
|
2222
1796
|
intervalSeconds,
|
|
2223
1797
|
...(project === undefined ? {} : { project }),
|
|
2224
1798
|
...(budgetSeconds === undefined ? {} : { budgetSeconds }),
|
|
2225
|
-
...(askTimeoutSeconds === undefined ? {} : { askTimeoutSeconds }),
|
|
2226
1799
|
...(armedFile === undefined ? {} : { armedFile }),
|
|
2227
1800
|
...(accessFile === undefined ? {} : { accessFile }),
|
|
2228
1801
|
...(message === undefined ? {} : { message }),
|
|
@@ -2856,12 +2429,6 @@ interface PendingLocalTick {
|
|
|
2856
2429
|
interface ActiveLocalTick extends PendingLocalTick {
|
|
2857
2430
|
/** A person who writes during an autonomous run is awake by construction. */
|
|
2858
2431
|
humanWaiting: boolean;
|
|
2859
|
-
/** The most recent inbound turn was an *active arming proof* (conductor #415):
|
|
2860
|
-
* a reply matching a persisted pending challenge for this project. Set when
|
|
2861
|
-
* `humanWaiting` is, without flipping the human-present exemption — the
|
|
2862
|
-
* operator answered a challenge, so they are awake, and the model must not
|
|
2863
|
-
* treat the matching token as license to mutate pairing or access. */
|
|
2864
|
-
armingProof?: boolean;
|
|
2865
2432
|
}
|
|
2866
2433
|
|
|
2867
2434
|
interface TelegramInterrupt {
|
|
@@ -3231,29 +2798,6 @@ interface TickSession {
|
|
|
3231
2798
|
legacyArmLogged: boolean;
|
|
3232
2799
|
/** The local tick whose agent loop is currently running, if any. */
|
|
3233
2800
|
activeLocalTick?: ActiveLocalTick;
|
|
3234
|
-
/**
|
|
3235
|
-
* The to-spec launch token the last low-queue tick authorized (#777). Set
|
|
3236
|
-
* when the tick appended a launch block; the `task` tool_call gate accepts
|
|
3237
|
-
* exactly one batch echoing it and refuses a marker-bearing call without
|
|
3238
|
-
* it (no authorized batch this tick), with a stale token, or after a batch
|
|
3239
|
-
* was already accepted. Cleared at the start of every tick — healthy and
|
|
3240
|
-
* low-queue alike — so a token minted on a low-queue tick can never be
|
|
3241
|
-
* spent on a later healthy-queue tick; the tick that mints the next token
|
|
3242
|
-
* sets it fresh with its own {@link TickSession.launchItems}.
|
|
3243
|
-
*/
|
|
3244
|
-
launchToken?: string;
|
|
3245
|
-
/** The project the {launchToken} authorization was minted for — the one
|
|
3246
|
-
* in-flight rows are recorded against. */
|
|
3247
|
-
launchProject?: string;
|
|
3248
|
-
/** The mechanically selected batch the current token authorizes: the gate
|
|
3249
|
-
* refuses any item whose issue or routing is not on this list, so parked,
|
|
3250
|
-
* parent/epic and other excluded candidates cannot be stamped in-flight
|
|
3251
|
-
* no matter what the model sends. */
|
|
3252
|
-
launchItems?: ToSpecLaunchItem[];
|
|
3253
|
-
/** The one batch this session's gate has let through, so a second attempt
|
|
3254
|
-
* this tick refuses, and the tool_result capture can match results to
|
|
3255
|
-
* items by index. */
|
|
3256
|
-
launchedBatch?: { toolCallId: string; items: ToSpecBatchItem[] };
|
|
3257
2801
|
}
|
|
3258
2802
|
|
|
3259
2803
|
/**
|
|
@@ -3342,14 +2886,8 @@ async function tick(
|
|
|
3342
2886
|
config: TickConfig,
|
|
3343
2887
|
session: TickSession,
|
|
3344
2888
|
toSpecTrackerSeam: ToSpecTrackerSeam | undefined,
|
|
2889
|
+
armSeam: ArmConsumerSeam | undefined,
|
|
3345
2890
|
): Promise<void> {
|
|
3346
|
-
// A launch authorization belongs to one emitted tick only. Revoke it before
|
|
3347
|
-
// any gate, config, store or tracker read so a skipped/degraded/healthy next
|
|
3348
|
-
// tick cannot reuse a token minted by an earlier low-queue tick.
|
|
3349
|
-
session.launchedBatch = undefined;
|
|
3350
|
-
session.launchToken = undefined;
|
|
3351
|
-
session.launchProject = undefined;
|
|
3352
|
-
session.launchItems = undefined;
|
|
3353
2891
|
const live = currentConfig(ctx.cwd, config);
|
|
3354
2892
|
const arm = live.armedFile === undefined ? undefined : resolveArmState(live.armedFile, live.project);
|
|
3355
2893
|
if (arm?.legacy === "stranded" && !session.legacyArmLogged) {
|
|
@@ -3358,6 +2896,29 @@ async function tick(
|
|
|
3358
2896
|
`[omp-conductor] ${legacyArmedMarkerPath()}: ${LEGACY_ARM_MARKER_DETAIL} — this heartbeat stays disarmed`,
|
|
3359
2897
|
);
|
|
3360
2898
|
}
|
|
2899
|
+
// An unanswered ceremony that passed its window is a dead ceremony, not a
|
|
2900
|
+
// silent one (#1061): the tick that notices tells the operator why and how
|
|
2901
|
+
// to re-run it, on the surface the challenge was sent to. Deliberately
|
|
2902
|
+
// before the tick gate — a disarmed fleet is exactly the state an
|
|
2903
|
+
// unanswered challenge leaves, and its death notice must not wait for an
|
|
2904
|
+
// arm to be heard. The scan is synchronous so an idle host (no expired
|
|
2905
|
+
// records) costs the tick nothing; only a send is awaited.
|
|
2906
|
+
const armNow = (armSeam?.now ?? Date.now)();
|
|
2907
|
+
for (const expired of expiredArmChallenges(live.project, armNow)) {
|
|
2908
|
+
const surface: ArmNoticeSurface = { accessFile: live.accessFile, project: live.project };
|
|
2909
|
+
const send = armSeam?.notify ?? sendArmNotice;
|
|
2910
|
+
try {
|
|
2911
|
+
await send(armExpiryNotice(expired, live.project), surface);
|
|
2912
|
+
} catch (err) {
|
|
2913
|
+
// Keep the record: the next heartbeat retries, and `doctor` still
|
|
2914
|
+
// reports it.
|
|
2915
|
+
pi.logger.error(
|
|
2916
|
+
`[omp-conductor] arm expiry notice not delivered: ${err instanceof Error ? err.message : String(err)}`,
|
|
2917
|
+
);
|
|
2918
|
+
continue;
|
|
2919
|
+
}
|
|
2920
|
+
clearArmTransaction(expired.key, expired.id);
|
|
2921
|
+
}
|
|
3361
2922
|
const decision = tickDecision({
|
|
3362
2923
|
armed: arm === undefined || arm.armed,
|
|
3363
2924
|
channelOk: live.accessFile === undefined || channelIsUp(live.accessFile),
|
|
@@ -3563,6 +3124,17 @@ async function tick(
|
|
|
3563
3124
|
now - FRICTION_COOLDOWN_MS,
|
|
3564
3125
|
);
|
|
3565
3126
|
if (frictionSignals.length > 0) content = `${content}\n${formatFrictionDigest(frictionSignals)}`;
|
|
3127
|
+
// Policy edits the session made on its own authority, read back before
|
|
3128
|
+
// they reach the operator (Phase 4). Placed directly after the friction
|
|
3129
|
+
// digest and before the decision rows because that is the order of the
|
|
3130
|
+
// Learning loop itself: the evidence that provokes an amendment, then
|
|
3131
|
+
// the mechanical ones already applied and owed a report, then the
|
|
3132
|
+
// judgment ones still waiting on an answer. The scan reaches past one
|
|
3133
|
+
// digest's worth of backlog on purpose — see AMENDMENT_AUDIT_SCAN_LIMIT.
|
|
3134
|
+
const amendments = formatAmendmentAudit(
|
|
3135
|
+
frictionStore.undigestedMaterialEvents(scope.projectName, AMENDMENT_AUDIT_SCAN_LIMIT),
|
|
3136
|
+
);
|
|
3137
|
+
if (amendments !== undefined) content = `${content}\n${amendments}`;
|
|
3566
3138
|
// What the session still owes its operator, read from the ledger rather
|
|
3567
3139
|
// than from what it remembers asking (#136). Appended every tick,
|
|
3568
3140
|
// because the whole failure was a question surviving in context only.
|
|
@@ -3609,16 +3181,13 @@ async function tick(
|
|
|
3609
3181
|
const grooming = store.groomingVerdicts(projectName);
|
|
3610
3182
|
// #848: "the queue is empty / running dry" is the tracker's word, not
|
|
3611
3183
|
// the last dispatch pass's echo. One open-issue snapshot per tick,
|
|
3612
|
-
// read live through the
|
|
3613
|
-
//
|
|
3614
|
-
//
|
|
3615
|
-
//
|
|
3616
|
-
//
|
|
3617
|
-
//
|
|
3618
|
-
//
|
|
3619
|
-
// queues. If the tracker read fails, the digest falls back to
|
|
3620
|
-
// explicitly dated wording (never a present-tense empty claim),
|
|
3621
|
-
// and the offer retries its own read exactly as it did before.
|
|
3184
|
+
// read live through the authoritative tracker surface, overlaid with
|
|
3185
|
+
// the label_ops projection exactly as the dispatch pass judges
|
|
3186
|
+
// eligibility — so a promotion, unblock or projection write after the
|
|
3187
|
+
// last dispatch pass is visible on the very next tick even while a
|
|
3188
|
+
// drain holds claiming. If the tracker read fails, the digest falls
|
|
3189
|
+
// back to explicitly dated wording, never a present-tense empty
|
|
3190
|
+
// claim.
|
|
3622
3191
|
//
|
|
3623
3192
|
// The read is made only for dispatch rows the digest could render:
|
|
3624
3193
|
// a healthy row (routed at/above a numeric grooming threshold)
|
|
@@ -3627,16 +3196,14 @@ async function tick(
|
|
|
3627
3196
|
// below the trigger) is exactly where the old wording lied, so every
|
|
3628
3197
|
// such tick reads the live queue. Under `groomBelow: "always"`
|
|
3629
3198
|
// (#988) the duty never clears on volume, so every dispatch row
|
|
3630
|
-
// reads the queue
|
|
3199
|
+
// reads the queue.
|
|
3631
3200
|
const needsQueueRead =
|
|
3632
3201
|
dispatch !== undefined &&
|
|
3633
3202
|
(dispatch.ready === 0 || dispatch.routed === 0 || groomingDue(dispatch.routed, groomBelow));
|
|
3634
3203
|
let queueObservation: QueueObservation | undefined;
|
|
3635
|
-
let openSnapshot: readonly ReadyIssue[] | undefined;
|
|
3636
3204
|
if (toSpecTrackerSeam !== undefined && needsQueueRead) {
|
|
3637
3205
|
try {
|
|
3638
3206
|
const open = await toSpecTrackerSeam.listOpenIssues(project);
|
|
3639
|
-
openSnapshot = open;
|
|
3640
3207
|
const effective = open.map((issue) => {
|
|
3641
3208
|
const pending = store.pendingLabelOpsFor(projectName, issue.number);
|
|
3642
3209
|
return pending.length === 0
|
|
@@ -3659,57 +3226,29 @@ async function tick(
|
|
|
3659
3226
|
groomBelow,
|
|
3660
3227
|
grooming,
|
|
3661
3228
|
queueObservation,
|
|
3662
|
-
// One clock for the inventory and the
|
|
3663
|
-
// never call a verdict durable that the
|
|
3229
|
+
// One clock for the inventory and the audit below: the digest must
|
|
3230
|
+
// never call a verdict durable that the daemon's own selection is
|
|
3664
3231
|
// about to re-groom (#887).
|
|
3665
3232
|
now,
|
|
3666
3233
|
);
|
|
3667
3234
|
if (queue !== undefined) content = `${content}\n${queue}`;
|
|
3668
|
-
//
|
|
3669
|
-
//
|
|
3670
|
-
// the
|
|
3671
|
-
//
|
|
3672
|
-
//
|
|
3673
|
-
//
|
|
3674
|
-
//
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
// before the reads, so the offer below can only mint for THIS tick.
|
|
3679
|
-
const toSpecActive = store.activeRuns(projectName);
|
|
3680
|
-
const launch = await offerToSpecLaunch({
|
|
3681
|
-
summary: dispatch,
|
|
3682
|
-
groomBelow,
|
|
3235
|
+
// The Duty-2 audit that replaced #777's launch block (#1041/#1040).
|
|
3236
|
+
// The daemon promotes unattended, so what the tick owes is review,
|
|
3237
|
+
// and the window is two intervals wide for the same reason the
|
|
3238
|
+
// recovery digest's is: a tick that ran long must not drop the
|
|
3239
|
+
// promotions it was meant to audit. A tick that reports nothing
|
|
3240
|
+
// renders nothing — the store, not this call site, decides whether
|
|
3241
|
+
// there is anything to say.
|
|
3242
|
+
const auditSince = now - 2 * config.intervalSeconds * 1_000;
|
|
3243
|
+
const audit = formatPromotionAudit({
|
|
3244
|
+
promotions: store.promotionsSince(projectName, auditSince),
|
|
3683
3245
|
grooming,
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
project,
|
|
3687
|
-
|
|
3688
|
-
now,
|
|
3246
|
+
project: project.name,
|
|
3247
|
+
tracker: project.tracker.repo,
|
|
3248
|
+
queueLabel: project.queueLabel,
|
|
3249
|
+
since: auditSince,
|
|
3689
3250
|
});
|
|
3690
|
-
if (
|
|
3691
|
-
content = `${content}\n${launch.block}`;
|
|
3692
|
-
// A minted token authorizes exactly the batch this tick's block
|
|
3693
|
-
// describes: the project and the allowlist are captured so the
|
|
3694
|
-
// tool_call gate records in-flight rows against the same project
|
|
3695
|
-
// the prompt named and refuses any item off the list.
|
|
3696
|
-
session.launchProject = project.name;
|
|
3697
|
-
session.launchToken = launch.token;
|
|
3698
|
-
session.launchItems = launch.items;
|
|
3699
|
-
} else if (openSnapshot !== undefined) {
|
|
3700
|
-
// A low queue with nothing eligible is a finding, not silence: the
|
|
3701
|
-
// snapshot read succeeded, so the exclusions — and only they — are
|
|
3702
|
-
// why no batch is offered. Rendered only when the read succeeded,
|
|
3703
|
-
// so a tracker failure never masquerades as "nothing eligible".
|
|
3704
|
-
const noBatch = toSpecNoBatchLine({
|
|
3705
|
-
summary: dispatch,
|
|
3706
|
-
groomBelow,
|
|
3707
|
-
grooming,
|
|
3708
|
-
active: toSpecActive,
|
|
3709
|
-
now,
|
|
3710
|
-
});
|
|
3711
|
-
if (noBatch !== undefined) content = `${content}\n${noBatch}`;
|
|
3712
|
-
}
|
|
3251
|
+
if (audit !== undefined) content = `${content}\n${audit}`;
|
|
3713
3252
|
// Pending intake is the same class of standing block as the friction
|
|
3714
3253
|
// and decisions read-outs: a store-backed duty the orchestrator must
|
|
3715
3254
|
// not derive from memory. The store answers, the prompt instructs.
|
|
@@ -3720,7 +3259,8 @@ async function tick(
|
|
|
3720
3259
|
});
|
|
3721
3260
|
if (pendingIntake !== undefined) content = `${content}\n${pendingIntake}`;
|
|
3722
3261
|
} catch {
|
|
3723
|
-
// unreadable config: no queue digest,
|
|
3262
|
+
// unreadable config: no queue digest, promotion audit, or
|
|
3263
|
+
// pending-intake block this tick
|
|
3724
3264
|
}
|
|
3725
3265
|
} catch (err) {
|
|
3726
3266
|
frictionStore?.close();
|
|
@@ -3887,11 +3427,12 @@ function armTickHeartbeat(
|
|
|
3887
3427
|
config: TickConfig,
|
|
3888
3428
|
session: TickSession,
|
|
3889
3429
|
toSpecTrackerSeam: ToSpecTrackerSeam | undefined,
|
|
3430
|
+
armSeam: ArmConsumerSeam | undefined,
|
|
3890
3431
|
): void {
|
|
3891
3432
|
writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
|
|
3892
3433
|
const runScheduledTick = async (): Promise<void> => {
|
|
3893
3434
|
try {
|
|
3894
|
-
await tick(pi, ctx, config, session, toSpecTrackerSeam);
|
|
3435
|
+
await tick(pi, ctx, config, session, toSpecTrackerSeam, armSeam);
|
|
3895
3436
|
} finally {
|
|
3896
3437
|
writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
|
|
3897
3438
|
}
|
|
@@ -3947,7 +3488,7 @@ function armTickHeartbeat(
|
|
|
3947
3488
|
// call runs inside the session_start handler dispatch, which cannot see an
|
|
3948
3489
|
// async rejection — an escaped one reaches the process-level
|
|
3949
3490
|
// unhandledRejection handler and takes the session down.
|
|
3950
|
-
void tick(pi, ctx, config, session, toSpecTrackerSeam).catch((err) => {
|
|
3491
|
+
void tick(pi, ctx, config, session, toSpecTrackerSeam, armSeam).catch((err) => {
|
|
3951
3492
|
pi.logger.error(
|
|
3952
3493
|
`[omp-conductor] arm-time tick failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
3953
3494
|
);
|
|
@@ -3992,325 +3533,229 @@ function armTickGuard(pi: TickApi, ctx: TickContext, budgetMs: number): void {
|
|
|
3992
3533
|
}
|
|
3993
3534
|
|
|
3994
3535
|
/**
|
|
3995
|
-
*
|
|
3996
|
-
*
|
|
3997
|
-
*
|
|
3998
|
-
*
|
|
3999
|
-
*
|
|
4000
|
-
*
|
|
4001
|
-
* each final result later as an async-result message, which the orchestrator
|
|
4002
|
-
* routes through {@link TO_SPEC_RESULT_TOOL}.
|
|
3536
|
+
* Factory-time seams for the extension, used by tests. Production runs
|
|
3537
|
+
* `orchestratorTickExtension(pi)` with no options and gets the module's own
|
|
3538
|
+
* real-time defaults; the `ask` seam hands the registered {@link ASK_TOOL} an
|
|
3539
|
+
* injected clock (`now`) so the filed row's timestamp is deterministic. There
|
|
3540
|
+
* is no wait seam any more: the ask does not wait (#438 as re-cut for the
|
|
3541
|
+
* console split), so there is no timer for a test to control.
|
|
4003
3542
|
*/
|
|
4004
|
-
interface
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
3543
|
+
export interface OrchestratorTickExtensionOptions {
|
|
3544
|
+
ask?: {
|
|
3545
|
+
now?: () => number;
|
|
3546
|
+
};
|
|
3547
|
+
/**
|
|
3548
|
+
* The authoritative tracker surface the queue digest reads (#848).
|
|
3549
|
+
* Production omits it and the extension builds the real seam over the
|
|
3550
|
+
* existing Tracker adapter for each project; tests inject deterministic
|
|
3551
|
+
* fakes so the digest's live inventory is provable without a network. An
|
|
3552
|
+
* unreadable snapshot leaves the digest dated rather than claiming a
|
|
3553
|
+
* present-tense empty queue.
|
|
3554
|
+
*/
|
|
3555
|
+
toSpec?: { tracker?: ToSpecTrackerSeam };
|
|
3556
|
+
/**
|
|
3557
|
+
* The arm ceremony's mechanical consumer seams (#1061). Production omits
|
|
3558
|
+
* them and the extension classifies inbound turns against the pending
|
|
3559
|
+
* challenge records itself, sending confirmations and expiry notices over
|
|
3560
|
+
* the same bridge transport `armTicks` sends the challenge on. Tests inject
|
|
3561
|
+
* a recorder (`notify`) and a fixed clock (`now`) so the settle and the
|
|
3562
|
+
* notices are provable without Telegram or timers.
|
|
3563
|
+
*/
|
|
3564
|
+
arm?: {
|
|
3565
|
+
now?: () => number;
|
|
3566
|
+
notify?: ArmNoticeFn;
|
|
3567
|
+
};
|
|
4009
3568
|
}
|
|
4010
3569
|
|
|
4011
|
-
/**
|
|
4012
|
-
interface
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
/** The agent's raw output — the exact text that must reach the parser. */
|
|
4016
|
-
output?: unknown;
|
|
4017
|
-
/** The harness's parsed structured output, when one exists. */
|
|
4018
|
-
structuredOutput?: unknown;
|
|
3570
|
+
/** The send surface an arm notice rides: the live tick config's channel and project. */
|
|
3571
|
+
export interface ArmNoticeSurface {
|
|
3572
|
+
accessFile?: string;
|
|
3573
|
+
project?: string;
|
|
4019
3574
|
}
|
|
4020
3575
|
|
|
4021
3576
|
/**
|
|
4022
|
-
*
|
|
4023
|
-
*
|
|
4024
|
-
*
|
|
4025
|
-
* `tool_call` on `task`:
|
|
4026
|
-
* - a call without the {@link TO_SPEC_BATCH_MARKER} in `context` is the
|
|
4027
|
-
* orchestrator's own task use and passes untouched;
|
|
4028
|
-
* - a marker-bearing call is the conductor batch and is gated hard: it needs
|
|
4029
|
-
* this tick's exact token, the batch shape (1..{@link TO_SPEC_BATCH_MAX}
|
|
4030
|
-
* items, each starting with the two contract lines), unique items and
|
|
4031
|
-
* names, every item ON the tick's mechanically selected allowlist (issue
|
|
4032
|
-
* and routing — the parked/parent/epic exclusions are baked into that
|
|
4033
|
-
* list, so a forbidden candidate can never be stamped in-flight), and
|
|
4034
|
-
* store-side eligibility — the same {@link toSpecCandidateExclusion} rule
|
|
4035
|
-
* the prompt block's exclusion lines came from, so an item excluded in
|
|
4036
|
-
* prose is excluded in the gate for the same reason;
|
|
4037
|
-
* - on acceptance it stamps every item's `agent`, `outputSchema` and
|
|
4038
|
-
* `schemaMode` (the model never carries the schema itself), records the
|
|
4039
|
-
* durable in-flight rows through the #735 grooming table, and latches the
|
|
4040
|
-
* session so a second batch this tick refuses.
|
|
4041
|
-
*
|
|
4042
|
-
* `tool_result` on the accepted call captures a *settled* batch: every item
|
|
4043
|
-
* is routed through {@link recordToSpecGrooming} independently, so a
|
|
4044
|
-
* malformed or failed item persists `blocked` and its siblings survive. A
|
|
4045
|
-
* background launch settles with no results here — its completed items arrive
|
|
4046
|
-
* as async-result messages and are persisted through
|
|
4047
|
-
* {@link TO_SPEC_RESULT_TOOL}.
|
|
3577
|
+
* One mechanical Telegram send the arm consumer raises: the confirmation when
|
|
3578
|
+
* a reply settles the ceremony, or the expiry notice when a challenge dies
|
|
3579
|
+
* unanswered. The default is {@link sendArmNotice}; tests record instead.
|
|
4048
3580
|
*/
|
|
4049
|
-
|
|
4050
|
-
const api = pi as TickApi & {
|
|
4051
|
-
on(
|
|
4052
|
-
event: "tool_call",
|
|
4053
|
-
handler: (
|
|
4054
|
-
event: { toolName: string; toolCallId: string; input: Record<string, unknown> },
|
|
4055
|
-
ctx: unknown,
|
|
4056
|
-
) => { block: true; reason: string } | { input: Record<string, unknown> } | undefined,
|
|
4057
|
-
): void;
|
|
4058
|
-
on(event: "tool_result", handler: (event: ToSpecTaskToolEvent, ctx: unknown) => void): void;
|
|
4059
|
-
};
|
|
3581
|
+
export type ArmNoticeFn = (text: string, surface: ArmNoticeSurface) => Promise<void> | void;
|
|
4060
3582
|
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
items.push(item);
|
|
4125
|
-
}
|
|
4126
|
-
for (let i = 1; i < items.length; i += 1) {
|
|
4127
|
-
for (let j = 0; j < i; j += 1) {
|
|
4128
|
-
if (items[j]!.issue === items[i]!.issue) {
|
|
4129
|
-
return {
|
|
4130
|
-
block: true,
|
|
4131
|
-
reason: `task refused: #${items[i]!.issue} appears twice in one batch.`,
|
|
4132
|
-
};
|
|
4133
|
-
}
|
|
4134
|
-
}
|
|
4135
|
-
}
|
|
4136
|
-
// The allowlist is the mechanical selection this tick's token authorized:
|
|
4137
|
-
// every item's issue number AND routing must match an entry the launch
|
|
4138
|
-
// offer produced from the authoritative tracker snapshot. Parked,
|
|
4139
|
-
// parent/epic, already-groomed, in-flight, held and dispatched candidates
|
|
4140
|
-
// were never on it, so no item carrying one can be stamped in-flight here
|
|
4141
|
-
// — the model cannot self-select past the conductor's selection (#805).
|
|
4142
|
-
const allowlist = session.launchItems;
|
|
4143
|
-
if (allowlist === undefined) {
|
|
4144
|
-
return {
|
|
4145
|
-
block: true,
|
|
4146
|
-
reason:
|
|
4147
|
-
"task refused: this tick's launch did not carry a mechanically selected batch — " +
|
|
4148
|
-
"re-tick before launching.",
|
|
4149
|
-
};
|
|
4150
|
-
}
|
|
4151
|
-
for (const item of items) {
|
|
4152
|
-
if (!allowlist.some((allowed) => allowed.issue === item.issue && allowed.routing === item.routing)) {
|
|
4153
|
-
const listed = allowlist.map((allowed) => `#${allowed.issue} (${allowed.routing})`).join(", ");
|
|
4154
|
-
return {
|
|
4155
|
-
block: true,
|
|
4156
|
-
reason:
|
|
4157
|
-
`task refused: #${item.issue} is not on this tick's mechanically selected to-spec batch ` +
|
|
4158
|
-
`(${listed}). Launch exactly the selected candidates, no substitutes.`,
|
|
4159
|
-
};
|
|
4160
|
-
}
|
|
4161
|
-
}
|
|
4162
|
-
const projectName = session.launchProject;
|
|
4163
|
-
if (projectName === undefined) {
|
|
4164
|
-
return {
|
|
4165
|
-
block: true,
|
|
4166
|
-
reason: "task refused: the batch was authorized without a project — re-tick before launching.",
|
|
4167
|
-
};
|
|
4168
|
-
}
|
|
4169
|
-
// Store-side eligibility, the same rule the prompt block's exclusion list
|
|
4170
|
-
// came from. Eligible items then become durable in-flight rows, so a
|
|
4171
|
-
// crashed batch still suppresses re-launch until the TTL expires.
|
|
4172
|
-
let store: Store | undefined;
|
|
3583
|
+
/** The consumer's injectable clock and transport, carried into the tick's helpers. */
|
|
3584
|
+
export interface ArmConsumerSeam {
|
|
3585
|
+
now?: () => number;
|
|
3586
|
+
notify?: ArmNoticeFn;
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
/**
|
|
3590
|
+
* The plain text of an inbound message, for the arm classifier. Content blocks
|
|
3591
|
+
* join with newlines so a code split across block boundaries still tokenises
|
|
3592
|
+
* whole; non-text blocks contribute nothing. `undefined` for an absent or
|
|
3593
|
+
* empty message, which is ordinary chat either way.
|
|
3594
|
+
*/
|
|
3595
|
+
function inboundMessageText(content: readonly { type?: string; text?: string }[] | undefined): string | undefined {
|
|
3596
|
+
if (content === undefined || content.length === 0) return undefined;
|
|
3597
|
+
const text = content.map((block) => block.text ?? "").join("\n");
|
|
3598
|
+
return text.length === 0 ? undefined : text;
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
/**
|
|
3602
|
+
* The ready-to-send expiry notice: the reason — never answered, or answered
|
|
3603
|
+
* but never settled — and the exact command that re-runs the ceremony. The
|
|
3604
|
+
* code is the proof and is never named in a notice; only the window is.
|
|
3605
|
+
*/
|
|
3606
|
+
function armExpiryNotice(expired: { key: string; sighting: ArmChallengeSighting }, project: string | undefined): string {
|
|
3607
|
+
const reissue =
|
|
3608
|
+
expired.key === FLEET_ARM_KEY || project === undefined || project === ""
|
|
3609
|
+
? "omp-conductor arm"
|
|
3610
|
+
: `omp-conductor arm --project ${project}`;
|
|
3611
|
+
const sentAt =
|
|
3612
|
+
expired.sighting.sentAt === undefined
|
|
3613
|
+
? ""
|
|
3614
|
+
: ` sent ${new Date(expired.sighting.sentAt).toISOString()}`;
|
|
3615
|
+
const reason =
|
|
3616
|
+
expired.sighting.acknowledgedAt === undefined
|
|
3617
|
+
? `The arming challenge${sentAt} expired without being answered — nothing is armed.`
|
|
3618
|
+
: `The arming challenge${sentAt} was answered but never settled before its window closed — nothing is armed.`;
|
|
3619
|
+
return `${reason} Send a fresh one: ${reissue}`;
|
|
3620
|
+
}
|
|
3621
|
+
|
|
3622
|
+
/**
|
|
3623
|
+
* The default transport for the arm consumer's confirmation and expiry
|
|
3624
|
+
* notices: the same bot token, paired channel and project topic `armTicks`
|
|
3625
|
+
* sends the challenge on, so the notice lands on the surface the operator was
|
|
3626
|
+
* asked in. Deliberately not availability-gated — the ceremony is the
|
|
3627
|
+
* operator's own interaction, exactly like the challenge send itself, and a
|
|
3628
|
+
* notice held for quiet hours would be the silent failure #1061 removes. Any
|
|
3629
|
+
* missing or down fact throws; the caller logs and keeps the challenge record
|
|
3630
|
+
* so the next heartbeat retries and `doctor` still reports it.
|
|
3631
|
+
*/
|
|
3632
|
+
export async function sendArmNotice(text: string, surface: ArmNoticeSurface): Promise<void> {
|
|
3633
|
+
if (surface.accessFile === undefined) {
|
|
3634
|
+
throw new Error("no accessFile — the arm notice has no channel");
|
|
3635
|
+
}
|
|
3636
|
+
const channel = readTelegramChannel(surface.accessFile);
|
|
3637
|
+
if (channel.kind === "down") {
|
|
3638
|
+
throw new Error(`escalation channel is not up (${surface.accessFile}): ${channel.reason}`);
|
|
3639
|
+
}
|
|
3640
|
+
const token = readTelegramToken();
|
|
3641
|
+
if (token === undefined) {
|
|
3642
|
+
throw new Error("no Telegram bot token readable — the arm notice cannot send");
|
|
3643
|
+
}
|
|
3644
|
+
let topicId: number | undefined;
|
|
3645
|
+
if (surface.project !== undefined) {
|
|
4173
3646
|
try {
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
);
|
|
4178
|
-
const active = new Set(store.activeRuns(projectName).map((run) => run.issue));
|
|
4179
|
-
const now = Date.now();
|
|
4180
|
-
for (const item of items) {
|
|
4181
|
-
const reason = toSpecCandidateExclusion(
|
|
4182
|
-
{ issue: item.issue },
|
|
4183
|
-
{ grooming: byIssue.get(item.issue), active: active.has(item.issue) },
|
|
4184
|
-
now,
|
|
4185
|
-
);
|
|
4186
|
-
if (reason !== undefined) {
|
|
4187
|
-
return {
|
|
4188
|
-
block: true,
|
|
4189
|
-
reason: `task refused: ${reason}. Drop that item (and every other listed exclusion) from this batch and re-call.`,
|
|
4190
|
-
};
|
|
4191
|
-
}
|
|
4192
|
-
}
|
|
4193
|
-
const launchedEvidence = JSON.stringify({
|
|
4194
|
-
kind: "to-spec-in-flight",
|
|
4195
|
-
launchedAt: now,
|
|
4196
|
-
batch: token,
|
|
4197
|
-
agent: TO_SPEC_AGENT,
|
|
4198
|
-
});
|
|
4199
|
-
for (const item of items) {
|
|
4200
|
-
store.upsertGrooming({
|
|
4201
|
-
project: projectName,
|
|
4202
|
-
issue: item.issue,
|
|
4203
|
-
verdict: "blocked",
|
|
4204
|
-
reason: TO_SPEC_IN_FLIGHT_REASON,
|
|
4205
|
-
evidence: launchedEvidence,
|
|
4206
|
-
at: now,
|
|
4207
|
-
});
|
|
4208
|
-
}
|
|
4209
|
-
} catch (err) {
|
|
4210
|
-
pi.logger.error(
|
|
4211
|
-
`[omp-conductor] to-spec launch not recorded: ${err instanceof Error ? err.message : String(err)}`,
|
|
4212
|
-
);
|
|
4213
|
-
return {
|
|
4214
|
-
block: true,
|
|
4215
|
-
reason: `task refused: the launch could not be recorded durably (${
|
|
4216
|
-
err instanceof Error ? err.message : String(err)
|
|
4217
|
-
}); nothing was started.`,
|
|
4218
|
-
};
|
|
4219
|
-
} finally {
|
|
4220
|
-
store?.close();
|
|
3647
|
+
topicId = resolveProjectTopicId(findProject(loadConfig(), surface.project));
|
|
3648
|
+
} catch {
|
|
3649
|
+
/* no project config — flat chat, exactly like armTicks */
|
|
4221
3650
|
}
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
3651
|
+
}
|
|
3652
|
+
await sendTelegram(token, channel.owner, text, { topicId });
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
/**
|
|
3656
|
+
* The settle half of the mechanical consumer: write the markers the matched
|
|
3657
|
+
* challenge recorded and clear its transaction, then confirm on the
|
|
3658
|
+
* challenge's surface. The recorded targets are the contract — the exact rule
|
|
3659
|
+
* `armReply` applies — so nothing is ever armed that the challenge did not
|
|
3660
|
+
* name. A pre-targets record (one release of overlap) is not settled here:
|
|
3661
|
+
* nothing in-session may guess a marker the challenge never recorded; the CLI
|
|
3662
|
+
* half still settles it, and the operator is told so.
|
|
3663
|
+
*/
|
|
3664
|
+
function settleInboundArmMatch(
|
|
3665
|
+
pi: TickApi,
|
|
3666
|
+
match: ArmReplyMatch,
|
|
3667
|
+
notify: (text: string) => void,
|
|
3668
|
+
): void {
|
|
3669
|
+
if (match.targets === undefined || match.owner === undefined) {
|
|
3670
|
+
pi.logger.error(
|
|
3671
|
+
"[omp-conductor] an inbound arming code matched a pre-targets challenge — nothing armed in-session; " +
|
|
3672
|
+
'run `omp-conductor arm --reply "<the reply>"` to settle it',
|
|
4231
3673
|
);
|
|
4232
|
-
return
|
|
4233
|
-
}
|
|
3674
|
+
return;
|
|
3675
|
+
}
|
|
3676
|
+
for (const target of match.targets) {
|
|
3677
|
+
const state = resolveArmState(target.armedFile, target.project);
|
|
3678
|
+
writeArmedMarker(target.armedFile, match.owner, state);
|
|
3679
|
+
}
|
|
3680
|
+
clearArmTransaction(match.key, match.id);
|
|
3681
|
+
const label =
|
|
3682
|
+
match.targets.length === 1 && match.targets[0]!.project !== undefined
|
|
3683
|
+
? `project ${match.targets[0]!.project}`
|
|
3684
|
+
: `${match.targets.length} project(s)`;
|
|
3685
|
+
notify(`Arming verified — ${label} armed. The reply was consumed; nothing else to do.`);
|
|
3686
|
+
}
|
|
4234
3687
|
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
3688
|
+
/**
|
|
3689
|
+
* The arm ceremony's mechanical consumer (#1061): classify one inbound user
|
|
3690
|
+
* turn against this host's pending challenges and, on a match, settle the
|
|
3691
|
+
* ceremony exactly as `omp-conductor arm --reply` would — markers for the
|
|
3692
|
+
* recorded targets, transaction cleared, a confirmation on the surface the
|
|
3693
|
+
* challenge was sent to. This is the real owner the ceremony needed on a host
|
|
3694
|
+
* with no console session: the challenge goes to the project topic, which is
|
|
3695
|
+
* this pane's own claimed surface, so the reply lands here and the ceremony
|
|
3696
|
+
* completes with no second human command. A code past its window is answered
|
|
3697
|
+
* with the re-run command and the dead record is cleared; a lookalike matching
|
|
3698
|
+
* nothing stays inert (#415's non-disclosure bound is unchanged).
|
|
3699
|
+
*
|
|
3700
|
+
* Never throws: an inbound message must not take the session down. Failures
|
|
3701
|
+
* are logged and the CLI half of the ceremony stays available.
|
|
3702
|
+
*/
|
|
3703
|
+
function consumeInboundArmReply(
|
|
3704
|
+
pi: TickApi,
|
|
3705
|
+
cwd: string,
|
|
3706
|
+
startupProject: string | undefined,
|
|
3707
|
+
text: string,
|
|
3708
|
+
seam: ArmConsumerSeam | undefined,
|
|
3709
|
+
): void {
|
|
3710
|
+
// The state key the send half derived from the same file, read fresh so a
|
|
3711
|
+
// re-stamp between the send and the reply cannot split the ceremony. An
|
|
3712
|
+
// invalid config cannot be trusted to name either the key or a marker —
|
|
3713
|
+
// fail closed exactly like `armReply`, but without throwing at a message.
|
|
3714
|
+
const reread = readTickConfig(cwd);
|
|
3715
|
+
if (reread.kind === "invalid") {
|
|
3716
|
+
pi.logger.error(
|
|
3717
|
+
`[omp-conductor] inbound arm reply not classified — tick config invalid at ${reread.path}: ` +
|
|
3718
|
+
`${reread.problem}; use \`omp-conductor arm --reply\` on the host`,
|
|
3719
|
+
);
|
|
3720
|
+
return;
|
|
3721
|
+
}
|
|
3722
|
+
const project = reread.kind === "ok" ? reread.config.project : startupProject;
|
|
3723
|
+
const surface: ArmNoticeSurface = {
|
|
3724
|
+
...(reread.kind === "ok" ? { accessFile: reread.config.accessFile } : {}),
|
|
3725
|
+
project,
|
|
3726
|
+
};
|
|
3727
|
+
const now = (seam?.now ?? Date.now)();
|
|
3728
|
+
const resolved = resolveArmReply(project, text, now);
|
|
3729
|
+
const notify = (notice: string): void => {
|
|
3730
|
+
const send = seam?.notify ?? sendArmNotice;
|
|
4250
3731
|
try {
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
const raw = typeof settled.output === "string" ? settled.output : "";
|
|
4260
|
-
const structured = settled.structuredOutput;
|
|
4261
|
-
const fallback =
|
|
4262
|
-
structured !== null &&
|
|
4263
|
-
typeof structured === "object" &&
|
|
4264
|
-
typeof (structured as { data?: unknown }).data === "object"
|
|
4265
|
-
? JSON.stringify((structured as { data?: unknown }).data)
|
|
4266
|
-
: "";
|
|
4267
|
-
try {
|
|
4268
|
-
recordToSpecGrooming(store, {
|
|
4269
|
-
project: projectName,
|
|
4270
|
-
issue: item.issue,
|
|
4271
|
-
input: raw.trim().length > 0 ? raw : fallback,
|
|
4272
|
-
});
|
|
4273
|
-
} catch (err) {
|
|
4274
|
-
pi.logger.error(
|
|
4275
|
-
`[omp-conductor] to-spec result not persisted for #${item.issue}: ${
|
|
4276
|
-
err instanceof Error ? err.message : String(err)
|
|
4277
|
-
}`,
|
|
4278
|
-
);
|
|
4279
|
-
}
|
|
4280
|
-
}
|
|
3732
|
+
// The send is async on the production path; message_start handlers are
|
|
3733
|
+
// not, so delivery rides the microtask queue and its failure lands in
|
|
3734
|
+
// the log, never on the session.
|
|
3735
|
+
void Promise.resolve(send(notice, surface)).catch((err: unknown) => {
|
|
3736
|
+
pi.logger.error(
|
|
3737
|
+
`[omp-conductor] arm notice not delivered: ${err instanceof Error ? err.message : String(err)}`,
|
|
3738
|
+
);
|
|
3739
|
+
});
|
|
4281
3740
|
} catch (err) {
|
|
4282
3741
|
pi.logger.error(
|
|
4283
|
-
`[omp-conductor]
|
|
3742
|
+
`[omp-conductor] arm notice not delivered: ${err instanceof Error ? err.message : String(err)}`,
|
|
4284
3743
|
);
|
|
4285
|
-
} finally {
|
|
4286
|
-
store?.close();
|
|
4287
3744
|
}
|
|
4288
|
-
});
|
|
4289
|
-
}
|
|
4290
|
-
|
|
4291
|
-
/**
|
|
4292
|
-
* Factory-time seams for the extension, used by tests. Production runs
|
|
4293
|
-
* `orchestratorTickExtension(pi)` with no options and gets the module's own
|
|
4294
|
-
* real-time defaults; the `ask` seam hands the registered {@link ASK_TOOL} an
|
|
4295
|
-
* injected clock (`wait`/`now`) so the two timeout outcomes can be proven
|
|
4296
|
-
* through the real tool deterministically, without real timers (#683).
|
|
4297
|
-
*/
|
|
4298
|
-
export interface OrchestratorTickExtensionOptions {
|
|
4299
|
-
ask?: {
|
|
4300
|
-
wait?: (ms: number) => Promise<void>;
|
|
4301
|
-
now?: () => number;
|
|
4302
|
-
/** Injected interactive delivery surface (#722); production builds its own. */
|
|
4303
|
-
interactive?: AskInteractiveDelivery;
|
|
4304
3745
|
};
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
3746
|
+
if (resolved.verdict === "matched") {
|
|
3747
|
+
settleInboundArmMatch(pi, resolved.match, notify);
|
|
3748
|
+
return;
|
|
3749
|
+
}
|
|
3750
|
+
// A code past its window: the ceremony is dead, so answer the person who is
|
|
3751
|
+
// clearly trying to complete it — and clear the expired record so neither
|
|
3752
|
+
// this path nor the heartbeat ever notifies twice.
|
|
3753
|
+
if (resolved.verdict === "expired") {
|
|
3754
|
+
for (const expired of expiredArmChallenges(project, now)) {
|
|
3755
|
+
notify(armExpiryNotice(expired, project));
|
|
3756
|
+
clearArmTransaction(expired.key, expired.id);
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
4314
3759
|
}
|
|
4315
3760
|
|
|
4316
3761
|
export default function orchestratorTickExtension(
|
|
@@ -4337,17 +3782,13 @@ export default function orchestratorTickExtension(
|
|
|
4337
3782
|
let releaseGateArmed = false;
|
|
4338
3783
|
let availabilityGateArmed = false;
|
|
4339
3784
|
let guardArmed = false;
|
|
4340
|
-
//
|
|
4341
|
-
//
|
|
4342
|
-
//
|
|
4343
|
-
//
|
|
4344
|
-
|
|
4345
|
-
//
|
|
4346
|
-
//
|
|
4347
|
-
// adapter (one tracker per project read, per low-queue tick); tests inject a
|
|
4348
|
-
// deterministic fake. The seam is the ONLY tracker surface the launch path
|
|
4349
|
-
// touches — the tick never mutates tracker state and never lets the model
|
|
4350
|
-
// self-select candidates.
|
|
3785
|
+
// The authoritative tracker seam the queue digest reads (#848): production
|
|
3786
|
+
// runs without options and builds the real seam lazily over the existing
|
|
3787
|
+
// Tracker adapter (one tracker read per low-queue tick); tests inject a
|
|
3788
|
+
// deterministic fake. Read-only — the tick never mutates tracker state.
|
|
3789
|
+
// Grooming itself no longer launches from here: the daemon owns the
|
|
3790
|
+
// selection and the launch (#1041), so there is no launch token to mint and
|
|
3791
|
+
// no `task` gate to arm (#1040).
|
|
4351
3792
|
const toSpecTrackerSeam: ToSpecTrackerSeam | undefined =
|
|
4352
3793
|
options.toSpec?.tracker ?? {
|
|
4353
3794
|
listOpenIssues: (project) => makeTracker(project).listOpenIssues(),
|
|
@@ -4453,8 +3894,10 @@ export default function orchestratorTickExtension(
|
|
|
4453
3894
|
|
|
4454
3895
|
/** `configuredProject` carries {@link TickConfig.project} for the same reason
|
|
4455
3896
|
* {@link armReleaseGate} takes it: a recovered tick must reconstruct *this*
|
|
4456
|
-
* fleet's availability policy, not refuse to guess between two projects.
|
|
4457
|
-
|
|
3897
|
+
* fleet's availability policy, not refuse to guess between two projects.
|
|
3898
|
+
* `cwd` is the fleet directory itself, which the arm consumer re-reads the
|
|
3899
|
+
* tick config from at reply time (#1061). */
|
|
3900
|
+
const armAvailabilityGate = (configuredProject?: string, cwd?: string): void => {
|
|
4458
3901
|
if (availabilityGateArmed) return;
|
|
4459
3902
|
availabilityGateArmed = true;
|
|
4460
3903
|
|
|
@@ -4486,56 +3929,32 @@ export default function orchestratorTickExtension(
|
|
|
4486
3929
|
}
|
|
4487
3930
|
return;
|
|
4488
3931
|
}
|
|
4489
|
-
//
|
|
4490
|
-
//
|
|
4491
|
-
//
|
|
4492
|
-
//
|
|
4493
|
-
//
|
|
4494
|
-
//
|
|
4495
|
-
//
|
|
4496
|
-
//
|
|
4497
|
-
//
|
|
4498
|
-
//
|
|
4499
|
-
//
|
|
4500
|
-
//
|
|
3932
|
+
// An inbound user turn on THIS session is an anomaly, not a
|
|
3933
|
+
// conversation: operator DMs belong to the 24/7 console session, so a
|
|
3934
|
+
// turn arriving here is an accidental topic post. Two things happen,
|
|
3935
|
+
// and both are mechanisms — nothing here is left to model behaviour:
|
|
3936
|
+
//
|
|
3937
|
+
// 1. The arm ceremony's reply is classified against the pending
|
|
3938
|
+
// challenge records (#1061). The pre-#415 tombstone is retired: it
|
|
3939
|
+
// reasoned the reply lands "where no tick extension runs", but the
|
|
3940
|
+
// send half delivers the challenge to the project topic — this
|
|
3941
|
+
// pane's own claimed surface — so the reply lands exactly here, and
|
|
3942
|
+
// on a host with no console session the CLI's `arm --reply` step had
|
|
3943
|
+
// no owner at all. A matching code settles the ceremony
|
|
3944
|
+
// (markers written, transaction cleared) and answers with a
|
|
3945
|
+
// confirmation; a code past its window is answered with the re-run
|
|
3946
|
+
// command; a lookalike matching nothing stays inert, exactly as
|
|
3947
|
+
// #415 required.
|
|
3948
|
+
// 2. {@link tickGuardDecision}'s preemption, unchanged: a person who
|
|
3949
|
+
// posts into the fleet topic by accident is still a person waiting,
|
|
3950
|
+
// and the autonomous-interrupt refusal must not fire at them.
|
|
4501
3951
|
if (message.role === "user" && message.synthetic !== true && message.attribution !== "agent") {
|
|
4502
|
-
|
|
4503
|
-
message.content
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
.join(" ") ?? "";
|
|
4507
|
-
const verdict = classifyArmReply(configuredProject, replyText, Date.now());
|
|
4508
|
-
const proof = verdict === "matched";
|
|
3952
|
+
if (cwd !== undefined) {
|
|
3953
|
+
const text = inboundMessageText(message.content);
|
|
3954
|
+
if (text !== undefined) consumeInboundArmReply(pi, cwd, configuredProject, text, options.arm);
|
|
3955
|
+
}
|
|
4509
3956
|
if (session.activeLocalTick !== undefined) {
|
|
4510
3957
|
session.activeLocalTick.humanWaiting = true;
|
|
4511
|
-
if (proof) session.activeLocalTick.armingProof = true;
|
|
4512
|
-
}
|
|
4513
|
-
// Acknowledged whether or not a local tick is running: an inactivity-
|
|
4514
|
-
// window challenge reply still deserves deterministic handling, and the
|
|
4515
|
-
// host-side `arm` owns completion either way.
|
|
4516
|
-
if (proof) {
|
|
4517
|
-
pi.sendMessage(
|
|
4518
|
-
{ customType: ARM_PROOF_CUSTOM_TYPE, content: ARM_PROOF_ACK_TEXT, display: true, attribution: "agent" },
|
|
4519
|
-
{ triggerTurn: true, deliverAs: "steer" },
|
|
4520
|
-
);
|
|
4521
|
-
} else if (verdict !== "none") {
|
|
4522
|
-
// A code that matched nothing used to be silent: the waiter kept
|
|
4523
|
-
// waiting and the operator had no idea whether they had been heard,
|
|
4524
|
-
// so the fastest way to be sure of the current code was to scroll
|
|
4525
|
-
// (#991). The answer never echoes the token — that would put the
|
|
4526
|
-
// plaintext in the chat and in any log that captures outbound
|
|
4527
|
-
// messages, defeating the hash-only storage on purpose — and it never
|
|
4528
|
-
// says whether some other project has a live challenge, because
|
|
4529
|
-
// `classifyArmReply` reads only this session's own records.
|
|
4530
|
-
pi.sendMessage(
|
|
4531
|
-
{
|
|
4532
|
-
customType: ARM_REPLY_CUSTOM_TYPE,
|
|
4533
|
-
content: verdict === "expired" ? ARM_EXPIRED_REPLY_TEXT : ARM_UNKNOWN_REPLY_TEXT,
|
|
4534
|
-
display: true,
|
|
4535
|
-
attribution: "agent",
|
|
4536
|
-
},
|
|
4537
|
-
{ triggerTurn: true, deliverAs: "steer" },
|
|
4538
|
-
);
|
|
4539
3958
|
}
|
|
4540
3959
|
}
|
|
4541
3960
|
});
|
|
@@ -4560,7 +3979,7 @@ export default function orchestratorTickExtension(
|
|
|
4560
3979
|
);
|
|
4561
3980
|
};
|
|
4562
3981
|
|
|
4563
|
-
// Mount the
|
|
3982
|
+
// Mount the durable ask surface (#438) at extension-factory time, not in
|
|
4564
3983
|
// `session_start`: OMP 17.2.9 snapshots the extension's active tool set
|
|
4565
3984
|
// before it emits `session_start`, so a tool registered there mutates the
|
|
4566
3985
|
// registry but never reaches this session's model-visible set. Registering
|
|
@@ -4575,13 +3994,10 @@ export default function orchestratorTickExtension(
|
|
|
4575
3994
|
// surface is brought into the live set by {@link tick} — and only for as
|
|
4576
3995
|
// long as the current tick config resolves a project.
|
|
4577
3996
|
//
|
|
4578
|
-
// The
|
|
4579
|
-
//
|
|
4580
|
-
//
|
|
4581
|
-
//
|
|
4582
|
-
// from {@link askSession}, filled by `session_start` only once ownership is
|
|
4583
|
-
// accepted for a session that goes on to compose a tick; a call on any
|
|
4584
|
-
// other session fails closed.
|
|
3997
|
+
// The session state the tools need — the cwd and the startup config that
|
|
3998
|
+
// routes a call to a project — is read lazily from {@link askSession},
|
|
3999
|
+
// filled by `session_start` only once ownership is accepted for a session
|
|
4000
|
+
// that goes on to compose a tick; a call on any other session fails closed.
|
|
4585
4001
|
//
|
|
4586
4002
|
// The tool is inert unless the config resolves this fleet's project: it needs
|
|
4587
4003
|
// the project to file the decision row against and to resolve the delivery
|
|
@@ -4598,7 +4014,7 @@ export default function orchestratorTickExtension(
|
|
|
4598
4014
|
*/
|
|
4599
4015
|
const resolveOperatorAskContext = (
|
|
4600
4016
|
tool: string,
|
|
4601
|
-
): { ok: true; project: ProjectConfig
|
|
4017
|
+
): { ok: true; project: ProjectConfig } | { ok: false; text: string } => {
|
|
4602
4018
|
const session = askSession;
|
|
4603
4019
|
if (session === undefined) {
|
|
4604
4020
|
return {
|
|
@@ -4615,7 +4031,7 @@ export default function orchestratorTickExtension(
|
|
|
4615
4031
|
"nothing was asked or recorded. Repair the config, do not ask through another path.",
|
|
4616
4032
|
};
|
|
4617
4033
|
}
|
|
4618
|
-
return { ok: true, project: routed.project
|
|
4034
|
+
return { ok: true, project: routed.project };
|
|
4619
4035
|
};
|
|
4620
4036
|
|
|
4621
4037
|
const operatorAskDelivery =
|
|
@@ -4657,22 +4073,15 @@ export default function orchestratorTickExtension(
|
|
|
4657
4073
|
label: ASK_TOOL,
|
|
4658
4074
|
defaultInactive: true,
|
|
4659
4075
|
description:
|
|
4660
|
-
`Ask your operator one question
|
|
4661
|
-
`
|
|
4662
|
-
`
|
|
4663
|
-
`
|
|
4664
|
-
`
|
|
4665
|
-
`
|
|
4666
|
-
`
|
|
4667
|
-
`
|
|
4668
|
-
`
|
|
4669
|
-
`"options" are supplied, must be one of their labels — the label as delivered, never an index. ` +
|
|
4670
|
-
`When nobody answers, the declared "on-timeout" decides: ` +
|
|
4671
|
-
`"auto-proceed" applies your recommended option and resolves the decision row naming the ` +
|
|
4672
|
-
`auto-application ("<option> (auto-applied on ask timeout)"); "park" leaves the row open and ` +
|
|
4673
|
-
`pending — re-surfaced in every tick prompt until answered or the seven-day expiry — and you then ` +
|
|
4674
|
-
`take the blocked work out of the claimable queue and record its state. A timeout is "nobody ` +
|
|
4675
|
-
`answered yet", never a cancellation, an error, or an operator "no".`,
|
|
4076
|
+
`Ask your operator one question. It does NOT wait for the answer: the question is recorded ` +
|
|
4077
|
+
`durably (a decision row + the same delivery path as \`omp-conductor message\`), delivered per the ` +
|
|
4078
|
+
`reporting policy, and the call returns immediately with the row id and the exact command that ` +
|
|
4079
|
+
`resolves it. Your operator answers in their console session — this tick session is headless and no ` +
|
|
4080
|
+
`reply can land here — and an unanswered row stays open and pending, re-surfaced in every tick ` +
|
|
4081
|
+
`prompt, until it is answered, withdrawn, or swept by the seven-day decision expiry. When ` +
|
|
4082
|
+
`"options" are supplied, "recommended" must be one of their labels — the label as delivered, never ` +
|
|
4083
|
+
`an index. Park the work the question blocks in the same turn you ask: out of the claimable queue, ` +
|
|
4084
|
+
`state recorded, row id in your report. Never treat your own recommendation as an approval.`,
|
|
4676
4085
|
parameters: askParameterSchema(),
|
|
4677
4086
|
approval: "write",
|
|
4678
4087
|
execute: async (_toolCallId, params) => {
|
|
@@ -4682,29 +4091,22 @@ export default function orchestratorTickExtension(
|
|
|
4682
4091
|
}
|
|
4683
4092
|
// Routing follows the *live* tick config, not the session-start stamp: a
|
|
4684
4093
|
// restamp (un-stamped → stamped, or project A → B) must make the next
|
|
4685
|
-
// tick's toolbox land on the project the turn actually ticks for.
|
|
4686
|
-
// ceiling stays startup-only — routing is re-read every call.
|
|
4094
|
+
// tick's toolbox land on the project the turn actually ticks for.
|
|
4687
4095
|
const context = resolveOperatorAskContext(ASK_TOOL);
|
|
4688
4096
|
if (!context.ok) {
|
|
4689
4097
|
return { content: [{ type: "text", text: context.text }], isError: true };
|
|
4690
4098
|
}
|
|
4691
4099
|
const projectConfig = context.project;
|
|
4692
|
-
const config = context.config;
|
|
4693
4100
|
const store = openStore(dbPath());
|
|
4694
4101
|
let result: AskResult;
|
|
4695
4102
|
try {
|
|
4696
4103
|
result = await performAsk(parsed.request, {
|
|
4697
4104
|
store,
|
|
4698
4105
|
project: projectConfig.name,
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
//
|
|
4702
|
-
|
|
4703
|
-
// the timeout outcomes deterministically hands both in. The
|
|
4704
|
-
// interactive surface (#722) is production's default; a test may
|
|
4705
|
-
// inject a fake through the same seam.
|
|
4706
|
-
interactive: options.ask?.interactive ?? interactiveAskSurface({ project: projectConfig, store }),
|
|
4707
|
-
...(options.ask === undefined ? {} : { wait: options.ask.wait, now: options.ask.now }),
|
|
4106
|
+
// Test seam: production omits `now` and `performAsk` falls back to
|
|
4107
|
+
// the module's real clock; a test hands one in so the filed row's
|
|
4108
|
+
// timestamp is deterministic.
|
|
4109
|
+
...(options.ask?.now === undefined ? {} : { now: options.ask.now }),
|
|
4708
4110
|
deliver: operatorAskDelivery(ASK_TOOL, projectConfig, store),
|
|
4709
4111
|
});
|
|
4710
4112
|
} finally {
|
|
@@ -4724,19 +4126,15 @@ export default function orchestratorTickExtension(
|
|
|
4724
4126
|
label: QUESTIONNAIRE_TOOL,
|
|
4725
4127
|
defaultInactive: true,
|
|
4726
4128
|
description:
|
|
4727
|
-
`Ask your operator several
|
|
4728
|
-
`
|
|
4729
|
-
`
|
|
4730
|
-
`
|
|
4731
|
-
`
|
|
4732
|
-
`
|
|
4733
|
-
`
|
|
4734
|
-
|
|
4735
|
-
`
|
|
4736
|
-
`${MAX_QUESTIONNAIRE_ITEMS} items. This path is plain text by design (one message, not ` +
|
|
4737
|
-
`${MAX_QUESTIONNAIRE_ITEMS} button posts), so a prose reply does not itself resolve a row: map ` +
|
|
4738
|
-
`it with \`omp-conductor decision resolve <id> --answer "…"\`. Use conductor_ask for a single ` +
|
|
4739
|
-
`question.`,
|
|
4129
|
+
`Ask your operator several questions about ONE issue as a single message. Like conductor_ask it ` +
|
|
4130
|
+
`does NOT wait: every item is recorded as its own durable decision row before anything is ` +
|
|
4131
|
+
`delivered, all bound to the issue you name in "spec-issue" — so the answers become that issue's ` +
|
|
4132
|
+
`provenance and a later reader sees why a slice is shaped the way it is — and the call returns the ` +
|
|
4133
|
+
`group id, every row id, and the command that resolves each one. Your operator answers in their ` +
|
|
4134
|
+
`console session, item by item, in any order. Ask ONLY the judgement calls that genuinely belong ` +
|
|
4135
|
+
`to your operator — anything a repo read can answer is your own work — and at most ` +
|
|
4136
|
+
`${MAX_QUESTIONNAIRE_ITEMS} items. Park the work every item blocks in the same turn you ask, and ` +
|
|
4137
|
+
`name the group id in your report.`,
|
|
4740
4138
|
parameters: questionnaireParameterSchema(),
|
|
4741
4139
|
approval: "write",
|
|
4742
4140
|
execute: async (_toolCallId, params) => {
|
|
@@ -4754,9 +4152,7 @@ export default function orchestratorTickExtension(
|
|
|
4754
4152
|
const result = await performQuestionnaire(parsed.request, {
|
|
4755
4153
|
store,
|
|
4756
4154
|
project: projectConfig.name,
|
|
4757
|
-
|
|
4758
|
-
turnBudgetSeconds: context.config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS,
|
|
4759
|
-
...(options.ask === undefined ? {} : { wait: options.ask.wait, now: options.ask.now }),
|
|
4155
|
+
...(options.ask?.now === undefined ? {} : { now: options.ask.now }),
|
|
4760
4156
|
deliver: operatorAskDelivery(QUESTIONNAIRE_TOOL, projectConfig, store),
|
|
4761
4157
|
});
|
|
4762
4158
|
return { content: [{ type: "text", text: result.text }] };
|
|
@@ -4766,114 +4162,6 @@ export default function orchestratorTickExtension(
|
|
|
4766
4162
|
},
|
|
4767
4163
|
});
|
|
4768
4164
|
|
|
4769
|
-
// The async half of the to-spec result capture (#777). Registered at
|
|
4770
|
-
// extension-factory time like {@link ASK_TOOL}, with the same routing
|
|
4771
|
-
// contract: the state it needs (cwd + startup config) is filled by
|
|
4772
|
-
// `session_start` for an accepted fleet session, and the live config is
|
|
4773
|
-
// re-read at execution so a restamp binds the current project. The tool
|
|
4774
|
-
// only ever writes the grooming table — never an issue, a label, or a
|
|
4775
|
-
// dispatch row — so the output of a background batch persists while no
|
|
4776
|
-
// candidate can become claimable from it.
|
|
4777
|
-
pi.registerTool({
|
|
4778
|
-
name: TO_SPEC_RESULT_TOOL,
|
|
4779
|
-
label: TO_SPEC_RESULT_TOOL,
|
|
4780
|
-
description:
|
|
4781
|
-
`Persist the exact raw output of one completed to-spec grooming item (#777). ` +
|
|
4782
|
-
`Call it once per completed batch item after the batch settles — in the tool ` +
|
|
4783
|
-
`result, or when an async-result message delivers the item — passing the issue ` +
|
|
4784
|
-
`number and the agent's EXACT raw output as \`input\` (read agent://<id> when the ` +
|
|
4785
|
-
`inline text is truncated; never paraphrase). The conductor parses the output ` +
|
|
4786
|
-
`against the strict to-spec contract and records the verdict durably: a valid ` +
|
|
4787
|
-
`result persists its verdict, anything malformed, source-less or stale persists ` +
|
|
4788
|
-
`as blocked, and a failing item never discards its siblings. One call per item, ` +
|
|
4789
|
-
`success and failure alike; never edits an issue or a label.`,
|
|
4790
|
-
parameters: {
|
|
4791
|
-
type: "object",
|
|
4792
|
-
properties: {
|
|
4793
|
-
issue: { type: "integer", description: "The tracker issue number the item groomed." },
|
|
4794
|
-
input: {
|
|
4795
|
-
type: "string",
|
|
4796
|
-
description: "The to-spec agent's exact raw output for this item.",
|
|
4797
|
-
},
|
|
4798
|
-
},
|
|
4799
|
-
required: ["issue", "input"],
|
|
4800
|
-
additionalProperties: false,
|
|
4801
|
-
},
|
|
4802
|
-
approval: "write",
|
|
4803
|
-
execute: async (_toolCallId, params) => {
|
|
4804
|
-
const fleet = askSession;
|
|
4805
|
-
if (fleet === undefined) {
|
|
4806
|
-
return {
|
|
4807
|
-
content: [
|
|
4808
|
-
{
|
|
4809
|
-
type: "text",
|
|
4810
|
-
text: `${TO_SPEC_RESULT_TOOL}: not available in this session (no orchestrator tick); nothing was persisted.`,
|
|
4811
|
-
},
|
|
4812
|
-
],
|
|
4813
|
-
isError: true,
|
|
4814
|
-
};
|
|
4815
|
-
}
|
|
4816
|
-
const issue = params["issue"];
|
|
4817
|
-
const input = params["input"];
|
|
4818
|
-
if (typeof issue !== "number" || !Number.isInteger(issue) || typeof input !== "string" || input.length === 0) {
|
|
4819
|
-
return {
|
|
4820
|
-
content: [
|
|
4821
|
-
{ type: "text", text: `${TO_SPEC_RESULT_TOOL}: expected an integer \`issue\` and a non-empty \`input\` string.` },
|
|
4822
|
-
],
|
|
4823
|
-
isError: true,
|
|
4824
|
-
};
|
|
4825
|
-
}
|
|
4826
|
-
const routed = resolveAskProject(fleet.cwd, fleet.config);
|
|
4827
|
-
if (routed.kind === "error") {
|
|
4828
|
-
return {
|
|
4829
|
-
content: [
|
|
4830
|
-
{
|
|
4831
|
-
type: "text",
|
|
4832
|
-
text:
|
|
4833
|
-
`${TO_SPEC_RESULT_TOOL}: conductor config unreadable (${routed.problem}); nothing was persisted. ` +
|
|
4834
|
-
"Keep the outputs in the transcript and persist after the config is repaired.",
|
|
4835
|
-
},
|
|
4836
|
-
],
|
|
4837
|
-
isError: true,
|
|
4838
|
-
};
|
|
4839
|
-
}
|
|
4840
|
-
const store = openStore(dbPath());
|
|
4841
|
-
try {
|
|
4842
|
-
const outcome = recordToSpecGrooming(store, {
|
|
4843
|
-
project: routed.project.name,
|
|
4844
|
-
issue,
|
|
4845
|
-
input,
|
|
4846
|
-
});
|
|
4847
|
-
const record = outcome.record;
|
|
4848
|
-
const kept = outcome.kind === "kept-prior" ? " (kept the prior valid verdict)" : "";
|
|
4849
|
-
return {
|
|
4850
|
-
content: [
|
|
4851
|
-
{
|
|
4852
|
-
type: "text",
|
|
4853
|
-
text:
|
|
4854
|
-
`${TO_SPEC_RESULT_TOOL}: #${issue} persisted as ${record.verdict} (${record.reason})${kept}. ` +
|
|
4855
|
-
"The grooming table now decides re-grooming; promotion stays yours.",
|
|
4856
|
-
},
|
|
4857
|
-
],
|
|
4858
|
-
};
|
|
4859
|
-
} catch (err) {
|
|
4860
|
-
return {
|
|
4861
|
-
content: [
|
|
4862
|
-
{
|
|
4863
|
-
type: "text",
|
|
4864
|
-
text: `${TO_SPEC_RESULT_TOOL}: could not persist #${issue}: ${
|
|
4865
|
-
err instanceof Error ? err.message : String(err)
|
|
4866
|
-
} — keep the output and retry the call.`,
|
|
4867
|
-
},
|
|
4868
|
-
],
|
|
4869
|
-
isError: true,
|
|
4870
|
-
};
|
|
4871
|
-
} finally {
|
|
4872
|
-
store.close();
|
|
4873
|
-
}
|
|
4874
|
-
},
|
|
4875
|
-
});
|
|
4876
|
-
|
|
4877
4165
|
pi.on("session_start", (_event, ctx) => {
|
|
4878
4166
|
if (decided) return;
|
|
4879
4167
|
|
|
@@ -4905,7 +4193,7 @@ export default function orchestratorTickExtension(
|
|
|
4905
4193
|
// invalid config names none — it cannot be trusted to.
|
|
4906
4194
|
const configuredProject = result.kind === "ok" ? result.config.project : undefined;
|
|
4907
4195
|
armReleaseGate(configuredProject);
|
|
4908
|
-
armAvailabilityGate(configuredProject);
|
|
4196
|
+
armAvailabilityGate(configuredProject, ctx.cwd);
|
|
4909
4197
|
// The tool is already registered (extension-factory time); only the session
|
|
4910
4198
|
// state it acts on is filled, and only once ownership is accepted below.
|
|
4911
4199
|
// `configuredProject` isn't closed over at all — routing is re-read from
|
|
@@ -4995,7 +4283,7 @@ export default function orchestratorTickExtension(
|
|
|
4995
4283
|
// not create or deliver decisions). `cwd` is for re-reading the live
|
|
4996
4284
|
// tick config at execution, `config` for the startup-only ceiling.
|
|
4997
4285
|
askSession = { cwd: ctx.cwd, config };
|
|
4998
|
-
armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam);
|
|
4286
|
+
armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam, options.arm);
|
|
4999
4287
|
if (!guardArmed) {
|
|
5000
4288
|
guardArmed = true;
|
|
5001
4289
|
armTickGuard(pi, ctx, (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1000);
|
|
@@ -5017,7 +4305,7 @@ export default function orchestratorTickExtension(
|
|
|
5017
4305
|
// why): a declined or unresolved session keeps `askSession` undefined and
|
|
5018
4306
|
// the tool fails closed.
|
|
5019
4307
|
askSession = { cwd: ctx.cwd, config };
|
|
5020
|
-
armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam);
|
|
4308
|
+
armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam, options.arm);
|
|
5021
4309
|
if (!guardArmed) {
|
|
5022
4310
|
guardArmed = true;
|
|
5023
4311
|
armTickGuard(pi, ctx, (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1000);
|
|
@@ -5035,272 +4323,3 @@ export default function orchestratorTickExtension(
|
|
|
5035
4323
|
);
|
|
5036
4324
|
});
|
|
5037
4325
|
}
|
|
5038
|
-
|
|
5039
|
-
/** One error's message, for a reason string — never a stack. */
|
|
5040
|
-
function errText(err: unknown): string {
|
|
5041
|
-
return err instanceof Error ? err.message : String(err);
|
|
5042
|
-
}
|
|
5043
|
-
|
|
5044
|
-
/**
|
|
5045
|
-
* Write one prompt-protocol file the way omp-telegram's `atomicJson` does:
|
|
5046
|
-
* temp file in the same directory, then rename. The bridge reads these files
|
|
5047
|
-
* on a hot path (every tap), so a half-written request must never be visible.
|
|
5048
|
-
*/
|
|
5049
|
-
function atomicallyWriteJson(path: string, value: unknown): void {
|
|
5050
|
-
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
5051
|
-
const tmp = `${path}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
|
|
5052
|
-
writeFileSync(tmp, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
|
5053
|
-
renameSync(tmp, path);
|
|
5054
|
-
}
|
|
5055
|
-
|
|
5056
|
-
/** Remove one prompt file; a missing file is not an error. */
|
|
5057
|
-
function removeFile(path: string): void {
|
|
5058
|
-
try {
|
|
5059
|
-
rmSync(path, { force: true });
|
|
5060
|
-
} catch {
|
|
5061
|
-
// best effort — a leftover prompt request dies with its owner process
|
|
5062
|
-
}
|
|
5063
|
-
}
|
|
5064
|
-
|
|
5065
|
-
/**
|
|
5066
|
-
* The interactive Telegram surface for one bounded ask (#722).
|
|
5067
|
-
*
|
|
5068
|
-
* `telegram_ask` posts its options as a Bot API inline keyboard whose taps the
|
|
5069
|
-
* running omp-telegram bridge acknowledges and answers through a documented
|
|
5070
|
-
* cross-process file protocol (guide.md: "prompts/ — Cross-process
|
|
5071
|
-
* selectable-question requests (live while their owning session is) and their
|
|
5072
|
-
* answers"): the asking process writes `<state>/prompts/<nonce>.json`, the
|
|
5073
|
-
* bridge validates the tap against that request (responder, chat, topic,
|
|
5074
|
-
* message id, owner-pid liveness) and writes `<nonce>.answer.json`; the asking
|
|
5075
|
-
* process reads the envelope and settles. `interactiveAskSurface` is that
|
|
5076
|
-
* asking-process half, nothing more: it posts the question with the same
|
|
5077
|
-
* `qa:<nonce>:s:<index>` callbacks `prompts.ts` routes, writes the request file
|
|
5078
|
-
* so the bridge recognizes the taps, and translates the envelope the bridge
|
|
5079
|
-
* writes into the decision row — the resolution is the chosen option's *label*,
|
|
5080
|
-
* never an index and never free text.
|
|
5081
|
-
*
|
|
5082
|
-
* The decision id doubles as the protocol nonce: it fits `[A-Za-z0-9_-]`, the
|
|
5083
|
-
* bridge's callback regex, and makes the pending question and its answer
|
|
5084
|
-
* addressable by the row that records them.
|
|
5085
|
-
*/
|
|
5086
|
-
export function interactiveAskSurface(deps: {
|
|
5087
|
-
project: ProjectConfig;
|
|
5088
|
-
store: Store;
|
|
5089
|
-
/** Injected Bot API transport (tests); production posts to api.telegram.org. */
|
|
5090
|
-
call?: (method: string, payload: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
5091
|
-
/** Injected state dir (tests); production resolves it like the token. */
|
|
5092
|
-
stateDir?: string;
|
|
5093
|
-
/** Injected bot token (tests); production reads the state dir's .env. */
|
|
5094
|
-
token?: string;
|
|
5095
|
-
now?: () => number;
|
|
5096
|
-
}): AskInteractiveDelivery {
|
|
5097
|
-
const surfaceStateDir = deps.stateDir ?? telegramStateDir();
|
|
5098
|
-
const call = deps.call ?? telegramCall(deps.token ?? readTelegramToken() ?? "");
|
|
5099
|
-
const now = deps.now ?? Date.now;
|
|
5100
|
-
// Where each pending question physically sits, so collect/close can settle
|
|
5101
|
-
// the right message without re-reading the request file.
|
|
5102
|
-
const posted = new Map<string, { chatId: string; messageId: number; settled: boolean }>();
|
|
5103
|
-
|
|
5104
|
-
const promptsDir = (): string => join(surfaceStateDir, "prompts");
|
|
5105
|
-
const requestPath = (nonce: string): string => join(promptsDir(), `${nonce}.json`);
|
|
5106
|
-
const answerPath = (nonce: string): string => join(promptsDir(), `${nonce}.answer.json`);
|
|
5107
|
-
|
|
5108
|
-
const ownerId = (): string | undefined => {
|
|
5109
|
-
let raw: string;
|
|
5110
|
-
try {
|
|
5111
|
-
raw = readFileSync(join(surfaceStateDir, "access.json"), "utf8");
|
|
5112
|
-
} catch {
|
|
5113
|
-
return undefined;
|
|
5114
|
-
}
|
|
5115
|
-
let access: { allowFrom?: unknown };
|
|
5116
|
-
try {
|
|
5117
|
-
access = JSON.parse(raw) as { allowFrom?: unknown };
|
|
5118
|
-
} catch {
|
|
5119
|
-
return undefined;
|
|
5120
|
-
}
|
|
5121
|
-
const allowFrom = access.allowFrom;
|
|
5122
|
-
if (!Array.isArray(allowFrom) || allowFrom.length !== 1 || typeof allowFrom[0] !== "string") {
|
|
5123
|
-
return undefined;
|
|
5124
|
-
}
|
|
5125
|
-
return allowFrom[0];
|
|
5126
|
-
};
|
|
5127
|
-
|
|
5128
|
-
const tokenAvailable = (): boolean =>
|
|
5129
|
-
deps.call !== undefined || deps.token !== undefined || readTelegramToken() !== undefined;
|
|
5130
|
-
|
|
5131
|
-
return {
|
|
5132
|
-
unavailableReason(request) {
|
|
5133
|
-
const chat = deps.project.escalation.telegramChatId;
|
|
5134
|
-
if (chat === undefined || chat === "") {
|
|
5135
|
-
return "no escalation.telegramChatId configured for this project";
|
|
5136
|
-
}
|
|
5137
|
-
if (!tokenAvailable()) {
|
|
5138
|
-
return "no Telegram bot token readable (install and configure omp-telegram, or set OMP_TELEGRAM_STATE_DIR)";
|
|
5139
|
-
}
|
|
5140
|
-
if (ownerId() === undefined) {
|
|
5141
|
-
return "no paired Telegram owner (omp-telegram access.json must name exactly one allowed user)";
|
|
5142
|
-
}
|
|
5143
|
-
// The interactive post is still a delivery under the reporting policy: a
|
|
5144
|
-
// question the policy would hold for the digest or the availability
|
|
5145
|
-
// window must not bypass that hold just because it has buttons.
|
|
5146
|
-
const disposition = interruptDisposition(
|
|
5147
|
-
deps.project.reporting,
|
|
5148
|
-
request.category ?? "decision-needed",
|
|
5149
|
-
now(),
|
|
5150
|
-
);
|
|
5151
|
-
if (disposition !== "interrupt") {
|
|
5152
|
-
return `the question defers under the reporting policy (${disposition}) — it must be held, not posted`;
|
|
5153
|
-
}
|
|
5154
|
-
return undefined;
|
|
5155
|
-
},
|
|
5156
|
-
|
|
5157
|
-
async post(request, decisionId) {
|
|
5158
|
-
const chat = deps.project.escalation.telegramChatId;
|
|
5159
|
-
const owner = ownerId();
|
|
5160
|
-
if (chat === undefined || owner === undefined) {
|
|
5161
|
-
return {
|
|
5162
|
-
ok: false,
|
|
5163
|
-
reason: "the interactive surface is not configured (no escalation.telegramChatId or no paired owner)",
|
|
5164
|
-
};
|
|
5165
|
-
}
|
|
5166
|
-
const render = renderInteractiveAsk(request, decisionId);
|
|
5167
|
-
const threadId = resolveProjectTopicId(deps.project);
|
|
5168
|
-
let result: Record<string, unknown>;
|
|
5169
|
-
try {
|
|
5170
|
-
result = await call("sendMessage", {
|
|
5171
|
-
chat_id: chat,
|
|
5172
|
-
...(threadId === undefined ? {} : { message_thread_id: threadId }),
|
|
5173
|
-
text: render.text,
|
|
5174
|
-
reply_markup: render.markup,
|
|
5175
|
-
});
|
|
5176
|
-
} catch (err) {
|
|
5177
|
-
return { ok: false, reason: `Telegram rejected the interactive question: ${errText(err)}` };
|
|
5178
|
-
}
|
|
5179
|
-
const messageId = result["message_id"];
|
|
5180
|
-
const chatType =
|
|
5181
|
-
typeof result["chat"] === "object" && result["chat"] !== null
|
|
5182
|
-
? String((result["chat"] as Record<string, unknown>)["type"] ?? "private")
|
|
5183
|
-
: "private";
|
|
5184
|
-
if (typeof messageId !== "number" || !Number.isSafeInteger(messageId)) {
|
|
5185
|
-
// The question went out, but unaddressable — take it back rather than
|
|
5186
|
-
// leaving a button row nothing can settle.
|
|
5187
|
-
await call("deleteMessage", { chat_id: chat, message_id: messageId as number }).catch(
|
|
5188
|
-
() => undefined,
|
|
5189
|
-
);
|
|
5190
|
-
return { ok: false, reason: "Telegram posted no usable message id" };
|
|
5191
|
-
}
|
|
5192
|
-
const recommended =
|
|
5193
|
-
request.recommended === undefined
|
|
5194
|
-
? undefined
|
|
5195
|
-
: (request.options ?? []).findIndex((option) => option.label === request.recommended);
|
|
5196
|
-
const requestFile = {
|
|
5197
|
-
version: 1,
|
|
5198
|
-
nonce: decisionId,
|
|
5199
|
-
responderId: owner,
|
|
5200
|
-
chatId: chat,
|
|
5201
|
-
chatType,
|
|
5202
|
-
threadId,
|
|
5203
|
-
page: 0,
|
|
5204
|
-
messageId,
|
|
5205
|
-
questions: [
|
|
5206
|
-
{
|
|
5207
|
-
id: "q1",
|
|
5208
|
-
question: request.question,
|
|
5209
|
-
options: request.options ?? [],
|
|
5210
|
-
...(recommended === undefined || recommended < 0 ? {} : { recommended }),
|
|
5211
|
-
},
|
|
5212
|
-
],
|
|
5213
|
-
questionIndex: 0,
|
|
5214
|
-
answers: [],
|
|
5215
|
-
selectedIndices: [],
|
|
5216
|
-
awaitingText: (request.options ?? []).length === 0,
|
|
5217
|
-
ownerPid: process.pid,
|
|
5218
|
-
};
|
|
5219
|
-
try {
|
|
5220
|
-
atomicallyWriteJson(requestPath(decisionId), requestFile as unknown);
|
|
5221
|
-
} catch (err) {
|
|
5222
|
-
// The buttons went out but nothing would ever answer them — take the
|
|
5223
|
-
// message back rather than leave a dead keyboard in the chat.
|
|
5224
|
-
await call("deleteMessage", { chat_id: chat, message_id: messageId }).catch(() => undefined);
|
|
5225
|
-
return { ok: false, reason: `could not register the interactive question: ${errText(err)}` };
|
|
5226
|
-
}
|
|
5227
|
-
posted.set(decisionId, { chatId: chat, messageId, settled: false });
|
|
5228
|
-
return { ok: true };
|
|
5229
|
-
},
|
|
5230
|
-
|
|
5231
|
-
collect(decisionId) {
|
|
5232
|
-
const state = posted.get(decisionId);
|
|
5233
|
-
if (state === undefined) return;
|
|
5234
|
-
let raw: string | undefined;
|
|
5235
|
-
try {
|
|
5236
|
-
raw = readFileSync(answerPath(decisionId), "utf8");
|
|
5237
|
-
} catch {
|
|
5238
|
-
return; // no answer yet
|
|
5239
|
-
}
|
|
5240
|
-
let parsed: AskAnswerEnvelope | undefined;
|
|
5241
|
-
try {
|
|
5242
|
-
parsed = parseAskAnswerEnvelope(JSON.parse(raw) as unknown);
|
|
5243
|
-
} catch {
|
|
5244
|
-
parsed = undefined;
|
|
5245
|
-
}
|
|
5246
|
-
if (parsed === undefined) return; // an unreadable envelope is "no answer yet"
|
|
5247
|
-
const write = askAnswerRowWrite(parsed);
|
|
5248
|
-
if (write === undefined) return; // expiry/abort: the bounded wait still owns the row
|
|
5249
|
-
deps.store.resolveDecision(decisionId, write.state, write.resolution, now());
|
|
5250
|
-
removeFile(requestPath(decisionId));
|
|
5251
|
-
removeFile(answerPath(decisionId));
|
|
5252
|
-
state.settled = true;
|
|
5253
|
-
const outcomeText =
|
|
5254
|
-
write.state === "withdrawn"
|
|
5255
|
-
? write.resolution
|
|
5256
|
-
: `User selected: ${write.resolution}`;
|
|
5257
|
-
// The bridge edits its own prompts' messages; this surface owns the edit
|
|
5258
|
-
// for its own, so an answered ask reads as answered and a stale tap
|
|
5259
|
-
// finds no keyboard.
|
|
5260
|
-
call("editMessageText", {
|
|
5261
|
-
chat_id: state.chatId,
|
|
5262
|
-
message_id: state.messageId,
|
|
5263
|
-
text: outcomeText,
|
|
5264
|
-
reply_markup: { inline_keyboard: [] },
|
|
5265
|
-
}).catch(() => undefined);
|
|
5266
|
-
},
|
|
5267
|
-
|
|
5268
|
-
close(decisionId) {
|
|
5269
|
-
const state = posted.get(decisionId);
|
|
5270
|
-
removeFile(requestPath(decisionId));
|
|
5271
|
-
removeFile(answerPath(decisionId));
|
|
5272
|
-
if (state !== undefined && !state.settled) {
|
|
5273
|
-
call("editMessageReplyMarkup", {
|
|
5274
|
-
chat_id: state.chatId,
|
|
5275
|
-
message_id: state.messageId,
|
|
5276
|
-
reply_markup: { inline_keyboard: [] },
|
|
5277
|
-
}).catch(() => undefined);
|
|
5278
|
-
state.settled = true;
|
|
5279
|
-
}
|
|
5280
|
-
},
|
|
5281
|
-
};
|
|
5282
|
-
}
|
|
5283
|
-
|
|
5284
|
-
/** The Bot API transport: one JSON POST per call, the `result` back. */
|
|
5285
|
-
function telegramCall(
|
|
5286
|
-
token: string,
|
|
5287
|
-
): (method: string, payload: Record<string, unknown>) => Promise<Record<string, unknown>> {
|
|
5288
|
-
return async (method, payload) => {
|
|
5289
|
-
if (token === "") throw new Error("no Telegram bot token");
|
|
5290
|
-
const url = `https://api.telegram.org/bot${token}/${method}`;
|
|
5291
|
-
const res = await fetch(url, {
|
|
5292
|
-
method: "POST",
|
|
5293
|
-
headers: { "content-type": "application/json" },
|
|
5294
|
-
body: JSON.stringify(payload),
|
|
5295
|
-
});
|
|
5296
|
-
const raw = await res.text();
|
|
5297
|
-
if (!res.ok) {
|
|
5298
|
-
throw new Error(`telegram ${method} failed: HTTP ${res.status} ${raw.slice(0, 200)}`);
|
|
5299
|
-
}
|
|
5300
|
-
const parsed = JSON.parse(raw) as { ok?: unknown; result?: unknown };
|
|
5301
|
-
if (parsed.ok !== true) {
|
|
5302
|
-
throw new Error(`telegram ${method} rejected: ${raw.slice(0, 200)}`);
|
|
5303
|
-
}
|
|
5304
|
-
return (parsed.result as Record<string, unknown>) ?? {};
|
|
5305
|
-
};
|
|
5306
|
-
}
|