@lincoln504/pi-research 0.1.12 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +64 -115
- package/dist/cli.mjs +23211 -0
- package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
- package/dist/prompts/research-knowledge-search-extractor.md +41 -0
- package/dist/prompts/research-tool-usage.md +92 -0
- package/dist/prompts/researcher.md +89 -0
- package/dist/prompts/system-coordinator.md +75 -0
- package/dist/prompts/system-lead-evaluator.md +118 -0
- package/dist/thread-worker.mjs +2611 -0
- package/dist/webgpu-probe.mjs +80 -0
- package/docs/AGENT-SKILL.md +102 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +213 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +113 -0
- package/docs/THIRD-PARTY-NOTICES.md +64 -0
- package/package.json +102 -36
- package/scripts/build.cjs +140 -0
- package/scripts/cleanup.cjs +149 -0
- package/scripts/prepare.cjs +28 -0
- package/scripts/setup.cjs +134 -0
- package/skills/pi-research/README.md +90 -0
- package/skills/pi-research/SKILL.md +102 -0
- package/skills/pi-research/references/configuration.md +77 -0
- package/skills/pi-research/scripts/run.mjs +237 -0
- package/src/cleanup/research-cleanup.ts +140 -0
- package/src/cli.ts +1044 -0
- package/src/config.ts +1036 -147
- package/src/constants.ts +115 -84
- package/src/core/health-check-service.ts +265 -0
- package/src/core/interfaces/health-check-interfaces.ts +50 -0
- package/src/core/interfaces/knowledge-interfaces.ts +190 -0
- package/src/core/interfaces/observer-interfaces.ts +67 -0
- package/src/core/interfaces/orchestration-interfaces.ts +86 -0
- package/src/core/interfaces/planning-interfaces.ts +100 -0
- package/src/core/interfaces/process-interfaces.ts +37 -0
- package/src/core/interfaces/research-plan-types.ts +52 -0
- package/src/core/interfaces/scheduler-interfaces.ts +91 -0
- package/src/core/interfaces/service-names.ts +39 -0
- package/src/core/interfaces/state-manager-interfaces.ts +29 -0
- package/src/core/interfaces/steering-interfaces.ts +31 -0
- package/src/core/llm/agentic-repair.ts +169 -0
- package/src/core/llm/inject-date.ts +52 -0
- package/src/core/llm/llm-timeout.ts +64 -0
- package/src/core/llm/llm-utils.ts +86 -0
- package/src/core/llm/model-registry-factory.ts +287 -0
- package/src/core/llm/pi-ai-completion.ts +29 -0
- package/src/core/llm/prompts.ts +50 -0
- package/src/core/llm/research-model-resolver.ts +91 -0
- package/src/core/planning-constants.ts +13 -0
- package/src/core/planning-service.ts +649 -0
- package/src/core/planning-utils.ts +299 -0
- package/src/core/scheduler-factory.ts +48 -0
- package/src/core/scheduler-service.ts +123 -0
- package/src/core/service-initialization.ts +194 -0
- package/src/core/service-interfaces.ts +48 -0
- package/src/core/service-registry.ts +659 -0
- package/src/healthcheck/index.ts +271 -265
- package/src/healthcheck/registry.ts +130 -0
- package/src/index.ts +639 -50
- package/src/infrastructure/browser/browser-cleanup.ts +309 -0
- package/src/infrastructure/browser/browser-client.ts +199 -0
- package/src/infrastructure/browser/browser-error-utils.ts +160 -0
- package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
- package/src/infrastructure/browser/browser-server.ts +174 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
- package/src/infrastructure/browser/cleanup-utils.ts +155 -0
- package/src/infrastructure/browser/client-agent.ts +31 -0
- package/src/infrastructure/browser/config.ts +288 -0
- package/src/infrastructure/browser/ensure-browser.ts +205 -0
- package/src/infrastructure/browser/index.ts +37 -0
- package/src/infrastructure/browser/priority-task-queue.ts +226 -0
- package/src/infrastructure/browser/scheduler-factory.ts +354 -0
- package/src/infrastructure/browser/task-execution-service.ts +209 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
- package/src/infrastructure/browser/thread-worker-types.ts +25 -0
- package/src/infrastructure/browser/thread-worker.mjs +2611 -0
- package/src/infrastructure/browser/thread-worker.ts +168 -0
- package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
- package/src/infrastructure/embedding/embedding-client.ts +205 -0
- package/src/infrastructure/embedding/embedding-factory.ts +324 -0
- package/src/infrastructure/embedding/embedding-server.ts +535 -0
- package/src/infrastructure/file-lock-service.ts +592 -0
- package/src/infrastructure/gpu-resource-service.ts +191 -0
- package/src/infrastructure/knowledge-store-service.ts +475 -0
- package/src/infrastructure/metrics-service.ts +129 -0
- package/src/infrastructure/process-lifecycle-service.ts +253 -0
- package/src/infrastructure/scheduler-factory-service.ts +58 -0
- package/src/infrastructure/service-initialization.ts +292 -0
- package/src/infrastructure/state/state-backup-manager.ts +290 -0
- package/src/infrastructure/state/state-browser-api.ts +62 -0
- package/src/infrastructure/state/state-browser-manager.ts +70 -0
- package/src/infrastructure/state/state-manager-service.ts +264 -0
- package/src/infrastructure/state/state-manager.ts +547 -0
- package/src/infrastructure/state/state-metrics.ts +92 -0
- package/src/infrastructure/state/state-migration.ts +103 -0
- package/src/infrastructure/state/state-path-configuration.ts +119 -0
- package/src/infrastructure/state/state-session-api.ts +94 -0
- package/src/infrastructure/state/state-session-manager.ts +127 -0
- package/src/infrastructure/state/state-validator.ts +93 -0
- package/src/infrastructure/types/state-types.ts +85 -0
- package/src/knowledge/chunker.ts +157 -0
- package/src/knowledge/embedder-init.ts +413 -0
- package/src/knowledge/embedder-utils.ts +296 -0
- package/src/knowledge/embedder.ts +592 -0
- package/src/knowledge/index.ts +126 -0
- package/src/knowledge/lancedb-loader.ts +35 -0
- package/src/knowledge/migration.ts +17 -0
- package/src/knowledge/model-config.ts +132 -0
- package/src/knowledge/onnx-env.ts +48 -0
- package/src/knowledge/store-operations.ts +291 -0
- package/src/knowledge/store-schema.ts +79 -0
- package/src/knowledge/store.ts +1251 -0
- package/src/knowledge/transformers-loader.ts +31 -0
- package/src/knowledge/webgpu-probe.mjs +80 -0
- package/src/knowledge/webgpu-viability.ts +255 -0
- package/src/knowledge/writer-queue.ts +252 -0
- package/src/logger.ts +316 -122
- package/src/observers/research-observer-impl.ts +459 -0
- package/src/orchestration/deep-research-orchestrator.ts +524 -773
- package/src/orchestration/deep-research-types.ts +47 -35
- package/src/orchestration/headless-observer.ts +114 -0
- package/src/orchestration/orchestration-types.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +463 -0
- package/src/orchestration/research-orchestration-service.ts +549 -0
- package/src/orchestration/research-session-service.ts +178 -0
- package/src/orchestration/research-synthesis-service.ts +348 -0
- package/src/orchestration/researcher-executor.ts +394 -0
- package/src/orchestration/researcher.ts +115 -42
- package/src/orchestration/service-initialization.ts +109 -0
- package/src/orchestration/session-context.ts +7 -3
- package/src/orchestration/session-state.ts +672 -0
- package/src/prompts/research-knowledge-relevance-triage.md +35 -0
- package/src/prompts/research-knowledge-search-extractor.md +41 -0
- package/src/prompts/research-tool-usage.md +63 -16
- package/src/prompts/researcher.md +59 -136
- package/src/prompts/system-coordinator.md +64 -13
- package/src/prompts/system-lead-evaluator.md +82 -189
- package/src/research-config.ts +872 -0
- package/src/sdk.ts +847 -0
- package/src/security/cisa-kev.ts +72 -9
- package/src/security/cvss.ts +92 -0
- package/src/security/ecosystem.ts +45 -0
- package/src/security/github-advisories.ts +168 -428
- package/src/security/github-advisory-types.ts +282 -0
- package/src/security/index.ts +63 -177
- package/src/security/nvd-client.ts +550 -0
- package/src/security/nvd-types.ts +179 -0
- package/src/security/osv-client.ts +246 -0
- package/src/security/osv-types.ts +285 -0
- package/src/security/types.ts +67 -53
- package/src/skill-install/skill-installer.ts +519 -0
- package/src/stackexchange/index.ts +81 -27
- package/src/stackexchange/output/compact.ts +7 -6
- package/src/stackexchange/output/table.ts +11 -7
- package/src/stackexchange/queries.ts +0 -10
- package/src/stackexchange/rest-client.ts +141 -66
- package/src/stackexchange/types.ts +0 -17
- package/src/tool.ts +15 -423
- package/src/tools/grep.ts +32 -310
- package/src/tools/health-tool-definition.ts +101 -0
- package/src/tools/index.ts +38 -9
- package/src/tools/research-knowledge-search.ts +974 -0
- package/src/tools/research-knowledge-types.ts +77 -0
- package/src/tools/research-tool-definition.ts +494 -0
- package/src/tools/scrape.ts +286 -388
- package/src/tools/search.ts +98 -153
- package/src/tools/security.ts +107 -80
- package/src/tools/stackexchange.ts +58 -35
- package/src/tools/youtube-transcript.ts +163 -0
- package/src/tui/knowledge-search-panel.ts +86 -0
- package/src/tui/pulse.ts +86 -0
- package/src/tui/research-health.ts +124 -0
- package/src/tui/research-panel-color-utils.ts +340 -0
- package/src/tui/research-panel-format.ts +54 -0
- package/src/tui/research-panel-state.ts +404 -0
- package/src/tui/research-panel-wave.ts +280 -0
- package/src/tui/research-panel.ts +339 -435
- package/src/tui/research-tui-manager.ts +233 -0
- package/src/tui/tui-controller.ts +137 -0
- package/src/types/extension-context.ts +18 -49
- package/src/types/index.ts +61 -0
- package/src/types/llm.ts +68 -29
- package/src/types/research-panel-types.ts +61 -0
- package/src/utils/circuit-breaker.ts +146 -0
- package/src/utils/citation-utils.ts +97 -0
- package/src/utils/disk-space-checker.ts +55 -0
- package/src/utils/error-tracker.ts +265 -0
- package/src/utils/host-config.ts +45 -0
- package/src/utils/input-validation.ts +8 -10
- package/src/utils/json-utils.ts +150 -16
- package/src/utils/llm-usage.ts +54 -0
- package/src/utils/log-rotation.ts +125 -0
- package/src/utils/log-utils.ts +159 -0
- package/src/utils/make-resource-loader.ts +3 -1
- package/src/utils/metrics-summary.ts +459 -0
- package/src/utils/metrics.ts +302 -0
- package/src/utils/research-export.ts +28 -12
- package/src/utils/safe-unref.ts +31 -0
- package/src/utils/shared-links.ts +195 -90
- package/src/utils/shutdown-manager.ts +65 -0
- package/src/utils/stdio-capture.ts +407 -0
- package/src/utils/text-utils.ts +264 -7
- package/src/utils/tool-usage-tracker.ts +83 -16
- package/src/utils/url-utils.ts +255 -0
- package/src/utils/user-agent.ts +28 -0
- package/src/web-research/browser-search.ts +179 -0
- package/src/web-research/retry-utils.ts +173 -77
- package/src/web-research/scraper-types.ts +72 -0
- package/src/web-research/scraper-utils.ts +434 -0
- package/src/web-research/search.ts +78 -218
- package/src/web-research/types.ts +9 -24
- package/src/web-research/utils.ts +2 -143
- package/src/web-research/web-scraper.ts +477 -0
- package/src/youtube/index.ts +154 -0
- package/src/youtube/potoken.ts +259 -0
- package/src/youtube/transcript-client.ts +390 -0
- package/src/youtube/video-id.ts +118 -0
- package/config/searxng/default-settings.yml +0 -189
- package/config/searxng/limiter.toml +0 -34
- package/config/tooling/eslint.config.js +0 -73
- package/config/tooling/vitest.config.integration.ts +0 -17
- package/config/tooling/vitest.config.ts +0 -40
- package/config/tooling/vitest.config.unit.ts +0 -12
- package/docs/assets/README-banner.jpg +0 -0
- package/scripts/setup.js +0 -174
- package/src/infrastructure/searxng-lifecycle.ts +0 -577
- package/src/infrastructure/searxng-manager.ts +0 -1183
- package/src/infrastructure/state-manager.ts +0 -918
- package/src/orchestration/deep-research-reducer.ts +0 -129
- package/src/orchestration/id-utils.ts +0 -63
- package/src/security/nvd.ts +0 -532
- package/src/security/osv.ts +0 -487
- package/src/stackexchange/cache.ts +0 -111
- package/src/stackexchange/output/index.ts +0 -7
- package/src/stackexchange/output/json.ts +0 -21
- package/src/tui/searxng-status.ts +0 -80
- package/src/utils/inject-date.ts +0 -29
- package/src/utils/research-error.ts +0 -205
- package/src/utils/searxng-config.ts +0 -94
- package/src/utils/session-state.ts +0 -295
- package/src/web-research/dependencies.ts +0 -20
- package/src/web-research/scrapers.ts +0 -935
- package/tsconfig.json +0 -40
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Lock Service
|
|
3
|
+
*
|
|
4
|
+
* Low-level cross-process file locking primitives with UUID-based ownership.
|
|
5
|
+
* Provides thread-safe and cross-process-safe file locking mechanisms.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'node:fs/promises';
|
|
9
|
+
import * as crypto from 'node:crypto';
|
|
10
|
+
import * as pathmod from 'node:path';
|
|
11
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
12
|
+
import { logger } from '../logger.ts';
|
|
13
|
+
import { metrics } from '../utils/metrics.ts';
|
|
14
|
+
import type { IService } from '../core/service-registry.ts';
|
|
15
|
+
import { ServiceLifecycle } from '../core/service-registry.ts';
|
|
16
|
+
import { ServiceNames } from '../core/interfaces/service-names.ts';
|
|
17
|
+
import type { IProcessLifecycle } from '../core/interfaces/process-interfaces.ts';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Global storage for tracking lock ownership within the same async execution flow.
|
|
21
|
+
* This allows safe re-entrancy (nested locks) while correctly serializing
|
|
22
|
+
* non-nested concurrent calls on the same instance.
|
|
23
|
+
*/
|
|
24
|
+
const lockContext = new AsyncLocalStorage<Set<string>>();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Lock configuration options
|
|
28
|
+
*/
|
|
29
|
+
export interface FileLockOptions {
|
|
30
|
+
/** Lock file path */
|
|
31
|
+
lockFilePath: string;
|
|
32
|
+
/** Maximum time to wait for lock acquisition (default: 20 seconds) */
|
|
33
|
+
lockTimeout?: number;
|
|
34
|
+
/** Number of retries for lock acquisition (default: 200) */
|
|
35
|
+
lockRetries?: number;
|
|
36
|
+
/** Delay between retries in milliseconds (default: 100ms) */
|
|
37
|
+
lockRetryDelay?: number;
|
|
38
|
+
/** Stale lock threshold in milliseconds (default: 15 seconds) */
|
|
39
|
+
lockStaleThreshold?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Stale threshold (ms) applied ONLY when the lock's owner process is provably
|
|
42
|
+
* alive. Much larger than lockStaleThreshold so a live process holding the lock
|
|
43
|
+
* during a slow critical section (disk stall, fsync) is never stolen from —
|
|
44
|
+
* only reclaimed in the rare PID-reuse case. Default: max(120s, 4×stale).
|
|
45
|
+
*/
|
|
46
|
+
liveOwnerStaleThreshold?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Process lifecycle service, used for PID+start-time (PID-reuse-safe) liveness
|
|
49
|
+
* checks. Optional for backward compatibility with direct construction (tests,
|
|
50
|
+
* not-yet-wired call sites): when omitted, liveness falls back to the pre-existing
|
|
51
|
+
* bare `process.kill(pid, 0)` check.
|
|
52
|
+
*/
|
|
53
|
+
processLifecycle?: IProcessLifecycle;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* File Lock Service
|
|
58
|
+
*
|
|
59
|
+
* Provides cross-process file locking with UUID-based ownership verification.
|
|
60
|
+
* Handles stale lock detection and cleanup.
|
|
61
|
+
*/
|
|
62
|
+
export class FileLockService implements IService {
|
|
63
|
+
readonly name = ServiceNames.FILE_LOCK_SERVICE;
|
|
64
|
+
lifecycle = ServiceLifecycle.UNINITIALIZED;
|
|
65
|
+
private _initialized = false;
|
|
66
|
+
|
|
67
|
+
private readonly lockFilePath: string;
|
|
68
|
+
private readonly lockTimeout: number;
|
|
69
|
+
private readonly lockRetries: number;
|
|
70
|
+
private readonly lockRetryDelay: number;
|
|
71
|
+
private readonly lockStaleThreshold: number;
|
|
72
|
+
private readonly liveOwnerStaleThreshold: number;
|
|
73
|
+
private readonly processLifecycle: IProcessLifecycle | null;
|
|
74
|
+
|
|
75
|
+
// Lock tracking
|
|
76
|
+
private lockHandle: fs.FileHandle | null = null;
|
|
77
|
+
private readonly lockUuid: string = crypto.randomUUID();
|
|
78
|
+
private queue: Promise<void> = Promise.resolve();
|
|
79
|
+
private resolveTurn: (() => void) | null = null;
|
|
80
|
+
private lockCount: number = 0;
|
|
81
|
+
// Our own process start time, resolved ONCE in initialize() (may shell out on
|
|
82
|
+
// macOS/Windows) and embedded into every lock we write, so a peer can detect
|
|
83
|
+
// PID reuse. Null when unavailable (no processLifecycle, or lookup failed) —
|
|
84
|
+
// then lock content omits startTime and readers treat us as a legacy owner.
|
|
85
|
+
private ownStartTime: number | null = null;
|
|
86
|
+
|
|
87
|
+
constructor(options: FileLockOptions) {
|
|
88
|
+
this.lockFilePath = options.lockFilePath;
|
|
89
|
+
this.lockTimeout = options.lockTimeout ?? 20000;
|
|
90
|
+
this.lockRetries = options.lockRetries ?? 200;
|
|
91
|
+
this.lockRetryDelay = options.lockRetryDelay ?? 100;
|
|
92
|
+
this.lockStaleThreshold = options.lockStaleThreshold ?? 15000;
|
|
93
|
+
this.liveOwnerStaleThreshold =
|
|
94
|
+
options.liveOwnerStaleThreshold ?? Math.max(120000, this.lockStaleThreshold * 4);
|
|
95
|
+
this.processLifecycle = options.processLifecycle ?? null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Decide whether a contended lock may be reclaimed.
|
|
100
|
+
* - Dead owner → reclaim immediately (crash cleanup).
|
|
101
|
+
* - Legacy lock (no pid) → reclaim once aged past the normal stale threshold.
|
|
102
|
+
* - Provably-alive owner → reclaim ONLY after liveOwnerStaleThreshold, so we
|
|
103
|
+
* never steal a lock a live process is actively
|
|
104
|
+
* holding during a (possibly slow) critical section.
|
|
105
|
+
* Stealing it would let two writers run the same
|
|
106
|
+
* read-modify-write and lose an update.
|
|
107
|
+
*/
|
|
108
|
+
private async _shouldReclaim(
|
|
109
|
+
pid: number | null,
|
|
110
|
+
startTime: number | null,
|
|
111
|
+
lockAge: number,
|
|
112
|
+
memo?: Map<string, boolean>,
|
|
113
|
+
): Promise<boolean> {
|
|
114
|
+
if (!(await this._resolveOwnerAlive(pid, startTime, memo))) return true;
|
|
115
|
+
if (pid === null) return lockAge > this.lockStaleThreshold;
|
|
116
|
+
return lockAge > this.liveOwnerStaleThreshold;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Liveness of a contended owner, memoized per acquire() episode. The memo (keyed
|
|
121
|
+
* by pid:startTime) ensures the PID+start-time check — which may spawn a ps/powershell
|
|
122
|
+
* subprocess on macOS/Windows — runs at most ONCE per distinct owner per episode,
|
|
123
|
+
* not once per ~100ms retry tick. A change of owner mid-episode has a different
|
|
124
|
+
* (pid,startTime) key, so it naturally busts the cache and forces a fresh check.
|
|
125
|
+
*/
|
|
126
|
+
private async _resolveOwnerAlive(
|
|
127
|
+
pid: number | null,
|
|
128
|
+
startTime: number | null,
|
|
129
|
+
memo?: Map<string, boolean>,
|
|
130
|
+
): Promise<boolean> {
|
|
131
|
+
if (!memo) return this._isOwnerAlive(pid, startTime);
|
|
132
|
+
const key = `${pid ?? 'null'}:${startTime ?? 'null'}`;
|
|
133
|
+
const cached = memo.get(key);
|
|
134
|
+
if (cached !== undefined) return cached;
|
|
135
|
+
const alive = await this._isOwnerAlive(pid, startTime);
|
|
136
|
+
memo.set(key, alive);
|
|
137
|
+
return alive;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async initialize(): Promise<void> {
|
|
141
|
+
if (this._initialized) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
this.lifecycle = ServiceLifecycle.INITIALIZING;
|
|
145
|
+
|
|
146
|
+
// Ensure lock directory exists
|
|
147
|
+
try {
|
|
148
|
+
const lockDir = pathmod.dirname(this.lockFilePath);
|
|
149
|
+
await fs.mkdir(lockDir, { recursive: true, mode: 0o700 });
|
|
150
|
+
} catch (err) {
|
|
151
|
+
logger.warn(`[FileLockService] Failed to create lock directory: ${err instanceof Error ? err.message : String(err)}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Resolve our own start time ONCE, here — never on the hot write path. The
|
|
155
|
+
// lookup may shell out on macOS/Windows; getCurrentProcessStartTime() also
|
|
156
|
+
// caches internally, so this is belt-and-suspenders keeping _makeLockContent
|
|
157
|
+
// fully synchronous. Null on failure → lock content omits startTime.
|
|
158
|
+
if (this.processLifecycle) {
|
|
159
|
+
try {
|
|
160
|
+
this.ownStartTime = await this.processLifecycle.getCurrentProcessStartTime();
|
|
161
|
+
} catch {
|
|
162
|
+
this.ownStartTime = null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Clean up any stale locks on initialization (fire and forget)
|
|
167
|
+
await this.cleanupStaleLocksOnStartup();
|
|
168
|
+
this._initialized = true;
|
|
169
|
+
this.lifecycle = ServiceLifecycle.INITIALIZED;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async dispose(): Promise<void> {
|
|
173
|
+
this.lifecycle = ServiceLifecycle.DISPOSING;
|
|
174
|
+
await this.cleanup();
|
|
175
|
+
this.lifecycle = ServiceLifecycle.DISPOSED;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Clean up any stale lock files on initialization.
|
|
180
|
+
* This handles cases where locks weren't released due to crashes.
|
|
181
|
+
*/
|
|
182
|
+
private async cleanupStaleLocksOnStartup(): Promise<void> {
|
|
183
|
+
try {
|
|
184
|
+
const rawContent = await fs.readFile(this.lockFilePath, 'utf-8').catch(() => null);
|
|
185
|
+
if (rawContent === null) return; // No lock file — nothing to do
|
|
186
|
+
|
|
187
|
+
const stats = await fs.stat(this.lockFilePath);
|
|
188
|
+
const lockAge = Date.now() - stats.mtimeMs;
|
|
189
|
+
const parsed = this._parseLockContent(rawContent);
|
|
190
|
+
// One-shot memo dedupes the two liveness lookups below (log line + decision).
|
|
191
|
+
const memo = new Map<string, boolean>();
|
|
192
|
+
const ownerAlive = await this._resolveOwnerAlive(parsed?.pid ?? null, parsed?.startTime ?? null, memo);
|
|
193
|
+
|
|
194
|
+
if (await this._shouldReclaim(parsed?.pid ?? null, parsed?.startTime ?? null, lockAge, memo)) {
|
|
195
|
+
logger.log(
|
|
196
|
+
`[FileLockService] Cleaning up stale lock file (${Math.round(lockAge / 1000)}s old, owner alive: ${ownerAlive})`
|
|
197
|
+
);
|
|
198
|
+
// The liveness lookups above can take long enough for another process to
|
|
199
|
+
// reclaim the stale lock and write its own fresh one at this path. A bare
|
|
200
|
+
// unlink here would delete that live lock. Rename-to-trash and re-verify
|
|
201
|
+
// the UUID matches what we decided on — same guard as the contended path.
|
|
202
|
+
const expectedUuid = parsed?.uuid ?? '';
|
|
203
|
+
const trashPath = `${this.lockFilePath}.trash.${crypto.randomBytes(8).toString('hex')}`;
|
|
204
|
+
try {
|
|
205
|
+
await fs.rename(this.lockFilePath, trashPath);
|
|
206
|
+
const trashContent = await fs.readFile(trashPath, 'utf-8');
|
|
207
|
+
const trashParsed = this._parseLockContent(trashContent);
|
|
208
|
+
if ((trashParsed?.uuid ?? '') !== expectedUuid) {
|
|
209
|
+
// Lock changed hands mid-check — restore it and back off.
|
|
210
|
+
try { await fs.link(trashPath, this.lockFilePath); } catch { /* ignore */ }
|
|
211
|
+
await fs.unlink(trashPath);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
await fs.unlink(trashPath);
|
|
215
|
+
logger.log('[FileLockService] Stale lock removed');
|
|
216
|
+
} catch { /* best-effort startup cleanup; acquire() handles contention */ }
|
|
217
|
+
}
|
|
218
|
+
} catch (error: unknown) {
|
|
219
|
+
// ENOENT is expected (no lock file exists)
|
|
220
|
+
if (error instanceof Error && 'code' in error) {
|
|
221
|
+
const errnoError = error as NodeJS.ErrnoException;
|
|
222
|
+
if (errnoError.code !== 'ENOENT') {
|
|
223
|
+
logger.warn(`[FileLockService] Could not check lock file: ${errnoError.message}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Acquire a filesystem lock for exclusive access.
|
|
231
|
+
* Supports re-entrancy (recursive locking) within the same async execution context.
|
|
232
|
+
* @throws Error if unable to acquire lock within timeout
|
|
233
|
+
*/
|
|
234
|
+
async acquireLock(): Promise<void> {
|
|
235
|
+
const heldLocks = lockContext.getStore();
|
|
236
|
+
const lockKey = `${this.lockFilePath}:${this.lockUuid}`;
|
|
237
|
+
|
|
238
|
+
// 0. Support re-entrancy: check if the current async flow already holds this lock instance.
|
|
239
|
+
if (heldLocks?.has(lockKey)) {
|
|
240
|
+
if (this.lockHandle !== null) {
|
|
241
|
+
this.lockCount++;
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 1. Join the local FIFO queue for this instance.
|
|
247
|
+
// This ensures that multiple concurrent calls to acquireLock() on the same
|
|
248
|
+
// instance (e.g. from different queries in the same process) are handled
|
|
249
|
+
// one by one, preventing internal state corruption and redundant disk I/O.
|
|
250
|
+
let myResolve!: () => void;
|
|
251
|
+
const myTurn = new Promise<void>((resolve) => {
|
|
252
|
+
myResolve = resolve;
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const previous = this.queue;
|
|
256
|
+
this.queue = myTurn;
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
await previous;
|
|
260
|
+
} catch (_err) {
|
|
261
|
+
// Continue even if previous turn failed
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Secondary re-entrancy check: if a previous turn in the queue already
|
|
265
|
+
// acquired the lock for us (e.g. nested call that bypassed the queue).
|
|
266
|
+
if (heldLocks?.has(lockKey) && this.lockHandle !== null) {
|
|
267
|
+
this.lockCount++;
|
|
268
|
+
// Release our turn immediately so the next caller can proceed.
|
|
269
|
+
if (myResolve) myResolve();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Capture resolve function so we can trigger it in releaseLock() or on failure
|
|
274
|
+
this.resolveTurn = myResolve;
|
|
275
|
+
|
|
276
|
+
const startTime = Date.now();
|
|
277
|
+
let contentionCount = 0;
|
|
278
|
+
// One entry per distinct (pid,startTime) owner seen during THIS acquireLock()
|
|
279
|
+
// call, so a live contended owner waited out across many ~100ms retry ticks is
|
|
280
|
+
// liveness-checked once, not once per tick (no ps/powershell storm on non-Linux).
|
|
281
|
+
const contendedOwnerAliveMemo = new Map<string, boolean>();
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
for (let _attempt = 0; _attempt < this.lockRetries; _attempt++) {
|
|
285
|
+
// Use a LOCAL handle variable so that concurrent acquireLock() calls on
|
|
286
|
+
// the same instance cannot close each other's file handle through the
|
|
287
|
+
// shared this.lockHandle field. We only promote to this.lockHandle after
|
|
288
|
+
// the lock is fully and successfully acquired.
|
|
289
|
+
let handle: fs.FileHandle | null = null;
|
|
290
|
+
try {
|
|
291
|
+
// Open lock file and write owner identity immediately (atomic)
|
|
292
|
+
handle = await fs.open(this.lockFilePath, 'wx', 0o600);
|
|
293
|
+
await handle.write(this._makeLockContent());
|
|
294
|
+
await handle.sync(); // Ensure content is written to disk
|
|
295
|
+
|
|
296
|
+
// Fully acquired — commit to instance state
|
|
297
|
+
this.lockHandle = handle;
|
|
298
|
+
this.lockCount = 1;
|
|
299
|
+
|
|
300
|
+
// Track in ALS for nested calls
|
|
301
|
+
if (heldLocks) {
|
|
302
|
+
heldLocks.add(lockKey);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const duration = Date.now() - startTime;
|
|
306
|
+
metrics.observe('state_lock_acquire_duration_ms', duration);
|
|
307
|
+
metrics.increment('state_lock_acquire_total', 1, { status: 'success' });
|
|
308
|
+
metrics.setGauge('state_lock_held', 1);
|
|
309
|
+
if (contentionCount > 0) {
|
|
310
|
+
metrics.increment('state_lock_contention_total', 1);
|
|
311
|
+
metrics.observe('state_lock_contention_retries', contentionCount);
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
} catch (error: unknown) {
|
|
315
|
+
// Close the locally-opened handle only (never touch this.lockHandle here)
|
|
316
|
+
if (handle) {
|
|
317
|
+
try {
|
|
318
|
+
await handle.close();
|
|
319
|
+
} catch {
|
|
320
|
+
// Ignore close error
|
|
321
|
+
}
|
|
322
|
+
// If we successfully opened the file but failed to write/sync,
|
|
323
|
+
// we should try to unlink it so other processes aren't blocked
|
|
324
|
+
// by a partial/empty lock file.
|
|
325
|
+
try {
|
|
326
|
+
await fs.unlink(this.lockFilePath);
|
|
327
|
+
} catch {
|
|
328
|
+
// Ignore unlink error
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (error instanceof Error && 'code' in error) {
|
|
333
|
+
const errnoError = error as NodeJS.ErrnoException;
|
|
334
|
+
if (errnoError.code === 'EEXIST') {
|
|
335
|
+
contentionCount++;
|
|
336
|
+
// Read lock content to verify ownership and liveness before considering stale
|
|
337
|
+
try {
|
|
338
|
+
const rawContent = await fs.readFile(this.lockFilePath, 'utf-8');
|
|
339
|
+
const parsed = this._parseLockContent(rawContent);
|
|
340
|
+
const lockUuid = parsed?.uuid ?? '';
|
|
341
|
+
const stats = await fs.stat(this.lockFilePath);
|
|
342
|
+
const lockAge = Date.now() - stats.mtimeMs;
|
|
343
|
+
|
|
344
|
+
if (lockUuid === this.lockUuid) {
|
|
345
|
+
// This is our own lock — a previous attempt was interrupted. Reclaim.
|
|
346
|
+
try { await fs.unlink(this.lockFilePath); } catch { /* ignore */ }
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Reclaim a dead owner's lock immediately; for a provably-alive
|
|
351
|
+
// owner only after the much larger liveOwnerStaleThreshold (see
|
|
352
|
+
// _shouldReclaim). This closes the lost-update window where a peer
|
|
353
|
+
// stole the lock from a live writer stalled >15s mid-fsync. A live
|
|
354
|
+
// owner holding a fresh lock is genuine contention — handled by the
|
|
355
|
+
// surrounding retry loop / timeout, not by stealing.
|
|
356
|
+
if (await this._shouldReclaim(parsed?.pid ?? null, parsed?.startTime ?? null, lockAge, contendedOwnerAliveMemo)) {
|
|
357
|
+
const trashPath = `${this.lockFilePath}.trash.${crypto.randomBytes(8).toString('hex')}`;
|
|
358
|
+
try {
|
|
359
|
+
await fs.rename(this.lockFilePath, trashPath);
|
|
360
|
+
const trashContent = await fs.readFile(trashPath, 'utf-8');
|
|
361
|
+
const trashParsed = this._parseLockContent(trashContent);
|
|
362
|
+
if ((trashParsed?.uuid ?? '') !== lockUuid) {
|
|
363
|
+
try { await fs.link(trashPath, this.lockFilePath); } catch { /* ignore */ }
|
|
364
|
+
await fs.unlink(trashPath);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
await fs.unlink(trashPath);
|
|
368
|
+
} catch { /* ignore */ }
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
} catch (_statError) {
|
|
372
|
+
const trashPath = `${this.lockFilePath}.trash.${crypto.randomBytes(8).toString('hex')}`;
|
|
373
|
+
try {
|
|
374
|
+
await fs.rename(this.lockFilePath, trashPath);
|
|
375
|
+
await fs.unlink(trashPath);
|
|
376
|
+
continue;
|
|
377
|
+
} catch { /* ignore */ }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (Date.now() - startTime >= this.lockTimeout) {
|
|
381
|
+
const duration = Date.now() - startTime;
|
|
382
|
+
metrics.observe('state_lock_acquire_duration_ms', duration, { status: 'timeout' });
|
|
383
|
+
metrics.increment('state_lock_acquire_total', 1, { status: 'timeout' });
|
|
384
|
+
throw new Error(
|
|
385
|
+
`Failed to acquire lock at ${this.lockFilePath}: timeout after ${this.lockTimeout}ms`,
|
|
386
|
+
{ cause: error },
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
await this.sleep(this.lockRetryDelay);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
metrics.increment('state_lock_acquire_total', 1, { status: 'failed' });
|
|
399
|
+
throw new Error(`Failed to acquire lock after ${this.lockRetries} retries`);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
// If acquisition failed (timeout/error), we MUST release our turn in the
|
|
402
|
+
// queue so the next caller can try.
|
|
403
|
+
if (this.resolveTurn) {
|
|
404
|
+
this.resolveTurn();
|
|
405
|
+
this.resolveTurn = null;
|
|
406
|
+
}
|
|
407
|
+
throw err;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Release the filesystem lock.
|
|
413
|
+
*/
|
|
414
|
+
async releaseLock(): Promise<void> {
|
|
415
|
+
if (this.lockHandle === null) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
this.lockCount--;
|
|
420
|
+
if (this.lockCount > 0) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
// 1. Double check ownership before unlinking
|
|
426
|
+
const content = await fs.readFile(this.lockFilePath, 'utf8').catch(() => '');
|
|
427
|
+
const parsed = this._parseLockContent(content);
|
|
428
|
+
if (parsed?.uuid === this.lockUuid) {
|
|
429
|
+
await fs.unlink(this.lockFilePath).catch(() => {});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// 2. Clean up handle
|
|
433
|
+
await this.lockHandle.close().catch(() => {});
|
|
434
|
+
this.lockHandle = null;
|
|
435
|
+
this.lockCount = 0;
|
|
436
|
+
|
|
437
|
+
// 3. Remove from ALS
|
|
438
|
+
const heldLocks = lockContext.getStore();
|
|
439
|
+
if (heldLocks) {
|
|
440
|
+
heldLocks.delete(`${this.lockFilePath}:${this.lockUuid}`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
metrics.setGauge('state_lock_held', 0);
|
|
444
|
+
} finally {
|
|
445
|
+
// 4. Signal the next caller in the queue that it's their turn.
|
|
446
|
+
const resolve = this.resolveTurn;
|
|
447
|
+
this.resolveTurn = null;
|
|
448
|
+
if (resolve) {
|
|
449
|
+
resolve();
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Execute a function within the scope of a filesystem lock.
|
|
456
|
+
* This is the preferred way to use the lock service.
|
|
457
|
+
* Safe re-entrancy is supported within the same async flow.
|
|
458
|
+
*/
|
|
459
|
+
async withLock<T>(callback: () => Promise<T> | T, _timeout: number = 30000): Promise<T> {
|
|
460
|
+
const heldLocks = lockContext.getStore();
|
|
461
|
+
const lockKey = `${this.lockFilePath}:${this.lockUuid}`;
|
|
462
|
+
|
|
463
|
+
// If we already hold this lock instance in this async flow, just call the callback
|
|
464
|
+
if (heldLocks?.has(lockKey)) {
|
|
465
|
+
return await callback();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Otherwise, create a new context (if none exists) and acquire the lock
|
|
469
|
+
return await lockContext.run(heldLocks || new Set<string>(), async () => {
|
|
470
|
+
await this.acquireLock();
|
|
471
|
+
try {
|
|
472
|
+
return await callback();
|
|
473
|
+
} finally {
|
|
474
|
+
await this.releaseLock();
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Force clean up any lock files owned by this instance.
|
|
481
|
+
*/
|
|
482
|
+
async cleanup(): Promise<void> {
|
|
483
|
+
if (this.lockHandle) {
|
|
484
|
+
try {
|
|
485
|
+
const content = await fs.readFile(this.lockFilePath, 'utf8').catch(() => '');
|
|
486
|
+
const parsed = this._parseLockContent(content);
|
|
487
|
+
if (parsed?.uuid === this.lockUuid) {
|
|
488
|
+
await fs.unlink(this.lockFilePath).catch(() => {});
|
|
489
|
+
}
|
|
490
|
+
} catch { /* ignore */ }
|
|
491
|
+
try {
|
|
492
|
+
await this.lockHandle.close();
|
|
493
|
+
} catch { /* ignore */ }
|
|
494
|
+
this.lockHandle = null;
|
|
495
|
+
}
|
|
496
|
+
this.lockCount = 0;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Check if the current instance holds the lock
|
|
501
|
+
*/
|
|
502
|
+
isLocked(): boolean {
|
|
503
|
+
return this.lockHandle !== null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Get the UUID for this service instance
|
|
508
|
+
*/
|
|
509
|
+
getLockUuid(): string {
|
|
510
|
+
return this.lockUuid;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Encode the lock file content with owner identity for liveness checks.
|
|
515
|
+
*/
|
|
516
|
+
private _makeLockContent(): string {
|
|
517
|
+
const content: { uuid: string; pid: number; startTime?: number } = {
|
|
518
|
+
uuid: this.lockUuid,
|
|
519
|
+
pid: process.pid,
|
|
520
|
+
};
|
|
521
|
+
// Omit (don't null) when unknown, so a legacy reader and a "couldn't resolve"
|
|
522
|
+
// writer both collapse to the same startTime-absent case.
|
|
523
|
+
if (this.ownStartTime !== null) content.startTime = this.ownStartTime;
|
|
524
|
+
return JSON.stringify(content);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Parse lock file content — handles both legacy bare-UUID and new JSON format.
|
|
529
|
+
* Returns null if content is empty or unparseable. A missing/non-number startTime
|
|
530
|
+
* (legacy lock, or a writer that couldn't resolve its own) parses to null.
|
|
531
|
+
*/
|
|
532
|
+
private _parseLockContent(content: string): { uuid: string; pid: number | null; startTime: number | null } | null {
|
|
533
|
+
const trimmed = content.trim();
|
|
534
|
+
if (!trimmed) return null;
|
|
535
|
+
try {
|
|
536
|
+
const parsed = JSON.parse(trimmed) as { uuid?: string; pid?: number; startTime?: number };
|
|
537
|
+
if (typeof parsed.uuid === 'string') {
|
|
538
|
+
return {
|
|
539
|
+
uuid: parsed.uuid,
|
|
540
|
+
pid: typeof parsed.pid === 'number' ? parsed.pid : null,
|
|
541
|
+
startTime: typeof parsed.startTime === 'number' ? parsed.startTime : null,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
} catch {
|
|
545
|
+
// Legacy format: bare UUID string
|
|
546
|
+
return { uuid: trimmed, pid: null, startTime: null };
|
|
547
|
+
}
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Check whether the process that wrote the lock file is still alive.
|
|
553
|
+
* Returns true (assume alive) if we cannot determine liveness.
|
|
554
|
+
*
|
|
555
|
+
* With processLifecycle wired AND a startTime recorded in the lock, this is a
|
|
556
|
+
* PID-reuse-safe check: a recycled PID whose start time differs from the lock's
|
|
557
|
+
* reads as dead. Without either (no DI, or a legacy/unknown-startTime lock) it
|
|
558
|
+
* degrades to a bare signal(0) check — the exact pre-fix behavior, no subprocess.
|
|
559
|
+
*/
|
|
560
|
+
private async _isOwnerAlive(pid: number | null, startTime: number | null): Promise<boolean> {
|
|
561
|
+
if (pid === null) return true; // Legacy/unparseable lock — can't check, assume alive
|
|
562
|
+
if (pid === process.pid) {
|
|
563
|
+
// Same PID as us. Normally that means it's our own lock — but if the lock
|
|
564
|
+
// carries a start time that differs from ours, this PID was RECYCLED and the
|
|
565
|
+
// lock belongs to a dead predecessor, not us. Only assume "alive" when the
|
|
566
|
+
// start times agree (or either is unknown).
|
|
567
|
+
if (startTime !== null && this.ownStartTime !== null) return startTime === this.ownStartTime;
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
if (!this.processLifecycle) {
|
|
571
|
+
// Not wired for DI (e.g. a bare `new FileLockService(...)`): preserve the
|
|
572
|
+
// original signal(0)-only behavior exactly.
|
|
573
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
574
|
+
}
|
|
575
|
+
if (startTime === null) {
|
|
576
|
+
// Legacy lock (no startTime) or the peer's own start-time lookup failed:
|
|
577
|
+
// can't assert PID identity, so bare-PID path. isProcessAlive() with no
|
|
578
|
+
// expectedStartTime is signal(0)-only — no subprocess.
|
|
579
|
+
return this.processLifecycle.isProcessAlive(pid);
|
|
580
|
+
}
|
|
581
|
+
// PID-reuse-safe. May shell out on macOS/Windows — the hot contention path
|
|
582
|
+
// reaches this only through the per-episode memo (_resolveOwnerAlive).
|
|
583
|
+
return this.processLifecycle.isProcessAlive(pid, startTime);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Sleep for a specified number of milliseconds
|
|
588
|
+
*/
|
|
589
|
+
private sleep(ms: number): Promise<void> {
|
|
590
|
+
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
591
|
+
}
|
|
592
|
+
}
|