@staix/agent-hub 0.34.1 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.md +35 -6
- package/docs/adding-an-ai-tool.md +2 -2
- package/docs/addons/claude-mem.md +4 -4
- package/docs/addons/open-code-review.md +1 -1
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +9 -5
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +1 -1
- package/docs/agent-notes/daemon.md +4 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +1 -1
- package/docs/agent-notes/tests.md +5 -0
- package/docs/design/adapters.md +110 -2
- package/docs/design/agent-hub-architecture.md +63 -1
- package/docs/design/agent-hub.md +6 -0
- package/docs/design/bus.md +18 -0
- package/docs/design/execution-reference.md +146 -0
- package/docs/design/task-board.md +10 -0
- package/docs/dogfood.md +33 -12
- package/docs/events.md +38 -20
- package/docs/memory.md +21 -7
- package/docs/operations.md +539 -24
- package/docs/quickstart.md +12 -4
- package/docs/research.md +71 -13
- package/docs/roles.md +1 -1
- package/docs/sdlc.md +9 -7
- package/docs/security.md +87 -11
- package/docs/smoke.md +14 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +8 -3
- package/docs/verification/2026-11-07-compat-python-startup-1248.md +51 -0
- package/docs/verification/2026-11-07-compat-receipt-harness-1248.md +105 -0
- package/docs/verification/2026-11-07-compat-receipt-producers-1248.md +96 -0
- package/docs/verification/2026-11-07-compat-workflow-1248.md +94 -0
- package/docs/verification/2026-11-07-daemon-startup-1232.md +169 -0
- package/docs/verification/2026-11-07-jcode-receipt-reconstruction-1248.md +118 -0
- package/docs/verification/2026-11-07-kimi-delayed-usage.md +34 -0
- package/docs/verification/2026-11-07-kimi-review-watch-1284.md +115 -0
- package/docs/verification/2026-11-08-agy-1.5.3.md +96 -0
- package/docs/verification/2026-11-08-agy-acp-t0.md +56 -0
- package/docs/verification/2026-11-08-hub-visual-1058.md +161 -0
- package/docs/verification/2026-11-08-jcode-0991-model-setter-1317.md +143 -0
- package/docs/verification/2026-11-08-kimi-252-model-setter-1317.md +137 -0
- package/docs/verification/2026-11-08-kimi-tui-completion-1282.md +45 -0
- package/docs/verification/2026-11-08-kimi-wire-yield-1283.md +31 -0
- package/docs/verification/2026-11-08-live-model-override-1317.md +250 -0
- package/docs/verification/2026-11-08-qwen-0269-acp.md +138 -0
- package/docs/verification/2026-11-08-qwen-0269-model-setter-1317.md +91 -0
- package/docs/verification/2026-11-09-0.35.0.md +129 -0
- package/docs/verification/2026-11-09-headless-records-1320.md +157 -0
- package/docs/verification/claude-permission-transport-native.py +10 -3
- package/docs/verification/claude-shell-wrapper-native.py +8 -8
- package/docs/verified.json +245 -15
- package/package.json +3 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/kimi-hook.js +20397 -0
- package/plugins/agent-hub/kimi-review-policy-worker.js +217 -0
- package/plugins/agent-hub/server.js +2701 -664
- package/scripts/check-docs.mjs +12 -1
- package/src/adapters/acp-model-policy.ts +56 -0
- package/src/adapters/acp.ts +175 -5
- package/src/adapters/agy/entry.ts +8 -5
- package/src/adapters/agy/hooks.ts +65 -15
- package/src/adapters/channel-peer.ts +33 -1
- package/src/adapters/claude-implementer.ts +16 -3
- package/src/adapters/codex-appserver.ts +118 -8
- package/src/adapters/codex-review-isolation.ts +3 -1
- package/src/adapters/compat.ts +11 -6
- package/src/adapters/definitions/builtin-metadata.ts +8 -1
- package/src/adapters/definitions/native-cli-entry-kimi.ts +76 -7
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -2
- package/src/adapters/definitions/native-cli-preview-kimi.ts +9 -2
- package/src/adapters/definitions/reviewer-variants.ts +3 -1
- package/src/adapters/kimi-review-policy-worker.js +2 -0
- package/src/adapters/kimi-review-policy-worker.ts +85 -0
- package/src/adapters/kimi-review-policy.ts +68 -0
- package/src/adapters/kimi-review.ts +41 -41
- package/src/adapters/kimi-tui-facts.ts +153 -0
- package/src/adapters/kimi-wire-usage.ts +33 -8
- package/src/adapters/local-worker.ts +59 -20
- package/src/adapters/model-token.ts +3 -0
- package/src/adapters/pi.ts +27 -5
- package/src/adapters/registry-types.ts +34 -3
- package/src/adapters/registry.ts +1 -0
- package/src/adapters/reviewer-policy-watch.ts +86 -0
- package/src/adapters/reviewer-print.ts +80 -18
- package/src/cli/agents.ts +1 -1
- package/src/cli/facts-hook.ts +4 -2
- package/src/cli/help.ts +3 -2
- package/src/cli/identity.ts +3 -0
- package/src/cli/kimi-facts-hook.ts +11 -0
- package/src/cli/kimi-plugin.ts +157 -0
- package/src/cli/main.ts +48 -9
- package/src/cli/model.ts +52 -0
- package/src/cli/output.ts +23 -1
- package/src/cli/reset.ts +1 -1
- package/src/cli/status-lines.ts +8 -4
- package/src/hub/budget.ts +1 -1
- package/src/hub/bus.ts +40 -2
- package/src/hub/code-facts.ts +10 -3
- package/src/hub/completions.ts +3 -1
- package/src/hub/config-apply.ts +2 -1
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +1050 -106
- package/src/hub/deliverable-files.ts +38 -5
- package/src/hub/dogfood-profile.ts +1 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +28 -18
- package/src/hub/execution-lineage.ts +120 -0
- package/src/hub/execution-reference.ts +36 -0
- package/src/hub/hub-tools.ts +21 -2
- package/src/hub/instances.ts +4 -3
- package/src/hub/kimi-events.ts +46 -0
- package/src/hub/kimi-usage.ts +132 -29
- package/src/hub/lifecycle.ts +31 -15
- package/src/hub/limits.ts +13 -4
- package/src/hub/manager.ts +15 -2
- package/src/hub/memory-policy.ts +2 -1
- package/src/hub/memory-sync-attestation.ts +54 -0
- package/src/hub/memory-sync-state.ts +20 -5
- package/src/hub/model-overrides.ts +115 -0
- package/src/hub/native-hook-launch.ts +12 -3
- package/src/hub/operational-view.ts +2 -2
- package/src/hub/peers.ts +27 -0
- package/src/hub/permission-mode.ts +1 -1
- package/src/hub/person-figures.ts +88 -0
- package/src/hub/report.ts +38 -4
- package/src/hub/research-dataset.ts +158 -0
- package/src/hub/research-memory.ts +68 -0
- package/src/hub/research-quality.ts +118 -0
- package/src/hub/research-record.ts +279 -0
- package/src/hub/research.ts +126 -27
- package/src/hub/restart.ts +14 -0
- package/src/hub/settings.ts +3 -0
- package/src/hub/startup-event-seeds.ts +51 -0
- package/src/hub/startup-readiness.ts +65 -0
- package/src/hub/startup-timing.ts +38 -0
- package/src/hub/tasks.ts +29 -9
- package/src/hub/ui.ts +9 -5
- package/src/hub/visual-assets.ts +50 -0
- package/src/hub/visual.ts +257 -0
- package/src/memory/brief.ts +169 -14
- package/src/memory/capture.ts +2 -1
- package/src/memory/recall.ts +46 -5
- package/src/memory/record-provenance.ts +82 -0
- package/src/memory/records.ts +116 -0
- package/src/models/route/adec.ts +190 -0
- package/src/models/route/config.ts +37 -3
- package/src/models/route/decision-evidence.ts +83 -0
- package/src/models/route/decision.ts +143 -0
- package/src/models/route/runtime.ts +13 -6
- package/src/ui/index.html +134 -6
- package/src/ui/plugin.ts +65 -4
- package/templates/AGENTS.block.md +1 -1
- package/templates/config.json +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { closeSync, fstatSync, lstatSync, openSync, readSync, readdirSync, statSync } from "node:fs";
|
|
1
|
+
import { closeSync, fstatSync, lstatSync, openSync, readSync as fsReadSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { join, sep } from "node:path";
|
|
4
4
|
import { realPath } from "../hub/project.ts";
|
|
@@ -43,6 +43,10 @@ export type KimiWireRead =
|
|
|
43
43
|
| { status: "ok"; rows: KimiWireRow[]; /** bytes remain unread because of the per-call bound: read again */ more: boolean }
|
|
44
44
|
| { status: "unknown"; reason: KimiWireReason };
|
|
45
45
|
|
|
46
|
+
export type KimiWireSlice =
|
|
47
|
+
| { status: "ok"; rows: KimiWireRow[]; more: boolean; commit(): void }
|
|
48
|
+
| { status: "unknown"; reason: KimiWireReason };
|
|
49
|
+
|
|
46
50
|
/** What counts against `budget.kimi_tokens_5h`: new input, cache writes and output. Cache reads are recorded and shown, never counted. */
|
|
47
51
|
export function kimiCountedTokens(row: Pick<KimiWireRow, "inputOther" | "inputCacheCreation" | "output">): number {
|
|
48
52
|
return row.inputOther + row.inputCacheCreation + row.output;
|
|
@@ -88,6 +92,8 @@ export class KimiWireReader {
|
|
|
88
92
|
private dir: string | undefined;
|
|
89
93
|
private files = new Map<string, FileState>();
|
|
90
94
|
private broken: KimiWireReason | undefined;
|
|
95
|
+
private bytes = 0;
|
|
96
|
+
private discovery = 0;
|
|
91
97
|
|
|
92
98
|
constructor(options: KimiWireOptions) {
|
|
93
99
|
this.home = options.home;
|
|
@@ -95,15 +101,34 @@ export class KimiWireReader {
|
|
|
95
101
|
this.chunk = Math.max(LINE_CAP * 2, options.maxBytes ?? DEFAULT_CHUNK);
|
|
96
102
|
}
|
|
97
103
|
|
|
104
|
+
private readBytes(fd: number, buffer: Buffer, offset: number, length: number, position: number): number {
|
|
105
|
+
const read = fsReadSync(fd, buffer, offset, length, position);
|
|
106
|
+
this.bytes += read;
|
|
107
|
+
return read;
|
|
108
|
+
}
|
|
109
|
+
|
|
98
110
|
/** The rows written since the last successful read, sub-agents included. */
|
|
99
111
|
read(): KimiWireRead {
|
|
112
|
+
const slice = this.readSlice();
|
|
113
|
+
if (slice.status === "unknown") return slice;
|
|
114
|
+
const { commit, ...read } = slice;
|
|
115
|
+
commit();
|
|
116
|
+
return read;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Numeric I/O observations for deterministic benchmarks; no path, row or native text. */
|
|
120
|
+
stats(): { bytes: number; discovery: number } { return { bytes: this.bytes, discovery: this.discovery }; }
|
|
121
|
+
|
|
122
|
+
/** A successful slice commits its offsets only after the caller has committed every row it returned. */
|
|
123
|
+
readSlice(): KimiWireSlice {
|
|
100
124
|
if (this.broken) return { status: "unknown", reason: this.broken };
|
|
125
|
+
this.bytes = 0; this.discovery = 0;
|
|
101
126
|
try {
|
|
102
|
-
const dir = this.resolve();
|
|
127
|
+
const dir = this.resolve(); this.discovery++;
|
|
103
128
|
const next = new Map<string, FileState>();
|
|
104
129
|
const rows: KimiWireRow[] = [];
|
|
105
130
|
let more = false, budget = this.chunk;
|
|
106
|
-
const agents = this.agents(dir);
|
|
131
|
+
const agents = this.agents(dir); this.discovery++;
|
|
107
132
|
for (const name of this.files.keys()) if (!agents.has(name)) throw new Unknown("missing");
|
|
108
133
|
for (const [name, wire] of agents) {
|
|
109
134
|
const previous = this.files.get(name);
|
|
@@ -114,8 +139,8 @@ export class KimiWireReader {
|
|
|
114
139
|
budget -= result.read;
|
|
115
140
|
more ||= result.more;
|
|
116
141
|
}
|
|
117
|
-
|
|
118
|
-
return { status: "ok", rows, more };
|
|
142
|
+
let committed = false;
|
|
143
|
+
return { status: "ok", rows, more, commit: () => { if (!committed) { this.files = next; committed = true; } } };
|
|
119
144
|
} catch (error) {
|
|
120
145
|
const reason: KimiWireReason = error instanceof Unknown ? error.reason : "io-error";
|
|
121
146
|
if (!TRANSIENT.has(reason)) this.broken = reason; // the log no longer matches what was counted: stays unknown for this session
|
|
@@ -161,7 +186,7 @@ export class KimiWireReader {
|
|
|
161
186
|
const buffer = Buffer.allocUnsafe(stat.size);
|
|
162
187
|
let got = 0;
|
|
163
188
|
while (got < stat.size) {
|
|
164
|
-
const n =
|
|
189
|
+
const n = this.readBytes(fd, buffer, got, stat.size - got, got);
|
|
165
190
|
if (n <= 0) break;
|
|
166
191
|
got += n;
|
|
167
192
|
}
|
|
@@ -206,7 +231,7 @@ export class KimiWireReader {
|
|
|
206
231
|
let got = 0;
|
|
207
232
|
while (got < length) {
|
|
208
233
|
let n: number;
|
|
209
|
-
try { n =
|
|
234
|
+
try { n = this.readBytes(fd, buffer, got, length - got, start + got); } catch { throw new Unknown("io-error"); }
|
|
210
235
|
if (n <= 0) throw new Unknown("shrunk");
|
|
211
236
|
got += n;
|
|
212
237
|
}
|
|
@@ -234,7 +259,7 @@ export class KimiWireReader {
|
|
|
234
259
|
let got = 0;
|
|
235
260
|
while (got < length) {
|
|
236
261
|
let n: number;
|
|
237
|
-
try { n =
|
|
262
|
+
try { n = this.readBytes(fd, buffer, got, length - got, state.offset + got); } catch { throw new Unknown("io-error"); }
|
|
238
263
|
if (n <= 0) break;
|
|
239
264
|
got += n;
|
|
240
265
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import type { ModelOverrideSelection } from "./registry-types.ts";
|
|
2
|
+
import { isNativeModelToken } from "./model-token.ts";
|
|
1
3
|
import { isReviewObserverExcluded, reviewCaptureExcluded } from "../hub/memory-policy.ts";
|
|
4
|
+
import { DecisionPolicyChangedError, type DecisionHost } from "../models/route/decision.ts";
|
|
2
5
|
import { HubRouteRuntime, type RouteEvent, type AdvisorEvent } from "../models/route/runtime.ts";
|
|
3
6
|
import type { HubRoute } from "../models/route/config.ts";
|
|
4
7
|
import type { RouteLabelEvent, RouteTurnOutcome } from "../models/route/labels.ts";
|
|
@@ -21,6 +24,8 @@ import type { ExecutionBudgetDecision } from "../hub/execution-budget.ts";
|
|
|
21
24
|
import type { OwnedInputContext, OwnedInputObserver } from "./claude-print-child.ts";
|
|
22
25
|
|
|
23
26
|
export interface LocalOptions {
|
|
27
|
+
/** Host-only choice sampled once at the next turn/review boundary. */
|
|
28
|
+
getModelOverride?: () => ModelOverrideSelection;
|
|
24
29
|
profile?: import("../hub/profiles.ts").ProfileSelection;
|
|
25
30
|
sdlcStages?: readonly string[];
|
|
26
31
|
sdlcCriteria?: boolean;
|
|
@@ -33,6 +38,9 @@ export interface LocalOptions {
|
|
|
33
38
|
/** Model alias sent straight to OmniRoute when no hub route is active or its call fails. */
|
|
34
39
|
fixedModel: string;
|
|
35
40
|
hubRoutes?: () => Record<string, HubRoute>;
|
|
41
|
+
/** Current host public-view screen for bounded decision evidence; absent means no text is released. */
|
|
42
|
+
routeEvidencePublic?: (text: string) => boolean;
|
|
43
|
+
routeDecisionContext?: DecisionHost["decisionContext"];
|
|
36
44
|
/** routing.toml `stay_switch` for hub stage routes (#197). */
|
|
37
45
|
staySwitch?: () => StaySwitchPolicy | undefined;
|
|
38
46
|
onRoute?: (event: RouteEvent) => void;
|
|
@@ -116,6 +124,7 @@ export class LocalPeer extends BasePeer {
|
|
|
116
124
|
private readonly warnedLegacyRoutes = new Set<string>();
|
|
117
125
|
private routeEnvs: Envelope[] = [];
|
|
118
126
|
private routePii = false;
|
|
127
|
+
private routeReviewScope: { excluded: boolean } | undefined;
|
|
119
128
|
private routeTask?: number;
|
|
120
129
|
private labelTurnId?: string;
|
|
121
130
|
private turn = 0; // generation guard, as in acp.ts: a turn aborted by the watchdog must not touch the next one
|
|
@@ -153,6 +162,10 @@ export class LocalPeer extends BasePeer {
|
|
|
153
162
|
return result;
|
|
154
163
|
},
|
|
155
164
|
onCampus: () => this.opts.omni.onCampus(),
|
|
165
|
+
decisionContext: () => this.decisionContext(),
|
|
166
|
+
publicDecisionText: text => this.opts.routeEvidencePublic?.(text) === true,
|
|
167
|
+
admitDecision: () => this.requireBudget(this.routeEnvs, "model_calls"),
|
|
168
|
+
onDecisionIssue: reason => this.opts.log?.(`decision route observation ${reason}; using the original heuristic; check adec serve and routing.toml policy settings, then retry`),
|
|
156
169
|
staySwitch: () => this.opts.staySwitch?.(),
|
|
157
170
|
onRoute: event => this.opts.onRoute?.(event),
|
|
158
171
|
onAdvisor: event => this.opts.onAdvisor?.(event),
|
|
@@ -161,6 +174,13 @@ export class LocalPeer extends BasePeer {
|
|
|
161
174
|
this.sandboxProfile = profile(opts.cwd, opts.tools.bashNetwork ?? false, opts.tools.readAllow, opts.tools.deny, opts.tools.readOnly);
|
|
162
175
|
}
|
|
163
176
|
|
|
177
|
+
private decisionContext(): { pii: boolean; private: boolean; excluded: boolean } {
|
|
178
|
+
const current = this.opts.routeDecisionContext?.();
|
|
179
|
+
if (this.opts.routeDecisionContext && (!current || typeof current.private !== "boolean" || typeof current.excluded !== "boolean" || current.pii !== undefined && typeof current.pii !== "boolean")) throw new DecisionPolicyChangedError();
|
|
180
|
+
return { pii: this.routePii || current?.pii === true, private: this.routeEnvs.some(env => env.private === true) || current?.private === true,
|
|
181
|
+
excluded: this.routeEnvs.some(env => remoteEnvelope(env) || isReviewObserverExcluded(env)) || this.routeReviewScope?.excluded === true || current?.excluded === true };
|
|
182
|
+
}
|
|
183
|
+
|
|
164
184
|
recoveryMetadata(): Record<string, unknown> {
|
|
165
185
|
return { launch: { kind: "local", cwd: this.opts.cwd, model: this.opts.fixedModel, ...(this.opts.route ? { route: this.opts.route } : {}) }, sessionId: this.sessionId };
|
|
166
186
|
}
|
|
@@ -202,11 +222,22 @@ export class LocalPeer extends BasePeer {
|
|
|
202
222
|
this.activeDeliveryId = deliveryId;
|
|
203
223
|
this.abort = new AbortController();
|
|
204
224
|
this.setState("busy");
|
|
225
|
+
let selected: ModelOverrideSelection | undefined, modelFailure: Error | undefined;
|
|
226
|
+
try {
|
|
227
|
+
selected = this.opts.getModelOverride?.();
|
|
228
|
+
if (selected?.model !== undefined && !isNativeModelToken(selected.model)) throw new Error("invalid model");
|
|
229
|
+
} catch { modelFailure = new Error("runtime model selection unavailable; check the peer model setting and retry"); }
|
|
230
|
+
let modelApplied = false;
|
|
231
|
+
const captured = selected;
|
|
232
|
+
const selection = captured && { model: captured.model, applied: () => {
|
|
233
|
+
if (modelApplied || turn !== this.turn) return;
|
|
234
|
+
modelApplied = true; try { captured.applied(); } catch { /* Status acknowledgement is not model authority. */ }
|
|
235
|
+
} };
|
|
205
236
|
if (deliveryId) this.delivery({ id: deliveryId, state: "accepted" });
|
|
206
237
|
// The turn works on its own message list and joins the history only as a whole, so a failed or aborted turn can
|
|
207
238
|
// never leave a tool call without its result (strict servers reject that history forever after).
|
|
208
239
|
const msgs: ChatMessage[] = [{ role: "user", content: [envs.some(env => env.private || remoteEnvelope(env)) ? undefined : this.opts.titlePreface?.(), renderDigest(envs, true)].filter(Boolean).join("\n") }];
|
|
209
|
-
const progress = { sideEffects: 0, last: "" };
|
|
240
|
+
const progress = { sideEffects: 0, last: "", answerComplete: false };
|
|
210
241
|
const requestedPolicy = this.opts.turnPolicy?.(envs);
|
|
211
242
|
const policy = envs.some(env => env.private) ? { ...requestedPolicy, pii: true } : requestedPolicy;
|
|
212
243
|
const remoteTurn = envs.some(remoteEnvelope);
|
|
@@ -217,11 +248,11 @@ export class LocalPeer extends BasePeer {
|
|
|
217
248
|
// A PII turn speaks to the console user only, and privately: its answer must not be broadcast to cloud-hosted peers.
|
|
218
249
|
// The task id travels with it, so the board can keep the text (`ahub task show`) while tail and log show a stub.
|
|
219
250
|
const reply: EnvelopeOpts = { inReplyTo: replyParent(envs), to: replyAudience(envs), ...(policy?.pii ? { to: [USER], private: true, priority: "important" as const, ...(policy.task ? { refs: { task: policy.task } } : {}) } : {}) };
|
|
220
|
-
this.run(envs, turn, msgs, progress, policy, reply, reviewScope)
|
|
251
|
+
this.run(envs, turn, msgs, progress, policy, reply, reviewScope, selection, modelFailure)
|
|
221
252
|
.then((answer) => {
|
|
222
253
|
if (turn !== this.turn) return;
|
|
223
254
|
if (preserveHistory && !reviewScope.excluded) this.commit(msgs); // Private, remote and review turns never enter later local capture through model history.
|
|
224
|
-
if (answer) this.
|
|
255
|
+
if (answer) this.finalAnswer(answer, reply, progress.answerComplete);
|
|
225
256
|
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed" });
|
|
226
257
|
completed = true;
|
|
227
258
|
})
|
|
@@ -278,11 +309,14 @@ export class LocalPeer extends BasePeer {
|
|
|
278
309
|
envs: Envelope[],
|
|
279
310
|
turn: number,
|
|
280
311
|
msgs: ChatMessage[],
|
|
281
|
-
progress: { sideEffects: number; last: string },
|
|
312
|
+
progress: { sideEffects: number; last: string; answerComplete: boolean },
|
|
282
313
|
policy: { route?: string; fixedModel?: string; pii: boolean; task?: string } | undefined,
|
|
283
314
|
reply: EnvelopeOpts,
|
|
284
315
|
reviewScope: { excluded: boolean },
|
|
316
|
+
selection?: ModelOverrideSelection,
|
|
317
|
+
modelFailure?: Error,
|
|
285
318
|
): Promise<string> {
|
|
319
|
+
if (modelFailure) throw modelFailure;
|
|
286
320
|
const { maxSteps = 30 } = this.opts;
|
|
287
321
|
const turnSignal = this.abort!.signal;
|
|
288
322
|
// Remote text/tool output is never memory evidence, even beside ordinary local work in the same turn.
|
|
@@ -294,22 +328,26 @@ export class LocalPeer extends BasePeer {
|
|
|
294
328
|
}
|
|
295
329
|
this.routeEnvs = envs;
|
|
296
330
|
this.routePii = !!policy?.pii;
|
|
331
|
+
this.routeReviewScope = reviewScope;
|
|
332
|
+
if (turn !== this.turn || turnSignal.aborted) return "";
|
|
333
|
+
const fixedPrompt = this.turnRecordPrompt(system(this.opts.cwd, this.opts.preamble));
|
|
297
334
|
const ctx = this.toolContext(envs, turnSignal, !!policy?.pii, reply);
|
|
298
335
|
let usedTools = false;
|
|
299
336
|
let captureInvocation = 0;
|
|
300
337
|
for (let step = 0; step < maxSteps; step++) {
|
|
301
338
|
this.elide(msgs);
|
|
302
|
-
const res = await this.call(msgs, policy, envs);
|
|
339
|
+
const res = await this.call(msgs, policy, envs, selection, fixedPrompt);
|
|
303
340
|
if (turn !== this.turn) return "";
|
|
304
341
|
this.touch();
|
|
305
342
|
msgs.push(res.message);
|
|
306
343
|
progress.last = res.message.content?.trim() || progress.last;
|
|
307
344
|
if (!res.message.tool_calls?.length) {
|
|
308
|
-
const concluded = await this.conclude(msgs, policy, turn, step, maxSteps, progress.last, turnSignal, res.routeDecision);
|
|
345
|
+
const concluded = await this.conclude(msgs, policy, turn, step, maxSteps, progress.last, turnSignal, res.routeDecision, selection, fixedPrompt);
|
|
309
346
|
if (turn !== this.turn) return "";
|
|
310
347
|
if (concluded === undefined) continue;
|
|
311
|
-
if (usedTools && !reviewScope.excluded) capture?.summarize(concluded);
|
|
312
|
-
|
|
348
|
+
if (usedTools && !reviewScope.excluded) capture?.summarize(concluded.answer);
|
|
349
|
+
progress.answerComplete = concluded.complete;
|
|
350
|
+
return concluded.answer;
|
|
313
351
|
}
|
|
314
352
|
if (res.message.tool_calls.some(call => call.function.name.replace(/^mcp__[^ ]+__/, "") === "hub_review" && reviewCaptureExcluded(call.function.name, call.function.arguments))) reviewScope.excluded = true;
|
|
315
353
|
const results: ChatMessage[] = [];
|
|
@@ -443,23 +481,24 @@ export class LocalPeer extends BasePeer {
|
|
|
443
481
|
}
|
|
444
482
|
|
|
445
483
|
/** Hub-owned in-process policy, with a fixed OmniRoute alias as fallback. */
|
|
446
|
-
private async call(turnMsgs: ChatMessage[], policy: { route?: string; fixedModel?: string; pii?: boolean; task?: string } | undefined, envs: Envelope[]): Promise<ChatResult> {
|
|
484
|
+
private async call(turnMsgs: ChatMessage[], policy: { route?: string; fixedModel?: string; pii?: boolean; task?: string } | undefined, envs: Envelope[], selection?: ModelOverrideSelection, fixedPrompt = system(this.opts.cwd, this.opts.preamble)): Promise<ChatResult> {
|
|
447
485
|
const { omni } = this.opts;
|
|
448
|
-
const route = effectiveLocalRoute(this.opts.route, policy?.route, policy?.fixedModel);
|
|
486
|
+
const route = selection?.model === undefined ? effectiveLocalRoute(this.opts.route, policy?.route, policy?.fixedModel) : undefined;
|
|
449
487
|
if (route && !route.startsWith("hub/") && !this.warnedLegacyRoutes.has(route)) {
|
|
450
488
|
this.warnedLegacyRoutes.add(route);
|
|
451
489
|
this.opts.log?.(`legacy route ${route} is deprecated and ignored; using fixed model`);
|
|
452
490
|
}
|
|
453
|
-
const fixedModel = policy?.fixedModel ?? this.opts.fixedModel;
|
|
491
|
+
const fixedModel = selection?.model ?? policy?.fixedModel ?? this.opts.fixedModel;
|
|
454
492
|
const task = policy?.task === undefined ? undefined : Number(policy.task);
|
|
455
493
|
const tools = [...this.toolSchemas(), ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].concat(SESSION_TITLE_TOOL).map(asFunction) : [])];
|
|
456
494
|
const signal = this.abort!.signal;
|
|
457
|
-
const messages: ChatMessage[] = [{ role: "system", content:
|
|
495
|
+
const messages: ChatMessage[] = [{ role: "system", content: fixedPrompt }, ...this.history, ...turnMsgs];
|
|
458
496
|
const routed = await this.hubCall(route, messages, policy, signal);
|
|
459
|
-
if (routed) return routed;
|
|
497
|
+
if (routed) { selection?.applied(); return routed; }
|
|
460
498
|
await this.requireBudget(envs, "model_calls");
|
|
461
499
|
this.releaseObserverInput(this.currentObserverInputContext, envs);
|
|
462
500
|
const res = await omni.chat({ model: fixedModel, messages, tools }, { signal, ...(policy?.pii ? { onCampusOnly: true } : {}) });
|
|
501
|
+
selection?.applied();
|
|
463
502
|
this.lastServedBy = `omniroute ${fixedModel} (provider ${res.provider ?? "?"})`;
|
|
464
503
|
this.recordUsage(res, fixedModel, task);
|
|
465
504
|
return res;
|
|
@@ -502,21 +541,21 @@ export class LocalPeer extends BasePeer {
|
|
|
502
541
|
if (!route || !config) return undefined;
|
|
503
542
|
try { return await this.routes.call(route, config, messages, this.routeScope(!!policy?.pii), !!policy?.pii, signal); }
|
|
504
543
|
catch (error) {
|
|
505
|
-
if (signal.aborted || error instanceof ExecutionBudgetStop) throw error;
|
|
544
|
+
if (signal.aborted || error instanceof ExecutionBudgetStop || error instanceof DecisionPolicyChangedError) throw error;
|
|
506
545
|
this.opts.log?.(`hub route ${route} unavailable; using fixed model`);
|
|
507
546
|
return undefined;
|
|
508
547
|
}
|
|
509
548
|
}
|
|
510
549
|
|
|
511
|
-
private async conclude(msgs: ChatMessage[], policy: { route?: string; fixedModel?: string; pii: boolean } | undefined, turn: number, step: number, maxSteps: number, answer: string, signal: AbortSignal, decisionId?: string): Promise<string | undefined> {
|
|
512
|
-
const route = effectiveLocalRoute(this.opts.route, policy?.route, policy?.fixedModel), config = this.hubConfig(route);
|
|
513
|
-
if (!route || !config) return answer;
|
|
514
|
-
const messages: ChatMessage[] = [{ role: "system", content:
|
|
550
|
+
private async conclude(msgs: ChatMessage[], policy: { route?: string; fixedModel?: string; pii: boolean } | undefined, turn: number, step: number, maxSteps: number, answer: string, signal: AbortSignal, decisionId?: string, selection?: ModelOverrideSelection, fixedPrompt = system(this.opts.cwd, this.opts.preamble)): Promise<{ answer: string; complete: boolean } | undefined> {
|
|
551
|
+
const route = selection?.model === undefined ? effectiveLocalRoute(this.opts.route, policy?.route, policy?.fixedModel) : undefined, config = this.hubConfig(route);
|
|
552
|
+
if (!route || !config) return { answer, complete: true };
|
|
553
|
+
const messages: ChatMessage[] = [{ role: "system", content: fixedPrompt }, ...this.history, ...msgs];
|
|
515
554
|
const feedback = await this.routes.review(route, config, messages, this.routeScope(!!policy?.pii, turn), !!policy?.pii, signal, decisionId);
|
|
516
|
-
if (turn !== this.turn || !feedback) return answer;
|
|
555
|
+
if (turn !== this.turn || !feedback) return { answer, complete: true };
|
|
517
556
|
msgs.push({ role: "user", content: feedback });
|
|
518
557
|
if (step + 1 >= maxSteps) this.opts.onLimit?.("steps");
|
|
519
|
-
return step + 1 < maxSteps ? undefined : `(review requested changes; step limit reached) ${answer}
|
|
558
|
+
return step + 1 < maxSteps ? undefined : { answer: `(review requested changes; step limit reached) ${answer}`, complete: false };
|
|
520
559
|
}
|
|
521
560
|
|
|
522
561
|
private observeTool(call: ToolCall, output: string, policy: { pii: boolean; task?: string } | undefined, nativeTurn: string): void {
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** #844 native model word: no spaces or leading dash, at most 128 ASCII bytes. */
|
|
2
|
+
const NATIVE_WORD = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,127}$/;
|
|
3
|
+
export function isNativeModelToken(value: unknown): value is string { return typeof value === "string" && NATIVE_WORD.test(value); }
|
package/src/adapters/pi.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ModelOverrideSelection } from "./registry-types.ts";
|
|
2
|
+
import { isNativeModelToken } from "./model-token.ts";
|
|
1
3
|
import { buildPiLaunch } from "../pi/launch.ts";
|
|
2
4
|
import { randomUUID } from "node:crypto";
|
|
3
5
|
import { existsSync, mkdirSync } from "node:fs";
|
|
@@ -41,6 +43,8 @@ export interface PiOptions {
|
|
|
41
43
|
hubStateDir?: string;
|
|
42
44
|
cwd: string; stateDir: string; cmd?: string[]; mode: "headless" | "tui"; backend: "auto" | "dgx" | "mlx"; sessionFile?: string; sessionId?: string;
|
|
43
45
|
model?: string;
|
|
46
|
+
/** Host-only choice sampled once at the next turn/review boundary. */
|
|
47
|
+
getModelOverride?: () => ModelOverrideSelection;
|
|
44
48
|
relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string, signal?: AbortSignal, context?: PiToolContext) => Promise<string | PiToolResult>; tools: PiToolSchema[];
|
|
45
49
|
preamble?: string;
|
|
46
50
|
/** Host-owned conditional instruction, sampled once per delivered turn. */
|
|
@@ -205,6 +209,8 @@ export class PiPeer extends BasePeer {
|
|
|
205
209
|
private owner = randomUUID();
|
|
206
210
|
private _tuiLaunch?: PiTuiLaunch;
|
|
207
211
|
private requestedModel = "";
|
|
212
|
+
private baselineRequestedModel = "";
|
|
213
|
+
private runtimeModelSelected = false;
|
|
208
214
|
private starting?: Promise<void>;
|
|
209
215
|
private ownerClaimed = false;
|
|
210
216
|
private ownerToken = "";
|
|
@@ -233,7 +239,7 @@ export class PiPeer extends BasePeer {
|
|
|
233
239
|
private tuiWaiters: Array<{ resolve: (command: Record<string, unknown> | undefined) => void; timer: ReturnType<typeof setTimeout> }> = [];
|
|
234
240
|
|
|
235
241
|
/** The fixed production factory supplies preflight separately, preserving transport option identity. */
|
|
236
|
-
constructor(id: PeerId, private readonly opts: PiOptions, private readonly preflight?: () => Promise<void>) { super(id, opts.watchdogMs); }
|
|
242
|
+
constructor(id: PeerId, private readonly opts: PiOptions, private readonly preflight?: () => Promise<void>) { super(id, opts.watchdogMs); this.baselineRequestedModel = opts.model ?? ""; }
|
|
237
243
|
get tuiLaunch(): PiTuiLaunch | undefined { return this._tuiLaunch; }
|
|
238
244
|
get pendingResume(): { sessionId?: string; sessionFile?: string } {
|
|
239
245
|
if (this.canReuseVerifiedEmptyResume()) return { sessionId: this.verifiedEmptyResume!.sessionId };
|
|
@@ -754,14 +760,23 @@ export class PiPeer extends BasePeer {
|
|
|
754
760
|
let attempted = false;
|
|
755
761
|
let promptId: string | undefined;
|
|
756
762
|
try {
|
|
757
|
-
const
|
|
763
|
+
const selection = this.opts.getModelOverride?.();
|
|
764
|
+
if (selection?.model !== undefined && !isNativeModelToken(selection.model)) throw new Error("runtime model must be one bounded native token");
|
|
765
|
+
const baseline = await this.opts.selectModel?.(envs);
|
|
766
|
+
const requested = selection?.model ?? baseline ?? (this.baselineRequestedModel || undefined);
|
|
767
|
+
if (selection && selection.model === undefined && this.runtimeModelSelected && !requested) throw new Error(`baseline model unavailable; restore the peer model configuration and run ahub restart ${this.id} before retrying`);
|
|
758
768
|
if (rosterEpoch !== this.rosterEpoch || !this.acceptingTools) throw new Error("Pi is off roster or stopped");
|
|
759
|
-
if (requested)
|
|
769
|
+
if (requested) {
|
|
770
|
+
if (selection?.model !== undefined) await this.applyRequestedModel(requested);
|
|
771
|
+
else await this.setRequestedModel(requested);
|
|
772
|
+
this.runtimeModelSelected = selection?.model !== undefined;
|
|
773
|
+
}
|
|
760
774
|
if (rosterEpoch !== this.rosterEpoch || !this.acceptingTools) throw new Error("Pi is off roster or stopped");
|
|
761
775
|
this.noteActivity();
|
|
762
776
|
attempted = true;
|
|
763
777
|
if (deliveryId) this.activeDeliveryIds.add(deliveryId);
|
|
764
|
-
const
|
|
778
|
+
const recordPrompt = this.opts.mode === "tui" ? "" : this.turnRecordPrompt("");
|
|
779
|
+
const message = [recordPrompt, envs.some(env => env.private || remoteEnvelope(env)) ? undefined : this.opts.titlePreface?.(), renderDigest(envs, true)].filter(Boolean).join("\n");
|
|
765
780
|
if (this.opts.mode === "tui") { const pending = this.sendTui({ type: "prompt", message }); promptId = this.pendingPromptId; await pending; }
|
|
766
781
|
else await this.sendRpc({ type: "prompt", message });
|
|
767
782
|
if (promptId !== undefined && promptId !== this.pendingPromptId) return;
|
|
@@ -770,6 +785,8 @@ export class PiPeer extends BasePeer {
|
|
|
770
785
|
this.setState("offline");
|
|
771
786
|
return;
|
|
772
787
|
}
|
|
788
|
+
if (rosterEpoch !== this.rosterEpoch || !this.acceptingTools) throw new Error("Pi is off roster or stopped");
|
|
789
|
+
try { selection?.applied(); } catch { /* Only prompt acceptance proves this turn used the selected model or unchanged native default. */ }
|
|
773
790
|
if (deliveryId && this.activeDeliveryIds.has(deliveryId)) this.delivery({ id: deliveryId, state: "accepted" });
|
|
774
791
|
} catch (error) {
|
|
775
792
|
if (promptId !== undefined && promptId !== this.pendingPromptId) return;
|
|
@@ -831,7 +848,12 @@ export class PiPeer extends BasePeer {
|
|
|
831
848
|
}
|
|
832
849
|
|
|
833
850
|
getRequestedModel(): string { return this.requestedModel; }
|
|
851
|
+
getBaselineRequestedModel(): string { return this.baselineRequestedModel; }
|
|
834
852
|
async setRequestedModel(model: string): Promise<void> {
|
|
853
|
+
await this.applyRequestedModel(model);
|
|
854
|
+
this.baselineRequestedModel = model;
|
|
855
|
+
}
|
|
856
|
+
private async applyRequestedModel(model: string): Promise<void> {
|
|
835
857
|
if (!this.acceptingTools) throw new Error("Pi is off roster or stopped");
|
|
836
858
|
const slash = model.indexOf("/");
|
|
837
859
|
const provider = "agent-hub-local";
|
|
@@ -971,7 +993,7 @@ export class PiPeer extends BasePeer {
|
|
|
971
993
|
else if (error?.startsWith("execution budget")) this.onMessage?.(`Pi stopped at the execution budget: ${error}. Inspect partial work before continuing.`, reply);
|
|
972
994
|
else if (approvalStopReason) this.onMessage?.(approvalStopReason, reply);
|
|
973
995
|
else if (error) void this.opts.onTurnFailure?.(this.activeEnvs, error, ceiling);
|
|
974
|
-
else if (text) this.
|
|
996
|
+
else if (text) this.finalAnswer(text, reply);
|
|
975
997
|
for (const id of this.activeDeliveryIds) this.delivery({ id, state: approvalStopReason ? "completed" : error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
|
|
976
998
|
this.activeDeliveryIds.clear();
|
|
977
999
|
this.currentReply = undefined; this.activeEnvs = [];
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/** One host-latched runtime choice; applied binds the captured revision and boundary, never a later value. */
|
|
2
|
+
export interface ModelOverrideSelection {
|
|
3
|
+
readonly model: string | undefined;
|
|
4
|
+
applied(nativeModel?: string): void;
|
|
5
|
+
/** Retire only this still-unacknowledged capture after a definitive native pre-start rejection. */
|
|
6
|
+
rejected?(): void;
|
|
7
|
+
}
|
|
8
|
+
|
|
1
9
|
import type { ClaudeImplementerOptions } from "./claude-implementer.ts";
|
|
2
10
|
import type { ReviewerBackend, ReviewerPrintOptions } from "./reviewer-print.ts";
|
|
3
11
|
import type { SettingDef } from "../hub/settings.ts";
|
|
@@ -126,7 +134,7 @@ export interface ContextReader {
|
|
|
126
134
|
export type { Capability, CapabilityManifest, SideEffect } from "../hub/capabilities.ts";
|
|
127
135
|
|
|
128
136
|
export interface NativeFactsInput {
|
|
129
|
-
event: "SessionStart" | "PreInvocation" | "PostInvocation" | "PreToolUse" | "PostToolUse" | "Stop";
|
|
137
|
+
event: "SessionStart" | "PreInvocation" | "PostInvocation" | "PreToolUse" | "PostToolUse" | "Stop" | "TurnStarted" | "StopFailure" | "Interrupt" | "SessionEnd" | "TaskStarted" | "Notification";
|
|
130
138
|
phase: "session" | "start" | "pre" | "post" | "stop";
|
|
131
139
|
sessionId: string;
|
|
132
140
|
transcriptPath: string;
|
|
@@ -138,15 +146,36 @@ export interface NativeFactsInput {
|
|
|
138
146
|
fullyIdle?: boolean;
|
|
139
147
|
terminationReason?: string;
|
|
140
148
|
}
|
|
149
|
+
export interface NativeFactsReadOptions {
|
|
150
|
+
completion?: boolean;
|
|
151
|
+
sinceOffset?: number;
|
|
152
|
+
unchanged?: { size: number; mtimeMs: number };
|
|
153
|
+
}
|
|
154
|
+
export interface NativeFactsUsage {
|
|
155
|
+
id: string;
|
|
156
|
+
index: number;
|
|
157
|
+
inputTokens: number;
|
|
158
|
+
outputTokens: number;
|
|
159
|
+
cacheReadTokens: number;
|
|
160
|
+
totalTokens: number;
|
|
161
|
+
at?: string;
|
|
162
|
+
}
|
|
141
163
|
export interface NativeFactsTranscript {
|
|
142
|
-
completion?: { id: string; index: number };
|
|
164
|
+
completion?: { id: string; index: number; reason?: "completed" | "failed" | "cancelled" };
|
|
165
|
+
started?: { id: string; index: number; origin: string };
|
|
166
|
+
background?: number;
|
|
143
167
|
factIds: string[];
|
|
168
|
+
usage?: NativeFactsUsage[];
|
|
169
|
+
size?: number;
|
|
170
|
+
mtimeMs?: number;
|
|
171
|
+
scannedFrom?: number;
|
|
172
|
+
unchanged?: boolean;
|
|
144
173
|
}
|
|
145
174
|
/** Fixed native codec and bounded readback, never supplied by project configuration. */
|
|
146
175
|
export interface NativeFactsPort {
|
|
147
176
|
decode(event: string, input: unknown): NativeFactsInput | undefined;
|
|
148
177
|
encode(event: string, text?: string): Record<string, unknown> | undefined;
|
|
149
|
-
read(session: string, transcript: string): NativeFactsTranscript;
|
|
178
|
+
read(session: string, transcript: string, options?: NativeFactsReadOptions): NativeFactsTranscript;
|
|
150
179
|
}
|
|
151
180
|
|
|
152
181
|
/** Only the frozen native seams are extensible; a definition cannot add an arbitrary daemon callback. */
|
|
@@ -300,6 +329,8 @@ export interface AdapterDefinition {
|
|
|
300
329
|
validateEffective?(raw: Record<string, unknown>, fail: (key: string, reason: string) => never): void;
|
|
301
330
|
};
|
|
302
331
|
capabilities?: { manifest?(peer: PeerAdapter): Promise<CapabilityManifest>; watch?: boolean };
|
|
332
|
+
/** Fixed factory support only; the host still checks the actual native surface and form. */
|
|
333
|
+
modelOverride?: { readonly boundary: "turn"; readonly acp?: import("./acp-model-policy.ts").AcpModelPolicy };
|
|
303
334
|
/** Stable host projection order and planner eligibility, independent of default task roles. */
|
|
304
335
|
projection?: { roleRank?: number; startRank?: number; helpRank?: number; plannerEligible?: boolean };
|
|
305
336
|
cli: {
|
package/src/adapters/registry.ts
CHANGED
|
@@ -243,6 +243,7 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
|
|
|
243
243
|
["titlePersistence", definition.titlePersistence !== undefined],
|
|
244
244
|
["questionWaitMs", definition.questionWaitMs !== undefined],
|
|
245
245
|
["settle", definition.settle !== undefined],
|
|
246
|
+
["modelOverride", definition.modelOverride !== undefined],
|
|
246
247
|
["start.acpCommand", declared(definition.start, "acpCommand")],
|
|
247
248
|
["attach.admission", declared(definition.attach, "admission")],
|
|
248
249
|
["budget.resetSoftTokensOnResume", declared(definition.budget, "resetSoftTokensOnResume")],
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** A fixed host producer's full policy check, never a verdict-authority cache. */
|
|
2
|
+
export interface ReviewerPolicyProbe {
|
|
3
|
+
/** Boot/protocol failures revoke even when a child exits before its first periodic poll. */
|
|
4
|
+
readonly failure?: Promise<void>;
|
|
5
|
+
check(): Promise<boolean>;
|
|
6
|
+
stop(): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export interface ReviewerPolicyWatch {
|
|
9
|
+
/** The caller attaches ownership before any timer or notification can run. */
|
|
10
|
+
start(): void;
|
|
11
|
+
/** Only an explicit owner retry may repeat a failed join. */
|
|
12
|
+
stop(retry?: boolean): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export interface ReviewerPolicyWatchClock {
|
|
15
|
+
now(): number;
|
|
16
|
+
schedule(delayMs: number, run: () => void): () => void;
|
|
17
|
+
}
|
|
18
|
+
const POLICY_POLL_MS = 100;
|
|
19
|
+
|
|
20
|
+
/** Serialized absolute cadence: an overdue check runs now, coalescing missed slots at its start. */
|
|
21
|
+
export function createReviewerPolicyWatch(probe: ReviewerPolicyProbe, changed: () => void,
|
|
22
|
+
clock: Partial<ReviewerPolicyWatchClock> = {}): ReviewerPolicyWatch {
|
|
23
|
+
const now = clock.now ?? (() => performance.now());
|
|
24
|
+
const schedule = clock.schedule ?? ((delayMs: number, run: () => void) => {
|
|
25
|
+
const timer = setTimeout(run, delayMs); timer.unref?.();
|
|
26
|
+
return () => clearTimeout(timer);
|
|
27
|
+
});
|
|
28
|
+
let started = false, stopped = false, due = 0;
|
|
29
|
+
let cancelTimer: (() => void) | undefined, flight: Promise<void> | undefined;
|
|
30
|
+
let stopping: Promise<void> | undefined, stopFailed = false;
|
|
31
|
+
const sample = () => {
|
|
32
|
+
const value = now();
|
|
33
|
+
if (!Number.isFinite(value)) throw new Error("review policy clock unavailable");
|
|
34
|
+
return value;
|
|
35
|
+
};
|
|
36
|
+
const stop = (retry = false): Promise<void> => {
|
|
37
|
+
stopped = true;
|
|
38
|
+
cancelTimer?.(); cancelTimer = undefined;
|
|
39
|
+
if (retry && stopFailed) { stopping = undefined; stopFailed = false; }
|
|
40
|
+
if (!stopping) {
|
|
41
|
+
const pending = flight;
|
|
42
|
+
// Termination must settle a blocked check before joining that check; the reverse order can deadlock.
|
|
43
|
+
stopping = Promise.resolve().then(() => probe.stop()).then(async () => { await pending; });
|
|
44
|
+
void stopping.catch(() => { stopFailed = true; });
|
|
45
|
+
}
|
|
46
|
+
return stopping;
|
|
47
|
+
};
|
|
48
|
+
const fail = () => {
|
|
49
|
+
if (stopped) return;
|
|
50
|
+
void stop().catch(() => {}); // The owner retains and observes the same failed join.
|
|
51
|
+
try { changed(); } catch { /* The probe is already stopping; a notification cannot rearm it. */ }
|
|
52
|
+
};
|
|
53
|
+
const arm = () => {
|
|
54
|
+
if (stopped) return;
|
|
55
|
+
try { cancelTimer = schedule(Math.max(0, due - sample()), tick); }
|
|
56
|
+
catch { fail(); }
|
|
57
|
+
};
|
|
58
|
+
const tick = () => {
|
|
59
|
+
cancelTimer = undefined;
|
|
60
|
+
if (stopped) return;
|
|
61
|
+
if (flight) { fail(); return; }
|
|
62
|
+
try {
|
|
63
|
+
const at = sample();
|
|
64
|
+
if (at > due) due += Math.floor((at - due) / POLICY_POLL_MS) * POLICY_POLL_MS;
|
|
65
|
+
} catch { fail(); return; }
|
|
66
|
+
const pending = (async () => {
|
|
67
|
+
let current = false;
|
|
68
|
+
try { current = await probe.check() === true; } catch { /* A failed check is policy drift. */ }
|
|
69
|
+
if (stopped) return;
|
|
70
|
+
if (!current) { fail(); return; }
|
|
71
|
+
due += POLICY_POLL_MS;
|
|
72
|
+
arm();
|
|
73
|
+
})();
|
|
74
|
+
flight = pending;
|
|
75
|
+
void pending.finally(() => { if (flight === pending) flight = undefined; }).catch(() => {});
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
start: () => {
|
|
79
|
+
if (started || stopped) return;
|
|
80
|
+
started = true;
|
|
81
|
+
if (probe.failure) void probe.failure.then(fail, fail);
|
|
82
|
+
try { due = sample() + POLICY_POLL_MS; arm(); } catch { fail(); }
|
|
83
|
+
},
|
|
84
|
+
stop,
|
|
85
|
+
};
|
|
86
|
+
}
|