@link-assistant/hive-mind 2.15.2 → 2.17.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/CHANGELOG.md +125 -0
- package/README.hi.md +12 -0
- package/README.md +15 -0
- package/README.ru.md +15 -0
- package/README.zh.md +24 -12
- package/package.json +24 -17
- package/src/agent-snapshot-store.lib.mjs +252 -0
- package/src/agent.lib.mjs +25 -25
- package/src/agent.version-gates.lib.mjs +73 -0
- package/src/bot-lifecycle.lib.mjs +63 -5
- package/src/child-exit.lib.mjs +53 -1
- package/src/claude.session-tokens.lib.mjs +10 -8
- package/src/claude.session-transcript-repair.lib.mjs +65 -34
- package/src/cleanup.mjs +57 -3
- package/src/codex.lib.mjs +11 -1
- package/src/development-log.lib.mjs +22 -7
- package/src/disk-guard.lib.mjs +21 -1
- package/src/formal-ai-version.lib.mjs +10 -6
- package/src/github-error-reporter.lib.mjs +84 -2
- package/src/github.lib.mjs +212 -152
- package/src/instrument.mjs +12 -14
- package/src/instrument.sanitize.lib.mjs +52 -0
- package/src/isolation-runner.lib.mjs +56 -33
- package/src/isolation-runner.parsers.lib.mjs +29 -3
- package/src/isolation-runner.resume.lib.mjs +263 -0
- package/src/log-bounded-read.lib.mjs +411 -0
- package/src/log-sanitize-stream.lib.mjs +267 -0
- package/src/log-sanitize-worker-entry.mjs +31 -0
- package/src/log-sanitize-worker.lib.mjs +186 -0
- package/src/log-upload.lib.mjs +16 -4
- package/src/pull-request-changes.lib.mjs +1 -1
- package/src/session-completion-state.lib.mjs +124 -0
- package/src/session-kill-diagnostics.lib.mjs +117 -12
- package/src/session-kill-policy.lib.mjs +18 -7
- package/src/session-kill-resume.in-place.lib.mjs +136 -0
- package/src/session-kill-resume.lib.mjs +48 -18
- package/src/session-monitor.kill-sections.lib.mjs +8 -0
- package/src/session-monitor.lib.mjs +132 -9
- package/src/session-store.lib.mjs +15 -1
- package/src/solve.clone-errors.lib.mjs +86 -0
- package/src/solve.config.lib.mjs +5 -2
- package/src/solve.repository.lib.mjs +36 -63
- package/src/solve.resource-diagnostics.lib.mjs +105 -5
- package/src/start-command-cli.lib.mjs +60 -0
- package/src/telegram-bot.mjs +31 -91
- package/src/telegram-log-command.lib.mjs +7 -3
- package/src/telegram-overrides-validation.lib.mjs +73 -0
- package/src/telegram-terminal-watch-command.lib.mjs +9 -1
- package/src/working-session-summary.lib.mjs +1 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-enter the *same* container when recovering a killed session (issue #2189).
|
|
3
|
+
*
|
|
4
|
+
* The incident behind #2189 ended with a session that was killed 10 minutes
|
|
5
|
+
* after the AI tool had already finished its work. Recovery, when it finally
|
|
6
|
+
* happened, threw that container away: a fresh isolated run re-cloned the
|
|
7
|
+
* repository, re-installed everything and re-did work that was sitting on disk.
|
|
8
|
+
* The issue asks for the opposite — "ideally re-entering the same `$` session
|
|
9
|
+
* id / container".
|
|
10
|
+
*
|
|
11
|
+
* `start-command@0.33.0` (upstream link-foundation/start#162, filed from this
|
|
12
|
+
* very issue) makes that possible: `$ --resume <id> -- <command>` commits the
|
|
13
|
+
* stopped container's filesystem and runs the recovery command in a container
|
|
14
|
+
* derived from that snapshot, keeping the original execution UUID and log.
|
|
15
|
+
*
|
|
16
|
+
* Not every session may take that path, and the two exceptions are deliberate:
|
|
17
|
+
*
|
|
18
|
+
* - **Formal AI tasks** (issue #2146) reach their sidecar over an *internal*
|
|
19
|
+
* Docker network that Hive Mind attaches with `docker network connect` after
|
|
20
|
+
* the container is created. `$` knows nothing about that network, so a
|
|
21
|
+
* resumed container would come up without it and the task would silently talk
|
|
22
|
+
* to nothing. #2146 requires Formal AI to fail closed, so these fall back to
|
|
23
|
+
* the normal launch path, which re-acquires the sidecar lease properly.
|
|
24
|
+
* - **`--use-router` tasks** are attached to the router network the same way,
|
|
25
|
+
* with a freshly minted token, and have the same problem.
|
|
26
|
+
*
|
|
27
|
+
* Everything else — the overwhelming majority, and every session in the
|
|
28
|
+
* original incident — resumes in place.
|
|
29
|
+
*
|
|
30
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2189
|
|
31
|
+
* @see https://github.com/link-foundation/start/issues/162
|
|
32
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2146
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { isFormalAiTask } from './formal-ai-sidecar.lib.mjs';
|
|
36
|
+
import { hasUseRouterFlag } from './router-isolation.lib.mjs';
|
|
37
|
+
import { RESUME_MODES } from './isolation-runner.resume.lib.mjs';
|
|
38
|
+
|
|
39
|
+
/** Why a killed session cannot be re-entered in place. Reported, never thrown. */
|
|
40
|
+
export const IN_PLACE_SKIP_REASONS = Object.freeze({
|
|
41
|
+
NOT_DOCKER: 'not-docker',
|
|
42
|
+
NO_IDENTIFIER: 'no-identifier',
|
|
43
|
+
FORMAL_AI_TASK: 'formal-ai-task',
|
|
44
|
+
ROUTER_TASK: 'router-task',
|
|
45
|
+
NO_RESUME_SUPPORT: 'no-resume-support',
|
|
46
|
+
CONTAINER_GONE: 'container-gone',
|
|
47
|
+
UNSUPPORTED: 'resume-unsupported',
|
|
48
|
+
REFUSED: 'resume-refused',
|
|
49
|
+
ERROR: 'resume-error',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Decide — purely, from persisted facts — whether a killed session is a
|
|
54
|
+
* candidate for a same-container resume.
|
|
55
|
+
*
|
|
56
|
+
* Kept separate from the Docker probe below so the policy is testable without a
|
|
57
|
+
* daemon, and so a caller can report precisely *why* a session was relaunched
|
|
58
|
+
* from scratch instead of resumed.
|
|
59
|
+
*
|
|
60
|
+
* @param {Object} options
|
|
61
|
+
* @param {string} options.sessionName - The killed session's name (= container name)
|
|
62
|
+
* @param {Object} options.sessionInfo - Persisted session info
|
|
63
|
+
* @returns {{eligible: boolean, reason: string, identifier: string|null, containerName: string|null}}
|
|
64
|
+
*/
|
|
65
|
+
export function planSameContainerResume({ sessionName = null, sessionInfo = {} } = {}) {
|
|
66
|
+
const containerName = sessionInfo?.sessionId || sessionName || null;
|
|
67
|
+
const identifier = sessionInfo?.executionUuid || containerName || null;
|
|
68
|
+
const base = { eligible: false, identifier, containerName };
|
|
69
|
+
|
|
70
|
+
if (sessionInfo?.isolationBackend !== 'docker') {
|
|
71
|
+
// screen/tmux sessions have no filesystem to preserve: their work happens
|
|
72
|
+
// on the host, which a fresh run already sees.
|
|
73
|
+
return { ...base, reason: IN_PLACE_SKIP_REASONS.NOT_DOCKER };
|
|
74
|
+
}
|
|
75
|
+
if (!identifier) return { ...base, reason: IN_PLACE_SKIP_REASONS.NO_IDENTIFIER };
|
|
76
|
+
|
|
77
|
+
const args = Array.isArray(sessionInfo?.args) ? sessionInfo.args : [];
|
|
78
|
+
if (isFormalAiTask({ args, model: sessionInfo?.model || null })) {
|
|
79
|
+
return { ...base, reason: IN_PLACE_SKIP_REASONS.FORMAL_AI_TASK };
|
|
80
|
+
}
|
|
81
|
+
if (hasUseRouterFlag(args)) return { ...base, reason: IN_PLACE_SKIP_REASONS.ROUTER_TASK };
|
|
82
|
+
|
|
83
|
+
return { ...base, eligible: true, reason: 'ready' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Attempt the same-container resume. Never throws, and never leaves work
|
|
88
|
+
* running that it does not report: the caller may only fall back to a fresh
|
|
89
|
+
* launch when `resumed` is false.
|
|
90
|
+
*
|
|
91
|
+
* @param {Object} options
|
|
92
|
+
* @param {string} options.sessionName - The killed session's name
|
|
93
|
+
* @param {Object} options.sessionInfo - Persisted session info
|
|
94
|
+
* @param {Object} options.plan - Result of planKillRecovery() (needs `command.display`)
|
|
95
|
+
* @param {Object} options.runner - Isolation runner module
|
|
96
|
+
* @param {boolean} [options.verbose]
|
|
97
|
+
* @returns {Promise<{resumed: boolean, reason: string, sessionId: string|null, executionUuid: string|null, mode: string|null, snapshotImage: string|null}>}
|
|
98
|
+
*/
|
|
99
|
+
export async function resumeKilledSessionInPlace({ sessionName, sessionInfo, plan, runner, verbose = false } = {}) {
|
|
100
|
+
const decision = planSameContainerResume({ sessionName, sessionInfo });
|
|
101
|
+
const miss = reason => ({ resumed: false, reason, sessionId: null, executionUuid: decision.identifier, mode: null, snapshotImage: null });
|
|
102
|
+
if (!decision.eligible) return miss(decision.reason);
|
|
103
|
+
if (typeof runner?.resumeIsolatedSession !== 'function' || typeof runner?.checkDockerContainerExists !== 'function') {
|
|
104
|
+
return miss(IN_PLACE_SKIP_REASONS.NO_RESUME_SUPPORT);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A container that no longer exists has nothing left to re-enter; `$` would
|
|
108
|
+
// fall back to a full relaunch, which is what the caller does anyway — but
|
|
109
|
+
// through the path that also re-acquires leases.
|
|
110
|
+
const exists = await runner.checkDockerContainerExists(decision.containerName, verbose);
|
|
111
|
+
if (!exists) return miss(IN_PLACE_SKIP_REASONS.CONTAINER_GONE);
|
|
112
|
+
|
|
113
|
+
const result = await runner.resumeIsolatedSession(decision.identifier, { command: plan?.command?.display || null, verbose });
|
|
114
|
+
if (!result?.success) {
|
|
115
|
+
const reason = result?.unsupported ? IN_PLACE_SKIP_REASONS.UNSUPPORTED : IN_PLACE_SKIP_REASONS.REFUSED;
|
|
116
|
+
if (verbose) console.log(`[VERBOSE] In-place resume of ${sessionName} was not possible (${reason}): ${result?.error || 'no reason given'}`);
|
|
117
|
+
return miss(reason);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// `docker-snapshot` names the new container `<session>-resume-<attempt>`; the
|
|
121
|
+
// old name stays addressable through upstream's `sessionNameHistory`, but the
|
|
122
|
+
// *new* one is what `$ --status` reports on now, so that is what the monitor
|
|
123
|
+
// has to track. A resume that somehow reports the old name (a `docker-start`
|
|
124
|
+
// race, say) is tracked under the execution UUID instead, which upstream
|
|
125
|
+
// resolves just as well and cannot collide with the dying session's entry.
|
|
126
|
+
const returnedName = result.sessionName && result.sessionName !== sessionName ? result.sessionName : null;
|
|
127
|
+
const sessionId = returnedName || decision.identifier;
|
|
128
|
+
return {
|
|
129
|
+
resumed: true,
|
|
130
|
+
reason: result.mode === RESUME_MODES.DOCKER_SNAPSHOT ? 'resumed-in-place' : `resumed-${result.mode || 'unknown'}`,
|
|
131
|
+
sessionId,
|
|
132
|
+
executionUuid: result.uuid || decision.identifier,
|
|
133
|
+
mode: result.mode || null,
|
|
134
|
+
snapshotImage: result.snapshotImage || null,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -11,16 +11,20 @@
|
|
|
11
11
|
* The restart is bounded by `--session-kill-resume-attempts` (default 1), so a
|
|
12
12
|
* job that reliably runs the host out of memory cannot storm the queue.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* Issue #2189 made `resume` the default: a killed session that is only ever
|
|
15
|
+
* *offered* for resume is a session nobody resumes. `--on-session-kill=report`
|
|
16
|
+
* turns everything below back off, and `planKillRecovery` still returns
|
|
17
|
+
* `reason: 'policy-report'` in that case.
|
|
16
18
|
*
|
|
17
19
|
* @see https://github.com/link-assistant/hive-mind/issues/2134
|
|
20
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2189
|
|
18
21
|
*/
|
|
19
22
|
|
|
20
23
|
import { readLastSessionIdFromLog, planKilledSessionResume } from './session-resume.lib.mjs';
|
|
21
24
|
import { resolveOnSessionKillPolicy, resolveSessionKillResumeAttempts, shouldResumeKilledSession, ON_SESSION_KILL_RESUME } from './session-kill-policy.lib.mjs';
|
|
22
25
|
import { argvFromSessionArgs } from './session-monitor.kill-sections.lib.mjs';
|
|
23
26
|
import { formatKillResumeSection } from './session-kill-diagnostics.lib.mjs';
|
|
27
|
+
import { resumeKilledSessionInPlace } from './session-kill-resume.in-place.lib.mjs';
|
|
24
28
|
|
|
25
29
|
/** Field recording how many automatic recovery sessions this session produced. */
|
|
26
30
|
export const KILL_RESUME_ATTEMPTS_FIELD = 'killRecoveryAttempts';
|
|
@@ -62,9 +66,18 @@ export function planKillRecovery({ sessionInfo = {}, logPath = null, killed = fa
|
|
|
62
66
|
/**
|
|
63
67
|
* Start the recovery working session decided by {@link planKillRecovery}.
|
|
64
68
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
69
|
+
* Two ways in, in order of preference:
|
|
70
|
+
*
|
|
71
|
+
* 1. **Same container** (issue #2189) — `$ --resume` re-enters the killed
|
|
72
|
+
* session's own filesystem, so the clone, the caches and the half-finished
|
|
73
|
+
* branch survive. See `./session-kill-resume.in-place.lib.mjs` for the
|
|
74
|
+
* cases that are deliberately excluded.
|
|
75
|
+
* 2. **A fresh isolated run** — the original behaviour, used whenever (1) is
|
|
76
|
+
* not available or refuses. Correct, just more expensive.
|
|
77
|
+
*
|
|
78
|
+
* Either way the new session is tracked like any other, so it reports its own
|
|
79
|
+
* completion (and, if it is killed too, its own diagnosis) through the normal
|
|
80
|
+
* path.
|
|
68
81
|
*
|
|
69
82
|
* @param {Object} options
|
|
70
83
|
* @param {string} options.sessionName - The killed session's name
|
|
@@ -74,10 +87,10 @@ export function planKillRecovery({ sessionInfo = {}, logPath = null, killed = fa
|
|
|
74
87
|
* @param {Function} options.trackSession - Tracker for the new session
|
|
75
88
|
* @param {Function} [options.persistSnapshot] - Persist the attempt counter
|
|
76
89
|
* @param {boolean} [options.verbose]
|
|
77
|
-
* @returns {Promise<{resumed: boolean, reason: string, sessionId: string|null, display: string|null}>}
|
|
90
|
+
* @returns {Promise<{resumed: boolean, reason: string, sessionId: string|null, display: string|null, inPlace: boolean}>}
|
|
78
91
|
*/
|
|
79
92
|
export async function startKillRecoverySession({ sessionName, sessionInfo, plan, runner, trackSession, persistSnapshot = null, verbose = false } = {}) {
|
|
80
|
-
const fail = reason => ({ resumed: false, reason, sessionId: null, display: plan?.command?.display || null });
|
|
93
|
+
const fail = reason => ({ resumed: false, reason, sessionId: null, display: plan?.command?.display || null, inPlace: false });
|
|
81
94
|
if (!plan?.shouldResume || !plan.command) return fail(plan?.reason || 'no-plan');
|
|
82
95
|
if (!runner || typeof runner.executeWithIsolation !== 'function' || typeof runner.generateSessionId !== 'function') return fail('no-isolation-runner');
|
|
83
96
|
if (typeof trackSession !== 'function') return fail('no-tracker');
|
|
@@ -85,10 +98,20 @@ export async function startKillRecoverySession({ sessionName, sessionInfo, plan,
|
|
|
85
98
|
if (!backend) return fail('no-isolation-backend');
|
|
86
99
|
|
|
87
100
|
try {
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
101
|
+
// Preferred path: re-enter the container the work already happened in.
|
|
102
|
+
const inPlace = await resumeKilledSessionInPlace({ sessionName, sessionInfo, plan, runner, verbose });
|
|
103
|
+
let newSessionId = inPlace.sessionId;
|
|
104
|
+
let executionUuid = inPlace.executionUuid;
|
|
105
|
+
let containerFilesystemStartBytes = null;
|
|
106
|
+
|
|
107
|
+
if (!inPlace.resumed) {
|
|
108
|
+
newSessionId = runner.generateSessionId();
|
|
109
|
+
const tool = sessionInfo?.tool || 'claude';
|
|
110
|
+
const result = await runner.executeWithIsolation(sessionInfo?.command || 'solve', plan.command.args, { backend, sessionId: newSessionId, tool, verbose });
|
|
111
|
+
if (!result?.success) return fail('start-failed');
|
|
112
|
+
executionUuid = result.executionUuid || null;
|
|
113
|
+
containerFilesystemStartBytes = Number.isFinite(result.containerFilesystemStartBytes) ? result.containerFilesystemStartBytes : null;
|
|
114
|
+
}
|
|
92
115
|
|
|
93
116
|
trackSession(
|
|
94
117
|
newSessionId,
|
|
@@ -102,9 +125,15 @@ export async function startKillRecoverySession({ sessionName, sessionInfo, plan,
|
|
|
102
125
|
[KILL_RESUME_ATTEMPTS_FIELD]: plan.attempt,
|
|
103
126
|
killRecoveryResumed: true,
|
|
104
127
|
killRecoveryOfSession: sessionName,
|
|
128
|
+
// A resumed execution keeps its UUID; a fresh launch gets a new one, and
|
|
129
|
+
// inheriting the dead session's would make `$ --status` answer about the
|
|
130
|
+
// wrong execution until the monitor happened to correct it.
|
|
131
|
+
executionUuid,
|
|
132
|
+
killRecoveryInPlace: inPlace.resumed,
|
|
133
|
+
killRecoveryResumeMode: inPlace.mode || null,
|
|
105
134
|
oomEventObservedAt: undefined,
|
|
106
135
|
dockerBackendGoneFirstSeenAt: undefined,
|
|
107
|
-
containerFilesystemStartBytes
|
|
136
|
+
containerFilesystemStartBytes,
|
|
108
137
|
},
|
|
109
138
|
verbose
|
|
110
139
|
);
|
|
@@ -119,9 +148,10 @@ export async function startKillRecoverySession({ sessionName, sessionInfo, plan,
|
|
|
119
148
|
}
|
|
120
149
|
|
|
121
150
|
if (verbose) {
|
|
122
|
-
|
|
151
|
+
const how = inPlace.resumed ? `resumed in place (${inPlace.mode || 'unknown mode'})` : `started fresh (in-place resume skipped: ${inPlace.reason})`;
|
|
152
|
+
console.log(`[VERBOSE] Session ${sessionName} was killed; recovery session ${newSessionId} ${how} (attempt ${plan.attempt}/${plan.maxAttempts}): ${plan.command.display}`);
|
|
123
153
|
}
|
|
124
|
-
return { resumed: true, reason: 'started', sessionId: newSessionId, display: plan.command.display };
|
|
154
|
+
return { resumed: true, reason: inPlace.resumed ? inPlace.reason : 'started', sessionId: newSessionId, display: plan.command.display, inPlace: inPlace.resumed };
|
|
125
155
|
} catch (error) {
|
|
126
156
|
if (verbose) {
|
|
127
157
|
console.log(`[VERBOSE] Could not start recovery session for ${sessionName}: ${error?.message || error}`);
|
|
@@ -135,7 +165,7 @@ export async function startKillRecoverySession({ sessionName, sessionInfo, plan,
|
|
|
135
165
|
* Never throws — a failed recovery must still leave a correct kill report.
|
|
136
166
|
*
|
|
137
167
|
* @param {Object} options - See planKillRecovery() and startKillRecoverySession()
|
|
138
|
-
* @returns {Promise<{resumed: boolean, reason: string, policy: string, sessionId: string|null, display: string|null, attempt: number, maxAttempts: number}>}
|
|
168
|
+
* @returns {Promise<{resumed: boolean, reason: string, policy: string, sessionId: string|null, display: string|null, attempt: number, maxAttempts: number, inPlace: boolean}>}
|
|
139
169
|
*/
|
|
140
170
|
export async function recoverKilledSession({ sessionName, sessionInfo, logPath = null, killed = false, env = process.env, runner = null, trackSession = null, persistSnapshot = null, verbose = false, readLastSessionId = readLastSessionIdFromLog } = {}) {
|
|
141
171
|
let plan;
|
|
@@ -143,15 +173,15 @@ export async function recoverKilledSession({ sessionName, sessionInfo, logPath =
|
|
|
143
173
|
plan = planKillRecovery({ sessionInfo, logPath, killed, env, verbose, readLastSessionId });
|
|
144
174
|
} catch (error) {
|
|
145
175
|
if (verbose) console.log(`[VERBOSE] Could not plan kill recovery for ${sessionName}: ${error?.message || error}`);
|
|
146
|
-
return { resumed: false, reason: 'plan-error', policy: ON_SESSION_KILL_RESUME, sessionId: null, display: null, attempt: 0, maxAttempts: 0 };
|
|
176
|
+
return { resumed: false, reason: 'plan-error', policy: ON_SESSION_KILL_RESUME, sessionId: null, display: null, attempt: 0, maxAttempts: 0, inPlace: false };
|
|
147
177
|
}
|
|
148
178
|
|
|
149
179
|
if (!plan.shouldResume) {
|
|
150
|
-
return { resumed: false, reason: plan.reason, policy: plan.policy, sessionId: null, display: plan.command?.display || null, attempt: plan.attempt, maxAttempts: plan.maxAttempts };
|
|
180
|
+
return { resumed: false, reason: plan.reason, policy: plan.policy, sessionId: null, display: plan.command?.display || null, attempt: plan.attempt, maxAttempts: plan.maxAttempts, inPlace: false };
|
|
151
181
|
}
|
|
152
182
|
|
|
153
183
|
const started = await startKillRecoverySession({ sessionName, sessionInfo, plan, runner, trackSession, persistSnapshot, verbose });
|
|
154
|
-
return { resumed: started.resumed, reason: started.reason, policy: plan.policy, sessionId: started.sessionId, display: started.display, attempt: plan.attempt, maxAttempts: plan.maxAttempts };
|
|
184
|
+
return { resumed: started.resumed, reason: started.reason, policy: plan.policy, sessionId: started.sessionId, display: started.display, attempt: plan.attempt, maxAttempts: plan.maxAttempts, inPlace: started.inPlace === true };
|
|
155
185
|
}
|
|
156
186
|
|
|
157
187
|
/**
|
|
@@ -95,6 +95,14 @@ export async function buildKillCompletionSections({ sessionName, sessionInfo, st
|
|
|
95
95
|
exitCode,
|
|
96
96
|
stopRequestedByUser: sessionInfo?.stopRequestedByUser === true,
|
|
97
97
|
locale,
|
|
98
|
+
// start-command 0.33.0 scans the log tail for fatal markers when the
|
|
99
|
+
// command exits and reports what it found (link-foundation/start#164,
|
|
100
|
+
// #165 — both filed from this issue). Pass it through: `$` saw the exit
|
|
101
|
+
// live, so it can carry evidence our bounded re-read of a huge log may
|
|
102
|
+
// have missed. Absent on an older `$`, which is why the local scan stays.
|
|
103
|
+
reportedMemoryExhausted: statusResult?.memoryExhausted ?? null,
|
|
104
|
+
reportedMemoryExhaustedReason: statusResult?.memoryExhaustedReason ?? null,
|
|
105
|
+
reportedExitReason: statusResult?.exitReason ?? null,
|
|
98
106
|
});
|
|
99
107
|
|
|
100
108
|
const argv = argvFromSessionArgs(sessionInfo?.args);
|
|
@@ -25,7 +25,8 @@ import { formatSessionCompletionMessage, getSessionCompletionExitCode, classifyS
|
|
|
25
25
|
import { notifySubscribers, getSubscriberCount } from './telegram-subscribers.lib.mjs';
|
|
26
26
|
import { safeSendMessage, safeEditMessageText } from './telegram-safe-reply.lib.mjs';
|
|
27
27
|
import { classifyExitStatus, normalizeExitCode } from './session-status.lib.mjs';
|
|
28
|
-
import {
|
|
28
|
+
import { buildResumeCommand, formatResumeSection } from './session-resume.lib.mjs';
|
|
29
|
+
import { readLogMarkerLines, readLogTextBounded, scanLogTextChunks } from './log-bounded-read.lib.mjs';
|
|
29
30
|
import { resolveFailedSessionPullRequestState } from './github-pr-state.lib.mjs';
|
|
30
31
|
// Issue #2117: a docker terminal failure that no anchored log footer corroborates may be an exit code start-command fabricated from the command's own output.
|
|
31
32
|
import { clearUnverifiedDockerTerminalMarker as clearUnverifiedDockerTerminalMarkerImpl, shouldDeferUnverifiedDockerTerminal as shouldDeferUnverifiedDockerTerminalImpl } from './session-monitor.docker-terminal.lib.mjs';
|
|
@@ -33,6 +34,8 @@ import { isDockerIsolation, sessionStartMs, resolveOomKilledState, resolveStaleE
|
|
|
33
34
|
// Issue #2134: kill-cause diagnostics + the matching pull-request notice.
|
|
34
35
|
import { buildKillCompletionSections, announceKillOnPullRequest } from './session-monitor.kill-sections.lib.mjs';
|
|
35
36
|
import { runKillRecoveryForCompletion } from './session-kill-resume.lib.mjs';
|
|
37
|
+
// Issue #2189: the handled latch + the memoized last-tool-session-id read that keep a completed session from replaying its whole completion pipeline on every poll.
|
|
38
|
+
import { isCompletionHandled, markCompletionHandled, resolveCachedLastToolSessionId } from './session-completion-state.lib.mjs';
|
|
36
39
|
import { createSessionRegistryQueries } from './session-monitor.queries.lib.mjs';
|
|
37
40
|
export { formatSessionCompletionMessage, getSessionCompletionExitCode } from './work-session-formatting.lib.mjs';
|
|
38
41
|
export { DOCKER_TERMINAL_FOOTER_GRACE_MS } from './session-monitor.docker-terminal.lib.mjs';
|
|
@@ -326,6 +329,8 @@ function normalizeSessionUrl(url) {
|
|
|
326
329
|
return url.replace(/#.*$/, '').replace(/\/+$/, '').toLowerCase();
|
|
327
330
|
}
|
|
328
331
|
const GITHUB_PULL_REQUEST_URL_RE = /https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/([0-9]+)/g;
|
|
332
|
+
/** Marker prefix parsed by `parseDiskMarkers` (issue #1945/#1988), used to collect just those lines. */
|
|
333
|
+
const DISK_MARKER_LINE_RE = /📊 \[DISK\] /;
|
|
329
334
|
export function extractPullRequestUrlFromText(text, { owner = null, repo = null } = {}) {
|
|
330
335
|
if (!text) return null;
|
|
331
336
|
const expectedOwner = owner ? String(owner).toLowerCase() : null;
|
|
@@ -344,8 +349,12 @@ export function extractPullRequestUrlFromText(text, { owner = null, repo = null
|
|
|
344
349
|
async function resolvePullRequestUrlFromSessionLog(logPath, ctx, { verbose = false, readFile = fs.readFile } = {}) {
|
|
345
350
|
if (!logPath) return null;
|
|
346
351
|
try {
|
|
347
|
-
|
|
348
|
-
|
|
352
|
+
// Issue #2189: this used to be `readFile(logPath, 'utf8')`, run once per
|
|
353
|
+
// monitor tick for a session the bot never marked handled — a 134 MB
|
|
354
|
+
// transcript pulled into the bot's own heap over and over. The chunked scan
|
|
355
|
+
// still covers the whole log, one chunk at a time, and stops at the first
|
|
356
|
+
// matching URL (which is printed early, when the PR is created).
|
|
357
|
+
const pullRequestUrl = await scanLogTextChunks(logPath, text => extractPullRequestUrlFromText(text, { owner: ctx.owner, repo: ctx.repo }), { readFile, verbose });
|
|
349
358
|
if (pullRequestUrl && verbose) {
|
|
350
359
|
console.log(`[VERBOSE] Found PR ${pullRequestUrl} in completed session log ${logPath}`);
|
|
351
360
|
}
|
|
@@ -370,7 +379,10 @@ export async function buildDiskDiagnosticsExtraSection(logPath, { verbose = fals
|
|
|
370
379
|
let logText = '';
|
|
371
380
|
if (logPath) {
|
|
372
381
|
try {
|
|
373
|
-
|
|
382
|
+
// Issue #2189: `parseDiskMarkers` only ever looks at `📊 [DISK]` lines,
|
|
383
|
+
// so collect those lines instead of the whole transcript. Cost is one
|
|
384
|
+
// chunk plus a few kilobytes of markers, whatever the log's size.
|
|
385
|
+
logText = await readLogMarkerLines(logPath, DISK_MARKER_LINE_RE, { readFile, verbose });
|
|
374
386
|
} catch (readError) {
|
|
375
387
|
if (verbose) {
|
|
376
388
|
console.log(`[VERBOSE] Could not read session log ${logPath} for disk diagnostics: ${readError?.message || readError}`);
|
|
@@ -402,7 +414,10 @@ export async function buildSubscriptionBlockedExtraSection(logPath, { verbose =
|
|
|
402
414
|
try {
|
|
403
415
|
let logText = '';
|
|
404
416
|
try {
|
|
405
|
-
|
|
417
|
+
// Issue #2189: the blocked report is printed as the run stops, so the
|
|
418
|
+
// bounded head+tail excerpt always contains it; the middle of a multi-
|
|
419
|
+
// gigabyte transcript never has to be resident to find it.
|
|
420
|
+
logText = await readLogTextBounded(logPath, { readFile, verbose });
|
|
406
421
|
} catch (readError) {
|
|
407
422
|
if (verbose) {
|
|
408
423
|
console.log(`[VERBOSE] Could not read session log ${logPath} for subscription block: ${readError?.message || readError}`);
|
|
@@ -439,6 +454,35 @@ async function getDockerContainerFilesystemSizeForSession(sessionName, sessionIn
|
|
|
439
454
|
return null;
|
|
440
455
|
}
|
|
441
456
|
}
|
|
457
|
+
/**
|
|
458
|
+
* How long a docker writable-layer measurement stays good enough while the
|
|
459
|
+
* session is still running (issue #2189).
|
|
460
|
+
*
|
|
461
|
+
* `docker ps --size` walks the container's whole writable layer. On the 27 GB
|
|
462
|
+
* layer in the captured incident that is expensive, and the monitor was paying
|
|
463
|
+
* it once per session per 30-second poll for no gain: the number is only ever
|
|
464
|
+
* *reported* at completion. The completion measurement is never throttled, so
|
|
465
|
+
* the number in the report is still fresh.
|
|
466
|
+
*/
|
|
467
|
+
export const DOCKER_FILESYSTEM_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
|
468
|
+
/**
|
|
469
|
+
* Whether the writable-layer size should be measured on this poll.
|
|
470
|
+
*
|
|
471
|
+
* @param {Object} sessionInfo
|
|
472
|
+
* @param {Object} [options]
|
|
473
|
+
* @param {boolean} [options.stillRunning] - The session is still executing
|
|
474
|
+
* @param {number} [options.now] - Epoch ms (injectable for tests)
|
|
475
|
+
* @param {number} [options.intervalMs]
|
|
476
|
+
* @returns {boolean}
|
|
477
|
+
*/
|
|
478
|
+
export function shouldRefreshDockerFilesystemSize(sessionInfo, { stillRunning = true, now = Date.now(), intervalMs = DOCKER_FILESYSTEM_REFRESH_INTERVAL_MS } = {}) {
|
|
479
|
+
if (sessionInfo?.isolationBackend !== 'docker') return false;
|
|
480
|
+
// The completion report quotes this number, so it is always measured fresh.
|
|
481
|
+
if (!stillRunning) return true;
|
|
482
|
+
const observedAt = Date.parse(sessionInfo?.containerFilesystemLastObservedAt || '');
|
|
483
|
+
if (!Number.isFinite(observedAt)) return true;
|
|
484
|
+
return now - observedAt >= intervalMs;
|
|
485
|
+
}
|
|
442
486
|
async function refreshDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, { verbose = false, sizeProvider = null } = {}) {
|
|
443
487
|
const bytes = await getDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, { verbose, sizeProvider });
|
|
444
488
|
if (!Number.isFinite(bytes)) return null;
|
|
@@ -665,6 +709,20 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
665
709
|
console.log(`[VERBOSE] Checking ${sessions.length} active session(s)...`);
|
|
666
710
|
}
|
|
667
711
|
for (const { sessionName, sessionInfo } of sessions) {
|
|
712
|
+
// Issue #2189: a session whose completion notification was already
|
|
713
|
+
// delivered is terminal. Without this latch the monitor re-entered the whole
|
|
714
|
+
// completion pipeline on every poll — re-resolving the linked pull request,
|
|
715
|
+
// re-scanning a 134 MB log, re-walking a 27 GB writable layer and re-sending
|
|
716
|
+
// a notification the user already had — because a late failure in that
|
|
717
|
+
// pipeline (or a bot restart) left the session tracked. Finalize it here,
|
|
718
|
+
// before any status probe, and do none of that work again.
|
|
719
|
+
if (isCompletionHandled(sessionInfo)) {
|
|
720
|
+
if (verbose) {
|
|
721
|
+
console.log(`[VERBOSE] Session ${sessionName} was already reported at ${sessionInfo.completionNotifiedAt}; finalizing without repeating the completion work (issue #2189)`);
|
|
722
|
+
}
|
|
723
|
+
completeSession(sessionName, sessionInfo.completionExitCode ?? 0, verbose, sessionInfo.completionStatus ?? null);
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
668
726
|
let stillRunning;
|
|
669
727
|
let exitCode = null;
|
|
670
728
|
let statusResult = null;
|
|
@@ -727,11 +785,13 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
727
785
|
}
|
|
728
786
|
}
|
|
729
787
|
}
|
|
730
|
-
if (sessionInfo
|
|
788
|
+
if (shouldRefreshDockerFilesystemSize(sessionInfo, { stillRunning })) {
|
|
731
789
|
observedContainerFilesystemBytes = await refreshDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, {
|
|
732
790
|
verbose,
|
|
733
791
|
sizeProvider: options.dockerContainerSizeProvider,
|
|
734
792
|
});
|
|
793
|
+
} else if (sessionInfo?.isolationBackend === 'docker' && verbose) {
|
|
794
|
+
console.log(`[VERBOSE] Session ${sessionName}: reusing the writable-layer size observed at ${sessionInfo.containerFilesystemLastObservedAt} (issue #2189: not re-walking the layer every poll)`);
|
|
735
795
|
}
|
|
736
796
|
if (!stillRunning) {
|
|
737
797
|
console.log(`Session ${sessionName} has finished. Sending notification to chat ${sessionInfo.chatId}`);
|
|
@@ -756,6 +816,10 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
756
816
|
statusResult,
|
|
757
817
|
readFile: options.readFile,
|
|
758
818
|
});
|
|
819
|
+
if (pullRequestUrl && sessionInfo.resolvedPullRequestUrl !== pullRequestUrl) {
|
|
820
|
+
sessionInfo.resolvedPullRequestUrl = pullRequestUrl;
|
|
821
|
+
persistSessionSnapshot(sessionName, sessionInfo);
|
|
822
|
+
}
|
|
759
823
|
} catch (lookupError) {
|
|
760
824
|
if (verbose) {
|
|
761
825
|
console.log(`[VERBOSE] Pull request lookup failed for ${sessionName}: ${lookupError?.message || lookupError}`);
|
|
@@ -806,8 +870,20 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
806
870
|
}
|
|
807
871
|
}
|
|
808
872
|
}
|
|
873
|
+
// Issue #2189: the last tool session id is read through the session
|
|
874
|
+
// record's cache, so the working-session log is scanned once per session
|
|
875
|
+
// instead of once per poll per consumer (the resume section below and the
|
|
876
|
+
// automatic recovery further down both need it, and both used to scan).
|
|
877
|
+
const resolveLastToolSessionId = logPath => {
|
|
878
|
+
const resolution = resolveCachedLastToolSessionId({ sessionInfo, logPath, verbose });
|
|
879
|
+
if (resolution.scanned) persistSessionSnapshot(sessionName, sessionInfo);
|
|
880
|
+
return resolution.id;
|
|
881
|
+
};
|
|
809
882
|
// Issue #1927: for a killed /solve, offer a command using the last tool
|
|
810
|
-
// session ID in the log.
|
|
883
|
+
// session ID in the log. Issue #2189 additionally *starts* that command
|
|
884
|
+
// by default (`--on-session-kill=resume`), bounded by
|
|
885
|
+
// `--session-kill-resume-attempts`, so the work is not left for a human
|
|
886
|
+
// to notice hours later.
|
|
811
887
|
const resumeExtraSections = [];
|
|
812
888
|
let killResumeCommand = null;
|
|
813
889
|
try {
|
|
@@ -821,7 +897,7 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
821
897
|
// `Session ID:` marker. Do not guess from neighboring UUID-named
|
|
822
898
|
// logs: start-command stores unrelated tasks in the same backend
|
|
823
899
|
// directory, which caused issue #2109's invalid resume id.
|
|
824
|
-
const lastSessionId =
|
|
900
|
+
const lastSessionId = resolveLastToolSessionId(logPath);
|
|
825
901
|
const resumeCommand = buildResumeCommand({ sessionInfo, lastSessionId });
|
|
826
902
|
const resumeSection = formatResumeSection({ lastSessionId, command: resumeCommand });
|
|
827
903
|
killResumeCommand = resumeCommand || null;
|
|
@@ -900,10 +976,21 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
900
976
|
runner: options.isolationRunner || null,
|
|
901
977
|
trackSession: options.trackSession || trackSession,
|
|
902
978
|
persistSnapshot: () => persistSessionSnapshot(sessionName, sessionInfo),
|
|
979
|
+
// Issue #2189: reuse the id already read above instead of scanning
|
|
980
|
+
// the same (possibly multi-gigabyte) log a second time.
|
|
981
|
+
readLastSessionId: logPath => resolveLastToolSessionId(logPath),
|
|
903
982
|
locale: sessionInfo?.locale || null,
|
|
904
983
|
verbose,
|
|
905
984
|
});
|
|
906
985
|
killRecovery = recovered.recovery;
|
|
986
|
+
if (killRecovery.resumed && killRecovery.sessionId) {
|
|
987
|
+
// Issue #2189: remember which session took over, so the durable
|
|
988
|
+
// history says what happened to this work and a restart cannot start
|
|
989
|
+
// a second recovery for the same kill.
|
|
990
|
+
sessionInfo.killRecoverySessionId = killRecovery.sessionId;
|
|
991
|
+
persistSessionSnapshot(sessionName, sessionInfo);
|
|
992
|
+
logEvent('session_kill_recovered', { sessionName, recoverySessionId: killRecovery.sessionId, attempt: killRecovery.attempt, maxAttempts: killRecovery.maxAttempts, policy: killRecovery.policy || null });
|
|
993
|
+
}
|
|
907
994
|
if (recovered.section) killReport.sections.push(recovered.section);
|
|
908
995
|
}
|
|
909
996
|
const message = formatSessionCompletionMessage({
|
|
@@ -952,6 +1039,16 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
952
1039
|
notifyFromChatId = sent?.chat?.id || sessionInfo.chatId;
|
|
953
1040
|
notifyMessageId = sent?.message_id || null;
|
|
954
1041
|
}
|
|
1042
|
+
// Issue #2189: the user has now been told. Latch that fact durably
|
|
1043
|
+
// BEFORE the remaining best-effort work (subscriber fan-out, container
|
|
1044
|
+
// cleanup), because any failure after this point used to send the whole
|
|
1045
|
+
// completion pipeline — and this notification — round again on the next
|
|
1046
|
+
// poll. The snapshot write means even a bot restart in this window
|
|
1047
|
+
// finalizes the session silently instead of re-notifying.
|
|
1048
|
+
if (markCompletionHandled(sessionInfo, { exitCode: finalExitCode, status: resolvedStatus })) {
|
|
1049
|
+
persistSessionSnapshot(sessionName, sessionInfo);
|
|
1050
|
+
logEvent('session_completion_notified', { sessionName, exitCode: finalExitCode ?? null, status: resolvedStatus || null, notifiedAt: sessionInfo.completionNotifiedAt });
|
|
1051
|
+
}
|
|
955
1052
|
// Issue #1688: forward the same completion message to every /subscribe-d user
|
|
956
1053
|
// in their private chat with the bot. Failures are logged but don't block
|
|
957
1054
|
// completion of the parent session.
|
|
@@ -1024,6 +1121,13 @@ async function resolvePullRequestUrlForSession(sessionInfo, { verbose = false, l
|
|
|
1024
1121
|
if (!ctx || ctx.type !== 'issue' || !ctx.owner || !ctx.repo || !ctx.number) {
|
|
1025
1122
|
return null;
|
|
1026
1123
|
}
|
|
1124
|
+
// Issue #2189: a completion that has to be retried must not re-run the linked-PR
|
|
1125
|
+
// lookup (an API round trip, then a scan of the session log). The answer cannot
|
|
1126
|
+
// change for a session that has already finished, so remember it.
|
|
1127
|
+
if (typeof sessionInfo.resolvedPullRequestUrl === 'string' && sessionInfo.resolvedPullRequestUrl) {
|
|
1128
|
+
if (verbose) console.log(`[VERBOSE] Reusing resolved pull request ${sessionInfo.resolvedPullRequestUrl} for this session (not looked up again)`);
|
|
1129
|
+
return sessionInfo.resolvedPullRequestUrl;
|
|
1130
|
+
}
|
|
1027
1131
|
if (typeof lookupLinkedPullRequest === 'function') {
|
|
1028
1132
|
const linkedPullRequestUrl = await lookupLinkedPullRequest(ctx);
|
|
1029
1133
|
if (linkedPullRequestUrl) return linkedPullRequestUrl;
|
|
@@ -1089,6 +1193,22 @@ export function startSessionMonitoring(bot, verbose = false, intervalMs = 30000,
|
|
|
1089
1193
|
* record whose startTime is after the current bot start (it cannot belong to a
|
|
1090
1194
|
* previous run), satisfying requirement #2's "started before bot start time".
|
|
1091
1195
|
*
|
|
1196
|
+
* Issue #2189 asks for the other half of that sentence — "on startup resume all
|
|
1197
|
+
* still-running / interrupted commands". Both cases are handled here plus the
|
|
1198
|
+
* first monitor tick, which runs synchronously after this function:
|
|
1199
|
+
*
|
|
1200
|
+
* - a session that is **still running** keeps running; re-registering it is
|
|
1201
|
+
* exactly what resumes it, and the bot reports it when it ends;
|
|
1202
|
+
* - a session that was **interrupted** (its backend is gone, or its log footer
|
|
1203
|
+
* records a kill) is detected as finished on that first tick and, under the
|
|
1204
|
+
* now-default `--on-session-kill=resume`, a recovery working session is
|
|
1205
|
+
* started from its last tool session id — bounded by
|
|
1206
|
+
* `--session-kill-resume-attempts`, whose counter is persisted, so a job
|
|
1207
|
+
* that dies every time cannot be relaunched once per bot restart forever;
|
|
1208
|
+
* - a session that was already **reported** before the previous process died
|
|
1209
|
+
* carries the persisted `completionNotifiedAt` latch and is finalized
|
|
1210
|
+
* silently, so a restart never re-notifies.
|
|
1211
|
+
*
|
|
1092
1212
|
* @param {object} [options]
|
|
1093
1213
|
* @param {object} [options.store] - Session store to load from (default: the store set via setSessionStore).
|
|
1094
1214
|
* @param {number} [options.botStartTime] - Epoch seconds; only sessions started strictly before this are resumed. Defaults to now.
|
|
@@ -1137,7 +1257,10 @@ export async function resumeTrackedSessions(options = {}) {
|
|
|
1137
1257
|
}
|
|
1138
1258
|
}
|
|
1139
1259
|
if (resumed.length > 0) {
|
|
1140
|
-
|
|
1260
|
+
// Issue #2189: say how many of these are already-reported leftovers, so the
|
|
1261
|
+
// startup line is not read as "N sessions are still working".
|
|
1262
|
+
const alreadyReported = resumed.filter(({ sessionInfo }) => isCompletionHandled(sessionInfo)).length;
|
|
1263
|
+
console.log(`♻️ Resumed monitoring of ${resumed.length} session(s) from durable store after restart${alreadyReported > 0 ? ` (${alreadyReported} already reported, will be finalized silently)` : ''}`);
|
|
1141
1264
|
} else if (verbose) {
|
|
1142
1265
|
console.log('[VERBOSE] resumeTrackedSessions: no eligible sessions to resume');
|
|
1143
1266
|
}
|
|
@@ -38,7 +38,21 @@ import path from 'node:path';
|
|
|
38
38
|
// `executionUuid` (#2154) is start-command's own identifier for the execution —
|
|
39
39
|
// the one `$ --list` prints. It differs from `sessionId`, so persisting it is
|
|
40
40
|
// what lets a restarted bot still correlate its sessions with the session list.
|
|
41
|
-
|
|
41
|
+
// Issue #2189 adds the fields that keep a completed session terminal and its
|
|
42
|
+
// per-poll cost constant:
|
|
43
|
+
// - `completionNotifiedAt`/`completionExitCode`/`completionStatus` latch the
|
|
44
|
+
// one delivered notification, so a restart between "notified" and
|
|
45
|
+
// "untracked" finalizes silently instead of re-running the whole completion
|
|
46
|
+
// pipeline and notifying the user again.
|
|
47
|
+
// - `lastToolSessionId` caches the marker found by scanning the working
|
|
48
|
+
// session log, so that scan is never O(log size) per poll.
|
|
49
|
+
// - `killRecoveryAttempts`/`killRecoverySessionId`/`killRecoveryOfSession`
|
|
50
|
+
// bound and record automatic recovery across restarts — without them a
|
|
51
|
+
// reliably-crashing job could restart once per bot launch forever.
|
|
52
|
+
// - `stopRequestedByUser`/`stopRequestedBy` must survive a restart too: with
|
|
53
|
+
// `--on-session-kill=resume` now the default, forgetting that an operator
|
|
54
|
+
// asked for the stop would relaunch the very work they cancelled.
|
|
55
|
+
const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'commandAlias', 'isolationBackend', 'sessionId', 'executionUuid', 'containerFilesystemStartBytes', 'containerFilesystemLastBytes', 'containerFilesystemLastObservedAt', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args', 'completionNotifiedAt', 'completionExitCode', 'completionStatus', 'lastToolSessionId', 'killRecoveryAttempts', 'killRecoverySessionId', 'killRecoveryOfSession', 'killRecoveryResumed', 'killRecoveryInPlace', 'killRecoveryResumeMode', 'stopRequestedByUser', 'stopRequestedBy', 'onSessionKill', 'resolvedPullRequestUrl'];
|
|
42
56
|
|
|
43
57
|
/**
|
|
44
58
|
* Resolve the directory durable bot state is written to. Honors
|