@akira-tl/forgerelay 0.3.5 → 0.3.7

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
@@ -4,6 +4,35 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.3.7] - 2026-08-10
8
+
9
+ ### Added
10
+
11
+ - Added debug-only runtime resource telemetry for RSS/V8 heap, MCP transport count, running/completed process counts, cached workspaces, and review checkpoint state so long-running deployments can identify which resource class is growing.
12
+
13
+ ### Changed
14
+
15
+ - Background `bash` completion state is now retained for at most five minutes, completed process handles are released immediately, completed notices are globally bounded, active processes have a global concurrency budget, and per-process retained output is smaller. High-output head/tail truncation no longer materializes whole strings as Unicode code-point arrays, sharply reducing transient heap growth and GC pressure.
16
+ - Abandoned MCP transport sessions and in-memory review checkpoint states now have hard capacity limits. Review state is also released when its logical workspace closes, while persisted Git checkpoint refs remain available for reconstruction.
17
+ - Workspace instruction discovery is now bounded and demand-driven: `open_workspace` scans only the workspace root and direct child directories, while deeper `AGENTS.md` / `CLAUDE.md` files are discovered along paths as the Agent first accesses them. Reads surface newly discovered instructions inline; mutation and shell calls stop before side effects and require a retry after newly discovered local instructions are applied.
18
+ - Workspace/session activity timestamps now use a small in-process write-behind cache. Hot `lastUsedAt` touches are coalesced and flushed to SQLite in one transaction at most every five minutes, with an explicit final flush during normal shutdown; semantic create/close/status writes remain immediate.
19
+
20
+ ### Fixed
21
+
22
+ - Expired, never-redeemed OAuth authorization codes are opportunistically evicted and cleared on provider shutdown instead of remaining in memory for the lifetime of the server.
23
+
24
+ ## [0.3.6] - 2026-08-10
25
+
26
+ ### Added
27
+
28
+ - `open_workspace(action="list")` now provides paginated logical-workspace inventory without adding a tenth Core tool. Inventory entries include a compact `project/workspaceId` label, persisted status, derived lifecycle state, checkout/worktree backing metadata, creation/last-used timestamps, idle duration, root validity, current-conversation selection, and filters for workspace ID, status, state, mode, root, and stale-only views.
29
+
30
+ ### Changed
31
+
32
+ - Workspace bootstrap context is now deduplicated by conversation scope, canonical workspace target, and a content fingerprint instead of by logical `workspaceId`. `context="auto"` remains the default, `context="full"` forces a refresh, and `context="none"` opens or resumes a workspace without returning the full AGENTS/Skills/guide/profile bootstrap.
33
+ - Context-delivery state is persisted independently from logical-workspace selection, so switching or closing one logical handle does not make the same conversation forget already-delivered project context. Changes to loaded instruction contents or relevant Skill, guide, profile, diagnostic, or nested-instruction metadata change the fingerprint and cause `auto` to deliver the refreshed context again.
34
+ - Workspace inventory is read-only with respect to workspace activity timestamps and runs the existing idle-session GC before listing. Persisted `status="active"` continues to mean the session has not been explicitly closed, while the derived `state` distinguishes currently active, stale-but-valid, invalid/missing-root, and closed records.
35
+
7
36
  ## [0.3.5] - 2026-08-10
8
37
 
9
38
  ### Changed
@@ -28,9 +28,9 @@ processId: <number>
28
28
 
29
29
  `action="run"` 与 `action="process"` 的参数不要混用。Process ownership 始终绑定原 `workspaceId`;未知或跨 workspace 的 `processId` 会被拒绝。
30
30
 
31
- 等待超时不会隐式 kill process。若没有必要立即等待,可以继续其他工作;进程完成后,ForgeRelay 会把 completion notice 一次性附加到同一 logical workspace 的后续 tool result
31
+ 等待超时不会隐式 kill process。若没有必要立即等待,可以继续其他工作;进程完成后,ForgeRelay 会把 completion notice 一次性附加到同一 logical workspace 的后续 tool result。未消费的 completed process notice 最多保留 5 分钟;进程退出时底层 ChildProcess/PTY handle 会立即释放。ForgeRelay 同时对 active process 与 completed notice 数量设置全局资源预算,达到 active process 上限时会拒绝启动新的命令,而不会擅自终止已有长任务。
32
32
 
33
- 不要因为暂时没有输出就重复启动相同长进程;先用返回的 `processId` poll
33
+ 不要因为暂时没有输出就重复启动相同长进程;先用返回的 `processId` poll。高输出命令会使用有界 head/tail buffer,不能把 ForgeRelay 当作无限历史日志存储。
34
34
 
35
35
  ## PTY / interactive commands
36
36
 
@@ -29,6 +29,11 @@ const migrations = [
29
29
  name: "local-agent-hook-reports",
30
30
  up: migrateLocalAgentHookReports,
31
31
  },
32
+ {
33
+ version: 7,
34
+ name: "workspace-context-deliveries",
35
+ up: migrateWorkspaceContextDeliveries,
36
+ },
32
37
  ];
33
38
  export function migrateDatabase(sqlite) {
34
39
  const migrate = sqlite.transaction(() => {
@@ -189,6 +194,20 @@ function migrateWorkspaceWorktreeBranches(sqlite) {
189
194
  function migrateLocalAgentHookReports(sqlite) {
190
195
  addColumnIfMissing(sqlite, "local_agent_sessions", "hook_reports_json", "text");
191
196
  }
197
+ function migrateWorkspaceContextDeliveries(sqlite) {
198
+ sqlite.exec(`
199
+ create table if not exists workspace_context_deliveries (
200
+ conversation_scope_id text not null,
201
+ target_key text not null,
202
+ context_fingerprint text not null,
203
+ delivered_at text not null,
204
+ primary key (conversation_scope_id, target_key)
205
+ );
206
+
207
+ create index if not exists workspace_context_deliveries_delivered_idx
208
+ on workspace_context_deliveries(delivered_at desc);
209
+ `);
210
+ }
192
211
  function addColumnIfMissing(sqlite, table, column, definition) {
193
212
  const columns = sqlite.prepare(`pragma table_info(${table})`).all();
194
213
  if (columns.some((existingColumn) => existingColumn.name === column))
package/dist/db/schema.js CHANGED
@@ -41,6 +41,15 @@ export const workspaceConversationBindings = sqliteTable("workspace_conversation
41
41
  primaryKey({ columns: [table.conversationScopeId, table.targetKey] }),
42
42
  index("workspace_conversation_bindings_workspace_idx").on(table.workspaceSessionId),
43
43
  ]);
44
+ export const workspaceContextDeliveries = sqliteTable("workspace_context_deliveries", {
45
+ conversationScopeId: text("conversation_scope_id").notNull(),
46
+ targetKey: text("target_key").notNull(),
47
+ contextFingerprint: text("context_fingerprint").notNull(),
48
+ deliveredAt: text("delivered_at").notNull(),
49
+ }, (table) => [
50
+ primaryKey({ columns: [table.conversationScopeId, table.targetKey] }),
51
+ index("workspace_context_deliveries_delivered_idx").on(table.deliveredAt),
52
+ ]);
44
53
  export const oauthClients = sqliteTable("oauth_clients", {
45
54
  clientId: text("client_id").primaryKey(),
46
55
  clientJson: text("client_json").notNull(),
package/dist/logger.js CHANGED
@@ -113,6 +113,8 @@ function formatPrettyMessage(entry, options) {
113
113
  case "mcp_transport_session_close_failed":
114
114
  case "mcp_session_close_failed":
115
115
  return `transport session ${transportSessionPrefix(entry) ?? "unknown"} close -> ${style("red", "error", options)}`;
116
+ case "runtime_resources":
117
+ return formatRuntimeResources(entry);
116
118
  case "auth_denied":
117
119
  return `auth denied${entry.reason ? `: ${String(entry.reason)}` : ""}`;
118
120
  case "mcp_request_error":
@@ -212,6 +214,20 @@ function stableColorIndex(value) {
212
214
  }
213
215
  return (hash >>> 0) % WORKSPACE_PROJECT_COLORS.length;
214
216
  }
217
+ function formatRuntimeResources(entry) {
218
+ const rssMb = bytesToMegabytes(numberField(entry.rssBytes));
219
+ const heapUsedMb = bytesToMegabytes(numberField(entry.heapUsedBytes));
220
+ const heapTotalMb = bytesToMegabytes(numberField(entry.heapTotalBytes));
221
+ const transports = numberField(entry.mcpTransports) ?? 0;
222
+ const running = numberField(entry.processesRunning) ?? 0;
223
+ const completed = numberField(entry.processesCompleted) ?? 0;
224
+ const workspaces = numberField(entry.cachedWorkspaces) ?? 0;
225
+ const reviewStates = numberField(entry.reviewStates) ?? 0;
226
+ return `runtime rss=${rssMb}MB heap=${heapUsedMb}/${heapTotalMb}MB transports=${transports} processes=${running} running/${completed} completed workspaces=${workspaces} review=${reviewStates}`;
227
+ }
228
+ function bytesToMegabytes(value) {
229
+ return value === undefined ? 0 : Math.round(value / (1024 * 1024));
230
+ }
215
231
  function formatGenericMessage(entry) {
216
232
  const event = String(entry.event ?? "log");
217
233
  const detail = [entry.reason, entry.error]
@@ -1,8 +1,14 @@
1
1
  export class McpTransportRegistry {
2
2
  transports = new Map();
3
3
  now;
4
+ maxTransports;
4
5
  constructor(options = {}) {
5
6
  this.now = options.now ?? Date.now;
7
+ this.maxTransports = options.maxTransports ?? Number.POSITIVE_INFINITY;
8
+ if (this.maxTransports !== Number.POSITIVE_INFINITY &&
9
+ (!Number.isInteger(this.maxTransports) || this.maxTransports < 1)) {
10
+ throw new Error("MCP transport limit must be a positive integer.");
11
+ }
6
12
  }
7
13
  get size() {
8
14
  return this.transports.size;
@@ -12,6 +18,30 @@ export class McpTransportRegistry {
12
18
  transport,
13
19
  lastActivityAt: this.now(),
14
20
  });
21
+ const excess = [];
22
+ while (this.transports.size > this.maxTransports) {
23
+ let oldestTransportSessionId;
24
+ let oldestActivityAt = Number.POSITIVE_INFINITY;
25
+ for (const [candidateSessionId, entry] of this.transports) {
26
+ if (candidateSessionId === transportSessionId && this.transports.size > 1)
27
+ continue;
28
+ if (entry.lastActivityAt >= oldestActivityAt)
29
+ continue;
30
+ oldestTransportSessionId = candidateSessionId;
31
+ oldestActivityAt = entry.lastActivityAt;
32
+ }
33
+ if (!oldestTransportSessionId)
34
+ break;
35
+ const oldest = this.transports.get(oldestTransportSessionId);
36
+ this.transports.delete(oldestTransportSessionId);
37
+ if (oldest) {
38
+ excess.push({
39
+ transportSessionId: oldestTransportSessionId,
40
+ transport: oldest.transport,
41
+ });
42
+ }
43
+ }
44
+ return closeTransports(excess);
15
45
  }
16
46
  get(transportSessionId) {
17
47
  const entry = this.transports.get(transportSessionId);
@@ -116,6 +116,7 @@ export class SingleUserOAuthProvider {
116
116
  }));
117
117
  return;
118
118
  }
119
+ this.pruneExpiredAuthorizationCodes();
119
120
  const code = `code-${randomUUID()}`;
120
121
  this.codes.set(code, {
121
122
  clientId: client.client_id,
@@ -177,9 +178,17 @@ export class SingleUserOAuthProvider {
177
178
  this.oauthStore.deleteRefreshToken(hashed);
178
179
  }
179
180
  close() {
181
+ this.codes.clear();
180
182
  this.oauthStore.close();
181
183
  }
184
+ pruneExpiredAuthorizationCodes(nowMs = Date.now()) {
185
+ for (const [code, record] of this.codes) {
186
+ if (record.expiresAtMs < nowMs)
187
+ this.codes.delete(code);
188
+ }
189
+ }
182
190
  validCodeRecord(client, authorizationCode) {
191
+ this.pruneExpiredAuthorizationCodes();
183
192
  const record = this.codes.get(authorizationCode);
184
193
  if (!record || record.clientId !== client.client_id || record.expiresAtMs < Date.now()) {
185
194
  throw new InvalidGrantError("Invalid authorization code");
@@ -7,8 +7,10 @@ const MAX_START_YIELD_MS = 300_000;
7
7
  const MAX_COMMAND_YIELD_MS = 300_000;
8
8
  const MAX_POLL_YIELD_MS = 300_000;
9
9
  const DEFAULT_MAX_OUTPUT_TOKENS = 10_000;
10
- const DEFAULT_BUFFER_CHARACTERS = 1_000_000;
11
- const COMPLETED_PROCESS_TTL_MS = 24 * 60 * 60 * 1_000;
10
+ const DEFAULT_BUFFER_CHARACTERS = 256_000;
11
+ const DEFAULT_MAX_ACTIVE_PROCESSES = 64;
12
+ const DEFAULT_MAX_COMPLETED_PROCESSES = 128;
13
+ const COMPLETED_PROCESS_TTL_MS = 5 * 60 * 1_000;
12
14
  const DEFAULT_COLUMNS = 80;
13
15
  const DEFAULT_ROWS = 24;
14
16
  function boundedInteger(value, fallback, maximum) {
@@ -56,21 +58,55 @@ function processEnvironment(input) {
56
58
  };
57
59
  }
58
60
  function codePointLength(value) {
59
- return Array.from(value).length;
60
- }
61
- function sliceCodePoints(value, start, end) {
62
- return Array.from(value).slice(start, end).join("");
61
+ let characters = 0;
62
+ for (let index = 0; index < value.length; index += 1) {
63
+ const codeUnit = value.charCodeAt(index);
64
+ if (codeUnit >= 0xd800 && codeUnit <= 0xdbff &&
65
+ index + 1 < value.length) {
66
+ const nextCodeUnit = value.charCodeAt(index + 1);
67
+ if (nextCodeUnit >= 0xdc00 && nextCodeUnit <= 0xdfff)
68
+ index += 1;
69
+ }
70
+ characters += 1;
71
+ }
72
+ return characters;
63
73
  }
64
74
  function takeHead(value, count) {
65
75
  if (count <= 0)
66
76
  return "";
67
- return sliceCodePoints(value, 0, count);
77
+ let index = 0;
78
+ let characters = 0;
79
+ while (index < value.length && characters < count) {
80
+ const codeUnit = value.charCodeAt(index);
81
+ if (codeUnit >= 0xd800 && codeUnit <= 0xdbff &&
82
+ index + 1 < value.length) {
83
+ const nextCodeUnit = value.charCodeAt(index + 1);
84
+ index += nextCodeUnit >= 0xdc00 && nextCodeUnit <= 0xdfff ? 2 : 1;
85
+ }
86
+ else {
87
+ index += 1;
88
+ }
89
+ characters += 1;
90
+ }
91
+ return value.slice(0, index);
68
92
  }
69
93
  function takeTail(value, count) {
70
94
  if (count <= 0)
71
95
  return "";
72
- const characters = Array.from(value);
73
- return characters.slice(Math.max(0, characters.length - count)).join("");
96
+ let index = value.length;
97
+ let characters = 0;
98
+ while (index > 0 && characters < count) {
99
+ index -= 1;
100
+ const codeUnit = value.charCodeAt(index);
101
+ if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff &&
102
+ index > 0) {
103
+ const previousCodeUnit = value.charCodeAt(index - 1);
104
+ if (previousCodeUnit >= 0xd800 && previousCodeUnit <= 0xdbff)
105
+ index -= 1;
106
+ }
107
+ characters += 1;
108
+ }
109
+ return value.slice(index);
74
110
  }
75
111
  function splitBudget(maxCharacters) {
76
112
  return {
@@ -97,20 +133,29 @@ export class HeadTailBuffer {
97
133
  append(output) {
98
134
  if (!output)
99
135
  return;
136
+ const outputCharacters = codePointLength(output);
100
137
  const previousTotal = this.totalCharacters;
101
- this.totalCharacters += codePointLength(output);
138
+ this.totalCharacters += outputCharacters;
102
139
  if (this.totalCharacters <= this.maxCharacters) {
103
140
  this.head += output;
104
141
  return;
105
142
  }
106
143
  const budget = splitBudget(this.maxCharacters);
107
144
  if (previousTotal <= this.maxCharacters) {
108
- const fullOutput = this.head + output;
109
- this.head = takeHead(fullOutput, budget.head);
110
- this.tail = takeTail(fullOutput, budget.tail);
145
+ const previousHead = this.head;
146
+ this.head = previousTotal >= budget.head
147
+ ? takeHead(previousHead, budget.head)
148
+ : previousHead + takeHead(output, budget.head - previousTotal);
149
+ this.tail = outputCharacters >= budget.tail
150
+ ? takeTail(output, budget.tail)
151
+ : takeTail(previousHead, budget.tail - outputCharacters) + output;
152
+ return;
153
+ }
154
+ if (outputCharacters >= budget.tail) {
155
+ this.tail = takeTail(output, budget.tail);
111
156
  return;
112
157
  }
113
- this.tail = takeTail(this.tail + output, budget.tail);
158
+ this.tail = takeTail(this.tail, budget.tail - outputCharacters) + output;
114
159
  }
115
160
  hasOutput() {
116
161
  return this.totalCharacters > 0;
@@ -145,13 +190,24 @@ function truncateOutput(output, maxCharacters) {
145
190
  export class ProcessManager {
146
191
  processes = new Map();
147
192
  completedByWorkspace = new Map();
193
+ completedProcessIds = [];
148
194
  maxBufferCharacters;
195
+ maxActiveProcesses;
196
+ maxCompletedProcesses;
149
197
  completedProcessTtlMs;
150
198
  maxStartYieldMs;
151
199
  monotonicNow;
152
200
  nextProcessId = 1;
153
201
  constructor(options = {}) {
154
202
  this.maxBufferCharacters = options.maxBufferCharacters ?? DEFAULT_BUFFER_CHARACTERS;
203
+ this.maxActiveProcesses = options.maxActiveProcesses ?? DEFAULT_MAX_ACTIVE_PROCESSES;
204
+ if (!Number.isInteger(this.maxActiveProcesses) || this.maxActiveProcesses < 1) {
205
+ throw new Error("Active process limit must be a positive integer.");
206
+ }
207
+ this.maxCompletedProcesses = options.maxCompletedProcesses ?? DEFAULT_MAX_COMPLETED_PROCESSES;
208
+ if (!Number.isInteger(this.maxCompletedProcesses) || this.maxCompletedProcesses < 1) {
209
+ throw new Error("Completed process limit must be a positive integer.");
210
+ }
155
211
  this.completedProcessTtlMs = options.completedProcessTtlMs
156
212
  ?? options.completedSessionTtlMs
157
213
  ?? COMPLETED_PROCESS_TTL_MS;
@@ -159,6 +215,9 @@ export class ProcessManager {
159
215
  this.monotonicNow = options.monotonicNow ?? (() => performance.now());
160
216
  }
161
217
  async start(input) {
218
+ if (this.stats().running >= this.maxActiveProcesses) {
219
+ throw new Error(`Active process limit reached (${this.maxActiveProcesses}). Poll, interrupt, or wait for an existing process before starting another.`);
220
+ }
162
221
  const processEntry = this.createProcess(input);
163
222
  this.processes.set(processEntry.id, processEntry);
164
223
  try {
@@ -214,6 +273,17 @@ export class ProcessManager {
214
273
  activeWorkspaceIds() {
215
274
  return new Set([...this.processes.values()].map((processEntry) => processEntry.workspaceId));
216
275
  }
276
+ stats() {
277
+ let running = 0;
278
+ let completed = 0;
279
+ for (const processEntry of this.processes.values()) {
280
+ if (processEntry.running)
281
+ running += 1;
282
+ else
283
+ completed += 1;
284
+ }
285
+ return { total: this.processes.size, running, completed };
286
+ }
217
287
  takeCompleted(workspaceId, maxOutputTokens, excludeProcessId) {
218
288
  const processIds = this.completedByWorkspace.get(workspaceId) ?? [];
219
289
  if (processIds.length === 0)
@@ -250,6 +320,7 @@ export class ProcessManager {
250
320
  }
251
321
  this.processes.clear();
252
322
  this.completedByWorkspace.clear();
323
+ this.completedProcessIds.length = 0;
253
324
  }
254
325
  async waitForExit(processEntry, yieldTimeMs) {
255
326
  let timer;
@@ -352,16 +423,24 @@ export class ProcessManager {
352
423
  processEntry.running = false;
353
424
  processEntry.exitCode = exitCode;
354
425
  processEntry.signal = signal;
426
+ processEntry.process = undefined;
355
427
  processEntry.resolveExit();
428
+ processEntry.cleanupTimer = setTimeout(() => this.removeProcess(processEntry.id), this.completedProcessTtlMs);
429
+ processEntry.cleanupTimer.unref();
356
430
  if (processEntry.background) {
357
431
  const completed = this.completedByWorkspace.get(processEntry.workspaceId) ?? [];
358
432
  if (!completed.includes(processEntry.id)) {
359
433
  completed.push(processEntry.id);
360
434
  this.completedByWorkspace.set(processEntry.workspaceId, completed);
435
+ this.completedProcessIds.push(processEntry.id);
436
+ }
437
+ while (this.completedProcessIds.length > this.maxCompletedProcesses) {
438
+ const oldestProcessId = this.completedProcessIds[0];
439
+ if (oldestProcessId === undefined)
440
+ break;
441
+ this.removeProcess(oldestProcessId);
361
442
  }
362
443
  }
363
- processEntry.cleanupTimer = setTimeout(() => this.removeProcess(processEntry.id), this.completedProcessTtlMs);
364
- processEntry.cleanupTimer.unref();
365
444
  }
366
445
  append(processEntry, output) {
367
446
  processEntry.buffer.append(output);
@@ -396,6 +475,9 @@ export class ProcessManager {
396
475
  if (processEntry?.cleanupTimer)
397
476
  clearTimeout(processEntry.cleanupTimer);
398
477
  this.processes.delete(processId);
478
+ const completedIndex = this.completedProcessIds.indexOf(processId);
479
+ if (completedIndex >= 0)
480
+ this.completedProcessIds.splice(completedIndex, 1);
399
481
  if (!processEntry)
400
482
  return;
401
483
  const completed = this.completedByWorkspace.get(processEntry.workspaceId);
@@ -3,26 +3,60 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { git, getGitEligibility, safeWorkspaceRefSegment } from "./git.js";
5
5
  const REVIEW_REF_PREFIX = "refs/devspace/review";
6
- export function createReviewCheckpointManager() {
6
+ const DEFAULT_MAX_REVIEW_WORKSPACE_STATES = 128;
7
+ export function createReviewCheckpointManager(options = {}) {
8
+ const maxWorkspaceStates = options.maxWorkspaceStates ?? DEFAULT_MAX_REVIEW_WORKSPACE_STATES;
9
+ if (!Number.isInteger(maxWorkspaceStates) || maxWorkspaceStates < 1) {
10
+ throw new Error("Review checkpoint workspace-state limit must be a positive integer.");
11
+ }
7
12
  const states = new Map();
8
13
  const initializations = new Map();
14
+ const touchState = (workspaceId) => {
15
+ const state = states.get(workspaceId);
16
+ if (!state)
17
+ return;
18
+ states.delete(workspaceId);
19
+ states.set(workspaceId, state);
20
+ };
21
+ const trimStates = () => {
22
+ while (states.size > maxWorkspaceStates) {
23
+ const oldestWorkspaceId = states.keys().next().value;
24
+ if (!oldestWorkspaceId)
25
+ break;
26
+ states.delete(oldestWorkspaceId);
27
+ }
28
+ };
9
29
  return {
30
+ get stateCount() {
31
+ return states.size;
32
+ },
33
+ async releaseWorkspace(workspaceId) {
34
+ const pending = initializations.get(workspaceId);
35
+ if (pending)
36
+ await pending;
37
+ states.delete(workspaceId);
38
+ },
10
39
  async initializeWorkspace({ workspaceId, root }) {
11
40
  const existingState = states.get(workspaceId);
12
41
  assertWorkspaceRoot(existingState, workspaceId, root);
13
42
  if (existingState?.root === root && existingState.gitRoot !== undefined) {
43
+ touchState(workspaceId);
14
44
  return;
15
45
  }
16
46
  const pending = initializations.get(workspaceId);
17
47
  if (pending) {
18
48
  await pending;
19
49
  assertWorkspaceRoot(states.get(workspaceId), workspaceId, root);
50
+ touchState(workspaceId);
51
+ trimStates();
20
52
  return;
21
53
  }
22
54
  const initialize = initializeWorkspaceState(states, workspaceId, root);
23
55
  initializations.set(workspaceId, initialize);
24
56
  try {
25
57
  await initialize;
58
+ touchState(workspaceId);
59
+ trimStates();
26
60
  }
27
61
  finally {
28
62
  if (initializations.get(workspaceId) === initialize) {
@@ -38,6 +72,7 @@ export function createReviewCheckpointManager() {
38
72
  state = states.get(workspaceId);
39
73
  }
40
74
  assertWorkspaceRoot(state, workspaceId, root);
75
+ touchState(workspaceId);
41
76
  if (!state?.gitRoot) {
42
77
  throw new Error(state?.diagnostic ?? "review.changes requires a Git workspace in this version.");
43
78
  }