byterover-cli 3.5.1 → 3.6.1

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 (84) hide show
  1. package/.env.production +4 -6
  2. package/dist/agent/core/interfaces/i-cipher-agent.d.ts +1 -0
  3. package/dist/agent/infra/agent/cipher-agent.d.ts +1 -0
  4. package/dist/agent/infra/agent/cipher-agent.js +1 -0
  5. package/dist/oclif/commands/curate/view.js +5 -25
  6. package/dist/oclif/commands/dream.d.ts +18 -0
  7. package/dist/oclif/commands/dream.js +230 -0
  8. package/dist/oclif/commands/query-log/summary.d.ts +18 -0
  9. package/dist/oclif/commands/query-log/summary.js +75 -0
  10. package/dist/oclif/commands/query-log/view.d.ts +23 -0
  11. package/dist/oclif/commands/query-log/view.js +95 -0
  12. package/dist/oclif/lib/time-filter.d.ts +10 -0
  13. package/dist/oclif/lib/time-filter.js +21 -0
  14. package/dist/server/config/environment.d.ts +10 -3
  15. package/dist/server/config/environment.js +34 -15
  16. package/dist/server/constants.d.ts +5 -0
  17. package/dist/server/constants.js +7 -0
  18. package/dist/server/core/domain/entities/query-log-entry.d.ts +61 -0
  19. package/dist/server/core/domain/entities/query-log-entry.js +40 -0
  20. package/dist/server/core/domain/transport/schemas.d.ts +108 -7
  21. package/dist/server/core/domain/transport/schemas.js +34 -2
  22. package/dist/server/core/interfaces/executor/i-query-executor.d.ts +23 -2
  23. package/dist/server/core/interfaces/i-terminal.d.ts +3 -0
  24. package/dist/server/core/interfaces/i-terminal.js +1 -0
  25. package/dist/server/core/interfaces/storage/i-query-log-store.d.ts +23 -0
  26. package/dist/server/core/interfaces/storage/i-query-log-store.js +2 -0
  27. package/dist/server/core/interfaces/usecase/i-query-log-summary-use-case.d.ts +44 -0
  28. package/dist/server/core/interfaces/usecase/i-query-log-summary-use-case.js +1 -0
  29. package/dist/server/core/interfaces/usecase/i-query-log-use-case.d.ts +13 -0
  30. package/dist/server/core/interfaces/usecase/i-query-log-use-case.js +3 -0
  31. package/dist/server/infra/daemon/agent-process.js +79 -9
  32. package/dist/server/infra/daemon/brv-server.js +74 -5
  33. package/dist/server/infra/dream/dream-lock-service.d.ts +37 -0
  34. package/dist/server/infra/dream/dream-lock-service.js +88 -0
  35. package/dist/server/infra/dream/dream-log-schema.d.ts +966 -0
  36. package/dist/server/infra/dream/dream-log-schema.js +57 -0
  37. package/dist/server/infra/dream/dream-log-store.d.ts +55 -0
  38. package/dist/server/infra/dream/dream-log-store.js +141 -0
  39. package/dist/server/infra/dream/dream-response-schemas.d.ts +219 -0
  40. package/dist/server/infra/dream/dream-response-schemas.js +38 -0
  41. package/dist/server/infra/dream/dream-state-schema.d.ts +67 -0
  42. package/dist/server/infra/dream/dream-state-schema.js +23 -0
  43. package/dist/server/infra/dream/dream-state-service.d.ts +38 -0
  44. package/dist/server/infra/dream/dream-state-service.js +91 -0
  45. package/dist/server/infra/dream/dream-trigger.d.ts +46 -0
  46. package/dist/server/infra/dream/dream-trigger.js +65 -0
  47. package/dist/server/infra/dream/dream-undo.d.ts +38 -0
  48. package/dist/server/infra/dream/dream-undo.js +293 -0
  49. package/dist/server/infra/dream/operations/consolidate.d.ts +52 -0
  50. package/dist/server/infra/dream/operations/consolidate.js +514 -0
  51. package/dist/server/infra/dream/operations/prune.d.ts +45 -0
  52. package/dist/server/infra/dream/operations/prune.js +362 -0
  53. package/dist/server/infra/dream/operations/synthesize.d.ts +37 -0
  54. package/dist/server/infra/dream/operations/synthesize.js +278 -0
  55. package/dist/server/infra/dream/parse-dream-response.d.ts +11 -0
  56. package/dist/server/infra/dream/parse-dream-response.js +35 -0
  57. package/dist/server/infra/executor/curate-executor.js +10 -0
  58. package/dist/server/infra/executor/dream-executor.d.ts +97 -0
  59. package/dist/server/infra/executor/dream-executor.js +431 -0
  60. package/dist/server/infra/executor/query-executor.d.ts +2 -2
  61. package/dist/server/infra/executor/query-executor.js +92 -22
  62. package/dist/server/infra/process/feature-handlers.js +10 -6
  63. package/dist/server/infra/process/query-log-handler.d.ts +42 -0
  64. package/dist/server/infra/process/query-log-handler.js +150 -0
  65. package/dist/server/infra/process/task-router.d.ts +40 -0
  66. package/dist/server/infra/process/task-router.js +67 -9
  67. package/dist/server/infra/process/transport-handlers.d.ts +4 -0
  68. package/dist/server/infra/process/transport-handlers.js +1 -0
  69. package/dist/server/infra/storage/file-curate-log-store.js +1 -1
  70. package/dist/server/infra/storage/file-query-log-store.d.ts +81 -0
  71. package/dist/server/infra/storage/file-query-log-store.js +249 -0
  72. package/dist/server/infra/transport/handlers/config-handler.js +1 -1
  73. package/dist/server/infra/usecase/curate-log-use-case.js +7 -3
  74. package/dist/server/infra/usecase/query-log-summary-narrative-formatter.d.ts +15 -0
  75. package/dist/server/infra/usecase/query-log-summary-narrative-formatter.js +79 -0
  76. package/dist/server/infra/usecase/query-log-summary-use-case.d.ts +13 -0
  77. package/dist/server/infra/usecase/query-log-summary-use-case.js +217 -0
  78. package/dist/server/infra/usecase/query-log-use-case.d.ts +31 -0
  79. package/dist/server/infra/usecase/query-log-use-case.js +128 -0
  80. package/dist/server/utils/log-format-utils.d.ts +5 -0
  81. package/dist/server/utils/log-format-utils.js +23 -0
  82. package/dist/shared/transport/events/config-events.d.ts +1 -1
  83. package/oclif.manifest.json +510 -255
  84. package/package.json +1 -1
@@ -21,6 +21,7 @@
21
21
  import { connectToTransport } from '@campfirein/brv-transport-client';
22
22
  import { randomUUID } from 'node:crypto';
23
23
  import { appendFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
24
25
  import { SESSIONS_DIR } from '../../../agent/core/domain/session/session-metadata.js';
25
26
  import { CipherAgent } from '../../../agent/infra/agent/index.js';
26
27
  import { FileSystemService } from '../../../agent/infra/file-system/file-system-service.js';
@@ -30,14 +31,22 @@ import { createSearchKnowledgeService } from '../../../agent/infra/tools/impleme
30
31
  import { AuthEvents } from '../../../shared/transport/events/auth-events.js';
31
32
  import { decodeSearchContent } from '../../../shared/transport/search-content.js';
32
33
  import { getCurrentConfig } from '../../config/environment.js';
33
- import { DEFAULT_LLM_MODEL, PROJECT } from '../../constants.js';
34
+ import { BRV_DIR, DEFAULT_LLM_MODEL, PROJECT } from '../../constants.js';
34
35
  import { serializeTaskError, TaskError, TaskErrorCode } from '../../core/domain/errors/task-error.js';
35
36
  import { loadSources } from '../../core/domain/source/source-schema.js';
36
37
  import { TransportAgentEventNames, TransportDaemonEventNames, TransportStateEventNames, TransportTaskEventNames, } from '../../core/domain/transport/schemas.js';
38
+ import { FileContextTreeArchiveService } from '../context-tree/file-context-tree-archive-service.js';
39
+ import { DreamLockService } from '../dream/dream-lock-service.js';
40
+ import { DreamLogStore } from '../dream/dream-log-store.js';
41
+ import { DreamStateService } from '../dream/dream-state-service.js';
42
+ import { DreamTrigger } from '../dream/dream-trigger.js';
37
43
  import { CurateExecutor } from '../executor/curate-executor.js';
44
+ import { DreamExecutor } from '../executor/dream-executor.js';
38
45
  import { FolderPackExecutor } from '../executor/folder-pack-executor.js';
39
46
  import { QueryExecutor } from '../executor/query-executor.js';
40
47
  import { SearchExecutor } from '../executor/search-executor.js';
48
+ import { FileCurateLogStore } from '../storage/file-curate-log-store.js';
49
+ import { FileReviewBackupStore } from '../storage/file-review-backup-store.js';
41
50
  import { AgentInstanceDiscovery } from '../transport/agent-instance-discovery.js';
42
51
  import { createAgentLogger } from './agent-logger.js';
43
52
  import { resolveSessionId } from './session-resolver.js';
@@ -198,7 +207,7 @@ async function start() {
198
207
  const sharedAllowedPaths = (sourcesData?.origins ?? []).map((o) => o.contextTreeRoot);
199
208
  const envConfig = getCurrentConfig();
200
209
  const agentConfig = {
201
- apiBaseUrl: envConfig.llmApiBaseUrl,
210
+ apiBaseUrl: envConfig.llmBaseUrl,
202
211
  fileSystem: { allowedPaths: ['.', ...sharedAllowedPaths], workingDirectory: projectPath },
203
212
  llm: {
204
213
  maxIterations: 10,
@@ -307,7 +316,7 @@ async function start() {
307
316
  transport.on(TransportTaskEventNames.EXECUTE, (task) => {
308
317
  agentLog(`task:execute received taskId=${task.taskId} type=${task.type} activeTaskCount=${activeTaskCount + 1}`);
309
318
  // eslint-disable-next-line no-void
310
- void executeTask(task, curateExecutor, folderPackExecutor, queryExecutor, searchExecutor);
319
+ void executeTask(task, curateExecutor, folderPackExecutor, queryExecutor, searchExecutor, searchService, configResult.storagePath);
311
320
  });
312
321
  // 8. Register with transport server (for TransportHandlers tracking)
313
322
  await transport.requestWithAck('agent:register', { projectPath });
@@ -315,8 +324,8 @@ async function start() {
315
324
  process.send?.({ clientId, type: 'ready' });
316
325
  agentLog('Ready — listening for tasks');
317
326
  }
318
- async function executeTask(task, curateExecutor, folderPackExecutor, queryExecutor, searchExecutor) {
319
- const { clientCwd, clientId, content, files, folderPath, taskId, type, worktreeRoot } = task;
327
+ async function executeTask(task, curateExecutor, folderPackExecutor, queryExecutor, searchExecutor, searchKnowledgeService, storagePath) {
328
+ const { clientCwd, clientId, content, files, folderPath, force, taskId, trigger, type, worktreeRoot } = task;
320
329
  if (!transport || !agent)
321
330
  return;
322
331
  // Search tasks are pure BM25 retrieval — no LLM, no provider needed.
@@ -385,9 +394,17 @@ async function executeTask(task, curateExecutor, folderPackExecutor, queryExecut
385
394
  }
386
395
  try {
387
396
  let result;
397
+ let logId;
388
398
  switch (type) {
389
399
  case 'curate': {
390
- result = await curateExecutor.executeWithAgent(agent, { clientCwd, content, files, projectRoot: projectPath, taskId, worktreeRoot });
400
+ result = await curateExecutor.executeWithAgent(agent, {
401
+ clientCwd,
402
+ content,
403
+ files,
404
+ projectRoot: projectPath,
405
+ taskId,
406
+ worktreeRoot,
407
+ });
391
408
  break;
392
409
  }
393
410
  case 'curate-folder': {
@@ -401,8 +418,61 @@ async function executeTask(task, curateExecutor, folderPackExecutor, queryExecut
401
418
  });
402
419
  break;
403
420
  }
421
+ case 'dream': {
422
+ const brvDir = join(projectPath, BRV_DIR);
423
+ const dreamLockService = new DreamLockService({ baseDir: brvDir });
424
+ const dreamStateService = new DreamStateService({ baseDir: brvDir });
425
+ // Run trigger check (acquires lock if eligible).
426
+ // Gate 3 (queue) is pre-checked by the daemon (TransportHandlers.preDispatchCheck
427
+ // for CLI dispatch, onAgentIdle for idle-trigger dispatch), so the agent treats
428
+ // its own queue view as empty. Gates 1 (time) and 2 (activity) are re-checked here
429
+ // as defense-in-depth in case state drifted between dispatch and execution.
430
+ const dreamTrigger = new DreamTrigger({
431
+ dreamLockService,
432
+ dreamStateService,
433
+ getQueueLength: () => 0,
434
+ });
435
+ const eligibility = await dreamTrigger.tryStartDream(projectPath, force);
436
+ if (!eligibility.eligible) {
437
+ result = `Dream skipped: ${eligibility.reason}`;
438
+ break;
439
+ }
440
+ const dreamExecutor = new DreamExecutor({
441
+ archiveService: new FileContextTreeArchiveService(),
442
+ curateLogStore: new FileCurateLogStore({ baseDir: storagePath }),
443
+ dreamLockService,
444
+ dreamLogStore: new DreamLogStore({ baseDir: brvDir }),
445
+ dreamStateService,
446
+ reviewBackupStore: new FileReviewBackupStore(brvDir),
447
+ searchService: searchKnowledgeService,
448
+ });
449
+ const dreamResult = await dreamExecutor.executeWithAgent(agent, {
450
+ priorMtime: eligibility.priorMtime,
451
+ projectRoot: projectPath,
452
+ taskId,
453
+ trigger: trigger ?? 'cli',
454
+ });
455
+ result = dreamResult.result;
456
+ logId = dreamResult.logId;
457
+ break;
458
+ }
404
459
  case 'query': {
405
- result = await queryExecutor.executeWithAgent(agent, { query: content, taskId, worktreeRoot });
460
+ const queryResult = await queryExecutor.executeWithAgent(agent, { query: content, taskId, worktreeRoot });
461
+ result = queryResult.response;
462
+ // Send query metadata to daemon for QueryLogHandler (crosses process boundary via transport).
463
+ // Must arrive BEFORE task:completed so setQueryResult runs before onTaskCompleted.
464
+ try {
465
+ transport.request(TransportTaskEventNames.QUERY_RESULT, {
466
+ matchedDocs: queryResult.matchedDocs,
467
+ searchMetadata: queryResult.searchMetadata,
468
+ taskId,
469
+ tier: queryResult.tier,
470
+ timing: queryResult.timing,
471
+ });
472
+ }
473
+ catch {
474
+ agentLog(`task:queryResult send failed taskId=${taskId}`);
475
+ }
406
476
  break;
407
477
  }
408
478
  case 'search': {
@@ -415,7 +485,7 @@ async function executeTask(task, curateExecutor, folderPackExecutor, queryExecut
415
485
  // Emit task:completed
416
486
  agentLog(`task:completed taskId=${taskId}`);
417
487
  try {
418
- transport.request(TransportTaskEventNames.COMPLETED, { clientId, result, taskId });
488
+ transport.request(TransportTaskEventNames.COMPLETED, { clientId, ...(logId ? { logId } : {}), projectPath, result, taskId });
419
489
  }
420
490
  catch (error) {
421
491
  agentLog(`task:completed send failed taskId=${taskId}: ${error instanceof Error ? error.message : String(error)}`);
@@ -426,7 +496,7 @@ async function executeTask(task, curateExecutor, folderPackExecutor, queryExecut
426
496
  const errorData = serializeTaskError(error);
427
497
  agentLog(`task:error taskId=${taskId} error=${errorData.message}`);
428
498
  try {
429
- transport.request(TransportTaskEventNames.ERROR, { clientId, error: errorData, taskId });
499
+ transport.request(TransportTaskEventNames.ERROR, { clientId, error: errorData, projectPath, taskId });
430
500
  }
431
501
  catch (error_) {
432
502
  agentLog(`task:error send failed taskId=${taskId}: ${error_ instanceof Error ? error_.message : String(error_)}`);
@@ -23,21 +23,25 @@
23
23
  import { GlobalInstanceManager } from '@campfirein/brv-transport-client';
24
24
  import express from 'express';
25
25
  import { fork } from 'node:child_process';
26
+ import { randomUUID } from 'node:crypto';
26
27
  import { mkdirSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
27
28
  import { dirname, join } from 'node:path';
28
29
  import { fileURLToPath } from 'node:url';
29
30
  import { ReviewEvents } from '../../../shared/transport/events/review-events.js';
30
31
  import { AGENT_IDLE_CHECK_INTERVAL_MS, AGENT_IDLE_TIMEOUT_MS, AGENT_POOL_MAX_SIZE, BRV_DIR, HEARTBEAT_FILE, } from '../../constants.js';
31
- import { TransportStateEventNames } from '../../core/domain/transport/schemas.js';
32
+ import { TransportStateEventNames, TransportTaskEventNames, } from '../../core/domain/transport/schemas.js';
32
33
  import { getGlobalDataDir } from '../../utils/global-data-path.js';
33
34
  import { getProjectDataDir } from '../../utils/path-utils.js';
34
35
  import { crashLog, processLog } from '../../utils/process-logger.js';
35
36
  import { ClientManager } from '../client/client-manager.js';
36
37
  import { ProjectConfigStore } from '../config/file-config-store.js';
38
+ import { DreamStateService } from '../dream/dream-state-service.js';
39
+ import { DreamTrigger } from '../dream/dream-trigger.js';
37
40
  import { createReviewApiRouter } from '../http/review-api-handler.js';
38
41
  import { broadcastToProjectRoom } from '../process/broadcast-utils.js';
39
42
  import { CurateLogHandler } from '../process/curate-log-handler.js';
40
43
  import { setupFeatureHandlers } from '../process/feature-handlers.js';
44
+ import { QueryLogHandler } from '../process/query-log-handler.js';
41
45
  import { TransportHandlers } from '../process/transport-handlers.js';
42
46
  import { ProjectRegistry } from '../project/project-registry.js';
43
47
  import { createProviderOAuthTokenStore } from '../provider-oauth/provider-oauth-token-store.js';
@@ -188,12 +192,26 @@ async function main() {
188
192
  log,
189
193
  projectRegistry,
190
194
  });
195
+ // Shared queue-length resolver — used by both idle timeout policy and dream trigger
196
+ const getQueueLength = (projectPath) => agentPool?.getQueueState().find((q) => q.projectPath === projectPath)?.queueLength ?? 0;
197
+ // Shared dream pre-check trigger factory.
198
+ // The lock service explicitly throws if invoked — gate 4 (lock) is the agent's job;
199
+ // the daemon must only ever evaluate gates 1-3 via checkEligibility().
200
+ const makeDreamPreCheckTrigger = (projectPath) => new DreamTrigger({
201
+ dreamLockService: {
202
+ tryAcquire() {
203
+ throw new Error('Lock must not be acquired during daemon eligibility pre-check');
204
+ },
205
+ },
206
+ dreamStateService: new DreamStateService({ baseDir: join(projectPath, BRV_DIR) }),
207
+ getQueueLength,
208
+ });
191
209
  // Agent idle timeout policy — kills agents after period of inactivity
192
210
  const agentIdleTimeoutPolicy = new AgentIdleTimeoutPolicy({
193
211
  checkIntervalMs: AGENT_IDLE_CHECK_INTERVAL_MS,
194
- getQueueLength: (projectPath) => agentPool?.getQueueState().find((q) => q.projectPath === projectPath)?.queueLength ?? 0,
212
+ getQueueLength,
195
213
  log,
196
- onAgentIdle(projectPath, queueLength) {
214
+ async onAgentIdle(projectPath, queueLength) {
197
215
  // Don't kill agents that have queued tasks waiting
198
216
  if (queueLength > 0) {
199
217
  log(`Skipping idle cleanup: ${projectPath} has ${queueLength} queued tasks`);
@@ -205,7 +223,28 @@ async function main() {
205
223
  log(`Skipping idle cleanup: ${projectPath} has active task`);
206
224
  return;
207
225
  }
208
- log(`Killing idle agent: ${projectPath}`);
226
+ // Check dream eligibility before killing (gates 1-3 only, no lock).
227
+ // Lock acquisition happens in the agent process when the dream task executes.
228
+ try {
229
+ const result = await makeDreamPreCheckTrigger(projectPath).checkEligibility(projectPath);
230
+ if (result.eligible) {
231
+ log(`Dream eligible, dispatching dream task: ${projectPath}`);
232
+ agentPool?.submitTask({
233
+ clientId: 'daemon',
234
+ content: 'Memory consolidation (idle trigger)',
235
+ force: false,
236
+ projectPath,
237
+ taskId: randomUUID(),
238
+ trigger: 'agent-idle',
239
+ type: 'dream',
240
+ });
241
+ return;
242
+ }
243
+ log(`Dream not eligible (${result.reason}), killing idle agent: ${projectPath}`);
244
+ }
245
+ catch {
246
+ log(`Dream eligibility check failed, killing idle agent: ${projectPath}`);
247
+ }
209
248
  agentPool?.handleAgentDisconnected(projectPath);
210
249
  },
211
250
  timeoutMs: AGENT_IDLE_TIMEOUT_MS,
@@ -247,15 +286,45 @@ async function main() {
247
286
  // Also broadcast to the project room so TUI and other connected clients are notified
248
287
  broadcastToProjectRoom(projectRegistry, projectRouter, info.projectPath, ReviewEvents.NOTIFY, payload, info.clientId);
249
288
  });
289
+ const queryLogHandler = new QueryLogHandler();
250
290
  const transportHandlers = new TransportHandlers({
251
291
  agentPool,
252
292
  clientManager,
253
- lifecycleHooks: [curateLogHandler],
293
+ lifecycleHooks: [curateLogHandler, queryLogHandler],
294
+ // Daemon-side gate for dream task:create — mirrors the idle-trigger pre-check
295
+ // in this file so the CLI path (brv dream without --force) actually honors
296
+ // gate 3 (queue). The agent-side check kept gate 3 hardcoded to skip,
297
+ // which made the CLI ignore the spec when other tasks were queued.
298
+ async preDispatchCheck(task, projectPath) {
299
+ if (task.type !== 'dream' || task.force)
300
+ return { eligible: true };
301
+ if (!projectPath)
302
+ return { eligible: true };
303
+ try {
304
+ const result = await makeDreamPreCheckTrigger(projectPath).checkEligibility(projectPath);
305
+ return result.eligible ? { eligible: true } : { eligible: false, skipResult: `Dream skipped: ${result.reason}` };
306
+ }
307
+ catch {
308
+ // Fail-open on pre-check errors: let the agent's own gate check be the fallback.
309
+ return { eligible: true };
310
+ }
311
+ },
254
312
  projectRegistry,
255
313
  projectRouter,
256
314
  transport: transportServer,
257
315
  });
258
316
  transportHandlers.setup();
317
+ // Wire query metadata from agent process → QueryLogHandler.
318
+ // Agent sends task:queryResult BEFORE task:completed (Socket.IO preserves order),
319
+ // so setQueryResult runs before onTaskCompleted merges the metadata.
320
+ transportServer.onRequest(TransportTaskEventNames.QUERY_RESULT, (data) => {
321
+ queryLogHandler.setQueryResult(data.taskId, {
322
+ matchedDocs: data.matchedDocs,
323
+ searchMetadata: data.searchMetadata,
324
+ tier: data.tier,
325
+ timing: data.timing,
326
+ });
327
+ });
259
328
  // 8. Create idle timeout policy + shutdown handler
260
329
  // (must be created before wiring closures that reference them)
261
330
  // onIdle captures shutdownHandler via closure; safe because
@@ -0,0 +1,37 @@
1
+ type DreamLockServiceOptions = {
2
+ baseDir: string;
3
+ staleTimeoutMs?: number;
4
+ };
5
+ /**
6
+ * PID-based lock for dream execution.
7
+ *
8
+ * Lock file contains the owning process PID. Staleness is determined by mtime.
9
+ * Uses write-then-verify to handle race conditions between concurrent acquirers.
10
+ */
11
+ export declare class DreamLockService {
12
+ private readonly lockFilePath;
13
+ private readonly staleTimeoutMs;
14
+ constructor(opts: DreamLockServiceOptions);
15
+ /** Clear PID content but keep the file. mtime becomes "last dream completion time". */
16
+ release(): Promise<void>;
17
+ /**
18
+ * Restore mtime after a failed dream so the time gate isn't fooled.
19
+ * If priorMtime is 0 (lock didn't exist before), delete the file.
20
+ */
21
+ rollback(priorMtime: number): Promise<void>;
22
+ /**
23
+ * Try to acquire the dream lock.
24
+ *
25
+ * Returns `{ acquired: true, priorMtime }` on success, where priorMtime
26
+ * is the lock file's mtime before acquisition (0 if file didn't exist).
27
+ *
28
+ * Returns `{ acquired: false }` if another live, non-stale process holds the lock.
29
+ */
30
+ tryAcquire(): Promise<{
31
+ acquired: false;
32
+ } | {
33
+ acquired: true;
34
+ priorMtime: number;
35
+ }>;
36
+ }
37
+ export {};
@@ -0,0 +1,88 @@
1
+ import { mkdir, readFile, stat, unlink, utimes, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ const LOCK_FILENAME = 'dream.lock';
4
+ const DEFAULT_STALE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
5
+ /**
6
+ * PID-based lock for dream execution.
7
+ *
8
+ * Lock file contains the owning process PID. Staleness is determined by mtime.
9
+ * Uses write-then-verify to handle race conditions between concurrent acquirers.
10
+ */
11
+ export class DreamLockService {
12
+ lockFilePath;
13
+ staleTimeoutMs;
14
+ constructor(opts) {
15
+ this.lockFilePath = join(opts.baseDir, LOCK_FILENAME);
16
+ this.staleTimeoutMs = opts.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS;
17
+ }
18
+ /** Clear PID content but keep the file. mtime becomes "last dream completion time". */
19
+ async release() {
20
+ await writeFile(this.lockFilePath, '', 'utf8');
21
+ }
22
+ /**
23
+ * Restore mtime after a failed dream so the time gate isn't fooled.
24
+ * If priorMtime is 0 (lock didn't exist before), delete the file.
25
+ */
26
+ async rollback(priorMtime) {
27
+ if (priorMtime === 0) {
28
+ await unlink(this.lockFilePath).catch(() => { });
29
+ return;
30
+ }
31
+ await writeFile(this.lockFilePath, '', 'utf8');
32
+ const time = new Date(priorMtime);
33
+ await utimes(this.lockFilePath, time, time);
34
+ }
35
+ /**
36
+ * Try to acquire the dream lock.
37
+ *
38
+ * Returns `{ acquired: true, priorMtime }` on success, where priorMtime
39
+ * is the lock file's mtime before acquisition (0 if file didn't exist).
40
+ *
41
+ * Returns `{ acquired: false }` if another live, non-stale process holds the lock.
42
+ */
43
+ async tryAcquire() {
44
+ let priorMtime = 0;
45
+ try {
46
+ const lockStat = await stat(this.lockFilePath);
47
+ priorMtime = lockStat.mtimeMs;
48
+ const content = await readFile(this.lockFilePath, 'utf8');
49
+ const pid = Number.parseInt(content.trim(), 10);
50
+ if (!Number.isNaN(pid) && pid > 0) {
51
+ const alive = isProcessAlive(pid);
52
+ const stale = Date.now() - lockStat.mtimeMs > this.staleTimeoutMs;
53
+ if (alive && !stale) {
54
+ return { acquired: false };
55
+ }
56
+ }
57
+ }
58
+ catch {
59
+ // File doesn't exist — priorMtime stays 0
60
+ }
61
+ // Write our PID
62
+ await mkdir(dirname(this.lockFilePath), { recursive: true });
63
+ await writeFile(this.lockFilePath, String(process.pid), 'utf8');
64
+ // Write-then-verify: re-read to confirm we won the race
65
+ try {
66
+ const content = await readFile(this.lockFilePath, 'utf8');
67
+ if (content.trim() !== String(process.pid)) {
68
+ return { acquired: false };
69
+ }
70
+ }
71
+ catch {
72
+ return { acquired: false };
73
+ }
74
+ return { acquired: true, priorMtime };
75
+ }
76
+ }
77
+ function isProcessAlive(pid) {
78
+ try {
79
+ process.kill(pid, 0);
80
+ return true;
81
+ }
82
+ catch (error) {
83
+ // EPERM: process exists but we can't signal it — treat as alive
84
+ if (error.code === 'EPERM')
85
+ return true;
86
+ return false;
87
+ }
88
+ }