pwn 0.5.783 → 0.5.784

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -3
  3. data/Rakefile +19 -1
  4. data/bin/pwn_setup +1 -1
  5. data/build_gem.sh +1 -1
  6. data/documentation/Backup-Retention.md +27 -0
  7. data/documentation/Cron.md +64 -35
  8. data/documentation/How-PWN-Works.md +1 -1
  9. data/documentation/Installation.md +247 -6
  10. data/documentation/Persistence.md +1 -1
  11. data/documentation/Reinforcement-Learning.md +5 -6
  12. data/documentation/diagrams/cron-scheduling.svg +130 -128
  13. data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
  14. data/etc/default_cron/session_cleanup.rb +1217 -10
  15. data/lib/pwn/ai/agent/extrospection.rb +6 -7
  16. data/lib/pwn/ai/agent/learning.rb +48 -7
  17. data/lib/pwn/ai/agent/policy.rb +5 -0
  18. data/lib/pwn/ai/agent/swarm.rb +31 -16
  19. data/lib/pwn/ai/router.rb +45 -2
  20. data/lib/pwn/cron.rb +120 -49
  21. data/lib/pwn/migrate.rb +292 -15
  22. data/lib/pwn/plugins/binary_parser.rb +5 -4
  23. data/lib/pwn/plugins/ghidra.rb +4 -3
  24. data/lib/pwn/plugins/log.rb +18 -2
  25. data/lib/pwn/plugins/repl/ai.rb +12 -1
  26. data/lib/pwn/plugins/repl/ai_console.rb +6 -2
  27. data/lib/pwn/plugins/repl/ai_swarm.rb +4 -4
  28. data/lib/pwn/sessions.rb +58 -16
  29. data/lib/pwn/setup.rb +9 -17
  30. data/lib/pwn/version.rb +1 -1
  31. data/scripts/build_gate.rb +58 -0
  32. data/spec/integration/build_gate_spec.rb +73 -0
  33. data/spec/integration/persistence_roundtrip_spec.rb +1 -7
  34. data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
  35. data/spec/lib/pwn/ai/router_spec.rb +30 -2
  36. data/spec/lib/pwn/cron_spec.rb +113 -6
  37. data/spec/lib/pwn/migrate_spec.rb +160 -5
  38. data/spec/lib/pwn/plugins/log_spec.rb +7 -0
  39. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +16 -4
  40. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +11 -0
  41. data/spec/lib/pwn/redaction_spec.rb +13 -10
  42. data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
  43. data/spec/lib/pwn/session_cleanup_default_spec.rb +165 -2
  44. data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
  45. data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
  46. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +389 -0
  47. data/spec/lib/pwn/sessions_spec.rb +112 -0
  48. data/spec/lib/pwn/setup_spec.rb +3 -1
  49. data/spec/support/gate_failure_formatter.rb +14 -0
  50. data/third_party/pwn_rdoc.jsonl +15 -1
  51. metadata +14 -7
@@ -2,6 +2,39 @@
2
2
 
3
3
  require 'spec_helper'
4
4
 
5
+ describe PWN::Migrate, 'exact migration snapshot scope' do
6
+ include_context 'pwn tmp sandbox'
7
+
8
+ it 'backs up nested registry paths and directory trees without cron runtime copies' do
9
+ FileUtils.mkdir_p(File.join(@tmp, 'cron', 'schedule-backups'))
10
+ FileUtils.mkdir_p(File.join(@tmp, 'skills', 'custom'))
11
+ File.write(File.join(@tmp, 'cron', 'jobs.yml'), "--- []\n")
12
+ File.write(File.join(@tmp, 'cron', 'worker.log'), 'diagnostic')
13
+ File.write(File.join(@tmp, 'cron', 'schedule-backups', 'receipt.json'), '{}')
14
+ File.write(File.join(@tmp, 'skills', 'custom', 'SKILL.md'), 'custom recovery')
15
+ snapshot = described_class.send(:backup!)
16
+ expect(File.read(File.join(snapshot, 'cron', 'jobs.yml'))).to eq("--- []\n")
17
+ expect(File.read(File.join(snapshot, 'skills', 'custom', 'SKILL.md'))).to eq('custom recovery')
18
+ expect(File).not_to exist(File.join(snapshot, 'cron', 'worker.log'))
19
+ expect(File).not_to exist(File.join(snapshot, 'cron', 'schedule-backups'))
20
+ end
21
+
22
+ it 'does not require new cron logs and maintenance copies in recovery coverage' do
23
+ File.write(File.join(@tmp, 'memory.json'), '{}')
24
+ 5.times do |i|
25
+ path = File.join(described_class::BACKUP_ROOT, "2026010#{i + 1}_000000")
26
+ FileUtils.mkdir_p(path)
27
+ File.write(File.join(path, 'memory.json'), '{}')
28
+ end
29
+ FileUtils.mkdir_p(File.join(@tmp, 'cron', 'schedule-backups'))
30
+ File.write(File.join(@tmp, 'cron', 'worker.log'), 'new diagnostic')
31
+ File.write(File.join(@tmp, 'cron', 'schedule-backups', 'receipt.json'), '{}')
32
+ result = described_class.prune_backups(dry_run: false, expire_history: true)
33
+ expect(result[:deleted]).to eq(%w[20260101_000000 20260102_000000])
34
+ expect(File.read(File.join(@tmp, 'cron', 'worker.log'))).to eq('new diagnostic')
35
+ end
36
+ end
37
+
5
38
  describe PWN::Migrate do
6
39
  it 'should display information for authors' do
7
40
  authors_response = PWN::Migrate
@@ -43,6 +76,59 @@ describe PWN::Migrate do
43
76
  allow(PWN::Config).to receive(:pwn_skills_path).and_return(File.join(@tmp, 'skills'))
44
77
  end
45
78
 
79
+ it 'expires unique old history only after current recovery coverage is established' do
80
+ File.write(File.join(@tmp, 'memory.json'), '{"current":true}')
81
+ 4.times do |i|
82
+ path = File.join(PWN::Migrate::BACKUP_ROOT, "2026010#{i + 1}_000000")
83
+ FileUtils.mkdir_p(path)
84
+ File.write(File.join(path, 'memory.json'), i == 3 ? '{"current":true}' : "{\"old\":#{i}}")
85
+ end
86
+ result = PWN::Migrate.prune_backups(dry_run: false, expire_history: true)
87
+ expect(result[:deleted]).to eq(['20260101_000000'])
88
+ expect(result[:retained].length).to eq(3)
89
+ expect(File).to exist(File.join(PWN::Migrate::BACKUP_ROOT, '.retention-manifest.json.gz'))
90
+ expect(File.read(File.join(@tmp, 'memory.json'))).to eq('{"current":true}')
91
+ end
92
+
93
+ context 'bounded historical expiry safeguards' do
94
+ before do
95
+ File.write(File.join(@tmp, 'memory.json'), '{}')
96
+ 5.times do |i|
97
+ path = File.join(PWN::Migrate::BACKUP_ROOT, "2026010#{i + 1}_000000")
98
+ FileUtils.mkdir_p(path)
99
+ File.write(File.join(path, 'memory.json'), '{}')
100
+ end
101
+ end
102
+
103
+ it 'pins actual referenced evidence, not quoted inventory prose' do
104
+ File.write(File.join(@tmp, 'learning.jsonl'), "#{JSON.generate(evidence_path: "#{PWN::Migrate::BACKUP_ROOT}/20260101_000000/memory.json", prose: "old path #{PWN::Migrate::BACKUP_ROOT}/20260102_000000/memory.json")}\n")
105
+ FileUtils.cp(File.join(@tmp, 'learning.jsonl'), File.join(PWN::Migrate::BACKUP_ROOT, '20260105_000000', 'learning.jsonl'))
106
+ result = PWN::Migrate.prune_backups(dry_run: false, expire_history: true)
107
+ expect(result[:deleted]).to eq(['20260102_000000'])
108
+ expect(result[:pins]).to eq(['20260101_000000'])
109
+ end
110
+
111
+ it 'refuses expiry without current coverage or matching credential recovery' do
112
+ File.write(File.join(@tmp, 'pwn.yaml'), 'encrypted-current-fixture')
113
+ result = PWN::Migrate.prune_backups(dry_run: false, expire_history: true)
114
+ expect(result[:incomplete]).to be true
115
+ expect(result[:deleted]).to be_empty
116
+ File.write(File.join(PWN::Migrate::BACKUP_ROOT, '20260105_000000', 'pwn.yaml'), 'encrypted-old-fixture')
117
+ expect(PWN::Migrate.prune_backups(dry_run: false, expire_history: true)[:error]).to match(/encrypted configuration/)
118
+ end
119
+
120
+ it 'refuses symlinks and held producer locks and exhausted admission' do
121
+ File.symlink(File.join(@tmp, 'memory.json'), File.join(PWN::Migrate::BACKUP_ROOT, '20260101_000000', 'unsafe'))
122
+ expect(PWN::Migrate.prune_backups(dry_run: false, expire_history: true)[:deleted]).to be_empty
123
+ File.unlink(File.join(PWN::Migrate::BACKUP_ROOT, '20260101_000000', 'unsafe'))
124
+ File.open(File.join(PWN::Migrate::BACKUP_ROOT, '.retention.lock'), 'w') do |lock|
125
+ lock.flock(File::LOCK_EX)
126
+ expect(PWN::Migrate.prune_backups(dry_run: false, expire_history: true)[:error]).to eq('Backup maintenance active')
127
+ end
128
+ expect(PWN::Migrate.prune_backups(dry_run: false, expire_history: true, admission: ->(_bytes) { false })[:deleted]).to be_empty
129
+ end
130
+ end
131
+
46
132
  it '.needed? / .status on a fresh (empty) root' do
47
133
  expect(PWN::Migrate.installed_schema).to eq(0)
48
134
  expect(PWN::Migrate.needed?).to be true
@@ -80,7 +166,7 @@ describe PWN::Migrate do
80
166
  File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 2))
81
167
  expect(described_class.needed?).to be(true)
82
168
  result = described_class.run(fix: false, backup: false, io: io)
83
- expect(result[:applied_migrations]).to eq([3, 4, 5, 6, 7, 8, 9, 10])
169
+ expect(result[:applied_migrations]).to eq([3, 4, 5, 6, 7, 8, 9, 10, 11])
84
170
  expected = Marshal.load(Marshal.dump(original))
85
171
  expected['custom']['model'] = nil
86
172
  expect(YAML.safe_load_file(path)).to eq(expected)
@@ -189,7 +275,7 @@ describe PWN::Migrate do
189
275
  File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 3))
190
276
  expect(described_class.needed?).to be(true)
191
277
  result = described_class.run(fix: true, backup: false, io: io)
192
- expect(result[:applied_migrations]).to eq([4, 5, 6, 7, 8, 9, 10])
278
+ expect(result[:applied_migrations]).to eq([4, 5, 6, 7, 8, 9, 10, 11])
193
279
  creds = YAML.safe_load_file(dec, symbolize_names: true)
194
280
  user = PWN::Plugins::Vault.dump(file: yaml, key: creds[:key], iv: creds[:iv])
195
281
  expect(user[:ai][:active]).to eq('grok')
@@ -215,7 +301,7 @@ describe PWN::Migrate do
215
301
  PWN::Plugins::Vault.create(file: yaml, decryptor_file: dec)
216
302
  File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 4))
217
303
  result = described_class.run(fix: true, backup: false, io: io)
218
- expect(result[:applied_migrations]).to eq([5, 6, 7, 8, 9, 10])
304
+ expect(result[:applied_migrations]).to eq([5, 6, 7, 8, 9, 10, 11])
219
305
  creds = YAML.safe_load_file(dec, symbolize_names: true)
220
306
  user = PWN::Plugins::Vault.dump(file: yaml, key: creds[:key], iv: creds[:iv])
221
307
  expect(user.dig(:ai, :agent, :skill_review)).to eq('off')
@@ -240,7 +326,7 @@ describe PWN::Migrate do
240
326
  File.write(yaml, YAML.dump(stale).gsub(/^(\s*):/, '\1'))
241
327
  PWN::Plugins::Vault.create(file: yaml, decryptor_file: dec)
242
328
  File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 5))
243
- expect(described_class.run(fix: true, backup: false, io: io)[:applied_migrations]).to eq([6, 7, 8, 9, 10])
329
+ expect(described_class.run(fix: true, backup: false, io: io)[:applied_migrations]).to eq([6, 7, 8, 9, 10, 11])
244
330
  creds = YAML.safe_load_file(dec, symbolize_names: true)
245
331
  user = PWN::Plugins::Vault.dump(file: yaml, key: creds[:key], iv: creds[:iv])
246
332
  theme = user.dig(:ai, :tui, :theme)
@@ -287,7 +373,7 @@ describe PWN::Migrate do
287
373
  File.write(yaml, YAML.dump(stale).gsub(/^(\s*):/, '\1'))
288
374
  PWN::Plugins::Vault.create(file: yaml, decryptor_file: dec)
289
375
  File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 8))
290
- expect(described_class.run(fix: true, backup: false, io: io)[:applied_migrations]).to eq([9, 10])
376
+ expect(described_class.run(fix: true, backup: false, io: io)[:applied_migrations]).to eq([9, 10, 11])
291
377
  creds = YAML.safe_load_file(dec, symbolize_names: true)
292
378
  user = PWN::Plugins::Vault.dump(file: yaml, key: creds[:key], iv: creds[:iv])
293
379
  long_fast = user.dig(:plugins, :meshtastic, :channel, :LongFast)
@@ -311,3 +397,72 @@ describe PWN::Migrate do
311
397
  end
312
398
  end
313
399
  end
400
+
401
+ describe PWN::Migrate, 'backup housekeeping' do
402
+ include_context 'pwn tmp sandbox'
403
+ let(:io) { StringIO.new }
404
+
405
+ it 'does not snapshot or rewrite the stamp on a no-op migration' do
406
+ File.write(File.join(@tmp, '.schema'), JSON.generate(schema: described_class::SCHEMA_VERSION))
407
+ before = File.binread(File.join(@tmp, '.schema'))
408
+ expect(described_class).not_to receive(:backup!)
409
+ result = described_class.run(io: io)
410
+ expect(result[:backup]).to be_nil
411
+ expect(File.binread(File.join(@tmp, '.schema'))).to eq(before)
412
+ end
413
+
414
+ it 'retains three recovery points and unique history but prunes verified duplicate snapshots' do
415
+ root = described_class::BACKUP_ROOT
416
+ 5.times do |i|
417
+ dir = File.join(root, "2026010#{i + 1}_000000")
418
+ FileUtils.mkdir_p(dir)
419
+ File.write(File.join(dir, 'learning.jsonl'), i.zero? ? 'unique evidence' : 'same evidence')
420
+ end
421
+ preview = described_class.prune_backups
422
+ expect(preview[:eligible].length).to eq(1)
423
+ expect(Dir.children(root).length).to eq(5)
424
+ applied = described_class.prune_backups(dry_run: false)
425
+ expect(applied[:deleted].length).to eq(1)
426
+ expect(File.binread(File.join(root, '20260101_000000', 'learning.jsonl'))).to eq('unique evidence')
427
+ expect(Dir.children(root).grep(/\A\d/).length).to eq(4)
428
+ end
429
+
430
+ it 'publishes a verified scoped snapshot and fails closed on a copy failure' do
431
+ File.write(File.join(@tmp, 'learning.jsonl'), 'historical evidence')
432
+ FileUtils.mkdir_p(File.join(@tmp, 'logs'))
433
+ File.write(File.join(@tmp, 'logs', 'debug.log'), 'not migration input')
434
+ path = described_class.send(:backup!)
435
+ expect(File.binread(File.join(path, 'learning.jsonl'))).to eq('historical evidence')
436
+ expect(File).not_to exist(File.join(path, 'logs'))
437
+ manifest = JSON.parse(File.binread(File.join(path, '.snapshot-manifest.json')))
438
+ expect(manifest['complete']).to be(true)
439
+ expect(manifest['files']).to have_key('learning.jsonl')
440
+ allow(FileUtils).to receive(:cp_r).and_raise(IOError, 'copy failed')
441
+ expect { described_class.send(:backup!) }.to raise_error(IOError, 'copy failed')
442
+ end
443
+
444
+ it 'rejects symlinked backup trees and exhausted admission without deleting anything' do
445
+ root = described_class::BACKUP_ROOT
446
+ FileUtils.mkdir_p(root)
447
+ File.symlink(@tmp, File.join(root, '20260101_000000'))
448
+ expect(described_class.prune_backups(dry_run: false)[:incomplete]).to be(true)
449
+ File.unlink(File.join(root, '20260101_000000'))
450
+ 4.times do |i|
451
+ dir = File.join(root, "2026010#{i + 1}_000000")
452
+ FileUtils.mkdir_p(dir)
453
+ File.write(File.join(dir, 'state'), 'same')
454
+ end
455
+ result = described_class.prune_backups(dry_run: false, admission: ->(_bytes) { false })
456
+ expect(result[:incomplete]).to be(true)
457
+ expect(result[:deleted]).to eq([])
458
+ expect(Dir.children(root).grep(/\A\d/).length).to eq(4)
459
+ end
460
+
461
+ it 'skips backups for fix mode when schema, files and vault have no drift' do
462
+ File.write(File.join(@tmp, '.schema'), JSON.generate(schema: described_class::SCHEMA_VERSION))
463
+ allow(described_class).to receive(:vault_drift).and_return({ missing: [] })
464
+ allow(described_class).to receive(:status).and_return({ schema_installed: described_class::SCHEMA_VERSION, schema_current: described_class::SCHEMA_VERSION, incompatible: [], pwn_version: 'test' })
465
+ expect(described_class).not_to receive(:backup!)
466
+ expect(described_class.run(fix: true, io: io)[:backup]).to be_nil
467
+ end
468
+ end
@@ -15,6 +15,13 @@ describe PWN::Plugins::Log do
15
15
  end
16
16
 
17
17
  describe 'pwn-ai debug session' do
18
+ include_context 'pwn tmp sandbox'
19
+
20
+ before do
21
+ allow(Dir).to receive(:home).and_return(File.join(@tmp, 'home'))
22
+ FileUtils.mkdir_p(File.join(Dir.home, '.pwn'))
23
+ end
24
+
18
25
  after do
19
26
  PWN::Plugins::Log.stop_debug if PWN::Plugins::Log.respond_to?(:stop_debug)
20
27
  end
@@ -317,7 +317,7 @@ describe 'session maintenance command workers' do
317
317
  console.instance_variable_get(:@tool_input)&.close
318
318
  end
319
319
 
320
- it 'keeps navigation and input alive through the real maintenance learning lock and queues errors' do
320
+ it 'defers a real maintenance learning lock without blocking navigation and queues errors' do
321
321
  require File.expand_path('../../../../../etc/default_cron/session_cleanup', __dir__)
322
322
  allow(PWN::SessionCleanup).to receive(:legacy_owners).and_return([])
323
323
  stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(@tmp, '.pwn', 'learning.jsonl'))
@@ -339,7 +339,7 @@ describe 'session maintenance command workers' do
339
339
  Timeout.timeout(2) { console.submit('/sessions cleanup') }
340
340
  Timeout.timeout(2) { entered.pop }
341
341
  worker = console.instance_variable_get(:@worker)
342
- expect(worker.join(0.05)).to be_nil
342
+ expect(worker.join(5)).to eq(worker)
343
343
  console.submit('/input fixture')
344
344
  expect(reader.read_nonblock(8)).to eq("fixture\n")
345
345
  console.handle("\u0014")
@@ -350,6 +350,16 @@ describe 'session maintenance command workers' do
350
350
  lock = nil
351
351
  expect(worker.join(5)).to eq(worker)
352
352
  console.send(:drain)
353
+ # A busy writer defers before deletion admission without waiting for release.
354
+ # A fresh command must still delete, proving the stop state does not leak.
355
+ expect(File).to exist(File.join(PWN::Sessions.sessions_dir, "#{sid}.jsonl"))
356
+ ledgers = Dir.glob(File.join(@tmp, '.pwn', 'maintenance', 'runs', '*.json')).map { |path| JSON.parse(File.read(path)) }
357
+ expect(ledgers.last).to include('state' => 'blocked')
358
+ expect(ledgers.last.dig('result', 'evidence_migration', 'deferred')).to eq('learning_writer_active')
359
+ expect(console.send(:busy?)).to be_falsey
360
+ console.submit('/sessions cleanup')
361
+ expect(console.instance_variable_get(:@worker).join(5)).not_to be_nil
362
+ console.send(:drain)
353
363
  expect(File).not_to exist(File.join(PWN::Sessions.sessions_dir, "#{sid}.jsonl"))
354
364
  expect(console.instance_variable_get(:@timeline).select { |row| row[0] == :command }.map { |row| row[1] }.join).to include('deleted: 1')
355
365
  allow(PWN::SessionCleanup).to receive(:maintain).and_raise(IOError, 'fixture maintenance failed')
@@ -379,10 +389,12 @@ describe 'session maintenance command workers' do
379
389
  console.instance_variable_set(:@tool_input, writer)
380
390
  entered = Queue.new
381
391
  release = Queue.new
382
- allow(PWN::SessionCleanup).to receive(:run).and_wrap_original do |method, **options|
392
+ # Pause inside the admitted deletion transaction, not before run checks stop.
393
+ # Cancellation here must finish the backed-up commit without deleting late IDs.
394
+ allow(PWN::Sessions).to receive(:delete).and_wrap_original do |method, **options|
383
395
  entered << true
384
396
  release.pop
385
- method.call(**options)
397
+ method.call(options)
386
398
  end
387
399
  Timeout.timeout(2) { console.handle("\r") }
388
400
  Timeout.timeout(2) { entered.pop }
@@ -5,6 +5,17 @@ require 'spec_helper'
5
5
  describe PWN::Plugins::REPL::AISwarm::Controller do
6
6
  include_context 'pwn tmp sandbox'
7
7
 
8
+ it 'routes explicit continuation retirement through the slash command without changing ordinary retire' do
9
+ controller = described_class.new(session_id: 'fixture')
10
+ allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture-swarm')
11
+ expect(PWN::AI::Agent::Swarm).to receive(:retire).with(hash_including(name: 'scout', continuation: true)).and_return(retired: true)
12
+ expect(PWN::AI::Agent::Swarm).to receive(:retire).with(hash_including(name: 'critic', continuation: false)).and_return(retired: true)
13
+ controller.execute(line: '/swarm retire scout --continuation')
14
+ controller.execute(line: '/swarm retire critic')
15
+ ensure
16
+ controller&.close
17
+ end
18
+
8
19
  it 'retains cancellation evidence and forwards bounded mission options unchanged' do
9
20
  allow(PWN::AI::Agent::Swarm).to receive(:solve) do |opts|
10
21
  expect(opts).to include(worktree: true, rounds: 2, verification_timeout: 120, role_timeout: 45)
@@ -40,22 +40,25 @@ RSpec.describe 'Persistence redaction' do
40
40
  it 'sanitizes regular, learning, debug, mirror, stderr and fallback writes' do
41
41
  Dir.mktmpdir do |dir|
42
42
  secret = 'ASIAABCDEFGHIJKLMNOP'
43
- path = File.join(dir, 'log')
44
- allow(File).to receive(:open).and_call_original
45
- allow(File).to(receive(:open).with('/tmp/pwn.log', 'a').and_wrap_original { |m, _, mode| m.call(path, mode) })
43
+ allow(Dir).to receive(:home).and_return(dir)
44
+ path = File.join(dir, '.pwn', 'logs', 'pwn.log')
46
45
  PWN::Plugins::Log.append(level: :info, msg: secret, which_self: secret)
47
46
  PWN::Plugins::Log.append(level: :info, msg: { api_key: 'nested-fixture-only' })
48
47
  expect(File.read(path)).not_to include(secret, 'nested-fixture-only')
49
48
  expect(File.read(path)).to include('[REDACTED:aws:')
50
- PWN::Plugins::Log.start_debug(path: path)
51
- PWN::Plugins::Log.progress(msg: secret)
52
- PWN::Plugins::Log.mirror_tui!(msg: secret)
53
- PWN::Plugins::Log.capture_stderr!(text: "#{secret}\n")
49
+ debug_path = File.join(dir, '.pwn', 'logs', 'debug.log')
50
+ PWN::Plugins::Log.start_debug(path: debug_path)
51
+ PWN::Plugins::Log.progress(msg: "progress-sink #{secret}")
52
+ PWN::Plugins::Log.mirror_tui!(msg: "mirror-sink #{secret}")
53
+ PWN::Plugins::Log.capture_stderr!(text: "stderr-sink #{secret}\n")
54
54
  allow(PWN::Plugins::Log).to receive(:progress).and_return(false)
55
- PWN::Plugins::Log.note_interrupt!(where: secret)
55
+ PWN::Plugins::Log.note_interrupt!(where: "fallback-sink #{secret}")
56
56
  PWN::Plugins::Log.stop_debug
57
- expect(File.read(path)).not_to include(secret)
58
- expect(File.read(path)).to include('[REDACTED:aws:')
57
+ body = File.read(debug_path)
58
+ expect(body).not_to include(secret)
59
+ %w[progress-sink mirror-sink stderr-sink fallback-sink].each do |sink|
60
+ expect(body).to include("#{sink} [REDACTED:aws:")
61
+ end
59
62
  ensure
60
63
  PWN::Plugins::Log.stop_debug
61
64
  end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'timeout'
5
+ require File.expand_path('../../../etc/default_cron/session_cleanup', __dir__)
6
+
7
+ RSpec.describe PWN::SessionCleanup, 'concurrent maintenance' do
8
+ include_context 'pwn tmp sandbox'
9
+
10
+ before do
11
+ allow(Dir).to receive(:home).and_return(File.join(@tmp, 'home'))
12
+ FileUtils.mkdir_p(File.join(Dir.home, '.pwn'))
13
+ stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(Dir.home, '.pwn', 'learning.jsonl'))
14
+ @processes = File.join(@tmp, 'processes')
15
+ FileUtils.mkdir_p(@processes)
16
+ @children = []
17
+ end
18
+
19
+ after do
20
+ @children.each do |pid, input, output|
21
+ input.close unless input.closed?
22
+ output.close unless output.closed?
23
+ Timeout.timeout(5) { Process.wait(pid) }
24
+ rescue Timeout::Error
25
+ Process.kill('KILL', pid)
26
+ Process.wait(pid)
27
+ rescue Errno::ECHILD
28
+ nil
29
+ end
30
+ end
31
+
32
+ def worker(&block)
33
+ reader, output = IO.pipe
34
+ input, writer = IO.pipe
35
+ pid = fork do
36
+ reader.close
37
+ writer.close
38
+ @children.each do |_, old_input, old_output|
39
+ old_input.close
40
+ old_output.close
41
+ end
42
+ $0 = 'pwn-ai'
43
+ block.call(input, output)
44
+ exit! 0
45
+ rescue Exception => e # rubocop:disable Lint/RescueException -- communicate child assertion failures
46
+ output.puts("ERROR: #{e.class}: #{e.message}")
47
+ exit! 1
48
+ end
49
+ input.close
50
+ output.close
51
+ @children << [pid, writer, reader]
52
+ File.symlink("/proc/#{pid}", File.join(@processes, pid.to_s))
53
+ [writer, reader]
54
+ end
55
+
56
+ def message(reader)
57
+ raise 'Child did not respond before deadline' unless reader.wait_readable(10)
58
+
59
+ reader.gets&.strip
60
+ end
61
+
62
+ def maintain
63
+ Timeout.timeout(5) { described_class.maintain(proc_root: @processes) }
64
+ end
65
+
66
+ it 'retains two real active clients, deletes unrelated work and revisits released evidence' do
67
+ ids = 3.times.map { PWN::Sessions.create[:id] }
68
+ learning = PWN::AI::Agent::Learning
69
+ File.write(learning::LEARNING_FILE, "#{JSON.generate(session_id: ids.first, lesson: 'résumé 日本語')}\n")
70
+ clients = ids.first(2).map do |id|
71
+ worker do |input, output|
72
+ lease = PWN::Sessions.activate(session_id: id)
73
+ output.puts('ready')
74
+ input.gets
75
+ PWN::Sessions.append(session_id: id, role: 'assistant', content: 'final 日本語 evidence')
76
+ PWN::Sessions.deactivate(lease: lease)
77
+ output.puts('released')
78
+ input.gets
79
+ end
80
+ end
81
+ clients.each { |client| expect(message(client.last)).to eq('ready') }
82
+ expect(described_class.legacy_owners(@processes)).to eq([])
83
+ first = maintain
84
+ expect(first[:deleted_ids]).to eq([ids.last])
85
+ expect(first[:evidence_migration][:deferred_active_sessions]).to eq(1)
86
+ expect(JSON.parse(File.read(learning::LEARNING_FILE))).not_to have_key('evidence_ref')
87
+ ids.first(2).each { |id| expect(File).to exist(File.join(PWN::Sessions.sessions_dir, "#{id}.jsonl")) }
88
+ clients.each do |input, output|
89
+ input.puts('release')
90
+ expect(message(output)).to eq('released')
91
+ end
92
+ second = maintain
93
+ expect(second[:deleted_ids]).to match_array(ids.first(2))
94
+ expect(learning.session_evidence(session_id: ids.first).last[:content]).to eq('final 日本語 evidence')
95
+ expect(second[:evidence_migration][:migrated_rows]).to eq(1)
96
+ end
97
+
98
+ it 'defers promptly behind a real learning writer and retries without losing its write' do
99
+ id = PWN::Sessions.create[:id]
100
+ learning = PWN::AI::Agent::Learning
101
+ input, output = worker do |commands, replies|
102
+ PWN::Sessions.send(:register_activity_owner)
103
+ lock = learning.send(:learning_lock)
104
+ replies.puts('locked')
105
+ commands.gets
106
+ File.write(learning::LEARNING_FILE, "#{JSON.generate(session_id: id, lesson: 'preserved 日本語')}\n")
107
+ learning.send(:learning_unlock, lock: lock)
108
+ replies.puts('written')
109
+ commands.gets
110
+ end
111
+ expect(message(output)).to eq('locked')
112
+ result = maintain
113
+ expect(result[:evidence_migration]).to include(deferred: 'learning_writer_active', migrated_rows: 0)
114
+ expect(result[:apply_blocked]).to be(true)
115
+ expect(File).to exist(File.join(PWN::Sessions.sessions_dir, "#{id}.jsonl"))
116
+ input.puts('write')
117
+ expect(message(output)).to eq('written')
118
+ expect(maintain[:deleted_ids]).to eq([id])
119
+ expect(JSON.parse(File.read(learning::LEARNING_FILE))['lesson']).to eq('preserved 日本語')
120
+ expect(learning.session_evidence(session_id: id)).not_to be_empty
121
+ end
122
+
123
+ it 'retains a real provider-blocked title worker without delaying unrelated cleanup' do
124
+ id = PWN::Sessions.create[:id]
125
+ other = PWN::Sessions.create[:id]
126
+ PWN::Sessions.append(session_id: id, role: 'user', content: 'Title fixture topic')
127
+ input, output = worker do |commands, replies|
128
+ allow(PWN::AI::Router).to receive(:summarize) do
129
+ replies.puts('provider entered')
130
+ commands.gets
131
+ JSON.generate(title: 'Fixture conversation summary', summary: 'Offline provider fixture summary')
132
+ end
133
+ PWN::Sessions.refresh_title(session_id: id)
134
+ replies.puts('titled')
135
+ commands.gets
136
+ end
137
+ expect(message(output)).to eq('provider entered')
138
+ expect(described_class.legacy_owners(@processes)).to eq([])
139
+ expect(maintain[:deleted_ids]).to eq([other])
140
+ input.puts('finish provider')
141
+ expect(message(output)).to eq('titled')
142
+ expect(PWN::Sessions.title_status(session_id: id)[:is_summarized]).to be(true)
143
+ expect(maintain[:deleted_ids]).to eq([])
144
+ end
145
+
146
+ it 'serializes two real outcome writers with repeated maintenance and preserves every row' do
147
+ learning = PWN::AI::Agent::Learning
148
+ clients = 2.times.map do |index|
149
+ worker do |commands, replies|
150
+ PWN::Sessions.send(:register_activity_owner)
151
+ replies.puts('ready')
152
+ commands.gets
153
+ 5.times { |turn| learning.note_outcome(task: "concurrent fixture #{index}/#{turn}", success: true) }
154
+ replies.puts('written')
155
+ commands.gets
156
+ end
157
+ end
158
+ clients.each { |client| expect(message(client.last)).to eq('ready') }
159
+ clients.each { |client| client.first.puts('start') } # rubocop:disable Style/CombinableLoops -- release only after both children are ready
160
+ 3.times { maintain }
161
+ clients.each { |client| expect(message(client.last)).to eq('written') }
162
+ maintain
163
+ rows = File.readlines(learning::LEARNING_FILE).map { |line| JSON.parse(line) }
164
+ expect(rows.length).to eq(10)
165
+ expect(rows.map { |row| row.fetch('task') }.uniq.length).to eq(10)
166
+ end
167
+
168
+ it 'admits only one real maintenance owner and leaves a retry possible' do
169
+ id = PWN::Sessions.create[:id]
170
+ input, output = worker do |commands, replies|
171
+ PWN::Sessions.send(:register_activity_owner)
172
+ described_class.send(:with_maintenance_lock, dry_run: false) do
173
+ replies.puts('locked')
174
+ commands.gets
175
+ end
176
+ replies.puts('released')
177
+ commands.gets
178
+ end
179
+ expect(message(output)).to eq('locked')
180
+ expect { maintain }.to raise_error(IOError, /maintenance already running/)
181
+ expect(File).to exist(File.join(PWN::Sessions.sessions_dir, "#{id}.jsonl"))
182
+ input.puts('release')
183
+ expect(message(output)).to eq('released')
184
+ expect(maintain[:deleted_ids]).to eq([id])
185
+ end
186
+
187
+ it 'does not mistake a genuinely untracked real client for a cooperating owner' do
188
+ id = PWN::Sessions.create[:id]
189
+ _, output = worker do |commands, replies|
190
+ PWN::Sessions.instance_variable_get(:@activity_owners)&.each_value { |file| file.close unless file.closed? }
191
+ replies.puts('legacy')
192
+ commands.gets
193
+ end
194
+ expect(message(output)).to eq('legacy')
195
+ expect(described_class.legacy_owners(@processes)).to include(hash_including(reason: 'legacy_session_ownership_unknown'))
196
+ result = maintain
197
+ expect(result[:apply_blocked]).to be(true)
198
+ expect(result[:deleted_ids]).to eq([])
199
+ expect(File).to exist(File.join(PWN::Sessions.sessions_dir, "#{id}.jsonl"))
200
+ end
201
+ end