viber-channel 0.8.31 → 0.8.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/bridge_spawn.ts +3 -0
- package/lib/capabilities.ts +18 -10
- package/lib/codex_model_pair.ts +166 -0
- package/lib/coverage_watch.ts +35 -3
- package/lib/gateway_result.ts +7 -6
- package/lib/presence_trigger.ts +112 -0
- package/lib/runner_exec.ts +30 -51
- package/lib/runner_registry.ts +1 -1
- package/lib/runner_roster.ts +37 -13
- package/lib/runner_stream.ts +28 -12
- package/lib/spawn_reason.ts +7 -86
- package/lib/team_template_tool.ts +2 -2
- package/package.json +1 -1
- package/viber-codex-bridge.ts +116 -5
package/lib/bridge_spawn.ts
CHANGED
|
@@ -75,6 +75,9 @@ export function createBridgeSpawn(opts: BridgeSpawnOptions = {}): SpawnFn {
|
|
|
75
75
|
// authoritatively below (codex-309-review blocking #1).
|
|
76
76
|
delete env.VIBER_AGENT_NAME_EXPLICIT;
|
|
77
77
|
delete env.VIBER_AGENT_ROLE;
|
|
78
|
+
// #665: a parent's codex model/effort must never pin a fresh agent; spec.env may set them.
|
|
79
|
+
delete env.VIBER_CODEX_MODEL;
|
|
80
|
+
delete env.VIBER_CODEX_EFFORT;
|
|
78
81
|
env.VIBER_AGENT_TIER = spec.tier;
|
|
79
82
|
if (spec.env) Object.assign(env, spec.env);
|
|
80
83
|
// Identity is set LAST, AFTER spec.env, so neither the inherited env nor a
|
package/lib/capabilities.ts
CHANGED
|
@@ -64,7 +64,7 @@ export function capabilitiesText(channelVersion: string | null = null): string {
|
|
|
64
64
|
" project token); the web's New team / Add agent use the same templates. No local",
|
|
65
65
|
" file, no built-in, no cache, no fallback: web unreachable, token refused or name",
|
|
66
66
|
" unknown = an error, and NOTHING is launched (an unknown name lists the available",
|
|
67
|
-
" ones).
|
|
67
|
+
" ones).",
|
|
68
68
|
" A template the spawn would refuse (its warnings, see below) is refused up front.",
|
|
69
69
|
" Output is verbose; only these lines matter:",
|
|
70
70
|
" team: verdict COMPLETE|INCOMPLET — N member(s)",
|
|
@@ -80,8 +80,10 @@ export function capabilitiesText(channelVersion: string | null = null): string {
|
|
|
80
80
|
" launch-<32 hex> (e.g. launch-03f927dc283f94f521b06ee573a9f582); from Viber app",
|
|
81
81
|
" 0.7.22 on, the bare 32 hex is accepted too (older: pass the launch- prefix);",
|
|
82
82
|
" read it on the \"member launch-…: launched\" line.",
|
|
83
|
-
" ONLINE DELAY:
|
|
84
|
-
"
|
|
83
|
+
" ONLINE DELAY: about 25-35 s from launch-team to online (one agent ~25 s,",
|
|
84
|
+
" three at once ~35 s), nearly all of it the runtime boot; the runner reports a",
|
|
85
|
+
" new agent within ~4 s of its presence record being written. An older Viber app waits for its",
|
|
86
|
+
" 30 s beat instead: up to ~60 s. An empty list_agents right",
|
|
85
87
|
" after launching is NOT a failure. Wait simply: call list_agents with",
|
|
86
88
|
" label_prefix \"<prefix>-\" and online: true every ~10 s, for at most ~60 s; past",
|
|
87
89
|
" that, check that the Viber app is running.",
|
|
@@ -94,8 +96,9 @@ export function capabilitiesText(channelVersion: string | null = null): string {
|
|
|
94
96
|
" → agent \"g1-dev\", team \"g1-solo\"",
|
|
95
97
|
" runtime: claude|codex|gemma · permission: read-only|read-write (gemma: read-only",
|
|
96
98
|
" only; all roles of one template must share one permission) · count optional;",
|
|
97
|
-
" model/effort: leave them OUT (runtime default) UNLESS the owner
|
|
98
|
-
"
|
|
99
|
+
" model/effort: on claude leave them OUT (runtime default) UNLESS the owner asks:",
|
|
100
|
+
" model opus|sonnet|haiku|fable, effort low|medium|high|xhigh|max. On codex BOTH are",
|
|
101
|
+
" REQUIRED: gpt-5.6-sol / low unless the owner asks for another listed pair.",
|
|
99
102
|
" (On the web, sidebar ⋯ → Add agent launches ONE named agent without a template edit.)",
|
|
100
103
|
"",
|
|
101
104
|
"DISSOLVE (stops the processes, then revokes the proven-stopped instances):",
|
|
@@ -143,11 +146,16 @@ export function capabilitiesText(channelVersion: string | null = null): string {
|
|
|
143
146
|
" runtime REQUIRED claude | codex | gemma",
|
|
144
147
|
" permission REQUIRED read-only | read-write (gemma: read-only only)",
|
|
145
148
|
" rolePrompt REQUIRED non-empty, ≤ 600 chars AFTER {prefix} expands to 32 chars",
|
|
146
|
-
" model
|
|
147
|
-
" version);
|
|
148
|
-
"
|
|
149
|
-
"
|
|
150
|
-
"
|
|
149
|
+
" model claude: optional, opus | sonnet | haiku | fable (a family, never a",
|
|
150
|
+
" version); codex: REQUIRED, one of gpt-6.1-sol gpt-6-astra",
|
|
151
|
+
" gpt-6-sol gpt-6-luna gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna",
|
|
152
|
+
" gpt-5.5; gemma: ≤ 64, stored but not applied",
|
|
153
|
+
" effort claude: optional, low | medium | high | xhigh | max;",
|
|
154
|
+
" codex: REQUIRED, one the model accepts — low..xhigh on all,",
|
|
155
|
+
" max except gpt-5.5, ultra except the luna models and gpt-5.5;",
|
|
156
|
+
" gemma: refused",
|
|
157
|
+
" claude: leave model/effort OUT (runtime default) unless the",
|
|
158
|
+
" owner asks; codex: gpt-5.6-sol / low unless the owner asks",
|
|
151
159
|
" count optional integer 1..10",
|
|
152
160
|
" name: non-empty, ≤ 80, unique per owner, case-insensitive (409 without code = taken).",
|
|
153
161
|
" update: send ONLY the fields you change (name / spec / project_id) plus",
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #665 — the model and reasoning effort a codex agent runs with.
|
|
3
|
+
*
|
|
4
|
+
* vibe-master hands them to the bridge as `VIBER_CODEX_MODEL` / `VIBER_CODEX_EFFORT`: BOTH or
|
|
5
|
+
* NEITHER. Neither is a role stored before #665 — the bridge then behaves exactly as before (no
|
|
6
|
+
* catalogue call, no field on `thread/start` / `turn/start`). A pair is:
|
|
7
|
+
* - checked ONCE at startup against the catalogue of THIS codex (`model/list`), before any
|
|
8
|
+
* thread or turn exists — the target is the pair SET by the template, never the model a
|
|
9
|
+
* resumed thread reports (that one is the OLD model);
|
|
10
|
+
* - applied on `thread/start` (a fresh thread) and on EVERY `turn/start`, which also moves a
|
|
11
|
+
* resumed thread onto the template's model.
|
|
12
|
+
* Anything it cannot honour stops the bridge with a message naming the pair: no silent fallback.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface CodexPair {
|
|
16
|
+
model: string;
|
|
17
|
+
effort: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A page of `model/list`, as codex-cli 0.159.3 answers it (only the fields read here). */
|
|
21
|
+
export interface ModelListPage {
|
|
22
|
+
data?: { id?: unknown; model?: unknown; supportedReasoningEfforts?: unknown }[];
|
|
23
|
+
nextCursor?: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ModelListFetch = (cursor: string | null) => Promise<ModelListPage>;
|
|
27
|
+
|
|
28
|
+
/** Read the pair from the env: both, or neither (`""` is unset, the #411 convention). */
|
|
29
|
+
export function readCodexPair(env: NodeJS.ProcessEnv): CodexPair | undefined {
|
|
30
|
+
const model = env.VIBER_CODEX_MODEL?.trim() ?? "";
|
|
31
|
+
const effort = env.VIBER_CODEX_EFFORT?.trim() ?? "";
|
|
32
|
+
if (model === "" && effort === "") return undefined;
|
|
33
|
+
if (model === "" || effort === "") {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`codex model/effort must come together: got VIBER_CODEX_MODEL="${model}", VIBER_CODEX_EFFORT="${effort}" — ` +
|
|
36
|
+
"relaunch from a template that sets both",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return { model, effort };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The `thread/start` fields for a pair (codex has no direct effort field there: it is config). */
|
|
43
|
+
export function threadStartPairParams(pair: CodexPair | undefined): Record<string, unknown> {
|
|
44
|
+
if (pair === undefined) return {};
|
|
45
|
+
return { model: pair.model, config: { model_reasoning_effort: pair.effort } };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The `turn/start` fields for a pair — they persist for later turns, resumed threads included. */
|
|
49
|
+
export function turnStartPairParams(pair: CodexPair | undefined): Record<string, unknown> {
|
|
50
|
+
if (pair === undefined) return {};
|
|
51
|
+
return { model: pair.model, effort: pair.effort };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The efforts a catalogue entry accepts (`[{ reasoningEffort }]`, or bare strings). */
|
|
55
|
+
function effortsOf(entry: { supportedReasoningEfforts?: unknown }): string[] {
|
|
56
|
+
const raw = entry.supportedReasoningEfforts;
|
|
57
|
+
if (!Array.isArray(raw)) throw new Error("model/list entry without supportedReasoningEfforts");
|
|
58
|
+
return raw.map((e) => {
|
|
59
|
+
const v = typeof e === "string" ? e : (e as { reasoningEffort?: unknown } | null)?.reasoningEffort;
|
|
60
|
+
if (typeof v !== "string") throw new Error("model/list entry with a malformed reasoning effort");
|
|
61
|
+
return v;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Walk every page of `model/list`; a repeated cursor is a loop, not a catalogue. */
|
|
66
|
+
async function collectCatalogue(fetchPage: ModelListFetch, expired: () => boolean): Promise<Map<string, string[]>> {
|
|
67
|
+
const catalogue = new Map<string, string[]>();
|
|
68
|
+
const seen = new Set<string>();
|
|
69
|
+
let cursor: string | null = null;
|
|
70
|
+
do {
|
|
71
|
+
// Past the deadline the caller has already refused: stop asking, do not walk on in the dark.
|
|
72
|
+
if (expired()) throw new Error("model/list walk stopped at the deadline");
|
|
73
|
+
const page = await fetchPage(cursor);
|
|
74
|
+
if (!Array.isArray(page.data)) throw new Error("model/list answered without a data array");
|
|
75
|
+
for (const entry of page.data) {
|
|
76
|
+
// `model` is the slug `thread/start` / `turn/start` take, guaranteed by 0.159.3. `id` equals
|
|
77
|
+
// it today but is not read: an entry without `model` is malformed, not resolved by guess.
|
|
78
|
+
const id = entry.model;
|
|
79
|
+
if (typeof id !== "string") throw new Error("model/list entry without a model");
|
|
80
|
+
catalogue.set(id, effortsOf(entry));
|
|
81
|
+
}
|
|
82
|
+
const next = page.nextCursor;
|
|
83
|
+
if (next !== null && next !== undefined && typeof next !== "string") {
|
|
84
|
+
throw new Error("model/list answered a non-string nextCursor");
|
|
85
|
+
}
|
|
86
|
+
cursor = next ?? null;
|
|
87
|
+
if (cursor !== null) {
|
|
88
|
+
if (seen.has(cursor)) throw new Error(`model/list repeated the cursor "${cursor}"`);
|
|
89
|
+
seen.add(cursor);
|
|
90
|
+
}
|
|
91
|
+
} while (cursor !== null);
|
|
92
|
+
return catalogue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Throw unless THIS codex's catalogue carries the pair. One deadline covers the whole pagination;
|
|
97
|
+
* an RPC failure, a malformed answer or the deadline all throw — the caller stops the bridge.
|
|
98
|
+
*/
|
|
99
|
+
export async function verifyCodexPair(
|
|
100
|
+
pair: CodexPair,
|
|
101
|
+
fetchPage: ModelListFetch,
|
|
102
|
+
timeoutMs: number,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
105
|
+
let expired = false;
|
|
106
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
107
|
+
timer = setTimeout(() => {
|
|
108
|
+
expired = true;
|
|
109
|
+
reject(new Error(`model/list did not answer within ${timeoutMs} ms`));
|
|
110
|
+
}, timeoutMs);
|
|
111
|
+
});
|
|
112
|
+
const walk = collectCatalogue(fetchPage, () => expired);
|
|
113
|
+
walk.catch(() => {}); // after the deadline its own rejection has nobody left to tell
|
|
114
|
+
let catalogue: Map<string, string[]>;
|
|
115
|
+
try {
|
|
116
|
+
catalogue = await Promise.race([walk, deadline]);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
119
|
+
throw new Error(`cannot check codex model ${pair.model} / effort ${pair.effort}: ${why}`);
|
|
120
|
+
} finally {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
}
|
|
123
|
+
const efforts = catalogue.get(pair.model);
|
|
124
|
+
if (efforts === undefined) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`codex model ${pair.model} is absent from the catalogue of THIS codex (has: ${[...catalogue.keys()].join(", ")}) — ` +
|
|
127
|
+
"update codex or re-edit the template",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (!efforts.includes(pair.effort)) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`codex model ${pair.model} does not accept effort ${pair.effort} on THIS codex (accepts: ${efforts.join(", ")}) — ` +
|
|
133
|
+
"re-edit the template",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Why codex is NOT running the pair, judged from what `thread/start` / `thread/resume` answer
|
|
140
|
+
* (`model`, `reasoningEffort` — the thread's EFFECTIVE settings), or undefined when it is. A null
|
|
141
|
+
* effort is "not reported", not a mismatch. No pair: nothing to compare (pre-#665 behaviour).
|
|
142
|
+
*/
|
|
143
|
+
export function pairMismatch(
|
|
144
|
+
pair: CodexPair | undefined,
|
|
145
|
+
response: unknown,
|
|
146
|
+
method: string,
|
|
147
|
+
): string | undefined {
|
|
148
|
+
if (pair === undefined) return undefined;
|
|
149
|
+
const r = (typeof response === "object" && response !== null ? response : {}) as {
|
|
150
|
+
model?: unknown;
|
|
151
|
+
reasoningEffort?: unknown;
|
|
152
|
+
};
|
|
153
|
+
if (r.model !== pair.model) {
|
|
154
|
+
return `codex ${method} runs model ${String(r.model)}, not the template's ${pair.model} — stopping rather than run the wrong model`;
|
|
155
|
+
}
|
|
156
|
+
// null = "not reported" (the field is nullable in 0.159.3); absent or any other type is a
|
|
157
|
+
// malformed answer, refused rather than read as a pass (Codex-2 review of cecd6ff7).
|
|
158
|
+
if (r.reasoningEffort === null) return undefined;
|
|
159
|
+
if (typeof r.reasoningEffort !== "string") {
|
|
160
|
+
return `codex ${method} answered a malformed reasoningEffort (${JSON.stringify(r.reasoningEffort) ?? "absent"}) — stopping rather than guess the effort`;
|
|
161
|
+
}
|
|
162
|
+
if (r.reasoningEffort !== pair.effort) {
|
|
163
|
+
return `codex ${method} runs effort ${r.reasoningEffort}, not the template's ${pair.effort} — stopping rather than run the wrong effort`;
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
package/lib/coverage_watch.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* <presenceDir>/<instance_id>.coverage.json
|
|
9
9
|
* { lease_active: true|false|null, beat_id, conversation_id, written_at }
|
|
10
10
|
*
|
|
11
|
-
* Every 15 s this module re-reads it (a local read, zero network) and
|
|
11
|
+
* Every 15 s this module re-reads it (a local read, zero network) — and at once when the file's
|
|
12
|
+
* mtime changes (#663, checked every second, also local) — and decides:
|
|
12
13
|
*
|
|
13
14
|
* - fresh coverage, lease true → ACTIVE;
|
|
14
15
|
* - lease false → the conversation was taken over: the
|
|
@@ -31,7 +32,7 @@
|
|
|
31
32
|
* Freshness is judged on the LOCAL clock of the coverage write (`written_at`),
|
|
32
33
|
* never on the server's beat id.
|
|
33
34
|
*/
|
|
34
|
-
import { readFileSync } from "node:fs";
|
|
35
|
+
import { readFileSync, statSync } from "node:fs";
|
|
35
36
|
import { DEFAULT_MAX_INDETERMINATE_LEASE_BEATS } from "./heartbeat.js";
|
|
36
37
|
import { runnerStatePath, readRunnerState } from "./runner_registry.js";
|
|
37
38
|
import { coveragePath } from "./runner_roster.js";
|
|
@@ -149,6 +150,26 @@ export interface CoverageWatchOptions {
|
|
|
149
150
|
log?: (line: string) => void;
|
|
150
151
|
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
151
152
|
clearTimer?: (handle: unknown) => void;
|
|
153
|
+
/** #663: how often the coverage file's mtime is checked (a local stat, no network). */
|
|
154
|
+
changeCheckMs?: number;
|
|
155
|
+
/** #663: the coverage file's mtime in ms, or `null` when absent/unreadable (tests inject it). */
|
|
156
|
+
coverageMtime?: (path: string) => number | null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* #663 — the agent re-read its coverage every 15 s, so after the runner's beat it could stay
|
|
161
|
+
* PAUSED (online on the web, yet unable to send or receive) for up to 15 s more. A cheap local
|
|
162
|
+
* check of the file's mtime now re-reads it as soon as the runner rewrites it. The 15 s re-read
|
|
163
|
+
* stays: it is what pauses an agent whose coverage went STALE, which no write announces.
|
|
164
|
+
*/
|
|
165
|
+
export const COVERAGE_CHANGE_CHECK_MS = 1_000;
|
|
166
|
+
|
|
167
|
+
function fileMtime(path: string): number | null {
|
|
168
|
+
try {
|
|
169
|
+
return statSync(path).mtimeMs;
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
152
173
|
}
|
|
153
174
|
|
|
154
175
|
export interface CoverageWatch {
|
|
@@ -236,9 +257,20 @@ export function startCoverageWatch(opts: CoverageWatchOptions): CoverageWatch {
|
|
|
236
257
|
|
|
237
258
|
tick();
|
|
238
259
|
const timer = setTimer(tick, opts.rereadMs ?? COVERAGE_REREAD_MS);
|
|
260
|
+
const mtimeOf = opts.coverageMtime ?? fileMtime;
|
|
261
|
+
let lastMtime = mtimeOf(covPath);
|
|
262
|
+
const changeTimer = setTimer(() => {
|
|
263
|
+
const m = mtimeOf(covPath);
|
|
264
|
+
if (m === lastMtime) return;
|
|
265
|
+
lastMtime = m;
|
|
266
|
+
if (m !== null) tick();
|
|
267
|
+
}, opts.changeCheckMs ?? COVERAGE_CHANGE_CHECK_MS);
|
|
239
268
|
return {
|
|
240
269
|
gate,
|
|
241
270
|
tick,
|
|
242
|
-
stop: () =>
|
|
271
|
+
stop: () => {
|
|
272
|
+
clearTimer(timer);
|
|
273
|
+
clearTimer(changeTimer);
|
|
274
|
+
},
|
|
243
275
|
};
|
|
244
276
|
}
|
package/lib/gateway_result.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* ## ⚠ WHY THIS IS A SEPARATE CONTRACT AND NOT AN EXTENSION OF `spawn_reason.ts`
|
|
5
5
|
*
|
|
6
6
|
* JP's decision of 2026-08-27: the gateway's result line is a marker of its OWN. ▶ Widening
|
|
7
|
-
* `parseSpawnResult`
|
|
7
|
+
* `parseSpawnResult` (the `vibe-master team spawn` reader, removed in #662 once #660 had removed
|
|
8
|
+
* its emitter) to also accept a nonce-less or differently-shaped payload would have reopened
|
|
8
9
|
* "the last valid marker wins" for `vibe-master`'s marker too — one relaxation, two contracts
|
|
9
10
|
* weakened. ⚠ So the runner learns a SECOND reader rather than loosening the first.
|
|
10
11
|
*
|
|
@@ -19,8 +20,8 @@
|
|
|
19
20
|
* ```
|
|
20
21
|
*
|
|
21
22
|
* ▶▶ So a HALF-LAUNCHED team exits `0`, and a consumer deriving success from the exit code reports
|
|
22
|
-
* `succeeded` — the browser then
|
|
23
|
-
* reason-only marker does not close that: it would be ABSENT on a `0`-exit INCOMPLET team, and an
|
|
23
|
+
* `succeeded` — the browser then said "team spawn completed" (now "team launch completed")
|
|
24
|
+
* while members were missing. ⚠ A reason-only marker does not close that: it would be ABSENT on a `0`-exit INCOMPLET team, and an
|
|
24
25
|
* absence is indistinguishable from a success.
|
|
25
26
|
*
|
|
26
27
|
* ⚠ The gateway's exit codes are NOT the defect and are not touched: `0` there means *a result was
|
|
@@ -28,7 +29,7 @@
|
|
|
28
29
|
*
|
|
29
30
|
* ## ▶ THE FALLBACK IS A FAILURE, NEVER A SUCCESS
|
|
30
31
|
*
|
|
31
|
-
* `parseSpawnResult`
|
|
32
|
+
* `parseSpawnResult` returned `null` for "no marker" and its caller *degraded to the previous
|
|
32
33
|
* message* — correct there, because that path's success was already established by the exit code.
|
|
33
34
|
* ⚠ **Here the exit code cannot establish success**, so a `null` must not be readable as one. Rules
|
|
34
35
|
* entry 10: a fallback is only legitimate when its value is IMPOSSIBLE to confuse with a measurement.
|
|
@@ -36,8 +37,8 @@
|
|
|
36
37
|
|
|
37
38
|
import { SPAWN_REASONS, type SpawnReason } from "./spawn_reason.js";
|
|
38
39
|
|
|
39
|
-
/** Mirrors `../../viber-gateway/lib/result_marker.ts`. ⚠ Named separately from
|
|
40
|
-
*
|
|
40
|
+
/** Mirrors `../../viber-gateway/lib/result_marker.ts`. ⚠ Named separately from the former
|
|
41
|
+
* `VIBEMASTER_RESULT` so that no reader could be satisfied by the other's line. */
|
|
41
42
|
const GATEWAY_MARKER = "VIBERGATEWAY_RESULT";
|
|
42
43
|
|
|
43
44
|
/** Bounded at both ends of the pipe, like `vibe-master`'s detail. */
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* presence_trigger.ts — #663: the runner beats when an agent APPEARS, not only on its 30 s tick.
|
|
3
|
+
*
|
|
4
|
+
* Measured (plan 663, step-01): an agent's presence record is on disk ~21 s after launch, and the
|
|
5
|
+
* runner then waited for the next tick of its 30 s beat — 3 to 26 s of pure waiting on 8 launches.
|
|
6
|
+
*
|
|
7
|
+
* JP's constraint (2026-10-01): **no more network than today when nothing changes.** So this module
|
|
8
|
+
* is LOCAL only: it re-lists the presence folder every `intervalMs` (a directory read and one stat
|
|
9
|
+
* per record — no request), and calls `onChange` when a record is NEW or REWRITTEN (an agent writes
|
|
10
|
+
* its record at registration, then again when its start time becomes known — the runner skips a
|
|
11
|
+
* record without one, so that rewrite must trigger too). The caller turns `onChange` into ONE extra
|
|
12
|
+
* beat, the same request as the periodic one.
|
|
13
|
+
*
|
|
14
|
+
* - **Deletions do not trigger.** The beat itself deletes the records the server acknowledged as
|
|
15
|
+
* ended; reacting to that would make every such beat schedule another one.
|
|
16
|
+
* - **Coverage files and the `runner/` folder are ignored**: the beat writes them after each
|
|
17
|
+
* acknowledgement, so they must not count as a change either.
|
|
18
|
+
* - **Debounced**: a team of three writes three records within a second — one beat, not three.
|
|
19
|
+
* - The first scan is the BASELINE: records already there when the runner starts are covered by the
|
|
20
|
+
* runner's own start-up beat.
|
|
21
|
+
*
|
|
22
|
+
* ⚠ Why polling and not `fs.watch` (Opus, plan review): on Windows `fs.watch` duplicates events and
|
|
23
|
+
* reports an atomic write (tmp + rename) as a `rename`; a 2 s directory read is cheap and exact.
|
|
24
|
+
*/
|
|
25
|
+
import { readdirSync, statSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
|
|
28
|
+
export const PRESENCE_SCAN_MS = 2_000;
|
|
29
|
+
export const PRESENCE_DEBOUNCE_MS = 500;
|
|
30
|
+
|
|
31
|
+
export interface PresenceTriggerOptions {
|
|
32
|
+
dir: string;
|
|
33
|
+
onChange: () => void;
|
|
34
|
+
intervalMs?: number;
|
|
35
|
+
debounceMs?: number;
|
|
36
|
+
/** Record file name → mtime (ms). `null` = folder unreadable or absent (treated as empty). */
|
|
37
|
+
list?: (dir: string) => Map<string, number> | null;
|
|
38
|
+
setInterval?: (fn: () => void, ms: number) => unknown;
|
|
39
|
+
clearInterval?: (handle: unknown) => void;
|
|
40
|
+
setTimeout?: (fn: () => void, ms: number) => unknown;
|
|
41
|
+
clearTimeout?: (handle: unknown) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PresenceTrigger {
|
|
45
|
+
/** One scan now (tests, and nothing else needs it). */
|
|
46
|
+
scan: () => void;
|
|
47
|
+
stop: () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The agent records of a presence folder: `<instance_id>.json`, never `*.coverage.json`. */
|
|
51
|
+
export function listPresenceRecords(dir: string): Map<string, number> | null {
|
|
52
|
+
let names: string[];
|
|
53
|
+
try {
|
|
54
|
+
names = readdirSync(dir);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const out = new Map<string, number>();
|
|
59
|
+
for (const name of names) {
|
|
60
|
+
if (!name.endsWith(".json") || name.endsWith(".coverage.json")) continue;
|
|
61
|
+
try {
|
|
62
|
+
out.set(name, statSync(join(dir, name)).mtimeMs);
|
|
63
|
+
} catch {
|
|
64
|
+
// Deleted between the listing and the stat: a deletion, which never triggers.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function startPresenceTrigger(opts: PresenceTriggerOptions): PresenceTrigger {
|
|
71
|
+
const list = opts.list ?? listPresenceRecords;
|
|
72
|
+
const setIv = opts.setInterval ?? ((fn: () => void, ms: number) => unrefd(setInterval(fn, ms)));
|
|
73
|
+
const clearIv = opts.clearInterval ?? ((h: unknown) => clearInterval(h as ReturnType<typeof setInterval>));
|
|
74
|
+
const setTo = opts.setTimeout ?? ((fn: () => void, ms: number) => unrefd(setTimeout(fn, ms)));
|
|
75
|
+
const clearTo = opts.clearTimeout ?? ((h: unknown) => clearTimeout(h as ReturnType<typeof setTimeout>));
|
|
76
|
+
const debounceMs = opts.debounceMs ?? PRESENCE_DEBOUNCE_MS;
|
|
77
|
+
|
|
78
|
+
let known: Map<string, number> = list(opts.dir) ?? new Map();
|
|
79
|
+
let pending: unknown = null;
|
|
80
|
+
let stopped = false;
|
|
81
|
+
|
|
82
|
+
const scan = (): void => {
|
|
83
|
+
if (stopped) return;
|
|
84
|
+
const now = list(opts.dir) ?? new Map<string, number>();
|
|
85
|
+
let changed = false;
|
|
86
|
+
for (const [name, mtime] of now) {
|
|
87
|
+
if (known.get(name) !== mtime) changed = true;
|
|
88
|
+
}
|
|
89
|
+
known = now;
|
|
90
|
+
if (!changed || pending !== null) return;
|
|
91
|
+
pending = setTo(() => {
|
|
92
|
+
pending = null;
|
|
93
|
+
if (!stopped) opts.onChange();
|
|
94
|
+
}, debounceMs);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const timer = setIv(scan, opts.intervalMs ?? PRESENCE_SCAN_MS);
|
|
98
|
+
return {
|
|
99
|
+
scan,
|
|
100
|
+
stop: () => {
|
|
101
|
+
stopped = true;
|
|
102
|
+
clearIv(timer);
|
|
103
|
+
if (pending !== null) clearTo(pending);
|
|
104
|
+
pending = null;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function unrefd<T>(h: T): T {
|
|
110
|
+
(h as { unref?: () => void }).unref?.();
|
|
111
|
+
return h;
|
|
112
|
+
}
|
package/lib/runner_exec.ts
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* Flow (per claimed command): validate the SERVER-VALIDATED role snapshot
|
|
6
6
|
* against the machine's local DEFAULT-DENY policy (permissions = template ∩
|
|
7
7
|
* local, never widened — C3) → report `starting` → shell
|
|
8
|
-
* `
|
|
9
|
-
* no injection)
|
|
10
|
-
* (re-claim/cancel) →
|
|
8
|
+
* `viber-gateway team` via an argv ARRAY (execFile, never a shell string —
|
|
9
|
+
* no injection), which invokes `vibe-master spawn` once per member → report
|
|
10
|
+
* `succeeded`/`failed` (fencing-guarded). A 409 report (re-claim/cancel) →
|
|
11
|
+
* cooperative abort (don't overwrite a newer outcome — C4).
|
|
11
12
|
*
|
|
12
13
|
* Dependency-injected: `runTeamSpawn` (the launcher) and `apiFetch` are
|
|
13
14
|
* injectable so the flow is unit-testable without shelling or a live server.
|
|
@@ -19,7 +20,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
|
|
|
19
20
|
import { join } from "node:path";
|
|
20
21
|
import { cfAccessHeaders } from "./cfAccess.js";
|
|
21
22
|
import { gatewayNothingToDo, gatewaySpawnOk, parseGatewayResult } from "./gateway_result.js";
|
|
22
|
-
import { messageForReason,
|
|
23
|
+
import { messageForReason, rosterNames, type SpawnReason } from "./spawn_reason.js";
|
|
23
24
|
import { identityBaseUrl } from "./base_urls.js";
|
|
24
25
|
import { recordSpawn, registryPath, type SpawnForRegistry, updateRegistry } from "./runner_registry.js";
|
|
25
26
|
import { relaunchInstance } from "./runner_relaunch.js";
|
|
@@ -246,12 +247,10 @@ export type TeamSpawnRunner = (
|
|
|
246
247
|
* ⚠⚠ #590: **the LAUNCHER states whether the desired state was already in place** — the reader
|
|
247
248
|
* no longer infers it from a bare `reason` string.
|
|
248
249
|
*
|
|
249
|
-
* ▶ The
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
* `classifyCollision`, which emitted this word only when NO roster member was missing
|
|
254
|
-
* (removed in #660 with `vibe-master team spawn`; no vibe-master command emits it now).
|
|
250
|
+
* ▶ The value that crossed this boundary used to carry neither the provenance nor the proof
|
|
251
|
+
* (`gwl10-review-codex-2`). The launcher now proves its own claim: the gateway through
|
|
252
|
+
* {@link gatewayNothingToDo} (roster coverage **and** verdict). It is the only launcher that
|
|
253
|
+
* makes one since #660 removed `vibe-master team spawn`.
|
|
255
254
|
*
|
|
256
255
|
* ⚠ **OPTIONAL, and the reader FAILS CLOSED on absence** (`?? false`): an omitted field is *no
|
|
257
256
|
* claim*, never *nothing to do*. ▶ This is deliberately unlike the `nonce` above, which is
|
|
@@ -311,10 +310,12 @@ export function vibeMasterBaseCmd(source: NodeJS.ProcessEnv = process.env): stri
|
|
|
311
310
|
return parts.length > 0 ? parts : ["vibe-master"];
|
|
312
311
|
}
|
|
313
312
|
|
|
314
|
-
/**
|
|
315
|
-
* shell string), a CURATED env, bounded time + output,
|
|
316
|
-
* timeout. The binary is resolved via VIBE_MASTER_CMD
|
|
317
|
-
|
|
313
|
+
/** The single-agent launcher (relaunch path): shell `vibe-master spawn` via execFile
|
|
314
|
+
* with an ARGV ARRAY (never a shell string), a CURATED env, bounded time + output,
|
|
315
|
+
* and process-tree kill on timeout. The binary is resolved via VIBE_MASTER_CMD
|
|
316
|
+
* (default `vibe-master`). `spawn` writes no outcome marker, so the exit code is
|
|
317
|
+
* the whole verdict — the gateway path, which has one, is {@link execFileGatewayTeamSpawn}. */
|
|
318
|
+
export const execFileVibeMasterSpawn = (args: string[]): Promise<{ ok: boolean; recap: string }> => {
|
|
318
319
|
const base = vibeMasterBaseCmd();
|
|
319
320
|
const bin = base[0] as string;
|
|
320
321
|
return new Promise((resolve) => {
|
|
@@ -323,28 +324,7 @@ export const execFileTeamSpawn: TeamSpawnRunner = (args, nonce) => {
|
|
|
323
324
|
[...base.slice(1), ...args],
|
|
324
325
|
{ timeout: 5 * 60 * 1000, killSignal: "SIGKILL", maxBuffer: 1024 * 1024, env: buildChildEnv() },
|
|
325
326
|
(err, stdout, stderr) => {
|
|
326
|
-
|
|
327
|
-
// before the 4 000-char cut. The marker is written last, so it is the
|
|
328
|
-
// first thing `.slice()` drops on a real (verbose) roster — parsing the
|
|
329
|
-
// recap would pass every test and fail every actual spawn. Redaction can
|
|
330
|
-
// also mangle the JSON. Both hazards disappear by reading first.
|
|
331
|
-
const parsed = parseSpawnResult(stderr ?? "", { nonce });
|
|
332
|
-
const recap = redactSecrets(`${stdout}\n${stderr}`.trim()).slice(0, 4000);
|
|
333
|
-
resolve({
|
|
334
|
-
ok: !err,
|
|
335
|
-
// ▶ #590: vibe-master's OWN guarantee, stated by the launcher that holds it.
|
|
336
|
-
// ⚠ MEASURED before #660 (`vibe-master/lib/teams.ts`, `classifyCollision`, removed with
|
|
337
|
-
// `team spawn`; `spawn` never emits a marker, so this now only reads an absent one): this word was emitted only
|
|
338
|
-
// when `missing.length === 0` — every roster name live — and a non-colliding call returns
|
|
339
|
-
// `null` before it, so an empty roster cannot produce it either. A partial team is
|
|
340
|
-
// `partial_team` and stays a failure. ▶ **The same roster-coverage property as the
|
|
341
|
-
// gateway's, by different code** — so promoting only the gateway path would turn a
|
|
342
|
-
// CORRECT de-duplication here back into a red, which is the defect JP arbitrated away.
|
|
343
|
-
nothingToDo: parsed?.reason === "already_running",
|
|
344
|
-
recap,
|
|
345
|
-
...(parsed?.reason ? { reason: parsed.reason } : {}),
|
|
346
|
-
...(parsed?.detail ? { detail: parsed.detail } : {}),
|
|
347
|
-
});
|
|
327
|
+
resolve({ ok: !err, recap: redactSecrets(`${stdout}\n${stderr}`.trim()).slice(0, 4000) });
|
|
348
328
|
},
|
|
349
329
|
);
|
|
350
330
|
});
|
|
@@ -524,7 +504,7 @@ export function buildGatewayTeamArgs(cmd: {
|
|
|
524
504
|
/**
|
|
525
505
|
* The gateway launcher. ⚠⚠ **`ok` is derived from the MARKER, never from the exit
|
|
526
506
|
* code**, and that is the whole reason this function exists beside
|
|
527
|
-
* {@link
|
|
507
|
+
* {@link execFileVibeMasterSpawn}.
|
|
528
508
|
*
|
|
529
509
|
* ```
|
|
530
510
|
* viber-gateway/lib/team_spawn_command.ts:358
|
|
@@ -532,7 +512,7 @@ export function buildGatewayTeamArgs(cmd: {
|
|
|
532
512
|
* ```
|
|
533
513
|
*
|
|
534
514
|
* ▶▶ So `ok = !err` would report `succeeded` on a HALF-LAUNCHED team, and the
|
|
535
|
-
* browser would say *team
|
|
515
|
+
* browser would say *team launch completed* while members are missing. ⚠ The
|
|
536
516
|
* gateway's exit codes are right — `0` means *a result was produced* — and are not
|
|
537
517
|
* touched; what changes is that this consumer stops reading that as *the team is
|
|
538
518
|
* there*.
|
|
@@ -758,7 +738,7 @@ async function report(
|
|
|
758
738
|
|
|
759
739
|
/**
|
|
760
740
|
* Execute one already-claimed command. Validates against local policy, reports
|
|
761
|
-
* starting → runs the team
|
|
741
|
+
* starting → runs the team launch → reports the outcome (fencing-guarded). A 409
|
|
762
742
|
* on the `starting` report means the claim is no longer ours (re-claim/cancel)
|
|
763
743
|
* → cooperative abort BEFORE spawning (never launch on a lost claim). Returns
|
|
764
744
|
* the terminal status the runner recorded (or "aborted").
|
|
@@ -768,11 +748,9 @@ export async function executeClaim(
|
|
|
768
748
|
cmd: ClaimedCommand,
|
|
769
749
|
deps: ExecDeps = {},
|
|
770
750
|
): Promise<"succeeded" | "failed" | "aborted"> {
|
|
771
|
-
// ⚠⚠ THE DEFAULT IS THE GATEWAY
|
|
772
|
-
// `
|
|
773
|
-
//
|
|
774
|
-
// ▶ `execFileTeamSpawn` stays EXPORTED: it is still the launcher for anything that
|
|
775
|
-
// talks to `vibe-master` directly, and its witnesses keep measuring that contract.
|
|
751
|
+
// ⚠⚠ THE DEFAULT IS THE GATEWAY — that swap IS the junction (#590). The only
|
|
752
|
+
// direct `vibe-master` launcher left is the single-agent relaunch
|
|
753
|
+
// ({@link execFileVibeMasterSpawn}).
|
|
776
754
|
const runTeamSpawn = deps.runTeamSpawn ?? execFileGatewayTeamSpawn;
|
|
777
755
|
const fetchImpl = deps.apiFetch ?? fetch;
|
|
778
756
|
if (cmd.kind === "relaunch") return executeRelaunchClaim(auth, cmd, deps, fetchImpl);
|
|
@@ -860,8 +838,9 @@ export async function executeClaim(
|
|
|
860
838
|
if (startingStatus < 200 || startingStatus >= 300) return "aborted";
|
|
861
839
|
|
|
862
840
|
// P1-1: execute EXACTLY the server-validated snapshot. Serialize the already-
|
|
863
|
-
// validated roles to a temp spec file and
|
|
864
|
-
//
|
|
841
|
+
// validated roles to a temp spec file and hand it to `viber-gateway team
|
|
842
|
+
// --spec-file` — so the gateway launches THOSE roles, never a name-resolved
|
|
843
|
+
// template. Serialize
|
|
865
844
|
// the validated object verbatim (no role rebuild). Cleaned up in `finally`.
|
|
866
845
|
const spec = { name: cmd.template_name, roles: cmd.template_spec.roles };
|
|
867
846
|
const { path: specPath, cleanup } = (deps.writeSpec ?? fsWriteSpec())(spec);
|
|
@@ -1004,7 +983,7 @@ export async function executeClaim(
|
|
|
1004
983
|
auth,
|
|
1005
984
|
cmd.id,
|
|
1006
985
|
ok
|
|
1007
|
-
? { fencing_token: cmd.fencing_token, status: "succeeded", result: { message: "team
|
|
986
|
+
? { fencing_token: cmd.fencing_token, status: "succeeded", result: { message: "team launch completed", log: logPath } }
|
|
1008
987
|
: nothingToDo
|
|
1009
988
|
? {
|
|
1010
989
|
fencing_token: cmd.fencing_token,
|
|
@@ -1031,8 +1010,8 @@ export async function executeClaim(
|
|
|
1031
1010
|
logRef,
|
|
1032
1011
|
)
|
|
1033
1012
|
: logRef
|
|
1034
|
-
? `team
|
|
1035
|
-
: "team
|
|
1013
|
+
? `team launch failed — see ${logRef} on that machine`
|
|
1014
|
+
: "team launch failed, and the local log could not be written on that machine",
|
|
1036
1015
|
...(reason && !nameUnconfirmed ? { reason } : {}),
|
|
1037
1016
|
},
|
|
1038
1017
|
fetchImpl,
|
|
@@ -1081,7 +1060,7 @@ export async function reconcileOnce(auth: RunnerAuthLite, deps: ExecDeps = {}):
|
|
|
1081
1060
|
/**
|
|
1082
1061
|
* #627 step-07 — a RELAUNCH command: one agent, from this runner's registry. The
|
|
1083
1062
|
* decision (refuse / re-attach / launch) is `relaunchInstance`'s; this reports it with
|
|
1084
|
-
* the same fencing-guarded reports as a team
|
|
1063
|
+
* the same fencing-guarded reports as a team launch. No automatic retry on failure.
|
|
1085
1064
|
*/
|
|
1086
1065
|
async function executeRelaunchClaim(
|
|
1087
1066
|
auth: RunnerAuthLite,
|
|
@@ -1121,7 +1100,7 @@ async function executeRelaunchClaim(
|
|
|
1121
1100
|
identity: runnerIdentity(auth),
|
|
1122
1101
|
...(deps.relaunchLockDir !== undefined ? { lockDir: deps.relaunchLockDir } : {}),
|
|
1123
1102
|
...(deps.relaunchEnumerate !== undefined ? { enumerate: deps.relaunchEnumerate } : {}),
|
|
1124
|
-
runSpawn: deps.runSingleSpawn ??
|
|
1103
|
+
runSpawn: deps.runSingleSpawn ?? execFileVibeMasterSpawn,
|
|
1125
1104
|
checkPolicy: (entry) =>
|
|
1126
1105
|
validateAgainstPolicy(
|
|
1127
1106
|
{ ...cmd, env: entry.env, template_spec: { roles: [{ ...entry.role }] } },
|
package/lib/runner_registry.ts
CHANGED
|
@@ -74,7 +74,7 @@ export interface RegistryRole {
|
|
|
74
74
|
permission: string;
|
|
75
75
|
rolePrompt?: string;
|
|
76
76
|
model?: string;
|
|
77
|
-
/** #656
|
|
77
|
+
/** #656 claude / #665 codex reasoning effort, replayed as `--effort` on a relaunch. */
|
|
78
78
|
effort?: string;
|
|
79
79
|
}
|
|
80
80
|
|
package/lib/runner_roster.ts
CHANGED
|
@@ -350,23 +350,47 @@ export interface RosterBeatDeps {
|
|
|
350
350
|
* A failed or non-2xx beat changes NOTHING on disk: the coverage then expires on its
|
|
351
351
|
* own and the ended records go again next beat.
|
|
352
352
|
*/
|
|
353
|
-
export
|
|
353
|
+
export function runRosterBeat(
|
|
354
|
+
auth: RosterBeatAuth,
|
|
355
|
+
deps: RosterBeatDeps = {},
|
|
356
|
+
opts: { queueIfBusy?: boolean } = {},
|
|
357
|
+
): Promise<"ok" | "revoked"> {
|
|
354
358
|
// SINGLE-FLIGHT. The enumeration alone can take up to 30 s — one heartbeat period —
|
|
355
359
|
// so an overlapping beat is ordinary, and two answers arriving in reverse order
|
|
356
|
-
// would rewrite an older coverage over a newer one (Codex, step-03 review). A
|
|
357
|
-
// that finds a beat in flight is skipped: the running one covers it.
|
|
358
|
-
if (beatInFlight) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
360
|
+
// would rewrite an older coverage over a newer one (Codex, step-03 review). A
|
|
361
|
+
// PERIODIC tick that finds a beat in flight is skipped: the running one covers it.
|
|
362
|
+
if (beatInFlight !== null) {
|
|
363
|
+
if (!opts.queueIfBusy) {
|
|
364
|
+
noteOnce("a heartbeat was skipped: the previous one is still in flight", deps.log ?? ((l: string) => process.stderr.write(`${l}\n`)));
|
|
365
|
+
return Promise.resolve("ok");
|
|
366
|
+
}
|
|
367
|
+
// #663: a TRIGGERED beat (a presence record appeared or changed) is not dropped: the beat in
|
|
368
|
+
// flight may have collected its roster BEFORE that record existed, so skipping it would leave
|
|
369
|
+
// the agent waiting for the next 30 s tick — the very delay the trigger removes. ONE follow-up
|
|
370
|
+
// beat at most, whatever the number of triggers while busy (Codex, plan review).
|
|
371
|
+
// ⚠ Chained on the beat's OUTCOME, success or rejection (Opus + Codex review): chained on
|
|
372
|
+
// success only, a rejected beat left `queuedBeat` pinned to a dead promise for good, and every
|
|
373
|
+
// later trigger silently lost its follow-up. A `revoked` answer cancels the follow-up (Codex).
|
|
374
|
+
const followUp = (previous: "ok" | "revoked" | undefined): Promise<"ok" | "revoked"> => {
|
|
375
|
+
queuedBeat = null;
|
|
376
|
+
if (previous === "revoked") return Promise.resolve("revoked");
|
|
377
|
+
return runRosterBeat(auth, deps, { queueIfBusy: true });
|
|
378
|
+
};
|
|
379
|
+
queuedBeat ??= beatInFlight.then(followUp, () => followUp(undefined));
|
|
380
|
+
return queuedBeat;
|
|
367
381
|
}
|
|
382
|
+
const run = (async () => {
|
|
383
|
+
try {
|
|
384
|
+
return await rosterBeatOnce(auth, deps);
|
|
385
|
+
} finally {
|
|
386
|
+
beatInFlight = null;
|
|
387
|
+
}
|
|
388
|
+
})();
|
|
389
|
+
beatInFlight = run;
|
|
390
|
+
return run;
|
|
368
391
|
}
|
|
369
|
-
let beatInFlight =
|
|
392
|
+
let beatInFlight: Promise<"ok" | "revoked"> | null = null;
|
|
393
|
+
let queuedBeat: Promise<"ok" | "revoked"> | null = null;
|
|
370
394
|
let unappliedBeats = 0;
|
|
371
395
|
/** #627 (decision A): pending beats before the runner says the handover is slow. */
|
|
372
396
|
export const HANDOVER_WARN_AFTER = 10;
|
package/lib/runner_stream.ts
CHANGED
|
@@ -19,8 +19,10 @@ import { randomBytes } from "node:crypto";
|
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { cfAccessHeaders } from "./cfAccess.js";
|
|
21
21
|
import { isTrustedViberOrigin } from "./urls.js";
|
|
22
|
-
import { type ExecDeps, reconcileOnce } from "./runner_exec.js";
|
|
22
|
+
import { type ExecDeps, reconcileOnce, runnerIdentity } from "./runner_exec.js";
|
|
23
23
|
import { runRosterBeat } from "./runner_roster.js";
|
|
24
|
+
import { presenceDir } from "./presence_record.js";
|
|
25
|
+
import { startPresenceTrigger } from "./presence_trigger.js";
|
|
24
26
|
|
|
25
27
|
/** Remove leftover temp spec files (`.viber/runner-specs/`) from a prior crash
|
|
26
28
|
* at daemon start — they may hold sensitive rolePrompts (Codex P1-1). The
|
|
@@ -127,8 +129,8 @@ async function safeReconcile(auth: RunnerAuth, deps: ExecDeps = {}): Promise<voi
|
|
|
127
129
|
*
|
|
128
130
|
* #627: the beat now carries the machine's ROSTER — the only presence signal of
|
|
129
131
|
* the agents this runner vouches for (`runner_roster.ts`). */
|
|
130
|
-
async function sendHeartbeat(auth: RunnerAuth): Promise<"ok" | "revoked"> {
|
|
131
|
-
return runRosterBeat(auth, { headers: cfAccessHeaders });
|
|
132
|
+
async function sendHeartbeat(auth: RunnerAuth, queueIfBusy = false): Promise<"ok" | "revoked"> {
|
|
133
|
+
return runRosterBeat(auth, { headers: cfAccessHeaders }, { queueIfBusy });
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
@@ -172,15 +174,28 @@ export async function runPersistentRunnerStream(
|
|
|
172
174
|
// Shared abort so a heartbeat-detected revocation tears down the live SSE now,
|
|
173
175
|
// instead of waiting for the next reconnect.
|
|
174
176
|
let currentAbort: AbortController | null = null;
|
|
175
|
-
const
|
|
176
|
-
void sendHeartbeat(auth)
|
|
177
|
-
|
|
178
|
-
revoked
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
177
|
+
const beat = (queueIfBusy: boolean): void => {
|
|
178
|
+
void sendHeartbeat(auth, queueIfBusy)
|
|
179
|
+
.then((r) => {
|
|
180
|
+
if (r === "revoked") {
|
|
181
|
+
revoked = true;
|
|
182
|
+
stopped = true;
|
|
183
|
+
currentAbort?.abort();
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
// A beat that throws (process enumeration, a disk read) is logged, never an unhandled
|
|
187
|
+
// rejection — and the next tick or trigger beats again (Opus review).
|
|
188
|
+
.catch((err) => process.stderr.write(`[runner] heartbeat error (non-fatal): ${String(err)}\n`));
|
|
189
|
+
};
|
|
190
|
+
const heartbeatTimer = setInterval(() => beat(false), HEARTBEAT_INTERVAL_MS);
|
|
191
|
+
// #663: one beat at START (agents already on the machine are covered at once, not after up to
|
|
192
|
+
// 30 s), then one more whenever a presence record appears or is rewritten — a LOCAL folder scan,
|
|
193
|
+
// no request unless something changed. The 30 s tick is unchanged.
|
|
194
|
+
beat(false);
|
|
195
|
+
const presenceTrigger = startPresenceTrigger({
|
|
196
|
+
dir: presenceDir(runnerIdentity(auth)),
|
|
197
|
+
onChange: () => beat(true),
|
|
198
|
+
});
|
|
184
199
|
// Reconcile timer is a RE-JITTERED recursive setTimeout (not setInterval): each
|
|
185
200
|
// tick draws a fresh crypto jitter so a fleet never re-synchronizes (Codex C6).
|
|
186
201
|
let reconcileTimer: ReturnType<typeof setTimeout>;
|
|
@@ -231,6 +246,7 @@ export async function runPersistentRunnerStream(
|
|
|
231
246
|
}
|
|
232
247
|
} finally {
|
|
233
248
|
clearInterval(heartbeatTimer);
|
|
249
|
+
presenceTrigger.stop();
|
|
234
250
|
clearTimeout(reconcileTimer!);
|
|
235
251
|
}
|
|
236
252
|
}
|
package/lib/spawn_reason.ts
CHANGED
|
@@ -7,20 +7,19 @@
|
|
|
7
7
|
* had nowhere to go. The cause existed — it was on the runner's stderr — and was
|
|
8
8
|
* thrown away here.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* extracts the old line and turns a reason into a sentence the web can show. The runner's OWN refusals (policy,
|
|
10
|
+
* The emitter of a team outcome is `viber-gateway/lib/result_marker.ts`
|
|
11
|
+
* (`VIBERGATEWAY_RESULT`, read by `gateway_result.ts`); the reasons below are its
|
|
12
|
+
* vocabulary. This module turns a reason into a sentence the web can show. The
|
|
13
|
+
* runner's OWN refusals (policy,
|
|
15
14
|
* unsafe input, a prior partial run) carry reasons from the same vocabulary, so
|
|
16
15
|
* the web never has to speak two languages.
|
|
17
16
|
*/
|
|
18
17
|
|
|
19
|
-
/** Reasons a spawn can be refused.
|
|
20
|
-
* four the runner produces itself. The API keeps its own allowlist: an unknown
|
|
18
|
+
/** Reasons a spawn can be refused. The launcher's vocabulary (emitted by
|
|
19
|
+
* `viber-gateway/lib/result_marker.ts`) plus the four the runner produces itself. The API keeps its own allowlist: an unknown
|
|
21
20
|
* reason is dropped there, never stored raw — it comes from a client machine. */
|
|
22
21
|
export const SPAWN_REASONS = [
|
|
23
|
-
// Emitted by
|
|
22
|
+
// Emitted by the launcher (viber-gateway)
|
|
24
23
|
"already_running",
|
|
25
24
|
"partial_team",
|
|
26
25
|
"template_invalid",
|
|
@@ -39,18 +38,6 @@ export const SPAWN_REASONS = [
|
|
|
39
38
|
|
|
40
39
|
export type SpawnReason = (typeof SPAWN_REASONS)[number];
|
|
41
40
|
|
|
42
|
-
const MARKER = "VIBEMASTER_RESULT";
|
|
43
|
-
|
|
44
|
-
/** Detail is diagnostic context, never shown raw to a user (the server bounds it
|
|
45
|
-
* again and the web renders from the REASON). Bounded here too so a runaway
|
|
46
|
-
* child cannot push a megabyte into a report. */
|
|
47
|
-
const DETAIL_MAX = 300;
|
|
48
|
-
|
|
49
|
-
export interface ParsedSpawnResult {
|
|
50
|
-
reason: SpawnReason;
|
|
51
|
-
detail?: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
41
|
/**
|
|
55
42
|
* Keep only the names that belong to THIS command's roster.
|
|
56
43
|
*
|
|
@@ -98,72 +85,6 @@ export function rosterNames(
|
|
|
98
85
|
return names;
|
|
99
86
|
}
|
|
100
87
|
|
|
101
|
-
/**
|
|
102
|
-
* Extract the outcome marker from RAW child stderr.
|
|
103
|
-
*
|
|
104
|
-
* Read this before the recap is built, never after: the recap is redacted and
|
|
105
|
-
* then cut to 4 000 characters, and the marker is written LAST — exactly the end
|
|
106
|
-
* that the cut removes. A four-member roster overflows that budget easily, so
|
|
107
|
-
* parsing the recap would work in tests and fail on a real spawn.
|
|
108
|
-
*
|
|
109
|
-
* Scans for the LAST line that starts with the marker AND parses AND carries a
|
|
110
|
-
* known reason — not simply the last line. On `launch_failed` the marker is
|
|
111
|
-
* written after child processes have inherited this same stderr, so trailing
|
|
112
|
-
* child output can follow it; and a line quoting the marker inside other text is
|
|
113
|
-
* ignored because the prefix must start the line.
|
|
114
|
-
*
|
|
115
|
-
* Returns null when there is no marker (an older vibe-master, or output lost to
|
|
116
|
-
* a maxBuffer overflow) — the caller then behaves exactly as before.
|
|
117
|
-
*/
|
|
118
|
-
export type MarkerTrust =
|
|
119
|
-
/** Believe only a marker echoing this nonce (the runner path — the default). */
|
|
120
|
-
| { nonce: string }
|
|
121
|
-
/** No emitter check at all. Named so a caller cannot fall into it by omission
|
|
122
|
-
* (Opus review): an optional nonce silently skipped would reopen "last valid
|
|
123
|
-
* marker wins" with no test turning red. Only for reading output nobody
|
|
124
|
-
* else could have written into — a human running the CLI, or a unit test. */
|
|
125
|
-
| { trustUnauthenticated: true };
|
|
126
|
-
|
|
127
|
-
export function parseSpawnResult(
|
|
128
|
-
rawStderr: string,
|
|
129
|
-
trust: MarkerTrust,
|
|
130
|
-
): ParsedSpawnResult | null {
|
|
131
|
-
const expectedNonce = "nonce" in trust ? trust.nonce : undefined;
|
|
132
|
-
if (expectedNonce !== undefined && expectedNonce.length === 0) {
|
|
133
|
-
throw new Error("parseSpawnResult: empty nonce — pass {trustUnauthenticated:true} to skip the check deliberately");
|
|
134
|
-
}
|
|
135
|
-
const known = new Set<string>(SPAWN_REASONS);
|
|
136
|
-
for (const line of rawStderr.split(/\r?\n/).reverse()) {
|
|
137
|
-
if (!line.startsWith(`${MARKER} `)) continue;
|
|
138
|
-
let parsed: unknown;
|
|
139
|
-
try {
|
|
140
|
-
parsed = JSON.parse(line.slice(MARKER.length + 1));
|
|
141
|
-
} catch {
|
|
142
|
-
continue; // truncated or interleaved — keep looking further back
|
|
143
|
-
}
|
|
144
|
-
if (typeof parsed !== "object" || parsed === null) continue;
|
|
145
|
-
const { reason, detail, nonce } = parsed as {
|
|
146
|
-
reason?: unknown;
|
|
147
|
-
detail?: unknown;
|
|
148
|
-
nonce?: unknown;
|
|
149
|
-
};
|
|
150
|
-
// Emitter proof. Spawned agents inherit this stderr, and an agent working on
|
|
151
|
-
// THIS repo prints this very contract in its own output — "the last valid
|
|
152
|
-
// marker wins" would let such a line overrule the real outcome and turn a
|
|
153
|
-
// launch_failed into a neutral already_running. Only the process we handed
|
|
154
|
-
// the nonce to can echo it.
|
|
155
|
-
if (expectedNonce && nonce !== expectedNonce) continue;
|
|
156
|
-
if (typeof reason !== "string" || !known.has(reason)) continue;
|
|
157
|
-
return {
|
|
158
|
-
reason: reason as SpawnReason,
|
|
159
|
-
...(typeof detail === "string" && detail.length > 0
|
|
160
|
-
? { detail: detail.slice(0, DETAIL_MAX) }
|
|
161
|
-
: {}),
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
return null;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
88
|
/**
|
|
168
89
|
* The sentence the web shows. Built from the REASON. Where agent names carry the
|
|
169
90
|
* actionable part ("which member is missing"), they go through
|
|
@@ -56,8 +56,8 @@ export const TEAM_TEMPLATES_TOOL_DEF = {
|
|
|
56
56
|
description:
|
|
57
57
|
"The roster, for create/update: { roles: [{ role, runtime, permission, rolePrompt, model?, effort?, count? }] } — same shape `get` returns. " +
|
|
58
58
|
"On update a spec REPLACES the whole spec: send every role. " +
|
|
59
|
-
"
|
|
60
|
-
"effort
|
|
59
|
+
"claude: leave model and effort OUT (the runtime picks its own default) UNLESS the owner explicitly asks; model is a family (opus, sonnet, haiku or fable), effort low, medium, high, xhigh or max. " +
|
|
60
|
+
"codex: model AND effort are REQUIRED — use gpt-5.6-sol / low unless the owner asks for another listed model and an effort it accepts. " +
|
|
61
61
|
"Full field rules: the capabilities tool.",
|
|
62
62
|
},
|
|
63
63
|
project_id: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viber-channel",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.33",
|
|
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
|
@@ -123,6 +123,15 @@ import { startBridgeToolHost, type BridgeToolHost } from "./lib/bridge_tool_host
|
|
|
123
123
|
import type { AgentToolsContext } from "./lib/agent_tools.ts";
|
|
124
124
|
import type { OpenDmResult } from "./lib/peers.ts";
|
|
125
125
|
import { resolveLockDir } from "./lib/lock_dir.ts";
|
|
126
|
+
import {
|
|
127
|
+
type CodexPair,
|
|
128
|
+
type ModelListPage,
|
|
129
|
+
readCodexPair,
|
|
130
|
+
threadStartPairParams,
|
|
131
|
+
turnStartPairParams,
|
|
132
|
+
pairMismatch,
|
|
133
|
+
verifyCodexPair,
|
|
134
|
+
} from "./lib/codex_model_pair.ts";
|
|
126
135
|
|
|
127
136
|
// #480: transport host vs logical identity. IDENTITY_URL is what the session and
|
|
128
137
|
// bridge-lock hashes are keyed on (this file builds several of them, below) —
|
|
@@ -673,7 +682,8 @@ class OutboundTurnTracker {
|
|
|
673
682
|
}
|
|
674
683
|
}
|
|
675
684
|
|
|
676
|
-
|
|
685
|
+
/** Exported for its #665 witnesses only (a fake child stands in for `codex app-server`). */
|
|
686
|
+
export class CodexAppServer {
|
|
677
687
|
private child: ChildProcessWithoutNullStreams;
|
|
678
688
|
private nextId = 1;
|
|
679
689
|
private stdoutBuffer = "";
|
|
@@ -700,6 +710,10 @@ class CodexAppServer {
|
|
|
700
710
|
* env (read through `bearer_token_env_var`) so it never lands in argv/config.
|
|
701
711
|
*/
|
|
702
712
|
mcp?: { url: string; bearerEnvVar: string; bearerValue: string },
|
|
713
|
+
/** #665: the model/effort the template set, or undefined (a role stored before #665). */
|
|
714
|
+
private readonly pair?: CodexPair,
|
|
715
|
+
/** Test seam (#665): what launches `codex app-server`. Production passes nothing. */
|
|
716
|
+
spawnChild: typeof spawn = spawn,
|
|
703
717
|
) {
|
|
704
718
|
const codexBin = process.env.VIBER_CODEX_BIN?.trim() || "codex";
|
|
705
719
|
const args = ["app-server"];
|
|
@@ -735,7 +749,7 @@ class CodexAppServer {
|
|
|
735
749
|
args.push("-c", `mcp_servers.viber.bearer_token_env_var="${mcp.bearerEnvVar}"`);
|
|
736
750
|
childEnv[mcp.bearerEnvVar] = mcp.bearerValue;
|
|
737
751
|
}
|
|
738
|
-
this.child =
|
|
752
|
+
this.child = spawnChild(codexBin, args, {
|
|
739
753
|
cwd: process.cwd(),
|
|
740
754
|
env: childEnv,
|
|
741
755
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -800,6 +814,7 @@ class CodexAppServer {
|
|
|
800
814
|
const existing = state.threads[key]?.thread_id;
|
|
801
815
|
const t0 = nowMs(); // #409: measure thread resume/start (the first-DM cost)
|
|
802
816
|
let resumeAttempted = false;
|
|
817
|
+
let resumeMismatch: string | undefined;
|
|
803
818
|
|
|
804
819
|
if (existing && !options.newThread) {
|
|
805
820
|
try {
|
|
@@ -813,17 +828,26 @@ class CodexAppServer {
|
|
|
813
828
|
sandbox: AGENT_SANDBOX,
|
|
814
829
|
developerInstructions: AGENT_INSTRUCTIONS,
|
|
815
830
|
excludeTurns: true,
|
|
831
|
+
// #665: the pair at resume time too — a resumed thread was created on the model of
|
|
832
|
+
// its FIRST launch; this moves it onto the template's, like turn/start does.
|
|
833
|
+
...threadStartPairParams(this.pair),
|
|
816
834
|
});
|
|
817
835
|
// #409 (Codex): validate/extract the id BEFORE logging success — a malformed
|
|
818
836
|
// response must NOT be logged as resume then again as resume-failed→start.
|
|
819
837
|
const resumedId = extractThreadId(resumed);
|
|
820
|
-
|
|
821
|
-
|
|
838
|
+
// #665: codex answers the thread's EFFECTIVE model — checked, never assumed. A mismatch
|
|
839
|
+
// leaves this try (it must not fall back to a fresh thread and hide the problem).
|
|
840
|
+
resumeMismatch = pairMismatch(this.pair, resumed, "thread/resume");
|
|
841
|
+
if (resumeMismatch === undefined) {
|
|
842
|
+
logTiming("ensureThread", nowMs() - t0, "outcome=resume");
|
|
843
|
+
return resumedId;
|
|
844
|
+
}
|
|
822
845
|
} catch (err) {
|
|
823
846
|
process.stderr.write(
|
|
824
847
|
`[viber-codex-bridge] codex: resume failed (${safeErrorMessage(err)}), starting a fresh thread\n`,
|
|
825
848
|
);
|
|
826
849
|
}
|
|
850
|
+
if (resumeMismatch !== undefined) this.stopOnPairMismatch(resumeMismatch);
|
|
827
851
|
}
|
|
828
852
|
|
|
829
853
|
process.stderr.write("[viber-codex-bridge] codex: starting fresh bridge-owned thread\n");
|
|
@@ -836,8 +860,18 @@ class CodexAppServer {
|
|
|
836
860
|
ephemeral: false,
|
|
837
861
|
threadSource: "user",
|
|
838
862
|
sessionStartSource: options.newThread ? "clear" : "startup",
|
|
863
|
+
...threadStartPairParams(this.pair),
|
|
864
|
+
}).catch((err: unknown) => {
|
|
865
|
+
// #665: a refusal caused by the pair must name it, not surface as a bare RPC error.
|
|
866
|
+
if (this.pair === undefined) throw err;
|
|
867
|
+
throw new Error(
|
|
868
|
+
`codex refused thread/start with model ${this.pair.model} / effort ${this.pair.effort}: ${safeErrorMessage(err)}`,
|
|
869
|
+
);
|
|
839
870
|
});
|
|
840
871
|
const threadId = extractThreadId(started);
|
|
872
|
+
// #665: same check on a fresh thread — what codex SAYS it runs, not what we asked.
|
|
873
|
+
const startMismatch = pairMismatch(this.pair, started, "thread/start");
|
|
874
|
+
if (startMismatch !== undefined) this.stopOnPairMismatch(startMismatch);
|
|
841
875
|
state.threads[key] = {
|
|
842
876
|
thread_id: threadId,
|
|
843
877
|
conversation_id: options.conversationId,
|
|
@@ -867,11 +901,36 @@ class CodexAppServer {
|
|
|
867
901
|
sandboxPolicy: buildTurnSandboxPolicy(AGENT_TIER),
|
|
868
902
|
developerInstructions: AGENT_INSTRUCTIONS,
|
|
869
903
|
responsesapiClientMetadata: { source: "viber-codex-bridge" },
|
|
904
|
+
// #665: on EVERY turn — it also moves a resumed thread onto the template's model.
|
|
905
|
+
...turnStartPairParams(this.pair),
|
|
870
906
|
});
|
|
871
907
|
const turnId = this.extractTurnId(response);
|
|
872
908
|
return await this.waitForTurn(threadId, turnId);
|
|
873
909
|
}
|
|
874
910
|
|
|
911
|
+
/**
|
|
912
|
+
* #665: check the pair against THIS codex's catalogue (`model/list`) — no thread, no turn, so
|
|
913
|
+
* it runs before any `turn/start`. No pair: nothing is called (the pre-#665 behaviour).
|
|
914
|
+
*/
|
|
915
|
+
/** #665: codex is not running the template's pair — stop the bridge in clear, never run on. */
|
|
916
|
+
private stopOnPairMismatch(reason: string): never {
|
|
917
|
+
process.stderr.write(`${LOG_PREFIX} #665: ${reason}\n`);
|
|
918
|
+
this.onUnexpectedExit?.(reason);
|
|
919
|
+
throw new Error(reason);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
async verifyPair(timeoutMs: number): Promise<void> {
|
|
923
|
+
if (this.pair === undefined) return;
|
|
924
|
+
await verifyCodexPair(
|
|
925
|
+
this.pair,
|
|
926
|
+
async (cursor) => (await this.request("model/list", { cursor, includeHidden: false })) as ModelListPage,
|
|
927
|
+
timeoutMs,
|
|
928
|
+
);
|
|
929
|
+
process.stderr.write(
|
|
930
|
+
`${LOG_PREFIX} #665: codex model ${this.pair.model} / effort ${this.pair.effort} — found in this codex's catalogue\n`,
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
|
|
875
934
|
close(): void {
|
|
876
935
|
this.closing = true;
|
|
877
936
|
this.child.kill();
|
|
@@ -1382,6 +1441,8 @@ function setupCodexRuntime(deps: {
|
|
|
1382
1441
|
requestShutdown: (err: BridgeShutdownError) => void;
|
|
1383
1442
|
/** #627: why the bridge is paused (null = may write). */
|
|
1384
1443
|
pauseReason: () => string | null;
|
|
1444
|
+
/** #665: the template's model/effort, or undefined. */
|
|
1445
|
+
pair: CodexPair | undefined;
|
|
1385
1446
|
}): { codex: CodexAppServer; toolHost: BridgeToolHost } {
|
|
1386
1447
|
const { tracker } = deps;
|
|
1387
1448
|
const bridgeToolsCtx: AgentToolsContext = {
|
|
@@ -1417,10 +1478,39 @@ function setupCodexRuntime(deps: {
|
|
|
1417
1478
|
deps.requestShutdown(new BridgeShutdownError(reason, 3));
|
|
1418
1479
|
},
|
|
1419
1480
|
{ url: toolHost.url, bearerEnvVar: "VIBER_MCP_TOKEN", bearerValue: toolHost.bearerToken },
|
|
1481
|
+
deps.pair,
|
|
1420
1482
|
);
|
|
1421
1483
|
return { codex, toolHost };
|
|
1422
1484
|
}
|
|
1423
1485
|
|
|
1486
|
+
/** #665: one deadline for the whole `model/list` walk at startup (it may refresh over network). */
|
|
1487
|
+
const CODEX_PAIR_CHECK_TIMEOUT_MS = 30_000;
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* #665: initialize codex and check its pair. On ANY failure — initialize, catalogue refusal,
|
|
1491
|
+
* deadline — `release` runs (child, tool host, lock) BEFORE the error leaves as a
|
|
1492
|
+
* BridgeShutdownError (a refused pair) or as itself (initialize): on the `--conversation` path nothing
|
|
1493
|
+
* else would release them.
|
|
1494
|
+
*/
|
|
1495
|
+
export async function startCheckedCodex(
|
|
1496
|
+
codex: Pick<CodexAppServer, "initialize" | "verifyPair">,
|
|
1497
|
+
timeoutMs: number,
|
|
1498
|
+
release: () => void,
|
|
1499
|
+
): Promise<void> {
|
|
1500
|
+
try {
|
|
1501
|
+
await codex.initialize();
|
|
1502
|
+
} catch (err) {
|
|
1503
|
+
release();
|
|
1504
|
+
throw err; // unchanged type: an initialize failure stays a crash, as before #665 (Opus review)
|
|
1505
|
+
}
|
|
1506
|
+
try {
|
|
1507
|
+
await codex.verifyPair(timeoutMs);
|
|
1508
|
+
} catch (err) {
|
|
1509
|
+
release();
|
|
1510
|
+
throw new BridgeShutdownError(safeErrorMessage(err), 2); // a refused pair is a deliberate end
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1424
1514
|
function logCodexSafety(): void {
|
|
1425
1515
|
process.stderr.write(
|
|
1426
1516
|
`${LOG_PREFIX} codex safety: tier=${AGENT_TIER}, sandbox=${AGENT_SANDBOX}, approvalPolicy=${AGENT_APPROVAL_POLICY}, network=${networkForTier(AGENT_TIER)}, ` +
|
|
@@ -1436,6 +1526,8 @@ function logCodexSafety(): void {
|
|
|
1436
1526
|
|
|
1437
1527
|
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
|
|
1438
1528
|
const options = parseArgs(argv);
|
|
1529
|
+
// #665: both or neither — a partial pair stops the bridge HERE, before any identity or child.
|
|
1530
|
+
const pair = readCodexPair(process.env);
|
|
1439
1531
|
const { auth, fingerprint } = loadBridgeAuthAndFingerprint();
|
|
1440
1532
|
// #317 (iii): per-turn outbound tracker (anti-double-post + send_message target).
|
|
1441
1533
|
const tracker = new OutboundTurnTracker();
|
|
@@ -1512,10 +1604,20 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
|
|
1512
1604
|
startConversation: helpers.startConversation,
|
|
1513
1605
|
requestShutdown: helpers.requestShutdown,
|
|
1514
1606
|
pauseReason: helpers.pauseReason,
|
|
1607
|
+
pair,
|
|
1515
1608
|
});
|
|
1516
1609
|
codex = rt.codex;
|
|
1517
1610
|
toolHost = rt.toolHost;
|
|
1518
1611
|
await codex.initialize();
|
|
1612
|
+
// #665: before the control stream opens, so before any join, thread or turn. A refusal is
|
|
1613
|
+
// a deliberate end (presence `ended`), never a crash the runner would read as alive.
|
|
1614
|
+
try {
|
|
1615
|
+
await codex.verifyPair(CODEX_PAIR_CHECK_TIMEOUT_MS);
|
|
1616
|
+
} catch (err) {
|
|
1617
|
+
const stop = new BridgeShutdownError(safeErrorMessage(err), 2);
|
|
1618
|
+
helpers.requestShutdown(stop);
|
|
1619
|
+
throw stop;
|
|
1620
|
+
}
|
|
1519
1621
|
logCodexSafety();
|
|
1520
1622
|
},
|
|
1521
1623
|
onShutdown: () => {
|
|
@@ -1570,10 +1672,19 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
|
|
1570
1672
|
if (!shutdown.signal.aborted) shutdown.abort(err);
|
|
1571
1673
|
},
|
|
1572
1674
|
pauseReason: () => gateRef.gate?.reason() ?? REASON_STARTING,
|
|
1675
|
+
pair,
|
|
1573
1676
|
});
|
|
1574
1677
|
codex = rt.codex;
|
|
1575
1678
|
toolHost = rt.toolHost;
|
|
1576
|
-
|
|
1679
|
+
// #665: before the presence record and the stream, so before any turn. This runs BEFORE the
|
|
1680
|
+
// try/finally below, so a failure must release what is already held (Codex-2 review of d6d127a5).
|
|
1681
|
+
const started = codex;
|
|
1682
|
+
const host = toolHost;
|
|
1683
|
+
await startCheckedCodex(started, CODEX_PAIR_CHECK_TIMEOUT_MS, () => {
|
|
1684
|
+
started.close();
|
|
1685
|
+
host.stop();
|
|
1686
|
+
res.bridgeLock.release();
|
|
1687
|
+
});
|
|
1577
1688
|
logCodexSafety();
|
|
1578
1689
|
process.stderr.write(`${LOG_PREFIX} ready: joined ${res.minted.conversation_id} as instance ${res.instanceKey.slice(0, 8)}\n`);
|
|
1579
1690
|
// #315: no control stream here, so beat the instance liveness directly.
|