spexcode 0.5.2 → 0.5.4

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 (151) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +25 -1
  4. package/spec-cli/hooks/dispatch.sh +24 -1
  5. package/spec-cli/src/anchors.ts +192 -58
  6. package/spec-cli/src/attach.ts +11 -8
  7. package/spec-cli/src/claude-headless.ts +13 -52
  8. package/spec-cli/src/cli.ts +209 -71
  9. package/spec-cli/src/client.ts +143 -5
  10. package/spec-cli/src/gateway-hub.ts +2 -6
  11. package/spec-cli/src/gateway.ts +96 -11
  12. package/spec-cli/src/git.ts +1395 -96
  13. package/spec-cli/src/graph.ts +21 -17
  14. package/spec-cli/src/graphCache.ts +405 -61
  15. package/spec-cli/src/graphStream.ts +581 -182
  16. package/spec-cli/src/guide.ts +15 -4
  17. package/spec-cli/src/harness.ts +1133 -71
  18. package/spec-cli/src/headless-controller.ts +63 -0
  19. package/spec-cli/src/help.ts +120 -64
  20. package/spec-cli/src/host-resources.ts +698 -0
  21. package/spec-cli/src/index.ts +95 -21
  22. package/spec-cli/src/init.ts +73 -15
  23. package/spec-cli/src/issues.ts +36 -3
  24. package/spec-cli/src/layout.ts +189 -41
  25. package/spec-cli/src/lint.ts +152 -27
  26. package/spec-cli/src/localIssues.ts +5 -1
  27. package/spec-cli/src/maintenance-wrapper.ts +276 -0
  28. package/spec-cli/src/materialize.ts +6 -7
  29. package/spec-cli/src/opencode-headless.ts +4 -8
  30. package/spec-cli/src/pi-headless.ts +17 -60
  31. package/spec-cli/src/process-identity.ts +174 -0
  32. package/spec-cli/src/project-store.ts +17 -0
  33. package/spec-cli/src/pty-bridge.ts +6 -3
  34. package/spec-cli/src/reviews.ts +17 -13
  35. package/spec-cli/src/runtime-ownership.ts +88 -0
  36. package/spec-cli/src/session-maintenance.ts +750 -0
  37. package/spec-cli/src/session-timeline.ts +6 -6
  38. package/spec-cli/src/sessions.ts +1251 -208
  39. package/spec-cli/src/sh.ts +3 -0
  40. package/spec-cli/src/source-files.ts +27 -2
  41. package/spec-cli/src/specs.ts +58 -25
  42. package/spec-cli/src/supervise.ts +31 -11
  43. package/spec-cli/src/uninstall.ts +7 -6
  44. package/spec-cli/templates/hooks/commit-msg +14 -0
  45. package/spec-cli/templates/hooks/pre-commit +29 -15
  46. package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
  47. package/spec-cli/templates/hooks/reference-transaction +105 -0
  48. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  49. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
  50. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
  51. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
  53. package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
  54. package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
  56. package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
  57. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  58. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  59. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  60. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  61. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  62. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  63. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  64. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  65. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  66. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  67. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  68. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  69. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  70. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  71. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  72. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  73. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  74. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  75. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  76. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  77. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  78. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  79. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  80. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  81. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  82. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  83. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  84. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  85. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  86. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  87. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  88. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  89. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  90. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  91. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  92. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  93. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  94. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  95. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  96. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  97. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  98. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  99. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  100. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  101. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  102. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  103. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  104. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  105. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  106. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  107. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  108. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  109. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  110. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  111. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  112. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  113. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  114. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  115. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  116. package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
  117. package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
  118. package/spec-dashboard/dist/assets/{PageScroll-CadAKuSy.js → PageScroll-CAY4S4g4.js} +1 -1
  119. package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
  120. package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
  121. package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
  122. package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
  123. package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
  124. package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
  125. package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
  126. package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
  127. package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
  128. package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
  129. package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
  130. package/spec-dashboard/dist/index.html +5 -4
  131. package/spec-dashboard/src/reviewFilters.js +46 -8
  132. package/spec-dashboard/src/session.js +64 -9
  133. package/spec-eval/src/cli.ts +144 -32
  134. package/spec-eval/src/evaltab.ts +16 -9
  135. package/spec-eval/src/freshness.ts +336 -54
  136. package/spec-eval/src/scenariofresh.ts +51 -1
  137. package/spec-eval/src/scenarios.ts +324 -25
  138. package/spec-eval/src/sessioneval.ts +969 -173
  139. package/spec-cli/src/message-stream.ts +0 -147
  140. package/spec-dashboard/dist/assets/Dashboard-CTAuTyZ3.js +0 -27
  141. package/spec-dashboard/dist/assets/EvalsPage-KbMMownG.js +0 -2
  142. package/spec-dashboard/dist/assets/IssuesPage-DmyLb9Rj.js +0 -1
  143. package/spec-dashboard/dist/assets/MobileApp-D2RZGt4Z.js +0 -2
  144. package/spec-dashboard/dist/assets/Modal-3brXUhM0.js +0 -1
  145. package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +0 -1
  146. package/spec-dashboard/dist/assets/SessionInterface-BtrzlOPs.js +0 -39
  147. package/spec-dashboard/dist/assets/SessionWindow-BWH5O0jh.js +0 -13
  148. package/spec-dashboard/dist/assets/Settings-COgdKTJB.js +0 -1
  149. package/spec-dashboard/dist/assets/TimelineChat-DQ21GSJK.js +0 -1
  150. package/spec-dashboard/dist/assets/index-D6HBvKkJ.js +0 -41
  151. package/spec-dashboard/dist/assets/index-DFdlYy4H.css +0 -1
@@ -0,0 +1,698 @@
1
+ import { cpus, platform } from 'node:os'
2
+ import { execFileSync } from 'node:child_process'
3
+ import { randomUUID } from 'node:crypto'
4
+ import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs'
5
+ import { dirname, join } from 'node:path'
6
+ import { defaultHarness, HARNESSES, harnessById, sessionIdentityEnvVars, type HarnessLivenessRecord, type SharedRuntimeDescriptor, type SharedRuntimeProbe } from './harness.js'
7
+ import { listSessionIds, readConfig, readJsonConfig, readPublicRecordEntry, readRawRecord, runtimeRoot, type PublicRecordEntry, type RawRecord } from './layout.js'
8
+ import { repoRoot } from './git.js'
9
+ import { endpointRecordPath } from './host.js'
10
+ import { detachedRuntimeGenerationToken, parseProcStat, processStartToken, verifyDetachedRuntime, type ProcessIdentity } from './process-identity.js'
11
+ import { readBackendInstanceRecords, type BackendInstanceRecord } from './runtime-ownership.js'
12
+
13
+ type Proc = ProcessIdentity & {
14
+ ppid: number
15
+ ticks: number
16
+ rssKiB: number
17
+ pssKiB: number | null
18
+ cpuPercent: number
19
+ command: string
20
+ env: Record<string, string>
21
+ }
22
+
23
+ export type ResourceReference = {
24
+ sessionId: string | null
25
+ threadId: string | null
26
+ status: string | null
27
+ ownerState: 'governed' | 'unowned' | 'ambiguous'
28
+ referenceState: 'loaded' | 'record-only' | 'queued-no-thread'
29
+ turnPresence: 'active' | 'idle' | 'unknown' | 'none'
30
+ protectsControlPlane: boolean
31
+ liveness?: 'offline' | 'unknown'
32
+ }
33
+
34
+ export type ResourceOwner = {
35
+ kind: 'session' | 'shared-runtime' | 'backend' | 'orphan' | 'unattributed'
36
+ id: string
37
+ label: string
38
+ status?: string
39
+ liveness?: 'offline' | 'unknown'
40
+ proposal?: string | null
41
+ note?: string | null
42
+ worktreePath?: string
43
+ branch?: string | null
44
+ stopped?: boolean
45
+ archived?: boolean
46
+ processes: Array<ProcessIdentity & { command: string; rssMiB: number; pssMiB: number | null; cpuPercent: number }>
47
+ rssMiB: number
48
+ pssMiB: number | null
49
+ cpuPercent: number
50
+ budget: { rssMiB: number | null; idleCpuPercent: number | null }
51
+ controlPlane?: { healthy: boolean; refCount: number | null; error?: string }
52
+ references?: ResourceReference[]
53
+ findings: string[]
54
+ reclaim?: { eligible: boolean; reason: string }
55
+ }
56
+
57
+ export type ResourceReport = {
58
+ version: 1
59
+ measuredAt: string
60
+ projectRoot: string
61
+ platform: string
62
+ available: boolean
63
+ unavailableReason?: string
64
+ host: {
65
+ memoryTotalMiB: number | null
66
+ memoryUsedMiB: number | null
67
+ memoryAvailableMiB: number | null
68
+ swapTotalMiB: number | null
69
+ swapUsedMiB: number | null
70
+ cpuPercent: number | null
71
+ }
72
+ budgets: ResourceBudgets
73
+ owners: ResourceOwner[]
74
+ totals: { rssMiB: number; pssMiB: number | null; cpuPercent: number }
75
+ findings: number
76
+ }
77
+
78
+ export type ResourceBudgets = {
79
+ sessionRssMiB: number
80
+ backendRssMiB: number
81
+ idleCpuPercent: number
82
+ sampleMs: number
83
+ reportIntervalMs: number
84
+ }
85
+
86
+ const DEFAULTS: ResourceBudgets = {
87
+ sessionRssMiB: 1024,
88
+ backendRssMiB: 2048,
89
+ idleCpuPercent: 2,
90
+ sampleMs: 1000,
91
+ reportIntervalMs: 60_000,
92
+ }
93
+
94
+ export class ResourceConflict extends Error {
95
+ constructor(message: string, readonly code = 'resource-conflict') {
96
+ super(message)
97
+ this.name = 'ResourceConflict'
98
+ }
99
+ }
100
+
101
+ const finitePositive = (value: unknown, fallback: number, name: string): number => {
102
+ if (value === undefined) return fallback
103
+ const n = Number(value)
104
+ if (!Number.isFinite(n) || n <= 0) throw new ResourceConflict(`resources.${name} must be a positive number`)
105
+ return n
106
+ }
107
+
108
+ export function resourceBudgets(root = repoRoot()): ResourceBudgets {
109
+ const r = readConfig(root).resources ?? {}
110
+ return {
111
+ sessionRssMiB: finitePositive(r.sessionRssMiB, DEFAULTS.sessionRssMiB, 'sessionRssMiB'),
112
+ backendRssMiB: finitePositive(r.backendRssMiB, DEFAULTS.backendRssMiB, 'backendRssMiB'),
113
+ idleCpuPercent: finitePositive(r.idleCpuPercent, DEFAULTS.idleCpuPercent, 'idleCpuPercent'),
114
+ sampleMs: Math.max(50, Math.floor(finitePositive(r.sampleMs, DEFAULTS.sampleMs, 'sampleMs'))),
115
+ reportIntervalMs: Math.max(5000, Math.floor(finitePositive(r.reportIntervalMs, DEFAULTS.reportIntervalMs, 'reportIntervalMs'))),
116
+ }
117
+ }
118
+
119
+ const readSelectedEnv = (path: string): Record<string, string> => {
120
+ let raw: string
121
+ try { raw = readFileSync(path, 'utf8') } catch { return {} }
122
+ const wanted = new Set([...sessionIdentityEnvVars(), 'SPEXCODE_PROJECT_ROOT', 'SPEXCODE_INSTANCE_ID'])
123
+ const out: Record<string, string> = {}
124
+ for (const entry of raw.split('\0')) {
125
+ const at = entry.indexOf('=')
126
+ if (at > 0 && wanted.has(entry.slice(0, at))) out[entry.slice(0, at)] = entry.slice(at + 1)
127
+ }
128
+ return out
129
+ }
130
+
131
+ const readPss = (path: string): number | null => {
132
+ try {
133
+ const hit = readFileSync(path, 'utf8').match(/^Pss:\s+(\d+)\s+kB$/m)
134
+ return hit ? Number(hit[1]) : null
135
+ } catch { return null }
136
+ }
137
+
138
+ let pageKiBCache: number | null = null
139
+ const pageKiB = () => {
140
+ if (pageKiBCache !== null) return pageKiBCache
141
+ try { pageKiBCache = Number(execFileSync('getconf', ['PAGESIZE'], { encoding: 'utf8' }).trim()) / 1024 }
142
+ catch { pageKiBCache = 4 }
143
+ return pageKiBCache
144
+ }
145
+ const procSnapshot = (procRoot = '/proc', withDetails = true): Map<number, Proc> => {
146
+ const out = new Map<number, Proc>()
147
+ let dirs: string[]
148
+ try { dirs = readdirSync(procRoot).filter((name) => /^\d+$/.test(name)) } catch { return out }
149
+ for (const name of dirs) {
150
+ const pid = Number(name)
151
+ try {
152
+ const stat = parseProcStat(readFileSync(join(procRoot, name, 'stat'), 'utf8'))
153
+ const command = withDetails
154
+ ? readFileSync(join(procRoot, name, 'comm'), 'utf8').trim()
155
+ : ''
156
+ out.set(pid, {
157
+ pid,
158
+ ppid: stat.ppid,
159
+ ticks: stat.ticks,
160
+ startToken: stat.startToken,
161
+ rssKiB: stat.rssPages * pageKiB(),
162
+ pssKiB: withDetails ? readPss(join(procRoot, name, 'smaps_rollup')) : null,
163
+ cpuPercent: 0,
164
+ command,
165
+ env: withDetails ? readSelectedEnv(join(procRoot, name, 'environ')) : {},
166
+ })
167
+ } catch { /* process exited or is unreadable during the snapshot */ }
168
+ }
169
+ return out
170
+ }
171
+
172
+ const totalCpuTicks = (procRoot = '/proc'): number => {
173
+ try {
174
+ const line = readFileSync(join(procRoot, 'stat'), 'utf8').split('\n')[0]
175
+ return line.split(/\s+/).slice(1).reduce((n, value) => n + Number(value || 0), 0)
176
+ } catch { return 0 }
177
+ }
178
+
179
+ const hostCpuSnapshot = (procRoot = '/proc'): { total: number; idle: number } | null => {
180
+ try {
181
+ const fields = readFileSync(join(procRoot, 'stat'), 'utf8').split('\n')[0].split(/\s+/).slice(1).map(Number)
182
+ return { total: fields.reduce((n, value) => n + (value || 0), 0), idle: (fields[3] || 0) + (fields[4] || 0) }
183
+ } catch { return null }
184
+ }
185
+
186
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
187
+
188
+ const descendants = (root: number, procs: Map<number, Proc>): Set<number> => {
189
+ const ids = new Set<number>([root])
190
+ let changed = true
191
+ while (changed) {
192
+ changed = false
193
+ for (const p of procs.values()) if (!ids.has(p.pid) && ids.has(p.ppid)) { ids.add(p.pid); changed = true }
194
+ }
195
+ return ids
196
+ }
197
+
198
+ type PublicRecordInventory = {
199
+ entries: PublicRecordEntry[]
200
+ records: RawRecord[]
201
+ byId: Map<string, PublicRecordEntry>
202
+ }
203
+ const publicRecordInventory = (): PublicRecordInventory => {
204
+ const entries = listSessionIds().map(readPublicRecordEntry)
205
+ const byId = new Map<string, PublicRecordEntry>()
206
+ for (const entry of entries) {
207
+ if (entry.kind === 'ok') byId.set(entry.raw.session_id, entry)
208
+ else if (entry.kind === 'corrupt') byId.set(entry.sessionId, entry)
209
+ }
210
+ return {
211
+ entries,
212
+ records: entries.flatMap((entry) => entry.kind === 'ok' ? [entry.raw] : []),
213
+ byId,
214
+ }
215
+ }
216
+ const rawRecords = (): RawRecord[] => listSessionIds().map(readRawRecord).filter((r): r is RawRecord => !!r)
217
+ const runtimePid = (file: string): number | null => {
218
+ try { const pid = Number(readFileSync(file, 'utf8').trim()); return Number.isFinite(pid) && pid > 0 ? pid : null }
219
+ catch { return null }
220
+ }
221
+ type SharedEntry = { descriptor: SharedRuntimeDescriptor; recs: RawRecord[] }
222
+ const sharedDescriptors = (recs: RawRecord[], retainRegistry = false): Map<string, SharedEntry> => {
223
+ const out = new Map<string, { descriptor: SharedRuntimeDescriptor; recs: RawRecord[] }>()
224
+ for (const harness of HARNESSES) for (const descriptor of harness.sharedRuntimes?.(runtimeRoot()) ?? []) {
225
+ if (!out.has(descriptor.key)) out.set(descriptor.key, { descriptor, recs: [] })
226
+ }
227
+ for (const rec of recs) {
228
+ // Keep archived records in the adapter join so a stale loaded thread is attributed to its real record and
229
+ // reported as an archive hazard. Clean archived records carry stopped:true and no loaded reference, so they
230
+ // contribute nothing to the active set while still preserving exact ownership if the invariant is violated.
231
+ if (!rec.governed) continue
232
+ const harness = harnessById(rec.harness || defaultHarness.id)
233
+ for (const descriptor of harness.sharedRuntimes?.(runtimeRoot()) ?? []) {
234
+ const entry = out.get(descriptor.key) ?? { descriptor, recs: [] }
235
+ entry.recs.push(rec)
236
+ out.set(descriptor.key, entry)
237
+ }
238
+ }
239
+ if (!retainRegistry) for (const [key, entry] of out) if (!entry.recs.length && !runtimePid(entry.descriptor.pidFile)) out.delete(key)
240
+ return out
241
+ }
242
+
243
+ const projectReferences = (entry: SharedEntry, probe: SharedRuntimeProbe, publicById: Map<string, PublicRecordEntry>): ResourceReference[] => {
244
+ const byThread = new Map<string, RawRecord[]>()
245
+ for (const rec of entry.recs) if (rec.harness_session_id) byThread.set(rec.harness_session_id, [...(byThread.get(rec.harness_session_id) ?? []), rec])
246
+ const observed = probe.healthy ? probe.references : []
247
+ const live = observed.map((reference): ResourceReference => {
248
+ const owners = byThread.get(reference.referenceId) ?? []
249
+ if (owners.length !== 1) return {
250
+ sessionId: null,
251
+ threadId: reference.referenceId,
252
+ status: null,
253
+ ownerState: owners.length ? 'ambiguous' : 'unowned',
254
+ referenceState: 'loaded',
255
+ turnPresence: reference.turnPresence,
256
+ protectsControlPlane: true,
257
+ }
258
+ const rec = owners[0]
259
+ const publicEntry = publicById.get(rec.session_id)
260
+ return {
261
+ sessionId: rec.session_id,
262
+ threadId: reference.referenceId,
263
+ status: rec.status,
264
+ ownerState: 'governed',
265
+ referenceState: 'loaded',
266
+ turnPresence: reference.turnPresence,
267
+ protectsControlPlane: true,
268
+ ...(publicEntry?.kind === 'ok' && publicEntry.liveness ? { liveness: publicEntry.liveness } : {}),
269
+ }
270
+ })
271
+ const loaded = new Set(observed.map((reference) => reference.referenceId))
272
+ for (const rec of entry.recs) {
273
+ if (rec.stopped || (rec.harness_session_id && loaded.has(rec.harness_session_id))) continue
274
+ const publicEntry = publicById.get(rec.session_id)
275
+ live.push({
276
+ sessionId: rec.session_id,
277
+ threadId: rec.harness_session_id ?? null,
278
+ status: rec.status,
279
+ ownerState: 'governed',
280
+ referenceState: rec.harness_session_id ? 'record-only' : 'queued-no-thread',
281
+ turnPresence: 'none',
282
+ protectsControlPlane: false,
283
+ ...(publicEntry?.kind === 'ok' && publicEntry.liveness ? { liveness: publicEntry.liveness } : {}),
284
+ })
285
+ }
286
+ return live
287
+ }
288
+
289
+ type Inventory = { procs: Map<number, Proc>; recs: RawRecord[]; publicById: Map<string, PublicRecordEntry>; ownership: Map<number, string>; shared: ReturnType<typeof sharedDescriptors>; backendPid: number | null; backendInstance: string | null; backendInstances: BackendInstanceRecord[] }
290
+
291
+ const buildInventory = (procs: Map<number, Proc>, publicRecords = publicRecordInventory()): Inventory => {
292
+ const recs = publicRecords.records
293
+ const activeRecs = recs.filter((rec) => {
294
+ if (!rec.archived) return true
295
+ // A legacy archived row with a resident leaf is a visible hazard, not clean cold storage. Keep charging
296
+ // that exact process until an explicit archive repair succeeds; truly cold records stay out of the active set.
297
+ const pid = runtimePid(join(runtimeRoot(), 'sessions', rec.session_id, 'agent.pid'))
298
+ return !!pid && procs.has(pid)
299
+ })
300
+ const byId = new Map(activeRecs.map((rec) => [rec.session_id, rec.session_id]))
301
+ for (const entry of publicRecords.entries) if (entry.kind === 'corrupt') byId.set(entry.sessionId, entry.sessionId)
302
+ for (const rec of activeRecs) if (rec.harness_session_id) byId.set(rec.harness_session_id, rec.session_id)
303
+ const ownership = new Map<number, string>()
304
+ const adapterVars = sessionIdentityEnvVars().filter((v) => v !== 'SPEXCODE_SESSION_ID')
305
+ const actingSession = (p: Proc): string | undefined => {
306
+ const acting = adapterVars.map((key) => p.env[key]).find((value) => value && byId.has(value))
307
+ return acting ? byId.get(acting) : undefined
308
+ }
309
+ for (const p of procs.values()) {
310
+ const acting = actingSession(p)
311
+ const fallback = p.env.SPEXCODE_SESSION_ID
312
+ const sid = acting ?? (fallback ? byId.get(fallback) : undefined)
313
+ if (sid) ownership.set(p.pid, `session:${sid}`)
314
+ }
315
+ for (const rec of activeRecs) {
316
+ const root = runtimePid(join(runtimeRoot(), 'sessions', rec.session_id, 'agent.pid'))
317
+ if (root && procs.has(root)) for (const pid of descendants(root, procs)) if (!ownership.has(pid)) ownership.set(pid, `session:${rec.session_id}`)
318
+ }
319
+
320
+ const shared = sharedDescriptors(recs)
321
+ for (const [key, entry] of shared) {
322
+ const root = runtimePid(entry.descriptor.pidFile)
323
+ if (root && procs.has(root)) for (const pid of descendants(root, procs)) {
324
+ const proc = procs.get(pid)!
325
+ // A legacy shared daemon may carry its first launcher's fallback session id. The registered root and
326
+ // descendants without a known acting thread still belong to the control plane; real per-thread tools
327
+ // carry the adapter's thread id and retain their session charge.
328
+ if (pid === root || !actingSession(proc)) ownership.set(pid, `shared:${key}`)
329
+ }
330
+ }
331
+
332
+ // Endpoint records are served-tree slots, while sessions/shared runtimes/instance history use the Git
333
+ // common-dir project slot. Reuse the endpoint seam instead of assuming both identities have one path.
334
+ const backend = readJsonConfig(endpointRecordPath(repoRoot())) as { pid?: number; instanceId?: string }
335
+ const backendPid = Number.isFinite(Number(backend.pid)) ? Number(backend.pid) : null
336
+ const backendInstance = typeof backend.instanceId === 'string' ? backend.instanceId : null
337
+ if (backendPid && procs.has(backendPid)) for (const pid of descendants(backendPid, procs)) {
338
+ if (!ownership.has(pid)) ownership.set(pid, `backend:${backendInstance || backendPid}`)
339
+ }
340
+ const backendInstances = readBackendInstanceRecords(repoRoot())
341
+ for (const instance of backendInstances) {
342
+ if (processStartToken(instance.pid) !== instance.startToken || !procs.has(instance.pid)) continue
343
+ const current = instance.pid === backendPid && instance.instanceId === backendInstance
344
+ const key = current ? `backend:${instance.instanceId}` : `orphan:backend:${instance.instanceId}`
345
+ for (const pid of descendants(instance.pid, procs)) if (!ownership.has(pid)) ownership.set(pid, key)
346
+ }
347
+
348
+ const root = repoRoot()
349
+ for (const p of procs.values()) {
350
+ if (ownership.has(p.pid) || p.env.SPEXCODE_PROJECT_ROOT !== root) continue
351
+ const claimed = p.env.SPEXCODE_SESSION_ID
352
+ if (claimed && !byId.has(claimed)) ownership.set(p.pid, `orphan:session:${claimed}`)
353
+ else if (p.env.SPEXCODE_INSTANCE_ID && p.env.SPEXCODE_INSTANCE_ID !== backendInstance) ownership.set(p.pid, `orphan:backend:${p.env.SPEXCODE_INSTANCE_ID}`)
354
+ else ownership.set(p.pid, 'unattributed:project')
355
+ }
356
+ return { procs, recs, publicById: publicRecords.byId, ownership, shared, backendPid, backendInstance, backendInstances }
357
+ }
358
+
359
+ const mib = (kib: number) => Math.round((kib / 1024) * 10) / 10
360
+ const ownerProcesses = (ids: number[], procs: Map<number, Proc>) => ids.map((pid) => procs.get(pid)!).filter(Boolean).map((p) => ({
361
+ pid: p.pid,
362
+ startToken: p.startToken,
363
+ command: p.command,
364
+ rssMiB: mib(p.rssKiB),
365
+ pssMiB: p.pssKiB === null ? null : mib(p.pssKiB),
366
+ cpuPercent: Math.round(p.cpuPercent * 10) / 10,
367
+ }))
368
+
369
+ const ownerTotals = (processes: ResourceOwner['processes']) => ({
370
+ rssMiB: Math.round(processes.reduce((n, p) => n + p.rssMiB, 0) * 10) / 10,
371
+ pssMiB: processes.every((p) => p.pssMiB !== null) ? Math.round(processes.reduce((n, p) => n + (p.pssMiB ?? 0), 0) * 10) / 10 : null,
372
+ cpuPercent: Math.round(processes.reduce((n, p) => n + p.cpuPercent, 0) * 10) / 10,
373
+ })
374
+
375
+ const terminal = (rec: RawRecord | undefined) => !!rec && rec.status === 'awaiting' && (rec.proposal === 'nothing' || rec.proposal === 'close')
376
+
377
+ const probeRuntime = async (descriptor: SharedRuntimeDescriptor): Promise<SharedRuntimeProbe> => {
378
+ try { return await descriptor.probe() }
379
+ catch (error) { return { healthy: false, references: [], error: (error as Error).message } }
380
+ }
381
+
382
+ const sessionStopBlocker = async (
383
+ id: string,
384
+ harnessId: string | null,
385
+ recs = rawRecords(),
386
+ knownProbes?: Map<string, SharedRuntimeProbe>,
387
+ opts: { coldReceipt?: unknown } = {},
388
+ ): Promise<string | null> => {
389
+ const allowed = harnessId
390
+ ? new Set((harnessById(harnessId).sharedRuntimes?.(runtimeRoot()) ?? []).map((descriptor) => descriptor.key))
391
+ : null
392
+ for (const [key, entry] of sharedDescriptors(recs, true)) {
393
+ if (allowed && !allowed.has(key)) continue
394
+ const descriptor = entry.descriptor
395
+ const pid = runtimePid(descriptor.pidFile)
396
+ const ownerCounts = new Map<string, number>()
397
+ for (const rec of entry.recs) if (rec.harness_session_id) ownerCounts.set(rec.harness_session_id, (ownerCounts.get(rec.harness_session_id) ?? 0) + 1)
398
+ const targetThread = entry.recs.find((rec) => rec.session_id === id)?.harness_session_id
399
+ if (targetThread && ownerCounts.get(targetThread) !== 1)
400
+ return `${descriptor.label} target thread ${targetThread} has no one exact governed session owner`
401
+ if (!knownProbes && descriptor.mutationGuard) {
402
+ if (!targetThread) return `${descriptor.label} target has no exact governed thread identity`
403
+ if (!pid) return `${descriptor.label} target-scoped mutation guard has no readable owner PID`
404
+ const identityBefore = verifyDetachedRuntime(pid, descriptor.receiptFile)
405
+ if (!identityBefore.ok)
406
+ return `${descriptor.label} PID ${pid} has no matching live detached process-boundary record: ${identityBefore.reason}`
407
+ let guard
408
+ try { guard = await descriptor.mutationGuard(targetThread, opts) }
409
+ catch (error) { return `${descriptor.label} target-scoped mutation guard failed: ${(error as Error).message}` }
410
+ const identityAfter = verifyDetachedRuntime(pid, descriptor.receiptFile)
411
+ if (!identityAfter.ok || detachedRuntimeGenerationToken(identityAfter.identity) !== detachedRuntimeGenerationToken(identityBefore.identity))
412
+ return `${descriptor.label} PID/start/detached-receipt identity changed during target-scoped mutation guard${identityAfter.ok ? '' : `: ${identityAfter.reason}`}`
413
+ if (guard.descendantIds.length && guard.coldTeardownAuthorized !== true)
414
+ return `${descriptor.label} target thread ${targetThread} has owned descendants (${guard.descendantIds.join(', ')})`
415
+ if (!guard.healthy)
416
+ return `${descriptor.label} target thread ${targetThread} is unknown: ${guard.error || 'target-scoped mutation guard failed'}`
417
+ if (guard.targetTurnPresence === 'active') return `${descriptor.label} target thread ${targetThread} has an active turn`
418
+ if (guard.targetTurnPresence === 'unknown') return `${descriptor.label} target thread ${targetThread} turn state is unknown`
419
+ continue
420
+ }
421
+ const probe = knownProbes?.get(descriptor.key) ?? await probeRuntime(descriptor)
422
+ const targetRef = probe.healthy && targetThread ? probe.references.find((reference) => reference.referenceId === targetThread) : undefined
423
+ const siblings = probe.healthy ? probe.references.filter((reference) => !targetThread || reference.referenceId !== targetThread) : []
424
+ const liveReason = siblings.length
425
+ ? `${siblings.length} live sibling thread(s)`
426
+ : probe.healthy ? `${probe.references.length} live thread reference(s)` : 'an unproven live reference set'
427
+ if (!pid) return `${descriptor.label} has ${liveReason} but no readable owner PID`
428
+ const identity = verifyDetachedRuntime(pid, descriptor.receiptFile)
429
+ if (!identity.ok) {
430
+ const refs = probe.references.map((reference) => reference.referenceId).join(', ') || 'no loaded threads'
431
+ return `${descriptor.label} PID ${pid} serves ${refs} and has no matching live detached process-boundary record: ${identity.reason}`
432
+ }
433
+ if (!probe.healthy)
434
+ return `${descriptor.label} PID ${pid}@${identity.identity.startToken} has an unproven live reference set: ${probe.error || 'unknown probe failure'}`
435
+ }
436
+ return null
437
+ }
438
+
439
+ const probeShared = async (shared: Map<string, SharedEntry>): Promise<Map<string, SharedRuntimeProbe>> => new Map(await Promise.all(
440
+ [...shared].map(async ([key, entry]) => [key, await probeRuntime(entry.descriptor)] as const),
441
+ ))
442
+
443
+ const sharedFindings = (references: ResourceReference[], probe: SharedRuntimeProbe): string[] => {
444
+ const findings: string[] = []
445
+ if (!probe.healthy) findings.push(`control-plane-probe-failed:${probe.error || 'unknown'}`)
446
+ if (references.some((ref) => ref.ownerState === 'unowned')) findings.push('unowned-loaded-thread')
447
+ if (references.some((ref) => ref.ownerState === 'ambiguous')) findings.push('ambiguous-loaded-thread-owner')
448
+ if (references.some((ref) => ref.turnPresence === 'unknown')) findings.push('turn-presence-unknown')
449
+ if (references.some((ref) => ref.referenceState !== 'loaded')) findings.push('record-without-live-thread')
450
+ return findings
451
+ }
452
+ const atomicJson = (path: string, value: unknown) => {
453
+ mkdirSync(dirname(path), { recursive: true })
454
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`
455
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
456
+ renameSync(tmp, path)
457
+ }
458
+
459
+ const memInfo = (procRoot = '/proc') => {
460
+ const values: Record<string, number> = {}
461
+ try {
462
+ for (const line of readFileSync(join(procRoot, 'meminfo'), 'utf8').split('\n')) {
463
+ const m = line.match(/^(\w+):\s+(\d+)/)
464
+ if (m) values[m[1]] = Number(m[2])
465
+ }
466
+ } catch { /* unavailable below */ }
467
+ const total = values.MemTotal
468
+ const available = values.MemAvailable
469
+ const swapTotal = values.SwapTotal
470
+ const swapFree = values.SwapFree
471
+ return {
472
+ memoryTotalMiB: total ? mib(total) : null,
473
+ memoryUsedMiB: total && available !== undefined ? mib(total - available) : null,
474
+ memoryAvailableMiB: available !== undefined ? mib(available) : null,
475
+ swapTotalMiB: swapTotal !== undefined ? mib(swapTotal) : null,
476
+ swapUsedMiB: swapTotal !== undefined && swapFree !== undefined ? mib(swapTotal - swapFree) : null,
477
+ }
478
+ }
479
+
480
+ export async function collectResourceReport(opts: { procRoot?: string; persist?: boolean } = {}): Promise<ResourceReport> {
481
+ const root = repoRoot()
482
+ const budgets = resourceBudgets(root)
483
+ if (platform() !== 'linux' && !opts.procRoot) {
484
+ const publicRecords = publicRecordInventory()
485
+ const recs = publicRecords.records
486
+ const owners: ResourceOwner[] = []
487
+ const shared = sharedDescriptors(recs)
488
+ const probes = await probeShared(shared)
489
+ for (const [key, entry] of shared) {
490
+ const probe = probes.get(key)!
491
+ const refs = projectReferences(entry, probe, publicRecords.byId)
492
+ owners.push({ kind: 'shared-runtime', id: key, label: entry.descriptor.label, processes: [], rssMiB: 0, pssMiB: null, cpuPercent: 0, budget: { rssMiB: null, idleCpuPercent: null }, controlPlane: { healthy: probe.healthy, refCount: probe.healthy ? probe.references.length : null, ...(probe.error ? { error: probe.error } : {}) }, references: refs, findings: sharedFindings(refs, probe) })
493
+ }
494
+ for (const entry of publicRecords.entries) if (entry.kind === 'corrupt') owners.push({
495
+ kind: 'session', id: entry.sessionId, label: `session ${entry.sessionId.slice(0, 8)}`, status: 'corrupt', liveness: 'unknown',
496
+ processes: [], rssMiB: 0, pssMiB: null, cpuPercent: 0,
497
+ budget: { rssMiB: budgets.sessionRssMiB, idleCpuPercent: budgets.idleCpuPercent },
498
+ findings: [`session-record-corrupt:${entry.error}`], reclaim: { eligible: false, reason: 'session record is corrupt; ownership and lifecycle are unknown' },
499
+ })
500
+ const report: ResourceReport = { version: 1, measuredAt: new Date().toISOString(), projectRoot: root, platform: platform(), available: false, unavailableReason: `host process metrics are unavailable on ${platform()}; shared runtime references remain visible`, host: { memoryTotalMiB: null, memoryUsedMiB: null, memoryAvailableMiB: null, swapTotalMiB: null, swapUsedMiB: null, cpuPercent: null }, budgets, owners, totals: { rssMiB: 0, pssMiB: null, cpuPercent: 0 }, findings: owners.reduce((n, o) => n + o.findings.length, 0) }
501
+ if (opts.persist !== false) atomicJson(join(runtimeRoot(), 'resource-report.json'), report)
502
+ return report
503
+ }
504
+
505
+ const procRoot = opts.procRoot ?? '/proc'
506
+ const first = procSnapshot(procRoot)
507
+ const hostBefore = hostCpuSnapshot(procRoot)
508
+ const cpuBefore = hostBefore?.total ?? totalCpuTicks(procRoot)
509
+ await sleep(budgets.sampleMs)
510
+ const second = procSnapshot(procRoot, false)
511
+ const hostAfter = hostCpuSnapshot(procRoot)
512
+ const cpuAfter = hostAfter?.total ?? totalCpuTicks(procRoot)
513
+ const totalDelta = Math.max(1, cpuAfter - cpuBefore)
514
+ for (const p of first.values()) {
515
+ const next = second.get(p.pid)
516
+ if (next && next.startToken === p.startToken) p.cpuPercent = Math.max(0, (next.ticks - p.ticks) / totalDelta * cpus().length * 100)
517
+ }
518
+
519
+ const inv = buildInventory(first)
520
+ const sharedProbes = await probeShared(inv.shared)
521
+ const grouped = new Map<string, number[]>()
522
+ for (const [pid, owner] of inv.ownership) grouped.set(owner, [...(grouped.get(owner) ?? []), pid])
523
+ const bySession = new Map(inv.recs.map((rec) => [rec.session_id, rec]))
524
+ const owners: ResourceOwner[] = []
525
+
526
+ for (const [owner, ids] of grouped) {
527
+ const processes = ownerProcesses(ids, first)
528
+ const totals = ownerTotals(processes)
529
+ let kind: ResourceOwner['kind'] = 'unattributed'
530
+ let id = owner
531
+ let label = owner
532
+ let status: string | undefined
533
+ let lifecycle: Pick<ResourceOwner, 'liveness' | 'proposal' | 'note' | 'worktreePath' | 'branch' | 'stopped' | 'archived'> | undefined
534
+ let rssBudget: number | null = null
535
+ let idleBudget: number | null = null
536
+ let references: ResourceReference[] | undefined
537
+ let controlPlane: ResourceOwner['controlPlane']
538
+ const findings: string[] = []
539
+ let reclaim: ResourceOwner['reclaim']
540
+
541
+ if (owner.startsWith('session:')) {
542
+ kind = 'session'; id = owner.slice(8); label = `session ${id.slice(0, 8)}`
543
+ const publicEntry = inv.publicById.get(id)
544
+ const rec = bySession.get(id); status = publicEntry?.kind === 'corrupt' ? 'corrupt' : rec?.status; rssBudget = budgets.sessionRssMiB; idleBudget = budgets.idleCpuPercent
545
+ if (rec && publicEntry?.kind === 'ok') lifecycle = {
546
+ ...(publicEntry.liveness ? { liveness: publicEntry.liveness } : {}),
547
+ proposal: rec.proposal, note: rec.note, worktreePath: rec.worktree_path, branch: rec.branch, stopped: rec.stopped, archived: rec.archived,
548
+ }
549
+ else if (publicEntry?.kind === 'corrupt') {
550
+ lifecycle = { liveness: 'unknown' }
551
+ findings.push(`session-record-corrupt:${publicEntry.error}`)
552
+ }
553
+ if (rec?.archived) findings.push('archived-runtime-hazard:leaf-still-resident')
554
+ if (totals.rssMiB > rssBudget) findings.push(`rss-over-budget:${Math.round((totals.rssMiB - rssBudget) * 10) / 10}MiB`)
555
+ if (status !== 'active' && status !== 'queued' && totals.cpuPercent > idleBudget) findings.push(`idle-cpu-over-budget:${Math.round((totals.cpuPercent - idleBudget) * 10) / 10}%`)
556
+ const stopBlocker = rec ? await sessionStopBlocker(id, rec.harness || defaultHarness.id, inv.recs, sharedProbes) : null
557
+ if (terminal(rec) && totals.cpuPercent <= idleBudget && !stopBlocker) {
558
+ reclaim = { eligible: true, reason: `terminal lifecycle ${rec!.status}/${rec!.proposal}; a future exact action must revalidate every fact` }
559
+ } else reclaim = { eligible: false, reason: stopBlocker ? `shared runtime unsafe: ${stopBlocker}` : terminal(rec) ? 'terminal record is still consuming CPU; liveness contradicts safe retirement' : 'owner is not terminal; budget age/status alone never authorizes stop' }
560
+ } else if (owner.startsWith('shared:')) {
561
+ kind = 'shared-runtime'; id = owner.slice(7)
562
+ const shared = inv.shared.get(id)!; label = shared.descriptor.label; rssBudget = budgets.backendRssMiB; idleBudget = budgets.idleCpuPercent
563
+ const probe = sharedProbes.get(id)!
564
+ references = projectReferences(shared, probe, inv.publicById)
565
+ if (references.some((reference) => reference.sessionId && bySession.get(reference.sessionId)?.archived)) findings.push('archived-runtime-hazard:loaded-thread')
566
+ controlPlane = { healthy: probe.healthy, refCount: probe.healthy ? probe.references.length : null, ...(probe.error ? { error: probe.error } : {}) }
567
+ if (totals.rssMiB > rssBudget) findings.push(`rss-over-budget:${Math.round((totals.rssMiB - rssBudget) * 10) / 10}MiB`)
568
+ const protectedReferences = references.filter((reference) => reference.protectsControlPlane)
569
+ const turnPresenceKnown = !references.some((reference) => reference.turnPresence === 'unknown')
570
+ if (probe.healthy && turnPresenceKnown && !references.some((reference) => reference.turnPresence === 'active') && totals.cpuPercent > idleBudget) findings.push(`idle-cpu-over-budget:${Math.round((totals.cpuPercent - idleBudget) * 10) / 10}%`)
571
+ if (probe.healthy && protectedReferences.length === 0) findings.push('orphan-shared-runtime:no-live-references')
572
+ findings.push(...sharedFindings(references, probe))
573
+ if (ids.some((pid) => sessionIdentityEnvVars().some((key) => !!first.get(pid)?.env[key]))) findings.push('identity-leak:project-control-plane-carries-session-id')
574
+ reclaim = { eligible: false, reason: !probe.healthy ? 'live reference count unknown; adapter/project teardown only' : protectedReferences.length ? `protected by ${protectedReferences.length} loaded/active thread reference(s)` : 'shared runtime teardown belongs to its adapter/project owner, never session stop' }
575
+ } else if (owner.startsWith('backend:')) {
576
+ kind = 'backend'; id = owner.slice(8); label = `backend ${id}`; rssBudget = budgets.backendRssMiB; idleBudget = budgets.idleCpuPercent
577
+ if (totals.rssMiB > rssBudget) findings.push(`rss-over-budget:${Math.round((totals.rssMiB - rssBudget) * 10) / 10}MiB`)
578
+ if (totals.cpuPercent > idleBudget) findings.push(`idle-cpu-over-budget:${Math.round((totals.cpuPercent - idleBudget) * 10) / 10}%`)
579
+ if (ids.some((pid) => sessionIdentityEnvVars().some((key) => !!first.get(pid)?.env[key]))) findings.push('identity-leak:project-control-plane-carries-session-id')
580
+ reclaim = { eligible: false, reason: 'current endpoint owner; backend teardown is not a session stop' }
581
+ } else if (owner.startsWith('orphan:')) {
582
+ kind = 'orphan'; id = owner.slice(7); label = owner; findings.push('orphan:owner-record-absent')
583
+ if (id.startsWith('session:')) {
584
+ const exactOwner = id.slice(8)
585
+ id = exactOwner
586
+ label = `orphan session ${exactOwner}`
587
+ reclaim = { eligible: true, reason: 'process carries this project and session identity, but the owner record is absent' }
588
+ } else if (id.startsWith('backend:')) {
589
+ const exactOwner = id.slice(8)
590
+ id = exactOwner
591
+ label = `orphan backend ${exactOwner}`
592
+ rssBudget = budgets.backendRssMiB
593
+ idleBudget = budgets.idleCpuPercent
594
+ if (totals.rssMiB > rssBudget) findings.push(`rss-over-budget:${Math.round((totals.rssMiB - rssBudget) * 10) / 10}MiB`)
595
+ if (totals.cpuPercent > idleBudget) findings.push(`idle-cpu-over-budget:${Math.round((totals.cpuPercent - idleBudget) * 10) / 10}%`)
596
+ reclaim = { eligible: false, reason: 'superseded backend generation remains owned by backend supervisor teardown, never session stop' }
597
+ } else reclaim = { eligible: false, reason: 'runtime teardown belongs to its registered owner' }
598
+ } else {
599
+ findings.push('unattributed:project-process-without-owner')
600
+ reclaim = { eligible: false, reason: 'ownership is insufficient; reporting stays read-only' }
601
+ }
602
+ owners.push({ kind, id, label, ...(status ? { status } : {}), ...(lifecycle ?? {}), processes, ...totals, budget: { rssMiB: rssBudget, idleCpuPercent: idleBudget }, ...(controlPlane ? { controlPlane } : {}), ...(references ? { references } : {}), findings, ...(reclaim ? { reclaim } : {}) })
603
+ }
604
+
605
+ // A corrupt record stays a public owner even without a currently attributable process. Its absence from a
606
+ // process-derived group cannot turn unknown lifecycle/ownership into disappearance.
607
+ for (const entry of inv.publicById.values()) if (entry.kind === 'corrupt'
608
+ && !owners.some((owner) => owner.kind === 'session' && owner.id === entry.sessionId)) owners.push({
609
+ kind: 'session', id: entry.sessionId, label: `session ${entry.sessionId.slice(0, 8)}`, status: 'corrupt', liveness: 'unknown',
610
+ processes: [], rssMiB: 0, pssMiB: null, cpuPercent: 0,
611
+ budget: { rssMiB: budgets.sessionRssMiB, idleCpuPercent: budgets.idleCpuPercent },
612
+ findings: [`session-record-corrupt:${entry.error}`], reclaim: { eligible: false, reason: 'session record is corrupt; ownership and lifecycle are unknown' },
613
+ })
614
+
615
+ // A referenced shared runtime with no readable process is still operationally important: keep its live or
616
+ // unknown refcount visible instead of silently omitting it from a process-derived report.
617
+ for (const [key, shared] of inv.shared) if (!owners.some((o) => o.kind === 'shared-runtime' && o.id === key)) {
618
+ const probe = sharedProbes.get(key)!
619
+ const references = projectReferences(shared, probe, inv.publicById)
620
+ const protectedReferences = references.filter((reference) => reference.protectsControlPlane)
621
+ owners.push({ kind: 'shared-runtime', id: key, label: shared.descriptor.label, processes: [], rssMiB: 0, pssMiB: null, cpuPercent: 0, budget: { rssMiB: budgets.backendRssMiB, idleCpuPercent: null }, controlPlane: { healthy: probe.healthy, refCount: probe.healthy ? probe.references.length : null, ...(probe.error ? { error: probe.error } : {}) }, references, findings: ['runtime-process-unavailable', ...sharedFindings(references, probe)], reclaim: { eligible: false, reason: !probe.healthy ? 'live reference count unknown; adapter/project teardown only' : protectedReferences.length ? `protected by ${protectedReferences.length} loaded/active thread reference(s)` : 'adapter/project teardown only' } })
622
+ }
623
+
624
+ owners.sort((a, b) => b.rssMiB - a.rssMiB || a.id.localeCompare(b.id))
625
+ const rssMiB = Math.round(owners.reduce((n, o) => n + o.rssMiB, 0) * 10) / 10
626
+ const pssMiB = owners.every((o) => o.pssMiB !== null) ? Math.round(owners.reduce((n, o) => n + (o.pssMiB ?? 0), 0) * 10) / 10 : null
627
+ const cpuPercent = Math.round(owners.reduce((n, o) => n + o.cpuPercent, 0) * 10) / 10
628
+ const report: ResourceReport = {
629
+ version: 1,
630
+ measuredAt: new Date().toISOString(),
631
+ projectRoot: root,
632
+ platform: platform(),
633
+ available: true,
634
+ host: {
635
+ ...memInfo(procRoot),
636
+ cpuPercent: hostBefore && hostAfter && hostAfter.total > hostBefore.total
637
+ ? Math.round((1 - (hostAfter.idle - hostBefore.idle) / (hostAfter.total - hostBefore.total)) * 1000) / 10
638
+ : null,
639
+ },
640
+ budgets,
641
+ owners,
642
+ totals: { rssMiB, pssMiB, cpuPercent },
643
+ findings: owners.reduce((n, owner) => n + owner.findings.length, 0),
644
+ }
645
+ if (opts.persist !== false) atomicJson(join(runtimeRoot(), 'resource-report.json'), report)
646
+ return report
647
+ }
648
+
649
+ export async function assertSessionStopSafe(
650
+ id: string,
651
+ rec: (HarnessLivenessRecord & { harness?: string }) | null,
652
+ opts: { coldReceipt?: unknown } = {},
653
+ ): Promise<void> {
654
+ if (!rec) throw new ResourceConflict(`refusing to stop ${id}: no readable session record proves the adapter or leaf owner`)
655
+ const blocker = await sessionStopBlocker(id, rec.harness || null, rawRecords(), undefined, opts)
656
+ if (blocker) throw new ResourceConflict(`refusing to stop ${id}: ${blocker}`)
657
+ }
658
+
659
+ export function formatResourceReport(report: ResourceReport): string {
660
+ const host = report.available
661
+ ? `host memory ${report.host.memoryUsedMiB}/${report.host.memoryTotalMiB} MiB, swap ${report.host.swapUsedMiB}/${report.host.swapTotalMiB} MiB`
662
+ : `host metrics unavailable: ${report.unavailableReason}`
663
+ const lines = [`resources @ ${report.measuredAt}`, ` ${host}`, ` attributed PSS ${report.totals.pssMiB ?? 'unavailable'} MiB (RSS ${report.totals.rssMiB} MiB), findings ${report.findings}`]
664
+ for (const owner of report.owners) {
665
+ const lifecycle = owner.status ? ` state=${owner.status}/${owner.proposal ?? '-'} branch=${owner.branch ?? '-'}` : ''
666
+ const refs = owner.references
667
+ ? ` refs=${owner.controlPlane?.refCount ?? '?'}/${owner.references.length} active=${owner.references.filter((r) => r.turnPresence === 'active').length} unknown=${owner.references.filter((r) => r.turnPresence === 'unknown').length}`
668
+ : ''
669
+ lines.push(` ${owner.kind.padEnd(14)} ${owner.id} rss=${owner.rssMiB}MiB pss=${owner.pssMiB ?? '?'}MiB cpu=${owner.cpuPercent}%${lifecycle}${refs}${owner.findings.length ? ` ! ${owner.findings.join(',')}` : ''}`)
670
+ }
671
+ return lines.join('\n')
672
+ }
673
+
674
+ let monitorStarted = false
675
+ export function startResourceMonitor(): void {
676
+ if (monitorStarted) return
677
+ monitorStarted = true
678
+ const interval = resourceBudgets().reportIntervalMs
679
+ let last = new Set<string>()
680
+ let running = false
681
+ const sample = async () => {
682
+ if (running) return
683
+ running = true
684
+ try {
685
+ const report = await collectResourceReport()
686
+ const stableFinding = (finding: string) => finding.startsWith('rss-over-budget:') ? 'rss-over-budget'
687
+ : finding.startsWith('idle-cpu-over-budget:') ? 'idle-cpu-over-budget'
688
+ : finding
689
+ const next = new Set(report.owners.flatMap((owner) => owner.findings.map((finding) => `${owner.kind}:${owner.id}:${stableFinding(finding)}`)))
690
+ for (const finding of next) if (!last.has(finding)) console.warn(`[resources] entered ${finding}`)
691
+ for (const finding of last) if (!next.has(finding)) console.warn(`[resources] cleared ${finding}`)
692
+ last = next
693
+ } catch (e) { console.error(`[resources] sample failed: ${(e as Error).message}`) }
694
+ finally { running = false }
695
+ }
696
+ setTimeout(() => void sample(), 5000).unref()
697
+ setInterval(() => void sample(), interval).unref()
698
+ }