@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.hi.md +12 -0
  3. package/README.md +15 -0
  4. package/README.ru.md +15 -0
  5. package/README.zh.md +24 -12
  6. package/package.json +24 -17
  7. package/src/agent-snapshot-store.lib.mjs +252 -0
  8. package/src/agent.lib.mjs +25 -25
  9. package/src/agent.version-gates.lib.mjs +73 -0
  10. package/src/bot-lifecycle.lib.mjs +63 -5
  11. package/src/child-exit.lib.mjs +53 -1
  12. package/src/claude.session-tokens.lib.mjs +10 -8
  13. package/src/claude.session-transcript-repair.lib.mjs +65 -34
  14. package/src/cleanup.mjs +57 -3
  15. package/src/codex.lib.mjs +11 -1
  16. package/src/development-log.lib.mjs +22 -7
  17. package/src/disk-guard.lib.mjs +21 -1
  18. package/src/formal-ai-version.lib.mjs +10 -6
  19. package/src/github-error-reporter.lib.mjs +84 -2
  20. package/src/github.lib.mjs +212 -152
  21. package/src/instrument.mjs +12 -14
  22. package/src/instrument.sanitize.lib.mjs +52 -0
  23. package/src/isolation-runner.lib.mjs +56 -33
  24. package/src/isolation-runner.parsers.lib.mjs +29 -3
  25. package/src/isolation-runner.resume.lib.mjs +263 -0
  26. package/src/log-bounded-read.lib.mjs +411 -0
  27. package/src/log-sanitize-stream.lib.mjs +267 -0
  28. package/src/log-sanitize-worker-entry.mjs +31 -0
  29. package/src/log-sanitize-worker.lib.mjs +186 -0
  30. package/src/log-upload.lib.mjs +16 -4
  31. package/src/pull-request-changes.lib.mjs +1 -1
  32. package/src/session-completion-state.lib.mjs +124 -0
  33. package/src/session-kill-diagnostics.lib.mjs +117 -12
  34. package/src/session-kill-policy.lib.mjs +18 -7
  35. package/src/session-kill-resume.in-place.lib.mjs +136 -0
  36. package/src/session-kill-resume.lib.mjs +48 -18
  37. package/src/session-monitor.kill-sections.lib.mjs +8 -0
  38. package/src/session-monitor.lib.mjs +132 -9
  39. package/src/session-store.lib.mjs +15 -1
  40. package/src/solve.clone-errors.lib.mjs +86 -0
  41. package/src/solve.config.lib.mjs +5 -2
  42. package/src/solve.repository.lib.mjs +36 -63
  43. package/src/solve.resource-diagnostics.lib.mjs +105 -5
  44. package/src/start-command-cli.lib.mjs +60 -0
  45. package/src/telegram-bot.mjs +31 -91
  46. package/src/telegram-log-command.lib.mjs +7 -3
  47. package/src/telegram-overrides-validation.lib.mjs +73 -0
  48. package/src/telegram-terminal-watch-command.lib.mjs +9 -1
  49. 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
- * Nothing here runs under the default `report` policy behaviour is unchanged
15
- * unless the operator opts in.
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
- * The new session is launched through the same isolation runner the original
66
- * used and is tracked like any other session, so it reports its own completion
67
- * (and, if it is killed too, its own diagnosis) through the normal path.
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
- const newSessionId = runner.generateSessionId();
89
- const tool = sessionInfo?.tool || 'claude';
90
- const result = await runner.executeWithIsolation(sessionInfo?.command || 'solve', plan.command.args, { backend, sessionId: newSessionId, tool, verbose });
91
- if (!result?.success) return fail('start-failed');
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: Number.isFinite(result.containerFilesystemStartBytes) ? result.containerFilesystemStartBytes : null,
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
- console.log(`[VERBOSE] Session ${sessionName} was killed; started recovery session ${newSessionId} (attempt ${plan.attempt}/${plan.maxAttempts}): ${plan.command.display}`);
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 { readLastSessionIdFromLog, buildResumeCommand, formatResumeSection } from './session-resume.lib.mjs';
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
- const logText = await readFile(logPath, 'utf8');
348
- const pullRequestUrl = extractPullRequestUrlFromText(logText, { owner: ctx.owner, repo: ctx.repo });
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
- logText = await readFile(logPath, 'utf8');
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
- logText = await readFile(logPath, 'utf8');
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?.isolationBackend === 'docker') {
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. Do not auto-relaunch work that may reliably OOM.
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 = readLastSessionIdFromLog(logPath, { verbose });
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
- console.log(`♻️ Resumed monitoring of ${resumed.length} session(s) from durable store after restart`);
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
- const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'commandAlias', 'isolationBackend', 'sessionId', 'executionUuid', 'containerFilesystemStartBytes', 'containerFilesystemLastBytes', 'containerFilesystemLastObservedAt', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args'];
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