@bridge4dev/runner 0.37.0 → 0.38.0
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/dist/agent-binary.d.ts +28 -0
- package/dist/agent-binary.js +99 -0
- package/dist/index.js +9 -2
- package/dist/self-update.js +49 -1
- package/dist/supervisor.d.ts +51 -3
- package/dist/supervisor.js +435 -244
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/ws-client.d.ts +28 -0
- package/dist/ws-client.js +33 -4
- package/package.json +1 -1
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The platform packages the SDK will look for, in its own order (`sdk.mjs`).
|
|
3
|
+
*
|
|
4
|
+
* BOTH linux variants are accepted rather than detecting musl the way the SDK
|
|
5
|
+
* does: the question here is «did the optional package get installed at all»,
|
|
6
|
+
* and answering it must never be the thing that blocks an update. A glibc
|
|
7
|
+
* binary on a musl host is a different failure — and since #225 it announces
|
|
8
|
+
* itself in the session feed instead of hiding.
|
|
9
|
+
*/
|
|
10
|
+
export declare function nativeCandidates(platform?: NodeJS.Platform, arch?: string): string[];
|
|
11
|
+
/**
|
|
12
|
+
* Absolute path to the Claude CLI inside an installed runner package, or null.
|
|
13
|
+
*
|
|
14
|
+
* Resolved from the SDK's own file, exactly like the SDK resolves it — npm may
|
|
15
|
+
* hoist the platform package next to the SDK, next to the runner, or several
|
|
16
|
+
* levels up, and only the module resolver knows which happened here.
|
|
17
|
+
*/
|
|
18
|
+
export declare function findClaudeCli(packageDir: string): string | null;
|
|
19
|
+
/**
|
|
20
|
+
* The same question about THIS process's own installation.
|
|
21
|
+
*
|
|
22
|
+
* Goes through the SDK's entry point rather than a path guess, so it answers
|
|
23
|
+
* correctly in every layout the runner runs in — a global npm install, a
|
|
24
|
+
* dedicated-user prefix, and the pnpm store of a source checkout, where the
|
|
25
|
+
* platform package is reachable from the SDK and from nowhere else.
|
|
26
|
+
*/
|
|
27
|
+
export declare function claudeCliPath(): string | null;
|
|
28
|
+
//# sourceMappingURL=agent-binary.d.ts.map
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Is the binary this runner would actually launch Claude with present?
|
|
6
|
+
*
|
|
7
|
+
* Not a hypothetical check (ticket #225, гоча #297). The Claude CLI does not
|
|
8
|
+
* live in this package: `@anthropic-ai/claude-agent-sdk` keeps it in a ~300 MB
|
|
9
|
+
* **optional** platform package, one per platform+arch. For npm the failure of
|
|
10
|
+
* an optional dependency is not an error — `npm install -g` exits 0, the runner
|
|
11
|
+
* reports a successful update and restarts into a build that cannot start a
|
|
12
|
+
* single Claude session. That is exactly what happened to transitway-dev-01 on
|
|
13
|
+
* 2026-08-11: every launch threw
|
|
14
|
+
* `Native CLI binary for linux-x64 not found` synchronously, before any event
|
|
15
|
+
* could be emitted, and a live session went silent for an hour and a half.
|
|
16
|
+
*
|
|
17
|
+
* The runner's own smoke test could not catch it: `devbridge-runner --version`
|
|
18
|
+
* loads the module graph, and the SDK resolves the platform binary lazily — on
|
|
19
|
+
* the first `query()`, which is a session, not a startup.
|
|
20
|
+
*/
|
|
21
|
+
const SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk';
|
|
22
|
+
/**
|
|
23
|
+
* The platform packages the SDK will look for, in its own order (`sdk.mjs`).
|
|
24
|
+
*
|
|
25
|
+
* BOTH linux variants are accepted rather than detecting musl the way the SDK
|
|
26
|
+
* does: the question here is «did the optional package get installed at all»,
|
|
27
|
+
* and answering it must never be the thing that blocks an update. A glibc
|
|
28
|
+
* binary on a musl host is a different failure — and since #225 it announces
|
|
29
|
+
* itself in the session feed instead of hiding.
|
|
30
|
+
*/
|
|
31
|
+
export function nativeCandidates(platform = process.platform, arch = process.arch) {
|
|
32
|
+
const exe = platform === 'win32' ? 'claude.exe' : 'claude';
|
|
33
|
+
const packages = platform === 'android'
|
|
34
|
+
? [`${SDK_PACKAGE}-linux-${arch}-android`]
|
|
35
|
+
: platform === 'linux'
|
|
36
|
+
? [`${SDK_PACKAGE}-linux-${arch}`, `${SDK_PACKAGE}-linux-${arch}-musl`]
|
|
37
|
+
: [`${SDK_PACKAGE}-${platform}-${arch}`];
|
|
38
|
+
return packages.map((name) => `${name}/${exe}`);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Absolute path to the Claude CLI inside an installed runner package, or null.
|
|
42
|
+
*
|
|
43
|
+
* Resolved from the SDK's own file, exactly like the SDK resolves it — npm may
|
|
44
|
+
* hoist the platform package next to the SDK, next to the runner, or several
|
|
45
|
+
* levels up, and only the module resolver knows which happened here.
|
|
46
|
+
*/
|
|
47
|
+
export function findClaudeCli(packageDir) {
|
|
48
|
+
const found = resolveFromSdkEntry(path.join(packageDir, 'node_modules', SDK_PACKAGE, 'sdk.mjs'));
|
|
49
|
+
// Inside THIS installation, or it does not count. Node's resolver walks up the
|
|
50
|
+
// directory tree and consults the global folders, so a package that is absent
|
|
51
|
+
// from the build we just installed can still be found in the one we are about
|
|
52
|
+
// to retire — and answering «present» from there is exactly the false green
|
|
53
|
+
// this check exists to prevent. A global npm install keeps its dependencies
|
|
54
|
+
// under its own package directory, so containment is also simply true.
|
|
55
|
+
return found && isInside(packageDir, found) ? found : null;
|
|
56
|
+
}
|
|
57
|
+
function isInside(dir, file) {
|
|
58
|
+
const relative = path.relative(path.resolve(dir), path.resolve(file));
|
|
59
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The same question about THIS process's own installation.
|
|
63
|
+
*
|
|
64
|
+
* Goes through the SDK's entry point rather than a path guess, so it answers
|
|
65
|
+
* correctly in every layout the runner runs in — a global npm install, a
|
|
66
|
+
* dedicated-user prefix, and the pnpm store of a source checkout, where the
|
|
67
|
+
* platform package is reachable from the SDK and from nowhere else.
|
|
68
|
+
*/
|
|
69
|
+
export function claudeCliPath() {
|
|
70
|
+
let sdkEntry;
|
|
71
|
+
try {
|
|
72
|
+
sdkEntry = createRequire(import.meta.url).resolve(SDK_PACKAGE);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return resolveFromSdkEntry(sdkEntry);
|
|
78
|
+
}
|
|
79
|
+
function resolveFromSdkEntry(sdkEntry) {
|
|
80
|
+
let resolve;
|
|
81
|
+
try {
|
|
82
|
+
resolve = createRequire(sdkEntry).resolve;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
for (const candidate of nativeCandidates()) {
|
|
88
|
+
try {
|
|
89
|
+
const resolved = resolve(candidate);
|
|
90
|
+
if (fs.existsSync(resolved))
|
|
91
|
+
return resolved;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Not installed under this name — try the next candidate.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=agent-binary.js.map
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { promisify } from 'node:util';
|
|
|
8
8
|
import { ClaudeAdapter } from './adapters/claude.js';
|
|
9
9
|
import { CodexAdapter } from './adapters/codex.js';
|
|
10
10
|
import { ensureCodexHome } from './adapters/codex-home.js';
|
|
11
|
+
import { claudeCliPath } from './agent-binary.js';
|
|
11
12
|
import { loadConfig, requireConfig, saveConfig } from './config.js';
|
|
12
13
|
import { log } from './log.js';
|
|
13
14
|
import { installIsWritable, installPrefixFor, isSupervisedProcess, manualUpdateCommand, resolveInstalledPackageDir, } from './self-update.js';
|
|
@@ -62,8 +63,14 @@ function argValue(args, flag) {
|
|
|
62
63
|
*/
|
|
63
64
|
function installedAgents() {
|
|
64
65
|
const agents = [];
|
|
65
|
-
// Claude
|
|
66
|
-
|
|
66
|
+
// Claude comes with the Agent SDK — but «comes with» is a claim about THIS
|
|
67
|
+
// installation, not a law (ticket #225). The CLI is an optional platform
|
|
68
|
+
// package, and an update that silently lost it leaves a runner that reports
|
|
69
|
+
// Claude, accepts Claude sessions, and cannot start a single one. Reported as
|
|
70
|
+
// measured, so the dashboard greys the agent out instead of offering a
|
|
71
|
+
// session that dies before its first word.
|
|
72
|
+
if (claudeCliPath())
|
|
73
|
+
agents.push('claude');
|
|
67
74
|
if (hasExecutable('codex'))
|
|
68
75
|
agents.push('codex');
|
|
69
76
|
return agents;
|
package/dist/self-update.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
+
import { findClaudeCli } from './agent-binary.js';
|
|
6
7
|
import { log } from './log.js';
|
|
7
8
|
import { stateDir } from './paths.js';
|
|
8
9
|
import { RUNNER_VERSION } from './version.js';
|
|
@@ -235,10 +236,19 @@ function installArgs(source, prefix) {
|
|
|
235
236
|
// a directory the daemon cannot write — and, on the rarer host where it can,
|
|
236
237
|
// npm cheerfully installs a SECOND copy somewhere the service does not exec,
|
|
237
238
|
// reports success, and the runner restarts on the old version forever.
|
|
239
|
+
//
|
|
240
|
+
// `--include=optional` is npm's default and is stated anyway (ticket #225):
|
|
241
|
+
// the Claude CLI ships as an OPTIONAL platform package, and a single
|
|
242
|
+
// `omit=optional` inherited from an `.npmrc`, an environment variable or a CI
|
|
243
|
+
// habit turns an update into a runner that cannot start a single Claude
|
|
244
|
+
// session — silently, because for npm a failed optional dependency is not a
|
|
245
|
+
// failure at all. The flag makes this deployment's intent explicit rather
|
|
246
|
+
// than dependent on whatever configuration the machine happens to carry.
|
|
238
247
|
return [
|
|
239
248
|
'install',
|
|
240
249
|
'-g',
|
|
241
250
|
'--ignore-scripts',
|
|
251
|
+
'--include=optional',
|
|
242
252
|
'--loglevel=error',
|
|
243
253
|
...(prefix ? ['--prefix', prefix] : []),
|
|
244
254
|
source,
|
|
@@ -259,7 +269,7 @@ export function manualUpdateCommand(tarballUrl, options = {}) {
|
|
|
259
269
|
const prefix = options.prefix === undefined ? installPrefixFor(packageDir) : options.prefix;
|
|
260
270
|
const user = options.user ?? os.userInfo().username;
|
|
261
271
|
const uid = options.uid ?? (typeof process.getuid === 'function' ? process.getuid() : -1);
|
|
262
|
-
const install = ['npm install -g --ignore-scripts --loglevel=error']
|
|
272
|
+
const install = ['npm install -g --ignore-scripts --include=optional --loglevel=error']
|
|
263
273
|
.concat(prefix ? [`--prefix ${prefix}`] : [])
|
|
264
274
|
.concat([tarballUrl])
|
|
265
275
|
.join(' ');
|
|
@@ -420,6 +430,44 @@ export async function selfUpdate(options) {
|
|
|
420
430
|
`Restore it on the server with: npm install -g${prefix ? ` --prefix ${prefix}` : ''} ${rollbackTarball}`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
|
|
421
431
|
}
|
|
422
432
|
}
|
|
433
|
+
// The binary the sessions will actually be started with (ticket #225).
|
|
434
|
+
//
|
|
435
|
+
// The probe above is not enough and 2026-08-11 proved it: `--version` loads
|
|
436
|
+
// the module graph, but the SDK resolves the Claude CLI lazily — on the first
|
|
437
|
+
// `query()`, i.e. inside a session, long after this update reported success.
|
|
438
|
+
// A missing OPTIONAL platform package therefore sailed through every check
|
|
439
|
+
// here, the daemon restarted, and the machine spent an hour and a half
|
|
440
|
+
// answering «продолжай» with nothing.
|
|
441
|
+
//
|
|
442
|
+
// One repair attempt first, because that is what the failure usually deserves:
|
|
443
|
+
// a 300 MB optional package that did not download is a network hiccup, not a
|
|
444
|
+
// broken release, and reinstalling it is cheaper for the user than a rollback.
|
|
445
|
+
if (!findClaudeCli(newPackageDir)) {
|
|
446
|
+
log.warn('self-update: the Claude CLI is missing from the new build — repairing', {
|
|
447
|
+
packageDir: newPackageDir,
|
|
448
|
+
});
|
|
449
|
+
try {
|
|
450
|
+
await installGlobal(exec, options.tarballUrl, prefix);
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
log.warn('self-update: the repair install failed', { error: describe(error) });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (!findClaudeCli(newPackageDir)) {
|
|
457
|
+
log.error('self-update: still no Claude CLI after the repair — rolling back', {
|
|
458
|
+
packageDir: newPackageDir,
|
|
459
|
+
});
|
|
460
|
+
const detail = 'the Claude CLI (an optional platform package of @anthropic-ai/claude-agent-sdk) did not install, ' +
|
|
461
|
+
'so no Claude session could have started on this build';
|
|
462
|
+
try {
|
|
463
|
+
await installGlobal(exec, rollbackTarball, prefix);
|
|
464
|
+
return fail(`The new version was not activated (${detail}). The previous version was restored and the runner keeps working.`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
|
|
465
|
+
}
|
|
466
|
+
catch (rollbackError) {
|
|
467
|
+
return fail(`The new version is broken (${detail}) and the rollback failed too (${describe(rollbackError)}). ` +
|
|
468
|
+
`Restore it on the server with: npm install -g --include=optional${prefix ? ` --prefix ${prefix}` : ''} ${rollbackTarball}`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
423
471
|
// The service unit may be pinned to a file inside the directory this update
|
|
424
472
|
// just replaced — early versions wrote the resolved script path, and a package
|
|
425
473
|
// rename moves it. Then the restart we are about to ask for would fail with
|
package/dist/supervisor.d.ts
CHANGED
|
@@ -19,6 +19,12 @@ export interface SupervisorOptions {
|
|
|
19
19
|
runnerToken?: string;
|
|
20
20
|
/** Test seam for the `self_update` command. */
|
|
21
21
|
selfUpdate?: typeof selfUpdate;
|
|
22
|
+
/**
|
|
23
|
+
* How long a freshly started agent process may say nothing before the session
|
|
24
|
+
* says so out loud (ticket #225). Test seam — the default is a minute, and a
|
|
25
|
+
* test that had to wait one would not be written.
|
|
26
|
+
*/
|
|
27
|
+
startupSilenceMs?: number;
|
|
22
28
|
/**
|
|
23
29
|
* Called after a successful update, once the reply is on the wire. The daemon
|
|
24
30
|
* exits here and systemd starts the new build; without a handler the runner
|
|
@@ -113,11 +119,53 @@ export declare class Supervisor {
|
|
|
113
119
|
* free CHAT session with no prompt at all (the agent boots, reports its
|
|
114
120
|
* capabilities and waits for the first message).
|
|
115
121
|
*
|
|
116
|
-
* Returns
|
|
117
|
-
*
|
|
118
|
-
*
|
|
122
|
+
* Returns what came of it: a caller holding a user message needs to know,
|
|
123
|
+
* because anything but `ok` means the message has to stay queued rather than
|
|
124
|
+
* be marked delivered (session 9).
|
|
125
|
+
*
|
|
126
|
+
* NEVER throws (ticket #225). Everything from reading the project prompt to
|
|
127
|
+
* the adapter's own constructor runs inside one guard, because the caller
|
|
128
|
+
* chain above cannot tell the difference between «did not start» and
|
|
129
|
+
* «threw»: the message path swallows the exception into a log line, and the
|
|
130
|
+
* reconnect path lets it abort the restore of every OTHER session on the
|
|
131
|
+
* machine. A launch that fails is a state this session reports, not an
|
|
132
|
+
* exception somebody else has to remember to catch.
|
|
119
133
|
*/
|
|
120
134
|
private launchAgent;
|
|
135
|
+
/**
|
|
136
|
+
* The agent process could not be started at all (ticket #225).
|
|
137
|
+
*
|
|
138
|
+
* Three things have to happen here, and until this ticket none of them did:
|
|
139
|
+
* the reason is said WHERE THE PERSON IS LOOKING (an `error` event is the
|
|
140
|
+
* feed's red line), the session stops claiming to be working, and the stack
|
|
141
|
+
* reaches journald for whoever has to fix the machine. The status is the
|
|
142
|
+
* honest one for a session with no process — the agent is not running, and a
|
|
143
|
+
* session left in `RUNNING` shows a stop button for a turn that does not
|
|
144
|
+
* exist.
|
|
145
|
+
*
|
|
146
|
+
* Never rethrows: this IS the handling. The caller gets `crashed` and decides
|
|
147
|
+
* what to do with the message it was holding.
|
|
148
|
+
*/
|
|
149
|
+
private launchCrashed;
|
|
150
|
+
/** How long a freshly started agent may say nothing before we say so. */
|
|
151
|
+
private static readonly STARTUP_SILENCE_MS;
|
|
152
|
+
/**
|
|
153
|
+
* Watch for the first word out of a process we just started (ticket #225).
|
|
154
|
+
*
|
|
155
|
+
* «The adapter object exists» is not «the agent is running». A CLI that hangs
|
|
156
|
+
* before its first frame — a stuck hook, an MCP server that never answers, a
|
|
157
|
+
* transcript it cannot read — produces no events, no error and no exit, and
|
|
158
|
+
* the session sits in `RUNNING` forever. On a healthy launch the first event
|
|
159
|
+
* arrives in about two seconds, so a minute of silence is not a slow start,
|
|
160
|
+
* it is something worth saying out loud.
|
|
161
|
+
*
|
|
162
|
+
* Says it and stops there: no kill. A long conversation has the right to boot
|
|
163
|
+
* slowly, and killing it would cost the person the very turn they are waiting
|
|
164
|
+
* for.
|
|
165
|
+
*/
|
|
166
|
+
private watchForFirstSignOfLife;
|
|
167
|
+
/** The process spoke, or went away — either way the watch is over. */
|
|
168
|
+
private clearStartupWatch;
|
|
121
169
|
/**
|
|
122
170
|
* The project's own prompt file, read fresh for THIS agent process.
|
|
123
171
|
*
|
package/dist/supervisor.js
CHANGED
|
@@ -51,6 +51,7 @@ function gitPolicyOf(descriptor) {
|
|
|
51
51
|
: {}),
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
|
+
const LAUNCH_REFUSED = { ok: false, reason: 'refused' };
|
|
54
55
|
export class Supervisor {
|
|
55
56
|
ws;
|
|
56
57
|
opts;
|
|
@@ -367,7 +368,11 @@ export class Supervisor {
|
|
|
367
368
|
await this.captureCheckpoint(running, 'TURN', 0);
|
|
368
369
|
if (this.isStale(running))
|
|
369
370
|
return;
|
|
370
|
-
|
|
371
|
+
// A launch that failed has already said so and reported a status the
|
|
372
|
+
// person can act on. Flushing the queue into it would only walk the same
|
|
373
|
+
// failure again, once per waiting message (ticket #225).
|
|
374
|
+
if (!this.launchAgent(running, composeInitialPrompt(descriptor), null).ok)
|
|
375
|
+
return;
|
|
371
376
|
}
|
|
372
377
|
else {
|
|
373
378
|
if (descriptor.epoch > 0) {
|
|
@@ -425,15 +430,23 @@ export class Supervisor {
|
|
|
425
430
|
* free CHAT session with no prompt at all (the agent boots, reports its
|
|
426
431
|
* capabilities and waits for the first message).
|
|
427
432
|
*
|
|
428
|
-
* Returns
|
|
429
|
-
*
|
|
430
|
-
*
|
|
433
|
+
* Returns what came of it: a caller holding a user message needs to know,
|
|
434
|
+
* because anything but `ok` means the message has to stay queued rather than
|
|
435
|
+
* be marked delivered (session 9).
|
|
436
|
+
*
|
|
437
|
+
* NEVER throws (ticket #225). Everything from reading the project prompt to
|
|
438
|
+
* the adapter's own constructor runs inside one guard, because the caller
|
|
439
|
+
* chain above cannot tell the difference between «did not start» and
|
|
440
|
+
* «threw»: the message path swallows the exception into a log line, and the
|
|
441
|
+
* reconnect path lets it abort the restore of every OTHER session on the
|
|
442
|
+
* machine. A launch that fails is a state this session reports, not an
|
|
443
|
+
* exception somebody else has to remember to catch.
|
|
431
444
|
*/
|
|
432
445
|
launchAgent(running, prompt, resumeId) {
|
|
433
446
|
const { descriptor } = running;
|
|
434
447
|
const adapter = this.opts.adapters[descriptor.agent];
|
|
435
448
|
if (!adapter || !running.worktreePath || !running.branch)
|
|
436
|
-
return
|
|
449
|
+
return LAUNCH_REFUSED;
|
|
437
450
|
// An exhausted USD budget must not relaunch $0.01-floor processes (QA-96 F4).
|
|
438
451
|
// Codex reports no cost at all, so its costUsd never leaves 0 — gating on it
|
|
439
452
|
// would be a limit that can never fire while the UI shows $0.00. Those
|
|
@@ -455,7 +468,7 @@ export class Supervisor {
|
|
|
455
468
|
errorMessage: `Session budget ($${descriptor.workspace.budgetUsd}) is exhausted`,
|
|
456
469
|
});
|
|
457
470
|
this.sessions.delete(descriptor.id);
|
|
458
|
-
return
|
|
471
|
+
return LAUNCH_REFUSED;
|
|
459
472
|
}
|
|
460
473
|
// The time budget is already spent: relaunching would burn a process for
|
|
461
474
|
// nothing and immediately stop again.
|
|
@@ -464,7 +477,7 @@ export class Supervisor {
|
|
|
464
477
|
level: 'warn',
|
|
465
478
|
text: 'The time budget is used up — press «Continue» to give the agent more time.',
|
|
466
479
|
});
|
|
467
|
-
return
|
|
480
|
+
return LAUNCH_REFUSED;
|
|
468
481
|
}
|
|
469
482
|
running.lastPrompt = prompt;
|
|
470
483
|
// Facts about this session only, plus the one file the project named. The
|
|
@@ -477,49 +490,60 @@ export class Supervisor {
|
|
|
477
490
|
// «loaded» nor «unchanged».
|
|
478
491
|
const rewinding = Boolean(running.rewindAnchor?.agentSession);
|
|
479
492
|
const resuming = rewinding || Boolean(resumeId);
|
|
480
|
-
|
|
481
|
-
|
|
493
|
+
// Held so the guard below can put it back: a rewind the person asked for
|
|
494
|
+
// must not be silently forgotten because the process that was going to
|
|
495
|
+
// apply it never started (ticket #225). The feed has already told them the
|
|
496
|
+
// conversation was cut.
|
|
482
497
|
const rewind = running.rewindAnchor;
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
? {}
|
|
501
|
-
:
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
? {
|
|
521
|
-
|
|
522
|
-
|
|
498
|
+
try {
|
|
499
|
+
const agentPrompt = this.resolveAgentPrompt(running, resuming && !rewinding);
|
|
500
|
+
const workspaceContext = composeWorkspaceContext(descriptor, agentPrompt?.text);
|
|
501
|
+
delete running.rewindAnchor;
|
|
502
|
+
// A rewind resumes the conversation the POINT names, which is not always
|
|
503
|
+
// the one this session is on now: rewinding twice, or rewinding the first
|
|
504
|
+
// message after a rewind, both reach back into the thread the fork came
|
|
505
|
+
// from. Its transcript is still on disk — that is what makes the point
|
|
506
|
+
// usable at all.
|
|
507
|
+
const resumeTarget = rewind ? rewind.agentSession : resumeId;
|
|
508
|
+
running.session = adapter.startSession({
|
|
509
|
+
sessionId: descriptor.id,
|
|
510
|
+
cwd: running.worktreePath,
|
|
511
|
+
...(prompt ? { prompt } : {}),
|
|
512
|
+
...(workspaceContext ? { workspaceContext } : {}),
|
|
513
|
+
// Only when it was actually read: layer 1 must refuse writes to the file
|
|
514
|
+
// this process was given, not to a path it was merely told about.
|
|
515
|
+
...(agentPrompt ? { agentPromptFile: agentPrompt.absPath } : {}),
|
|
516
|
+
trustMode: descriptor.workspace.trustMode,
|
|
517
|
+
...(descriptor.workspace.agentAutoCommit === undefined
|
|
518
|
+
? {}
|
|
519
|
+
: { agentAutoCommit: descriptor.workspace.agentAutoCommit }),
|
|
520
|
+
// Session 18. Always present, even when every field inside it is absent:
|
|
521
|
+
// an absent OBJECT and an object of absent fields resolve identically
|
|
522
|
+
// (`resolveGitPolicy` gives both the restrictive reading), and passing it
|
|
523
|
+
// unconditionally keeps one code path instead of two.
|
|
524
|
+
gitPolicy: gitPolicyOf(descriptor),
|
|
525
|
+
mode: running.mode,
|
|
526
|
+
...(running.model ? { model: running.model } : {}),
|
|
527
|
+
...(running.effort ? { effort: running.effort } : {}),
|
|
528
|
+
...(resumeTarget ? { resumeProviderSessionId: resumeTarget } : {}),
|
|
529
|
+
// Ticket #126: a conversation rewind takes effect exactly here, on the
|
|
530
|
+
// next process this session starts. Consumed rather than kept — a rewind
|
|
531
|
+
// is one event, not a standing setting, and re-applying it on a later
|
|
532
|
+
// relaunch would silently throw away everything said since.
|
|
533
|
+
...(rewind ? { resumeAtAnchor: rewind.anchor } : {}),
|
|
534
|
+
// Descriptor MCP (auto-issued per-workspace key) wins over config.toml.
|
|
535
|
+
...((descriptor.mcp ?? this.opts.mcp) ? { mcp: descriptor.mcp ?? this.opts.mcp } : {}),
|
|
536
|
+
// The SDK budget is per-process; hand the RESIDUAL session budget down.
|
|
537
|
+
...(reportsCost(descriptor.agent) && descriptor.workspace.budgetUsd !== null
|
|
538
|
+
? { maxBudgetUsd: Math.max(0.01, descriptor.workspace.budgetUsd - running.costBaseUsd) }
|
|
539
|
+
: {}),
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
catch (error) {
|
|
543
|
+
if (rewind)
|
|
544
|
+
running.rewindAnchor = rewind;
|
|
545
|
+
return this.launchCrashed(running, error);
|
|
546
|
+
}
|
|
523
547
|
// No prompt → nothing is running yet: the agent is up and waiting for the
|
|
524
548
|
// user's first message (free CHAT session). reportStatus drives the budget
|
|
525
549
|
// clock, so this call is also what starts (or does not start) billing.
|
|
@@ -527,6 +551,8 @@ export class Supervisor {
|
|
|
527
551
|
branch: running.branch,
|
|
528
552
|
worktreePath: running.worktreePath,
|
|
529
553
|
});
|
|
554
|
+
// The object exists; the PROCESS still has to prove it does (ticket #225).
|
|
555
|
+
this.watchForFirstSignOfLife(running);
|
|
530
556
|
// Ticket #196: a Stop or a pause that arrived while this process was coming
|
|
531
557
|
// up was dropped on the floor — `interruptSession` returns early when there
|
|
532
558
|
// is no adapter yet, and the window covers preparing the worktree and
|
|
@@ -541,7 +567,99 @@ export class Supervisor {
|
|
|
541
567
|
}));
|
|
542
568
|
}
|
|
543
569
|
void this.pumpEvents(running);
|
|
544
|
-
return true;
|
|
570
|
+
return { ok: true };
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* The agent process could not be started at all (ticket #225).
|
|
574
|
+
*
|
|
575
|
+
* Three things have to happen here, and until this ticket none of them did:
|
|
576
|
+
* the reason is said WHERE THE PERSON IS LOOKING (an `error` event is the
|
|
577
|
+
* feed's red line), the session stops claiming to be working, and the stack
|
|
578
|
+
* reaches journald for whoever has to fix the machine. The status is the
|
|
579
|
+
* honest one for a session with no process — the agent is not running, and a
|
|
580
|
+
* session left in `RUNNING` shows a stop button for a turn that does not
|
|
581
|
+
* exist.
|
|
582
|
+
*
|
|
583
|
+
* Never rethrows: this IS the handling. The caller gets `crashed` and decides
|
|
584
|
+
* what to do with the message it was holding.
|
|
585
|
+
*/
|
|
586
|
+
launchCrashed(running, error) {
|
|
587
|
+
const { descriptor } = running;
|
|
588
|
+
running.session = null;
|
|
589
|
+
log.error('supervisor: the agent process could not be started', {
|
|
590
|
+
sessionId: descriptor.id,
|
|
591
|
+
agent: descriptor.agent,
|
|
592
|
+
error: error instanceof Error ? (error.stack ?? error.message) : String(error),
|
|
593
|
+
});
|
|
594
|
+
const reason = maskSecretText(error);
|
|
595
|
+
this.sendEvent(running, 'error', {
|
|
596
|
+
message: `${AGENT_LABELS[descriptor.agent] ?? descriptor.agent} could not be started on this server: ${reason}`,
|
|
597
|
+
});
|
|
598
|
+
// A session that never began is FAILED — the same answer the neighbouring
|
|
599
|
+
// startup failures (no adapter, no worktree) already give. Everything else
|
|
600
|
+
// goes back to waiting for a human, which is the state «press Continue» is
|
|
601
|
+
// meaningful in; REVIEW keeps its row in the dashboard.
|
|
602
|
+
const status = running.lastReported === 'STARTING'
|
|
603
|
+
? 'FAILED'
|
|
604
|
+
: running.lastReported === 'REVIEW'
|
|
605
|
+
? 'REVIEW'
|
|
606
|
+
: 'WAITING_INPUT';
|
|
607
|
+
this.reportStatus(descriptor.id, status, {
|
|
608
|
+
costUsd: running.costUsd,
|
|
609
|
+
activeMs: running.activeMs,
|
|
610
|
+
errorMessage: `Agent process failed to start: ${reason}`,
|
|
611
|
+
});
|
|
612
|
+
// A FAILED session is over, and an entry left in the map would hold one of
|
|
613
|
+
// the runner's few slots for a process that never existed — `ensureCapacity`
|
|
614
|
+
// counts entries, not processes. Dropped exactly like the other startup
|
|
615
|
+
// failures do it. The parked states keep their entry on purpose: that is
|
|
616
|
+
// what «Продолжить» picks back up.
|
|
617
|
+
if (status === 'FAILED')
|
|
618
|
+
this.sessions.delete(descriptor.id);
|
|
619
|
+
return { ok: false, reason: 'crashed' };
|
|
620
|
+
}
|
|
621
|
+
/** How long a freshly started agent may say nothing before we say so. */
|
|
622
|
+
static STARTUP_SILENCE_MS = 60_000;
|
|
623
|
+
/**
|
|
624
|
+
* Watch for the first word out of a process we just started (ticket #225).
|
|
625
|
+
*
|
|
626
|
+
* «The adapter object exists» is not «the agent is running». A CLI that hangs
|
|
627
|
+
* before its first frame — a stuck hook, an MCP server that never answers, a
|
|
628
|
+
* transcript it cannot read — produces no events, no error and no exit, and
|
|
629
|
+
* the session sits in `RUNNING` forever. On a healthy launch the first event
|
|
630
|
+
* arrives in about two seconds, so a minute of silence is not a slow start,
|
|
631
|
+
* it is something worth saying out loud.
|
|
632
|
+
*
|
|
633
|
+
* Says it and stops there: no kill. A long conversation has the right to boot
|
|
634
|
+
* slowly, and killing it would cost the person the very turn they are waiting
|
|
635
|
+
* for.
|
|
636
|
+
*/
|
|
637
|
+
watchForFirstSignOfLife(running) {
|
|
638
|
+
this.clearStartupWatch(running);
|
|
639
|
+
running.heardFromAgent = false;
|
|
640
|
+
const silentMs = this.opts.startupSilenceMs ?? Supervisor.STARTUP_SILENCE_MS;
|
|
641
|
+
const timer = setTimeout(() => {
|
|
642
|
+
delete running.startupTimer;
|
|
643
|
+
if (running.heardFromAgent || !running.session || this.isStale(running))
|
|
644
|
+
return;
|
|
645
|
+
log.warn('supervisor: the agent process has said nothing since it started', {
|
|
646
|
+
sessionId: running.descriptor.id,
|
|
647
|
+
silentMs,
|
|
648
|
+
});
|
|
649
|
+
this.sendEvent(running, 'notice', {
|
|
650
|
+
level: 'warn',
|
|
651
|
+
text: 'The agent process started but has not said a word for a minute. It may still be loading a long conversation. If nothing happens, press «Stop» and then «Continue» — and if that does not help either, this server needs a look.',
|
|
652
|
+
});
|
|
653
|
+
}, silentMs);
|
|
654
|
+
timer.unref?.();
|
|
655
|
+
running.startupTimer = timer;
|
|
656
|
+
}
|
|
657
|
+
/** The process spoke, or went away — either way the watch is over. */
|
|
658
|
+
clearStartupWatch(running) {
|
|
659
|
+
if (running.startupTimer) {
|
|
660
|
+
clearTimeout(running.startupTimer);
|
|
661
|
+
delete running.startupTimer;
|
|
662
|
+
}
|
|
545
663
|
}
|
|
546
664
|
/**
|
|
547
665
|
* The project's own prompt file, read fresh for THIS agent process.
|
|
@@ -794,6 +912,9 @@ export class Supervisor {
|
|
|
794
912
|
delete running.activeSince;
|
|
795
913
|
}
|
|
796
914
|
this.clearBudgetTimers(running);
|
|
915
|
+
// The process this watch was armed for is gone; whatever it did or did not
|
|
916
|
+
// say, there is nothing left to wait for (ticket #225).
|
|
917
|
+
this.clearStartupWatch(running);
|
|
797
918
|
// Stale-resume recovery: relaunch once without a resume id.
|
|
798
919
|
if (running.freshRetry && !running.stopRequested) {
|
|
799
920
|
const { prompt } = running.freshRetry;
|
|
@@ -851,7 +972,8 @@ export class Supervisor {
|
|
|
851
972
|
this.sendEvent(running, 'settings', { mode });
|
|
852
973
|
// Empty prompt: the agent boots, reports its capabilities and waits, the
|
|
853
974
|
// same as a free CHAT session. It must NOT start a turn of its own here.
|
|
854
|
-
|
|
975
|
+
const relaunched = this.launchAgent(running, '', running.descriptor.providerSessionId);
|
|
976
|
+
if (relaunched.ok) {
|
|
855
977
|
// Anything typed during the park window is waiting on disk (see
|
|
856
978
|
// `deliverMessage`), and the new process is the one that can take it.
|
|
857
979
|
this.flushPendingMessages(running);
|
|
@@ -863,12 +985,17 @@ export class Supervisor {
|
|
|
863
985
|
}
|
|
864
986
|
return;
|
|
865
987
|
}
|
|
866
|
-
// The agent did not start — an exhausted budget
|
|
867
|
-
// session stays parked and resumable rather than
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
988
|
+
// The agent did not start — an exhausted budget, or a launch that crashed
|
|
989
|
+
// (ticket #225). The session stays parked and resumable rather than
|
|
990
|
+
// silently disappearing. A crash has already reported its own status and
|
|
991
|
+
// reason; re-reporting `statusForReport` here would overwrite them with
|
|
992
|
+
// the state the session was in before it failed.
|
|
993
|
+
if (relaunched.reason === 'refused') {
|
|
994
|
+
this.reportStatus(descriptor.id, statusForReport(running), {
|
|
995
|
+
costUsd: running.costUsd,
|
|
996
|
+
activeMs: running.activeMs,
|
|
997
|
+
});
|
|
998
|
+
}
|
|
872
999
|
this.drainSessionsWaitingForCapacity();
|
|
873
1000
|
return;
|
|
874
1001
|
}
|
|
@@ -1153,6 +1280,15 @@ export class Supervisor {
|
|
|
1153
1280
|
}
|
|
1154
1281
|
forwardEvent(running, event) {
|
|
1155
1282
|
const { descriptor } = running;
|
|
1283
|
+
// Ticket #225: ANY event is the process proving it came up — its own
|
|
1284
|
+
// capabilities probe answers within about two seconds of a healthy launch,
|
|
1285
|
+
// long before the agent says anything a person would read. That is a
|
|
1286
|
+
// deliberately weaker bar than «the agent is working» below: what this
|
|
1287
|
+
// watch is for is a CLI that never boots at all.
|
|
1288
|
+
if (!running.heardFromAgent) {
|
|
1289
|
+
running.heardFromAgent = true;
|
|
1290
|
+
this.clearStartupWatch(running);
|
|
1291
|
+
}
|
|
1156
1292
|
// Anything below that is the agent talking means the agent is working. Read
|
|
1157
1293
|
// before the switch so every such case gets it, including the ones added
|
|
1158
1294
|
// after this line was written.
|
|
@@ -1724,11 +1860,13 @@ export class Supervisor {
|
|
|
1724
1860
|
// A person typing into the session is the clearest signal that the work is
|
|
1725
1861
|
// back on track, so the automatic-continuation allowance starts over.
|
|
1726
1862
|
clearAutoResume(running.descriptor.id);
|
|
1727
|
-
if (this.launchAgent(running, text, running.descriptor.providerSessionId)) {
|
|
1863
|
+
if (this.launchAgent(running, text, running.descriptor.providerSessionId).ok) {
|
|
1728
1864
|
settle();
|
|
1729
1865
|
return;
|
|
1730
1866
|
}
|
|
1731
|
-
// The agent did not start (an exhausted budget
|
|
1867
|
+
// The agent did not start (an exhausted budget, or a launch that crashed —
|
|
1868
|
+
// ticket #225: it used to be swallowed into a log line, and the words the
|
|
1869
|
+
// person typed were retired from disk as though an agent had them). The
|
|
1732
1870
|
// instruction stays on disk, so «Продолжить» — which is what raises the
|
|
1733
1871
|
// budget — carries it to the agent instead of dropping it.
|
|
1734
1872
|
this.requeue(running, held, text, originSeq);
|
|
@@ -2167,210 +2305,263 @@ export class Supervisor {
|
|
|
2167
2305
|
}
|
|
2168
2306
|
}
|
|
2169
2307
|
// Redeliver unacked events for every persisted journal (at-least-once).
|
|
2308
|
+
//
|
|
2309
|
+
// Per file, because one that cannot be read must not cost the whole
|
|
2310
|
+
// reconnect (ticket #225): this runs BEFORE a single session is looked at,
|
|
2311
|
+
// so a throw here means nothing is restored at all — no launch, no status,
|
|
2312
|
+
// no note, on every reconnect for as long as the file stays broken.
|
|
2170
2313
|
for (const sessionId of this.journals.persistedSessionIds()) {
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2314
|
+
try {
|
|
2315
|
+
const journal = this.journals.open(sessionId);
|
|
2316
|
+
for (const event of journal.unacked()) {
|
|
2317
|
+
this.ws.send({
|
|
2318
|
+
type: 'event',
|
|
2319
|
+
sessionId,
|
|
2320
|
+
seq: event.seq,
|
|
2321
|
+
eventType: event.eventType,
|
|
2322
|
+
payload: event.payload,
|
|
2323
|
+
});
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
catch (error) {
|
|
2327
|
+
log.error('supervisor: journal could not be replayed', {
|
|
2175
2328
|
sessionId,
|
|
2176
|
-
|
|
2177
|
-
eventType: event.eventType,
|
|
2178
|
-
payload: event.payload,
|
|
2329
|
+
error: String(error),
|
|
2179
2330
|
});
|
|
2180
2331
|
}
|
|
2181
2332
|
}
|
|
2182
2333
|
for (const descriptor of descriptors) {
|
|
2183
|
-
//
|
|
2184
|
-
//
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
//
|
|
2191
|
-
//
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
// was down never arrived at all, because `session_pause` is
|
|
2203
|
-
// fire-and-forget with no outbox behind it.
|
|
2204
|
-
//
|
|
2205
|
-
// Both directions matter: the row may have gained a clock (hold now) or
|
|
2206
|
-
// lost one (release and send what was held). `applyPause` does both, and
|
|
2207
|
-
// it runs BEFORE `flushSessionOutbox` arrives from the API side.
|
|
2208
|
-
await this.applyPause(descriptor.id, descriptor.pausedUntil ?? null);
|
|
2209
|
-
this.reportStatus(descriptor.id, statusForReport(tracked), {
|
|
2210
|
-
costUsd: tracked.costUsd,
|
|
2211
|
-
...(tracked.branch ? { branch: tracked.branch } : {}),
|
|
2212
|
-
...(tracked.worktreePath ? { worktreePath: tracked.worktreePath } : {}),
|
|
2213
|
-
...(tracked.descriptor.providerSessionId
|
|
2214
|
-
? { providerSessionId: tracked.descriptor.providerSessionId }
|
|
2215
|
-
: {}),
|
|
2216
|
-
});
|
|
2217
|
-
continue;
|
|
2218
|
-
}
|
|
2219
|
-
// Session that went terminal while we were offline: the journal keeps
|
|
2220
|
-
// the last reported status — replay it instead of resurrecting the
|
|
2221
|
-
// session as resumable (QA-96 F1).
|
|
2222
|
-
if (this.journals.exists(descriptor.id)) {
|
|
2223
|
-
const journal = this.journals.open(descriptor.id);
|
|
2224
|
-
const last = journal.lastStatus;
|
|
2225
|
-
// Only replay a terminal status from THIS life of the session. A
|
|
2226
|
-
// resumed session carries a higher epoch, and replaying the FAILED it
|
|
2227
|
-
// was resumed from would kill it again the moment the runner reconnects.
|
|
2228
|
-
// A journal written by a runner from before session 13 could hold a
|
|
2229
|
-
// `DONE` — it is no longer a status this runner may report, and
|
|
2230
|
-
// replaying one would close the session for the user. Drop it and let
|
|
2231
|
-
// the session be picked back up like any other.
|
|
2232
|
-
if (last &&
|
|
2233
|
-
isTerminal(last.status) &&
|
|
2234
|
-
last.status !== 'DONE' &&
|
|
2235
|
-
(last.epoch ?? 0) >= descriptor.epoch) {
|
|
2236
|
-
this.ws.send({
|
|
2237
|
-
type: 'session_status',
|
|
2238
|
-
sessionId: descriptor.id,
|
|
2239
|
-
status: last.status,
|
|
2240
|
-
...(last.extra ?? {}),
|
|
2241
|
-
// Guarded above to be >= the descriptor's epoch, so the API keeps it.
|
|
2242
|
-
...(last.epoch === undefined ? {} : { epoch: last.epoch }),
|
|
2243
|
-
});
|
|
2244
|
-
if (journal.unacked().length === 0) {
|
|
2245
|
-
this.journals.closeAndDelete(descriptor.id);
|
|
2334
|
+
// One session must never cost the others their restore (ticket #225).
|
|
2335
|
+
// Everything below runs per descriptor, and until this guard existed a
|
|
2336
|
+
// single throw — a launch that could not start, a worktree that moved —
|
|
2337
|
+
// left the loop entirely: every session AFTER the failing one stayed
|
|
2338
|
+
// unrestored, on every reconnect, with nothing said anywhere. The blast
|
|
2339
|
+
// radius of a broken session is now that session.
|
|
2340
|
+
try {
|
|
2341
|
+
// Live local session: statuses are fire-and-forget on the wire, so a
|
|
2342
|
+
// status reached while the WS was down is re-reported here (QA-96 F1).
|
|
2343
|
+
const tracked = this.sessions.get(descriptor.id);
|
|
2344
|
+
if (tracked) {
|
|
2345
|
+
// The session was resumed server-side while this runner was offline, and
|
|
2346
|
+
// the local copy is that same work. Adopt the new epoch BEFORE reporting:
|
|
2347
|
+
// the API drops frames stamped with an older one, so keeping ours would
|
|
2348
|
+
// make every status this session ever sends invisible — it would sit in
|
|
2349
|
+
// "waiting" while the agent worked.
|
|
2350
|
+
if (descriptor.epoch > tracked.epoch) {
|
|
2351
|
+
tracked.epoch = descriptor.epoch;
|
|
2352
|
+
tracked.descriptor = { ...tracked.descriptor, epoch: descriptor.epoch };
|
|
2246
2353
|
}
|
|
2354
|
+
// Ticket #196, QA-149 MAJOR-1. The pause is re-established HERE, and
|
|
2355
|
+
// this is the case that matters most: a dropped socket leaves the agent
|
|
2356
|
+
// process running, so «reconnect» is precisely when a session is
|
|
2357
|
+
// `tracked`. The first cut of #196 read `pausedUntil` only in the two
|
|
2358
|
+
// constructors of a NEW `RunningSession`, which meant it survived a
|
|
2359
|
+
// runner RESTART and not a reconnect — and a pause set while the socket
|
|
2360
|
+
// was down never arrived at all, because `session_pause` is
|
|
2361
|
+
// fire-and-forget with no outbox behind it.
|
|
2362
|
+
//
|
|
2363
|
+
// Both directions matter: the row may have gained a clock (hold now) or
|
|
2364
|
+
// lost one (release and send what was held). `applyPause` does both, and
|
|
2365
|
+
// it runs BEFORE `flushSessionOutbox` arrives from the API side.
|
|
2366
|
+
await this.applyPause(descriptor.id, descriptor.pausedUntil ?? null);
|
|
2367
|
+
this.reportStatus(descriptor.id, statusForReport(tracked), {
|
|
2368
|
+
costUsd: tracked.costUsd,
|
|
2369
|
+
...(tracked.branch ? { branch: tracked.branch } : {}),
|
|
2370
|
+
...(tracked.worktreePath ? { worktreePath: tracked.worktreePath } : {}),
|
|
2371
|
+
...(tracked.descriptor.providerSessionId
|
|
2372
|
+
? { providerSessionId: tracked.descriptor.providerSessionId }
|
|
2373
|
+
: {}),
|
|
2374
|
+
});
|
|
2247
2375
|
continue;
|
|
2248
2376
|
}
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
session
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
lastPrompt: '',
|
|
2280
|
-
};
|
|
2281
|
-
running.journal.ensureSeqAbove(descriptor.lastSeq);
|
|
2282
|
-
// Anything the API handed us before the daemon stopped (session 9).
|
|
2283
|
-
running.pendingMessages.push(...running.journal.pending());
|
|
2284
|
-
this.sessions.set(descriptor.id, running);
|
|
2285
|
-
try {
|
|
2286
|
-
// A session being restored after a runner restart already has its
|
|
2287
|
-
// branch, so the API sends `CONTINUE` — the NEW guard inside would
|
|
2288
|
-
// otherwise fire on the runner's own previous work.
|
|
2289
|
-
const prepared = await this.prepareWorkspace(descriptor);
|
|
2290
|
-
running.branch = prepared.branch;
|
|
2291
|
-
running.worktreePath = prepared.worktreePath;
|
|
2292
|
-
if (prepared.baseSha)
|
|
2293
|
-
running.baseSha = prepared.baseSha;
|
|
2294
|
-
if (prepared.baseBranch)
|
|
2295
|
-
running.baseBranch = prepared.baseBranch;
|
|
2377
|
+
// Session that went terminal while we were offline: the journal keeps
|
|
2378
|
+
// the last reported status — replay it instead of resurrecting the
|
|
2379
|
+
// session as resumable (QA-96 F1).
|
|
2380
|
+
if (this.journals.exists(descriptor.id)) {
|
|
2381
|
+
const journal = this.journals.open(descriptor.id);
|
|
2382
|
+
const last = journal.lastStatus;
|
|
2383
|
+
// Only replay a terminal status from THIS life of the session. A
|
|
2384
|
+
// resumed session carries a higher epoch, and replaying the FAILED it
|
|
2385
|
+
// was resumed from would kill it again the moment the runner reconnects.
|
|
2386
|
+
// A journal written by a runner from before session 13 could hold a
|
|
2387
|
+
// `DONE` — it is no longer a status this runner may report, and
|
|
2388
|
+
// replaying one would close the session for the user. Drop it and let
|
|
2389
|
+
// the session be picked back up like any other.
|
|
2390
|
+
if (last &&
|
|
2391
|
+
isTerminal(last.status) &&
|
|
2392
|
+
last.status !== 'DONE' &&
|
|
2393
|
+
(last.epoch ?? 0) >= descriptor.epoch) {
|
|
2394
|
+
this.ws.send({
|
|
2395
|
+
type: 'session_status',
|
|
2396
|
+
sessionId: descriptor.id,
|
|
2397
|
+
status: last.status,
|
|
2398
|
+
...(last.extra ?? {}),
|
|
2399
|
+
// Guarded above to be >= the descriptor's epoch, so the API keeps it.
|
|
2400
|
+
...(last.epoch === undefined ? {} : { epoch: last.epoch }),
|
|
2401
|
+
});
|
|
2402
|
+
if (journal.unacked().length === 0) {
|
|
2403
|
+
this.journals.closeAndDelete(descriptor.id);
|
|
2404
|
+
}
|
|
2405
|
+
continue;
|
|
2406
|
+
}
|
|
2296
2407
|
}
|
|
2297
|
-
|
|
2298
|
-
this.
|
|
2299
|
-
errorMessage: `Failed to restore session worktree: ${String(error instanceof Error ? error.message : error).slice(0, 500)}`,
|
|
2300
|
-
});
|
|
2301
|
-
this.sessions.delete(descriptor.id);
|
|
2302
|
-
continue;
|
|
2408
|
+
if (descriptor.status === 'STARTING') {
|
|
2409
|
+
await this.startSession(descriptor);
|
|
2303
2410
|
}
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2411
|
+
else if (descriptor.providerSessionId) {
|
|
2412
|
+
// Runner restarted mid-session. The provider session is resumable —
|
|
2413
|
+
// park it until the user sends the next instruction. The map entry is
|
|
2414
|
+
// registered BEFORE the worktree await so a racing message is
|
|
2415
|
+
// buffered instead of dropped (QA-96 F3).
|
|
2416
|
+
const running = {
|
|
2417
|
+
descriptor,
|
|
2418
|
+
journal: this.journals.open(descriptor.id),
|
|
2419
|
+
session: null,
|
|
2420
|
+
lastReported: descriptor.status === 'REVIEW' ? 'REVIEW' : 'WAITING_INPUT',
|
|
2421
|
+
costUsd: descriptor.costUsd,
|
|
2422
|
+
costBaseUsd: descriptor.costUsd,
|
|
2423
|
+
stopRequested: false,
|
|
2424
|
+
parkRequested: false,
|
|
2425
|
+
pendingMessages: [],
|
|
2426
|
+
// Seed from the API, not 0: a runner restart used to hand the session
|
|
2427
|
+
// a full fresh budget silently.
|
|
2428
|
+
activeMs: descriptor.activeMsBase,
|
|
2429
|
+
extraBudgetMinutes: descriptor.extraBudgetMinutes,
|
|
2430
|
+
epoch: descriptor.epoch,
|
|
2431
|
+
openQuestions: new Set(),
|
|
2432
|
+
answeredAsks: new Set(),
|
|
2433
|
+
...pausedUntilOf(descriptor),
|
|
2434
|
+
mode: descriptor.mode,
|
|
2435
|
+
...(descriptor.model ? { model: descriptor.model } : {}),
|
|
2436
|
+
...(descriptor.effort ? { effort: descriptor.effort } : {}),
|
|
2437
|
+
lastPrompt: '',
|
|
2438
|
+
};
|
|
2439
|
+
running.journal.ensureSeqAbove(descriptor.lastSeq);
|
|
2440
|
+
// Anything the API handed us before the daemon stopped (session 9).
|
|
2441
|
+
running.pendingMessages.push(...running.journal.pending());
|
|
2442
|
+
this.sessions.set(descriptor.id, running);
|
|
2443
|
+
try {
|
|
2444
|
+
// A session being restored after a runner restart already has its
|
|
2445
|
+
// branch, so the API sends `CONTINUE` — the NEW guard inside would
|
|
2446
|
+
// otherwise fire on the runner's own previous work.
|
|
2447
|
+
const prepared = await this.prepareWorkspace(descriptor);
|
|
2448
|
+
running.branch = prepared.branch;
|
|
2449
|
+
running.worktreePath = prepared.worktreePath;
|
|
2450
|
+
if (prepared.baseSha)
|
|
2451
|
+
running.baseSha = prepared.baseSha;
|
|
2452
|
+
if (prepared.baseBranch)
|
|
2453
|
+
running.baseBranch = prepared.baseBranch;
|
|
2454
|
+
}
|
|
2455
|
+
catch (error) {
|
|
2456
|
+
this.reportStatus(descriptor.id, 'FAILED', {
|
|
2457
|
+
errorMessage: `Failed to restore session worktree: ${String(error instanceof Error ? error.message : error).slice(0, 500)}`,
|
|
2458
|
+
});
|
|
2459
|
+
this.sessions.delete(descriptor.id);
|
|
2349
2460
|
continue;
|
|
2350
2461
|
}
|
|
2351
|
-
|
|
2352
|
-
|
|
2462
|
+
/**
|
|
2463
|
+
* Was a turn actually in flight when the process died?
|
|
2464
|
+
*
|
|
2465
|
+
* `RUNNING` and `WAITING_PERMISSION` are the mid-turn statuses; the
|
|
2466
|
+
* others mean the agent was already waiting for a human, and there is
|
|
2467
|
+
* nothing to continue. REVIEW is deliberately excluded — the work is
|
|
2468
|
+
* finished and waiting to be looked at.
|
|
2469
|
+
*/
|
|
2470
|
+
const wasMidTurn = descriptor.status === 'RUNNING' || descriptor.status === 'WAITING_PERMISSION';
|
|
2471
|
+
const resumeId = descriptor.providerSessionId;
|
|
2472
|
+
// Ticket #177: `resumeId` is required, not merely nice to have. Without
|
|
2473
|
+
// it the relaunch starts a FRESH conversation, and `AUTO_RESUME_PROMPT`
|
|
2474
|
+
// — "continue from where you stopped, re-check what you were in the
|
|
2475
|
+
// middle of" — would be addressed to an agent that remembers none of
|
|
2476
|
+
// it. A process killed before it reported its session id (the SIGABRT
|
|
2477
|
+
// this ticket came from) leaves the row in exactly that state.
|
|
2478
|
+
// Ticket #196: a paused session is never continued automatically. The
|
|
2479
|
+
// row still says RUNNING — a pause interrupts the turn but is not a
|
|
2480
|
+
// status — so without this the reconnect would read «mid-turn» and
|
|
2481
|
+
// relaunch the agent with «continue from where you stopped», which is
|
|
2482
|
+
// the exact opposite of what the clock was set for.
|
|
2483
|
+
const willContinue = wasMidTurn &&
|
|
2484
|
+
!Supervisor.isPaused(running) &&
|
|
2485
|
+
Boolean(resumeId) &&
|
|
2486
|
+
claimAutoResume(descriptor.id);
|
|
2487
|
+
// The note stays either way (owner's call): an interruption is a fact
|
|
2488
|
+
// about the session and must not disappear just because we recovered
|
|
2489
|
+
// from it. Only the instruction at the end changes — telling someone to
|
|
2490
|
+
// send a message while the agent is already working again would be a lie.
|
|
2353
2491
|
this.sendEvent(running, 'system_note', {
|
|
2354
|
-
text:
|
|
2492
|
+
text: willContinue
|
|
2493
|
+
? 'Runner reconnected. The session was resumed — continuing the interrupted turn.'
|
|
2494
|
+
: resumeId
|
|
2495
|
+
? 'Runner reconnected. The session was resumed — send a message to continue.'
|
|
2496
|
+
: 'Runner reconnected, but the agent never got as far as naming its conversation, so it starts this one over. Your files, your branch and everything above are untouched.',
|
|
2355
2497
|
});
|
|
2498
|
+
if (willContinue) {
|
|
2499
|
+
// Resumed through the PROVIDER session, so the agent keeps its whole
|
|
2500
|
+
// conversation; the prompt is only the nudge a human would otherwise
|
|
2501
|
+
// have to type. Exactly what «продолжай» did by hand — no new class of
|
|
2502
|
+
// risk, and the same ceiling protects against a crash loop doing it
|
|
2503
|
+
// forever (see `auto-resume.ts`).
|
|
2504
|
+
const continued = this.launchAgent(running, AUTO_RESUME_PROMPT, resumeId);
|
|
2505
|
+
if (continued.ok) {
|
|
2506
|
+
this.reportStatus(descriptor.id, 'RUNNING', {});
|
|
2507
|
+
this.flushPendingMessages(running);
|
|
2508
|
+
continue;
|
|
2509
|
+
}
|
|
2510
|
+
// Could not start — an exhausted budget, or a launch that crashed
|
|
2511
|
+
// (ticket #225: this is the exact line the incident died on, and the
|
|
2512
|
+
// throw took the WHOLE restore loop with it). Fall through to the old
|
|
2513
|
+
// behaviour and say so honestly; a crash has already put its own
|
|
2514
|
+
// reason in the feed, so this note would only repeat it.
|
|
2515
|
+
if (continued.reason === 'refused') {
|
|
2516
|
+
this.sendEvent(running, 'system_note', {
|
|
2517
|
+
text: 'Could not continue automatically — send a message to pick the work back up.',
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
// REVIEW stays REVIEW (WAITING_INPUT is not reachable from it);
|
|
2522
|
+
// mid-turn statuses are downgraded to "waiting for the user".
|
|
2523
|
+
if (descriptor.status !== 'REVIEW') {
|
|
2524
|
+
this.reportStatus(descriptor.id, 'WAITING_INPUT', {});
|
|
2525
|
+
}
|
|
2526
|
+
this.flushPendingMessages(running);
|
|
2356
2527
|
}
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2528
|
+
else if (descriptor.status === 'WAITING_INPUT') {
|
|
2529
|
+
// A session that never had a turn (a free session still waiting for
|
|
2530
|
+
// its first message) has nothing to resume — just bring the agent back
|
|
2531
|
+
// up and keep waiting, instead of failing the session.
|
|
2532
|
+
await this.startSession({ ...descriptor, status: 'STARTING', prompt: '' });
|
|
2533
|
+
}
|
|
2534
|
+
else {
|
|
2535
|
+
this.reportStatus(descriptor.id, 'FAILED', {
|
|
2536
|
+
errorMessage: 'Runner restarted and this session cannot be resumed',
|
|
2537
|
+
});
|
|
2361
2538
|
}
|
|
2362
|
-
this.flushPendingMessages(running);
|
|
2363
|
-
}
|
|
2364
|
-
else if (descriptor.status === 'WAITING_INPUT') {
|
|
2365
|
-
// A session that never had a turn (a free session still waiting for
|
|
2366
|
-
// its first message) has nothing to resume — just bring the agent back
|
|
2367
|
-
// up and keep waiting, instead of failing the session.
|
|
2368
|
-
await this.startSession({ ...descriptor, status: 'STARTING', prompt: '' });
|
|
2369
2539
|
}
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2540
|
+
catch (error) {
|
|
2541
|
+
log.error('supervisor: session could not be restored after reconnect', {
|
|
2542
|
+
sessionId: descriptor.id,
|
|
2543
|
+
error: error instanceof Error ? (error.stack ?? error.message) : String(error),
|
|
2373
2544
|
});
|
|
2545
|
+
// Said on the session it belongs to, not only in journald. FAILED is
|
|
2546
|
+
// the honest word: this runner is not going to run it as things stand,
|
|
2547
|
+
// and «Продолжить» is what asks it to try again.
|
|
2548
|
+
//
|
|
2549
|
+
// Guarded in turn, and not out of superstition: reporting a status
|
|
2550
|
+
// WRITES to that session's journal, so the most likely reason the body
|
|
2551
|
+
// above failed — this session's own file — would fail the handler the
|
|
2552
|
+
// same way and take the loop with it after all. The other sessions
|
|
2553
|
+
// matter more than this one's status frame.
|
|
2554
|
+
try {
|
|
2555
|
+
this.reportStatus(descriptor.id, 'FAILED', {
|
|
2556
|
+
errorMessage: `Runner could not restore this session: ${maskSecretText(error)}`,
|
|
2557
|
+
});
|
|
2558
|
+
}
|
|
2559
|
+
catch (reportError) {
|
|
2560
|
+
log.error('supervisor: could not even report the failed restore', {
|
|
2561
|
+
sessionId: descriptor.id,
|
|
2562
|
+
error: String(reportError),
|
|
2563
|
+
});
|
|
2564
|
+
}
|
|
2374
2565
|
}
|
|
2375
2566
|
}
|
|
2376
2567
|
// Redelivery is done — anything still on disk from long-finished sessions
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const RUNNER_VERSION = "0.
|
|
1
|
+
export declare const RUNNER_VERSION = "0.38.0";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
package/dist/ws-client.d.ts
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
import { type GatewayFrame, type RunnerFrame } from './protocol.js';
|
|
2
|
+
/**
|
|
3
|
+
* Mirror of `RUNNER_LIVENESS_*` in `@devbridge/shared` — the DevBridge side is the
|
|
4
|
+
* source of truth, exactly like `protocol.ts` mirrors the API's wire types.
|
|
5
|
+
* Copied rather than imported because this package is published to npm on its own
|
|
6
|
+
* (see the note in `recipe-schema.ts`).
|
|
7
|
+
*
|
|
8
|
+
* No inbound traffic for this long means the path is half-dead even though the
|
|
9
|
+
* socket still looks OPEN (QA-96 F16). The threshold has to stay comfortably
|
|
10
|
+
* BELOW the gateway's own budget (`RUNNER_GATEWAY_BUDGET_MS`, 60 s): a re-connect
|
|
11
|
+
* that lands before the gateway gives up is free — it replaces the old socket and
|
|
12
|
+
* the machine never leaves ONLINE — while one that lands after costs the user a
|
|
13
|
+
* «server is offline» banner over a working machine (QA-162 F2).
|
|
14
|
+
*
|
|
15
|
+
* It used to say «the gateway pings every 30s» and wait 90 s. The gateway has
|
|
16
|
+
* pinged every 15 s since devreport #117, so that 90 s had quietly become six
|
|
17
|
+
* missed pings instead of the three it was written for, and the runner sat blind
|
|
18
|
+
* for 90–120 s on every drop. Both ends now read one set of numbers.
|
|
19
|
+
*/
|
|
20
|
+
export declare const LIVENESS_TIMEOUT_MS = 35000;
|
|
21
|
+
export declare const LIVENESS_CHECK_MS = 5000;
|
|
22
|
+
/** Kept in step with `RUNNER_GATEWAY_BUDGET_MS`; asserted by `ws-liveness.test.ts`. */
|
|
23
|
+
export declare const GATEWAY_BUDGET_MS = 60000;
|
|
24
|
+
/**
|
|
25
|
+
* Worst-case cost of acting on the threshold: the check granularity, the first
|
|
26
|
+
* backoff step with its jitter, and a TLS+upgrade handshake. The invariant test
|
|
27
|
+
* uses it, because «the runner re-dials first» is only true with this included.
|
|
28
|
+
*/
|
|
29
|
+
export declare const RECONNECT_COST_MS: number;
|
|
2
30
|
export interface WsClientEvents {
|
|
3
31
|
frame: (frame: GatewayFrame) => void;
|
|
4
32
|
open: () => void;
|
package/dist/ws-client.js
CHANGED
|
@@ -9,10 +9,34 @@ import { RUNNER_VERSION } from './version.js';
|
|
|
9
9
|
const BACKOFF_BASE_MS = 1_000;
|
|
10
10
|
const BACKOFF_MAX_MS = 60_000;
|
|
11
11
|
const REPLACED_COOLDOWN_MS = 60_000;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Mirror of `RUNNER_LIVENESS_*` in `@devbridge/shared` — the DevBridge side is the
|
|
14
|
+
* source of truth, exactly like `protocol.ts` mirrors the API's wire types.
|
|
15
|
+
* Copied rather than imported because this package is published to npm on its own
|
|
16
|
+
* (see the note in `recipe-schema.ts`).
|
|
17
|
+
*
|
|
18
|
+
* No inbound traffic for this long means the path is half-dead even though the
|
|
19
|
+
* socket still looks OPEN (QA-96 F16). The threshold has to stay comfortably
|
|
20
|
+
* BELOW the gateway's own budget (`RUNNER_GATEWAY_BUDGET_MS`, 60 s): a re-connect
|
|
21
|
+
* that lands before the gateway gives up is free — it replaces the old socket and
|
|
22
|
+
* the machine never leaves ONLINE — while one that lands after costs the user a
|
|
23
|
+
* «server is offline» banner over a working machine (QA-162 F2).
|
|
24
|
+
*
|
|
25
|
+
* It used to say «the gateway pings every 30s» and wait 90 s. The gateway has
|
|
26
|
+
* pinged every 15 s since devreport #117, so that 90 s had quietly become six
|
|
27
|
+
* missed pings instead of the three it was written for, and the runner sat blind
|
|
28
|
+
* for 90–120 s on every drop. Both ends now read one set of numbers.
|
|
29
|
+
*/
|
|
30
|
+
export const LIVENESS_TIMEOUT_MS = 35_000;
|
|
31
|
+
export const LIVENESS_CHECK_MS = 5_000;
|
|
32
|
+
/** Kept in step with `RUNNER_GATEWAY_BUDGET_MS`; asserted by `ws-liveness.test.ts`. */
|
|
33
|
+
export const GATEWAY_BUDGET_MS = 60_000;
|
|
34
|
+
/**
|
|
35
|
+
* Worst-case cost of acting on the threshold: the check granularity, the first
|
|
36
|
+
* backoff step with its jitter, and a TLS+upgrade handshake. The invariant test
|
|
37
|
+
* uses it, because «the runner re-dials first» is only true with this included.
|
|
38
|
+
*/
|
|
39
|
+
export const RECONNECT_COST_MS = LIVENESS_CHECK_MS + BACKOFF_BASE_MS + 1_000 + 2_000;
|
|
16
40
|
// Repeated HTTP 401/403 on the upgrade = bad token; slow way down.
|
|
17
41
|
const AUTH_FAILURE_THRESHOLD = 5;
|
|
18
42
|
const AUTH_FAILURE_COOLDOWN_MS = 10 * 60_000;
|
|
@@ -86,12 +110,17 @@ export class RunnerWsClient {
|
|
|
86
110
|
if (this.stopped)
|
|
87
111
|
return;
|
|
88
112
|
log.info('ws: connecting', { url: this.wsUrl, attempt: this.attempts + 1 });
|
|
113
|
+
const dialedAt = Date.now();
|
|
89
114
|
const socket = new WebSocket(this.wsUrl, {
|
|
90
115
|
headers: { Authorization: `Bearer ${this.token}` },
|
|
91
116
|
handshakeTimeout: 15_000,
|
|
92
117
|
});
|
|
93
118
|
this.socket = socket;
|
|
94
119
|
socket.on('open', () => {
|
|
120
|
+
// Success used to be the only outcome that logged NOTHING, so «connected»
|
|
121
|
+
// and «hung mid-handshake» read identically in the journal — which is
|
|
122
|
+
// exactly where an hour went during QA-162. One line ends that.
|
|
123
|
+
log.info('ws: connected', { attempts: this.attempts + 1, elapsedMs: Date.now() - dialedAt });
|
|
95
124
|
this.attempts = 0;
|
|
96
125
|
this.authFailures = 0;
|
|
97
126
|
this.lastInboundAt = Date.now();
|
package/package.json
CHANGED