viber-channel 0.8.17 → 0.8.19

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/lib/auth.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { chmodSync, readFileSync, writeFileSync } from "node:fs";
2
- import { isAbsolute, join } from "node:path";
2
+ import { basename, isAbsolute, join } from "node:path";
3
3
 
4
4
  export interface AuthJson {
5
5
  schema_version: number;
@@ -63,15 +63,81 @@ export function isDevBackend(
63
63
  * silently authenticated against the dev backend with STAGING creds (`-32000`)
64
64
  * because only `VIBER_BASE_URL` was set and the auth file defaulted to staging.
65
65
  */
66
- export function authFilePath(cwd: string = process.cwd()): string {
66
+ export function authFilePath(
67
+ cwd: string = process.cwd(),
68
+ baseUrl: string = process.env.VIBER_BASE_URL ?? "",
69
+ ): string {
70
+ const qa = isQaBackend(baseUrl);
67
71
  const override = process.env.VIBER_AUTH_FILE;
68
72
  if (override !== undefined && override.trim() !== "") {
73
+ assertAuthFileMatchesBackend(basename(override), qa);
69
74
  return isAbsolute(override) ? override : join(cwd, override);
70
75
  }
71
- const file = isDevBackend() ? "dev.auth.json" : "auth.json";
76
+ const file = isDevBackend(baseUrl) ? "dev.auth.json" : qa ? QA_AUTH_FILE : "auth.json";
72
77
  return join(cwd, ".viber", file);
73
78
  }
74
79
 
80
+ /**
81
+ * #613: the auth file `connect` must write for a claim URL. The CLAIM decides
82
+ * for qa: `connect https://viber-qa.dgypx.dev/connect/<id>` run with no
83
+ * VIBER_BASE_URL would otherwise derive the file from the (empty) env and write
84
+ * qa credentials into `auth.json`, overwriting STAGING's. A set env that
85
+ * contradicts the claim (qa vs not qa) is refused. Non-qa claims keep the
86
+ * pre-#613 behavior (file derived from the env) unchanged.
87
+ */
88
+ export function connectAuthPath(
89
+ claimBaseUrl: string,
90
+ cwd: string = process.cwd(),
91
+ envBaseUrl: string = process.env.VIBER_BASE_URL ?? "",
92
+ ): string {
93
+ const claimQa = isQaBackend(claimBaseUrl);
94
+ if (envBaseUrl.trim() !== "" && claimQa !== isQaBackend(envBaseUrl)) {
95
+ throw new Error(
96
+ `claim URL ${claimBaseUrl} and VIBER_BASE_URL=${envBaseUrl} disagree on the qa backend; refusing to write credentials`,
97
+ );
98
+ }
99
+ return authFilePath(cwd, claimQa ? claimBaseUrl : envBaseUrl);
100
+ }
101
+
102
+ /** #613: the qa web host. Matched EXACTLY (hostname), never by substring. */
103
+ export const QA_WEB_HOST = "viber-qa.dgypx.dev";
104
+ export const QA_AUTH_FILE = "qa.auth.json";
105
+
106
+ /**
107
+ * #613: true only when `VIBER_BASE_URL` is the qa web host. Same raw-env
108
+ * classification rule as {@link isDevBackend} (never the API transport host),
109
+ * but an exact hostname match: a substring test would let any host containing
110
+ * "qa" read qa credentials, or a qa host fall through to STAGING's auth.json.
111
+ */
112
+ export function isQaBackend(
113
+ baseUrl: string = process.env.VIBER_BASE_URL ?? "",
114
+ ): boolean {
115
+ try {
116
+ return new URL(baseUrl).hostname === QA_WEB_HOST;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * #613: a `VIBER_AUTH_FILE` override still wins, but a CONTRADICTORY one is
124
+ * refused instead of resolved in silence: qa credentials against another
125
+ * backend, or another env's well-known file against the qa backend. Custom
126
+ * file names stay allowed on both sides.
127
+ */
128
+ function assertAuthFileMatchesBackend(file: string, qa: boolean): void {
129
+ if (!qa && file === QA_AUTH_FILE) {
130
+ throw new Error(
131
+ `VIBER_AUTH_FILE points at ${QA_AUTH_FILE} but VIBER_BASE_URL is not https://${QA_WEB_HOST}`,
132
+ );
133
+ }
134
+ if (qa && (file === "auth.json" || file === "dev.auth.json")) {
135
+ throw new Error(
136
+ `VIBER_BASE_URL is the qa backend but VIBER_AUTH_FILE points at ${file} (another env's credentials)`,
137
+ );
138
+ }
139
+ }
140
+
75
141
  export function loadAuth(cwd: string = process.cwd()): AuthJson {
76
142
  const path = authFilePath(cwd);
77
143
  let raw: string;
@@ -306,11 +306,13 @@ export async function postBridgeReply(opts: {
306
306
  ownPostedIds: Set<string>;
307
307
  signal?: AbortSignal;
308
308
  logPrefix: string;
309
+ /** Fetch seam (#606) — see postMessage. Defaults to the global fetch. */
310
+ fetchImpl?: typeof fetch;
309
311
  }): Promise<void> {
310
312
  const { baseUrl, runtime, text, ownPostedIds, signal, logPrefix } = opts;
311
313
  let result;
312
314
  try {
313
- result = await postMessage(baseUrl, runtime.id, runtime.token, text, undefined, signal);
315
+ result = await postMessage(baseUrl, runtime.id, runtime.token, text, undefined, signal, opts.fetchImpl ?? fetch);
314
316
  } catch (err) {
315
317
  if (err instanceof ConversationTokenExpiredError) {
316
318
  throw new BridgeShutdownError("conversation token expired", 3);
@@ -345,6 +347,93 @@ export function decideTurnPostAction(opts: {
345
347
  return "auto-post";
346
348
  }
347
349
 
350
+ /** Max length of the runtime cause quoted verbatim in a channel failure notice. */
351
+ const FAILURE_CAUSE_MAX = 400;
352
+
353
+ /** Transport degradation observed during a turn (#606 step-02, codex only). */
354
+ export type TurnDegradation = { events: number; lastStatus?: number };
355
+
356
+ /**
357
+ * #606 — the text a bridge posts on the CHANNEL when a turn fails.
358
+ *
359
+ * The whole point of the issue is that a failed turn was written to stderr only,
360
+ * so the model filled the silence with an INVENTED cause. This text therefore
361
+ * quotes what the runtime said, VERBATIM and truncated, and adds nothing of its
362
+ * own: no classification, no advice, no guess. Pure (no clock, no network) so it
363
+ * is testable without a harness.
364
+ */
365
+ export function failureNoticeText(err: unknown, degradation?: TurnDegradation): string {
366
+ const raw = safeErrorMessage(err).replace(/\s+/g, " ").trim();
367
+ const cause = raw.length > FAILURE_CAUSE_MAX ? `${raw.slice(0, FAILURE_CAUSE_MAX)}...` : raw || "(aucun detail)";
368
+ let text = `Tour echoue, aucune reponse produite. Cause rapportee par le runtime: ${cause}`;
369
+ if (degradation && degradation.events > 0) {
370
+ const status = degradation.lastStatus ? `, dernier statut HTTP ${degradation.lastStatus}` : "";
371
+ text += ` (transport degrade: ${degradation.events} evenement(s) avant l'echec${status})`;
372
+ }
373
+ return text;
374
+ }
375
+
376
+ /**
377
+ * #606 — the dedup signature of a failure cause.
378
+ *
379
+ * A blown quota fails EVERY subsequent turn: without dedup the fix that makes a
380
+ * failure speak would flood the channel. Two failures of the same cause must
381
+ * therefore yield the SAME key, so the volatile identifiers a runtime stamps on
382
+ * each attempt (turnId, threadId, cf-ray, timestamps, long hex ids) are stripped.
383
+ *
384
+ * The degradation COUNT is deliberately reduced to its presence, never its value:
385
+ * keying on the number would make every different N a "new cause" and re-open the
386
+ * flood the dedup exists to close.
387
+ */
388
+ export function failureCauseKey(err: unknown, degradation?: TurnDegradation): string {
389
+ const normalized = safeErrorMessage(err)
390
+ .replace(/"(?:turnId|threadId|conversationId|requestId)"\s*:\s*"[^"]*"/g, "")
391
+ // STOPS at a JSON delimiter (review codex P2a). `\S+` here crossed the
392
+ // closing quote and ate everything up to the next SPACE — measured: two
393
+ // errors differing only by httpStatusCode 503 vs 429 collapsed onto
394
+ // `Codex turn failed: {"message":"Request failed,` because the status sat
395
+ // AFTER the cf-ray. A normalisation that eats the discriminator gags the
396
+ // second outage: the exact defect this key exists to avoid.
397
+ .replace(/cf-ray:\s*[^\s",}\]]+/gi, "")
398
+ .replace(/\d{4}-\d{2}-\d{2}T[\d:.]+Z?/g, "")
399
+ // The RESET INSTANT of a quota, in the two shapes our own log carries for
400
+ // the same outage (review codex, .raw l.95 vs l.111): with the date and
401
+ // without. Stripping only the clock left them on different keys, so the
402
+ // same cause announced twice.
403
+ .replace(
404
+ /\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{1,2}(?:st|nd|rd|th)?,?\s*\d{4}\b/gi,
405
+ "",
406
+ )
407
+ .replace(/\b\d{1,2}:\d{2}(:\d{2})?\s*(AM|PM)?\b/gi, "")
408
+ .replace(/\b[0-9a-f]{12,}\b/gi, "")
409
+ .replace(/\s+/g, " ")
410
+ .trim();
411
+ const degraded = degradation && degradation.events > 0 ? "|degraded" : "";
412
+ // WHOLE, neither truncated nor hashed (review codex, both passes). Slicing to a
413
+ // display bound made the key lose identity past that bound — two errors sharing
414
+ // a long prefix became one cause and the second was silenced. A digest fixed
415
+ // that but kept a residual collision, which costs a SUPPRESSED notice: the
416
+ // defect this key exists to prevent, made rare instead of impossible.
417
+ // There is no size constraint to trade against: the key is one closure variable
418
+ // holding one cause at a time. Keeping the normalised string entire removes the
419
+ // class rather than shrinking it — a display concern must not decide identity,
420
+ // which is the whole lesson of P2b.
421
+ return `${normalized}${degraded}`;
422
+ }
423
+
424
+ /**
425
+ * #606 step-02 — read the transport degradation a runtime attached to the error
426
+ * it threw. The failing turn never returns a TurnResult, so the count cannot ride
427
+ * on one; the codex bridge stamps it on the Error itself. Any runtime that stamps
428
+ * nothing (gemma: a single fetch, no intermediate transport events to observe)
429
+ * simply yields undefined, and the notice omits the clause.
430
+ */
431
+ export function readTurnDegradation(err: unknown): TurnDegradation | undefined {
432
+ const d = (err as { degradation?: unknown })?.degradation as TurnDegradation | undefined;
433
+ if (!d || typeof d.events !== "number" || d.events <= 0) return undefined;
434
+ return { events: d.events, lastStatus: typeof d.lastStatus === "number" ? d.lastStatus : undefined };
435
+ }
436
+
348
437
  /**
349
438
  * #429 cross-run dedup, mirror of dm_stream's `forwardMessage` guard (L146-147).
350
439
  * True when this id was already DELIVERED in a prior run and must be skipped on a
@@ -570,6 +659,34 @@ export async function sseLoop(opts: {
570
659
  const sseUrl = buildSseUrl(minted.ws_url, minted.conversation_id);
571
660
  const ownPostedIds = new Set<string>();
572
661
 
662
+ // #606 anti-flood state: the cause key of the LAST failure actually announced
663
+ // on the channel. A blown quota fails every subsequent turn, so announcing each
664
+ // one would replace a silence bug with a spam bug. Closure state, owned by this
665
+ // loop — no module singleton (.claude/rules/architecture.md §7).
666
+ // Cross-RUN note. This comment CLAIMED the key survives a re-join; it does not,
667
+ // and N.11 measures the opposite (review codex, prose-has-no-test: the code was
668
+ // right, the sentence was false). What actually happens: the watermark
669
+ // deliberately does NOT advance on a failed reply (at-least-once retry, see
670
+ // below), so a re-join replays the message and fails again — and since each
671
+ // sseLoop call owns a FRESH closure, that re-join announces once more. Same for
672
+ // a process restart. Deliberate: a bridge that comes back and hits the same wall
673
+ // is information. Within one join, the key is what stops the flood.
674
+ let lastAnnouncedCause: string | undefined;
675
+
676
+ /**
677
+ * #606 — a DELIVERED turn means the previous cause is over: rearm the notice.
678
+ *
679
+ * Lives HERE, on the turn outcome, and not at a caller. Review F2 measured why:
680
+ * written at the text caller only, the VOICE path never rearmed, so
681
+ * text-fails → voice-recovers → text-fails-again left the third failure GAGGED
682
+ * — the issue's own defect, reproduced by the issue's fix. Placing it on the
683
+ * outcome removes the class instead of patching the two paths that exist today.
684
+ * An ABORTED turn never gets here: teardown says nothing about runtime health.
685
+ */
686
+ function rearmFailureNotice(): void {
687
+ lastAnnouncedCause = undefined;
688
+ }
689
+
573
690
  // Run one turn for `content` via the injected runtime, then auto-post its
574
691
  // reply UNLESS an outbound tool already posted this turn (anti-double-post).
575
692
  // Returns true when the reply was dropped because the stream was aborted
@@ -598,12 +715,15 @@ export async function sseLoop(opts: {
598
715
  return true;
599
716
  case "suppress-outbound":
600
717
  process.stderr.write(`${logPrefix} outbound tool posted this turn on ${runtime.id}; suppressing final auto-post\n`);
718
+ rearmFailureNotice();
601
719
  return false;
602
720
  case "skip-empty":
603
721
  process.stderr.write(`${logPrefix} turn on ${runtime.id} produced no text and used no outbound tool; nothing to deliver\n`);
722
+ rearmFailureNotice();
604
723
  return false;
605
724
  case "auto-post":
606
- await postBridgeReply({ baseUrl: baseUrl(), runtime, text: reply, ownPostedIds, signal, logPrefix });
725
+ await postBridgeReply({ baseUrl: baseUrl(), runtime, text: reply, ownPostedIds, signal, logPrefix, fetchImpl });
726
+ rearmFailureNotice();
607
727
  return false;
608
728
  }
609
729
  }
@@ -652,6 +772,49 @@ export async function sseLoop(opts: {
652
772
  .finally(() => ackInFlightIds.delete(idStr));
653
773
  }
654
774
 
775
+
776
+ /**
777
+ * #606 — say a failed turn on the CHANNEL, best-effort.
778
+ *
779
+ * Deliberately unable to make things worse:
780
+ * - the transport may be the very thing that broke (the measured case was a
781
+ * 503 storm), so a failing notice is swallowed to stderr, NEVER retried and
782
+ * never rethrown — one failure must not become two;
783
+ * - it posts through postBridgeReply, so the notice id lands in ownPostedIds
784
+ * like any other reply and cannot feed itself back through handleInboundMessage;
785
+ * - the cause key is recorded only AFTER a post that actually succeeded, so a
786
+ * lost notice does not silence the next one.
787
+ */
788
+ async function announceTurnFailure(err: unknown): Promise<void> {
789
+ const degradation = readTurnDegradation(err);
790
+ const key = failureCauseKey(err, degradation);
791
+ if (key === lastAnnouncedCause) {
792
+ process.stderr.write(`${logPrefix} failure notice suppressed (same cause as the previous one)\n`);
793
+ return;
794
+ }
795
+ try {
796
+ await postBridgeReply({
797
+ baseUrl: baseUrl(),
798
+ runtime,
799
+ text: failureNoticeText(err, degradation),
800
+ ownPostedIds,
801
+ signal,
802
+ logPrefix,
803
+ fetchImpl,
804
+ });
805
+ lastAnnouncedCause = key;
806
+ } catch (notifyErr) {
807
+ // Includes BridgeShutdownError from an expired token: swallowed here on
808
+ // purpose. CONSEQUENCE, named because it is not obvious (review F3): a
809
+ // token expiry first met BY THE NOTICE is discovered later, on the next
810
+ // turn, instead of shutting the bridge down now. Accepted — this path
811
+ // runs while ALREADY handling a failure; escalating a
812
+ // shutdown from the notice would turn "the agent could not answer" into
813
+ // "the agent died", which is a worse report than the one we came to give.
814
+ process.stderr.write(`${logPrefix} failure notice could not be posted: ${safeErrorMessage(notifyErr)}\n`);
815
+ }
816
+ }
817
+
655
818
  async function handleInboundMessage(msg: ConversationMessage): Promise<boolean> {
656
819
  const idStr = msg.id !== undefined && msg.id !== null ? String(msg.id) : undefined;
657
820
  // TRUTHY check (not `!== undefined`): idStr === "" must yield NaN, not
@@ -688,6 +851,7 @@ export async function sseLoop(opts: {
688
851
  } catch (err) {
689
852
  if (err instanceof BridgeShutdownError) throw err;
690
853
  process.stderr.write(`${logPrefix} failed to handle message: ${safeErrorMessage(err)}\n`);
854
+ await announceTurnFailure(err);
691
855
  }
692
856
  return options.once === true;
693
857
  }
@@ -810,6 +974,12 @@ export async function sseLoop(opts: {
810
974
  } catch (err) {
811
975
  if (err instanceof BridgeShutdownError) throw err;
812
976
  process.stderr.write(`${logPrefix} failed to handle transcription: ${safeErrorMessage(err)}\n`);
977
+ // #606: a VOICE turn that fails was the second silent swallow in this
978
+ // same chain — found while building the tests, not by the grep on
979
+ // "failed to handle message", which only ever proved the uniqueness of
980
+ // the TEXT path. A user speaking to a dead runtime deserves the same
981
+ // notice as one typing to it.
982
+ await announceTurnFailure(err);
813
983
  }
814
984
  if (options.once) return;
815
985
  }
package/lib/connect.ts CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  writeFileSync,
20
20
  } from "node:fs";
21
21
  import { dirname, isAbsolute, join, relative } from "node:path";
22
- import { authFilePath } from "./auth.js";
22
+ import { connectAuthPath, isQaBackend } from "./auth.js";
23
23
  import { clientFingerprint } from "./fingerprint.js";
24
24
  import { cfAccessHeaders } from "./cfAccess.js";
25
25
 
@@ -50,6 +50,21 @@ interface TerminalResponse {
50
50
 
51
51
  type PollResponse = ConsumedResponse | PendingResponse | TerminalResponse;
52
52
 
53
+ const DEFAULT_NEXT_STEPS =
54
+ `Next steps:\n` +
55
+ ` 1. Register the channel MCP server (one-time per machine):\n` +
56
+ ` claude mcp add viber-channel --scope user -- bunx viber-channel@latest\n` +
57
+ ` 2. Restart Claude Code in this directory.\n\n`;
58
+
59
+ // #613: qa never runs the published channel — the default advice would be wrong
60
+ // at the exact moment of the qa gesture.
61
+ export const QA_NEXT_STEPS =
62
+ `Next steps (qa):\n` +
63
+ ` 1. Run the channel FROM SOURCE in the Gateway worktree: register viber-qa-channel\n` +
64
+ ` in its local .mcp.json (bun ./viber-channel/viber-channel.ts,\n` +
65
+ ` VIBER_BASE_URL=https://viber-qa.dgypx.dev). NEVER bunx viber-channel@latest.\n` +
66
+ ` 2. See plans/613-env-qa (mount procedure).\n\n`;
67
+
53
68
  const POLL_INTERVAL_MS = 2000;
54
69
  const MAX_POLL_DURATION_MS = 10 * 60 * 1000;
55
70
 
@@ -77,6 +92,7 @@ function writeAuthJson(
77
92
  cwd: string,
78
93
  data: ConsumedResponse,
79
94
  fingerprint: string,
95
+ authPath: string,
80
96
  ): void {
81
97
  const viberDir = join(cwd, ".viber");
82
98
  mkdirSync(viberDir, { recursive: true });
@@ -85,7 +101,7 @@ function writeAuthJson(
85
101
  // credentials to the same file the channel will read (e.g. dev.auth.json),
86
102
  // instead of silently writing auth.json while the channel reads elsewhere.
87
103
  // readme.md + .gitignore stay in <cwd>/.viber regardless.
88
- const authPath = authFilePath(cwd);
104
+ // #613: `authPath` comes from connectAuthPath (the claim decides for qa).
89
105
  mkdirSync(dirname(authPath), { recursive: true });
90
106
 
91
107
  const auth: Record<string, unknown> = {
@@ -159,6 +175,8 @@ export async function runConnect(
159
175
  cwd: string = process.cwd(),
160
176
  ): Promise<void> {
161
177
  const { baseUrl, claimId } = parseClaimUrl(claimUrl);
178
+ // #613: resolved BEFORE any network call, so a contradiction fails early.
179
+ const authPath = connectAuthPath(baseUrl, cwd);
162
180
 
163
181
  const fingerprint = clientFingerprint(cwd);
164
182
  process.stderr.write(
@@ -191,14 +209,11 @@ export async function runConnect(
191
209
  }
192
210
  const data = (await pollResp.json()) as PollResponse;
193
211
  if (data.status === "consumed") {
194
- writeAuthJson(cwd, data, fingerprint);
212
+ writeAuthJson(cwd, data, fingerprint, authPath);
195
213
  process.stdout.write(
196
214
  `\n✓ Connected as ${data.user_email} to project '${data.project_name}'.\n` +
197
- ` Wrote ${authFilePath(cwd)}\n\n` +
198
- `Next steps:\n` +
199
- ` 1. Register the channel MCP server (one-time per machine):\n` +
200
- ` claude mcp add viber-channel --scope user -- bunx viber-channel@latest\n` +
201
- ` 2. Restart Claude Code in this directory.\n\n`,
215
+ ` Wrote ${authPath}\n\n` +
216
+ (isQaBackend(baseUrl) ? QA_NEXT_STEPS : DEFAULT_NEXT_STEPS),
202
217
  );
203
218
  return;
204
219
  }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * gateway_result.ts — the runner's half of `viber-gateway team`'s result contract.
3
+ *
4
+ * ## ⚠ WHY THIS IS A SEPARATE CONTRACT AND NOT AN EXTENSION OF `spawn_reason.ts`
5
+ *
6
+ * JP's decision of 2026-08-27: the gateway's result line is a marker of its OWN. ▶ Widening
7
+ * `parseSpawnResult` to also accept a nonce-less or differently-shaped payload would have reopened
8
+ * "the last valid marker wins" for `vibe-master`'s marker too — one relaxation, two contracts
9
+ * weakened. ⚠ So the runner learns a SECOND reader rather than loosening the first.
10
+ *
11
+ * ## ⚠⚠ AND IT CARRIES A VERDICT, NOT ONLY A REASON — that asymmetry with `parseSpawnResult` is the fix
12
+ *
13
+ * Measured at `e9495114`:
14
+ *
15
+ * ```
16
+ * ../../viber-gateway/lib/team_spawn_command.ts:358
17
+ * "An INCOMPLET team whose members all reached a verdict is a RESULT, not a crash: 0"
18
+ * lib/runner_exec.ts resolve({ ok: !err, ... }) -> `err` is set on a NON-ZERO exit only
19
+ * ```
20
+ *
21
+ * ▶▶ So a HALF-LAUNCHED team exits `0`, and a consumer deriving success from the exit code reports
22
+ * `succeeded` — the browser then says "team spawn completed" while members are missing. ⚠ A
23
+ * reason-only marker does not close that: it would be ABSENT on a `0`-exit INCOMPLET team, and an
24
+ * absence is indistinguishable from a success.
25
+ *
26
+ * ⚠ The gateway's exit codes are NOT the defect and are not touched: `0` there means *a result was
27
+ * produced*, which is true. **The defect was reading that as *the team is there*.**
28
+ *
29
+ * ## ▶ THE FALLBACK IS A FAILURE, NEVER A SUCCESS
30
+ *
31
+ * `parseSpawnResult` returns `null` for "no marker" and the caller *degrades to the previous
32
+ * message* — correct there, because that path's success was already established by the exit code.
33
+ * ⚠ **Here the exit code cannot establish success**, so a `null` must not be readable as one. Rules
34
+ * entry 10: a fallback is only legitimate when its value is IMPOSSIBLE to confuse with a measurement.
35
+ */
36
+
37
+ import { SPAWN_REASONS, type SpawnReason } from "./spawn_reason.js";
38
+
39
+ /** Mirrors `../../viber-gateway/lib/result_marker.ts`. ⚠ Named separately from `VIBEMASTER_RESULT` so
40
+ * neither reader can be satisfied by the other's line. */
41
+ const GATEWAY_MARKER = "VIBERGATEWAY_RESULT";
42
+
43
+ /** Bounded at both ends of the pipe, like `vibe-master`'s detail. */
44
+ const DETAIL_MAX = 300;
45
+
46
+ const VERDICTS = ["COMPLETE", "INCOMPLET", "REFUSED"] as const;
47
+ export type GatewayVerdict = (typeof VERDICTS)[number];
48
+
49
+ export interface ParsedGatewayResult {
50
+ /** ⚠ `COMPLETE` is the ONLY value a caller may read as success. */
51
+ verdict: GatewayVerdict;
52
+ /**
53
+ * ⚠⚠ *Must someone go LOOK?* — the distinction the gateway's EXIT CODE carries and the verdict does
54
+ * not, and the one step-05 says the machine line owes: *"a caller cannot tell 'usage error' from 'a
55
+ * tree may be standing', and that is the only distinction that decides whether someone must go
56
+ * LOOK."*
57
+ *
58
+ * ▶ An `INCOMPLET` team may or may not have left a tree standing, so a consumer that wants to know
59
+ * whether to hunt cannot get it from the verdict. ⚠ **Defaults to `true` when the field is missing**
60
+ * — see {@link parseGatewayResult}: on this question the safe default is *go look*, because the
61
+ * expensive direction is believing nothing is standing when something is.
62
+ */
63
+ noVerdictTreeMayStand: boolean;
64
+ /** Absent when the gateway could not name the outcome truthfully in this vocabulary. ⚠ Then the
65
+ * caller has a proven FAILURE with no cause to render — which is honest, and is not a success. */
66
+ reason?: SpawnReason;
67
+ detail?: string;
68
+ }
69
+
70
+ /**
71
+ * Extract the gateway's marker from RAW stderr.
72
+ *
73
+ * Read BEFORE the recap is redacted and cut, for the reason `spawn_reason.ts` already states: the
74
+ * marker is written LAST, so it is the first thing a 4 000-character cut removes.
75
+ *
76
+ * ⚠ Scans BACKWARDS for the last line that starts with the marker AND parses AND echoes the nonce AND
77
+ * carries a known verdict — not simply the last line. ▶ An unknown value makes the scan keep going,
78
+ * which means a stale marker further back could be returned in its place; that is why the emitter
79
+ * check is mandatory here rather than optional, and why {@link gatewaySpawnOk} treats a `null` as a
80
+ * failure instead of a shrug.
81
+ *
82
+ * ⚠⚠ THE NONCE IS REQUIRED, WITH NO OPT-OUT ON THIS PATH — and what it buys is written, not implied.
83
+ * ▶ It DE-CORRELATES an accidental look-alike: an agent working on this repo prints this very contract
84
+ * on a deliberately-inherited stderr. ⚠ **It is not proof of emitter**: a same-user process can read a
85
+ * parent's command line on Windows and forge it. `runner_exec.ts` says the same of `vibe-master`'s
86
+ * nonce, so this path holds the SAME guarantee as the one it replaces and names it — no property that
87
+ * was ever held is withdrawn.
88
+ *
89
+ * ⚠ **And the bound on what the roster intersection protects — my first version claimed too much**
90
+ * (`gwl10-review-codex-2`): it said *what protects the RENDERED text is the roster intersection*.
91
+ * ▶ `keepRosterNames` intersects the **`detail`** only. It protects **no** part of the `verdict`, the
92
+ * `reason`, or which sentence gets selected — those are trusted from the marker. ⚠ *A guard credited
93
+ * with more than it covers is read as the answer to a defect it does not touch.*
94
+ */
95
+ export function parseGatewayResult(
96
+ rawStderr: string,
97
+ nonce: string,
98
+ ): ParsedGatewayResult | null {
99
+ if (nonce.length === 0) {
100
+ throw new Error("parseGatewayResult: the nonce is REQUIRED — there is no unauthenticated mode on this path");
101
+ }
102
+ const knownReasons = new Set<string>(SPAWN_REASONS);
103
+ const knownVerdicts = new Set<string>(VERDICTS);
104
+ for (const line of rawStderr.split(/\r?\n/).reverse()) {
105
+ if (!line.startsWith(`${GATEWAY_MARKER} `)) continue;
106
+ let parsed: unknown;
107
+ try {
108
+ parsed = JSON.parse(line.slice(GATEWAY_MARKER.length + 1));
109
+ } catch {
110
+ continue; // truncated or interleaved — keep looking further back
111
+ }
112
+ if (typeof parsed !== "object" || parsed === null) continue;
113
+ const {
114
+ verdict,
115
+ reason,
116
+ detail,
117
+ noVerdictTreeMayStand,
118
+ nonce: echoed,
119
+ } = parsed as {
120
+ verdict?: unknown;
121
+ reason?: unknown;
122
+ detail?: unknown;
123
+ noVerdictTreeMayStand?: unknown;
124
+ nonce?: unknown;
125
+ };
126
+ if (echoed !== nonce) continue;
127
+ if (typeof verdict !== "string" || !knownVerdicts.has(verdict)) continue;
128
+ return {
129
+ verdict: verdict as GatewayVerdict,
130
+ // ⚠⚠ FAILS TOWARD "GO LOOK". An older gateway, or a payload whose field was lost, must not read
131
+ // as "nothing is standing" — that is the cheap direction, and the expensive one is a tree nobody
132
+ // hunts. ▶ Rules entry 10: a fallback is only legitimate when it cannot be confused with a
133
+ // measurement, and here the safe value and the reassuring value are OPPOSITE.
134
+ noVerdictTreeMayStand: typeof noVerdictTreeMayStand === "boolean" ? noVerdictTreeMayStand : true,
135
+ // ⚠ A reason this runner does not know is DROPPED while the verdict is kept: the verdict is what
136
+ // decides success, so an unknown reason must cost the SENTENCE and never the failure. ▶ The
137
+ // opposite order — discarding the line — would turn an unrecognised reason into a false green.
138
+ ...(typeof reason === "string" && knownReasons.has(reason)
139
+ ? { reason: reason as SpawnReason }
140
+ : {}),
141
+ ...(typeof detail === "string" && detail.length > 0
142
+ ? { detail: detail.slice(0, DETAIL_MAX) }
143
+ : {}),
144
+ };
145
+ }
146
+ return null;
147
+ }
148
+
149
+ /**
150
+ * Did the gateway spawn the whole team?
151
+ *
152
+ * ⚠⚠ **`null` — no readable marker — is a FAILURE.** ▶ The exit code cannot answer this question (a
153
+ * `0` covers an INCOMPLET team), so "we could not read the verdict" and "it worked" must not collapse
154
+ * into the same answer. A caller that wants the old exit-code behaviour has to say so in a diff.
155
+ */
156
+ export function gatewaySpawnOk(parsed: ParsedGatewayResult | null): boolean {
157
+ return parsed?.verdict === "COMPLETE";
158
+ }
159
+
160
+ /**
161
+ * Was the DESIRED STATE already in place, so that launching nothing is the right outcome?
162
+ *
163
+ * ⚠⚠ JP's arbitration of 2026-08-27, on a defect the live shot surfaced: a replayed spawn that is
164
+ * CORRECTLY de-duplicated used to reach the browser as a RED failure. ▶ *Nothing is broken — the
165
+ * team is running, and the system protected the user from a duplicate.* ⚠ So it reports `succeeded`
166
+ * with a message that says nothing was launched.
167
+ *
168
+ * ## ⚠⚠ WHY THIS IS NOT THE FALSE GREEN THIS MODULE EXISTS TO CLOSE
169
+ *
170
+ * ⚠ The whole point of {@link gatewaySpawnOk} is that ONLY `COMPLETE` is success, because a
171
+ * half-launched team must never read as one. ▶ **This predicate is safe for a measured reason, not
172
+ * a judged one**: `already_running` is emitted by
173
+ * `../../viber-gateway/lib/result_marker.ts` ONLY when this call launched NOTHING **and**
174
+ * `alreadyPresent` covers the WHOLE roster **and** the roster is non-empty. ⚠⚠ **So the condition
175
+ * under which this returns true is exactly the condition that guard validates** — the desired roster
176
+ * is present, in full.
177
+ *
178
+ * ▶ Measured live (TIR D, 2026-08-27): second emission of the same command, `member: existing`,
179
+ * ONE record, ONE manifest, same `jobName`, same host anchor.
180
+ *
181
+ * ⚠ **It is a SEPARATE predicate rather than a widening of `gatewaySpawnOk`**: that function's
182
+ * meaning — *the whole team was launched by this call* — stays exact, and the new case shows up in a
183
+ * diff instead of hiding inside a boolean that already had a name.
184
+ */
185
+ export function gatewayNothingToDo(parsed: ParsedGatewayResult | null): boolean {
186
+ // ⚠⚠ THIS FUNCTION WAS DEAD, and the decision it names was taken on a BARE STRING beside it
187
+ // (`runner_exec.ts`: `reason === "already_running"`). Found independently, under seal, by
188
+ // `gwl10-review-opus` (execution) and `gwl10-review-codex-2` (contract), #590.
189
+ //
190
+ // ▶ The guarantee lived on a function nobody called; the decision lived in an expression that
191
+ // asserted less. **Rules entry 23: two statements about the same fact, and neither reads the other.**
192
+ //
193
+ // ⚠⚠ AND THE VERDICT IS CHECKED HERE, which the bare string never did. The docblock above
194
+ // justifies this predicate by the EMITTER's guard — nothing launched, `alreadyPresent` covers the
195
+ // whole roster — but that is a property of the EMITTER while this decision is taken by the READER,
196
+ // and the header of this very file writes that the nonce **de-correlates and is NOT proof of
197
+ // emitter**. ▶ So the reader verifies what it was relying on the emitter to hold:
198
+ //
199
+ // gateway_result.ts VERDICTS accepted here = COMPLETE | INCOMPLET | REFUSED
200
+ // result_marker.ts the team report emits COMPLETE | INCOMPLET
201
+ //
202
+ // ⚠⚠⚠ AND THE FIRST VERSION OF THIS GUARD REQUIRED `COMPLETE`, WHICH WAS A REGRESSION — caught by
203
+ // `gwl10-review-codex-2` under seal, reproduced by execution before being accepted.
204
+ //
205
+ // ⚠ It rested on *“`INCOMPLET` + `already_running` is internally inconsistent”*. **That premise is
206
+ // FALSE**, and the emitter says so:
207
+ //
208
+ // spawn.ts:533 verdict = launched.length === members.length ? COMPLETE : INCOMPLET
209
+ // result_marker.ts `already_running` requires launched.length === 0 AND members.length > 0
210
+ // => a NOMINAL REPLAY is ALWAYS `INCOMPLET`, and `COMPLETE + already_running` is UNREACHABLE
211
+ //
212
+ // ▶ Measured through the emitter itself:
213
+ // gatewayResultFor({verdict:"INCOMPLET", launched:[], alreadyPresent:["a","b"], members:["a","b"]})
214
+ // -> {"verdict":"INCOMPLET","reason":"already_running","detail":"a, b"}
215
+ //
216
+ // ⚠⚠ So requiring `COMPLETE` made this predicate **NEVER TRUE** — a guard that cannot bite — and
217
+ // it restored the FALSE RED that JP's arbitration existed to remove. ▶ `COMPLETE` here means *"every
218
+ // member was launched BY THIS CALL"*, which a replay by definition is not.
219
+ //
220
+ // ⚠ And the witness that "proved" it fabricated a `COMPLETE + already_running` marker by hand — a
221
+ // pair the emitter never produces. **A fixture built from the clause cannot refute the clause**
222
+ // (rules entry 16).
223
+ //
224
+ // ▶▶ What the reader can legitimately check is what the TEAM REPORT can emit: `COMPLETE` or
225
+ // `INCOMPLET`, never `REFUSED`. That keeps the protection — a verdict outside the emitter's range
226
+ // is refused — without denying the arbitrated case.
227
+ //
228
+ // ⚠ `already_running` is the ONLY reason in the vocabulary that renders as reassuring
229
+ // (`spawn_reason.ts`) — *the exact false green this issue exists to remove*.
230
+ // ⚠⚠ WRITTEN IN THE POSITIVE, and the first version was `verdict !== "REFUSED"` — an EXCLUSION,
231
+ // which decides by OMISSION (`gwl10-review-opus`, #590). ▶ `VERDICTS` carries three values today;
232
+ // a fourth added tomorrow would fall on the PROMOTE side with nothing saying so.
233
+ //
234
+ // ▶▶ The set is identical today, and a new verdict now lands on the SAFE side. ⚠ This is the
235
+ // third exclusion-list defect of this batch, all three the same shape, so the form is the fix.
236
+ //
237
+ // ⚠⚠ AND THE REASON THIS GUARD EXISTS IS NOT "the emitter cannot produce that pair"
238
+ // (`gwl10-review-opus`, and his argument is better than the one first written here):
239
+ //
240
+ // ▶▶ **The reader cannot authenticate the emitter — this file says so a few lines up — so the
241
+ // EMITTER'S RANGE does not protect the reader. A reader's guard must hold against ANY marker.**
242
+ //
243
+ // ⚠ That is the same family as the nonce: it DE-CORRELATES an accidental look-alike, it does not
244
+ // prove who wrote the line. ▶ So this predicate is not checking "is this pair plausible from our
245
+ // own emitter" — it is refusing to promote the single reassuring word in the vocabulary on anything
246
+ // but the two verdicts a team report is allowed to carry, whoever wrote them.
247
+ return (
248
+ parsed?.reason === "already_running" &&
249
+ (parsed.verdict === "COMPLETE" || parsed.verdict === "INCOMPLET")
250
+ );
251
+ }
252
+
253
+ /**
254
+ * Must someone go hunt a process on that machine?
255
+ *
256
+ * ⚠ Separate from {@link gatewaySpawnOk} because the two answer DIFFERENT questions and a single
257
+ * boolean conflated them: a `COMPLETE` team can still leave a member whose tree status could not be
258
+ * established, and a `REFUSED` command may have created nothing at all. ▶ **Success and "go look" are
259
+ * not each other's negation**, which is exactly why the gateway splits `2` from `3`.
260
+ *
261
+ * ⚠ `null` — no readable marker — answers **TRUE**: we could not establish anything, so we cannot
262
+ * establish that nothing is standing.
263
+ */
264
+ export function gatewayNoVerdictTreeMayStand(parsed: ParsedGatewayResult | null): boolean {
265
+ return parsed === null ? true : parsed.noVerdictTreeMayStand;
266
+ }
package/lib/messages.ts CHANGED
@@ -160,6 +160,13 @@ export async function postMessage(
160
160
  * `signal.aborted` guard before the call cannot.
161
161
  */
162
162
  signal?: AbortSignal,
163
+ /**
164
+ * Optional fetch seam (#606). Defaults to the global `fetch`. Exists so the
165
+ * failure-notice path of `sseLoop` is OBSERVABLE in tests: without it the only
166
+ * way to read what the bridge posts is to monkey-patch the global, and a test
167
+ * that cannot see the notice cannot prove the fix that exists to emit it.
168
+ */
169
+ fetchImpl: typeof fetch = fetch,
163
170
  ): Promise<MessagePostResult> {
164
171
  if (!text || text.trim().length === 0) {
165
172
  return {
@@ -173,7 +180,7 @@ export async function postMessage(
173
180
  const url = `${baseUrl}/api/conversations/${conversationId}/messages`;
174
181
  let resp: Response;
175
182
  try {
176
- resp = await fetch(url, {
183
+ resp = await fetchImpl(url, {
177
184
  method: "POST",
178
185
  headers: {
179
186
  Authorization: `Bearer ${conversationToken}`,
@@ -18,6 +18,7 @@ import { randomBytes } from "node:crypto";
18
18
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import { cfAccessHeaders } from "./cfAccess.js";
21
+ import { gatewayNothingToDo, gatewaySpawnOk, parseGatewayResult } from "./gateway_result.js";
21
22
  import { messageForReason, parseSpawnResult, rosterNames, type SpawnReason } from "./spawn_reason.js";
22
23
 
23
24
  /** A validated role from the server snapshot (allowlisted shape). */
@@ -155,7 +156,29 @@ export type TeamSpawnRunner = (
155
156
  * explicit trust parameter closes. A mock may ignore the argument; it cannot
156
157
  * forget that it exists. */
157
158
  nonce: string,
158
- ) => Promise<{ ok: boolean; recap: string; reason?: SpawnReason; detail?: string }>;
159
+ ) => Promise<{
160
+ ok: boolean;
161
+ recap: string;
162
+ reason?: SpawnReason;
163
+ detail?: string;
164
+ /**
165
+ * ⚠⚠ #590: **the LAUNCHER states whether the desired state was already in place** — the reader
166
+ * no longer infers it from a bare `reason` string.
167
+ *
168
+ * ▶ The same word reaches the reader from two launchers with two DIFFERENT guards, and the value
169
+ * that crossed this boundary carried neither the provenance nor the proof
170
+ * (`gwl10-review-codex-2`). Each runner now proves its own claim: the gateway through
171
+ * {@link gatewayNothingToDo} (roster coverage **and** verdict), `vibe-master` through
172
+ * `classifyCollision`, which emits this word only when NO roster member is missing.
173
+ *
174
+ * ⚠ **OPTIONAL, and the reader FAILS CLOSED on absence** (`?? false`): an omitted field is *no
175
+ * claim*, never *nothing to do*. ▶ This is deliberately unlike the `nonce` above, which is
176
+ * REQUIRED because an optional nonce would fail OPEN — a launcher could omit it and silently
177
+ * trust any marker. **Here omission denies, there omission would have trusted.** ⚠ Do not
178
+ * "harmonise" the two: the asymmetry is the safety.
179
+ */
180
+ nothingToDo?: boolean;
181
+ }>;
159
182
 
160
183
  /**
161
184
  * Build a CURATED child environment (Codex review, blocking): `execFile` would
@@ -227,6 +250,230 @@ export const execFileTeamSpawn: TeamSpawnRunner = (args, nonce) => {
227
250
  const recap = redactSecrets(`${stdout}\n${stderr}`.trim()).slice(0, 4000);
228
251
  resolve({
229
252
  ok: !err,
253
+ // ▶ #590: vibe-master's OWN guarantee, stated by the launcher that holds it.
254
+ // ⚠ MEASURED (`vibe-master/lib/teams.ts`, `classifyCollision`): this word is emitted only
255
+ // when `missing.length === 0` — every roster name live — and a non-colliding call returns
256
+ // `null` before it, so an empty roster cannot produce it either. A partial team is
257
+ // `partial_team` and stays a failure. ▶ **The same roster-coverage property as the
258
+ // gateway's, by different code** — so promoting only the gateway path would turn a
259
+ // CORRECT de-duplication here back into a red, which is the defect JP arbitrated away.
260
+ nothingToDo: parsed?.reason === "already_running",
261
+ recap,
262
+ ...(parsed?.reason ? { reason: parsed.reason } : {}),
263
+ ...(parsed?.detail ? { detail: parsed.detail } : {}),
264
+ });
265
+ },
266
+ );
267
+ });
268
+ };
269
+
270
+ /**
271
+ * The base command that launches `viber-gateway`. ⚠ Same convention as
272
+ * {@link vibeMasterBaseCmd} rather than a second one — a space-separated token
273
+ * list, still an argv ARRAY, no shell — because a dev runner must be able to
274
+ * point at the SOURCE gateway exactly as it can point at the source
275
+ * `vibe-master` (`VIBER_GATEWAY_CMD="bun /path/gateway.ts"`).
276
+ */
277
+ export function viberGatewayBaseCmd(source: NodeJS.ProcessEnv = process.env): string[] {
278
+ const raw = source.VIBER_GATEWAY_CMD?.trim();
279
+ const parts = raw ? raw.split(/\s+/) : ["viber-gateway"];
280
+ return parts.length > 0 ? parts : ["viber-gateway"];
281
+ }
282
+
283
+ /**
284
+ * Where the gateway's registry lives for THIS workspace.
285
+ *
286
+ * Beside the runner's other durable per-workspace state rather than at the bare cwd: see the
287
+ * measurement in {@link buildGatewayTeamArgs}. It must SURVIVE (contract line 17) and it must not be
288
+ * under `tmpdir()`, which `resolveRegistryDir` refuses outright.
289
+ */
290
+ export function gatewayRegistryDir(cwd: string = process.cwd()): string {
291
+ return join(cwd, ".viber", "gateway-registry");
292
+ }
293
+
294
+ /**
295
+ * Create the registry directory before the verb needs it, and return it.
296
+ *
297
+ * ⚠⚠ MEASURED IN THE LIVE FIRING WINDOW, through the real verb, right after `--dir` was wired:
298
+ *
299
+ * ```
300
+ * durable refusal: NOT PUBLISHED - ENOENT: no such file or directory, open
301
+ * '...\.viber\gateway-registry\manifest-....lock....staging'
302
+ * ```
303
+ *
304
+ * ▶ **`--dir` pointed at a directory nobody creates**, so `publishCommandRefusal` could not write and
305
+ * the refusal was NOT durable - and contract line 1 requires a refusal to be VISIBLE ON DISK.
306
+ *
307
+ * ⚠ And the neighbouring guard held, which is why the two halves are separate: the refusal was still
308
+ * PRINTED and its reason still travelled to the server. *A refusal nobody could record is still a
309
+ * refusal the caller must see.*
310
+ *
311
+ * ⚠ The party that CHOOSES the directory is the party that creates it. ▶ The gateway deliberately
312
+ * does NOT create an arbitrary `--dir`: a path a human mistyped should fail loudly rather than be
313
+ * silently brought into existence somewhere unintended.
314
+ */
315
+ export function ensureGatewayRegistryDir(cwd: string = process.cwd()): string {
316
+ const dir = gatewayRegistryDir(cwd);
317
+ mkdirSync(dir, { recursive: true });
318
+ return dir;
319
+ }
320
+
321
+ /**
322
+ * The argv for `viber-gateway team`. ⚠ EXPORTED and PURE so the route can be
323
+ * witnessed without launching anything: piloting the verb would start processes,
324
+ * so what a unit test can prove is the ARGV, and the verb itself is measured in
325
+ * the firing window.
326
+ *
327
+ * ## ⚠⚠ THREE THINGS THE OLD ARGV DID NOT CARRY, and each is a measured mismatch
328
+ *
329
+ * ```
330
+ * viber-gateway/gateway.ts --command-id, --prefix, --team, --spec-file, --cwd are ALL REQUIRED
331
+ * ("none of them has a defensible default")
332
+ * the old argv ["team","spawn","--spec-file",p,"--prefix",x,"--result-nonce",n]
333
+ * + --team ONLY when cmd.team_name is set
334
+ * ```
335
+ *
336
+ * 1. ⚠ **`--team` is REQUIRED there and OPTIONAL here.** ▶ It falls back to the
337
+ * PREFIX, which is already this command's team identity in practice:
338
+ * `rosterNames` builds every member name as `{prefix}-{role}`.
339
+ *
340
+ * ⚠⚠ **AND MY FIRST TWO REASONS FOR THIS FALLBACK WERE BOTH FALSE** — measured by
341
+ * `gwl10-review-codex-2` on the contract angle, and the fallback survives them:
342
+ *
343
+ * ```
344
+ * team_derivation.ts:308-310 rosterIdFor(cmdId) = digest({domain, cmd: cmdId})
345
+ * team_spawn_command.ts:277 const rosterId = rosterIdFor(cmd.id)
346
+ * ```
347
+ *
348
+ * ▶ The manifest is keyed by the **command id**, and it CONTAINS `team` +
349
+ * `agentIds`; it is not *"keyed `(team, agentId)`"* as I wrote. ⚠ So two commands
350
+ * sharing a prefix get two DISTINCT `cmd.id`s and therefore **two manifests** —
351
+ * my *"they share a manifest, which is CORRECT"* was false in both halves, and
352
+ * contract lines 13/14 are about the SAME command replayed, not two commands.
353
+ *
354
+ * ▶ **What actually requires a non-empty value** is `MixedTeam`, which refuses a
355
+ * batch whose team is `""` (`spawn.ts:318`), plus the gateway's own refusal of an
356
+ * absent `--team` (*"no defensible default"*). ⚠ *A conclusion that survives two
357
+ * false reasons still needs the true one written next to it.*
358
+ * 2. ⚠ **`--cwd`**: the runner's workspace is `process.cwd()` everywhere in this
359
+ * file (`.viber/runner-specs`, `-logs`, `-inflight`), so that is the workspace
360
+ * the agents must get. Passing none would have let the gateway decide, which is
361
+ * a value only this side can know.
362
+ * 3. ⚠⚠ **`--vibe-master-cmd`, and omitting it would be a SILENT DEV REGRESSION.**
363
+ * `VIBE_MASTER_CMD` exists so a dev runner points at the source instead of a
364
+ * stale global binary (the #460 dev-QA foot-gun). ▶ After this junction the
365
+ * runner no longer launches `vibe-master` — **the gateway does** — so the
366
+ * variable must travel as a FLAG or dev silently reverts to the installed
367
+ * binary. ⚠ Nothing would have gone red: the spawn would work, against the
368
+ * wrong binary. This is also the flag whose being *really taken by the verb* is
369
+ * one of the two live proofs the next firing window owes.
370
+ */
371
+ export function buildGatewayTeamArgs(cmd: {
372
+ commandId: string;
373
+ prefix: string;
374
+ teamName?: string;
375
+ specPath: string;
376
+ cwd: string;
377
+ registryDir: string;
378
+ permission: string;
379
+ env?: string;
380
+ nonce: string;
381
+ vibeMasterCmd?: string;
382
+ }): string[] {
383
+ const args = [
384
+ "team",
385
+ // ⚠ `--dir` IS REQUIRED HERE, and omitting it was a defect found in the firing-window preflight,
386
+ // before anything was launched. Measured at `8862ef3d`:
387
+ //
388
+ // ```
389
+ // buildGatewayTeamArgs -> no --dir
390
+ // gateway.ts main() resolveRegistryDir(argValue("--dir") ?? "")
391
+ // resolveRegistryDir("") -> <the REPO ROOT>
392
+ // ```
393
+ //
394
+ // ⚠ So every spawn would write `<launchId>.json`, `.manifest`, `.refusal` and `specs/` **into a
395
+ // checkout shared with another team** - untracked files at the repo root, which is the one thing
396
+ // this repo's shared-checkout rule forbids outright.
397
+ //
398
+ // ⚠ And the second hazard is worse than noise: `registry.ts:569` lists `*.json` in its own
399
+ // directory, so any JSON at that root reads back as a CORRUPT RECORD. Measured: this repo has
400
+ // ZERO `*.json` at its root today, so the hazard is not reached - **but that is an accident of
401
+ // layout, not a property**, and it is the third time this workshop has paid for treating a
402
+ // registry directory as a work area (*a suffix is not a namespace*).
403
+ //
404
+ // So it goes next to this file's other durable per-workspace state - `runner-specs`,
405
+ // `runner-logs`, `runner-inflight` - which also satisfies contract line 17: it SURVIVES, and it is
406
+ // not under `tmpdir()`, which `resolveRegistryDir` refuses outright.
407
+ "--dir",
408
+ cmd.registryDir,
409
+ "--command-id",
410
+ cmd.commandId,
411
+ "--prefix",
412
+ cmd.prefix,
413
+ // ⚠ See (1) above: the prefix IS the team identity when none was named.
414
+ "--team",
415
+ cmd.teamName ?? cmd.prefix,
416
+ "--spec-file",
417
+ cmd.specPath,
418
+ "--cwd",
419
+ cmd.cwd,
420
+ "--permission",
421
+ cmd.permission,
422
+ "--result-nonce",
423
+ cmd.nonce,
424
+ ];
425
+ if (cmd.env) args.push("--env", cmd.env);
426
+ // ▶ Only when set: an empty `--vibe-master-cmd` would make the gateway resolve an
427
+ // empty command, which is worse than not passing the flag at all.
428
+ if (cmd.vibeMasterCmd && cmd.vibeMasterCmd.trim().length > 0) {
429
+ args.push("--vibe-master-cmd", cmd.vibeMasterCmd.trim());
430
+ }
431
+ return args;
432
+ }
433
+
434
+ /**
435
+ * The gateway launcher. ⚠⚠ **`ok` is derived from the MARKER, never from the exit
436
+ * code**, and that is the whole reason this function exists beside
437
+ * {@link execFileTeamSpawn}.
438
+ *
439
+ * ```
440
+ * viber-gateway/lib/team_spawn_command.ts:358
441
+ * "An INCOMPLET team whose members all reached a verdict is a RESULT, not a crash: 0"
442
+ * ```
443
+ *
444
+ * ▶▶ So `ok = !err` would report `succeeded` on a HALF-LAUNCHED team, and the
445
+ * browser would say *team spawn completed* while members are missing. ⚠ The
446
+ * gateway's exit codes are right — `0` means *a result was produced* — and are not
447
+ * touched; what changes is that this consumer stops reading that as *the team is
448
+ * there*.
449
+ *
450
+ * ⚠ **No readable marker is a FAILURE**, not a degradation: unlike the
451
+ * `vibe-master` path, the exit code here cannot establish success, so
452
+ * "we could not read the verdict" must not collapse into "it worked"
453
+ * (`gatewaySpawnOk`).
454
+ */
455
+ export const execFileGatewayTeamSpawn: TeamSpawnRunner = (args, nonce) => {
456
+ const base = viberGatewayBaseCmd();
457
+ const bin = base[0] as string;
458
+ return new Promise((resolve) => {
459
+ execFile(
460
+ bin,
461
+ [...base.slice(1), ...args],
462
+ { timeout: 5 * 60 * 1000, killSignal: "SIGKILL", maxBuffer: 1024 * 1024, env: buildChildEnv() },
463
+ (_err, stdout, stderr) => {
464
+ // ⚠ RAW stderr, before redaction and before the 4 000-char cut, for the
465
+ // reason the marker is written LAST — the cut removes that end first.
466
+ const parsed = parseGatewayResult(stderr ?? "", nonce);
467
+ const recap = redactSecrets(`${stdout}\n${stderr}`.trim()).slice(0, 4000);
468
+ resolve({
469
+ // ▶ `_err` is deliberately UNUSED. Naming it with an underscore rather
470
+ // than dropping the parameter keeps the callback's shape identical to
471
+ // the vibe-master launcher's, so a reader comparing the two sees that
472
+ // the difference is a CHOICE and not an omission.
473
+ ok: gatewaySpawnOk(parsed),
474
+ // ▶ #590: the gateway's own guard, CALLED — it used to be dead code while the reader
475
+ // re-tested the bare string. It checks roster coverage AND the verdict.
476
+ nothingToDo: gatewayNothingToDo(parsed),
230
477
  recap,
231
478
  ...(parsed?.reason ? { reason: parsed.reason } : {}),
232
479
  ...(parsed?.detail ? { detail: parsed.detail } : {}),
@@ -394,7 +641,12 @@ export async function executeClaim(
394
641
  cmd: ClaimedCommand,
395
642
  deps: ExecDeps = {},
396
643
  ): Promise<"succeeded" | "failed" | "aborted"> {
397
- const runTeamSpawn = deps.runTeamSpawn ?? execFileTeamSpawn;
644
+ // ⚠⚠ THE DEFAULT IS THE GATEWAY NOW — that swap IS the junction, and leaving
645
+ // `execFileTeamSpawn` as the default behind a flag would have been a mechanism
646
+ // delivered without a junction, the exact shape this workshop has paid for twice.
647
+ // ▶ `execFileTeamSpawn` stays EXPORTED: it is still the launcher for anything that
648
+ // talks to `vibe-master` directly, and its witnesses keep measuring that contract.
649
+ const runTeamSpawn = deps.runTeamSpawn ?? execFileGatewayTeamSpawn;
398
650
  const fetchImpl = deps.apiFetch ?? fetch;
399
651
  const inflight = deps.inflight ?? fsInflightStore();
400
652
 
@@ -476,12 +728,74 @@ export async function executeClaim(
476
728
  // as the user; what protects the RENDERED text is the roster intersection
477
729
  // below, not this nonce.
478
730
  const nonce = randomBytes(16).toString("hex");
479
- const args = ["team", "spawn", "--spec-file", specPath, "--prefix", cmd.prefix, "--result-nonce", nonce];
480
- if (cmd.team_name) args.push("--team", cmd.team_name);
481
- if (cmd.env) args.push("--env", cmd.env);
731
+ //
732
+ // ⚠⚠ THE JUNCTION (#590 step-05). This used to be
733
+ // `["team","spawn","--spec-file",…]` for `vibe-master`; it is now
734
+ // `viber-gateway team`, which derives the batch, publishes the manifest BEFORE
735
+ // any claim, and invokes `vibe-master` itself. ▶ The route is the same shape —
736
+ // one team command, never N — because the spec file is *the snapshot the server
737
+ // validated* and splitting it here would recreate the local name resolution
738
+ // `--spec-file`'s strict mode exists to forbid.
739
+ //
740
+ // ▶ What the junction BUYS, and it is not a refactor: a `launchId` STABLE per
741
+ // `(command, agent)` (`attemptLaunchId`), so a replayed command claims the same
742
+ // attempt instead of minting a second one; and a denominator that survives the
743
+ // process, so a member refused before any claim stays counted.
744
+ const args = buildGatewayTeamArgs({
745
+ commandId: cmd.id,
746
+ prefix: cmd.prefix,
747
+ ...(cmd.team_name ? { teamName: cmd.team_name } : {}),
748
+ specPath,
749
+ cwd: process.cwd(),
750
+ // Beside `runner-specs`/`runner-logs`/`runner-inflight`, never the bare cwd - see
751
+ // `buildGatewayTeamArgs`, where the measurement that forced this is recorded.
752
+ registryDir: ensureGatewayRegistryDir(),
753
+ // ⚠ The roster's own permission, taken from the SERVER-VALIDATED snapshot and
754
+ // not invented here. ▶ A roster whose members disagree is REFUSED by the
755
+ // gateway (`assertUniformPermission`) rather than launched at one of the two
756
+ // — which is a behaviour CHANGE and the right one: `vibe-master` reads its
757
+ // permission from the ARGV and never from the spec, so a mixed roster used to
758
+ // launch a `read-only` member `read-write` with nothing going red.
759
+ permission: cmd.template_spec.roles[0]?.permission ?? "read-only",
760
+ ...(cmd.env ? { env: cmd.env } : {}),
761
+ nonce,
762
+ // ⚠ See (3) on `buildGatewayTeamArgs`: the runner no longer launches
763
+ // `vibe-master`, so this must TRAVEL or dev silently uses the installed
764
+ // binary. Read from the env here, where the variable is already the
765
+ // convention, rather than re-invented as a second knob.
766
+ ...(process.env.VIBE_MASTER_CMD ? { vibeMasterCmd: process.env.VIBE_MASTER_CMD } : {}),
767
+ });
482
768
 
483
769
  inflight.mark(cmd.id); // durable "started here" BEFORE launch (C4)
484
- const { ok, recap, reason, detail } = await runTeamSpawn(args, nonce);
770
+ const { ok, recap, reason, detail, nothingToDo: launcherSaysNothingToDo } =
771
+ await runTeamSpawn(args, nonce);
772
+ // ⚠⚠ JP's arbitration of 2026-08-27: a replayed spawn that is CORRECTLY de-duplicated is a
773
+ // NORMAL outcome, not a failure. ▶ Measured live (TIR D): second emission of the same command
774
+ // -> `member: existing`, ONE record, ONE manifest, same jobName, same host anchor. **Nothing is
775
+ // broken, and the system protected the user from a duplicate** - yet the browser showed RED.
776
+ //
777
+ // ⚠⚠ AND IT IS NOT THE FALSE GREEN THIS BATCH CLOSED, for a MEASURED reason: on the gateway path
778
+ // `already_running` is emitted ONLY when this call launched NOTHING **and** `alreadyPresent` covers
779
+ // the WHOLE roster **and** the roster is non-empty (`result_marker.ts`, `gatewayNothingToDo`).
780
+ // ▶ So the desired roster is present, in full.
781
+ //
782
+ // ⚠ The BOUND, because the same word reaches here from two launchers: on the `vibe-master` path
783
+ // the guarantee is vibe-master's own, not those three conditions. ▶ Both mean *the team with
784
+ // this prefix is already running*, which is why the status is the same; but only the gateway path
785
+ // has the roster-coverage guard, and a reader must not credit it to the other.
786
+ //
787
+ // ⚠⚠ AND THIS LINE USED TO READ `reason === "already_running"` — the bare string, crossed with
788
+ // NOTHING. #590, found independently under seal by both reviewers. ▶ The comment directly above
789
+ // says the guarantee DIFFERS by launcher, and the code three lines below credited it to both.
790
+ //
791
+ // ▶ Now each launcher STATES its own claim and the reader consumes the claim, not the word.
792
+ // ⚠ **FAIL CLOSED**: an absent field is *no claim*, so it denies the promotion. A launcher that
793
+ // says nothing gets the old failure path, never the reassuring one.
794
+ //
795
+ // ⚠⚠ `reason` is deliberately NOT consulted here any more. Re-adding it as a fallback
796
+ // (`?? reason === "already_running"`) would restore exactly the defect this closes — the
797
+ // reassuring word promoted on no proof — and it would do so while LOOKING like belt-and-braces.
798
+ const nothingToDo = launcherSaysNothingToDo ?? false;
485
799
 
486
800
  // #496: the failure now travels as a REASON plus a sentence built from it —
487
801
  // whoever clicked Spawn in a browser can act on what they read. The full
@@ -495,7 +809,7 @@ export async function executeClaim(
495
809
  // said "reconcile: handled 1 command(s)". A failure now states itself where
496
810
  // it is read FIRST. This stream is local, so the ABSOLUTE path belongs here;
497
811
  // what travels to the server is the relative ref below.
498
- if (!ok) {
812
+ if (!ok && !nothingToDo) {
499
813
  const writeDaemonLine =
500
814
  deps.writeDaemonLine ?? ((line: string) => process.stderr.write(`${line}\n`));
501
815
  writeDaemonLine(
@@ -510,6 +824,17 @@ export async function executeClaim(
510
824
  cmd.id,
511
825
  ok
512
826
  ? { fencing_token: cmd.fencing_token, status: "succeeded", result: { message: "team spawn completed", log: logPath } }
827
+ : nothingToDo
828
+ ? {
829
+ fencing_token: cmd.fencing_token,
830
+ status: "succeeded",
831
+ // ▶ A DIFFERENT message from the launch case, deliberately: the outcome is normal but it
832
+ // is NOT "the team was launched", and a reader must be able to tell the two apart.
833
+ result: {
834
+ message: "team already running — nothing was launched",
835
+ log: logPath,
836
+ },
837
+ }
513
838
  : {
514
839
  fencing_token: cmd.fencing_token,
515
840
  status: "failed",
@@ -532,7 +857,7 @@ export async function executeClaim(
532
857
  // the marker so a re-claim doesn't relaunch a command that already ran here.
533
858
  if (finalStatus >= 200 && finalStatus < 300) {
534
859
  inflight.clear(cmd.id);
535
- return ok ? "succeeded" : "failed";
860
+ return ok || nothingToDo ? "succeeded" : "failed";
536
861
  }
537
862
  return "aborted"; // report not accepted (409/other) — marker kept for reconcile
538
863
  } finally {
package/lib/urls.ts CHANGED
@@ -34,6 +34,8 @@ export function isTrustedViberOrigin(origin: string): boolean {
34
34
  if (host === "viber.dgypx.dev" || host === "viber-dev.dgypx.dev") return true;
35
35
  // #480: the agent API hosts. Hard-coded on purpose — see the warning below.
36
36
  if (host === "viber-api-staging.dgypx.dev") return true;
37
+ // #613: the qa env (web + agent API host), isolated from staging.
38
+ if (host === "viber-qa.dgypx.dev" || host === "viber-api-qa.dgypx.dev") return true;
37
39
  // Explicitly-configured bases (self-host / dev override) — matched by origin.
38
40
  // Both are LOCAL env vars, i.e. already trusted input.
39
41
  for (const configured of [process.env.VIBER_BASE_URL, process.env.VIBER_API_BASE_URL]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.8.17",
3
+ "version": "0.8.19",
4
4
  "description": "Voice + text MCP channel between a Claude Code session and the Viber UI (https://viber.dgypx.dev). Push transcripts to Claude; send_message tool delivers text back to the UI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,9 +42,57 @@ import {
42
42
  safeErrorMessage,
43
43
  senderLabel,
44
44
  shouldHandleMessage,
45
+ type TurnDegradation,
45
46
  type TurnPostAction,
46
47
  } from "./lib/bridge_core.ts";
47
48
 
49
+ /**
50
+ * #606 step-02 — pull the HTTP status out of a codex `codexErrorInfo`, if it has one.
51
+ *
52
+ * MEASURED shape: `{"responseStreamDisconnected":{"httpStatusCode":503}}`. Other
53
+ * variants are plain strings (`"usageLimitExceeded"`) with no status at all, so
54
+ * this returns undefined rather than guessing — the notice then simply omits the
55
+ * clause. Pure and exported so the shape can be tested without a codex process.
56
+ */
57
+ /**
58
+ * #606 step-02 — is this codex notification a transport DEGRADATION, and which
59
+ * turn does it belong to? Pure, so the measured JSON-RPC shape is tested without
60
+ * a codex process (the class of defect in .claude/rules: a wiring only exercised
61
+ * through a hand-stamped fixture is not exercised at all).
62
+ */
63
+ export function readDegradationNotification(
64
+ params: unknown,
65
+ ): { threadId: string; turnId: string; status?: number } | undefined {
66
+ const p = params as
67
+ | { willRetry?: unknown; turnId?: unknown; threadId?: unknown; error?: { codexErrorInfo?: unknown } }
68
+ | undefined;
69
+ if (!p || p.willRetry !== true) return undefined;
70
+ if (typeof p.threadId !== "string" || typeof p.turnId !== "string") return undefined;
71
+ return { threadId: p.threadId, turnId: p.turnId, status: extractHttpStatus(p.error?.codexErrorInfo) };
72
+ }
73
+
74
+ /**
75
+ * #606 step-02 — stamp the degradation a turn suffered onto the Error it fails
76
+ * with. A failing turn returns no TurnResult, so the Error is the only carrier;
77
+ * bridge_core reads it back with readTurnDegradation. Zero events → no stamp, so
78
+ * a clean failure never claims a degraded transport.
79
+ */
80
+ export function stampDegradation(err: Error, events: number, lastStatus?: number): Error {
81
+ if (events > 0) (err as Error & { degradation?: TurnDegradation }).degradation = { events, lastStatus };
82
+ return err;
83
+ }
84
+
85
+ export function extractHttpStatus(info: unknown): number | undefined {
86
+ if (!info || typeof info !== "object") return undefined;
87
+ for (const value of Object.values(info as Record<string, unknown>)) {
88
+ if (value && typeof value === "object") {
89
+ const status = (value as { httpStatusCode?: unknown }).httpStatusCode;
90
+ if (typeof status === "number") return status;
91
+ }
92
+ }
93
+ return undefined;
94
+ }
95
+
48
96
  /** Per-bridge log tag — passed to the shared core so its lines stay under this prefix. */
49
97
  const LOG_PREFIX = "[viber-codex-bridge]";
50
98
  // Re-export the bridge-core symbols that existing importers/tests reference by
@@ -117,6 +165,15 @@ interface TurnWaiter {
117
165
  threadId: string;
118
166
  turnId: string;
119
167
  deltas: string[];
168
+ /**
169
+ * #606 step-02 — transport degradation observed DURING this turn: codex emits a
170
+ * JSON-RPC `error` notification with `willRetry: true` for each WebSocket
171
+ * reconnect / HTTPS fallback, carrying `turnId` and an httpStatusCode. Counted
172
+ * here so a turn that later fails can SAY it was already limping, instead of
173
+ * leaving the model to invent why it was slow.
174
+ */
175
+ degradationEvents: number;
176
+ degradationLastStatus?: number;
120
177
  resolve: (value: string) => void;
121
178
  reject: (err: Error) => void;
122
179
  timeout: ReturnType<typeof setTimeout>;
@@ -690,7 +747,13 @@ class CodexAppServer {
690
747
  this.pending.clear();
691
748
  for (const waiter of this.turnWaiters.values()) {
692
749
  clearTimeout(waiter.timeout);
693
- waiter.reject(err);
750
+ // #606, review codex: same reason as the timeout path — a child that dies
751
+ // after a reconnect storm should say the storm, not only the death. A FRESH
752
+ // Error per waiter: stamping the shared one would give every waiter the
753
+ // last one's counters.
754
+ waiter.reject(
755
+ stampDegradation(new Error(reason), waiter.degradationEvents, waiter.degradationLastStatus),
756
+ );
694
757
  }
695
758
  this.turnWaiters.clear();
696
759
  // The child is gone — the bridge must NOT keep beating/serving SSE as a
@@ -881,7 +944,10 @@ class CodexAppServer {
881
944
  status === "failed" ||
882
945
  (typeof status === "object" && status !== null && "type" in status && status.type === "failed")
883
946
  ) {
884
- waiter.reject(new Error(`Codex turn failed: ${JSON.stringify(params.turn?.error ?? status)}`));
947
+ const failure = new Error(`Codex turn failed: ${JSON.stringify(params.turn?.error ?? status)}`);
948
+ // #606: a failing turn returns no TurnResult, so the degradation count
949
+ // rides on the Error itself — bridge_core reads it with readTurnDegradation.
950
+ waiter.reject(stampDegradation(failure, waiter.degradationEvents, waiter.degradationLastStatus));
885
951
  } else {
886
952
  waiter.resolve(waiter.deltas.join("").trim());
887
953
  }
@@ -890,9 +956,41 @@ class CodexAppServer {
890
956
 
891
957
  if (msg.method === "error" || msg.method === "warning" || msg.method === "configWarning") {
892
958
  process.stderr.write(`[viber-codex-bridge] codex ${msg.method}: ${JSON.stringify(msg.params)}\n`);
959
+ this.recordDegradation(msg);
893
960
  }
894
961
  }
895
962
 
963
+ /**
964
+ * #606 step-02 — count a transport hiccup against the turn that suffered it.
965
+ *
966
+ * MEASURED shape (.vctl/logs/dynamic-vibe-agent-606-review-codex.raw, l.34):
967
+ * {"error":{"message":"Reconnecting... 2/5",
968
+ * "codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":503}}},
969
+ * "willRetry":true,"threadId":"...","turnId":"..."}
970
+ *
971
+ * `willRetry: true` is the discriminator: codex is saying it is RECOVERING, so
972
+ * this is degradation, not the failure itself (the failure arrives separately as
973
+ * turn/completed with status failed). The count only ever reaches the channel
974
+ * through a turn that goes on to FAIL — a turn that recovers stays on stderr,
975
+ * because the channel is not a transport log.
976
+ *
977
+ * NOT COVERED, deliberately, and said here rather than left to look forgotten:
978
+ * - an abnormally LONG turn with no error notification (the issue's 43.8 s) —
979
+ * catching it needs a threshold, and a literal threshold is the very defect
980
+ * class of .claude/rules/instruments-qui-concluent-a-lenvers.md §4;
981
+ * - gemma has nothing equivalent: lib/ollama_chat.ts is a single fetch with no
982
+ * intermediate transport events, so there is nothing to observe and nothing
983
+ * is promised. bridge_core's failure notice still covers its FAILURES.
984
+ */
985
+ private recordDegradation(msg: JsonRpcMessage): void {
986
+ const event = readDegradationNotification(msg.params);
987
+ if (!event) return;
988
+ const waiter = this.turnWaiters.get(`${event.threadId}:${event.turnId}`);
989
+ if (!waiter) return;
990
+ waiter.degradationEvents += 1;
991
+ if (event.status !== undefined) waiter.degradationLastStatus = event.status;
992
+ }
993
+
896
994
  private handleServerRequest(msg: JsonRpcMessage): void {
897
995
  const result = decideServerRequestResponse(msg.method!, msg.params);
898
996
  // Log accepts of our tool-call elicitation and every decline, so the surgical
@@ -921,10 +1019,21 @@ class CodexAppServer {
921
1019
  const key = `${threadId}:${turnId}`;
922
1020
  return new Promise((resolve, reject) => {
923
1021
  const timeout = setTimeout(() => {
1022
+ const waiter = this.turnWaiters.get(key);
924
1023
  this.turnWaiters.delete(key);
925
- reject(new Error("Codex turn timed out after 10 minutes"));
1024
+ // #606, review codex: a timeout used to reject WITHOUT the degradation
1025
+ // stamp, so a turn that reconnected five times and then hung reported a
1026
+ // bare timeout — the most interesting failure losing the only fact that
1027
+ // explained it.
1028
+ reject(
1029
+ stampDegradation(
1030
+ new Error("Codex turn timed out after 10 minutes"),
1031
+ waiter?.degradationEvents ?? 0,
1032
+ waiter?.degradationLastStatus,
1033
+ ),
1034
+ );
926
1035
  }, 10 * 60 * 1000);
927
- this.turnWaiters.set(key, { threadId, turnId, deltas: [], resolve, reject, timeout });
1036
+ this.turnWaiters.set(key, { threadId, turnId, deltas: [], resolve, reject, timeout, degradationEvents: 0 });
928
1037
  });
929
1038
  }
930
1039
  }