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.
- checksums.yaml +4 -4
- data/Gemfile +3 -3
- data/Rakefile +19 -1
- data/bin/pwn_setup +1 -1
- data/build_gem.sh +1 -1
- data/documentation/Backup-Retention.md +27 -0
- data/documentation/Cron.md +64 -35
- data/documentation/How-PWN-Works.md +1 -1
- data/documentation/Installation.md +247 -6
- data/documentation/Persistence.md +1 -1
- data/documentation/Reinforcement-Learning.md +5 -6
- data/documentation/diagrams/cron-scheduling.svg +130 -128
- data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
- data/etc/default_cron/session_cleanup.rb +1217 -10
- data/lib/pwn/ai/agent/extrospection.rb +6 -7
- data/lib/pwn/ai/agent/learning.rb +48 -7
- data/lib/pwn/ai/agent/policy.rb +5 -0
- data/lib/pwn/ai/agent/swarm.rb +31 -16
- data/lib/pwn/ai/router.rb +45 -2
- data/lib/pwn/cron.rb +120 -49
- data/lib/pwn/migrate.rb +292 -15
- data/lib/pwn/plugins/binary_parser.rb +5 -4
- data/lib/pwn/plugins/ghidra.rb +4 -3
- data/lib/pwn/plugins/log.rb +18 -2
- data/lib/pwn/plugins/repl/ai.rb +12 -1
- data/lib/pwn/plugins/repl/ai_console.rb +6 -2
- data/lib/pwn/plugins/repl/ai_swarm.rb +4 -4
- data/lib/pwn/sessions.rb +58 -16
- data/lib/pwn/setup.rb +9 -17
- data/lib/pwn/version.rb +1 -1
- data/scripts/build_gate.rb +58 -0
- data/spec/integration/build_gate_spec.rb +73 -0
- data/spec/integration/persistence_roundtrip_spec.rb +1 -7
- data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
- data/spec/lib/pwn/ai/router_spec.rb +30 -2
- data/spec/lib/pwn/cron_spec.rb +113 -6
- data/spec/lib/pwn/migrate_spec.rb +160 -5
- data/spec/lib/pwn/plugins/log_spec.rb +7 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +16 -4
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +11 -0
- data/spec/lib/pwn/redaction_spec.rb +13 -10
- data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +165 -2
- data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +389 -0
- data/spec/lib/pwn/sessions_spec.rb +112 -0
- data/spec/lib/pwn/setup_spec.rb +3 -1
- data/spec/support/gate_failure_formatter.rb +14 -0
- data/third_party/pwn_rdoc.jsonl +15 -1
- 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 '
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
51
|
-
PWN::Plugins::Log.
|
|
52
|
-
PWN::Plugins::Log.
|
|
53
|
-
PWN::Plugins::Log.
|
|
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
|
-
|
|
58
|
-
expect(
|
|
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
|