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 +69 -3
- package/lib/bridge_core.ts +172 -2
- package/lib/connect.ts +23 -8
- package/lib/gateway_result.ts +266 -0
- package/lib/messages.ts +8 -1
- package/lib/runner_exec.ts +333 -8
- package/lib/urls.ts +2 -0
- package/package.json +1 -1
- package/viber-codex-bridge.ts +113 -4
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(
|
|
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;
|
package/lib/bridge_core.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 ${
|
|
198
|
-
|
|
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
|
|
183
|
+
resp = await fetchImpl(url, {
|
|
177
184
|
method: "POST",
|
|
178
185
|
headers: {
|
|
179
186
|
Authorization: `Bearer ${conversationToken}`,
|
package/lib/runner_exec.ts
CHANGED
|
@@ -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<{
|
|
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
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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 } =
|
|
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.
|
|
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": {
|
package/viber-codex-bridge.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|