@bridge4dev/runner 0.59.1 → 0.61.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/adapters/claude.js +197 -21
- package/dist/adapters/codex.js +22 -2
- package/dist/adapters/questions.js +1 -0
- package/dist/adapters/types.d.ts +42 -1
- package/dist/agent-binary.d.ts +45 -4
- package/dist/agent-binary.js +53 -4
- package/dist/attachments.d.ts +6 -0
- package/dist/attachments.js +162 -11
- package/dist/commit-message.js +8 -1
- package/dist/index.js +59 -2
- package/dist/journal.d.ts +40 -0
- package/dist/journal.js +82 -0
- package/dist/protocol.d.ts +52 -0
- package/dist/protocol.js +14 -0
- package/dist/self-update.d.ts +22 -0
- package/dist/self-update.js +22 -0
- package/dist/supervisor.d.ts +123 -2
- package/dist/supervisor.js +598 -91
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/attachments.js
CHANGED
|
@@ -44,6 +44,105 @@ const MAX_ATTACHMENT_BYTES = 51 * 1024 * 1024;
|
|
|
44
44
|
* generous is nil: a stalled download fails the same way, just later.
|
|
45
45
|
*/
|
|
46
46
|
const DOWNLOAD_TIMEOUT_MS = 180_000;
|
|
47
|
+
/**
|
|
48
|
+
* How many times one file is fetched before the message goes without it (#407).
|
|
49
|
+
*
|
|
50
|
+
* One attempt was the whole defect: on 13.09.2026 a 656 KB screenshot died on
|
|
51
|
+
* `ECONNRESET` half-way through the body — the API had answered 200 and the
|
|
52
|
+
* proxy had written all 656 604 bytes — and the person got a line in the feed
|
|
53
|
+
* instead of their screenshot. Re-fetching the same file from the same machine
|
|
54
|
+
* reproduced it about 5 times in 65, so the second attempt is very nearly free
|
|
55
|
+
* and the third is the one that covers a bad minute.
|
|
56
|
+
*
|
|
57
|
+
* Three and not more because of the far end: `RATE_LIMIT.DEV_RUNNER_FILE` is 60
|
|
58
|
+
* requests a minute per runner and a message carries at most
|
|
59
|
+
* `DEV_SESSION_MESSAGE_ATTACHMENT_CAP` = 5 files, so the worst message on this
|
|
60
|
+
* path costs 15 — a quarter of the budget, with the rest left for the other
|
|
61
|
+
* sessions on the machine.
|
|
62
|
+
*/
|
|
63
|
+
const DOWNLOAD_ATTEMPTS = 3;
|
|
64
|
+
/**
|
|
65
|
+
* What to wait before attempt 2 and before attempt 3.
|
|
66
|
+
*
|
|
67
|
+
* Short on purpose: a person is watching a message they just sent, and the
|
|
68
|
+
* fault this retries is a dropped connection rather than a busy server — the
|
|
69
|
+
* one case that genuinely needs a long wait (429) brings its own number below.
|
|
70
|
+
*/
|
|
71
|
+
const RETRY_PAUSES_MS = [1_000, 3_000];
|
|
72
|
+
/**
|
|
73
|
+
* The longest a `Retry-After` may hold up one file.
|
|
74
|
+
*
|
|
75
|
+
* The header is honoured because a 429 is the far end asking for room, and
|
|
76
|
+
* capped because it is allowed to say «600» — which would spend the whole
|
|
77
|
+
* budget below on waiting and deliver the message without the file anyway.
|
|
78
|
+
*/
|
|
79
|
+
const MAX_RETRY_AFTER_MS = 10_000;
|
|
80
|
+
/** `Retry-After` in seconds or as an HTTP date, clamped — or nothing usable. */
|
|
81
|
+
function retryAfterMs(response, now) {
|
|
82
|
+
const header = response.headers.get('retry-after');
|
|
83
|
+
if (!header)
|
|
84
|
+
return undefined;
|
|
85
|
+
const seconds = Number(header.trim());
|
|
86
|
+
const ms = Number.isFinite(seconds)
|
|
87
|
+
? seconds * 1_000
|
|
88
|
+
: Number.isNaN(Date.parse(header))
|
|
89
|
+
? NaN
|
|
90
|
+
: Date.parse(header) - now;
|
|
91
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
92
|
+
return undefined;
|
|
93
|
+
return Math.min(ms, MAX_RETRY_AFTER_MS);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Fetch one attachment once, and say whether the failure is worth repeating.
|
|
97
|
+
*
|
|
98
|
+
* Retried: anything `fetch` throws (a refused connection, a reset, a timeout)
|
|
99
|
+
* and anything thrown while reading the body — the incident was in the body, not
|
|
100
|
+
* in the response — plus 5xx and 429, which are the far end saying «not now».
|
|
101
|
+
*
|
|
102
|
+
* Not retried: every other 4xx (the file is gone, or this token may not have
|
|
103
|
+
* it), and a file over the ceiling, which will be over it again.
|
|
104
|
+
*/
|
|
105
|
+
async function fetchAttachmentOnce(doFetch, url, token, budgetMs) {
|
|
106
|
+
let response;
|
|
107
|
+
try {
|
|
108
|
+
response = await doFetch(url, {
|
|
109
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
110
|
+
signal: AbortSignal.timeout(budgetMs),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
return { ok: false, retry: true, reason: String(error) };
|
|
115
|
+
}
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
const retry = response.status >= 500 || response.status === 429;
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
retry,
|
|
121
|
+
reason: `HTTP ${response.status}`,
|
|
122
|
+
...(response.status === 429
|
|
123
|
+
? (() => {
|
|
124
|
+
const after = retryAfterMs(response, Date.now());
|
|
125
|
+
return after === undefined ? {} : { retryAfterMs: after };
|
|
126
|
+
})()
|
|
127
|
+
: {}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
let buffer;
|
|
131
|
+
try {
|
|
132
|
+
buffer = Buffer.from(await response.arrayBuffer());
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
return { ok: false, retry: true, reason: String(error) };
|
|
136
|
+
}
|
|
137
|
+
if (buffer.length > MAX_ATTACHMENT_BYTES) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
retry: false,
|
|
141
|
+
reason: `file is larger than ${Math.round(MAX_ATTACHMENT_BYTES / 1024 / 1024)}MB`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return { ok: true, buffer };
|
|
145
|
+
}
|
|
47
146
|
/**
|
|
48
147
|
* A file name that is safe to write and unambiguous to read.
|
|
49
148
|
*
|
|
@@ -134,9 +233,18 @@ export async function ensureGitExclude(worktreePath) {
|
|
|
134
233
|
* Returns what actually landed — a file that could not be fetched is reported
|
|
135
234
|
* and skipped rather than failing the whole message, because the text the user
|
|
136
235
|
* typed is usually still worth delivering.
|
|
236
|
+
*
|
|
237
|
+
* Each file gets up to `DOWNLOAD_ATTEMPTS` goes inside ONE `DOWNLOAD_TIMEOUT_MS`
|
|
238
|
+
* budget (#407): the ceiling on how long a person waits for their own message is
|
|
239
|
+
* unchanged, and the attempts share it rather than each getting their own.
|
|
137
240
|
*/
|
|
138
241
|
export async function saveAttachments(input) {
|
|
139
242
|
const doFetch = input.fetchImpl ?? fetch;
|
|
243
|
+
const sleep = input.sleepImpl ??
|
|
244
|
+
((ms) => new Promise((resolve) => {
|
|
245
|
+
const timer = setTimeout(resolve, ms);
|
|
246
|
+
timer.unref?.();
|
|
247
|
+
}));
|
|
140
248
|
const dir = path.join(input.worktreePath, ATTACHMENT_DIR);
|
|
141
249
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
142
250
|
await ensureGitExclude(input.worktreePath);
|
|
@@ -144,18 +252,59 @@ export async function saveAttachments(input) {
|
|
|
144
252
|
const failed = [];
|
|
145
253
|
const base = input.apiUrl.replace(/\/$/, '');
|
|
146
254
|
for (const attachment of input.attachments) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
255
|
+
const url = `${base}/api/v1/dev/runner/attachments/${encodeURIComponent(attachment.id)}`;
|
|
256
|
+
const deadline = Date.now() + DOWNLOAD_TIMEOUT_MS;
|
|
257
|
+
let buffer = null;
|
|
258
|
+
let lastReason = 'unknown';
|
|
259
|
+
/** What actually happened, for the summary line — not the ceiling. */
|
|
260
|
+
let made = 0;
|
|
261
|
+
for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt++) {
|
|
262
|
+
const budget = deadline - Date.now();
|
|
263
|
+
if (budget <= 0) {
|
|
264
|
+
lastReason = 'the time allowed for this file ran out';
|
|
265
|
+
break;
|
|
154
266
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
267
|
+
made += 1;
|
|
268
|
+
const outcome = await fetchAttachmentOnce(doFetch, url, input.token, budget);
|
|
269
|
+
if (outcome.ok) {
|
|
270
|
+
buffer = outcome.buffer;
|
|
271
|
+
break;
|
|
158
272
|
}
|
|
273
|
+
lastReason = outcome.reason;
|
|
274
|
+
// Every attempt is in the log with its number, so «it worked the second
|
|
275
|
+
// time» is visible afterwards rather than being a silent success.
|
|
276
|
+
log.warn('attachments: download attempt failed', {
|
|
277
|
+
attachmentId: attachment.id,
|
|
278
|
+
attempt,
|
|
279
|
+
of: DOWNLOAD_ATTEMPTS,
|
|
280
|
+
willRetry: outcome.retry && attempt < DOWNLOAD_ATTEMPTS,
|
|
281
|
+
error: outcome.reason,
|
|
282
|
+
});
|
|
283
|
+
if (!outcome.retry || attempt === DOWNLOAD_ATTEMPTS)
|
|
284
|
+
break;
|
|
285
|
+
const pause = Math.min(outcome.retryAfterMs ?? RETRY_PAUSES_MS[attempt - 1] ?? 0,
|
|
286
|
+
// Never wait past the budget: the wait would be the whole of what is
|
|
287
|
+
// left and the attempt it buys would have no time to run.
|
|
288
|
+
Math.max(0, deadline - Date.now()));
|
|
289
|
+
if (pause > 0)
|
|
290
|
+
await sleep(pause);
|
|
291
|
+
}
|
|
292
|
+
if (buffer === null) {
|
|
293
|
+
// One line per FILE at the end, next to the line the session feed gets.
|
|
294
|
+
log.warn('attachments: download failed', {
|
|
295
|
+
attachmentId: attachment.id,
|
|
296
|
+
// What was actually tried, and the ceiling beside it. Printing the
|
|
297
|
+
// ceiling alone said «3 attempts» for a 404 that was asked once — and
|
|
298
|
+
// «did the retry run on this file» is the one question this whole
|
|
299
|
+
// change exists to let somebody answer from the log.
|
|
300
|
+
attempts: made,
|
|
301
|
+
of: DOWNLOAD_ATTEMPTS,
|
|
302
|
+
error: lastReason,
|
|
303
|
+
});
|
|
304
|
+
failed.push(attachment.fileName);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
159
308
|
const name = safeAttachmentName(attachment.id, attachment.fileName);
|
|
160
309
|
fs.writeFileSync(path.join(dir, name), buffer, { mode: 0o600 });
|
|
161
310
|
saved.push({
|
|
@@ -166,7 +315,9 @@ export async function saveAttachments(input) {
|
|
|
166
315
|
});
|
|
167
316
|
}
|
|
168
317
|
catch (error) {
|
|
169
|
-
|
|
318
|
+
// The bytes are here and the disk refused them — not something a repeat
|
|
319
|
+
// of the download would mend.
|
|
320
|
+
log.warn('attachments: could not write the file', {
|
|
170
321
|
attachmentId: attachment.id,
|
|
171
322
|
error: String(error),
|
|
172
323
|
});
|
package/dist/commit-message.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
2
|
-
import { claudeExecutableOption } from './agent-binary.js';
|
|
2
|
+
import { assertClaudeInstalled, claudeExecutableOption } from './agent-binary.js';
|
|
3
3
|
import { scrubbedEnv } from './adapters/claude.js';
|
|
4
4
|
import { gitBranchDiff } from './gitops.js';
|
|
5
5
|
import { isSecretPath, maskString } from './policy.js';
|
|
@@ -149,6 +149,13 @@ export async function proposeCommitMessage(input, queryFn = query) {
|
|
|
149
149
|
}
|
|
150
150
|
let text = '';
|
|
151
151
|
try {
|
|
152
|
+
// The second place a real Claude run starts (#395). Same question as
|
|
153
|
+
// `ClaudeAdapter.startSession`, asked for the same reason and only of the
|
|
154
|
+
// real SDK: without it this run would reach for whatever binary the SDK
|
|
155
|
+
// resolves by itself. Inside the `try`, so the answer is the ordinary
|
|
156
|
+
// `{ ok: false, error }` this function already returns.
|
|
157
|
+
if (queryFn === query)
|
|
158
|
+
assertClaudeInstalled();
|
|
152
159
|
const run = queryFn({
|
|
153
160
|
prompt: buildPrompt(input, diff),
|
|
154
161
|
options: {
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { claimCageAuthority, runSystemctl } from './cage-authority.js';
|
|
|
13
13
|
import { acquireDaemonLock, isHeldByAnother } from './daemon-lock.js';
|
|
14
14
|
import { loadConfig, mergeIntoPairedConfig, requireConfig, saveConfig, } from './config.js';
|
|
15
15
|
import { log } from './log.js';
|
|
16
|
-
import { installIsWritable, installPrefixFor, isSupervisedProcess, manualUpdateCommand, resolveInstalledPackageDir, } from './self-update.js';
|
|
16
|
+
import { installIsWritable, installPrefixFor, isSupervisedProcess, restartCapability, manualUpdateCommand, resolveInstalledPackageDir, } from './self-update.js';
|
|
17
17
|
import { applyStoredClaudeToken } from './agent-auth.js';
|
|
18
18
|
import { Supervisor } from './supervisor.js';
|
|
19
19
|
import { readStatusFile, isPidAlive, writeStatusFile, STATUS_FRESH_MS } from './status-file.js';
|
|
@@ -175,6 +175,21 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
175
175
|
...(selfUpdatable()
|
|
176
176
|
? { selfUpdate: true }
|
|
177
177
|
: { selfUpdateBlocked: selfUpdateBlockedReason() ?? 'unsupervised' }),
|
|
178
|
+
/**
|
|
179
|
+
* 0.60.0: can be told to restart itself (#396), the same contract shape as
|
|
180
|
+
* the pair above.
|
|
181
|
+
*
|
|
182
|
+
* The condition is `isSupervisedProcess()` ALONE, and deliberately not
|
|
183
|
+
* `selfUpdatable()`: replacing the package needs an installed, writable npm
|
|
184
|
+
* package, but restarting needs only something that will start us again —
|
|
185
|
+
* a source checkout under systemd (this dogfood box) restarts perfectly
|
|
186
|
+
* well and must get the button.
|
|
187
|
+
*
|
|
188
|
+
* Announced rather than inferred from the version, because an older runner
|
|
189
|
+
* drops a command it cannot parse WITHOUT answering, and the dashboard
|
|
190
|
+
* would then offer a button that hangs until the gateway gives up.
|
|
191
|
+
*/
|
|
192
|
+
...restartCapability(),
|
|
178
193
|
/**
|
|
179
194
|
* Which OS user this daemon runs as (0.24.0).
|
|
180
195
|
*
|
|
@@ -526,6 +541,12 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
526
541
|
// это чтение, а не установка, и машину, чей владелец запретил ставить из
|
|
527
542
|
// дашборда, спросить о том, что на ней стоит, по-прежнему можно.
|
|
528
543
|
'agent_versions_refresh',
|
|
544
|
+
// #396. Listed unconditionally, unlike the `restart` flag above: the
|
|
545
|
+
// flag is what the dashboard draws a button from, and this list is what
|
|
546
|
+
// `runCommand` dispatches on. A machine that cannot restart still
|
|
547
|
+
// ANSWERS the command, with a sentence saying why — which is a better
|
|
548
|
+
// outcome than a frame nobody replies to.
|
|
549
|
+
'runner_restart',
|
|
529
550
|
],
|
|
530
551
|
};
|
|
531
552
|
}
|
|
@@ -911,6 +932,20 @@ async function cmdDaemon() {
|
|
|
911
932
|
CLAUDE: new ClaudeAdapter(),
|
|
912
933
|
...(codex ? { CODEX: codex } : {}),
|
|
913
934
|
},
|
|
935
|
+
/**
|
|
936
|
+
* #395: an agent installed from the dashboard is usable without a restart.
|
|
937
|
+
*
|
|
938
|
+
* The map above is built once, from what was on PATH when this process
|
|
939
|
+
* started — the same frozen snapshot `capabilities.agents` was. Claude does
|
|
940
|
+
* not need this (its adapter is unconditional and asks for the binary at
|
|
941
|
+
* session start), Codex does: its adapter is only built when `codex` was
|
|
942
|
+
* already there, so pressing «Install Codex» used to leave a machine that
|
|
943
|
+
* the card called ready and every session on which died at once.
|
|
944
|
+
*
|
|
945
|
+
* `hasExecutable` is asked HERE, at the moment of the question, which is
|
|
946
|
+
* the whole point.
|
|
947
|
+
*/
|
|
948
|
+
makeAdapter: (agent) => agent === 'CODEX' && hasExecutable('codex') ? bootstrapCodex(config) : null,
|
|
914
949
|
...(config.mcp ? { mcp: { url: config.mcp.url, token: config.mcp.token } } : {}),
|
|
915
950
|
...(config.limits?.max_sessions ? { maxSessionsLimit: config.limits.max_sessions } : {}),
|
|
916
951
|
/**
|
|
@@ -961,6 +996,22 @@ async function cmdDaemon() {
|
|
|
961
996
|
}, RESTART_DELAY_MS);
|
|
962
997
|
timer.unref();
|
|
963
998
|
},
|
|
999
|
+
// #396: the same exit, asked for directly rather than as the tail of an
|
|
1000
|
+
// update. The delay is the same and for the same reason — the answer has
|
|
1001
|
+
// to leave the socket before the process does, or the card shows a timeout
|
|
1002
|
+
// for a restart that is happening.
|
|
1003
|
+
onRestartCommanded: (note) => {
|
|
1004
|
+
log.info('daemon: restarting on a command from the dashboard', {
|
|
1005
|
+
sessions: supervisor.activeSessionIds.length,
|
|
1006
|
+
told: Boolean(note),
|
|
1007
|
+
});
|
|
1008
|
+
const timer = setTimeout(() => {
|
|
1009
|
+
supervisor.shutdown();
|
|
1010
|
+
ws.stop();
|
|
1011
|
+
process.exit(0);
|
|
1012
|
+
}, RESTART_DELAY_MS);
|
|
1013
|
+
timer.unref();
|
|
1014
|
+
},
|
|
964
1015
|
});
|
|
965
1016
|
const updateStatus = () => writeStatusFile({
|
|
966
1017
|
pid: process.pid,
|
|
@@ -1276,7 +1327,13 @@ async function runnerChecks() {
|
|
|
1276
1327
|
// /root/.local — a directory the daemon's user cannot write — and
|
|
1277
1328
|
// the install died naming a home nobody had chosen. `sudo -iu` hands
|
|
1278
1329
|
// the string to the TARGET user's login shell, so `$HOME` is theirs.
|
|
1279
|
-
|
|
1330
|
+
// `--omit=optional` is not cosmetic here (#395): without it npm pulls
|
|
1331
|
+
// the SDK's bundled Claude — 215 MB and a SECOND Claude of a
|
|
1332
|
+
// different version on the machine — and every other install path in
|
|
1333
|
+
// the product omits it (`dev-runner-install.sh`, `self-update.ts`).
|
|
1334
|
+
// This one line was how a hand-repaired machine ended up with a
|
|
1335
|
+
// binary no card could account for.
|
|
1336
|
+
fix: `sudo -iu ${me.user} sh -lc 'npm config set prefix "$HOME/.local" && npm install -g --ignore-scripts --omit=optional --loglevel=error @bridge4dev/runner'`,
|
|
1280
1337
|
fixMore: `(the \`npm config set prefix\` half is what keeps the button working: without it every LATER update aims at the system prefix again and fails with EACCES)`,
|
|
1281
1338
|
}),
|
|
1282
1339
|
});
|
package/dist/journal.d.ts
CHANGED
|
@@ -58,6 +58,23 @@ export declare class SessionJournal {
|
|
|
58
58
|
anchor: string;
|
|
59
59
|
providerSessionId: string;
|
|
60
60
|
} | null;
|
|
61
|
+
/**
|
|
62
|
+
* Every card this journal has published, and whether it is still open —
|
|
63
|
+
* in the order they were asked (#392).
|
|
64
|
+
*
|
|
65
|
+
* The persistent twin of the supervisor's `running.openQuestions`: that set
|
|
66
|
+
* dies with the process, and a process that dies ungracefully never gets to
|
|
67
|
+
* `shutdown()`, which is the only place it withdraws them. This one is
|
|
68
|
+
* rebuilt from the file, so the next process can close what the last one
|
|
69
|
+
* left open — the card in the browser does not know the runner restarted.
|
|
70
|
+
*
|
|
71
|
+
* Closed cards are remembered too, not only dropped: «this card was closed
|
|
72
|
+
* by somebody» is the fact that stops a SECOND tombstone. A queued answer
|
|
73
|
+
* that reaches the next process after a restore has already closed the card
|
|
74
|
+
* must rescue the words and say nothing more about the card — and the only
|
|
75
|
+
* witness that the card was closed is this file.
|
|
76
|
+
*/
|
|
77
|
+
private readonly asks;
|
|
61
78
|
constructor(sessionId: string, dir?: string);
|
|
62
79
|
private replay;
|
|
63
80
|
private write;
|
|
@@ -86,6 +103,29 @@ export declare class SessionJournal {
|
|
|
86
103
|
recordStatus(status: string, extra?: Record<string, unknown>, epoch?: number): void;
|
|
87
104
|
/** Assign the next seq and persist the event before it is sent. */
|
|
88
105
|
append(eventType: string, payload: Record<string, unknown>): JournalEvent;
|
|
106
|
+
/**
|
|
107
|
+
* Keep `openAsks` in step with the cards going out through `append` (#392).
|
|
108
|
+
*
|
|
109
|
+
* Here and not in the supervisor, because `append` is the one door every
|
|
110
|
+
* event takes: a card that was published was published through it, and so
|
|
111
|
+
* was every resolution — the adapter's own, `withdrawOpenQuestions`, the
|
|
112
|
+
* `not_open` miss. A set kept beside the callers would need every one of
|
|
113
|
+
* them to remember it, which is exactly how the in-memory set came to be
|
|
114
|
+
* empty at the moment it was needed.
|
|
115
|
+
*
|
|
116
|
+
* A resolution for a card this journal never published writes nothing:
|
|
117
|
+
* there is nothing to close, and remembering strangers would only grow the
|
|
118
|
+
* file.
|
|
119
|
+
*/
|
|
120
|
+
private trackAsk;
|
|
121
|
+
/** The cards still waiting on a person, oldest first (#392). */
|
|
122
|
+
openAskIds(): string[];
|
|
123
|
+
/**
|
|
124
|
+
* What this journal knows about one card (#392): `open`, `closed`, or
|
|
125
|
+
* nothing at all — a card asked by a runner from before these lines existed,
|
|
126
|
+
* or one this state directory never saw.
|
|
127
|
+
*/
|
|
128
|
+
askState(askId: string): 'open' | 'closed' | undefined;
|
|
89
129
|
/**
|
|
90
130
|
* Never reuse a seq the API already stored: after a runner state-dir wipe the
|
|
91
131
|
* local counter restarts at 1 and every replayed event would collide with an
|
package/dist/journal.js
CHANGED
|
@@ -21,6 +21,23 @@ export class SessionJournal {
|
|
|
21
21
|
lastStatus = null;
|
|
22
22
|
/** The agent's conversation tip, and the provider session it lives in. */
|
|
23
23
|
lastAnchor = null;
|
|
24
|
+
/**
|
|
25
|
+
* Every card this journal has published, and whether it is still open —
|
|
26
|
+
* in the order they were asked (#392).
|
|
27
|
+
*
|
|
28
|
+
* The persistent twin of the supervisor's `running.openQuestions`: that set
|
|
29
|
+
* dies with the process, and a process that dies ungracefully never gets to
|
|
30
|
+
* `shutdown()`, which is the only place it withdraws them. This one is
|
|
31
|
+
* rebuilt from the file, so the next process can close what the last one
|
|
32
|
+
* left open — the card in the browser does not know the runner restarted.
|
|
33
|
+
*
|
|
34
|
+
* Closed cards are remembered too, not only dropped: «this card was closed
|
|
35
|
+
* by somebody» is the fact that stops a SECOND tombstone. A queued answer
|
|
36
|
+
* that reaches the next process after a restore has already closed the card
|
|
37
|
+
* must rescue the words and say nothing more about the card — and the only
|
|
38
|
+
* witness that the card was closed is this file.
|
|
39
|
+
*/
|
|
40
|
+
asks = new Map();
|
|
24
41
|
constructor(sessionId, dir = journalDir()) {
|
|
25
42
|
this.sessionId = sessionId;
|
|
26
43
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -50,6 +67,16 @@ export class SessionJournal {
|
|
|
50
67
|
});
|
|
51
68
|
if (parsed.seq >= this.nextSeq)
|
|
52
69
|
this.nextSeq = parsed.seq + 1;
|
|
70
|
+
// The event lines carry the same fact as `ask_open`/`ask_closed`, and
|
|
71
|
+
// replaying both is harmless: the set is a set.
|
|
72
|
+
this.trackAsk(parsed.eventType, parsed.payload, false);
|
|
73
|
+
}
|
|
74
|
+
else if (parsed.kind === 'ask_open') {
|
|
75
|
+
if (this.asks.get(parsed.askId) !== 'closed')
|
|
76
|
+
this.asks.set(parsed.askId, 'open');
|
|
77
|
+
}
|
|
78
|
+
else if (parsed.kind === 'ask_closed') {
|
|
79
|
+
this.asks.set(parsed.askId, 'closed');
|
|
53
80
|
}
|
|
54
81
|
else if (parsed.kind === 'ack') {
|
|
55
82
|
this.unackedBySeq.delete(parsed.seq);
|
|
@@ -107,6 +134,11 @@ export class SessionJournal {
|
|
|
107
134
|
if (this.lastAnchor) {
|
|
108
135
|
lines.push({ kind: 'anchor', ...this.lastAnchor });
|
|
109
136
|
}
|
|
137
|
+
// AFTER the event lines: an unacked `question` re-written above would
|
|
138
|
+
// otherwise reopen, on the next replay, a card these lines say is closed.
|
|
139
|
+
for (const [askId, state] of this.asks) {
|
|
140
|
+
lines.push({ kind: state === 'open' ? 'ask_open' : 'ask_closed', askId });
|
|
141
|
+
}
|
|
110
142
|
if (this.lastStatus) {
|
|
111
143
|
lines.push({
|
|
112
144
|
kind: 'status',
|
|
@@ -182,8 +214,58 @@ export class SessionJournal {
|
|
|
182
214
|
const event = { seq: this.nextSeq++, eventType, payload };
|
|
183
215
|
this.write({ kind: 'event', ...event, ts: new Date().toISOString() });
|
|
184
216
|
this.unackedBySeq.set(event.seq, event);
|
|
217
|
+
this.trackAsk(eventType, payload, true);
|
|
185
218
|
return event;
|
|
186
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Keep `openAsks` in step with the cards going out through `append` (#392).
|
|
222
|
+
*
|
|
223
|
+
* Here and not in the supervisor, because `append` is the one door every
|
|
224
|
+
* event takes: a card that was published was published through it, and so
|
|
225
|
+
* was every resolution — the adapter's own, `withdrawOpenQuestions`, the
|
|
226
|
+
* `not_open` miss. A set kept beside the callers would need every one of
|
|
227
|
+
* them to remember it, which is exactly how the in-memory set came to be
|
|
228
|
+
* empty at the moment it was needed.
|
|
229
|
+
*
|
|
230
|
+
* A resolution for a card this journal never published writes nothing:
|
|
231
|
+
* there is nothing to close, and remembering strangers would only grow the
|
|
232
|
+
* file.
|
|
233
|
+
*/
|
|
234
|
+
trackAsk(eventType, payload, persist) {
|
|
235
|
+
if (eventType !== 'question' && eventType !== 'question_resolved')
|
|
236
|
+
return;
|
|
237
|
+
const askId = payload['askId'];
|
|
238
|
+
if (typeof askId !== 'string' || !askId)
|
|
239
|
+
return;
|
|
240
|
+
const state = this.asks.get(askId);
|
|
241
|
+
if (eventType === 'question') {
|
|
242
|
+
// A card is asked once; a replayed event line for one already closed
|
|
243
|
+
// (compaction keeps unacked events) must not reopen it.
|
|
244
|
+
if (state !== undefined)
|
|
245
|
+
return;
|
|
246
|
+
this.asks.set(askId, 'open');
|
|
247
|
+
if (persist)
|
|
248
|
+
this.write({ kind: 'ask_open', askId });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (state !== 'open')
|
|
252
|
+
return;
|
|
253
|
+
this.asks.set(askId, 'closed');
|
|
254
|
+
if (persist)
|
|
255
|
+
this.write({ kind: 'ask_closed', askId });
|
|
256
|
+
}
|
|
257
|
+
/** The cards still waiting on a person, oldest first (#392). */
|
|
258
|
+
openAskIds() {
|
|
259
|
+
return [...this.asks].filter(([, state]) => state === 'open').map(([askId]) => askId);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* What this journal knows about one card (#392): `open`, `closed`, or
|
|
263
|
+
* nothing at all — a card asked by a runner from before these lines existed,
|
|
264
|
+
* or one this state directory never saw.
|
|
265
|
+
*/
|
|
266
|
+
askState(askId) {
|
|
267
|
+
return this.asks.get(askId);
|
|
268
|
+
}
|
|
187
269
|
/**
|
|
188
270
|
* Never reuse a seq the API already stored: after a runner state-dir wipe the
|
|
189
271
|
* local counter restarts at 1 and every replayed event would collide with an
|
package/dist/protocol.d.ts
CHANGED
|
@@ -32,6 +32,20 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
|
|
|
32
32
|
* behaviour every runner had before this release.
|
|
33
33
|
*/
|
|
34
34
|
pausedUntil: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
|
35
|
+
/**
|
|
36
|
+
* Subagents the API still believes to be alive in this session (#393).
|
|
37
|
+
*
|
|
38
|
+
* The number is the dead process's last word, kept by the API: a runner
|
|
39
|
+
* restart takes every subagent with it, and the new process seeds its own
|
|
40
|
+
* count at zero — so the descriptor is the only place the number survives
|
|
41
|
+
* long enough to be written down. The API sends it only while it still
|
|
42
|
+
* believes it (its own trust window on the count's age); absent or zero
|
|
43
|
+
* means «nothing to say», and a restore says nothing.
|
|
44
|
+
*
|
|
45
|
+
* `.catch(undefined)` like its neighbours: one malformed value must cost
|
|
46
|
+
* its own session a line at most, never the frame (QA-100 MAJOR-1).
|
|
47
|
+
*/
|
|
48
|
+
backgroundTasks: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
|
|
35
49
|
/**
|
|
36
50
|
* The newest published version of THIS session's agent — Р13, §4.8.
|
|
37
51
|
*
|
|
@@ -246,6 +260,7 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
|
|
|
246
260
|
} | undefined;
|
|
247
261
|
workMode?: "DIRECT" | "BRANCH" | undefined;
|
|
248
262
|
pausedUntil?: string | null | undefined;
|
|
263
|
+
backgroundTasks?: number | undefined;
|
|
249
264
|
agentLatestVersion?: string | undefined;
|
|
250
265
|
skipAgentPrompt?: boolean | undefined;
|
|
251
266
|
branchHint?: string | undefined;
|
|
@@ -295,6 +310,7 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
|
|
|
295
310
|
activeMsBase?: number | undefined;
|
|
296
311
|
extraBudgetMinutes?: number | null | undefined;
|
|
297
312
|
pausedUntil?: unknown;
|
|
313
|
+
backgroundTasks?: unknown;
|
|
298
314
|
agentLatestVersion?: unknown;
|
|
299
315
|
skipAgentPrompt?: unknown;
|
|
300
316
|
branchHint?: unknown;
|
|
@@ -450,6 +466,20 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
450
466
|
* behaviour every runner had before this release.
|
|
451
467
|
*/
|
|
452
468
|
pausedUntil: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
|
469
|
+
/**
|
|
470
|
+
* Subagents the API still believes to be alive in this session (#393).
|
|
471
|
+
*
|
|
472
|
+
* The number is the dead process's last word, kept by the API: a runner
|
|
473
|
+
* restart takes every subagent with it, and the new process seeds its own
|
|
474
|
+
* count at zero — so the descriptor is the only place the number survives
|
|
475
|
+
* long enough to be written down. The API sends it only while it still
|
|
476
|
+
* believes it (its own trust window on the count's age); absent or zero
|
|
477
|
+
* means «nothing to say», and a restore says nothing.
|
|
478
|
+
*
|
|
479
|
+
* `.catch(undefined)` like its neighbours: one malformed value must cost
|
|
480
|
+
* its own session a line at most, never the frame (QA-100 MAJOR-1).
|
|
481
|
+
*/
|
|
482
|
+
backgroundTasks: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
|
|
453
483
|
/**
|
|
454
484
|
* The newest published version of THIS session's agent — Р13, §4.8.
|
|
455
485
|
*
|
|
@@ -664,6 +694,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
664
694
|
} | undefined;
|
|
665
695
|
workMode?: "DIRECT" | "BRANCH" | undefined;
|
|
666
696
|
pausedUntil?: string | null | undefined;
|
|
697
|
+
backgroundTasks?: number | undefined;
|
|
667
698
|
agentLatestVersion?: string | undefined;
|
|
668
699
|
skipAgentPrompt?: boolean | undefined;
|
|
669
700
|
branchHint?: string | undefined;
|
|
@@ -713,6 +744,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
713
744
|
activeMsBase?: number | undefined;
|
|
714
745
|
extraBudgetMinutes?: number | null | undefined;
|
|
715
746
|
pausedUntil?: unknown;
|
|
747
|
+
backgroundTasks?: unknown;
|
|
716
748
|
agentLatestVersion?: unknown;
|
|
717
749
|
skipAgentPrompt?: unknown;
|
|
718
750
|
branchHint?: unknown;
|
|
@@ -759,6 +791,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
759
791
|
} | undefined;
|
|
760
792
|
workMode?: "DIRECT" | "BRANCH" | undefined;
|
|
761
793
|
pausedUntil?: string | null | undefined;
|
|
794
|
+
backgroundTasks?: number | undefined;
|
|
762
795
|
agentLatestVersion?: string | undefined;
|
|
763
796
|
skipAgentPrompt?: boolean | undefined;
|
|
764
797
|
branchHint?: string | undefined;
|
|
@@ -814,6 +847,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
814
847
|
activeMsBase?: number | undefined;
|
|
815
848
|
extraBudgetMinutes?: number | null | undefined;
|
|
816
849
|
pausedUntil?: unknown;
|
|
850
|
+
backgroundTasks?: unknown;
|
|
817
851
|
agentLatestVersion?: unknown;
|
|
818
852
|
skipAgentPrompt?: unknown;
|
|
819
853
|
branchHint?: unknown;
|
|
@@ -883,6 +917,20 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
883
917
|
* behaviour every runner had before this release.
|
|
884
918
|
*/
|
|
885
919
|
pausedUntil: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
|
920
|
+
/**
|
|
921
|
+
* Subagents the API still believes to be alive in this session (#393).
|
|
922
|
+
*
|
|
923
|
+
* The number is the dead process's last word, kept by the API: a runner
|
|
924
|
+
* restart takes every subagent with it, and the new process seeds its own
|
|
925
|
+
* count at zero — so the descriptor is the only place the number survives
|
|
926
|
+
* long enough to be written down. The API sends it only while it still
|
|
927
|
+
* believes it (its own trust window on the count's age); absent or zero
|
|
928
|
+
* means «nothing to say», and a restore says nothing.
|
|
929
|
+
*
|
|
930
|
+
* `.catch(undefined)` like its neighbours: one malformed value must cost
|
|
931
|
+
* its own session a line at most, never the frame (QA-100 MAJOR-1).
|
|
932
|
+
*/
|
|
933
|
+
backgroundTasks: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
|
|
886
934
|
/**
|
|
887
935
|
* The newest published version of THIS session's agent — Р13, §4.8.
|
|
888
936
|
*
|
|
@@ -1097,6 +1145,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1097
1145
|
} | undefined;
|
|
1098
1146
|
workMode?: "DIRECT" | "BRANCH" | undefined;
|
|
1099
1147
|
pausedUntil?: string | null | undefined;
|
|
1148
|
+
backgroundTasks?: number | undefined;
|
|
1100
1149
|
agentLatestVersion?: string | undefined;
|
|
1101
1150
|
skipAgentPrompt?: boolean | undefined;
|
|
1102
1151
|
branchHint?: string | undefined;
|
|
@@ -1146,6 +1195,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1146
1195
|
activeMsBase?: number | undefined;
|
|
1147
1196
|
extraBudgetMinutes?: number | null | undefined;
|
|
1148
1197
|
pausedUntil?: unknown;
|
|
1198
|
+
backgroundTasks?: unknown;
|
|
1149
1199
|
agentLatestVersion?: unknown;
|
|
1150
1200
|
skipAgentPrompt?: unknown;
|
|
1151
1201
|
branchHint?: unknown;
|
|
@@ -1192,6 +1242,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1192
1242
|
} | undefined;
|
|
1193
1243
|
workMode?: "DIRECT" | "BRANCH" | undefined;
|
|
1194
1244
|
pausedUntil?: string | null | undefined;
|
|
1245
|
+
backgroundTasks?: number | undefined;
|
|
1195
1246
|
agentLatestVersion?: string | undefined;
|
|
1196
1247
|
skipAgentPrompt?: boolean | undefined;
|
|
1197
1248
|
branchHint?: string | undefined;
|
|
@@ -1244,6 +1295,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1244
1295
|
activeMsBase?: number | undefined;
|
|
1245
1296
|
extraBudgetMinutes?: number | null | undefined;
|
|
1246
1297
|
pausedUntil?: unknown;
|
|
1298
|
+
backgroundTasks?: unknown;
|
|
1247
1299
|
agentLatestVersion?: unknown;
|
|
1248
1300
|
skipAgentPrompt?: unknown;
|
|
1249
1301
|
branchHint?: unknown;
|
package/dist/protocol.js
CHANGED
|
@@ -59,6 +59,20 @@ export const SessionDescriptorSchema = z.object({
|
|
|
59
59
|
* behaviour every runner had before this release.
|
|
60
60
|
*/
|
|
61
61
|
pausedUntil: z.string().max(40).nullable().optional().catch(undefined),
|
|
62
|
+
/**
|
|
63
|
+
* Subagents the API still believes to be alive in this session (#393).
|
|
64
|
+
*
|
|
65
|
+
* The number is the dead process's last word, kept by the API: a runner
|
|
66
|
+
* restart takes every subagent with it, and the new process seeds its own
|
|
67
|
+
* count at zero — so the descriptor is the only place the number survives
|
|
68
|
+
* long enough to be written down. The API sends it only while it still
|
|
69
|
+
* believes it (its own trust window on the count's age); absent or zero
|
|
70
|
+
* means «nothing to say», and a restore says nothing.
|
|
71
|
+
*
|
|
72
|
+
* `.catch(undefined)` like its neighbours: one malformed value must cost
|
|
73
|
+
* its own session a line at most, never the frame (QA-100 MAJOR-1).
|
|
74
|
+
*/
|
|
75
|
+
backgroundTasks: z.number().int().min(0).optional().catch(undefined),
|
|
62
76
|
/**
|
|
63
77
|
* The newest published version of THIS session's agent — Р13, §4.8.
|
|
64
78
|
*
|