viber-channel 0.8.17 → 0.8.20
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/agent_tools.ts +18 -1
- package/lib/auth.ts +69 -3
- package/lib/bridge_core.ts +247 -18
- package/lib/connect.ts +23 -8
- package/lib/control_stream.ts +3 -2
- package/lib/coverage_watch.ts +244 -0
- package/lib/gateway_result.ts +266 -0
- package/lib/messages.ts +8 -1
- package/lib/presence_record.ts +358 -0
- package/lib/process_start.ts +132 -10
- package/lib/runner_enroll.ts +26 -4
- package/lib/runner_exec.ts +556 -9
- package/lib/runner_registry.ts +413 -0
- package/lib/runner_relaunch.ts +181 -0
- package/lib/runner_roster.ts +521 -0
- package/lib/runner_stream.ts +66 -35
- package/lib/urls.ts +5 -1
- package/package.json +1 -1
- package/viber-channel.ts +65 -25
- package/viber-codex-bridge.ts +154 -12
package/lib/agent_tools.ts
CHANGED
|
@@ -78,6 +78,16 @@ export interface AgentToolsContext {
|
|
|
78
78
|
* signal); the Codex bridge returns its per-turn stream signal (#303 parity).
|
|
79
79
|
*/
|
|
80
80
|
abortSignal?(): AbortSignal | undefined;
|
|
81
|
+
/**
|
|
82
|
+
* #627: why this agent is PAUSED, or null when it may write. A paused agent has no
|
|
83
|
+
* confirmed conversation lease, so it must not post anything; the tool says why.
|
|
84
|
+
*/
|
|
85
|
+
pauseReason?(): string | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pausedResult(ctx: AgentToolsContext): AgentToolResult | null {
|
|
89
|
+
const reason = ctx.pauseReason?.() ?? null;
|
|
90
|
+
return reason === null ? null : errorText(`Paused — ${reason}. Nothing was sent; it resumes on its own when the runner covers this agent again.`);
|
|
81
91
|
}
|
|
82
92
|
|
|
83
93
|
function text(s: string): AgentToolResult {
|
|
@@ -164,6 +174,8 @@ export async function messageAgent(
|
|
|
164
174
|
if (!ctx.instanceToken() || !ctx.voiceBaseUrl()) {
|
|
165
175
|
return errorText("Channel not ready: no instance identity yet.");
|
|
166
176
|
}
|
|
177
|
+
const paused = pausedResult(ctx);
|
|
178
|
+
if (paused !== null) return paused;
|
|
167
179
|
const peerId = args.instance_id;
|
|
168
180
|
const body = args.text;
|
|
169
181
|
if (typeof peerId !== "string" || peerId.trim().length === 0) {
|
|
@@ -183,7 +195,10 @@ export async function messageAgent(
|
|
|
183
195
|
if (!outcome.ok) {
|
|
184
196
|
let friendly: string;
|
|
185
197
|
if (outcome.detail === "target_offline") {
|
|
186
|
-
|
|
198
|
+
// #627: a just-launched agent comes online with its runner's first beat (≤ 30 s).
|
|
199
|
+
friendly =
|
|
200
|
+
"That agent is offline right now — it must be running to receive a DM. " +
|
|
201
|
+
"If it was just launched, it comes online with its runner's next beat (within 30 s): retry then.";
|
|
187
202
|
} else if (outcome.detail === "dm_unavailable_retry") {
|
|
188
203
|
friendly = "Could not open the DM due to a transient conflict — try message_agent again.";
|
|
189
204
|
} else {
|
|
@@ -237,6 +252,8 @@ export async function sendMessage(
|
|
|
237
252
|
if (!ctx.sendConversationToken() || !ctx.sendConversationId()) {
|
|
238
253
|
return errorText("Channel not ready: no conversation yet.");
|
|
239
254
|
}
|
|
255
|
+
const paused = pausedResult(ctx);
|
|
256
|
+
if (paused !== null) return paused;
|
|
240
257
|
const body = args.text;
|
|
241
258
|
if (typeof body !== "string" || body.trim().length === 0) {
|
|
242
259
|
return errorText("Invalid input: text must be a non-empty string");
|
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
|
@@ -32,12 +32,12 @@ import type { AuthJson } from "./auth.js";
|
|
|
32
32
|
import { acquireInstance, registerInstance, instanceKindFromEnv, maybeAttachTeam, type AcquiredInstance } from "./instance.js";
|
|
33
33
|
import {
|
|
34
34
|
runPersistentControlStream,
|
|
35
|
-
sendInstanceHeartbeat,
|
|
36
35
|
ControlStreamStopped,
|
|
37
36
|
ControlStreamAuthError,
|
|
38
37
|
type PersistentControlStream,
|
|
39
38
|
} from "./control_stream.js";
|
|
40
|
-
import {
|
|
39
|
+
import { type PresenceGate, startCoverageWatch } from "./coverage_watch.js";
|
|
40
|
+
import { type PresenceEndReason, type PresenceRecordHandle, startPresenceRecord } from "./presence_record.js";
|
|
41
41
|
|
|
42
42
|
/** A conversation message as it arrives over the conversation SSE / message list. */
|
|
43
43
|
export interface ConversationMessage {
|
|
@@ -71,11 +71,19 @@ export interface ConversationRuntime {
|
|
|
71
71
|
scheduler?: TokenRefreshScheduler;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* A controlled bridge exit carrying the process exit code to propagate.
|
|
76
|
+
*
|
|
77
|
+
* `endReason` (#627) names the cause for the presence record. It is set ONLY where
|
|
78
|
+
* the cause is known (revocation, lease lost, --once done). The class alone says
|
|
79
|
+
* nothing about intent: it also carries failures (token expired, refresh failed),
|
|
80
|
+
* which must leave the agent relaunchable.
|
|
81
|
+
*/
|
|
75
82
|
export class BridgeShutdownError extends Error {
|
|
76
83
|
constructor(
|
|
77
84
|
message: string,
|
|
78
85
|
public readonly exitCode: number,
|
|
86
|
+
public readonly endReason: PresenceEndReason = "shutdown",
|
|
79
87
|
) {
|
|
80
88
|
super(message);
|
|
81
89
|
this.name = "BridgeShutdownError";
|
|
@@ -306,11 +314,17 @@ export async function postBridgeReply(opts: {
|
|
|
306
314
|
ownPostedIds: Set<string>;
|
|
307
315
|
signal?: AbortSignal;
|
|
308
316
|
logPrefix: string;
|
|
317
|
+
/** Fetch seam (#606) — see postMessage. Defaults to the global fetch. */
|
|
318
|
+
fetchImpl?: typeof fetch;
|
|
319
|
+
/** #627: a reply finished during a pause waits for the resume before it is posted. */
|
|
320
|
+
gate?: PresenceGate;
|
|
309
321
|
}): Promise<void> {
|
|
310
322
|
const { baseUrl, runtime, text, ownPostedIds, signal, logPrefix } = opts;
|
|
323
|
+
await opts.gate?.waitActive(signal);
|
|
324
|
+
if (signal?.aborted) return;
|
|
311
325
|
let result;
|
|
312
326
|
try {
|
|
313
|
-
result = await postMessage(baseUrl, runtime.id, runtime.token, text, undefined, signal);
|
|
327
|
+
result = await postMessage(baseUrl, runtime.id, runtime.token, text, undefined, signal, opts.fetchImpl ?? fetch);
|
|
314
328
|
} catch (err) {
|
|
315
329
|
if (err instanceof ConversationTokenExpiredError) {
|
|
316
330
|
throw new BridgeShutdownError("conversation token expired", 3);
|
|
@@ -345,6 +359,93 @@ export function decideTurnPostAction(opts: {
|
|
|
345
359
|
return "auto-post";
|
|
346
360
|
}
|
|
347
361
|
|
|
362
|
+
/** Max length of the runtime cause quoted verbatim in a channel failure notice. */
|
|
363
|
+
const FAILURE_CAUSE_MAX = 400;
|
|
364
|
+
|
|
365
|
+
/** Transport degradation observed during a turn (#606 step-02, codex only). */
|
|
366
|
+
export type TurnDegradation = { events: number; lastStatus?: number };
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* #606 — the text a bridge posts on the CHANNEL when a turn fails.
|
|
370
|
+
*
|
|
371
|
+
* The whole point of the issue is that a failed turn was written to stderr only,
|
|
372
|
+
* so the model filled the silence with an INVENTED cause. This text therefore
|
|
373
|
+
* quotes what the runtime said, VERBATIM and truncated, and adds nothing of its
|
|
374
|
+
* own: no classification, no advice, no guess. Pure (no clock, no network) so it
|
|
375
|
+
* is testable without a harness.
|
|
376
|
+
*/
|
|
377
|
+
export function failureNoticeText(err: unknown, degradation?: TurnDegradation): string {
|
|
378
|
+
const raw = safeErrorMessage(err).replace(/\s+/g, " ").trim();
|
|
379
|
+
const cause = raw.length > FAILURE_CAUSE_MAX ? `${raw.slice(0, FAILURE_CAUSE_MAX)}...` : raw || "(aucun detail)";
|
|
380
|
+
let text = `Tour echoue, aucune reponse produite. Cause rapportee par le runtime: ${cause}`;
|
|
381
|
+
if (degradation && degradation.events > 0) {
|
|
382
|
+
const status = degradation.lastStatus ? `, dernier statut HTTP ${degradation.lastStatus}` : "";
|
|
383
|
+
text += ` (transport degrade: ${degradation.events} evenement(s) avant l'echec${status})`;
|
|
384
|
+
}
|
|
385
|
+
return text;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* #606 — the dedup signature of a failure cause.
|
|
390
|
+
*
|
|
391
|
+
* A blown quota fails EVERY subsequent turn: without dedup the fix that makes a
|
|
392
|
+
* failure speak would flood the channel. Two failures of the same cause must
|
|
393
|
+
* therefore yield the SAME key, so the volatile identifiers a runtime stamps on
|
|
394
|
+
* each attempt (turnId, threadId, cf-ray, timestamps, long hex ids) are stripped.
|
|
395
|
+
*
|
|
396
|
+
* The degradation COUNT is deliberately reduced to its presence, never its value:
|
|
397
|
+
* keying on the number would make every different N a "new cause" and re-open the
|
|
398
|
+
* flood the dedup exists to close.
|
|
399
|
+
*/
|
|
400
|
+
export function failureCauseKey(err: unknown, degradation?: TurnDegradation): string {
|
|
401
|
+
const normalized = safeErrorMessage(err)
|
|
402
|
+
.replace(/"(?:turnId|threadId|conversationId|requestId)"\s*:\s*"[^"]*"/g, "")
|
|
403
|
+
// STOPS at a JSON delimiter (review codex P2a). `\S+` here crossed the
|
|
404
|
+
// closing quote and ate everything up to the next SPACE — measured: two
|
|
405
|
+
// errors differing only by httpStatusCode 503 vs 429 collapsed onto
|
|
406
|
+
// `Codex turn failed: {"message":"Request failed,` because the status sat
|
|
407
|
+
// AFTER the cf-ray. A normalisation that eats the discriminator gags the
|
|
408
|
+
// second outage: the exact defect this key exists to avoid.
|
|
409
|
+
.replace(/cf-ray:\s*[^\s",}\]]+/gi, "")
|
|
410
|
+
.replace(/\d{4}-\d{2}-\d{2}T[\d:.]+Z?/g, "")
|
|
411
|
+
// The RESET INSTANT of a quota, in the two shapes our own log carries for
|
|
412
|
+
// the same outage (review codex, .raw l.95 vs l.111): with the date and
|
|
413
|
+
// without. Stripping only the clock left them on different keys, so the
|
|
414
|
+
// same cause announced twice.
|
|
415
|
+
.replace(
|
|
416
|
+
/\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,
|
|
417
|
+
"",
|
|
418
|
+
)
|
|
419
|
+
.replace(/\b\d{1,2}:\d{2}(:\d{2})?\s*(AM|PM)?\b/gi, "")
|
|
420
|
+
.replace(/\b[0-9a-f]{12,}\b/gi, "")
|
|
421
|
+
.replace(/\s+/g, " ")
|
|
422
|
+
.trim();
|
|
423
|
+
const degraded = degradation && degradation.events > 0 ? "|degraded" : "";
|
|
424
|
+
// WHOLE, neither truncated nor hashed (review codex, both passes). Slicing to a
|
|
425
|
+
// display bound made the key lose identity past that bound — two errors sharing
|
|
426
|
+
// a long prefix became one cause and the second was silenced. A digest fixed
|
|
427
|
+
// that but kept a residual collision, which costs a SUPPRESSED notice: the
|
|
428
|
+
// defect this key exists to prevent, made rare instead of impossible.
|
|
429
|
+
// There is no size constraint to trade against: the key is one closure variable
|
|
430
|
+
// holding one cause at a time. Keeping the normalised string entire removes the
|
|
431
|
+
// class rather than shrinking it — a display concern must not decide identity,
|
|
432
|
+
// which is the whole lesson of P2b.
|
|
433
|
+
return `${normalized}${degraded}`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* #606 step-02 — read the transport degradation a runtime attached to the error
|
|
438
|
+
* it threw. The failing turn never returns a TurnResult, so the count cannot ride
|
|
439
|
+
* on one; the codex bridge stamps it on the Error itself. Any runtime that stamps
|
|
440
|
+
* nothing (gemma: a single fetch, no intermediate transport events to observe)
|
|
441
|
+
* simply yields undefined, and the notice omits the clause.
|
|
442
|
+
*/
|
|
443
|
+
export function readTurnDegradation(err: unknown): TurnDegradation | undefined {
|
|
444
|
+
const d = (err as { degradation?: unknown })?.degradation as TurnDegradation | undefined;
|
|
445
|
+
if (!d || typeof d.events !== "number" || d.events <= 0) return undefined;
|
|
446
|
+
return { events: d.events, lastStatus: typeof d.lastStatus === "number" ? d.lastStatus : undefined };
|
|
447
|
+
}
|
|
448
|
+
|
|
348
449
|
/**
|
|
349
450
|
* #429 cross-run dedup, mirror of dm_stream's `forwardMessage` guard (L146-147).
|
|
350
451
|
* True when this id was already DELIVERED in a prior run and must be skipped on a
|
|
@@ -440,6 +541,8 @@ export interface ConversationStreamDeps {
|
|
|
440
541
|
/** Session-handle tag distinguishing runtimes in the handle path (codex/gemma). */
|
|
441
542
|
sessionTag: string;
|
|
442
543
|
makeRunTurn: MakeRunTurn;
|
|
544
|
+
/** #627: the pause gate — inbound turns and replies wait while the agent is paused. */
|
|
545
|
+
gate?: PresenceGate;
|
|
443
546
|
}
|
|
444
547
|
|
|
445
548
|
/**
|
|
@@ -509,7 +612,7 @@ export async function runConversationStream(
|
|
|
509
612
|
watermark = { lastId: 0 };
|
|
510
613
|
watermarks.set(convId, watermark);
|
|
511
614
|
}
|
|
512
|
-
await sseLoop({ minted, runtime, fingerprint, ownInstanceKey: instanceKey, options, signal: ctrl.signal, getBaseUrl: baseUrl, logPrefix, runTurn, watermark });
|
|
615
|
+
await sseLoop({ minted, runtime, fingerprint, ownInstanceKey: instanceKey, options, signal: ctrl.signal, getBaseUrl: baseUrl, logPrefix, runTurn, watermark, gate: deps.gate });
|
|
513
616
|
} finally {
|
|
514
617
|
parentSignal.removeEventListener("abort", onParentAbort);
|
|
515
618
|
runtime.scheduler?.cancel();
|
|
@@ -562,14 +665,45 @@ export async function sseLoop(opts: {
|
|
|
562
665
|
fetchImpl?: typeof fetch;
|
|
563
666
|
/** Injectable catch-up fetch; defaults to messages.ts `fetchMessages`. */
|
|
564
667
|
fetchMessagesImpl?: typeof fetchMessages;
|
|
668
|
+
/** #627: the pause gate (see ConversationStreamDeps.gate). */
|
|
669
|
+
gate?: PresenceGate;
|
|
565
670
|
}): Promise<void> {
|
|
566
671
|
const { minted, runtime, fingerprint, ownInstanceKey, options, signal, getBaseUrl, logPrefix, runTurn, watermark } = opts;
|
|
672
|
+
const deps = { gate: opts.gate };
|
|
567
673
|
const baseUrl = getBaseUrl; // called at each use below
|
|
568
674
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
569
675
|
const fetchMessagesImpl = opts.fetchMessagesImpl ?? fetchMessages;
|
|
570
676
|
const sseUrl = buildSseUrl(minted.ws_url, minted.conversation_id);
|
|
571
677
|
const ownPostedIds = new Set<string>();
|
|
572
678
|
|
|
679
|
+
// #606 anti-flood state: the cause key of the LAST failure actually announced
|
|
680
|
+
// on the channel. A blown quota fails every subsequent turn, so announcing each
|
|
681
|
+
// one would replace a silence bug with a spam bug. Closure state, owned by this
|
|
682
|
+
// loop — no module singleton (.claude/rules/architecture.md §7).
|
|
683
|
+
// Cross-RUN note. This comment CLAIMED the key survives a re-join; it does not,
|
|
684
|
+
// and N.11 measures the opposite (review codex, prose-has-no-test: the code was
|
|
685
|
+
// right, the sentence was false). What actually happens: the watermark
|
|
686
|
+
// deliberately does NOT advance on a failed reply (at-least-once retry, see
|
|
687
|
+
// below), so a re-join replays the message and fails again — and since each
|
|
688
|
+
// sseLoop call owns a FRESH closure, that re-join announces once more. Same for
|
|
689
|
+
// a process restart. Deliberate: a bridge that comes back and hits the same wall
|
|
690
|
+
// is information. Within one join, the key is what stops the flood.
|
|
691
|
+
let lastAnnouncedCause: string | undefined;
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* #606 — a DELIVERED turn means the previous cause is over: rearm the notice.
|
|
695
|
+
*
|
|
696
|
+
* Lives HERE, on the turn outcome, and not at a caller. Review F2 measured why:
|
|
697
|
+
* written at the text caller only, the VOICE path never rearmed, so
|
|
698
|
+
* text-fails → voice-recovers → text-fails-again left the third failure GAGGED
|
|
699
|
+
* — the issue's own defect, reproduced by the issue's fix. Placing it on the
|
|
700
|
+
* outcome removes the class instead of patching the two paths that exist today.
|
|
701
|
+
* An ABORTED turn never gets here: teardown says nothing about runtime health.
|
|
702
|
+
*/
|
|
703
|
+
function rearmFailureNotice(): void {
|
|
704
|
+
lastAnnouncedCause = undefined;
|
|
705
|
+
}
|
|
706
|
+
|
|
573
707
|
// Run one turn for `content` via the injected runtime, then auto-post its
|
|
574
708
|
// reply UNLESS an outbound tool already posted this turn (anti-double-post).
|
|
575
709
|
// Returns true when the reply was dropped because the stream was aborted
|
|
@@ -598,12 +732,15 @@ export async function sseLoop(opts: {
|
|
|
598
732
|
return true;
|
|
599
733
|
case "suppress-outbound":
|
|
600
734
|
process.stderr.write(`${logPrefix} outbound tool posted this turn on ${runtime.id}; suppressing final auto-post\n`);
|
|
735
|
+
rearmFailureNotice();
|
|
601
736
|
return false;
|
|
602
737
|
case "skip-empty":
|
|
603
738
|
process.stderr.write(`${logPrefix} turn on ${runtime.id} produced no text and used no outbound tool; nothing to deliver\n`);
|
|
739
|
+
rearmFailureNotice();
|
|
604
740
|
return false;
|
|
605
741
|
case "auto-post":
|
|
606
|
-
await postBridgeReply({ baseUrl: baseUrl(), runtime, text: reply, ownPostedIds, signal, logPrefix });
|
|
742
|
+
await postBridgeReply({ baseUrl: baseUrl(), runtime, text: reply, ownPostedIds, signal, logPrefix, fetchImpl, gate: deps.gate });
|
|
743
|
+
rearmFailureNotice();
|
|
607
744
|
return false;
|
|
608
745
|
}
|
|
609
746
|
}
|
|
@@ -652,6 +789,50 @@ export async function sseLoop(opts: {
|
|
|
652
789
|
.finally(() => ackInFlightIds.delete(idStr));
|
|
653
790
|
}
|
|
654
791
|
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* #606 — say a failed turn on the CHANNEL, best-effort.
|
|
795
|
+
*
|
|
796
|
+
* Deliberately unable to make things worse:
|
|
797
|
+
* - the transport may be the very thing that broke (the measured case was a
|
|
798
|
+
* 503 storm), so a failing notice is swallowed to stderr, NEVER retried and
|
|
799
|
+
* never rethrown — one failure must not become two;
|
|
800
|
+
* - it posts through postBridgeReply, so the notice id lands in ownPostedIds
|
|
801
|
+
* like any other reply and cannot feed itself back through handleInboundMessage;
|
|
802
|
+
* - the cause key is recorded only AFTER a post that actually succeeded, so a
|
|
803
|
+
* lost notice does not silence the next one.
|
|
804
|
+
*/
|
|
805
|
+
async function announceTurnFailure(err: unknown): Promise<void> {
|
|
806
|
+
const degradation = readTurnDegradation(err);
|
|
807
|
+
const key = failureCauseKey(err, degradation);
|
|
808
|
+
if (key === lastAnnouncedCause) {
|
|
809
|
+
process.stderr.write(`${logPrefix} failure notice suppressed (same cause as the previous one)\n`);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
try {
|
|
813
|
+
await postBridgeReply({
|
|
814
|
+
baseUrl: baseUrl(),
|
|
815
|
+
runtime,
|
|
816
|
+
text: failureNoticeText(err, degradation),
|
|
817
|
+
ownPostedIds,
|
|
818
|
+
signal,
|
|
819
|
+
logPrefix,
|
|
820
|
+
fetchImpl,
|
|
821
|
+
gate: deps.gate,
|
|
822
|
+
});
|
|
823
|
+
lastAnnouncedCause = key;
|
|
824
|
+
} catch (notifyErr) {
|
|
825
|
+
// Includes BridgeShutdownError from an expired token: swallowed here on
|
|
826
|
+
// purpose. CONSEQUENCE, named because it is not obvious (review F3): a
|
|
827
|
+
// token expiry first met BY THE NOTICE is discovered later, on the next
|
|
828
|
+
// turn, instead of shutting the bridge down now. Accepted — this path
|
|
829
|
+
// runs while ALREADY handling a failure; escalating a
|
|
830
|
+
// shutdown from the notice would turn "the agent could not answer" into
|
|
831
|
+
// "the agent died", which is a worse report than the one we came to give.
|
|
832
|
+
process.stderr.write(`${logPrefix} failure notice could not be posted: ${safeErrorMessage(notifyErr)}\n`);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
655
836
|
async function handleInboundMessage(msg: ConversationMessage): Promise<boolean> {
|
|
656
837
|
const idStr = msg.id !== undefined && msg.id !== null ? String(msg.id) : undefined;
|
|
657
838
|
// TRUTHY check (not `!== undefined`): idStr === "" must yield NaN, not
|
|
@@ -674,6 +855,15 @@ export async function sseLoop(opts: {
|
|
|
674
855
|
// AT-MOST-ONCE: mark handled BEFORE the reply so a runtime failure does not
|
|
675
856
|
// re-feed the same message on reconnect (no reply storm). A failed reply is
|
|
676
857
|
// dropped for this run — deliberately asymmetric with dm_stream's conduit.
|
|
858
|
+
// #627: while paused, the message is DEFERRED (never dropped): the turn starts
|
|
859
|
+
// once the runner covers this agent again. The wait comes BEFORE the message is
|
|
860
|
+
// marked handled: a stream stopped during the pause leaves it unhandled, and the
|
|
861
|
+
// persistent watermark un-advanced, so the re-join replays it (Codex, step-06).
|
|
862
|
+
if (deps.gate?.isPaused()) {
|
|
863
|
+
process.stderr.write(`${logPrefix} paused (${deps.gate.reason()}): message deferred\n`);
|
|
864
|
+
await deps.gate.waitActive(signal);
|
|
865
|
+
if (signal.aborted) return false;
|
|
866
|
+
}
|
|
677
867
|
if (idStr) handledIds.add(idStr);
|
|
678
868
|
process.stderr.write(`${logPrefix} message received: ${msg.content!.slice(0, 80)}\n`);
|
|
679
869
|
try {
|
|
@@ -688,6 +878,7 @@ export async function sseLoop(opts: {
|
|
|
688
878
|
} catch (err) {
|
|
689
879
|
if (err instanceof BridgeShutdownError) throw err;
|
|
690
880
|
process.stderr.write(`${logPrefix} failed to handle message: ${safeErrorMessage(err)}\n`);
|
|
881
|
+
await announceTurnFailure(err);
|
|
691
882
|
}
|
|
692
883
|
return options.once === true;
|
|
693
884
|
}
|
|
@@ -792,7 +983,8 @@ export async function sseLoop(opts: {
|
|
|
792
983
|
|
|
793
984
|
if (eventType === "stop") {
|
|
794
985
|
process.stderr.write(`${logPrefix} received stop signal, exiting\n`);
|
|
795
|
-
|
|
986
|
+
// #627: the same server stop as claude's `stop` event → the same cause.
|
|
987
|
+
throw new BridgeShutdownError("received stop signal", 0, "revoked");
|
|
796
988
|
}
|
|
797
989
|
|
|
798
990
|
if (eventType === "transcription" && data) {
|
|
@@ -810,6 +1002,12 @@ export async function sseLoop(opts: {
|
|
|
810
1002
|
} catch (err) {
|
|
811
1003
|
if (err instanceof BridgeShutdownError) throw err;
|
|
812
1004
|
process.stderr.write(`${logPrefix} failed to handle transcription: ${safeErrorMessage(err)}\n`);
|
|
1005
|
+
// #606: a VOICE turn that fails was the second silent swallow in this
|
|
1006
|
+
// same chain — found while building the tests, not by the grep on
|
|
1007
|
+
// "failed to handle message", which only ever proved the uniqueness of
|
|
1008
|
+
// the TEXT path. A user speaking to a dead runtime deserves the same
|
|
1009
|
+
// notice as one typing to it.
|
|
1010
|
+
await announceTurnFailure(err);
|
|
813
1011
|
}
|
|
814
1012
|
if (options.once) return;
|
|
815
1013
|
}
|
|
@@ -1174,6 +1372,8 @@ export interface BridgeHelpers {
|
|
|
1174
1372
|
parentSignal: AbortSignal;
|
|
1175
1373
|
/** Request a controlled bridge shutdown (e.g. codex's app-server died). */
|
|
1176
1374
|
requestShutdown: (err: BridgeShutdownError) => void;
|
|
1375
|
+
/** #627: why the agent is paused (null = may write) — for the tool host. */
|
|
1376
|
+
pauseReason: () => string | null;
|
|
1177
1377
|
}
|
|
1178
1378
|
|
|
1179
1379
|
/** A runtime's adapter for the shared await-invite bridge. */
|
|
@@ -1226,6 +1426,10 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
|
|
|
1226
1426
|
const streamTasks = new Set<Promise<void>>();
|
|
1227
1427
|
let voiceBaseUrl = "";
|
|
1228
1428
|
let instanceKey = "";
|
|
1429
|
+
// #627: the local presence record read by the machine's runner.
|
|
1430
|
+
let presence: PresenceRecordHandle | undefined;
|
|
1431
|
+
// #627: the pause gate, fed by the runner's local coverage (no network heartbeat).
|
|
1432
|
+
let gate: PresenceGate | undefined;
|
|
1229
1433
|
|
|
1230
1434
|
const startConversation = (minted: ConversationMintResponse): void => {
|
|
1231
1435
|
// A pushed join is the only announcement a bridge that reuses a persisted
|
|
@@ -1249,11 +1453,12 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
|
|
|
1249
1453
|
logPrefix,
|
|
1250
1454
|
sessionTag,
|
|
1251
1455
|
makeRunTurn,
|
|
1456
|
+
gate,
|
|
1252
1457
|
})
|
|
1253
1458
|
.then(() => {
|
|
1254
1459
|
// --once: one handled message completes the bridge cleanly.
|
|
1255
1460
|
if (options.once && !shutdown.signal.aborted) {
|
|
1256
|
-
shutdown.abort(new BridgeShutdownError("once: completed", 0));
|
|
1461
|
+
shutdown.abort(new BridgeShutdownError("once: completed", 0, "once-completed"));
|
|
1257
1462
|
}
|
|
1258
1463
|
})
|
|
1259
1464
|
.catch((err) => {
|
|
@@ -1271,6 +1476,21 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
|
|
|
1271
1476
|
});
|
|
1272
1477
|
bridgeLock = identity.bridgeLock;
|
|
1273
1478
|
instanceKey = identity.instanceKey;
|
|
1479
|
+
// Await-invite bridges may hold several conversations; the beat sends none,
|
|
1480
|
+
// so the record carries none either.
|
|
1481
|
+
presence = startPresenceRecord({ identityBaseUrl, instanceId: identity.instanceKey, label, projectId: auth.project_id, getConversationId: () => null, lockDir });
|
|
1482
|
+
// #627: no network heartbeat — the runner vouches for this bridge. An await-invite
|
|
1483
|
+
// bridge follows no single conversation lease, only its runner's freshness.
|
|
1484
|
+
const watch = startCoverageWatch({
|
|
1485
|
+
identityBaseUrl,
|
|
1486
|
+
instanceId: identity.instanceKey,
|
|
1487
|
+
getConversationId: () => null,
|
|
1488
|
+
onLeaseLost: () => shutdown.abort(new BridgeShutdownError("conversation lease lost (taken over)", 0, "lease-lost")),
|
|
1489
|
+
lockDir,
|
|
1490
|
+
log: (m) => process.stderr.write(`${logPrefix} ${m}`),
|
|
1491
|
+
});
|
|
1492
|
+
gate = watch.gate;
|
|
1493
|
+
shutdown.signal.addEventListener("abort", () => watch.stop(), { once: true });
|
|
1274
1494
|
// #480: adopt BEFORE the control stream and the heartbeat start — they are
|
|
1275
1495
|
// this bridge's two longest-lived flows and the whole point of the change.
|
|
1276
1496
|
api.offer(identity.apiBaseUrlAnnounced);
|
|
@@ -1282,6 +1502,7 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
|
|
|
1282
1502
|
requestShutdown: (err) => {
|
|
1283
1503
|
if (!shutdown.signal.aborted) shutdown.abort(err);
|
|
1284
1504
|
},
|
|
1505
|
+
pauseReason: () => gate?.reason() ?? null,
|
|
1285
1506
|
};
|
|
1286
1507
|
await onStart?.({ identity: { instanceKey: identity.instanceKey, instanceToken: identity.instanceToken }, helpers });
|
|
1287
1508
|
|
|
@@ -1298,25 +1519,21 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
|
|
|
1298
1519
|
shutdown.abort(
|
|
1299
1520
|
reason === "unauthorized"
|
|
1300
1521
|
? new BridgeShutdownError("instance token revoked or invalid", 3)
|
|
1301
|
-
: new BridgeShutdownError("instance revoked", 0),
|
|
1522
|
+
: new BridgeShutdownError("instance revoked", 0, "revoked"),
|
|
1302
1523
|
);
|
|
1303
1524
|
},
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
},
|
|
1525
|
+
// #627: an old server may still probe; this agent never beats on the network —
|
|
1526
|
+
// its runner answers for it (Python no longer probes a runner-covered agent).
|
|
1527
|
+
onBeatNow: () => {},
|
|
1307
1528
|
},
|
|
1308
1529
|
);
|
|
1309
|
-
const ihb = startInstanceHeartbeat({
|
|
1310
|
-
getBaseUrl: baseUrl,
|
|
1311
|
-
instanceId: identity.instanceKey,
|
|
1312
|
-
getInstanceToken: () => identity.instanceToken,
|
|
1313
|
-
});
|
|
1314
|
-
shutdown.signal.addEventListener("abort", () => ihb.stop(), { once: true });
|
|
1315
1530
|
controlStream.firstJoin.catch(() => {});
|
|
1316
1531
|
await controlStream.done;
|
|
1317
1532
|
const reason = shutdown.signal.reason;
|
|
1318
1533
|
if (reason instanceof BridgeShutdownError) throw reason;
|
|
1319
1534
|
} finally {
|
|
1535
|
+
// Read BEFORE the bare abort below replaces an unset reason.
|
|
1536
|
+
endBridgePresence(presence, shutdown.signal.aborted ? shutdown.signal.reason : undefined);
|
|
1320
1537
|
if (!shutdown.signal.aborted) shutdown.abort();
|
|
1321
1538
|
const pending = [...streamTasks];
|
|
1322
1539
|
for (const ctrl of activeStreams.values()) ctrl.abort(new BridgeShutdownError("bridge shutdown", 0));
|
|
@@ -1329,3 +1546,15 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
|
|
|
1329
1546
|
|
|
1330
1547
|
// Re-export control-stream error types so runtimes can map them if needed.
|
|
1331
1548
|
export { ControlStreamStopped, ControlStreamAuthError };
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* #627 — record how a bridge ended. A `BridgeShutdownError` is an end the bridge
|
|
1552
|
+
* went through → `ended` with its `endReason` (failures default to "shutdown").
|
|
1553
|
+
* Anything else — an unexpected throw — leaves the record active, like a crash.
|
|
1554
|
+
* The runner, not this function, decides what either means.
|
|
1555
|
+
*/
|
|
1556
|
+
export function endBridgePresence(presence: PresenceRecordHandle | undefined, reason: unknown): void {
|
|
1557
|
+
if (presence === undefined) return;
|
|
1558
|
+
if (reason instanceof BridgeShutdownError) presence.markEnded(reason.endReason);
|
|
1559
|
+
else presence.stop();
|
|
1560
|
+
}
|
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
|
}
|
package/lib/control_stream.ts
CHANGED
|
@@ -26,9 +26,10 @@ export interface ControlEvent {
|
|
|
26
26
|
data: string;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/** Build the instance control-stream SSE URL.
|
|
29
|
+
/** Build the instance control-stream SSE URL. `presence=runner` (#627): this agent's
|
|
30
|
+
* runner vouches for it, so its connect must not count as liveness on the server. */
|
|
30
31
|
export function buildControlStreamUrl(baseUrl: string, instanceId: string): string {
|
|
31
|
-
return `${baseUrl}/api/instances/${instanceId}/events`;
|
|
32
|
+
return `${baseUrl}/api/instances/${instanceId}/events?presence=runner`;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/**
|