switchroom 0.18.31 → 0.18.33
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/dist/agent-scheduler/index.js +4 -2
- package/dist/auth-broker/index.js +21 -3
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +1410 -852
- package/dist/host-control/main.js +22 -4
- package/dist/vault/approvals/kernel-server.js +21 -3
- package/dist/vault/broker/server.js +48 -4
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +148 -23
- package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
- package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
- package/telegram-plugin/gateway/gateway.ts +6403 -13456
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/pending-user-notice.ts +59 -13
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +297 -31
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
- package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
- package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
- package/telegram-plugin/tests/obligation-store.test.ts +43 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +110 -38
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +34 -4
- package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
- package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
- package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
- package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
|
@@ -81,8 +81,16 @@ interface TrailEntry {
|
|
|
81
81
|
text: string;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// Mirrors the production matcher (`pending-work-progress.ts` SUFFIX_RE):
|
|
85
|
+
// the CURRENT emit is the italic rich-markdown form
|
|
86
|
+
// `\n\n_still working (Nm) · message me anytime, I'll keep you posted_`
|
|
87
|
+
// (#2669) — mtcute's parsed `Message.text` strips the italic markers, so
|
|
88
|
+
// the observed text is `\n\nstill working (Nm) · …`. The legacy em-dash
|
|
89
|
+
// prefix (`— still working`) and a literal-underscore render are also
|
|
90
|
+
// tolerated. The old mandatory-em-dash regex could not match ANY current
|
|
91
|
+
// production edit (stale oracle, diagnosed 2026-07-18).
|
|
84
92
|
const SUFFIX_RE =
|
|
85
|
-
/\n\n
|
|
93
|
+
/\n\n(?:— |_)?still working \(\d+m\)( · message me anytime, I'll keep you posted)?_?$/;
|
|
86
94
|
|
|
87
95
|
function pad(s: string, n: number): string {
|
|
88
96
|
return s.length >= n ? s : s + " ".repeat(n - s.length);
|
|
@@ -139,11 +147,20 @@ describe("uat: cross-turn pending-async ambient progress (#1445)", () => {
|
|
|
139
147
|
if (firstAnchorMsgId == null && entry.kind === "fresh") {
|
|
140
148
|
firstAnchorMsgId = entry.messageId;
|
|
141
149
|
}
|
|
150
|
+
// EXACT match only. The prompt demands the completion signal be
|
|
151
|
+
// the single word "done" and nothing else; the loose `/\bdone\b/`
|
|
152
|
+
// fallback false-positived on the model's beat-1 ACK ("…I'll
|
|
153
|
+
// reply \"done\" when it finishes"), tripping a 10s wind-down that
|
|
154
|
+
// quit the test ~22s BEFORE the turn even ended — so the ambient
|
|
155
|
+
// edit mechanism was never observable (#3334 item b, observed UAT
|
|
156
|
+
// v0.18.32 r2). Requiring `trim() === "done"` makes the terminal
|
|
157
|
+
// signal deterministic and un-spoofable by an ack that merely
|
|
158
|
+
// mentions the word.
|
|
142
159
|
const trimmedFinal = entry.text.trim().toLowerCase();
|
|
143
160
|
const looksLikeDone =
|
|
144
161
|
entry.kind === "fresh" &&
|
|
145
162
|
entry.messageId !== firstAnchorMsgId &&
|
|
146
|
-
|
|
163
|
+
trimmedFinal === "done";
|
|
147
164
|
if (looksLikeDone) {
|
|
148
165
|
sawDone = true;
|
|
149
166
|
quiescenceDeadline = Date.now() + 10_000;
|
|
@@ -55,8 +55,10 @@
|
|
|
55
55
|
import { describe, it, expect, beforeAll } from "vitest";
|
|
56
56
|
import { execSync } from "node:child_process";
|
|
57
57
|
import { spinUp } from "../harness.js";
|
|
58
|
+
import { restartCapableOrAnnounceSkip } from "../restart-capability.js";
|
|
58
59
|
|
|
59
60
|
const AGENT = "test-harness";
|
|
61
|
+
const SCENARIO_TITLE = "uat: always-on after restart";
|
|
60
62
|
|
|
61
63
|
// Budget for the marker-safe restart itself (per
|
|
62
64
|
// feedback_agent_restart_needs_sudo_when_running.md, restart blocks
|
|
@@ -74,15 +76,6 @@ const HARD_REPLY_BUDGET_MS = 120_000;
|
|
|
74
76
|
// contract but worth logging for forensic visibility.
|
|
75
77
|
const VISION_REPLY_BUDGET_MS = 30_000;
|
|
76
78
|
|
|
77
|
-
function canShellSudo(): boolean {
|
|
78
|
-
try {
|
|
79
|
-
execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
|
|
80
|
-
return true;
|
|
81
|
-
} catch {
|
|
82
|
-
return false;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
79
|
function restartAgent(name: string): void {
|
|
87
80
|
// Marker-safe restart per memory feedback_compose_rollout.md +
|
|
88
81
|
// feedback_agent_restart_needs_sudo_when_running.md. Apply step
|
|
@@ -96,11 +89,12 @@ function restartAgent(name: string): void {
|
|
|
96
89
|
}
|
|
97
90
|
|
|
98
91
|
// This scenario requires NOPASSWD sudo + the switchroom CLI on PATH on
|
|
99
|
-
// the harness host. Skip on CI runners that don't expose those
|
|
100
|
-
|
|
92
|
+
// the harness host. Skip on CI runners that don't expose those — and when
|
|
93
|
+
// we skip, announce it LOUDLY so a green isn't mistaken for live proof.
|
|
94
|
+
const sudoOk = restartCapableOrAnnounceSkip(SCENARIO_TITLE);
|
|
101
95
|
|
|
102
96
|
(sudoOk ? describe : describe.skip)(
|
|
103
|
-
|
|
97
|
+
SCENARIO_TITLE,
|
|
104
98
|
() => {
|
|
105
99
|
beforeAll(() => {
|
|
106
100
|
restartAgent(AGENT);
|
|
@@ -21,10 +21,13 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { describe, it, expect } from "vitest";
|
|
24
|
-
import {
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
25
|
import { spinUp } from "../harness.js";
|
|
26
|
+
import { restartCapableOrAnnounceSkip } from "../restart-capability.js";
|
|
26
27
|
|
|
27
28
|
const AGENT = "test-harness";
|
|
29
|
+
const SCENARIO_TITLE =
|
|
30
|
+
"uat: deliberate restart mid-turn resumes exactly once (DM, #2988)";
|
|
28
31
|
const MID_TURN_MS = 10_000; // let the turn get in-flight before the bounce
|
|
29
32
|
const RESUME_BUDGET_MS = 180_000; // boot + resume + reply
|
|
30
33
|
const QUIET_WINDOW_MS = 90_000; // after the resume completes, no second resume may fire
|
|
@@ -36,15 +39,6 @@ const SAME_TURN_GRACE_MS = 20_000;
|
|
|
36
39
|
|
|
37
40
|
const RESUME_FRAMING = /resum|picking .*back|interrupted|cut off|just restarted/i;
|
|
38
41
|
|
|
39
|
-
function canShellSudo(): boolean {
|
|
40
|
-
try {
|
|
41
|
-
execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
|
|
42
|
-
return true;
|
|
43
|
-
} catch {
|
|
44
|
-
return false;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
42
|
function kickRestartDetached(name: string): void {
|
|
49
43
|
// A deliberate operator restart: clean SIGTERM path, clean-shutdown marker
|
|
50
44
|
// written — the exact class #2988 makes resume in-flight work through.
|
|
@@ -57,10 +51,10 @@ function kickRestartDetached(name: string): void {
|
|
|
57
51
|
child.unref();
|
|
58
52
|
}
|
|
59
53
|
|
|
60
|
-
const sudoOk =
|
|
54
|
+
const sudoOk = restartCapableOrAnnounceSkip(SCENARIO_TITLE);
|
|
61
55
|
|
|
62
56
|
(sudoOk ? describe : describe.skip)(
|
|
63
|
-
|
|
57
|
+
SCENARIO_TITLE,
|
|
64
58
|
() => {
|
|
65
59
|
it(
|
|
66
60
|
"an operator restart mid-turn resumes the work once, and never a second time",
|
|
@@ -23,26 +23,20 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { describe, it, expect } from "vitest";
|
|
26
|
-
import {
|
|
26
|
+
import { spawn } from "node:child_process";
|
|
27
27
|
import { spinUp } from "../harness.js";
|
|
28
|
+
import { restartCapableOrAnnounceSkip } from "../restart-capability.js";
|
|
28
29
|
|
|
29
30
|
const AGENT = "test-harness";
|
|
30
31
|
const MID_TURN_MS = 10_000; // let the turn enqueue (become a recorded interrupted turn)
|
|
31
32
|
const RESUME_BUDGET_MS = 180_000; // boot + resume + reply
|
|
32
33
|
|
|
34
|
+
const SCENARIO_TITLE = "uat: interrupted turn resumes after restart (DM)";
|
|
35
|
+
|
|
33
36
|
// The resume builder tells the model to "briefly let the user know you're
|
|
34
37
|
// resuming what was interrupted" — so the reply always opens with this framing.
|
|
35
38
|
const RESUME_FRAMING = /resum|picking .*back|interrupted|cut off|just restarted/i;
|
|
36
39
|
|
|
37
|
-
function canShellSudo(): boolean {
|
|
38
|
-
try {
|
|
39
|
-
execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
|
|
40
|
-
return true;
|
|
41
|
-
} catch {
|
|
42
|
-
return false;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
40
|
function kickRestartDetached(name: string): void {
|
|
47
41
|
// --force WITHOUT --wait → recreate now, interrupting the in-flight turn.
|
|
48
42
|
const child = spawn(
|
|
@@ -54,9 +48,9 @@ function kickRestartDetached(name: string): void {
|
|
|
54
48
|
child.unref();
|
|
55
49
|
}
|
|
56
50
|
|
|
57
|
-
const sudoOk =
|
|
51
|
+
const sudoOk = restartCapableOrAnnounceSkip(SCENARIO_TITLE);
|
|
58
52
|
|
|
59
|
-
(sudoOk ? describe : describe.skip)(
|
|
53
|
+
(sudoOk ? describe : describe.skip)(SCENARIO_TITLE, () => {
|
|
60
54
|
it(
|
|
61
55
|
"a turn interrupted by a restart is resumed and the resume turn completes",
|
|
62
56
|
async () => {
|
|
@@ -70,23 +70,47 @@ const TAIL = "MULTITAIL9";
|
|
|
70
70
|
|
|
71
71
|
// We need the composed reply to exceed the 32768-char cap so the gateway
|
|
72
72
|
// chunks it. Asking a model to emit ~40k literal chars is flaky (it'll
|
|
73
|
-
// summarize or truncate).
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
73
|
+
// summarize or truncate). We keep the task MECHANICAL (cycle a fixed
|
|
74
|
+
// sentence list, prefix each line with its index) so an agent reliably
|
|
75
|
+
// obeys — but every emitted line is UNIQUE. Verbatim repetition of one
|
|
76
|
+
// sentence 900+ times reads as degenerate output to Anthropic's content
|
|
77
|
+
// filter and 4xx-kills the turn before any reply exists to send (observed
|
|
78
|
+
// live 2026-07-18, ci-uat run 29634400115: four "unknown-4xx / Output
|
|
79
|
+
// blocked by content filtering policy" events, gateway-supervisor.log
|
|
80
|
+
// 06:43–06:53Z — the reply never reached the send path).
|
|
81
|
+
const PAD_SENTENCES = [
|
|
82
|
+
"The quick brown fox jumps over the lazy dog.",
|
|
83
|
+
"Pack my box with five dozen liquor jugs.",
|
|
84
|
+
"How vexingly quick daft zebras jump.",
|
|
85
|
+
"Sphinx of black quartz, judge my vow.",
|
|
86
|
+
"The five boxing wizards jump quickly.",
|
|
87
|
+
"Jackdaws love my big sphinx of quartz.",
|
|
88
|
+
"Waltz, bad nymph, for quick jigs vex.",
|
|
89
|
+
"Glib jocks quiz nymph to vex dwarf.",
|
|
90
|
+
"Bright vixens jump; dozy fowl quack.",
|
|
91
|
+
"Quick zephyrs blow, vexing daft Jim.",
|
|
92
|
+
];
|
|
93
|
+
// Conservative per-line floor: the SHORTEST pad sentence plus the numeric
|
|
94
|
+
// prefix. Overshoot the cap by a 1.3 safety factor so a slightly-short
|
|
95
|
+
// reply still splits.
|
|
96
|
+
const PER_LINE_MIN =
|
|
97
|
+
Math.min(...PAD_SENTENCES.map((s) => s.length)) + "Line 1: ".length;
|
|
98
|
+
const LINE_COUNT = Math.ceil((RICH_MESSAGE_MAX_CHARS / PER_LINE_MIN) * 1.3);
|
|
80
99
|
|
|
81
100
|
const PROMPT = [
|
|
82
101
|
`I need a LONG reply to test message chunking. Do EXACTLY this, nothing else:`,
|
|
83
102
|
``,
|
|
84
103
|
`1. Start your reply with: ${HEAD}: **head bold marker**`,
|
|
85
|
-
`2. Then output
|
|
86
|
-
` "
|
|
104
|
+
`2. Then output ${LINE_COUNT} numbered lines. Line i (1-based) must be:`,
|
|
105
|
+
` "Line i: " followed by sentence number (i mod 10) from this list`,
|
|
106
|
+
` (list index 0-9):`,
|
|
107
|
+
...PAD_SENTENCES.map((s, idx) => ` ${idx}: "${s}"`),
|
|
108
|
+
` So line 1 is "Line 1: ${PAD_SENTENCES[1]}", line 2 is`,
|
|
109
|
+
` "Line 2: ${PAD_SENTENCES[2]}", and so on, wrapping the list.`,
|
|
87
110
|
`3. End your reply with: ${TAIL}: **tail bold marker**`,
|
|
88
111
|
``,
|
|
89
|
-
`
|
|
112
|
+
`Every line is unique because of its number. Do not summarize, do not`,
|
|
113
|
+
`stop early — emit all ${LINE_COUNT} lines.`,
|
|
90
114
|
].join("\n");
|
|
91
115
|
|
|
92
116
|
function kinds(msg: ObservedMessage): Set<string> {
|
|
@@ -104,9 +128,17 @@ function kinds(msg: ObservedMessage): Set<string> {
|
|
|
104
128
|
await sc.sendDM(PROMPT);
|
|
105
129
|
|
|
106
130
|
// Chunk 1: the part carrying the HEAD marker.
|
|
131
|
+
//
|
|
132
|
+
// Budget: 240s, not 120s. When the model stages the long output
|
|
133
|
+
// via a file Write+Read (a 900+-line, ~90k-token turn), the
|
|
134
|
+
// inbound→chunks wall time runs ~3m49s (observed UAT v0.18.32 r2:
|
|
135
|
+
// test gave up at 120s / 08:42:13; the correctly-chunked reply
|
|
136
|
+
// actually landed 08:44:02 — a timing-budget oracle defect, not a
|
|
137
|
+
// delivery failure, since both ordered chunks delivered with zero
|
|
138
|
+
// 4xx). 240s clears that staging path with headroom (#3334 item a).
|
|
107
139
|
const first = await sc.expectMessage(
|
|
108
140
|
(m: ObservedMessage) => m.text.includes(HEAD) || m.text === "\x01",
|
|
109
|
-
{ from: "bot", timeout:
|
|
141
|
+
{ from: "bot", timeout: 240_000 },
|
|
110
142
|
);
|
|
111
143
|
// Chunk 2 (final): the part carrying the TAIL marker. This is a
|
|
112
144
|
// DIFFERENT message than `first` — if the reply hadn't chunked,
|
|
@@ -116,7 +148,7 @@ function kinds(msg: ObservedMessage): Set<string> {
|
|
|
116
148
|
(m: ObservedMessage) =>
|
|
117
149
|
(m.text.includes(TAIL) || m.text === "\x01") &&
|
|
118
150
|
m.messageId !== first.messageId,
|
|
119
|
-
{ from: "bot", timeout:
|
|
151
|
+
{ from: "bot", timeout: 240_000 },
|
|
120
152
|
);
|
|
121
153
|
|
|
122
154
|
// (4) Neither part is the unsupported-media sentinel.
|
|
@@ -163,7 +195,9 @@ function kinds(msg: ObservedMessage): Set<string> {
|
|
|
163
195
|
await sc.tearDown();
|
|
164
196
|
}
|
|
165
197
|
},
|
|
166
|
-
|
|
198
|
+
// Outer budget must exceed the two sequential 240s expectMessage
|
|
199
|
+
// windows (chunk 1, then chunk 2) plus spinUp settle + slack.
|
|
200
|
+
540_000,
|
|
167
201
|
);
|
|
168
202
|
},
|
|
169
203
|
);
|
|
@@ -54,11 +54,12 @@ import {
|
|
|
54
54
|
stripMarkdown,
|
|
55
55
|
truncate,
|
|
56
56
|
} from './card-format.js'
|
|
57
|
-
import {
|
|
57
|
+
import { WORKER_HISTORY_MAX } from './status-no-truncate.js'
|
|
58
58
|
import {
|
|
59
59
|
renderStatusCard,
|
|
60
60
|
formatStepSuffix,
|
|
61
61
|
renderCombinedWorkerFeed,
|
|
62
|
+
workerHistoryDepth,
|
|
62
63
|
type CombinedWorkerRow,
|
|
63
64
|
} from './tool-activity-summary.js'
|
|
64
65
|
import { isSendGateShed } from './send-gate.js'
|
|
@@ -213,6 +214,9 @@ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): st
|
|
|
213
214
|
final: finished,
|
|
214
215
|
liveSuffix: finished ? '' : liveSuffix,
|
|
215
216
|
result,
|
|
217
|
+
// Lone-worker card: window to the w=1 point of Ken's curve (6) so it shows
|
|
218
|
+
// the full recent trail, not the 5-line agent-card default (#3349).
|
|
219
|
+
historyWindow: workerHistoryDepth(1),
|
|
216
220
|
})
|
|
217
221
|
if (card == null) {
|
|
218
222
|
// Unreachable (header always present) — defensive.
|
|
@@ -420,7 +424,7 @@ interface WorkerRow {
|
|
|
420
424
|
agentId: string
|
|
421
425
|
/**
|
|
422
426
|
* Accumulated narrative lines (oldest→newest), deduped within the whole
|
|
423
|
-
* rolling window. Rolling-window capped to
|
|
427
|
+
* rolling window. Rolling-window capped to WORKER_HISTORY_MAX. Grows the
|
|
424
428
|
* live render so the feed reads like the main agent's answer.
|
|
425
429
|
*/
|
|
426
430
|
narrative: string[]
|
|
@@ -471,6 +475,14 @@ interface WorkerRow {
|
|
|
471
475
|
* once past STEP_TIMER_MIN_MS.
|
|
472
476
|
*/
|
|
473
477
|
stepStartedAtMs: number | null
|
|
478
|
+
/**
|
|
479
|
+
* Stable 1-based ordinal within the group's CURRENT card, assigned from the
|
|
480
|
+
* group's monotonic counter when the row is first registered and IMMUTABLE
|
|
481
|
+
* thereafter. The combined feed renders it as a `{n}. ` header prefix when
|
|
482
|
+
* 2+ workers are running; survivors keep their numbers when an earlier
|
|
483
|
+
* worker finishes (never renumbered positionally — #3298).
|
|
484
|
+
*/
|
|
485
|
+
ordinal: number
|
|
474
486
|
}
|
|
475
487
|
|
|
476
488
|
/**
|
|
@@ -511,6 +523,14 @@ interface FeedGroup {
|
|
|
511
523
|
chain: Promise<void>
|
|
512
524
|
/** Live workers in this group, keyed by agentId (insertion ≈ dispatch order). */
|
|
513
525
|
workers: Map<string, WorkerRow>
|
|
526
|
+
/**
|
|
527
|
+
* Monotonic per-card worker counter — `++counter` hands each newly
|
|
528
|
+
* registered row its stable {@link WorkerRow.ordinal}. Reset to 0 whenever a
|
|
529
|
+
* fresh card starts (group creation, and registration into an emptied group
|
|
530
|
+
* — which covers the group-reuse-after-terminal repaint, since finalize
|
|
531
|
+
* drops rows from `workers` immediately), so every new card numbers from 1.
|
|
532
|
+
*/
|
|
533
|
+
workerOrdinalCounter: number
|
|
514
534
|
/**
|
|
515
535
|
* Terminal renders (per finishing worker's recap) staged because a 429
|
|
516
536
|
* cooldown / flood window blocked the edit. Keyed by agentId so a SECOND
|
|
@@ -781,8 +801,10 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
781
801
|
row.narrative.push(line)
|
|
782
802
|
// The `→` current-step line just CHANGED — reset the per-step timer.
|
|
783
803
|
row.stepStartedAtMs = nowFn()
|
|
784
|
-
|
|
785
|
-
|
|
804
|
+
// Retain up to WORKER_HISTORY_MAX (6) — the lone-worker card's deepest
|
|
805
|
+
// window (#3349). STATUS_ROLLING_LINES (5) governs the agent card, not this.
|
|
806
|
+
if (row.narrative.length > WORKER_HISTORY_MAX) {
|
|
807
|
+
row.narrative.splice(0, row.narrative.length - WORKER_HISTORY_MAX)
|
|
786
808
|
}
|
|
787
809
|
}
|
|
788
810
|
|
|
@@ -844,6 +866,9 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
844
866
|
const currentStep = r.narrative.length > 0 ? r.narrative[r.narrative.length - 1] : v.latestSummary
|
|
845
867
|
return {
|
|
846
868
|
description: v.description,
|
|
869
|
+
// Stable per-card ordinal — assigned at registration, kept for the
|
|
870
|
+
// card's life (survivors don't renumber when an earlier worker ends).
|
|
871
|
+
ordinal: r.ordinal,
|
|
847
872
|
elapsedMs: elapsedFor(r),
|
|
848
873
|
toolCount: v.toolCount,
|
|
849
874
|
totalTokens: v.totalTokens,
|
|
@@ -1488,6 +1513,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
1488
1513
|
cooldownUntil: 0,
|
|
1489
1514
|
chain: Promise.resolve(),
|
|
1490
1515
|
workers: new Map(),
|
|
1516
|
+
workerOrdinalCounter: 0,
|
|
1491
1517
|
pendingFinalize: new Map(),
|
|
1492
1518
|
terminalPainted: false,
|
|
1493
1519
|
}
|
|
@@ -1512,8 +1538,12 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
1512
1538
|
}
|
|
1513
1539
|
let row = g.workers.get(agentId)
|
|
1514
1540
|
if (row == null) {
|
|
1541
|
+
// A registration into an EMPTIED group starts a fresh card — number
|
|
1542
|
+
// from 1 again rather than continuing the dead card's sequence.
|
|
1543
|
+
if (g.workers.size === 0) g.workerOrdinalCounter = 0
|
|
1515
1544
|
row = {
|
|
1516
1545
|
agentId,
|
|
1546
|
+
ordinal: ++g.workerOrdinalCounter,
|
|
1517
1547
|
narrative: [],
|
|
1518
1548
|
lastView: null,
|
|
1519
1549
|
state: 'running',
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* #2787 Mechanism B — the `claudeBusyKeys` orphan-reaper lifecycle, extracted
|
|
3
|
-
* so the gateway glue that mutates the busy-key set stays in lockstep with its
|
|
4
|
-
* shadow insertion-timestamp map and is unit-testable WITHOUT importing
|
|
5
|
-
* `gateway.ts` (which boots the bot + IPC listener on module load and so cannot
|
|
6
|
-
* run inside a unit test). The gateway keeps ownership of the two containers
|
|
7
|
-
* (`claudeBusyKeys: Set`, `claudeBusyKeySince: Map`) and calls these helpers at
|
|
8
|
-
* every mark / reap site; `disconnect-flush.ts` clears them at bridge death.
|
|
9
|
-
* The test harness (`tests/busy-key-reaper.test.ts`) wires the SAME containers,
|
|
10
|
-
* the SAME delivery queue, and the real `flushOnAgentDisconnect` to reproduce
|
|
11
|
-
* the disconnect → reconnect → re-mark and slow-vs-orphan delivery sequences.
|
|
12
|
-
*
|
|
13
|
-
* Two invariants this module enforces:
|
|
14
|
-
*
|
|
15
|
-
* 1. LOCKSTEP. The timestamp map shadows actual SET MEMBERSHIP, not its own
|
|
16
|
-
* prior presence. `markBusyKeyLockstep` stamps a fresh timestamp whenever a
|
|
17
|
-
* key transitions idle→busy (`claudeBusyKeys.has(key) === false`). Gating on
|
|
18
|
-
* the set — not on `since.has` — is load-bearing: a disconnect flush clears
|
|
19
|
-
* `claudeBusyKeys` directly, so if the shadow map ever lagged, a `!since.has`
|
|
20
|
-
* guard would decline to re-stamp on the reconnect→re-mark and leave a
|
|
21
|
-
* stale, >TTL-old timestamp on a freshly-marked key — which the reaper would
|
|
22
|
-
* then reap out from under a live delivery.
|
|
23
|
-
*
|
|
24
|
-
* 2. SLOW-DELIVERY SAFETY (the #1922 hazard). `busy` is marked EAGERLY at
|
|
25
|
-
* delivery, and the gateway→claude enqueue-ack lag can be up to ~5 MINUTES
|
|
26
|
-
* under load (#1922). During that eager-mark→enqueue window the gateway's
|
|
27
|
-
* `currentTurn` is null yet the turn is real-and-merely-slow. A bare
|
|
28
|
-
* time-grace reaper cannot tell "slow" from "orphaned" and would reap a
|
|
29
|
-
* genuine delivery, re-opening the idle-drain gate while claude is about to
|
|
30
|
-
* process the very inbound whose key it just reaped (duplicate / concurrent
|
|
31
|
-
* delivery on the CORE inbound path). So the reap is PROOF-GATED: a key is
|
|
32
|
-
* reaped ONLY when it has NO entry in the delivery-confirm queue. A
|
|
33
|
-
* slow-but-real inbound is tracked there from delivery until claude's
|
|
34
|
-
* `enqueue` ack (via the never-drop re-deliver loop), so any key still
|
|
35
|
-
* awaiting its turn is present and skipped — no matter how slow. The time
|
|
36
|
-
* grace is retained as defense-in-depth, defaulting well above the observed
|
|
37
|
-
* ack-lag tail. Only a busy-marked key with NO pending delivery AND age past
|
|
38
|
-
* the grace is a true orphan (its turn acked and should have cleared busy at
|
|
39
|
-
* turn_end, or it was a steer/interrupt inbound excluded from tracking that
|
|
40
|
-
* amends a now-absent turn) — reaping those can never clobber a slow one.
|
|
41
|
-
*/
|
|
42
|
-
|
|
43
|
-
/** Lockstep mark: add to the busy set and, on the idle→busy transition only,
|
|
44
|
-
* stamp the insertion time. Keyed on set membership so a re-mark after a
|
|
45
|
-
* disconnect flush (which cleared the set directly) always re-stamps fresh. */
|
|
46
|
-
export function markBusyKeyLockstep(
|
|
47
|
-
keys: Set<string>,
|
|
48
|
-
since: Map<string, number>,
|
|
49
|
-
key: string,
|
|
50
|
-
now: number,
|
|
51
|
-
): void {
|
|
52
|
-
const wasBusy = keys.has(key)
|
|
53
|
-
keys.add(key)
|
|
54
|
-
if (!wasBusy) since.set(key, now)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Lockstep clear: delete from both containers. */
|
|
58
|
-
export function clearBusyKeyLockstep(
|
|
59
|
-
keys: Set<string>,
|
|
60
|
-
since: Map<string, number>,
|
|
61
|
-
key: string,
|
|
62
|
-
): void {
|
|
63
|
-
keys.delete(key)
|
|
64
|
-
since.delete(key)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface ReapOptions {
|
|
68
|
-
/** Grace before a busy-marked key with no pending delivery is a true orphan. */
|
|
69
|
-
ttlMs: number
|
|
70
|
-
/** True iff a delivery-confirm entry is still tracked for this key — i.e. a
|
|
71
|
-
* slow-but-real inbound awaiting its enqueue ack. Such keys are NEVER reaped,
|
|
72
|
-
* regardless of age (the #1922 slow-delivery guarantee). */
|
|
73
|
-
hasPendingDelivery: (key: string) => boolean
|
|
74
|
-
/** Optional line logger for each reaped key (observability). */
|
|
75
|
-
log?: (msg: string) => void
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Reap orphaned busy markers. Call ONLY once the gateway has asserted no turn is
|
|
80
|
-
* in flight (`currentTurn == null`). Returns the keys reaped (for the caller's
|
|
81
|
-
* metrics / assertions). Also prunes shadow-map entries whose key already left
|
|
82
|
-
* the set, so the map can't grow unbounded.
|
|
83
|
-
*/
|
|
84
|
-
export function reapOrphanBusyKeys(
|
|
85
|
-
keys: Set<string>,
|
|
86
|
-
since: Map<string, number>,
|
|
87
|
-
now: number,
|
|
88
|
-
opts: ReapOptions,
|
|
89
|
-
): string[] {
|
|
90
|
-
const reaped: string[] = []
|
|
91
|
-
for (const key of [...since.keys()]) {
|
|
92
|
-
if (!keys.has(key)) {
|
|
93
|
-
since.delete(key)
|
|
94
|
-
continue
|
|
95
|
-
}
|
|
96
|
-
// Proof gate: a key with a pending delivery is a slow-but-real inbound still
|
|
97
|
-
// awaiting its enqueue ack (the never-drop re-deliver loop owns it) — NEVER
|
|
98
|
-
// an orphan, regardless of age. Skip so a multi-minute (#1922) delivery
|
|
99
|
-
// can't be reaped out from under claude.
|
|
100
|
-
if (opts.hasPendingDelivery(key)) continue
|
|
101
|
-
const stampedAt = since.get(key) ?? now
|
|
102
|
-
if (now - stampedAt >= opts.ttlMs) {
|
|
103
|
-
keys.delete(key)
|
|
104
|
-
since.delete(key)
|
|
105
|
-
reaped.push(key)
|
|
106
|
-
opts.log?.(
|
|
107
|
-
`telegram gateway: reaped orphan busy key=${key} (no turn in flight, ` +
|
|
108
|
-
`no pending delivery, >${opts.ttlMs}ms) — unwedging idle-drain`,
|
|
109
|
-
)
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
return reaped
|
|
113
|
-
}
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Gate-parity drift probe — scaffolding for the inbound-delivery
|
|
3
|
-
* state-machine cutover (#2794, RFC PR3b→PR4).
|
|
4
|
-
*
|
|
5
|
-
* ## Why this exists
|
|
6
|
-
*
|
|
7
|
-
* The inbound-delivery pipeline is mid-migration and TRIPLE-maintained:
|
|
8
|
-
*
|
|
9
|
-
* 1. the pure state machine (`inbound-delivery-machine.ts`) — the model,
|
|
10
|
-
* 2. the shadow (`inbound-delivery-machine-shadow.ts`) — module-scope
|
|
11
|
-
* machine state advanced by `shadowEmit`, now AUTHORITATIVE for the
|
|
12
|
-
* turn-in-flight gate via `isMachineInTurn()`,
|
|
13
|
-
* 3. the legacy imperative `claudeBusyKeys` set — still fully maintained
|
|
14
|
-
* in parallel and read as the kill-switch fallback.
|
|
15
|
-
*
|
|
16
|
-
* Per #2794 the standing risk is DRIFT: because the same turn-lifecycle
|
|
17
|
-
* fact ("is a turn in flight?") is tracked in two live places, a fix in
|
|
18
|
-
* one can silently diverge from the other. The machine is authoritative
|
|
19
|
-
* today, but `claudeBusyKeys` is still wired and still read on the
|
|
20
|
-
* kill-switch path — so the two MUST NOT drift in the dangerous direction.
|
|
21
|
-
*
|
|
22
|
-
* ## What "agree" means (and the ONE intended divergence)
|
|
23
|
-
*
|
|
24
|
-
* The machine and `claudeBusyKeys` are designed to agree on every
|
|
25
|
-
* WELL-FORMED schedule (every turnStart has a matching turnEnd). They are
|
|
26
|
-
* deliberately allowed to diverge in exactly ONE direction on a MALFORMED
|
|
27
|
-
* schedule — an orphaned turnStart (turn B opens before turn A's turnEnd
|
|
28
|
-
* ever lands, the gymbro/clerk 5-min dangle of 2026-05-28):
|
|
29
|
-
*
|
|
30
|
-
* - `busykeys_dangle` — machine reads idle (self-healed via TTL tick or
|
|
31
|
-
* single-activeTurn reopen) while `claudeBusyKeys`
|
|
32
|
-
* still holds an orphan key. This is EXPECTED and
|
|
33
|
-
* GOOD: it is precisely the wedge the machine was
|
|
34
|
-
* made authoritative to kill. Not a drift alarm.
|
|
35
|
-
*
|
|
36
|
-
* - `machine_over_holds` — machine reads in-flight while `claudeBusyKeys`
|
|
37
|
-
* is empty. This is the DANGEROUS direction: the
|
|
38
|
-
* now-authoritative gate would hold closed while
|
|
39
|
-
* the imperative view says idle — a NEW wedge
|
|
40
|
-
* class the cutover must never introduce. This
|
|
41
|
-
* is the drift #2794 warns about; surface it.
|
|
42
|
-
*
|
|
43
|
-
* This module is a pure classifier plus a log-only runtime probe. It
|
|
44
|
-
* performs NO I/O of its own beyond an optional injected log sink and
|
|
45
|
-
* changes NO delivery behaviour — it only observes. Deleting it once the
|
|
46
|
-
* `claudeBusyKeys` shadow is removed in PR4 is a no-op for behaviour.
|
|
47
|
-
*/
|
|
48
|
-
|
|
49
|
-
export type GateParityDivergence = 'none' | 'busykeys_dangle' | 'machine_over_holds'
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Classify the relationship between the machine's authoritative
|
|
53
|
-
* turn-in-flight read and the legacy imperative `claudeBusyKeys` size.
|
|
54
|
-
* Pure — no side effects.
|
|
55
|
-
*/
|
|
56
|
-
export function gateParityDivergence(
|
|
57
|
-
machineInTurn: boolean,
|
|
58
|
-
busyKeysSize: number,
|
|
59
|
-
): GateParityDivergence {
|
|
60
|
-
const busy = busyKeysSize > 0
|
|
61
|
-
if (machineInTurn === busy) return 'none'
|
|
62
|
-
// machine idle, busyKeys non-empty → the orphan dangle the machine heals.
|
|
63
|
-
if (!machineInTurn && busy) return 'busykeys_dangle'
|
|
64
|
-
// machine in-flight, busyKeys empty → the dangerous over-hold.
|
|
65
|
-
return 'machine_over_holds'
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* True only for divergences that indicate a real cutover regression
|
|
70
|
-
* (the machine holding a gate the imperative shadow believes is open).
|
|
71
|
-
* The benign `busykeys_dangle` — the very wedge the machine fixes — is
|
|
72
|
-
* NOT flagged, so this probe has zero false positives on the known-good
|
|
73
|
-
* self-heal path.
|
|
74
|
-
*/
|
|
75
|
-
export function isDangerousGateDivergence(d: GateParityDivergence): boolean {
|
|
76
|
-
return d === 'machine_over_holds'
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Log-only runtime drift canary. Call at the authoritative gate read.
|
|
81
|
-
* Emits a single grep-friendly `gw-trace gate-drift` line ONLY on the
|
|
82
|
-
* dangerous over-hold direction. Returns the machine value UNCHANGED so
|
|
83
|
-
* it can wrap the gate read without altering behaviour:
|
|
84
|
-
*
|
|
85
|
-
* return probeGateParity(isMachineInTurn(), claudeBusyKeys.size)
|
|
86
|
-
*
|
|
87
|
-
* @param log optional sink (default stderr) — test hook.
|
|
88
|
-
*/
|
|
89
|
-
export function probeGateParity(
|
|
90
|
-
machineInTurn: boolean,
|
|
91
|
-
busyKeysSize: number,
|
|
92
|
-
log: (line: string) => void = (line) => process.stderr.write(line),
|
|
93
|
-
): boolean {
|
|
94
|
-
const d = gateParityDivergence(machineInTurn, busyKeysSize)
|
|
95
|
-
if (isDangerousGateDivergence(d)) {
|
|
96
|
-
log(
|
|
97
|
-
`gw-trace gate-drift kind=${d} machineInTurn=${machineInTurn} ` +
|
|
98
|
-
`busyKeys=${busyKeysSize} note=machine-authoritative-gate-holds-while-imperative-idle\n`,
|
|
99
|
-
)
|
|
100
|
-
}
|
|
101
|
-
return machineInTurn
|
|
102
|
-
}
|