pwn 0.5.781 → 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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/documentation/Installation.md +33 -9
  3. data/documentation/diagrams/dot/extrospection-world-awareness.dot +37 -92
  4. data/documentation/diagrams/dot/memory-skills-detailed.dot +33 -98
  5. data/documentation/diagrams/dot/overall-pwn-architecture.dot +54 -32
  6. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +53 -30
  7. data/documentation/diagrams/dot/reinforcement-learning.dot +46 -111
  8. data/documentation/diagrams/dot/swarm-multi-agent.dot +36 -61
  9. data/documentation/diagrams/extrospection-world-awareness.svg +139 -510
  10. data/documentation/diagrams/memory-skills-detailed.svg +126 -475
  11. data/documentation/diagrams/overall-pwn-architecture.svg +116 -266
  12. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +158 -244
  13. data/documentation/diagrams/reinforcement-learning.svg +238 -529
  14. data/documentation/diagrams/swarm-multi-agent.svg +104 -233
  15. data/documentation/pwn-REPL.md +31 -3
  16. data/documentation/pwn-ai-Agent.md +62 -2
  17. data/etc/default_cron/session_cleanup.rb +335 -42
  18. data/lib/pwn/ai/agent/curriculum.rb +2 -2
  19. data/lib/pwn/ai/agent/learning.rb +11 -4
  20. data/lib/pwn/ai/agent/mistakes.rb +22 -3
  21. data/lib/pwn/ai/agent/tools/sessions.rb +6 -6
  22. data/lib/pwn/ai/http_retry.rb +3 -0
  23. data/lib/pwn/ai/router.rb +3 -1
  24. data/lib/pwn/cron.rb +1 -1
  25. data/lib/pwn/memory.rb +62 -6
  26. data/lib/pwn/plugins/repl/ai.rb +13 -3
  27. data/lib/pwn/plugins/repl/ai_console.rb +37 -21
  28. data/lib/pwn/session_trace.rb +1 -1
  29. data/lib/pwn/sessions.rb +305 -80
  30. data/lib/pwn/version.rb +1 -1
  31. data/spec/lib/pwn/ai/agent/learning_spec.rb +65 -2
  32. data/spec/lib/pwn/ai/agent/swarm_spec.rb +3 -3
  33. data/spec/lib/pwn/memory_spec.rb +58 -1
  34. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +191 -6
  35. data/spec/lib/pwn/plugins/repl/ai_result_chronology_spec.rb +2 -2
  36. data/spec/lib/pwn/plugins/repl/ai_spec.rb +28 -11
  37. data/spec/lib/pwn/session_cleanup_default_spec.rb +2 -2
  38. data/spec/lib/pwn/session_cleanup_payload_spec.rb +467 -36
  39. data/spec/lib/pwn/sessions_spec.rb +116 -16
  40. data/third_party/pwn_rdoc.jsonl +12 -0
  41. metadata +1 -1
@@ -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)
@@ -262,7 +262,9 @@ module PWN
262
262
 
263
263
  private_class_method def self.evidence_write(opts = {})
264
264
  path = opts[:path]
265
- body = opts[:body]
265
+ # Verification is byte equality, not Ruby's encoding-sensitive text equality.
266
+ # Retag a copy only: do not transcode, normalize or scrub evidence/backups.
267
+ body = opts[:body].b
266
268
  dir = File.dirname(path)
267
269
  ancestor = dir
268
270
  ancestor = File.dirname(ancestor) until File.exist?(ancestor) || File.symlink?(ancestor)
@@ -332,7 +334,8 @@ module PWN
332
334
  # Reentrant across note_outcome -> pruning; flock serializes cooperating processes.
333
335
  private_class_method def self.learning_lock
334
336
  key = :pwn_learning_write_lock
335
- return nil if Thread.current[key]
337
+ owner = Thread.current[key]
338
+ return nil if owner.is_a?(Hash) && owner[:pid] == Process.pid && owner[:thread] == Thread.current && !owner[:file].closed?
336
339
 
337
340
  root = File.dirname(LEARNING_FILE)
338
341
  FileUtils.mkdir_p(root, mode: 0o700)
@@ -340,9 +343,9 @@ module PWN
340
343
  raise IOError, 'Unsafe learning lock' if File.symlink?(path) || File.realpath(root) != File.expand_path(root)
341
344
 
342
345
  # Ownership transfers to the caller's ensure/learning_unlock.
343
- file = File.open(path, File::RDWR | File::CREAT, 0o600) # rubocop:disable Style/FileOpen
346
+ file = File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen
344
347
  file.flock(File::LOCK_EX)
345
- Thread.current[key] = file
348
+ Thread.current[key] = { pid: Process.pid, thread: Thread.current, file: file }
346
349
  file
347
350
  end
348
351
 
@@ -1250,6 +1253,8 @@ module PWN
1250
1253
  # stays high-signal.
1251
1254
 
1252
1255
  public_class_method def self.consolidate(opts = {})
1256
+ return PWN::Memory.transaction(path: PWN::Memory::MEMORY_FILE) { consolidate(opts) } unless PWN::Memory.transaction_owned?(path: PWN::Memory::MEMORY_FILE)
1257
+
1253
1258
  cap = opts[:max_entries] || MAX_MEMORY_ENTRIES
1254
1259
  return { removed: 0 } unless defined?(PWN::Memory)
1255
1260
 
@@ -2221,6 +2226,8 @@ module PWN
2221
2226
  # after upgrading; subsequent writes never produce these.
2222
2227
 
2223
2228
  public_class_method def self.purge_noise
2229
+ return PWN::Memory.transaction(path: PWN::Memory::MEMORY_FILE) { purge_noise } unless PWN::Memory.transaction_owned?(path: PWN::Memory::MEMORY_FILE)
2230
+
2224
2231
  return { removed: 0 } unless defined?(PWN::Memory)
2225
2232
 
2226
2233
  mem = nil
@@ -63,15 +63,18 @@ module PWN
63
63
  FileUtils.mkdir_p(File.dirname(MISTAKES_FILE))
64
64
  return {} unless File.exist?(MISTAKES_FILE)
65
65
 
66
- JSON.parse(File.read(MISTAKES_FILE), symbolize_names: true)
67
- rescue StandardError
68
- {}
66
+ store = JSON.parse(File.read(MISTAKES_FILE), symbolize_names: true)
67
+ raise IOError, 'Invalid mistakes map' unless store.is_a?(Hash)
68
+
69
+ store
69
70
  end
70
71
 
71
72
  # Supported Method Parameters::
72
73
  # PWN::AI::Agent::Mistakes.save(store: hash)
73
74
 
74
75
  public_class_method def self.save(opts = {})
76
+ return PWN::Memory.transaction(path: MISTAKES_FILE) { save(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
77
+
75
78
  store = opts[:store] ||= {}
76
79
  FileUtils.mkdir_p(File.dirname(MISTAKES_FILE))
77
80
  atomic_write(path: MISTAKES_FILE, body: JSON.pretty_generate(store))
@@ -195,6 +198,8 @@ module PWN
195
198
  # the caller (Loop.run) can drive cross-session repeat detection.
196
199
 
197
200
  public_class_method def self.record(opts = {})
201
+ return PWN::Memory.transaction(path: MISTAKES_FILE) { record(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
202
+
198
203
  tool = opts[:tool].to_s
199
204
  error = opts[:error].to_s
200
205
  return nil if tool.empty? || error.strip.empty?
@@ -272,6 +277,8 @@ module PWN
272
277
  # )
273
278
 
274
279
  public_class_method def self.resolve(opts = {})
280
+ return PWN::Memory.transaction(path: MISTAKES_FILE) { resolve(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
281
+
275
282
  sig = opts[:signature].to_s
276
283
  fix = opts[:fix].to_s
277
284
  raise 'ERROR: signature is required' if sig.empty?
@@ -416,6 +423,8 @@ module PWN
416
423
  }.freeze
417
424
 
418
425
  public_class_method def self.extinguish!(opts = {})
426
+ return PWN::Memory.transaction(path: MISTAKES_FILE) { extinguish!(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
427
+
419
428
  sig = opts[:signature].to_s
420
429
  return nil if sig.empty?
421
430
 
@@ -457,6 +466,8 @@ module PWN
457
466
  # Auto-resolve parked items that already have a known extinguish recipe
458
467
  # so the operator inbox does not keep scars the loop can close itself.
459
468
  public_class_method def self.extinguish_parked!(opts = {})
469
+ return PWN::Memory.transaction(path: MISTAKES_FILE) { extinguish_parked!(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
470
+
460
471
  limit = (opts[:limit] || 20).to_i
461
472
  dry = opts[:dry_run] ? true : false
462
473
  rows = load.values.select do |m|
@@ -495,6 +506,8 @@ module PWN
495
506
  end
496
507
 
497
508
  public_class_method def self.park(opts = {})
509
+ return PWN::Memory.transaction(path: MISTAKES_FILE) { park(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
510
+
498
511
  sig = opts[:signature].to_s
499
512
  raise 'ERROR: signature is required' if sig.empty?
500
513
 
@@ -692,6 +705,8 @@ module PWN
692
705
  end
693
706
 
694
707
  public_class_method def self.note_hint_outcome(opts = {})
708
+ return PWN::Memory.transaction(path: MISTAKES_FILE) { note_hint_outcome(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
709
+
695
710
  sig = (opts[:signature] || signature(tool: opts[:tool], error: opts[:error])).to_s
696
711
  return nil if sig.empty?
697
712
 
@@ -777,6 +792,8 @@ module PWN
777
792
  # drop after Memory already holds mistake_fix_<sig>.
778
793
 
779
794
  public_class_method def self.lean!(opts = {})
795
+ return PWN::Memory.transaction(path: MISTAKES_FILE) { lean!(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
796
+
780
797
  dry = opts[:dry_run] ? true : false
781
798
  max_resolved = (opts[:max_resolved_kept] || MAX_RESOLVED_KEPT).to_i
782
799
  min_age = (opts[:resolved_min_age_days] || RESOLVED_MIN_AGE_DAYS).to_f
@@ -878,6 +895,8 @@ module PWN
878
895
  # PWN::AI::Agent::Mistakes.reset
879
896
 
880
897
  public_class_method def self.reset
898
+ return PWN::Memory.transaction(path: MISTAKES_FILE) { reset } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
899
+
881
900
  FileUtils.rm_f(MISTAKES_FILE)
882
901
  {}
883
902
  end
@@ -52,23 +52,23 @@ PWN::AI::Agent::Registry.register(
52
52
  toolset: 'sessions',
53
53
  schema: {
54
54
  name: 'sessions_list',
55
- description: 'List every pwn-ai session transcript in ~/.pwn/sessions ' \
56
- '(id, title, path, size, mtime, lines, dependencies, dependency_errors) newest-first. ' \
55
+ description: 'List the current active session and valid summarized or dependency-backed pwn-ai sessions in ~/.pwn/sessions ' \
56
+ 'as paginated concise rows (id, title, mtime, dependencies), oldest-first, with total and global diagnostics. ' \
57
57
  'dependencies lists files referencing this session through structured links, not prose mentions. ' \
58
58
  'Use to discover a session_id for learning_reflect / ' \
59
59
  'learning_distill_skill / sessions_view.',
60
60
  parameters: {
61
61
  type: 'object',
62
62
  properties: {
63
- limit: { type: 'integer', default: 25, description: 'Max sessions to return (newest first).' }
63
+ limit: { type: 'integer', default: 25, description: 'Max sessions to return (oldest first).' },
64
+ offset: { type: 'integer', description: 'Number of retained sessions to skip; omitted selects newest page. Current active session is also included.' }
64
65
  },
65
66
  required: []
66
67
  }
67
68
  },
68
69
  check: -> { defined?(PWN::Sessions) },
69
70
  handler: lambda { |args|
70
- limit = (args[:limit] || 25).to_i
71
- PWN::Sessions.list.sort_by { |s| s[:mtime].to_s }.reverse.first(limit)
71
+ PWN::Sessions.catalog(limit: args[:limit] || 25, offset: args[:offset], current_session_id: Thread.current[:pwn_session_id])
72
72
  }
73
73
  )
74
74
 
@@ -152,7 +152,7 @@ PWN::AI::Agent::Registry.register(
152
152
  },
153
153
  check: -> { defined?(PWN::Sessions) },
154
154
  handler: lambda { |_args|
155
- listing = PWN::Sessions.list
155
+ listing = PWN::Sessions.list(include_hidden: true)
156
156
  PWN::Sessions.stats.merge(
157
157
  disk_bytes: listing.sum { |s| s[:size].to_i },
158
158
  dir: PWN::Sessions.sessions_dir
@@ -31,6 +31,9 @@ module PWN
31
31
  # Resolve provider defaults from the active request without mutating Env.
32
32
  public_class_method def self.provider_config(opts = {})
33
33
  engine = opts[:engine]
34
+ title_config = Thread.current[:pwn_session_title_config]
35
+ return title_config[engine] if title_config
36
+
34
37
  owner = Thread.current[:pwn_request_runtime]
35
38
  return owner.provider_config(engine) if owner.respond_to?(:provider_config)
36
39