viber-channel 0.8.19 → 0.8.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/agent_tools.ts +18 -1
- package/lib/bridge_core.ts +76 -17
- package/lib/bridge_spawn.ts +1 -1
- package/lib/control_stream.ts +3 -2
- package/lib/coverage_watch.ts +244 -0
- package/lib/fingerprint.ts +1 -1
- package/lib/hidden_proc.ts +99 -0
- package/lib/parent_watchdog.ts +1 -1
- package/lib/presence_record.ts +358 -0
- package/lib/process_start.ts +132 -10
- package/lib/runner_enroll.ts +26 -4
- package/lib/runner_exec.ts +229 -7
- package/lib/runner_registry.ts +413 -0
- package/lib/runner_relaunch.ts +181 -0
- package/lib/runner_roster.ts +521 -0
- package/lib/runner_stream.ts +66 -35
- package/lib/urls.ts +3 -1
- package/package.json +1 -1
- package/viber-channel.ts +65 -25
- package/viber-codex-bridge.ts +43 -9
package/lib/agent_tools.ts
CHANGED
|
@@ -78,6 +78,16 @@ export interface AgentToolsContext {
|
|
|
78
78
|
* signal); the Codex bridge returns its per-turn stream signal (#303 parity).
|
|
79
79
|
*/
|
|
80
80
|
abortSignal?(): AbortSignal | undefined;
|
|
81
|
+
/**
|
|
82
|
+
* #627: why this agent is PAUSED, or null when it may write. A paused agent has no
|
|
83
|
+
* confirmed conversation lease, so it must not post anything; the tool says why.
|
|
84
|
+
*/
|
|
85
|
+
pauseReason?(): string | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pausedResult(ctx: AgentToolsContext): AgentToolResult | null {
|
|
89
|
+
const reason = ctx.pauseReason?.() ?? null;
|
|
90
|
+
return reason === null ? null : errorText(`Paused — ${reason}. Nothing was sent; it resumes on its own when the runner covers this agent again.`);
|
|
81
91
|
}
|
|
82
92
|
|
|
83
93
|
function text(s: string): AgentToolResult {
|
|
@@ -164,6 +174,8 @@ export async function messageAgent(
|
|
|
164
174
|
if (!ctx.instanceToken() || !ctx.voiceBaseUrl()) {
|
|
165
175
|
return errorText("Channel not ready: no instance identity yet.");
|
|
166
176
|
}
|
|
177
|
+
const paused = pausedResult(ctx);
|
|
178
|
+
if (paused !== null) return paused;
|
|
167
179
|
const peerId = args.instance_id;
|
|
168
180
|
const body = args.text;
|
|
169
181
|
if (typeof peerId !== "string" || peerId.trim().length === 0) {
|
|
@@ -183,7 +195,10 @@ export async function messageAgent(
|
|
|
183
195
|
if (!outcome.ok) {
|
|
184
196
|
let friendly: string;
|
|
185
197
|
if (outcome.detail === "target_offline") {
|
|
186
|
-
|
|
198
|
+
// #627: a just-launched agent comes online with its runner's first beat (≤ 30 s).
|
|
199
|
+
friendly =
|
|
200
|
+
"That agent is offline right now — it must be running to receive a DM. " +
|
|
201
|
+
"If it was just launched, it comes online with its runner's next beat (within 30 s): retry then.";
|
|
187
202
|
} else if (outcome.detail === "dm_unavailable_retry") {
|
|
188
203
|
friendly = "Could not open the DM due to a transient conflict — try message_agent again.";
|
|
189
204
|
} else {
|
|
@@ -237,6 +252,8 @@ export async function sendMessage(
|
|
|
237
252
|
if (!ctx.sendConversationToken() || !ctx.sendConversationId()) {
|
|
238
253
|
return errorText("Channel not ready: no conversation yet.");
|
|
239
254
|
}
|
|
255
|
+
const paused = pausedResult(ctx);
|
|
256
|
+
if (paused !== null) return paused;
|
|
240
257
|
const body = args.text;
|
|
241
258
|
if (typeof body !== "string" || body.trim().length === 0) {
|
|
242
259
|
return errorText("Invalid input: text must be a non-empty string");
|
package/lib/bridge_core.ts
CHANGED
|
@@ -32,12 +32,12 @@ import type { AuthJson } from "./auth.js";
|
|
|
32
32
|
import { acquireInstance, registerInstance, instanceKindFromEnv, maybeAttachTeam, type AcquiredInstance } from "./instance.js";
|
|
33
33
|
import {
|
|
34
34
|
runPersistentControlStream,
|
|
35
|
-
sendInstanceHeartbeat,
|
|
36
35
|
ControlStreamStopped,
|
|
37
36
|
ControlStreamAuthError,
|
|
38
37
|
type PersistentControlStream,
|
|
39
38
|
} from "./control_stream.js";
|
|
40
|
-
import {
|
|
39
|
+
import { type PresenceGate, startCoverageWatch } from "./coverage_watch.js";
|
|
40
|
+
import { type PresenceEndReason, type PresenceRecordHandle, startPresenceRecord } from "./presence_record.js";
|
|
41
41
|
|
|
42
42
|
/** A conversation message as it arrives over the conversation SSE / message list. */
|
|
43
43
|
export interface ConversationMessage {
|
|
@@ -71,11 +71,19 @@ export interface ConversationRuntime {
|
|
|
71
71
|
scheduler?: TokenRefreshScheduler;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* A controlled bridge exit carrying the process exit code to propagate.
|
|
76
|
+
*
|
|
77
|
+
* `endReason` (#627) names the cause for the presence record. It is set ONLY where
|
|
78
|
+
* the cause is known (revocation, lease lost, --once done). The class alone says
|
|
79
|
+
* nothing about intent: it also carries failures (token expired, refresh failed),
|
|
80
|
+
* which must leave the agent relaunchable.
|
|
81
|
+
*/
|
|
75
82
|
export class BridgeShutdownError extends Error {
|
|
76
83
|
constructor(
|
|
77
84
|
message: string,
|
|
78
85
|
public readonly exitCode: number,
|
|
86
|
+
public readonly endReason: PresenceEndReason = "shutdown",
|
|
79
87
|
) {
|
|
80
88
|
super(message);
|
|
81
89
|
this.name = "BridgeShutdownError";
|
|
@@ -308,8 +316,12 @@ export async function postBridgeReply(opts: {
|
|
|
308
316
|
logPrefix: string;
|
|
309
317
|
/** Fetch seam (#606) — see postMessage. Defaults to the global fetch. */
|
|
310
318
|
fetchImpl?: typeof fetch;
|
|
319
|
+
/** #627: a reply finished during a pause waits for the resume before it is posted. */
|
|
320
|
+
gate?: PresenceGate;
|
|
311
321
|
}): Promise<void> {
|
|
312
322
|
const { baseUrl, runtime, text, ownPostedIds, signal, logPrefix } = opts;
|
|
323
|
+
await opts.gate?.waitActive(signal);
|
|
324
|
+
if (signal?.aborted) return;
|
|
313
325
|
let result;
|
|
314
326
|
try {
|
|
315
327
|
result = await postMessage(baseUrl, runtime.id, runtime.token, text, undefined, signal, opts.fetchImpl ?? fetch);
|
|
@@ -529,6 +541,8 @@ export interface ConversationStreamDeps {
|
|
|
529
541
|
/** Session-handle tag distinguishing runtimes in the handle path (codex/gemma). */
|
|
530
542
|
sessionTag: string;
|
|
531
543
|
makeRunTurn: MakeRunTurn;
|
|
544
|
+
/** #627: the pause gate — inbound turns and replies wait while the agent is paused. */
|
|
545
|
+
gate?: PresenceGate;
|
|
532
546
|
}
|
|
533
547
|
|
|
534
548
|
/**
|
|
@@ -598,7 +612,7 @@ export async function runConversationStream(
|
|
|
598
612
|
watermark = { lastId: 0 };
|
|
599
613
|
watermarks.set(convId, watermark);
|
|
600
614
|
}
|
|
601
|
-
await sseLoop({ minted, runtime, fingerprint, ownInstanceKey: instanceKey, options, signal: ctrl.signal, getBaseUrl: baseUrl, logPrefix, runTurn, watermark });
|
|
615
|
+
await sseLoop({ minted, runtime, fingerprint, ownInstanceKey: instanceKey, options, signal: ctrl.signal, getBaseUrl: baseUrl, logPrefix, runTurn, watermark, gate: deps.gate });
|
|
602
616
|
} finally {
|
|
603
617
|
parentSignal.removeEventListener("abort", onParentAbort);
|
|
604
618
|
runtime.scheduler?.cancel();
|
|
@@ -651,8 +665,11 @@ export async function sseLoop(opts: {
|
|
|
651
665
|
fetchImpl?: typeof fetch;
|
|
652
666
|
/** Injectable catch-up fetch; defaults to messages.ts `fetchMessages`. */
|
|
653
667
|
fetchMessagesImpl?: typeof fetchMessages;
|
|
668
|
+
/** #627: the pause gate (see ConversationStreamDeps.gate). */
|
|
669
|
+
gate?: PresenceGate;
|
|
654
670
|
}): Promise<void> {
|
|
655
671
|
const { minted, runtime, fingerprint, ownInstanceKey, options, signal, getBaseUrl, logPrefix, runTurn, watermark } = opts;
|
|
672
|
+
const deps = { gate: opts.gate };
|
|
656
673
|
const baseUrl = getBaseUrl; // called at each use below
|
|
657
674
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
658
675
|
const fetchMessagesImpl = opts.fetchMessagesImpl ?? fetchMessages;
|
|
@@ -722,7 +739,7 @@ export async function sseLoop(opts: {
|
|
|
722
739
|
rearmFailureNotice();
|
|
723
740
|
return false;
|
|
724
741
|
case "auto-post":
|
|
725
|
-
await postBridgeReply({ baseUrl: baseUrl(), runtime, text: reply, ownPostedIds, signal, logPrefix, fetchImpl });
|
|
742
|
+
await postBridgeReply({ baseUrl: baseUrl(), runtime, text: reply, ownPostedIds, signal, logPrefix, fetchImpl, gate: deps.gate });
|
|
726
743
|
rearmFailureNotice();
|
|
727
744
|
return false;
|
|
728
745
|
}
|
|
@@ -801,6 +818,7 @@ export async function sseLoop(opts: {
|
|
|
801
818
|
signal,
|
|
802
819
|
logPrefix,
|
|
803
820
|
fetchImpl,
|
|
821
|
+
gate: deps.gate,
|
|
804
822
|
});
|
|
805
823
|
lastAnnouncedCause = key;
|
|
806
824
|
} catch (notifyErr) {
|
|
@@ -837,6 +855,15 @@ export async function sseLoop(opts: {
|
|
|
837
855
|
// AT-MOST-ONCE: mark handled BEFORE the reply so a runtime failure does not
|
|
838
856
|
// re-feed the same message on reconnect (no reply storm). A failed reply is
|
|
839
857
|
// dropped for this run — deliberately asymmetric with dm_stream's conduit.
|
|
858
|
+
// #627: while paused, the message is DEFERRED (never dropped): the turn starts
|
|
859
|
+
// once the runner covers this agent again. The wait comes BEFORE the message is
|
|
860
|
+
// marked handled: a stream stopped during the pause leaves it unhandled, and the
|
|
861
|
+
// persistent watermark un-advanced, so the re-join replays it (Codex, step-06).
|
|
862
|
+
if (deps.gate?.isPaused()) {
|
|
863
|
+
process.stderr.write(`${logPrefix} paused (${deps.gate.reason()}): message deferred\n`);
|
|
864
|
+
await deps.gate.waitActive(signal);
|
|
865
|
+
if (signal.aborted) return false;
|
|
866
|
+
}
|
|
840
867
|
if (idStr) handledIds.add(idStr);
|
|
841
868
|
process.stderr.write(`${logPrefix} message received: ${msg.content!.slice(0, 80)}\n`);
|
|
842
869
|
try {
|
|
@@ -956,7 +983,8 @@ export async function sseLoop(opts: {
|
|
|
956
983
|
|
|
957
984
|
if (eventType === "stop") {
|
|
958
985
|
process.stderr.write(`${logPrefix} received stop signal, exiting\n`);
|
|
959
|
-
|
|
986
|
+
// #627: the same server stop as claude's `stop` event → the same cause.
|
|
987
|
+
throw new BridgeShutdownError("received stop signal", 0, "revoked");
|
|
960
988
|
}
|
|
961
989
|
|
|
962
990
|
if (eventType === "transcription" && data) {
|
|
@@ -1344,6 +1372,8 @@ export interface BridgeHelpers {
|
|
|
1344
1372
|
parentSignal: AbortSignal;
|
|
1345
1373
|
/** Request a controlled bridge shutdown (e.g. codex's app-server died). */
|
|
1346
1374
|
requestShutdown: (err: BridgeShutdownError) => void;
|
|
1375
|
+
/** #627: why the agent is paused (null = may write) — for the tool host. */
|
|
1376
|
+
pauseReason: () => string | null;
|
|
1347
1377
|
}
|
|
1348
1378
|
|
|
1349
1379
|
/** A runtime's adapter for the shared await-invite bridge. */
|
|
@@ -1396,6 +1426,10 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
|
|
|
1396
1426
|
const streamTasks = new Set<Promise<void>>();
|
|
1397
1427
|
let voiceBaseUrl = "";
|
|
1398
1428
|
let instanceKey = "";
|
|
1429
|
+
// #627: the local presence record read by the machine's runner.
|
|
1430
|
+
let presence: PresenceRecordHandle | undefined;
|
|
1431
|
+
// #627: the pause gate, fed by the runner's local coverage (no network heartbeat).
|
|
1432
|
+
let gate: PresenceGate | undefined;
|
|
1399
1433
|
|
|
1400
1434
|
const startConversation = (minted: ConversationMintResponse): void => {
|
|
1401
1435
|
// A pushed join is the only announcement a bridge that reuses a persisted
|
|
@@ -1419,11 +1453,12 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
|
|
|
1419
1453
|
logPrefix,
|
|
1420
1454
|
sessionTag,
|
|
1421
1455
|
makeRunTurn,
|
|
1456
|
+
gate,
|
|
1422
1457
|
})
|
|
1423
1458
|
.then(() => {
|
|
1424
1459
|
// --once: one handled message completes the bridge cleanly.
|
|
1425
1460
|
if (options.once && !shutdown.signal.aborted) {
|
|
1426
|
-
shutdown.abort(new BridgeShutdownError("once: completed", 0));
|
|
1461
|
+
shutdown.abort(new BridgeShutdownError("once: completed", 0, "once-completed"));
|
|
1427
1462
|
}
|
|
1428
1463
|
})
|
|
1429
1464
|
.catch((err) => {
|
|
@@ -1441,6 +1476,21 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
|
|
|
1441
1476
|
});
|
|
1442
1477
|
bridgeLock = identity.bridgeLock;
|
|
1443
1478
|
instanceKey = identity.instanceKey;
|
|
1479
|
+
// Await-invite bridges may hold several conversations; the beat sends none,
|
|
1480
|
+
// so the record carries none either.
|
|
1481
|
+
presence = startPresenceRecord({ identityBaseUrl, instanceId: identity.instanceKey, label, projectId: auth.project_id, getConversationId: () => null, lockDir });
|
|
1482
|
+
// #627: no network heartbeat — the runner vouches for this bridge. An await-invite
|
|
1483
|
+
// bridge follows no single conversation lease, only its runner's freshness.
|
|
1484
|
+
const watch = startCoverageWatch({
|
|
1485
|
+
identityBaseUrl,
|
|
1486
|
+
instanceId: identity.instanceKey,
|
|
1487
|
+
getConversationId: () => null,
|
|
1488
|
+
onLeaseLost: () => shutdown.abort(new BridgeShutdownError("conversation lease lost (taken over)", 0, "lease-lost")),
|
|
1489
|
+
lockDir,
|
|
1490
|
+
log: (m) => process.stderr.write(`${logPrefix} ${m}`),
|
|
1491
|
+
});
|
|
1492
|
+
gate = watch.gate;
|
|
1493
|
+
shutdown.signal.addEventListener("abort", () => watch.stop(), { once: true });
|
|
1444
1494
|
// #480: adopt BEFORE the control stream and the heartbeat start — they are
|
|
1445
1495
|
// this bridge's two longest-lived flows and the whole point of the change.
|
|
1446
1496
|
api.offer(identity.apiBaseUrlAnnounced);
|
|
@@ -1452,6 +1502,7 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
|
|
|
1452
1502
|
requestShutdown: (err) => {
|
|
1453
1503
|
if (!shutdown.signal.aborted) shutdown.abort(err);
|
|
1454
1504
|
},
|
|
1505
|
+
pauseReason: () => gate?.reason() ?? null,
|
|
1455
1506
|
};
|
|
1456
1507
|
await onStart?.({ identity: { instanceKey: identity.instanceKey, instanceToken: identity.instanceToken }, helpers });
|
|
1457
1508
|
|
|
@@ -1468,25 +1519,21 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
|
|
|
1468
1519
|
shutdown.abort(
|
|
1469
1520
|
reason === "unauthorized"
|
|
1470
1521
|
? new BridgeShutdownError("instance token revoked or invalid", 3)
|
|
1471
|
-
: new BridgeShutdownError("instance revoked", 0),
|
|
1522
|
+
: new BridgeShutdownError("instance revoked", 0, "revoked"),
|
|
1472
1523
|
);
|
|
1473
1524
|
},
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
},
|
|
1525
|
+
// #627: an old server may still probe; this agent never beats on the network —
|
|
1526
|
+
// its runner answers for it (Python no longer probes a runner-covered agent).
|
|
1527
|
+
onBeatNow: () => {},
|
|
1477
1528
|
},
|
|
1478
1529
|
);
|
|
1479
|
-
const ihb = startInstanceHeartbeat({
|
|
1480
|
-
getBaseUrl: baseUrl,
|
|
1481
|
-
instanceId: identity.instanceKey,
|
|
1482
|
-
getInstanceToken: () => identity.instanceToken,
|
|
1483
|
-
});
|
|
1484
|
-
shutdown.signal.addEventListener("abort", () => ihb.stop(), { once: true });
|
|
1485
1530
|
controlStream.firstJoin.catch(() => {});
|
|
1486
1531
|
await controlStream.done;
|
|
1487
1532
|
const reason = shutdown.signal.reason;
|
|
1488
1533
|
if (reason instanceof BridgeShutdownError) throw reason;
|
|
1489
1534
|
} finally {
|
|
1535
|
+
// Read BEFORE the bare abort below replaces an unset reason.
|
|
1536
|
+
endBridgePresence(presence, shutdown.signal.aborted ? shutdown.signal.reason : undefined);
|
|
1490
1537
|
if (!shutdown.signal.aborted) shutdown.abort();
|
|
1491
1538
|
const pending = [...streamTasks];
|
|
1492
1539
|
for (const ctrl of activeStreams.values()) ctrl.abort(new BridgeShutdownError("bridge shutdown", 0));
|
|
@@ -1499,3 +1546,15 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
|
|
|
1499
1546
|
|
|
1500
1547
|
// Re-export control-stream error types so runtimes can map them if needed.
|
|
1501
1548
|
export { ControlStreamStopped, ControlStreamAuthError };
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* #627 — record how a bridge ended. A `BridgeShutdownError` is an end the bridge
|
|
1552
|
+
* went through → `ended` with its `endReason` (failures default to "shutdown").
|
|
1553
|
+
* Anything else — an unexpected throw — leaves the record active, like a crash.
|
|
1554
|
+
* The runner, not this function, decides what either means.
|
|
1555
|
+
*/
|
|
1556
|
+
export function endBridgePresence(presence: PresenceRecordHandle | undefined, reason: unknown): void {
|
|
1557
|
+
if (presence === undefined) return;
|
|
1558
|
+
if (reason instanceof BridgeShutdownError) presence.markEnded(reason.endReason);
|
|
1559
|
+
else presence.stop();
|
|
1560
|
+
}
|
package/lib/bridge_spawn.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* the bridge's default "register a fresh instance" path.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { spawn as nodeSpawn } from "
|
|
17
|
+
import { spawn as nodeSpawn } from "./hidden_proc.js";
|
|
18
18
|
import type { AgentSpec, ChildHandle, SpawnFn } from "./supervisor.ts";
|
|
19
19
|
|
|
20
20
|
/** Minimal child surface the adapter consumes (matches node's ChildProcess). */
|
package/lib/control_stream.ts
CHANGED
|
@@ -26,9 +26,10 @@ export interface ControlEvent {
|
|
|
26
26
|
data: string;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/** Build the instance control-stream SSE URL.
|
|
29
|
+
/** Build the instance control-stream SSE URL. `presence=runner` (#627): this agent's
|
|
30
|
+
* runner vouches for it, so its connect must not count as liveness on the server. */
|
|
30
31
|
export function buildControlStreamUrl(baseUrl: string, instanceId: string): string {
|
|
31
|
-
return `${baseUrl}/api/instances/${instanceId}/events`;
|
|
32
|
+
return `${baseUrl}/api/instances/${instanceId}/events?presence=runner`;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* coverage_watch.ts — the agent side of runner-owned presence (#627 step-06).
|
|
3
|
+
*
|
|
4
|
+
* JP's decision: no per-session heartbeat any more. An agent of this version never
|
|
5
|
+
* beats on the network; its machine's RUNNER vouches for it and relays, after each
|
|
6
|
+
* acknowledged beat, a local COVERAGE file (`runner_roster.ts`):
|
|
7
|
+
*
|
|
8
|
+
* <presenceDir>/<instance_id>.coverage.json
|
|
9
|
+
* { lease_active: true|false|null, beat_id, conversation_id, written_at }
|
|
10
|
+
*
|
|
11
|
+
* Every 15 s this module re-reads it (a local read, zero network) and decides:
|
|
12
|
+
*
|
|
13
|
+
* - fresh coverage, lease true → ACTIVE;
|
|
14
|
+
* - lease false → the conversation was taken over: the
|
|
15
|
+
* channel closes, as before (#400);
|
|
16
|
+
* - lease null → the existing tolerance: 3 indeterminate
|
|
17
|
+
* answers from 3 DISTINCT beats, then close;
|
|
18
|
+
* the same file read twice counts once;
|
|
19
|
+
* - coverage older than 120 s, absent,
|
|
20
|
+
* or for another conversation → PAUSED: nothing is sent, nothing is
|
|
21
|
+
* delivered, until a fresh coverage returns.
|
|
22
|
+
*
|
|
23
|
+
* Why 120 s (plan 627, table of delays): pause (120) + one reread (15) = 135 s <
|
|
24
|
+
* the 210 s lease — so an old holder has stopped writing before anyone else can
|
|
25
|
+
* take its conversation. The conversation writes carry no fencing token; this
|
|
26
|
+
* ordering IS the guard.
|
|
27
|
+
*
|
|
28
|
+
* An agent STARTS paused, until its first coverage. An agent in await-invite has no
|
|
29
|
+
* conversation: there is no lease to follow, only the runner's freshness.
|
|
30
|
+
*
|
|
31
|
+
* Freshness is judged on the LOCAL clock of the coverage write (`written_at`),
|
|
32
|
+
* never on the server's beat id.
|
|
33
|
+
*/
|
|
34
|
+
import { readFileSync } from "node:fs";
|
|
35
|
+
import { DEFAULT_MAX_INDETERMINATE_LEASE_BEATS } from "./heartbeat.js";
|
|
36
|
+
import { runnerStatePath, readRunnerState } from "./runner_registry.js";
|
|
37
|
+
import { coveragePath } from "./runner_roster.js";
|
|
38
|
+
|
|
39
|
+
export const COVERAGE_REREAD_MS = 15_000;
|
|
40
|
+
export const PAUSE_AFTER_MS = 120_000;
|
|
41
|
+
/** The #400 tolerance, ONE definition (Opus M3). */
|
|
42
|
+
export const MAX_INDETERMINATE_LEASES = DEFAULT_MAX_INDETERMINATE_LEASE_BEATS;
|
|
43
|
+
|
|
44
|
+
export const REASON_NO_RUNNER = "aucun runner connecté pour ce backend";
|
|
45
|
+
export const REASON_LEASE_UNCONFIRMED = "bail non confirmé par le runner";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The pause gate the channel consults before sending or delivering anything.
|
|
49
|
+
* `waitActive()` resolves at once when active; while paused it resolves on resume.
|
|
50
|
+
*/
|
|
51
|
+
/**
|
|
52
|
+
* #627 — the channel's pause decision when it may have NO instance identity. An
|
|
53
|
+
* agent the server gave no instance id has no presence record, so no runner can
|
|
54
|
+
* ever cover it: pausing it would be a silent, permanent wait. It then runs as
|
|
55
|
+
* before #627 (no pause, and no lease either), which the channel logs LOUDLY once.
|
|
56
|
+
* With an identity, the gate decides; before the gate exists, it is starting.
|
|
57
|
+
*/
|
|
58
|
+
export function channelPauseReason(identity: "pending" | "none" | PresenceGate): string | null {
|
|
59
|
+
if (identity === "none") return null;
|
|
60
|
+
if (identity === "pending") return REASON_STARTING;
|
|
61
|
+
return identity.reason();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const REASON_STARTING = "canal en démarrage";
|
|
65
|
+
|
|
66
|
+
export class PresenceGate {
|
|
67
|
+
private pauseReason: string | null;
|
|
68
|
+
private waiters: Array<() => void> = [];
|
|
69
|
+
|
|
70
|
+
/** Pending waiters (tests). */
|
|
71
|
+
pendingWaiters(): number {
|
|
72
|
+
return this.waiters.length;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
constructor(initialReason: string | null) {
|
|
76
|
+
this.pauseReason = initialReason;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** null when active; otherwise why the agent is paused. */
|
|
80
|
+
reason(): string | null {
|
|
81
|
+
return this.pauseReason;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
isPaused(): boolean {
|
|
85
|
+
return this.pauseReason !== null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Resolves when active — or when `signal` aborts, so a paused agent can still shut down. */
|
|
89
|
+
waitActive(signal?: AbortSignal): Promise<void> {
|
|
90
|
+
if (this.pauseReason === null || signal?.aborted) return Promise.resolve();
|
|
91
|
+
return new Promise((resolve) => {
|
|
92
|
+
// The abort listener is REMOVED on a normal resume, so repeated pauses do not
|
|
93
|
+
// pile listeners on one long-lived signal (Codex, step-06 review).
|
|
94
|
+
const onAbort = (): void => {
|
|
95
|
+
// An aborted waiter leaves the list too: streams stopped during a long pause
|
|
96
|
+
// must not pile up (Codex, step-06 review).
|
|
97
|
+
const i = this.waiters.indexOf(done);
|
|
98
|
+
if (i >= 0) this.waiters.splice(i, 1);
|
|
99
|
+
done();
|
|
100
|
+
};
|
|
101
|
+
const done = (): void => {
|
|
102
|
+
signal?.removeEventListener("abort", onAbort);
|
|
103
|
+
resolve();
|
|
104
|
+
};
|
|
105
|
+
this.waiters.push(done);
|
|
106
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
set(reason: string | null): void {
|
|
111
|
+
this.pauseReason = reason;
|
|
112
|
+
if (reason === null) for (const w of this.waiters.splice(0)) w();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface CoverageFile {
|
|
117
|
+
lease_active: boolean | null;
|
|
118
|
+
beat_id: string;
|
|
119
|
+
conversation_id: string | null;
|
|
120
|
+
written_at: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readCoverage(path: string): CoverageFile | null {
|
|
124
|
+
try {
|
|
125
|
+
const v = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
126
|
+
const lease = v.lease_active;
|
|
127
|
+
if (!(lease === true || lease === false || lease === null)) return null;
|
|
128
|
+
if (typeof v.beat_id !== "string" || v.beat_id === "") return null;
|
|
129
|
+
if (!(v.conversation_id === null || typeof v.conversation_id === "string")) return null;
|
|
130
|
+
if (typeof v.written_at !== "number") return null;
|
|
131
|
+
return { lease_active: lease, beat_id: v.beat_id, conversation_id: v.conversation_id, written_at: v.written_at };
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface CoverageWatchOptions {
|
|
138
|
+
identityBaseUrl: string;
|
|
139
|
+
instanceId: string;
|
|
140
|
+
/** Read on every tick: the agent may be re-bound to another conversation. */
|
|
141
|
+
getConversationId: () => string | null;
|
|
142
|
+
/** The conversation was definitely lost: close, exactly as the old heartbeat did. */
|
|
143
|
+
onLeaseLost: () => void;
|
|
144
|
+
lockDir?: string;
|
|
145
|
+
now?: () => number;
|
|
146
|
+
rereadMs?: number;
|
|
147
|
+
pauseAfterMs?: number;
|
|
148
|
+
maxIndeterminate?: number;
|
|
149
|
+
log?: (line: string) => void;
|
|
150
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
151
|
+
clearTimer?: (handle: unknown) => void;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface CoverageWatch {
|
|
155
|
+
gate: PresenceGate;
|
|
156
|
+
/** One evaluation, now (the timer calls it; tests too). */
|
|
157
|
+
tick: () => void;
|
|
158
|
+
stop: () => void;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function startCoverageWatch(opts: CoverageWatchOptions): CoverageWatch {
|
|
162
|
+
const now = opts.now ?? Date.now;
|
|
163
|
+
const pauseAfter = opts.pauseAfterMs ?? PAUSE_AFTER_MS;
|
|
164
|
+
const maxIndeterminate = opts.maxIndeterminate ?? MAX_INDETERMINATE_LEASES;
|
|
165
|
+
const log = opts.log ?? ((line: string) => process.stderr.write(line));
|
|
166
|
+
const setTimer =
|
|
167
|
+
opts.setTimer ??
|
|
168
|
+
((fn: () => void, ms: number) => {
|
|
169
|
+
const h = setInterval(fn, ms);
|
|
170
|
+
(h as { unref?: () => void }).unref?.();
|
|
171
|
+
return h as unknown;
|
|
172
|
+
});
|
|
173
|
+
const clearTimer = opts.clearTimer ?? ((h: unknown) => clearInterval(h as ReturnType<typeof setInterval>));
|
|
174
|
+
|
|
175
|
+
const gate = new PresenceGate(REASON_NO_RUNNER);
|
|
176
|
+
const covPath = coveragePath(opts.identityBaseUrl, opts.instanceId.toLowerCase(), opts.lockDir);
|
|
177
|
+
const runnerPath = runnerStatePath(opts.identityBaseUrl, opts.lockDir);
|
|
178
|
+
const countedNullBeats = new Set<string>();
|
|
179
|
+
let lost = false;
|
|
180
|
+
|
|
181
|
+
const pause = (reason: string): void => {
|
|
182
|
+
if (gate.reason() !== reason) log(`[presence] paused: ${reason}\n`);
|
|
183
|
+
gate.set(reason);
|
|
184
|
+
};
|
|
185
|
+
const resume = (): void => {
|
|
186
|
+
if (gate.isPaused()) log("[presence] resumed: covered by the runner\n");
|
|
187
|
+
gate.set(null);
|
|
188
|
+
};
|
|
189
|
+
const loseLease = (why: string): void => {
|
|
190
|
+
if (lost) return;
|
|
191
|
+
lost = true;
|
|
192
|
+
log(`[presence] conversation lease lost (${why}) — closing\n`);
|
|
193
|
+
gate.set(REASON_LEASE_UNCONFIRMED);
|
|
194
|
+
opts.onLeaseLost();
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const tick = (): void => {
|
|
198
|
+
if (lost) return;
|
|
199
|
+
const t = now();
|
|
200
|
+
const cov = readCoverage(covPath);
|
|
201
|
+
const fresh = cov !== null && t - cov.written_at <= pauseAfter;
|
|
202
|
+
if (!fresh) {
|
|
203
|
+
const runner = readRunnerState(runnerPath);
|
|
204
|
+
const runnerFresh = runner?.last_beat_ok_at !== null && runner !== null && t - (runner.last_beat_ok_at as number) <= pauseAfter;
|
|
205
|
+
pause(runnerFresh ? REASON_LEASE_UNCONFIRMED : REASON_NO_RUNNER);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const conversation = opts.getConversationId();
|
|
209
|
+
if (conversation === null) {
|
|
210
|
+
// await-invite: no lease to follow — the runner's word is enough.
|
|
211
|
+
resume();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (cov.conversation_id !== conversation) {
|
|
215
|
+
pause(REASON_LEASE_UNCONFIRMED); // a lease renewed for ANOTHER conversation proves nothing
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (cov.lease_active === false) {
|
|
219
|
+
loseLease("taken over");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (cov.lease_active === null) {
|
|
223
|
+
if (!countedNullBeats.has(cov.beat_id)) {
|
|
224
|
+
countedNullBeats.add(cov.beat_id);
|
|
225
|
+
if (countedNullBeats.size >= maxIndeterminate) {
|
|
226
|
+
loseLease(`${countedNullBeats.size} indeterminate beats`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
resume(); // tolerated, as the old heartbeat did
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
countedNullBeats.clear();
|
|
234
|
+
resume();
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
tick();
|
|
238
|
+
const timer = setTimer(tick, opts.rereadMs ?? COVERAGE_REREAD_MS);
|
|
239
|
+
return {
|
|
240
|
+
gate,
|
|
241
|
+
tick,
|
|
242
|
+
stop: () => clearTimer(timer),
|
|
243
|
+
};
|
|
244
|
+
}
|
package/lib/fingerprint.ts
CHANGED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hidden_proc.ts — the ONLY door to `node:child_process` and `Bun.spawn*` in this package (#638).
|
|
3
|
+
* vibe-master and viber-gateway re-export it, so there is ONE `hide()` for the three packages.
|
|
4
|
+
*
|
|
5
|
+
* The Viber app (`viber-gateway.exe`) is a GUI-subsystem binary: it has no console. On Windows a
|
|
6
|
+
* console child of a console-less parent gets a NEW, VISIBLE console unless the spawn passes
|
|
7
|
+
* `windowsHide: true`. The runner heartbeat (every 30 s) enumerates processes through PowerShell,
|
|
8
|
+
* so each beat flashed a window.
|
|
9
|
+
*
|
|
10
|
+
* Measured (#638 step-01, bun 1.3.14, compiled `--windows-hide-console` exe started through WMI):
|
|
11
|
+
*
|
|
12
|
+
* ```
|
|
13
|
+
* execFileSync / execSync / exec / Bun.spawnSync hide=false -> child console visible=True
|
|
14
|
+
* hide=true -> child hwnd=0 (NO console at all)
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* `hwnd=0` is a console WITHOUT a window (CREATE_NO_WINDOW), and it is inherited: the `exec` rows are
|
|
18
|
+
* already a GRANDCHILD (`cmd.exe /c powershell`) and read `hwnd=0` too. So hiding the first edge may be
|
|
19
|
+
* enough for console descendants — but not for a Bun/Node parent that spawns with its own flags.
|
|
20
|
+
* Measured again for a Bun parent (step-03): compiled GUI exe -> Bun console child spawned with
|
|
21
|
+
* hide=true -> its grandchild spawned WITHOUT hide reads `hwnd=0 visible=False`; child hide=false ->
|
|
22
|
+
* grandchild `visible=True`. So hiding the first edge is enough for console descendants.
|
|
23
|
+
* Every spawn still goes through this helper BY DECISION (plan 638, defence in depth): the next first
|
|
24
|
+
* edge is written by someone who has not read this.
|
|
25
|
+
* No-op outside Windows.
|
|
26
|
+
*/
|
|
27
|
+
import * as cp from "node:child_process";
|
|
28
|
+
|
|
29
|
+
/** Every option object that reaches `child_process` passes here. Exported for its witness. */
|
|
30
|
+
export function hide<T extends object>(opts: T | undefined): T & { windowsHide: true } {
|
|
31
|
+
return { ...(opts ?? ({} as T)), windowsHide: true };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Backend = Pick<typeof cp, "exec" | "execSync" | "execFile" | "execFileSync" | "spawn">;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `execFile` / `execFileSync` / `spawn` accept `args` OR options in 2nd place (and `execFile` a
|
|
38
|
+
* callback anywhere). Split them so a call without `args` still carries its options through `hide`.
|
|
39
|
+
*/
|
|
40
|
+
function splitArgs(rest: unknown[]): { args: readonly string[]; tail: unknown[] } {
|
|
41
|
+
return Array.isArray(rest[0]) ? { args: rest[0] as readonly string[], tail: rest.slice(1) } : { args: [], tail: rest };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function optsAndCallback(tail: unknown[]): { opts: object | undefined; cb: unknown } {
|
|
45
|
+
const cb = tail.find((t) => typeof t === "function");
|
|
46
|
+
const opts = tail.find((t) => t !== null && typeof t === "object") as object | undefined;
|
|
47
|
+
return { opts, cb };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The wrappers over a given backend. Exported so the witness can hand a recording fake (a
|
|
51
|
+
* `mock.module` of `node:child_process` leaks into every other test file of the run). */
|
|
52
|
+
export function hiddenOver(b: Backend): Backend {
|
|
53
|
+
return {
|
|
54
|
+
exec: ((command: string, ...rest: unknown[]) => {
|
|
55
|
+
const { opts, cb } = optsAndCallback(rest);
|
|
56
|
+
return b.exec(command, hide(opts as cp.ExecOptions | undefined), cb as Parameters<typeof cp.exec>[2]);
|
|
57
|
+
}) as typeof cp.exec,
|
|
58
|
+
execSync: ((command: string, options?: cp.ExecSyncOptions) => b.execSync(command, hide(options))) as typeof cp.execSync,
|
|
59
|
+
execFile: ((file: string, ...rest: unknown[]) => {
|
|
60
|
+
const { args, tail } = splitArgs(rest);
|
|
61
|
+
const { opts, cb } = optsAndCallback(tail);
|
|
62
|
+
return b.execFile(file, args, hide(opts as cp.ExecFileOptions | undefined), cb as Parameters<typeof cp.execFile>[3]);
|
|
63
|
+
}) as typeof cp.execFile,
|
|
64
|
+
execFileSync: ((file: string, ...rest: unknown[]) => {
|
|
65
|
+
const { args, tail } = splitArgs(rest);
|
|
66
|
+
return b.execFileSync(file, args, hide(tail[0] as cp.ExecFileSyncOptions | undefined));
|
|
67
|
+
}) as typeof cp.execFileSync,
|
|
68
|
+
spawn: ((command: string, ...rest: unknown[]) => {
|
|
69
|
+
const { args, tail } = splitArgs(rest);
|
|
70
|
+
return b.spawn(command, args, hide(tail[0] as cp.SpawnOptions | undefined));
|
|
71
|
+
}) as typeof cp.spawn,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const { exec, execSync, execFile, execFileSync, spawn } = hiddenOver(cp);
|
|
76
|
+
|
|
77
|
+
// ── Bun.spawn* (the channel, vibe-master and the gateway all run on Bun) ──
|
|
78
|
+
|
|
79
|
+
type BunSpawn = typeof Bun.spawn;
|
|
80
|
+
type BunSpawnSync = typeof Bun.spawnSync;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Both forms of `Bun.spawn*`: `(cmd[], opts)` and `({ cmd, ...opts })` — in the second the options live
|
|
84
|
+
* in the FIRST argument, so hiding only the second would silently leave it visible.
|
|
85
|
+
*/
|
|
86
|
+
function hiddenArgs(cmd: unknown, opts: unknown): [unknown, unknown?] {
|
|
87
|
+
return Array.isArray(cmd) ? [cmd, hide(opts as object | undefined)] : [hide(cmd as object)];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The wrappers over a given `Bun`. Exported so the witness can hand a recording fake. */
|
|
91
|
+
export function hiddenBunOver(b: { spawn: BunSpawn; spawnSync: BunSpawnSync }): { bunSpawn: BunSpawn; bunSpawnSync: BunSpawnSync } {
|
|
92
|
+
const call = (f: (...a: unknown[]) => unknown) => (cmd: unknown, opts?: unknown) => f(...hiddenArgs(cmd, opts));
|
|
93
|
+
return {
|
|
94
|
+
bunSpawn: call(b.spawn as (...a: unknown[]) => unknown) as BunSpawn,
|
|
95
|
+
bunSpawnSync: call(b.spawnSync as (...a: unknown[]) => unknown) as BunSpawnSync,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const { bunSpawn, bunSpawnSync } = hiddenBunOver(Bun);
|
package/lib/parent_watchdog.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* still alive". `signal 0` probes existence without delivering a signal.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { execSync } from "
|
|
23
|
+
import { execSync } from "./hidden_proc.js";
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Shim processes that sit BETWEEN the real agent (e.g. claude.exe) and us in the
|