pwn 0.5.782 → 0.5.783

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15a843d292cc4872961ee750110b60f259e14c1f50185522ee9a219f8dabf75d
4
- data.tar.gz: 00c12b64346f6e35d8f6d44b638c1e9e0edb856390db76834fb72a65471d0ac7
3
+ metadata.gz: 43f675e7e241c2f9c2ac2b96c52b43259b337871bc3227fb2cbac832457f7f23
4
+ data.tar.gz: c053c15cff377d88ee43b25103203928eaa037c3bca976fd6d6483d5543ad5fd
5
5
  SHA512:
6
- metadata.gz: cb8ac9969959a5403b761a906497337852619dcbeef1c9f5a12956eb062963409426feaba0a188c3d10a278a422364c602fb30947ea28ef63b8f49d477e49b63
7
- data.tar.gz: 0c79e7ce4c8f77974f25574bf309ce72e5b46ad40e562de334d653765b66f130959a65a157d567242f1756ed493a5e7c8d6febf435579ef1243ea969d8a57f9e
6
+ metadata.gz: eca4d79da6a33b24ec42cbc39c7c07afd0cfec053f98a49655b2bd6ef3c93bbba32f91aa90e755fe88eb8ec7bb13db892f87ade9e6b715b2d14f0b867415e576
7
+ data.tar.gz: 8c8dad0dd07d97a0b4941fadf03683dd14cfe7fe045c06431d7f725c39ea4dadaac76988a722efaa9186abe71fa4ba731b95c8ae0fa58b1efd0d7baa367f45b1
@@ -221,12 +221,18 @@ incomplete metadata is unknown, never named. `/sessions list` and cleanup share
221
221
  `title_source`, `is_summarized`, `classification` and `title_reason`. Classification
222
222
  does not establish deletion safety: malformed records, symlinks and uncertain pin
223
223
  stores remain protected. Age alone does not preserve an unsummarized session.
224
- Version 5 backs up unreferenced unsummarized sessions before a guarded `Sessions.delete`
225
- call that bypasses the ordinary age pin. Current sessions and every known
226
- learning/mistake/memory reference remain protected. `/sessions list` exposes
227
- `dependencies` as an array of files **referencing this session** (empty when
228
- none), not files this session depends on. `dependency_errors` lists unknown,
229
- malformed or unsafe stores; cleanup fails closed when it is nonempty.
224
+ Version 7 backs up unreferenced unsummarized sessions before a guarded `Sessions.delete`
225
+ call that bypasses the ordinary age pin. `/sessions list` shows only valid
226
+ LLM-summary-title or valid dependency-backed sessions, excluding sessions with
227
+ attributed dependency errors. Active leases protect bytes even when a session
228
+ is hidden. Explicit resume and `Sessions.list(include_hidden: true)` still reach
229
+ hidden records. `dependencies` lists files **referencing this session**, not
230
+ files it depends on. Per-session `dependency_errors` contains only safely
231
+ attributed errors; `unresolved_dependency_stores` reports global uncertainty.
232
+ The index's `errors` remains the unattributed store list: it blocks apply and is
233
+ reported as `unresolved_store_remediation`, never copied onto every session.
234
+ Parsed rows are validated independently so an invalid shared row does not
235
+ invalidate unrelated good records.
230
236
  The shared index follows defined structural fields, not ID-looking text.
231
237
  Dependency-backed sessions remain visible and resumable, not archived away.
232
238
  Vault/config/credential payloads and historical backups are not scanned.
@@ -242,9 +248,27 @@ are rechecked under per-ID/transcript locks. `ledger.json` records exact IDs,
242
248
  hashes, skips and completed deletions; dry-run JSON contains metadata only and
243
249
  creates no archive. Backups are for recovery, not an automatic reference
244
250
  resolver: referenced placeholders remain in the active list until a separately
245
- verified archive-aware lookup policy exists. No memory, learning or mistake
246
- records are rewritten. The process checks and advisory locks do not claim a
247
- transactional guarantee against arbitrary non-cooperating external writers.
251
+ verified archive-aware lookup policy exists. Invalid learning evidence rows with
252
+ one proven session owner can be removed with their inactive session under the
253
+ Learning writer lock. The entire original shared store is backed up; only the
254
+ invalid row is removed, keeping unrelated rows byte-for-byte. Prepared/committed
255
+ `dependency_removals` ledger entries record hashes, row locators and backup paths.
256
+ Approval and locked rechecks bind both transcript and dependency fingerprints.
257
+ Valid references still protect a session; conflicting valid/invalid references,
258
+ multiple owners, and attributed stores without a cooperating permanent writer
259
+ lock report `unresolved_dependency_remediation` and remain physically untouched.
260
+ Memory, its archive, and Mistakes now use permanent reentrant store transactions
261
+ covering complete read-modify-write operations, including Learning consolidation
262
+ and noise purging. Single-owner invalid map entries can be removed under those
263
+ locks with whole-store backups and prepared/committed ledgers; unrelated map
264
+ entries retain their values (JSON formatting may change). Stale expiry snapshots
265
+ cannot remove refreshed memory. Corrupt Mistakes input raises rather than
266
+ falling back to an empty map. Other stores (including disputed learning, findings,
267
+ policy trajectories and event logs) remain unresolved when no safe targeted writer
268
+ protocol exists. Deploy all cooperating writers before enabling remediation; old
269
+ installed writers do not honor the new locks. No shared store is wiped. The process
270
+ checks and advisory locks do not claim a transactional guarantee against
271
+ arbitrary non-cooperating external writers.
248
272
 
249
273
  The cleanup-specific pins additionally preserve every session ID referenced in
250
274
  retained learning, mistake and durable memory stores, including non-gold learning
@@ -45,9 +45,37 @@ Aliases shared by multiple sessions are ambiguous too; use a stable ID.
45
45
  `dependencies` means **referenced by**: files containing defined structural
46
46
  references to that session. Transcript prose, prompts, tool results and quoted
47
47
  JSON—including previous `/sessions list` output—are not dependencies. An empty
48
- array means no known structural references; `dependency_errors` separately
49
- reports uncertainty and blocks scheduled cleanup. Dependency-backed sessions
50
- stay visible and resumable. See Installation's session-cleanup schema table.
48
+ array means no known structural references. `dependency_errors` contains only
49
+ errors attributed to this session; `unresolved_dependency_stores` reports global
50
+ uncertainty without assigning blame. Lists always include the current active
51
+ session, even while its title is provisional or summary generation failed.
52
+ Other rows require valid summary provenance or valid structural dependencies.
53
+ This display exception does not confer summary provenance or bypass cleanup:
54
+ activity leases still prevent physical deletion. Explicit resume remains available.
55
+ `/sessions list [offset] [limit]` defaults to the newest page, ordered oldest-first
56
+ within that page. An explicit offset starts at that oldest-first position.
57
+ The current session is marked `active: true` and included once even outside the
58
+ requested page (so at most limit + 1 rows). `total` counts unique visible sessions;
59
+ `offset` and `has_more` describe the base page, not the extra active row.
60
+ Global unresolved stores and specific diagnostics are reported once in the envelope.
61
+ Title workers capture the active provider/model when the request starts, summarize
62
+ only that session's persisted conversation, and retry on resume. Pending work is
63
+ coalesced per session rather than dropping another session's update. Failed or
64
+ invalid generation keeps the previous title and cursor, and reports a warning
65
+ without exposing provider payloads. It never fabricates `title_source: summary`.
66
+ `/sessions cleanup` applies the bundled cron maintenance operation; use
67
+ `/sessions cleanup --dry-run` for a read-only preview. Migration preserves full
68
+ redacted learning snapshots and their offline readers; it does not manufacture
69
+ lossless migrations for other stores. Unknown global ownership blocks apply.
70
+ Prepared/committed maintenance ledgers and recoverable backups live outside
71
+ ordinary backup pruning. Old writers must be quiesced before live deployment;
72
+ updating a helper alone does not update a running process.
73
+ Request checkpoints use a version-1, hash-bound identity adapter: their session
74
+ field is provenance because restore consumes the checkpoint itself. Unknown
75
+ versions, fields and mismatched paths remain unresolved; checkpoint bytes are
76
+ never rewritten by cleanup. Other unmigrated evidence edges remain retained.
77
+ `Sessions.list(include_hidden: true)` is the diagnostic inventory. See
78
+ Installation's session-cleanup schema table and remediation limitations.
51
79
  `title_source`, `is_summarized`, `classification` and `title_reason` expose the
52
80
  same strict provenance used by cleanup. Only persisted `summary` provenance with
53
81
  a nonblank summary/title and positive integer summary cursor counts as named.
@@ -39,8 +39,68 @@ The curses console exposes these local views through the command menu:
39
39
  Running phase checkpoints are atomically persisted before publication, including
40
40
  actual check records, uncovered requirements and repair reasons.
41
41
  Worktrees isolate edits, **not** arbitrary code execution or network access.
42
- * `/sessions cleanup` previews the same provenance/dependency policy as scheduled
43
- cleanup. Inspect the exact IDs, paths, fingerprints, retention reasons and
42
+ * `/sessions list [offset] [limit]` is a page, not a retention limit. The
43
+ envelope reports `returned`, visible `total`, `physical_sessions`,
44
+ `filtered_sessions`, `has_previous` and `has_more`, plus a disjoint root-entry
45
+ `storage` breakdown. Filtered transcripts remain physically retained and explicitly
46
+ resumable. Neither pagination nor layout migration deletes summarized sessions.
47
+ * Offline housekeeping migration is separate from ordinary cleanup. After deploying
48
+ matching source and stopping **all** launchers, workers and cron schedulers, run
49
+ from the checkout:
50
+
51
+ ```ruby
52
+ require 'pwn'
53
+ require './etc/default_cron/session_cleanup'
54
+ PWN::SessionCleanup.reorganize # metadata-only plan; no changes
55
+ PWN::SessionCleanup.reorganize(dry_run: false, writers_stopped: true)
56
+ PWN::SessionCleanup.run(dry_run: true) # inspect remaining retention categories
57
+ # Only after reviewing the ledger and approving cleanup:
58
+ PWN::SessionCleanup.run(dry_run: false)
59
+ ```
60
+
61
+ Launch Ruby with `bundle exec ruby -Ilib` so this uses matching source, not an
62
+ older installed gem. `writers_stopped` is an operator assertion, not a way to
63
+ bypass held leases or unknown legacy owners. Backups and prepared/committed
64
+ ledgers live in private `~/.pwn/session_cleanup_archive` directories. The layout
65
+ migration moves unreferenced metadata and lease inodes under `sessions/support`;
66
+ new trace support also uses that layout. Existing trace paths and dependency-backed
67
+ metadata stay at their exact locations to preserve evidence anchors and are
68
+ reported in `retained_support`. Canonical transcripts stay at the root. Abandoned numeric-PID transcript temp
69
+ files are removed only after exact recoverable backups in this offline operation.
70
+ Held own-process capability markers stay in place; other active owners block.
71
+ Readers accept legacy and compact paths, including partially completed moves.
72
+ Do not restart old binaries after applying the layout. Unknown files, mesh
73
+ mappings and request checkpoints remain untouched. Complete policy trajectories
74
+ are self-contained training provenance, not transcript-retention edges; incomplete
75
+ legacy trajectories and other unresolved evidence consumers still retain sources.
76
+ * Historical IDs are not automatically transcript dependencies. The shared index
77
+ validates self-contained Mistakes records (error/fix/counters), Memory reflection
78
+ values, Extrospection drift observations and Curriculum practice results before
79
+ treating their session IDs as provenance. These stores are not rewritten or
80
+ deleted by this classification. Incomplete records still pin their identified
81
+ source; malformed containers remain diagnostics. No summary or training label
82
+ is invented. Mistakes' previous-turn readers and Memory's session-turn reader
83
+ operate on the caller's current session, not historical occurrence IDs.
84
+ Curriculum's practice log is an audit log, not a replay/evaluation queue.
85
+ * Actual Learning transcript consumers retain full redacted, content-addressed
86
+ evidence through the existing migration before transcript deletion. Sessions.load,
87
+ Curriculum.offline_judge discovery and Reward PRM copy-on-write annotation use
88
+ the verified evidence; source hashes and complete source equality are checked
89
+ before releasing a live transcript. Overlapping provenance records no longer
90
+ prevent that preservation/re-index/deletion transaction.
91
+ * Swarm `personas.json` is different: `Swarm.ask` calls `persona_session`, which
92
+ reuses its ID and acquires a writable-session lease for `Loop.run`. `retire`
93
+ removes an agents.yml registration, not that association; an explicit persona
94
+ or later respawn can reuse it. There is no persisted association-completed or
95
+ retired-session marker. Neither old mtime, an absent registry name nor a
96
+ completed bus message proves a map obsolete. Such mappings remain protected,
97
+ even with a Learning snapshot, because a read-only training artifact is not a
98
+ writable continuation. Cleanup does not silently archive them or impose a cap.
99
+ * `/sessions cleanup` runs the same maintenance operation as bundled cron:
100
+ validate, preserve supported learning evidence, remediate attributable invalid
101
+ records with recoverable backups, re-index, and remove eligible inactive sessions.
102
+ `/sessions cleanup --dry-run` previews without changing evidence or deleting
103
+ sessions. Inspect the exact IDs, paths, fingerprints, retention reasons and
44
104
  uncertainty. `a`, then Enter confirms only the displayed candidate set.
45
105
  Newly discovered or changed records are excluded. Apply rechecks activity,
46
106
  references and source bytes under locks, backs up candidate bytes, and reports
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # PWN managed default: session_cleanup v6
3
+ # PWN managed default: session_cleanup v9
4
4
  # No provider calls or broad store GC. Unsummarized candidates are backed up first.
5
5
  require 'pwn'
6
6
  require 'json'
@@ -12,7 +12,7 @@ require 'securerandom'
12
12
  module PWN
13
13
  # Provenance-based session cleanup, shared by seeded cron payloads.
14
14
  module SessionCleanup
15
- VERSION = 6
15
+ VERSION = 9
16
16
 
17
17
  def self.classify(path, id)
18
18
  return :unknown unless path == File.join(PWN::Sessions.sessions_dir, "#{id}.jsonl")
@@ -100,7 +100,7 @@ module PWN
100
100
 
101
101
  # Fingerprints include inode identity and nanosecond timestamps, not bodies.
102
102
  def self.snapshot(path)
103
- [path, path.sub(/\.jsonl\z/, '.meta.json')].to_h do |file|
103
+ [path, PWN::Sessions.support_path(kind: :metadata, session_id: File.basename(path, '.jsonl'))].to_h do |file|
104
104
  raise 'Refusing symlink' if File.symlink?(file)
105
105
  next [File.basename(file), nil] unless File.exist?(file)
106
106
 
@@ -138,7 +138,7 @@ module PWN
138
138
  expected.each do |name, record|
139
139
  next unless record
140
140
 
141
- source = File.join(File.dirname(path), name)
141
+ source = name.end_with?('.meta.json') ? PWN::Sessions.support_path(kind: :metadata, session_id: File.basename(name, '.meta.json')) : File.join(File.dirname(path), name)
142
142
  bytes = File.binread(source)
143
143
  raise 'Changed before backup' unless Digest::SHA256.hexdigest(bytes) == record[:sha256]
144
144
 
@@ -149,7 +149,278 @@ module PWN
149
149
  publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result))
150
150
  end
151
151
 
152
+ # Only stores with a cooperating permanent writer lock are auto-remediated.
153
+ # Other attributed errors remain explicit operator work, never broad GC.
154
+ def self.invalid_dependencies(id, references = nil)
155
+ (references || PWN::Sessions.dependency_index)[:invalid_records].select { |row| row[:owners].include?(id) }
156
+ end
157
+
158
+ def self.remediable?(id, references = nil)
159
+ rows = invalid_dependencies(id, references)
160
+ !rows.empty? && rows.all? do |row|
161
+ relative = row[:path].delete_prefix("#{File.join(Dir.home, '.pwn')}/")
162
+ supported = (relative == 'learning.jsonl' && row[:locator].is_a?(Integer)) ||
163
+ (%w[memory.json memory.archive.json mistakes.json].include?(relative) && row[:locator].is_a?(String))
164
+ row[:owners] == [id] && supported
165
+ end
166
+ end
167
+
168
+ def self.cleanup_state(path, id, references = nil)
169
+ state = classify(path, id)
170
+ return state if state == :unknown
171
+
172
+ status = PWN::Sessions.retention_status(session_id: id, references: references)
173
+ return :invalid_dependency unless status[:dependency_errors].empty?
174
+
175
+ state
176
+ end
177
+
178
+ def self.with_dependency_lock(id)
179
+ rows = invalid_dependencies(id)
180
+ raise 'Unresolved dependency remediation' unless rows.empty? || remediable?(id)
181
+
182
+ # Lock even absent stores: a concurrent writer can introduce the first
183
+ # reference between the final index and unlink. These inodes are permanent.
184
+ root = File.join(Dir.home, '.pwn')
185
+ paths = %w[.learning-write.lock memory.json.write.lock mistakes.json.write.lock].map { |name| File.join(root, name) }.sort
186
+ locks = []
187
+ begin
188
+ paths.each do |path|
189
+ lock = File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen -- closed together in ensure
190
+ locks << lock
191
+ raise 'Dependency writer active' unless lock.flock(File::LOCK_EX | File::LOCK_NB)
192
+ end
193
+ yield
194
+ ensure
195
+ locks.reverse_each(&:close)
196
+ end
197
+ end
198
+
199
+ def self.remove_invalid_dependencies(id, result)
200
+ rows = invalid_dependencies(id)
201
+ return if rows.empty?
202
+
203
+ raise 'Dependency remediation changed' unless remediable?(id)
204
+
205
+ rows.group_by { |row| row[:path] }.each do |path, entries|
206
+ bytes = File.binread(path)
207
+ hash = Digest::SHA256.hexdigest(bytes)
208
+ raise 'Dependency changed' unless entries.all? { |row| row[:sha256] == hash }
209
+
210
+ backup_path = File.join(result[:archive_dir], "dependency-#{SecureRandom.hex(8)}.jsonl")
211
+ publish(backup_path, bytes)
212
+ raise 'Dependency backup failed' unless Digest::SHA256.file(backup_path).hexdigest == hash
213
+
214
+ locators = entries.map { |row| row[:locator] }
215
+ updated = if path.end_with?('.jsonl')
216
+ bytes.lines.each_with_index.reject { |_, index| locators.include?(index) }.map(&:first).join
217
+ else
218
+ map = JSON.parse(bytes)
219
+ raise 'Dependency map changed' unless map.is_a?(Hash) && locators.all? { |key| map.key?(key) }
220
+
221
+ locators.each { |key| map.delete(key) }
222
+ JSON.pretty_generate(map)
223
+ end
224
+ removal = { session_id: id, path: path, backup: backup_path, sha256: hash,
225
+ updated_sha256: Digest::SHA256.hexdigest(updated), locators: locators, state: 'prepared' }
226
+ (result[:dependency_removals] ||= []) << removal
227
+ publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result))
228
+ raise 'Dependency changed after backup' unless File.binread(path) == bytes
229
+
230
+ publish(path, updated)
231
+ # Compare exact bytes even when JSON emits non-ASCII UTF-8 text.
232
+ raise 'Dependency write verification failed' unless File.binread(path) == updated.b
233
+
234
+ removal[:state] = 'committed'
235
+ publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result))
236
+ end
237
+ end
238
+
239
+ # The command and bundled cron use this exact maintenance transaction.
240
+ # Legacy writers block all mutations, including evidence migration.
241
+ # One permanent inode serializes CLI, curses and cron maintenance writers.
242
+ # Read-only previews never acquire a writer lock or create lock files.
243
+ def self.with_maintenance_lock(dry_run:)
244
+ return yield if dry_run
245
+
246
+ root = PWN::Sessions.sessions_dir
247
+ raise IOError, 'Unsafe session maintenance directory' unless File.realpath(root) == File.expand_path(root)
248
+
249
+ path = File.join(root, '.maintenance.lock')
250
+ owner = Thread.current[:pwn_session_maintenance_lock]
251
+ return yield if owner && owner[:pid] == Process.pid && owner[:thread] == Thread.current && owner[:path] == path && !owner[:file].closed?
252
+
253
+ File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |file|
254
+ raise IOError, 'Session maintenance already running; wait for its result before retrying' unless file.flock(File::LOCK_EX | File::LOCK_NB)
255
+
256
+ begin
257
+ Thread.current[:pwn_session_maintenance_lock] = { pid: Process.pid, thread: Thread.current, path: path, file: file }
258
+ yield
259
+ ensure
260
+ Thread.current[:pwn_session_maintenance_lock] = owner
261
+ end
262
+ end
263
+ end
264
+
265
+ def self.maintain(dry_run: false, current_session_id: nil, proc_root: '/proc')
266
+ with_maintenance_lock(dry_run: dry_run) do
267
+ maintenance_transaction(dry_run: dry_run, current_session_id: current_session_id, proc_root: proc_root)
268
+ end
269
+ end
270
+
271
+ def self.maintenance_transaction(dry_run:, current_session_id:, proc_root:)
272
+ preflight = sweep(dry_run: true, current_session_id: current_session_id, proc_root: proc_root)
273
+ blocked = !preflight[:legacy_owners].empty? || Array(preflight[:unresolved_store_remediation]).any?
274
+ unless dry_run || blocked
275
+ # Private metadata-only preflight survives an interrupted migration.
276
+ backup(File.join(PWN::Sessions.sessions_dir, 'inventory.jsonl'), {}, preflight)
277
+ ledger = File.join(preflight[:archive_dir], 'maintenance.json')
278
+ publish(ledger, JSON.pretty_generate(state: 'prepared', inventory: preflight))
279
+ blocked = !legacy_owners(proc_root).empty?
280
+ end
281
+ migration = PWN::AI::Agent::Learning.migrate_evidence(dry_run: dry_run || blocked)
282
+ result = if dry_run || blocked
283
+ preflight
284
+ else
285
+ sweep(dry_run: false, current_session_id: current_session_id, proc_root: proc_root)
286
+ end
287
+ result[:dry_run] = dry_run
288
+ result[:apply_blocked] = true if blocked
289
+ result[:evidence_migration] = migration
290
+ result[:remaining_sessions] = Dir.glob(File.join(PWN::Sessions.sessions_dir, '*.jsonl')).length
291
+ result[:visible_sessions] = PWN::Sessions.list.length
292
+ if ledger
293
+ result[:maintenance_ledger] = ledger
294
+ publish(ledger, JSON.pretty_generate(state: blocked ? 'blocked' : 'committed', inventory: preflight, result: result))
295
+ end
296
+ result
297
+ end
298
+
299
+ # Explicit offline layout migration: never a cron side effect. Stop schedulers
300
+ # and all writers first; old binaries cannot understand the new layout.
301
+ # Transcript files never move. Temporary writer remnants are recoverable.
302
+ def self.reorganize(dry_run: true, writers_stopped: false, proc_root: '/proc')
303
+ raise ArgumentError, 'All writers and schedulers must be stopped' unless dry_run || writers_stopped
304
+
305
+ with_maintenance_lock(dry_run: dry_run) do
306
+ root = PWN::Sessions.sessions_dir
307
+ plan = Dir.children(root).sort.filter_map do |name|
308
+ target = case name
309
+ when /\A\d{8}_\d{6}_[a-f0-9]{8}\.meta\.json\z/ then File.join('support', 'metadata', name)
310
+ when /\A\..+\.cleanup\.lock\z/ then File.join('support', 'leases', name)
311
+ when /\A\.activity-owner-\d+-\d+\.lock\z/ then File.join('support', 'owners', name)
312
+ when /\A\d{8}_\d{6}_[a-f0-9]{8}\.jsonl\.\d+\.tmp\z/ then nil
313
+ else next
314
+ end
315
+ source = File.join(root, name)
316
+ raise IOError, 'Unsafe layout source' unless File.file?(source) && !File.symlink?(source)
317
+
318
+ { source: source, target: target && File.join(root, target), sha256: Digest::SHA256.file(source).hexdigest,
319
+ inode: File.stat(source).ino, temporary: target.nil? }
320
+ end
321
+ # Existing trace paths may be immutable finding/artifact anchors. Keep
322
+ # those exact paths; new traces use compact storage after the marker.
323
+ retained_support = Dir.glob(File.join(root, '*', 'trace.jsonl'))
324
+ references = PWN::Sessions.dependency_index
325
+ raise IOError, 'Uncertain evidence anchors prevent layout migration' unless references[:errors].empty?
326
+
327
+ plan.reject! do |row|
328
+ next false unless row[:source].end_with?('.meta.json')
329
+
330
+ id = File.basename(row[:source], '.meta.json')
331
+ anchored = !references[:dependencies].fetch(id, []).empty? || !references[:session_errors].fetch(id, []).empty?
332
+ retained_support << row[:source] if anchored
333
+ anchored
334
+ end
335
+ result = { state: 'planned', records: plan, retained_support: retained_support, transcripts: Dir.glob(File.join(root, '*.jsonl')).length }
336
+ next result if dry_run
337
+
338
+ raise IOError, 'Legacy writers remain active' unless legacy_owners(proc_root).empty?
339
+
340
+ handles = []
341
+ begin
342
+ locks = Dir.glob(File.join(root, 'support', '{leases,owners}', '.*.lock'))
343
+ locks.each do |path|
344
+ file = File.open(path, File::RDWR | File::NOFOLLOW) # rubocop:disable Style/FileOpen -- transaction owns all handles
345
+ handles << file
346
+ locked = file.flock(File::LOCK_EX | File::LOCK_NB)
347
+ own_marker = File.basename(path).start_with?(".activity-owner-#{Process.pid}-")
348
+ raise IOError, 'Session writers are active' unless locked || own_marker
349
+ end
350
+ plan.reject! do |row|
351
+ next false unless row[:source].end_with?('.lock')
352
+
353
+ # The migration caller can own its capability marker; leave it in
354
+ # place, not a replacement inode. Other held markers block migration.
355
+ file = File.open(row[:source], File::RDWR | File::NOFOLLOW) # rubocop:disable Style/FileOpen -- transaction holds every lock through commit
356
+ handles << file
357
+ locked = file.flock(File::LOCK_EX | File::LOCK_NB)
358
+ own_marker = File.basename(row[:source]).start_with?(".activity-owner-#{Process.pid}-")
359
+ raise IOError, 'Session writers are active' unless locked || own_marker
360
+
361
+ !locked
362
+ end
363
+ backup(File.join(root, 'layout.jsonl'), {}, result)
364
+ ledger = File.join(result[:archive_dir], 'layout.json')
365
+ result[:state] = 'prepared'
366
+ publish(ledger, JSON.pretty_generate(result))
367
+ # Back up every byte before the first move, including abandoned temp
368
+ # output. Do not infer that a temp is safe merely from its age/PID.
369
+ plan.each_with_index do |row, index|
370
+ raise IOError, 'Layout source changed' unless File.stat(row[:source]).ino == row[:inode] && Digest::SHA256.file(row[:source]).hexdigest == row[:sha256]
371
+ raise IOError, 'Layout destination exists' if row[:target] && (File.exist?(row[:target]) || File.symlink?(row[:target]))
372
+
373
+ row[:backup] = File.join(result[:archive_dir], "layout-#{index}.bin")
374
+ publish(row[:backup], File.binread(row[:source]))
375
+ raise IOError, 'Layout backup mismatch' unless Digest::SHA256.file(row[:backup]).hexdigest == row[:sha256]
376
+ end
377
+ publish(ledger, JSON.pretty_generate(result))
378
+ plan.each do |row|
379
+ raise IOError, 'Layout source changed' unless Digest::SHA256.file(row[:source]).hexdigest == row[:sha256]
380
+
381
+ if row[:temporary]
382
+ File.unlink(row[:source])
383
+ else
384
+ FileUtils.mkdir_p(File.dirname(row[:target]), mode: 0o700)
385
+ raise IOError, 'Unsafe layout destination' unless File.realpath(File.dirname(row[:target])) == File.expand_path(File.dirname(row[:target]))
386
+
387
+ File.rename(row[:source], row[:target])
388
+ raise IOError, 'Layout move mismatch' unless File.stat(row[:target]).ino == row[:inode] && Digest::SHA256.file(row[:target]).hexdigest == row[:sha256]
389
+ end
390
+ row[:moved] = true
391
+ publish(ledger, JSON.pretty_generate(result))
392
+ end
393
+ publish(File.join(root, '.layout-v2'), "2\n")
394
+ result[:state] = 'committed'
395
+ publish(ledger, JSON.pretty_generate(result))
396
+ result
397
+ ensure
398
+ handles.each(&:close)
399
+ end
400
+ end
401
+ end
402
+
403
+ def self.summary(result)
404
+ { version: VERSION, dry_run: result[:dry_run], apply_blocked: result[:apply_blocked] == true,
405
+ scanned: result[:scanned], candidates: result[:candidate_ids].length, deleted: result[:deleted_ids].length,
406
+ remaining: result[:remaining_sessions], visible: result[:visible_sessions], skipped: result[:skipped],
407
+ evidence_migration: result[:evidence_migration], archive_dir: result[:archive_dir],
408
+ unresolved_dependency_stores: result[:dependency_errors] || [], legacy_owners: result[:legacy_owners] }
409
+ end
410
+
152
411
  def self.run(dry_run: true, current_session_id: nil, proc_root: '/proc', approved_candidates: nil)
412
+ return sweep(dry_run: dry_run, current_session_id: current_session_id, proc_root: proc_root, approved_candidates: approved_candidates) if approved_candidates
413
+
414
+ maintain(dry_run: dry_run, current_session_id: current_session_id, proc_root: proc_root)
415
+ end
416
+
417
+ def self.sweep(dry_run: true, current_session_id: nil, proc_root: '/proc', approved_candidates: nil)
418
+ with_maintenance_lock(dry_run: dry_run) do
419
+ sweep_transaction(dry_run: dry_run, current_session_id: current_session_id, proc_root: proc_root, approved_candidates: approved_candidates)
420
+ end
421
+ end
422
+
423
+ def self.sweep_transaction(dry_run:, current_session_id:, proc_root:, approved_candidates:)
153
424
  raise 'Session cleanup requires the updated Sessions activity API; load the updated source or deploy it before running this helper' unless PWN::Sessions.respond_to?(:activate)
154
425
 
155
426
  root = PWN::Sessions.sessions_dir
@@ -158,26 +429,41 @@ module PWN
158
429
  current = current_session_id || (PWN::Env.dig(:ai, :session_id) if defined?(PWN::Env))
159
430
  legacy = legacy_owners(proc_root)
160
431
  result = { version: VERSION, dry_run: dry_run, scanned: 0, unnamed: 0, candidate_ids: [], deleted_ids: [], records: [], skipped: Hash.new(0), legacy_owners: legacy }
161
- unless pins_known?
432
+ references = PWN::Sessions.dependency_index
433
+ unless references[:errors].empty?
162
434
  result[:skipped][:unknown_pins] = 1
163
- result[:dependency_errors] = PWN::Sessions.dependency_index[:errors]
164
- return result
435
+ result[:dependency_errors] = references[:errors]
436
+ result[:unresolved_store_remediation] = references[:errors]
165
437
  end
166
- dependencies = PWN::Sessions.dependency_index[:dependencies]
167
- pins = referenced_session_ids
438
+ result[:diagnostics] = references.fetch(:diagnostics, [])
439
+ dependencies = references[:dependencies]
440
+ pins = dependencies.keys
168
441
  Dir.glob(File.join(root, '*.jsonl')).each do |path|
169
442
  id = File.basename(path, '.jsonl')
170
443
  result[:scanned] += 1
171
- state = classify(path, id)
172
- record = PWN::Sessions.title_status(session_id: id).merge(id: id, classification: state, dependencies: dependencies.fetch(id, []))
444
+ state = cleanup_state(path, id, references)
445
+ record = PWN::Sessions.retention_status(session_id: id, references: references).merge(id: id, classification: state, dependencies: dependencies.fetch(id, []))
173
446
  result[:records] << record
174
- unless state == :unnamed
447
+ unless references[:errors].empty?
448
+ record[:skip] = :unknown_pins
449
+ next
450
+ end
451
+ unless %i[unnamed invalid_dependency].include?(state)
175
452
  result[:skipped][state] += 1
176
453
  next
177
454
  end
455
+ if state == :invalid_dependency && (!remediable?(id, references) || pins.include?(id))
456
+ record[:skip] = :unresolved_dependency_remediation
457
+ result[:skipped][:unresolved_dependency_remediation] += 1
458
+ next
459
+ end
178
460
  expected = snapshot(path)
461
+ expected_dependencies = invalid_dependencies(id, references)
462
+ record[:dependency_records] = expected_dependencies
179
463
  record[:files] = expected
180
- if approved_candidates && approved_candidates[id] != expected
464
+ approval = approved_candidates && approved_candidates[id]
465
+ approved = approval.is_a?(Hash) && approval.key?(:files) ? approval[:files] == expected && approval[:dependency_records] == expected_dependencies : approval == expected && expected_dependencies.empty?
466
+ if approved_candidates && !approved
181
467
  record[:skip] = :not_approved_or_changed
182
468
  result[:skipped][:not_approved_or_changed] += 1
183
469
  next
@@ -204,7 +490,7 @@ module PWN
204
490
  next
205
491
  end
206
492
  # Never unlink lock files: waiters must keep sharing the same inode.
207
- lock_path = File.join(root, ".#{id}.cleanup.lock")
493
+ lock_path = PWN::Sessions.support_path(kind: :lease, session_id: id)
208
494
  raise 'Refusing symlinked cleanup lock' if File.symlink?(lock_path)
209
495
 
210
496
  File.open(lock_path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
@@ -213,34 +499,39 @@ module PWN
213
499
  record[:skip] = :locked
214
500
  next
215
501
  end
216
- File.open(path, File::RDONLY | File::NOFOLLOW) do |transcript|
217
- unless transcript.flock(File::LOCK_EX | File::LOCK_NB)
218
- result[:skipped][:locked] += 1
219
- record[:skip] = :locked
220
- next
502
+ with_dependency_lock(id) do
503
+ File.open(path, File::RDONLY | File::NOFOLLOW) do |transcript|
504
+ unless transcript.flock(File::LOCK_EX | File::LOCK_NB)
505
+ result[:skipped][:locked] += 1
506
+ record[:skip] = :locked
507
+ next
508
+ end
509
+ # Activation and deletion serialize on the same per-session inode.
510
+ # Legacy ownership uncertainty still requires operator migration.
511
+ unless File.identical?(transcript, path) && cleanup_state(path, id) == state && invalid_dependencies(id) == expected_dependencies && snapshot(path) == expected && legacy_owners(proc_root).empty? && pins_known? && !referenced_session_ids.include?(id)
512
+ result[:skipped][:changed] += 1
513
+ record[:skip] = :changed
514
+ next
515
+ end
516
+ backup(path, expected, result)
517
+ unless snapshot(path) == expected && cleanup_state(path, id) == state && invalid_dependencies(id) == expected_dependencies && legacy_owners(proc_root).empty? && pins_known? && !referenced_session_ids.include?(id)
518
+ result[:skipped][:changed] += 1
519
+ record[:skip] = :changed
520
+ next
521
+ end
522
+ # Only verified, archived, unreferenced unsummarized sessions;
523
+ # current and all known reference pins checked above.
524
+ # The normal delete API's age pin is deliberately bypassed here.
525
+ remove_invalid_dependencies(id, result)
526
+ raise 'Dependencies changed after remediation' unless pins_known? && !referenced_session_ids.include?(id) && invalid_dependencies(id).empty? && snapshot(path) == expected
527
+
528
+ PWN::Sessions.delete(session_id: id, current_session_id: current, force: true, cleanup_lock: lock)
529
+ raise 'Deletion did not remove both session records' if File.exist?(path) || File.exist?(PWN::Sessions.support_path(kind: :metadata, session_id: id))
530
+
531
+ result[:deleted_ids] << id
532
+ record[:deleted] = true
533
+ publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result))
221
534
  end
222
- # Activation and deletion serialize on the same per-session inode.
223
- # Legacy ownership uncertainty still requires operator migration.
224
- unless File.identical?(transcript, path) && classify(path, id) == :unnamed && snapshot(path) == expected && legacy_owners(proc_root).empty? && pins_known? && !referenced_session_ids.include?(id)
225
- result[:skipped][:changed] += 1
226
- record[:skip] = :changed
227
- next
228
- end
229
- backup(path, expected, result)
230
- unless snapshot(path) == expected && classify(path, id) == :unnamed && legacy_owners(proc_root).empty? && pins_known? && !referenced_session_ids.include?(id)
231
- result[:skipped][:changed] += 1
232
- record[:skip] = :changed
233
- next
234
- end
235
- # Only verified, archived, unreferenced unsummarized sessions;
236
- # current and all known reference pins checked above.
237
- # The normal delete API's age pin is deliberately bypassed here.
238
- PWN::Sessions.delete(session_id: id, current_session_id: current, force: true, cleanup_lock: lock)
239
- raise 'Deletion did not remove both session records' if File.exist?(path) || File.exist?(path.sub(/\.jsonl\z/, '.meta.json'))
240
-
241
- result[:deleted_ids] << id
242
- record[:deleted] = true
243
- publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result))
244
535
  end
245
536
  end
246
537
  rescue StandardError => e
@@ -250,11 +541,13 @@ module PWN
250
541
  publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result)) if result[:archive_dir]
251
542
  result
252
543
  end
544
+
545
+ private_class_method :with_maintenance_lock, :maintenance_transaction, :sweep_transaction
253
546
  end
254
547
  end
255
548
 
256
549
  if $PROGRAM_NAME == __FILE__
257
550
  raise 'Use --dry-run or --apply' unless ARGV.length == 1 && %w[--dry-run --apply].include?(ARGV.first)
258
551
 
259
- puts JSON.generate(PWN::SessionCleanup.run(dry_run: ARGV.first == '--dry-run'))
552
+ puts JSON.generate(PWN::SessionCleanup.summary(PWN::SessionCleanup.maintain(dry_run: ARGV.first == '--dry-run')))
260
553
  end
@@ -229,9 +229,9 @@ module PWN
229
229
  commit = opts.key?(:commit) ? opts[:commit] : true
230
230
  cutoff = Time.now.utc - (since_h * 3_600)
231
231
 
232
- # PWN::Sessions.list is arity-0 (no kwargs). Cap/sort after the call.
232
+ # Offline evidence discovery must include sessions hidden from operator lists.
233
233
  sids = if PWN::Sessions.respond_to?(:list)
234
- Array(PWN::Sessions.list)
234
+ Array(PWN::Sessions.list(include_hidden: true))
235
235
  .sort_by { |s| s.is_a?(Hash) ? s[:mtime].to_s : '' }
236
236
  .reverse
237
237
  .first(limit * 2)