viber-channel 0.8.31 → 0.8.32
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/capabilities.ts +5 -3
- 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_roster.ts +37 -13
- package/lib/runner_stream.ts +28 -12
- package/lib/spawn_reason.ts +7 -86
- package/package.json +1 -1
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.",
|
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_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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viber-channel",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.32",
|
|
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": {
|