solidity-argus 0.3.6 → 0.5.6

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 (107) hide show
  1. package/AGENTS.md +13 -6
  2. package/README.md +24 -12
  3. package/package.json +7 -3
  4. package/skills/checklists/cyfrin-best-practices-runtime/SKILL.md +1 -0
  5. package/skills/checklists/cyfrin-best-practices-upgrades/SKILL.md +1 -0
  6. package/skills/checklists/cyfrin-defi-core/SKILL.md +1 -0
  7. package/skills/checklists/cyfrin-defi-integrations/SKILL.md +1 -0
  8. package/skills/checklists/cyfrin-gas/SKILL.md +1 -0
  9. package/skills/checklists/general-audit/SKILL.md +1 -0
  10. package/skills/methodology/audit-workflow/SKILL.md +1 -0
  11. package/skills/methodology/report-template/SKILL.md +1 -0
  12. package/skills/methodology/severity-classification/SKILL.md +1 -0
  13. package/skills/protocol-patterns/amm-dex/SKILL.md +1 -0
  14. package/skills/protocol-patterns/bridges-cross-chain/SKILL.md +1 -0
  15. package/skills/protocol-patterns/dao-governance/SKILL.md +1 -0
  16. package/skills/protocol-patterns/lending-borrowing/SKILL.md +1 -0
  17. package/skills/protocol-patterns/staking-vesting/SKILL.md +1 -0
  18. package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +0 -50
  19. package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +0 -63
  20. package/src/agents/argus-prompt.ts +98 -33
  21. package/src/agents/pythia-prompt.ts +18 -1
  22. package/src/agents/scribe-prompt.ts +32 -10
  23. package/src/agents/sentinel-prompt.ts +19 -0
  24. package/src/agents/themis-prompt.ts +110 -0
  25. package/src/cli/commands/doctor.ts +29 -17
  26. package/src/config/loader.ts +29 -5
  27. package/src/config/schema.ts +45 -45
  28. package/src/constants/defaults.ts +1 -0
  29. package/src/create-hooks.ts +851 -142
  30. package/src/create-managers.ts +4 -2
  31. package/src/create-tools.ts +5 -1
  32. package/src/features/audit-enforcer/audit-enforcer.ts +1 -11
  33. package/src/features/background-agent/background-manager.ts +32 -5
  34. package/src/features/error-recovery/tool-error-recovery.ts +1 -0
  35. package/src/features/persistent-state/audit-state-manager.ts +272 -29
  36. package/src/features/persistent-state/event-sink.ts +96 -25
  37. package/src/features/persistent-state/findings-materializer.ts +57 -3
  38. package/src/features/persistent-state/global-run-index.ts +86 -8
  39. package/src/features/persistent-state/index.ts +7 -1
  40. package/src/features/persistent-state/run-finalizer.ts +116 -7
  41. package/src/features/persistent-state/run-pruner.ts +93 -0
  42. package/src/hooks/agent-tracker.ts +14 -2
  43. package/src/hooks/compaction-hook.ts +7 -16
  44. package/src/hooks/config-handler.ts +83 -29
  45. package/src/hooks/context-budget.ts +4 -5
  46. package/src/hooks/event-hook.ts +213 -57
  47. package/src/hooks/knowledge-sync-hook.ts +2 -3
  48. package/src/hooks/safe-create-hook.ts +13 -1
  49. package/src/hooks/system-prompt-hook.ts +20 -39
  50. package/src/hooks/tool-tracking-hook.ts +606 -326
  51. package/src/index.ts +15 -1
  52. package/src/knowledge/scvd-client.ts +2 -4
  53. package/src/knowledge/scvd-errors.ts +25 -2
  54. package/src/knowledge/scvd-index.ts +7 -5
  55. package/src/knowledge/scvd-sync.ts +6 -6
  56. package/src/managers/types.ts +20 -2
  57. package/src/shared/agent-names.ts +23 -0
  58. package/src/shared/audit-artifact-resolver.ts +8 -3
  59. package/src/shared/audit-phases.ts +12 -0
  60. package/src/shared/cache-paths.ts +41 -0
  61. package/src/shared/drop-diagnostics.ts +2 -2
  62. package/src/shared/forge-errors.ts +31 -0
  63. package/src/shared/forge-runner.ts +30 -0
  64. package/src/shared/format-error.ts +3 -0
  65. package/src/shared/index.ts +9 -0
  66. package/src/shared/key-tools.ts +39 -0
  67. package/src/shared/logger.ts +7 -7
  68. package/src/shared/path-containment.ts +25 -0
  69. package/src/shared/path-utils.ts +11 -0
  70. package/src/shared/report-path-resolver.ts +4 -2
  71. package/src/shared/safe-emit.ts +24 -0
  72. package/src/shared/token-utils.ts +5 -0
  73. package/src/shared/type-guards.ts +8 -0
  74. package/src/shared/validation-constants.ts +52 -0
  75. package/src/skills/analysis/cluster.ts +1 -114
  76. package/src/skills/analysis/normalize.ts +2 -114
  77. package/src/skills/analysis/stopwords.ts +109 -0
  78. package/src/skills/argus-skill-resolver.ts +6 -3
  79. package/src/solodit-lifecycle.ts +153 -37
  80. package/src/state/adapters.ts +60 -66
  81. package/src/state/finding-aggregation.ts +6 -8
  82. package/src/state/finding-fingerprint.ts +1 -1
  83. package/src/state/finding-store.ts +31 -9
  84. package/src/state/index.ts +1 -1
  85. package/src/state/projectors.ts +27 -19
  86. package/src/state/schemas.ts +8 -32
  87. package/src/state/types.ts +3 -0
  88. package/src/tools/contract-analyzer-tool.ts +4 -6
  89. package/src/tools/forge-coverage-tool.ts +10 -35
  90. package/src/tools/forge-fuzz-tool.ts +21 -51
  91. package/src/tools/forge-test-tool.ts +25 -47
  92. package/src/tools/gas-analysis-tool.ts +12 -41
  93. package/src/tools/pattern-checker-tool.ts +37 -15
  94. package/src/tools/pattern-loader.ts +18 -4
  95. package/src/tools/persist-deduped-tool.ts +94 -0
  96. package/src/tools/proxy-detection-tool.ts +35 -34
  97. package/src/tools/read-findings-tool.ts +390 -0
  98. package/src/tools/record-finding-tool.ts +120 -25
  99. package/src/tools/report-generator-tool.ts +396 -328
  100. package/src/tools/report-preflight.ts +5 -1
  101. package/src/tools/slither-tool.ts +55 -16
  102. package/src/tools/solodit-search-tool.ts +260 -112
  103. package/src/tools/sync-knowledge-tool.ts +2 -3
  104. package/src/utils/solidity-parser.ts +39 -24
  105. package/src/features/migration/index.ts +0 -14
  106. package/src/features/migration/migration-adapter.ts +0 -151
  107. package/src/features/migration/parity-telemetry.ts +0 -133
@@ -16,8 +16,10 @@ export function createManagers(args: {
16
16
  const backgroundManager = createBackgroundManager(
17
17
  backgroundDispatcher ??
18
18
  (async (agentName: string, prompt: string) => {
19
- logger.warn(`Background dispatch not wired: ${agentName} (${prompt.slice(0, 50)}...)`)
20
- return `noop-${Date.now()}`
19
+ logger.warn(
20
+ `Background dispatcher not configured — task will not be executed: ${agentName} (${prompt.slice(0, 50)}...)`,
21
+ )
22
+ return ""
21
23
  }),
22
24
  { maxConcurrent: config.background?.max_concurrent ?? 3 },
23
25
  )
@@ -7,7 +7,9 @@ import { forgeFuzzTool } from "./tools/forge-fuzz-tool"
7
7
  import { forgeTestTool } from "./tools/forge-test-tool"
8
8
  import { gasAnalysisTool } from "./tools/gas-analysis-tool"
9
9
  import { patternCheckerTool } from "./tools/pattern-checker-tool"
10
+ import { persistDedupedTool } from "./tools/persist-deduped-tool"
10
11
  import { proxyDetectionTool } from "./tools/proxy-detection-tool"
12
+ import { readFindingsTool } from "./tools/read-findings-tool"
11
13
  import { recordFindingTool } from "./tools/record-finding-tool"
12
14
  import { reportGeneratorTool } from "./tools/report-generator-tool"
13
15
  import { slitherTool } from "./tools/slither-tool"
@@ -26,12 +28,14 @@ export function createTools(config: ArgusConfig): Record<string, ToolDefinition>
26
28
  argus_proxy_detection: proxyDetectionTool,
27
29
  argus_skill_load: argusSkillLoadTool,
28
30
  argus_record_finding: recordFindingTool,
31
+ argus_read_findings: readFindingsTool,
32
+ argus_persist_deduped: persistDedupedTool,
29
33
  argus_generate_report: reportGeneratorTool,
30
34
  argus_sync_knowledge: syncKnowledgeTool,
31
35
  }
32
36
 
33
37
  if (config.solodit?.enabled !== false) {
34
- tools.argus_solodit_search = createSoloditSearchTool(config.solodit?.port ?? 3000)
38
+ tools.argus_solodit_search = createSoloditSearchTool()
35
39
  }
36
40
 
37
41
  return tools
@@ -1,16 +1,6 @@
1
+ import { PHASE_ORDER } from "../../shared/audit-phases"
1
2
  import type { AuditPhase, AuditState } from "../../state/types"
2
3
 
3
- const PHASE_ORDER: AuditPhase[] = [
4
- "reconnaissance",
5
- "scanning",
6
- "manual-review",
7
- "attack-surface",
8
- "research",
9
- "testing",
10
- "reporting",
11
- "complete",
12
- ]
13
-
14
4
  const REPORTING_PHASES: AuditPhase[] = ["reporting", "complete"]
15
5
 
16
6
  const KEY_TOOL_FAMILIES: Array<{ family: string; prefixes: string[] }> = [
@@ -44,6 +44,7 @@ export function createBackgroundManager(
44
44
  let runningCount = 0
45
45
  const maxConcurrent = options?.maxConcurrent ?? 3
46
46
  let taskCount = 0
47
+ let drainScheduled = false
47
48
 
48
49
  function safeInvokeCallback(callback: CompletionCallback, taskId: string, result: unknown): void {
49
50
  try {
@@ -70,7 +71,16 @@ export function createBackgroundManager(
70
71
  task.callbacks.clear()
71
72
  }
72
73
 
73
- function processQueue(): void {
74
+ function scheduleDrain(): void {
75
+ if (drainScheduled) return
76
+ drainScheduled = true
77
+ queueMicrotask(() => {
78
+ drainScheduled = false
79
+ drainQueue()
80
+ })
81
+ }
82
+
83
+ function drainQueue(): void {
74
84
  while (runningCount < maxConcurrent && queue.length > 0) {
75
85
  const nextTaskId = queue.shift()
76
86
 
@@ -86,7 +96,16 @@ export function createBackgroundManager(
86
96
  task.status = "running"
87
97
  runningCount += 1
88
98
 
89
- dispatcher(task.agentName, task.prompt, task.options)
99
+ const TASK_TIMEOUT_MS = 5 * 60 * 1000
100
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined
101
+ const timeoutPromise = new Promise<never>((_, reject) => {
102
+ timeoutHandle = setTimeout(
103
+ () => reject(new Error(`Background task timed out after 5 minutes: ${nextTaskId}`)),
104
+ TASK_TIMEOUT_MS,
105
+ )
106
+ })
107
+
108
+ Promise.race([dispatcher(task.agentName, task.prompt, task.options), timeoutPromise])
90
109
  .then((result) => {
91
110
  const currentTask = tasks.get(nextTaskId)
92
111
 
@@ -105,14 +124,22 @@ export function createBackgroundManager(
105
124
  return
106
125
  }
107
126
 
127
+ const isTimeout =
128
+ error instanceof Error && error.message.includes("timed out after 5 minutes")
129
+ if (isTimeout) {
130
+ logger.error(`Background task timed out: ${nextTaskId}`, error)
131
+ } else {
132
+ logger.error(`Background task failed: ${nextTaskId}`, error)
133
+ }
134
+
108
135
  currentTask.status = "failed"
109
136
  currentTask.error = error
110
- logger.error(`Background task failed: ${nextTaskId}`, error)
111
137
  invokeCallbacks(nextTaskId, error)
112
138
  })
113
139
  .finally(() => {
140
+ if (timeoutHandle) clearTimeout(timeoutHandle)
114
141
  runningCount = Math.max(0, runningCount - 1)
115
- processQueue()
142
+ scheduleDrain()
116
143
  })
117
144
  }
118
145
  }
@@ -130,7 +157,7 @@ export function createBackgroundManager(
130
157
  })
131
158
 
132
159
  queue.push(taskId)
133
- processQueue()
160
+ scheduleDrain()
134
161
 
135
162
  return taskId
136
163
  }
@@ -60,6 +60,7 @@ export function createToolErrorRecoveryHandler(
60
60
 
61
61
  return (toolResult: { tool: string; result: string }): string | null => {
62
62
  const { tool, result } = toolResult
63
+ if (!result || typeof result !== "string") return null
63
64
  const lowerResult = result.toLowerCase()
64
65
 
65
66
  const isViaIr =
@@ -1,14 +1,18 @@
1
- import { mkdir, rename } from "node:fs/promises"
1
+ import { createHash } from "node:crypto"
2
+ import { mkdirSync } from "node:fs"
3
+ import { mkdir, readdir, rename, rm, stat } from "node:fs/promises"
2
4
  import { dirname, join } from "node:path"
3
5
  import type { AuditStateManager } from "../../managers/types"
4
6
  import { createLogger } from "../../shared/logger"
5
7
  import { type ArgusRootResolver, defaultRootResolver } from "../../shared/path-root-resolver"
6
8
  import { createAuditState } from "../../state/audit-state"
9
+ import { normalizeText } from "../../state/finding-fingerprint"
7
10
  import { projectAuditState, stableHash } from "../../state/projectors"
8
11
  import type { AuditState, PersistentAuditState } from "../../state/types"
9
12
  import { readEvents } from "./event-sink"
10
13
 
11
14
  const STATE_FILE_NAME = "argus-state.json"
15
+ const SESSIONS_DIR = "sessions"
12
16
  const STATE_VERSION = "2"
13
17
 
14
18
  type ProjectedAuditCore = Pick<
@@ -24,6 +28,73 @@ interface ConsistentStateResult {
24
28
  repaired: boolean
25
29
  }
26
30
 
31
+ const SAVE_MUTEX_TIMEOUT_MS = 30_000
32
+ const MAX_SAVE_CAS_RETRIES = 10
33
+ const LEGACY_OBSERVATION_ID_PATTERN = /^obs-\d+$/
34
+
35
+ function generateDeterministicFindingId(
36
+ check: string,
37
+ file: string,
38
+ lines: [number, number],
39
+ ): string {
40
+ return createHash("sha256")
41
+ .update(`${normalizeText(check)}:${normalizeText(file)}:${lines[0]}-${lines[1]}`)
42
+ .digest("hex")
43
+ .substring(0, 16)
44
+ }
45
+
46
+ export function migrateLegacyFindingIds(state: AuditState): number {
47
+ let migratedCount = 0
48
+
49
+ state.findings = state.findings.map((finding) => {
50
+ if (!LEGACY_OBSERVATION_ID_PATTERN.test(finding.id)) {
51
+ return finding
52
+ }
53
+
54
+ migratedCount += 1
55
+ return {
56
+ ...finding,
57
+ id: generateDeterministicFindingId(finding.check, finding.file, finding.lines),
58
+ }
59
+ })
60
+
61
+ return migratedCount
62
+ }
63
+
64
+ export function createAsyncMutex(timeoutMs = SAVE_MUTEX_TIMEOUT_MS) {
65
+ const logger = createLogger()
66
+ let chain = Promise.resolve()
67
+
68
+ return {
69
+ async acquire(): Promise<() => void> {
70
+ const previous = chain
71
+ let releaseCurrent!: () => void
72
+ chain = new Promise<void>((resolve) => {
73
+ releaseCurrent = resolve
74
+ })
75
+
76
+ await previous
77
+
78
+ let released = false
79
+ const timeout = setTimeout(() => {
80
+ // Log the timeout but do NOT release — the holder must finish
81
+ // its critical section and call release() explicitly.
82
+ logger.error(`audit-state-manager mutex held for >${timeoutMs}ms — possible deadlock`)
83
+ }, timeoutMs)
84
+
85
+ return () => {
86
+ if (released) {
87
+ return
88
+ }
89
+
90
+ released = true
91
+ clearTimeout(timeout)
92
+ releaseCurrent()
93
+ }
94
+ },
95
+ }
96
+ }
97
+
27
98
  function isObject(value: unknown): value is Record<string, unknown> {
28
99
  return typeof value === "object" && value !== null
29
100
  }
@@ -115,28 +186,40 @@ export function createDebouncedSave(
115
186
  ): {
116
187
  save: (state: AuditState) => void
117
188
  flush: () => Promise<void>
189
+ dispose: () => void
118
190
  } {
119
191
  let timer: ReturnType<typeof setTimeout> | null = null
120
- let pendingState: AuditState | null = null
192
+ const pendingStates: AuditState[] = []
193
+ let persistQueue = Promise.resolve()
121
194
 
122
- async function persistPendingState(): Promise<void> {
123
- if (!pendingState) {
195
+ async function persistPendingStateQueue(): Promise<void> {
196
+ if (pendingStates.length === 0) {
124
197
  return
125
198
  }
126
199
 
127
- const stateToPersist = pendingState
128
- pendingState = null
200
+ // Only the latest state matters — each write replaces the file
201
+ const latestState = pendingStates[pendingStates.length - 1]
202
+ if (!latestState) {
203
+ pendingStates.length = 0
204
+ return
205
+ }
206
+ pendingStates.length = 0
129
207
 
130
208
  try {
131
- await saveState(stateToPersist)
209
+ await saveState(latestState)
132
210
  } catch {
133
211
  createLogger().debug("Debounced state persistence failed")
134
212
  }
135
213
  }
136
214
 
215
+ function enqueuePersist(): Promise<void> {
216
+ persistQueue = persistQueue.then(() => persistPendingStateQueue())
217
+ return persistQueue
218
+ }
219
+
137
220
  return {
138
221
  save(state: AuditState): void {
139
- pendingState = state
222
+ pendingStates.push(state)
140
223
 
141
224
  if (timer) {
142
225
  clearTimeout(timer)
@@ -144,7 +227,7 @@ export function createDebouncedSave(
144
227
 
145
228
  timer = setTimeout(() => {
146
229
  timer = null
147
- void persistPendingState()
230
+ void enqueuePersist()
148
231
  }, delayMs)
149
232
  },
150
233
  async flush(): Promise<void> {
@@ -153,7 +236,14 @@ export function createDebouncedSave(
153
236
  timer = null
154
237
  }
155
238
 
156
- await persistPendingState()
239
+ await enqueuePersist()
240
+ },
241
+ dispose(): void {
242
+ if (timer) {
243
+ clearTimeout(timer)
244
+ timer = null
245
+ }
246
+ pendingStates.length = 0
157
247
  },
158
248
  }
159
249
  }
@@ -164,8 +254,39 @@ export function createAuditStateManager(
164
254
  ): AuditStateManager {
165
255
  const logger = createLogger()
166
256
 
167
- const stateFilePath = join(resolver.writeRoot(projectDir), STATE_FILE_NAME)
257
+ const argusRoot = resolver.writeRoot(projectDir)
258
+ const sharedStateFilePath = join(argusRoot, STATE_FILE_NAME)
259
+ const sessionsDirPath = join(argusRoot, SESSIONS_DIR)
260
+ let stateFilePath = sharedStateFilePath
261
+ let boundSessionId: string | undefined
168
262
  let currentState: AuditState = createAuditState(projectDir).state
263
+ const saveMutex = createAsyncMutex()
264
+
265
+ async function cleanupStaleTempFiles(): Promise<void> {
266
+ for (const dirPath of [argusRoot, sessionsDirPath]) {
267
+ let entries: string[]
268
+
269
+ try {
270
+ entries = await readdir(dirPath)
271
+ } catch {
272
+ continue
273
+ }
274
+
275
+ for (const entry of entries) {
276
+ if (!entry.endsWith(".tmp")) {
277
+ continue
278
+ }
279
+
280
+ try {
281
+ await rm(join(dirPath, entry), { force: true })
282
+ } catch (error) {
283
+ logger.warn(`Failed to remove stale tmp state file ${entry}`, error)
284
+ }
285
+ }
286
+ }
287
+ }
288
+
289
+ const startupCleanup = cleanupStaleTempFiles()
169
290
 
170
291
  async function deriveConsistentState(state: AuditState): Promise<ConsistentStateResult> {
171
292
  if (!state.sessionId || !state.projectDir) {
@@ -219,16 +340,91 @@ export function createAuditStateManager(
219
340
  }
220
341
  }
221
342
 
343
+ function bindSession(sessionId: string): void {
344
+ if (boundSessionId) {
345
+ logger.debug(`Already bound to session ${boundSessionId}, ignoring bind for ${sessionId}`)
346
+ return
347
+ }
348
+ boundSessionId = sessionId
349
+ stateFilePath = join(sessionsDirPath, `state-${sessionId}.json`)
350
+ try {
351
+ mkdirSync(sessionsDirPath, { recursive: true })
352
+ } catch {
353
+ logger.warn(`Failed to create sessions directory: ${sessionsDirPath}`)
354
+ }
355
+ logger.debug(`Bound state manager to session ${sessionId}: ${stateFilePath}`)
356
+ }
357
+
222
358
  async function load(): Promise<AuditState | null> {
223
359
  try {
224
- const resolvedPath = resolver.resolveReadPath(projectDir, STATE_FILE_NAME)
225
- const readPath = resolvedPath ?? stateFilePath
360
+ // 1. If bound to a session, try the session-scoped file first
361
+ let readPath: string | null = null
226
362
 
227
- const file = Bun.file(readPath)
228
- if (!(await file.exists())) {
363
+ if (boundSessionId) {
364
+ const sessionFile = Bun.file(stateFilePath)
365
+ if (await sessionFile.exists()) {
366
+ readPath = stateFilePath
367
+ }
368
+ }
369
+
370
+ // 2. Bound sessions with no matching file start clean — no cross-session contamination
371
+ if (!readPath && boundSessionId) {
372
+ logger.info("Starting new audit session with clean state")
373
+ return null
374
+ }
375
+
376
+ // 3. Unbound: scan sessions dir for most recent (backward compat)
377
+ if (!readPath) {
378
+ try {
379
+ const entries = await readdir(sessionsDirPath)
380
+ const jsonFiles = entries.filter((e) => e.startsWith("state-") && e.endsWith(".json"))
381
+
382
+ if (jsonFiles.length > 0) {
383
+ let newest: { name: string; mtime: number } | null = null
384
+ for (const name of jsonFiles) {
385
+ const filePath = join(sessionsDirPath, name)
386
+ try {
387
+ const s = await stat(filePath)
388
+ const mtime = s.mtimeMs
389
+ if (
390
+ !newest ||
391
+ mtime > newest.mtime ||
392
+ (mtime === newest.mtime && name > newest.name)
393
+ ) {
394
+ newest = { name, mtime }
395
+ }
396
+ } catch {
397
+ // Skip unreadable files
398
+ }
399
+ }
400
+ if (newest) {
401
+ readPath = join(sessionsDirPath, newest.name)
402
+ logger.debug(
403
+ `No session-scoped file for (unbound), falling back to newest: ${newest.name}`,
404
+ )
405
+ }
406
+ }
407
+ } catch {
408
+ // sessions dir doesn't exist yet
409
+ }
410
+ }
411
+
412
+ // 4. Unbound: try legacy shared file
413
+ if (!readPath) {
414
+ const resolvedPath = resolver.resolveReadPath(projectDir, STATE_FILE_NAME)
415
+ const legacyPath = resolvedPath ?? sharedStateFilePath
416
+ const legacyFile = Bun.file(legacyPath)
417
+ if (await legacyFile.exists()) {
418
+ readPath = legacyPath
419
+ logger.debug(`Falling back to legacy shared state file: ${legacyPath}`)
420
+ }
421
+ }
422
+
423
+ if (!readPath) {
229
424
  return null
230
425
  }
231
426
 
427
+ const file = Bun.file(readPath)
232
428
  const content = await file.text()
233
429
  if (!content.trim()) {
234
430
  return null
@@ -259,6 +455,11 @@ export function createAuditStateManager(
259
455
  }
260
456
  }
261
457
 
458
+ const migratedFindingCount = migrateLegacyFindingIds(state)
459
+ if (migratedFindingCount > 0) {
460
+ logger.info(`Migrating ${migratedFindingCount} finding IDs to deterministic format`)
461
+ }
462
+
262
463
  if (snapshotSeq !== undefined) {
263
464
  logger.debug(`Loaded snapshot with last_event_seq=${snapshotSeq} from ${readPath}`)
264
465
  }
@@ -292,21 +493,18 @@ export function createAuditStateManager(
292
493
  }
293
494
  }
294
495
 
295
- let saveInFlight = false
296
-
297
496
  async function save(state: AuditState): Promise<void> {
497
+ await startupCleanup
498
+ const releaseMutex = await saveMutex.acquire()
298
499
  currentState = state
299
500
 
300
- if (saveInFlight) return
301
- saveInFlight = true
302
-
303
501
  try {
304
- while (true) {
502
+ for (let attempt = 0; attempt < MAX_SAVE_CAS_RETRIES; attempt += 1) {
305
503
  const stateToSave = currentState
306
504
  const consistent = await deriveConsistentState(stateToSave)
307
505
 
308
506
  if (consistent.repaired) {
309
- logger.warn(
507
+ logger.debug(
310
508
  `State/core divergence detected for run ${stateToSave.sessionId}; auto-repairing`,
311
509
  )
312
510
  currentState = consistent.state
@@ -323,17 +521,40 @@ export function createAuditStateManager(
323
521
  }
324
522
 
325
523
  const tempFilePath = `${stateFilePath}.${Date.now()}.tmp`
326
- await mkdir(dirname(stateFilePath), { recursive: true })
327
- await Bun.write(tempFilePath, `${JSON.stringify(persistentState, null, 2)}\n`)
328
- await rename(tempFilePath, stateFilePath)
524
+ const targetDir = dirname(stateFilePath)
525
+ await mkdir(targetDir, { recursive: true })
526
+
527
+ // Retry write+rename on ENOENT — mkdir may not have flushed to
528
+ // disk before Bun.write attempts to use the directory.
529
+ const ENOENT_RETRIES = 3
530
+ for (let fsRetry = 0; fsRetry < ENOENT_RETRIES; fsRetry += 1) {
531
+ try {
532
+ await Bun.write(tempFilePath, `${JSON.stringify(persistentState, null, 2)}\n`)
533
+ await rename(tempFilePath, stateFilePath)
534
+ break
535
+ } catch (fsErr) {
536
+ const isEnoent =
537
+ fsErr instanceof Error && (fsErr as NodeJS.ErrnoException).code === "ENOENT"
538
+ if (!isEnoent || fsRetry === ENOENT_RETRIES - 1) {
539
+ throw fsErr
540
+ }
541
+ // Re-create directory and retry after a brief delay
542
+ await mkdir(targetDir, { recursive: true })
543
+ await Bun.sleep(50)
544
+ }
545
+ }
329
546
 
330
- if (currentState === consistent.state) break
547
+ if (currentState === consistent.state) {
548
+ return
549
+ }
331
550
  }
551
+
552
+ logger.warn("CAS retries exhausted after 10 attempts; using last read state")
332
553
  } catch (err) {
333
554
  logger.warn("Failed to persist audit state", err)
334
555
  throw err
335
556
  } finally {
336
- saveInFlight = false
557
+ releaseMutex()
337
558
  }
338
559
  }
339
560
 
@@ -364,7 +585,7 @@ export function createAuditStateManager(
364
585
  if (hasContent) {
365
586
  try {
366
587
  const consistent = await deriveConsistentState(currentState)
367
- const archivesDir = join(dirname(stateFilePath), "archives")
588
+ const archivesDir = join(argusRoot, "archives")
368
589
  await mkdir(archivesDir, { recursive: true })
369
590
  const archivePath = join(archivesDir, `argus-state.${Date.now()}.json`)
370
591
  const persistentState: PersistentAuditState = {
@@ -383,15 +604,37 @@ export function createAuditStateManager(
383
604
  }
384
605
 
385
606
  currentState = createAuditState(projectDir).state
386
- await save(currentState)
607
+
608
+ try {
609
+ await rm(stateFilePath, { force: true })
610
+ } catch (error) {
611
+ logger.warn(`Failed to remove live state file after archive: ${stateFilePath}`, error)
612
+ }
613
+ }
614
+
615
+ let disposed = false
616
+
617
+ async function dispose(): Promise<void> {
618
+ if (disposed) {
619
+ return
620
+ }
621
+ disposed = true
622
+
623
+ try {
624
+ await save(currentState)
625
+ } catch (err) {
626
+ logger.warn("Failed to flush state during dispose", err)
627
+ }
387
628
  }
388
629
 
389
630
  return {
631
+ bindSession,
390
632
  load,
391
633
  save,
392
634
  get,
393
635
  update,
394
636
  reset,
395
637
  archive,
638
+ dispose,
396
639
  }
397
640
  }