@link-assistant/hive-mind 2.8.8 → 2.8.9

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 CHANGED
@@ -1,5 +1,27 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.8.9
4
+
5
+ ### Patch Changes
6
+
7
+ - fd1789e: fix(development-log): collect a development log for every working session (#2090)
8
+
9
+ A run with `--auto-restart-until-mergeable` (implied by `--auto-merge`) starts a
10
+ new tool session with its own session UUID per restart iteration, but only the
11
+ first session ever reached the pull request:
12
+ `createDevelopmentLogFinalizer` memoized a single collection per process, no
13
+ restart path invoked the finalizer again, and several exit paths (usage limit,
14
+ tool failure, graceful shutdown, auto-continue) skipped finalization entirely.
15
+ The single collected `solve.log` was also truncated at collection time and was
16
+ committed twice, as a byte-identical duplicate under `<tool>-<sessionId>.log`.
17
+
18
+ The finalizer is now memoized per session id, every restart iteration finalizes
19
+ its own session at the shared `executeToolIteration` chokepoint, `safeExit`
20
+ forces a final collection on every exit path, and each session directory stores
21
+ only its own byte range of the process log (`metadata.json` schema version 3,
22
+ `artifacts.solveLogRange`) so the union of the sessions is the complete log
23
+ without duplication.
24
+
3
25
  ## 2.8.8
4
26
 
5
27
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.8.8",
3
+ "version": "2.8.9",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -1,11 +1,105 @@
1
1
  /**
2
- * Build a once-only finalizer so both the normal and error completion paths can
3
- * preserve a development log without creating duplicate commits.
2
+ * Build a per-session finalizer so every completion path can preserve a
3
+ * development log without creating duplicate commits.
4
+ *
5
+ * Issue #1596 introduced a once-only finalizer: the first call collected the
6
+ * artifacts and every later call reused the memoized promise. Issue #2090
7
+ * showed that this silently drops every session after the first one — a run
8
+ * with `--auto-restart-until-mergeable` starts a brand new tool session (a new
9
+ * session UUID) per restart iteration, but only the very first session ever
10
+ * reached the repository.
11
+ *
12
+ * The finalizer is therefore memoized *per session id* instead of per process:
13
+ *
14
+ * - the same session id is collected only once (no duplicate commits),
15
+ * - a different session id is collected again into its own `sessions/<uuid>/`
16
+ * directory,
17
+ * - each collection copies only the slice of the solve log that was produced
18
+ * since the previous collection, so the union of all session directories is
19
+ * the complete process log without duplicating megabytes per session.
4
20
  */
5
- export const createDevelopmentLogFinalizer = ({ collect, getParams }) => {
6
- let resultPromise = null;
7
- return () => {
8
- if (!resultPromise) resultPromise = Promise.resolve().then(() => collect(getParams()));
21
+ export const createDevelopmentLogFinalizer = ({ collect, getParams, register = true }) => {
22
+ // sessionKey -> promise of the collection result (dedupe per session).
23
+ const collections = new Map();
24
+ // sessionKey -> { startByte, sessionId } of an already collected session.
25
+ const sessionStartBytes = new Map();
26
+ // Byte offset in the solve log right after the last collected slice.
27
+ let nextLogStartByte = 0;
28
+ // Key of the session collected most recently, so a forced finalize at exit
29
+ // extends *that* session's log slice instead of re-collecting the first one.
30
+ let lastSessionKey = null;
31
+ // Serializes collections so their log slices never interleave.
32
+ let queue = Promise.resolve();
33
+
34
+ const toKey = sessionId => (sessionId ? String(sessionId) : '__no-session__');
35
+
36
+ const finalize = (options = {}) => {
37
+ const params = { ...getParams() };
38
+ if (options.sessionId !== undefined && options.sessionId !== null) params.sessionId = options.sessionId;
39
+
40
+ let sessionKey = toKey(params.sessionId);
41
+ if (options.force && options.sessionId === undefined && lastSessionKey) {
42
+ // Exit-time collection: the log tail belongs to the session collected
43
+ // most recently (which is a restart-iteration session, not the first one
44
+ // still referenced by the caller's `sessionId` variable).
45
+ sessionKey = lastSessionKey;
46
+ params.sessionId = sessionStartBytes.get(sessionKey)?.sessionId ?? params.sessionId;
47
+ }
48
+ const alreadyCollected = collections.has(sessionKey);
49
+
50
+ // Re-collecting the same session is only useful when the caller explicitly
51
+ // asks for it (process exit, to capture the log tail produced after the
52
+ // session finished). Otherwise reuse the memoized result.
53
+ if (alreadyCollected && !options.force) return collections.get(sessionKey);
54
+
55
+ lastSessionKey = sessionKey;
56
+
57
+ // Collections are serialized: each one commits and pushes, and the log
58
+ // slice boundaries only make sense when resolved sequentially.
59
+ const resultPromise = queue
60
+ .catch(() => {})
61
+ .then(() => {
62
+ const known = sessionStartBytes.get(sessionKey);
63
+ const logStartByte = known ? known.startByte : nextLogStartByte;
64
+ sessionStartBytes.set(sessionKey, { startByte: logStartByte, sessionId: params.sessionId ?? null });
65
+ return collect({ ...params, logStartByte });
66
+ })
67
+ .then(result => {
68
+ const endByte = result?.logEndByte;
69
+ if (typeof endByte === 'number' && endByte > nextLogStartByte) nextLogStartByte = endByte;
70
+ return result;
71
+ });
72
+
73
+ queue = resultPromise.catch(() => {});
74
+ collections.set(sessionKey, resultPromise);
9
75
  return resultPromise;
10
76
  };
77
+
78
+ finalize.getCollectedSessionKeys = () => [...collections.keys()];
79
+ // Publish the finalizer so restart iterations (watch mode,
80
+ // auto-restart-until-mergeable, keep-working, escalation, auto-ensure) and
81
+ // every exit path can collect the session they just finished.
82
+ if (register) setActiveDevelopmentLogFinalizer(finalize);
83
+ return finalize;
84
+ };
85
+
86
+ // Module-level registry so restart iterations deep in the call tree (watch
87
+ // mode, auto-restart-until-mergeable, keep-working, escalation, auto-ensure)
88
+ // can finalize the development log of the session they just finished without
89
+ // threading the finalizer through every call signature.
90
+ let activeFinalizer = null;
91
+
92
+ export const setActiveDevelopmentLogFinalizer = finalizer => {
93
+ activeFinalizer = typeof finalizer === 'function' ? finalizer : null;
94
+ };
95
+
96
+ export const getActiveDevelopmentLogFinalizer = () => activeFinalizer;
97
+
98
+ export const finalizeActiveDevelopmentLog = async (options = {}) => {
99
+ if (!activeFinalizer) return { skipped: 'no-active-finalizer' };
100
+ try {
101
+ return await activeFinalizer(options);
102
+ } catch (error) {
103
+ return { skipped: 'error', error };
104
+ }
11
105
  };
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs/promises';
2
+ import { createReadStream, createWriteStream } from 'node:fs';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
5
+ import { pipeline } from 'node:stream/promises';
4
6
 
5
7
  const sanitizePathSegment = (value, fallback) => {
6
8
  const raw = value === null || value === undefined || value === '' ? fallback : String(value);
@@ -113,6 +115,23 @@ const findCodexSessionFile = async ({ sessionId, homeDir }) => {
113
115
  }
114
116
  };
115
117
 
118
+ // Copy a byte range of the solve log into the session directory.
119
+ // Issue #2090: each session stores only the slice of the process log that was
120
+ // produced while that session was running, so the union of all session
121
+ // directories is the complete log instead of N truncated copies of the same
122
+ // prefix. Returns the byte offset right after the copied slice.
123
+ const copyLogSlice = async ({ logFile, destinationPath, logStartByte = 0 }) => {
124
+ const stat = await fs.stat(logFile);
125
+ // The log was rotated/truncated since the previous collection: copy it whole.
126
+ const start = Number.isFinite(logStartByte) && logStartByte > 0 && logStartByte <= stat.size ? logStartByte : 0;
127
+ if (stat.size === 0 || start >= stat.size) {
128
+ await fs.writeFile(destinationPath, '');
129
+ return { logStartByte: start, logEndByte: stat.size };
130
+ }
131
+ await pipeline(createReadStream(logFile, { start, end: stat.size - 1 }), createWriteStream(destinationPath));
132
+ return { logStartByte: start, logEndByte: stat.size };
133
+ };
134
+
116
135
  const copyKnownSessionFiles = async ({ repositoryPath, sessionRelativeDirectory, logFile, sessionId, tool, homeDir }) => {
117
136
  if (!sessionId) return [];
118
137
 
@@ -149,9 +168,16 @@ const copyKnownSessionFiles = async ({ repositoryPath, sessionRelativeDirectory,
149
168
 
150
169
  const copied = [];
151
170
  const seenSources = new Set();
171
+ // Issue #2090: when the tool renamed the running solve log to
172
+ // `<sessionId>.log`, this candidate resolves to the very log file that is
173
+ // already copied as solve.log — copying it again duplicated megabytes per
174
+ // session (PR link-assistant/formal-ai#809 stored two byte-identical 7 MB
175
+ // files). Skip it instead.
176
+ const resolvedLogFile = logFile ? path.resolve(logFile) : null;
152
177
  for (const candidate of candidates) {
153
178
  if (!candidate.sourcePath || seenSources.has(candidate.sourcePath)) continue;
154
179
  seenSources.add(candidate.sourcePath);
180
+ if (resolvedLogFile && path.resolve(candidate.sourcePath) === resolvedLogFile) continue;
155
181
 
156
182
  const relativePath = `${sessionRelativeDirectory}/${safeFileName(candidate.destinationName)}`;
157
183
  const copiedPath = path.join(sessionDirectory, safeFileName(candidate.destinationName));
@@ -163,7 +189,7 @@ const copyKnownSessionFiles = async ({ repositoryPath, sessionRelativeDirectory,
163
189
  return copied;
164
190
  };
165
191
 
166
- export const writeDevelopmentLogArtifacts = async ({ repositoryPath, logFile, issueNumber, prNumber, tool, sessionId, branchName, rawCommand, now = new Date(), homeDir = os.homedir() }) => {
192
+ export const writeDevelopmentLogArtifacts = async ({ repositoryPath, logFile, issueNumber, prNumber, tool, sessionId, branchName, rawCommand, logStartByte = 0, now = new Date(), homeDir = os.homedir() }) => {
167
193
  if (!repositoryPath) {
168
194
  throw new Error('repositoryPath is required to write development-log artifacts');
169
195
  }
@@ -179,9 +205,14 @@ export const writeDevelopmentLogArtifacts = async ({ repositoryPath, logFile, is
179
205
  await fs.mkdir(sessionDirectory, { recursive: true });
180
206
 
181
207
  let copiedLogRelativePath = null;
208
+ let logSlice = { logStartByte: 0, logEndByte: 0 };
182
209
  if (logFile) {
183
210
  copiedLogRelativePath = `${sessionRelativeDirectory}/solve.log`;
184
- await fs.copyFile(logFile, path.join(repositoryPath, copiedLogRelativePath));
211
+ logSlice = await copyLogSlice({
212
+ logFile,
213
+ destinationPath: path.join(repositoryPath, copiedLogRelativePath),
214
+ logStartByte,
215
+ });
185
216
  }
186
217
 
187
218
  const sessionFiles = await copyKnownSessionFiles({
@@ -195,7 +226,9 @@ export const writeDevelopmentLogArtifacts = async ({ repositoryPath, logFile, is
195
226
 
196
227
  const metadataRelativePath = `${sessionRelativeDirectory}/metadata.json`;
197
228
  const metadata = {
198
- schemaVersion: 2,
229
+ // v3 (issue #2090): one directory per tool session, `solve.log` holds only
230
+ // this session's slice of the process log (see solveLogRange).
231
+ schemaVersion: 3,
199
232
  collectedAt: now.toISOString(),
200
233
  issueNumber: issueNumber ?? null,
201
234
  prNumber: prNumber ?? null,
@@ -207,6 +240,7 @@ export const writeDevelopmentLogArtifacts = async ({ repositoryPath, logFile, is
207
240
  caseStudyDirectory,
208
241
  artifacts: {
209
242
  solveLog: copiedLogRelativePath ? addDotSlash(toPosixPath(copiedLogRelativePath)) : null,
243
+ solveLogRange: copiedLogRelativePath ? { startByte: logSlice.logStartByte, endByte: logSlice.logEndByte } : null,
210
244
  sessionFiles,
211
245
  },
212
246
  };
@@ -221,12 +255,14 @@ export const writeDevelopmentLogArtifacts = async ({ repositoryPath, logFile, is
221
255
  copiedLogRelativePath: copiedLogRelativePath ? toPosixPath(copiedLogRelativePath) : null,
222
256
  metadataRelativePath: toPosixPath(metadataRelativePath),
223
257
  sessionFiles,
258
+ logStartByte: logSlice.logStartByte,
259
+ logEndByte: logSlice.logEndByte,
224
260
  };
225
261
  };
226
262
 
227
263
  const getCommandOutput = result => (result?.stderr?.toString?.() || result?.stdout?.toString?.() || '').trim();
228
264
 
229
- export const collectAndCommitDevelopmentLogArtifacts = async ({ enabled, repositoryPath, logFile, issueNumber, prNumber, tool, sessionId, branchName, rawCommand, $, log }) => {
265
+ export const collectAndCommitDevelopmentLogArtifacts = async ({ enabled, repositoryPath, logFile, issueNumber, prNumber, tool, sessionId, branchName, rawCommand, logStartByte = 0, $, log }) => {
230
266
  if (!enabled) {
231
267
  return { skipped: 'disabled' };
232
268
  }
@@ -237,7 +273,7 @@ export const collectAndCommitDevelopmentLogArtifacts = async ({ enabled, reposit
237
273
  }
238
274
 
239
275
  // Issue #2048: verbose trace so the commit timing (relative to PR readiness signals) is diagnosable from logs.
240
- await log?.(`🔍 Development log finalize: issue #${issueNumber ?? '?'}, PR #${prNumber ?? 'pending'}, branch ${branchName ?? 'none'}, session ${sessionId ?? 'none'}`, { verbose: true });
276
+ await log?.(`🔍 Development log finalize: issue #${issueNumber ?? '?'}, PR #${prNumber ?? 'pending'}, branch ${branchName ?? 'none'}, session ${sessionId ?? 'none'}, log slice from byte ${logStartByte}`, { verbose: true });
241
277
 
242
278
  try {
243
279
  const artifacts = await writeDevelopmentLogArtifacts({
@@ -249,8 +285,10 @@ export const collectAndCommitDevelopmentLogArtifacts = async ({ enabled, reposit
249
285
  sessionId,
250
286
  branchName,
251
287
  rawCommand,
288
+ logStartByte,
252
289
  });
253
290
 
291
+ await log?.(`🧾 Development log artifacts written to ${artifacts.sessionRelativeDirectory} (log bytes ${artifacts.logStartByte}-${artifacts.logEndByte})`, { verbose: true });
254
292
  await log?.(`🧾 Development log artifacts written to ${artifacts.developmentLogDirectory}`);
255
293
 
256
294
  if (!$) {
@@ -9,6 +9,11 @@
9
9
  // Issue #1823: working-session guard for --do-not-shutdown-in-the-middle-of-working-session.
10
10
  // Static import is safe: working-session.lib.mjs has no heavy deps and does NOT import this module.
11
11
  import { isFlagEnabled as isWorkingSessionFlagEnabled, isWorkingSessionActive, requestShutdown as requestWorkingSessionShutdown, forceKillActiveChildren as forceKillWorkingSessionChildren } from './working-session.lib.mjs';
12
+ // Issue #2090: preserve the development log on every exit path (usage limit
13
+ // reached, tool failure, repository setup failure, graceful shutdown,
14
+ // auto-continue hand-off). No-op unless solve registered a finalizer, so hive
15
+ // and other consumers of this module are unaffected.
16
+ import { finalizeActiveDevelopmentLog } from './development-log.finalize.lib.mjs';
12
17
 
13
18
  // Lazy-load Sentry to avoid keeping the event loop alive when not needed
14
19
  let Sentry = null;
@@ -241,6 +246,10 @@ export const logActiveHandles = async (log = null) => {
241
246
  export const safeExit = async (code = 0, reason = 'Process completed', { skipPreExit = false, failureActionSection = null } = {}) => {
242
247
  await showExitMessage(reason, code);
243
248
 
249
+ // Issue #2090: collect the working session that is still uncollected (and the
250
+ // log tail produced after it) before the process goes away.
251
+ await finalizeActiveDevelopmentLog({ force: true });
252
+
244
253
  if (!skipPreExit && code !== 0 && preExitFunction && !preExitHandlerRan) {
245
254
  preExitHandlerRan = true;
246
255
  try {
@@ -476,6 +476,18 @@ export const executeToolIteration = async params => {
476
476
  diskPath: '/',
477
477
  label: 'after AI restart iteration',
478
478
  });
479
+
480
+ // Issue #2090: every restart iteration starts a brand new tool session with
481
+ // its own session UUID. Collect its development log here — this is the single
482
+ // chokepoint shared by watch mode, auto-restart-until-mergeable,
483
+ // keep-working, escalation and auto-ensure — otherwise only the very first
484
+ // session of the process ever reached the pull request.
485
+ // When the tool did not report a session id there is nothing to key a new
486
+ // directory on, so extend the previously collected session instead of losing
487
+ // this iteration's part of the log.
488
+ const { finalizeActiveDevelopmentLog } = await import('./development-log.finalize.lib.mjs');
489
+ await (toolResult?.sessionId ? finalizeActiveDevelopmentLog({ sessionId: toolResult.sessionId }) : finalizeActiveDevelopmentLog({ force: true }));
490
+
479
491
  return toolResult;
480
492
  };
481
493