pwn 0.5.782 → 0.5.783

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,7 @@ RSpec.describe PWN::SessionCleanup do
14
14
  before do
15
15
  allow(Dir).to receive(:home).and_return(File.join(@tmp, 'home'))
16
16
  FileUtils.mkdir_p(File.join(Dir.home, '.pwn'))
17
+ stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(Dir.home, '.pwn', 'learning.jsonl'))
17
18
  PWN::Sessions.create(id: id)
18
19
  end
19
20
 
@@ -21,6 +22,340 @@ RSpec.describe PWN::SessionCleanup do
21
22
  File.write(meta_path, JSON.generate({ id: id, title: title, source: 'pwn-ai' }.merge(value)))
22
23
  end
23
24
 
25
+ describe 'reader-based historical retention' do
26
+ it 'keeps incomplete provenance records and reusable swarm associations, including retired registry names' do
27
+ allow(described_class).to receive(:legacy_owners).and_return([])
28
+ root = File.join(Dir.home, '.pwn')
29
+ swarm = PWN::AI::Agent::Swarm
30
+ stub_const('PWN::AI::Agent::Swarm::SWARM_ROOT', File.join(root, 'swarm'))
31
+ swarm_id = swarm.create[:swarm_id]
32
+ swarm.spawn(name: 'fixture', role: 'fixture', swarm_id: swarm_id)
33
+ mapping = File.join(root, 'swarm', swarm_id, 'personas.json')
34
+ File.write(mapping, JSON.generate(fixture: id))
35
+ swarm.retire(name: 'fixture', swarm_id: swarm_id, local: true)
36
+ # Retire edits only agents.yml: persona_session still intentionally reuses
37
+ # this mapping for an explicit persona or a later respawn. Age is not exit.
38
+ stores = {
39
+ 'mistakes.json' => { incomplete: { sessions: [id] } },
40
+ 'memory.json' => { "reflect_#{id}_abcdef12" => { category: 'lesson' } },
41
+ 'extrospection.json' => { observations: [{ source: 'auto_extrospect', tags: ['drift', id] }] },
42
+ 'curriculum/log.jsonl' => { event: 'practice', data: [{ runs: [{ session_id: id }] }] }
43
+ }
44
+ stores.each do |name, row|
45
+ FileUtils.mkdir_p(File.dirname(File.join(root, name)))
46
+ File.write(File.join(root, name), JSON.generate(row))
47
+ end
48
+ expected = stores.keys.map { |name| File.join(root, name) } + [mapping]
49
+ expect(PWN::Sessions.dependency_index[:dependencies][id]).to match_array(expected)
50
+ expect(described_class.run(dry_run: false)[:deleted_ids]).to be_empty
51
+ expect(File.exist?(path)).to be(true)
52
+ expect(swarm.send(:persona_session, swarm_id: swarm_id, name: 'fixture')).to eq(id)
53
+ end
54
+
55
+ it 'preserves reflection values, drift observations and practice results without retaining their provenance transcripts' do
56
+ allow(described_class).to receive(:legacy_owners).and_return([])
57
+ root = File.join(Dir.home, '.pwn')
58
+ stub_const('PWN::Memory::MEMORY_FILE', File.join(root, 'memory.json'))
59
+ stub_const('PWN::AI::Agent::Extrospection::EXTRO_FILE', File.join(root, 'extrospection.json'))
60
+ stub_const('PWN::AI::Agent::Curriculum::CURRICULUM_DIR', File.join(root, 'curriculum'))
61
+ PWN::Memory.remember(key: "reflect_#{id}_abcdef12", value: 'Use verified results café', category: :lesson, source: :reflect)
62
+ PWN::AI::Agent::Extrospection.observe(source: 'auto_extrospect', category: :env, data: 'Host changed café', tags: ['drift', id])
63
+ run = { session_id: id, prompt: 'fixture', final: nil, trace: nil, score: nil, verdict: 'unknown', training_score: nil }
64
+ PWN::AI::Agent::Curriculum.send(:log, event: :practice, data: [{ signature: 'fixture', tool: 'shell', prompts: ['fixture'], resolved: false, runs: [run] }])
65
+ stores = %w[memory.json extrospection.json curriculum/log.jsonl].to_h { |name| [File.join(root, name), File.binread(File.join(root, name))] }
66
+ expect(PWN::Sessions.dependency_index[:dependencies].fetch(id, [])).to be_empty
67
+ expect(described_class.run(dry_run: false)[:deleted_ids]).to include(id)
68
+ stores.each { |file, bytes| expect(File.binread(file)).to eq(bytes) }
69
+ expect(PWN::Memory.recall(query: "reflect_#{id}_abcdef12", include_session: false).values.first[:value]).to eq('Use verified results café')
70
+ expect(PWN::AI::Agent::Extrospection.observations.first[:data]).to eq('Host changed café')
71
+ end
72
+
73
+ it 'removes transcripts without altering self-contained mistake history or its readers' do
74
+ allow(described_class).to receive(:legacy_owners).and_return([])
75
+ store = File.join(Dir.home, '.pwn', 'mistakes.json')
76
+ stub_const('PWN::AI::Agent::Mistakes::MISTAKES_FILE', store)
77
+ mistake = PWN::AI::Agent::Mistakes.record(tool: 'fixture', error: 'Unicode failure café', session_id: id)
78
+ PWN::AI::Agent::Mistakes.record(tool: 'unrelated', error: 'unrelated retained failure', session_id: '20200101_010203_abcdef99')
79
+ bytes = File.binread(store)
80
+ expect(PWN::Sessions.dependency_index[:dependencies].fetch(id, [])).not_to include(store)
81
+ expect(PWN::Sessions.protected_session_ids(hot_days: 0)).not_to include(id)
82
+ expect(described_class.run(dry_run: false, current_session_id: id)[:deleted_ids]).to be_empty
83
+ lease = PWN::Sessions.activate(session_id: id)
84
+ begin
85
+ expect(described_class.run(dry_run: false)[:deleted_ids]).to be_empty
86
+ expect(File.exist?(path)).to be(true)
87
+ ensure
88
+ PWN::Sessions.deactivate(lease: lease)
89
+ end
90
+ result = described_class.run(dry_run: false)
91
+ expect(result[:deleted_ids]).to include(id)
92
+ expect(File.exist?(path)).to be(false)
93
+ expect(File.binread(store)).to eq(bytes)
94
+ expect(PWN::AI::Agent::Mistakes.for_tool(tool: 'fixture').first).to eq(mistake)
95
+ end
96
+ end
97
+
98
+ describe 'offline storage layout' do
99
+ it 'preserves externally anchored metadata and legacy trace paths rather than breaking evidence' do
100
+ allow(described_class).to receive(:legacy_owners).and_return([])
101
+ PWN::SessionTrace.append(session_id: id, event: 'request', data: 'fixture')
102
+ trace = File.join(PWN::Sessions.sessions_dir, id, 'trace.jsonl')
103
+ File.write(File.join(Dir.home, '.pwn', 'findings.jsonl'), JSON.generate(session_id: id, evidence_paths: [meta_path, trace]))
104
+ result = described_class.reorganize(dry_run: false, writers_stopped: true)
105
+ expect(File.file?(meta_path)).to be(true)
106
+ expect(File.file?(trace)).to be(true)
107
+ expect(result[:retained_support]).to include(meta_path, trace)
108
+ expect(PWN::SessionTrace.read(session_id: id).length).to eq(1)
109
+ end
110
+
111
+ it 'requires stopped writers and preserves transcripts, metadata and lock inode through migration' do
112
+ allow(described_class).to receive(:legacy_owners).and_return([])
113
+ lock_path = PWN::Sessions.support_path(kind: :lease, session_id: id)
114
+ inode = File.stat(lock_path).ino
115
+ bytes = File.binread(path)
116
+ lease = PWN::Sessions.activate(session_id: id)
117
+ expect { described_class.reorganize(dry_run: false, writers_stopped: true) }.to raise_error(IOError, /active/)
118
+ PWN::Sessions.deactivate(lease: lease)
119
+ expect { described_class.reorganize(dry_run: false) }.to raise_error(ArgumentError, /stopped/)
120
+ result = described_class.reorganize(dry_run: false, writers_stopped: true)
121
+ expect(result[:state]).to eq('committed')
122
+ expect(File.binread(path)).to eq(bytes)
123
+ expect(File.stat(PWN::Sessions.support_path(kind: :lease, session_id: id)).ino).to eq(inode)
124
+ expect(File.exist?(meta_path)).to be(false)
125
+ expect(PWN::Sessions.metadata(session_id: id)[:title]).to eq(title)
126
+ expect(PWN::Sessions.dependency_index[:errors]).to be_empty
127
+ lease = PWN::Sessions.activate(session_id: id)
128
+ expect { described_class.reorganize(dry_run: false, writers_stopped: true) }.to raise_error(IOError, /active/)
129
+ PWN::Sessions.deactivate(lease: lease)
130
+ expect(described_class.run(dry_run: false)[:deleted_ids]).to eq([id])
131
+ ensure
132
+ PWN::Sessions.deactivate(lease: lease)
133
+ end
134
+ end
135
+
136
+ describe 'maintenance writer ownership' do
137
+ it 'admits only one maintenance writer across default and approved entry points' do
138
+ allow(described_class).to receive(:legacy_owners).and_return([])
139
+ entered = Queue.new
140
+ release = Queue.new
141
+ allow(PWN::AI::Agent::Learning).to receive(:migrate_evidence).and_wrap_original do |method, **options|
142
+ unless options[:dry_run]
143
+ entered << true
144
+ release.pop
145
+ end
146
+ method.call(**options)
147
+ end
148
+ worker = Thread.new { described_class.maintain }
149
+ Timeout.timeout(2) { entered.pop }
150
+ expect { described_class.run(dry_run: false, approved_candidates: {}) }.to raise_error(IOError, /maintenance already running/)
151
+ read, write = IO.pipe
152
+ child = fork do
153
+ read.close
154
+ begin
155
+ described_class.run(dry_run: false, approved_candidates: {})
156
+ write.write('unexpected writer')
157
+ rescue IOError => e
158
+ write.write(e.message)
159
+ ensure
160
+ write.close
161
+ exit! 0
162
+ end
163
+ end
164
+ write.close
165
+ expect(Timeout.timeout(2) { read.read }).to include('maintenance already running')
166
+ Process.wait(child)
167
+ read.close
168
+ expect(described_class.maintain(dry_run: true)[:dry_run]).to be(true)
169
+ release << true
170
+ expect(worker.join(5)).to eq(worker)
171
+ expect(worker.value[:deleted_ids]).to eq([id])
172
+ ensure
173
+ release << true if release
174
+ worker&.join(5)
175
+ end
176
+
177
+ it 'does not wait on a title provider and retains its active session lease' do
178
+ allow(described_class).to receive(:legacy_owners).and_return([])
179
+ PWN::Sessions.append(session_id: id, role: 'user', content: 'Fixture title topic')
180
+ other = PWN::Sessions.create[:id]
181
+ entered = Queue.new
182
+ release = Queue.new
183
+ allow(PWN::AI::Router).to receive(:summarize) do
184
+ entered << true
185
+ release.pop
186
+ JSON.generate(title: 'Fixture topic summary', summary: 'Offline fixture summary')
187
+ end
188
+ worker = Thread.new { PWN::Sessions.refresh_title(session_id: id) }
189
+ Timeout.timeout(2) { entered.pop }
190
+ result = Timeout.timeout(3) { described_class.maintain }
191
+ expect(result[:deleted_ids]).to eq([other])
192
+ expect(result[:records].find { |row| row[:id] == id }[:skip]).to eq(:active)
193
+ release << true
194
+ expect(worker.join(2)).to eq(worker)
195
+ expect(PWN::Sessions.title_status(session_id: id)[:is_summarized]).to be(true)
196
+ ensure
197
+ release << true if release
198
+ worker&.join(2)
199
+ end
200
+ end
201
+ end
202
+
203
+ RSpec.describe PWN::SessionCleanup do
204
+ include_context 'pwn tmp sandbox'
205
+
206
+ let(:id) { '20200101_010203_abcdef12' }
207
+ let(:title) { "pwn-ai session #{id}" }
208
+ let(:path) { File.join(PWN::Sessions.sessions_dir, "#{id}.jsonl") }
209
+ let(:meta_path) { path.sub('.jsonl', '.meta.json') }
210
+
211
+ before do
212
+ allow(Dir).to receive(:home).and_return(File.join(@tmp, 'home'))
213
+ FileUtils.mkdir_p(File.join(Dir.home, '.pwn'))
214
+ stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(Dir.home, '.pwn', 'learning.jsonl'))
215
+ PWN::Sessions.create(id: id)
216
+ end
217
+
218
+ def metadata(value)
219
+ File.write(meta_path, JSON.generate({ id: id, title: title, source: 'pwn-ai' }.merge(value)))
220
+ end
221
+
222
+ it 'runs evidence preservation and deletion through the default maintenance path idempotently' do
223
+ allow(described_class).to receive(:legacy_owners).and_return([])
224
+ PWN::Sessions.append(session_id: id, role: 'user', content: 'offline evidence fixture café')
225
+ PWN::Sessions.append(session_id: id, role: 'tool', content: 'verified tool observation 日本語')
226
+ PWN::Sessions.append(session_id: id, role: 'assistant', content: 'fixture final')
227
+ mistakes = File.join(Dir.home, '.pwn', 'mistakes.json')
228
+ stub_const('PWN::AI::Agent::Mistakes::MISTAKES_FILE', mistakes)
229
+ PWN::AI::Agent::Mistakes.record(tool: 'fixture', error: 'fixture failure', session_id: id)
230
+ mistake_bytes = File.binread(mistakes)
231
+ File.write(PWN::AI::Agent::Learning::LEARNING_FILE, "#{JSON.generate(session_id: id, outcome: 'fixture')}\n")
232
+ original = PWN::Sessions.load(session_id: id)
233
+ plan = described_class.maintain(dry_run: true)
234
+ expect(plan[:evidence_migration][:migratable_sessions]).to eq(1)
235
+ expect(File).to exist(path)
236
+ result = described_class.maintain
237
+ expect(result[:deleted_ids]).to eq([id])
238
+ expect(PWN::Sessions.load(session_id: id)).to eq(original)
239
+ expect(File.binread(mistakes)).to eq(mistake_bytes)
240
+ row = JSON.parse(File.read(PWN::AI::Agent::Learning::LEARNING_FILE), symbolize_names: true)
241
+ expect(PWN::AI::Agent::Learning.evidence_complete?(row: row)).to be(true)
242
+ evidence = File.join(Dir.home, '.pwn', 'learning', 'evidence', "#{row[:evidence_ref][:sha256]}.json")
243
+ expect(Digest::SHA256.file(evidence).hexdigest).to eq(row[:evidence_ref][:sha256])
244
+ PWN::AI::Agent::Reward.send(:annotate_session, session_id: id, rewards: [1])
245
+ expect(PWN::Sessions.load(session_id: id).find { |entry| entry[:role] == 'tool' }[:step_reward]).to eq(1)
246
+ expect(Digest::SHA256.file(evidence).hexdigest).to eq(row[:evidence_ref][:sha256])
247
+ expect(PWN::Sessions.catalog[:sessions]).to eq([])
248
+ expect(described_class.maintain[:deleted_ids]).to eq([])
249
+ expect(result[:maintenance_ledger]).to be_a(String)
250
+ expect(JSON.parse(File.read(result[:maintenance_ledger]))['state']).to eq('committed')
251
+ end
252
+
253
+ it 'validates versioned request checkpoints as provenance rather than transcript dependencies' do
254
+ runtime = PWN::AI::Agent::RequestRuntime.new(request: 'offline fixture', session_id: id, request_id: 'fixture-request')
255
+ index = PWN::Sessions.dependency_index
256
+ expect(index[:errors]).to eq([])
257
+ expect(index[:dependencies].fetch(id, [])).not_to include(runtime.checkpoint_path)
258
+ expect(index[:provenance][id]).to eq([runtime.checkpoint_path])
259
+ data = JSON.parse(File.read(runtime.checkpoint_path))
260
+ data['version'] = 99
261
+ File.write(runtime.checkpoint_path, JSON.generate(data))
262
+ index = PWN::Sessions.dependency_index
263
+ expect(index[:errors]).to include(runtime.checkpoint_path)
264
+ expect(index[:diagnostics].map { |row| row[:code] }).to include('Unsupported request checkpoint version')
265
+ end
266
+
267
+ it 'paginates visible retained sessions and reports global diagnostics only once' do
268
+ ids = [id, '20200101_010203_abcdef13', '20200101_010203_abcdef14']
269
+ ids.drop(1).each { |sid| PWN::Sessions.create(id: sid) }
270
+ File.write(File.join(Dir.home, '.pwn', 'learning.jsonl'), ids.map { |sid| JSON.generate(session_id: sid) }.join("\n"))
271
+ File.write(File.join(Dir.home, '.pwn', 'memory.json'), '{')
272
+ page = PWN::Sessions.catalog(offset: 0, limit: 2)
273
+ expect(page).to include(total: 3, has_more: true)
274
+ expect(page[:sessions].length).to eq(2)
275
+ expect(page[:sessions].flat_map(&:keys)).not_to include(:unresolved_dependency_stores, :dependency_errors)
276
+ expect(page[:unresolved_dependency_stores].length).to eq(1)
277
+ last = PWN::Sessions.catalog(offset: 2, limit: 2)
278
+ expect(last).to include(total: 3, has_more: false)
279
+ expect((page[:sessions] + last[:sessions]).map { |row| row[:id] }).to match_array(ids)
280
+ end
281
+
282
+ it 'shares default command, seeded cron and legacy cron maintenance behavior' do
283
+ allow(described_class).to receive(:legacy_owners).and_return([])
284
+ PWN::Sessions.append(session_id: id, role: 'user', content: 'retained café 日本語 🔒 fixture')
285
+ File.write(PWN::AI::Agent::Learning::LEARNING_FILE, JSON.generate(session_id: id, task: 'résumé'))
286
+ expect { PWN::Plugins::REPL.pwn_ai_run_sessions(args: ['cleanup']) }.to output(/deleted: 1/).to_stdout
287
+ expect(File).not_to exist(path)
288
+ expect(PWN::Sessions.load(session_id: id)).not_to be_empty
289
+ cron_id = PWN::Sessions.create[:id]
290
+ payload = PWN::Cron::DEFAULT_JOBS.find { |job| job[:name] == 'session_cleanup' }[:ruby]
291
+ allow(PWN::Cron).to receive(:cron_dir).and_return(File.expand_path('../../../etc/default_cron', __dir__))
292
+ expect(eval(payload)[:deleted_ids]).to eq([cron_id]) # rubocop:disable Security/Eval -- execute the exact bundled local job
293
+ legacy_id = PWN::Sessions.create[:id]
294
+ expect(described_class.run(dry_run: false)[:deleted_ids]).to eq([legacy_id])
295
+ end
296
+
297
+ it 'does not migrate evidence or delete while legacy ownership is unresolved' do
298
+ allow(described_class).to receive(:legacy_owners).and_return([{ pid: 123, reason: 'legacy_session_ownership_unknown' }])
299
+ expect(PWN::AI::Agent::Learning).to receive(:migrate_evidence).with(dry_run: true).and_call_original
300
+ expect(described_class.maintain[:apply_blocked]).to be(true)
301
+ expect(File).to exist(path)
302
+ end
303
+
304
+ %w[memory.json memory.archive.json mistakes.json].each do |store|
305
+ it "remediates only the attributable invalid map entry in #{store}" do
306
+ allow(described_class).to receive(:legacy_owners).and_return([])
307
+ target = File.join(Dir.home, '.pwn', store)
308
+ key = store == 'mistakes.json' ? 'bad' : "reflect_#{id}_abcdef12"
309
+ invalid = store == 'mistakes.json' ? { sessions: [id, 123] } : 123
310
+ valid = store == 'mistakes.json' ? { sessions: [], description: 'résumé 日本語 🔒' } : { value: 'keep résumé 日本語 🔒', category: 'fact' }
311
+ File.write(target, JSON.generate(key => invalid, 'unrelated' => valid))
312
+ expect(PWN::Sessions.dependency_index[:session_errors].keys).to eq([id])
313
+ expect(PWN::Sessions.list.map { |row| row[:id] }).not_to include(id)
314
+ lease = PWN::Sessions.activate(session_id: id)
315
+ expect(described_class.sweep(dry_run: false)[:deleted_ids]).to eq([])
316
+ PWN::Sessions.deactivate(lease: lease)
317
+ result = described_class.sweep(dry_run: false)
318
+ expect(result[:deleted_ids]).to eq([id])
319
+ expect(JSON.parse(File.read(target))).to eq('unrelated' => JSON.parse(JSON.generate(valid)))
320
+ removal = result[:dependency_removals].first
321
+ expect(removal[:state]).to eq('committed')
322
+ expect(JSON.parse(File.read(removal[:backup]))).to have_key(key)
323
+ ensure
324
+ PWN::Sessions.deactivate(lease: lease)
325
+ end
326
+ end
327
+
328
+ it 'rejects stale map approvals and never removes a refreshed entry' do
329
+ allow(described_class).to receive(:legacy_owners).and_return([])
330
+ target = File.join(Dir.home, '.pwn', 'memory.json')
331
+ key = "reflect_#{id}_abcdef12"
332
+ File.write(target, JSON.generate(key => 123))
333
+ plan = described_class.sweep
334
+ approvals = plan[:records].to_h { |row| [row[:id], row.slice(:files, :dependency_records)] }
335
+ File.write(target, JSON.generate(key => { value: 'refreshed' }))
336
+ expect(described_class.sweep(dry_run: false, approved_candidates: approvals)[:deleted_ids]).to eq([])
337
+ expect(JSON.parse(File.read(target))[key]['value']).to eq('refreshed')
338
+ end
339
+
340
+ it 'protects a session while a cooperative writer can introduce a new dependency' do
341
+ allow(described_class).to receive(:legacy_owners).and_return([])
342
+ target = File.join(Dir.home, '.pwn', 'memory.json')
343
+ PWN::Memory.transaction(path: target) do
344
+ expect(described_class.sweep(dry_run: false)[:deleted_ids]).to eq([])
345
+ expect(File).to exist(path)
346
+ end
347
+ end
348
+
349
+ it 'does not race a cooperating map writer' do
350
+ allow(described_class).to receive(:legacy_owners).and_return([])
351
+ target = File.join(Dir.home, '.pwn', 'memory.json')
352
+ File.write(target, JSON.generate("reflect_#{id}_abcdef12" => 123))
353
+ PWN::Memory.transaction(path: target) do
354
+ expect(described_class.sweep(dry_run: false)[:deleted_ids]).to eq([])
355
+ end
356
+ expect(described_class.sweep(dry_run: false)[:deleted_ids]).to eq([id])
357
+ end
358
+
24
359
  it 'protects only a held session lease and releases it while the owner remains alive' do
25
360
  proc_root = File.join(@tmp, 'processes')
26
361
  FileUtils.mkdir_p(proc_root)
@@ -45,12 +380,12 @@ RSpec.describe PWN::SessionCleanup do
45
380
  expect(ready_r.wait_readable(5)).not_to be_nil
46
381
  expect(ready_r.gets).to eq("ready\n")
47
382
  expect(described_class.legacy_owners(proc_root)).to eq([])
48
- result = described_class.run(dry_run: false, proc_root: proc_root)
383
+ result = described_class.sweep(dry_run: false, proc_root: proc_root)
49
384
  expect(result[:deleted_ids]).to eq([other])
50
385
  expect(result[:records].find { |row| row[:id] == id }[:skip]).to eq(:active)
51
386
  stop_w.puts('release')
52
387
  expect(ready_r.gets).to eq("released\n")
53
- expect(described_class.run(dry_run: false, proc_root: proc_root)[:deleted_ids]).to eq([id])
388
+ expect(described_class.sweep(dry_run: false, proc_root: proc_root)[:deleted_ids]).to eq([id])
54
389
  ensure
55
390
  stop_w&.close
56
391
  ready_r&.close
@@ -80,7 +415,7 @@ RSpec.describe PWN::SessionCleanup do
80
415
  expect(described_class.legacy_owners(proc_root)).to eq([])
81
416
  expect(PWN::Sessions.active?(session_id: id)).to be(true)
82
417
  other = PWN::Sessions.create(id: '20200101_010203_abcdef13')[:id]
83
- expect(described_class.run(dry_run: false, proc_root: proc_root)[:deleted_ids]).to eq([other])
418
+ expect(described_class.sweep(dry_run: false, proc_root: proc_root)[:deleted_ids]).to eq([other])
84
419
  ensure
85
420
  PWN::Sessions.deactivate(lease: lease)
86
421
  stop_w&.close
@@ -131,7 +466,7 @@ RSpec.describe PWN::SessionCleanup do
131
466
  expect(Process.wait2(child).last.exitstatus).to eq(23)
132
467
  original.call(*args)
133
468
  end
134
- expect(described_class.run(dry_run: false)[:deleted_ids]).to eq([id])
469
+ expect(described_class.sweep(dry_run: false)[:deleted_ids]).to eq([id])
135
470
  expect { PWN::Sessions.activate(session_id: id) }.to raise_error(IOError, /not found/)
136
471
  expect { PWN::Sessions.append(session_id: id, content: 'late writer') }.to raise_error(/not found/)
137
472
  expect(File).not_to exist(path)
@@ -194,6 +529,7 @@ RSpec.describe PWN::SessionCleanup do
194
529
  before do
195
530
  allow(Dir).to receive(:home).and_return(File.join(@tmp, 'home'))
196
531
  FileUtils.mkdir_p(File.join(Dir.home, '.pwn'))
532
+ stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(Dir.home, '.pwn', 'learning.jsonl'))
197
533
  PWN::Sessions.create(id: id)
198
534
  end
199
535
 
@@ -201,12 +537,106 @@ RSpec.describe PWN::SessionCleanup do
201
537
  File.write(meta_path, JSON.generate({ id: id, title: title, source: 'pwn-ai' }.merge(value)))
202
538
  end
203
539
 
540
+ it 'hides unretained sessions without attributing global store errors to them' do
541
+ File.write(File.join(Dir.home, '.pwn', 'memory.json'), '{')
542
+ expect(PWN::Sessions.list).to eq([])
543
+ row = PWN::Sessions.list(include_hidden: true).find { |session| session[:id] == id }
544
+ expect(row[:dependency_errors]).to eq([])
545
+ expect(row).not_to have_key(:unresolved_dependency_stores)
546
+ expect(PWN::Sessions.catalog[:unresolved_dependency_stores]).to eq([File.join(Dir.home, '.pwn', 'memory.json')])
547
+ end
548
+
549
+ it 'attributes broken learning evidence to its owner and preserves mixed valid rows' do
550
+ other = PWN::Sessions.create(id: '20200101_010203_abcdef13')[:id]
551
+ store = File.join(Dir.home, '.pwn', 'learning.jsonl')
552
+ good = "#{JSON.generate(session_id: other, score: 1)}\n"
553
+ File.write(store, "#{JSON.generate(session_id: id, evidence_ref: {})}\n#{good}")
554
+ index = PWN::Sessions.dependency_index
555
+ expect(index[:errors]).to eq([])
556
+ expect(index[:session_errors]).to eq(id => [store])
557
+ expect(index[:dependencies]).to eq(other => [store])
558
+ expect(PWN::Sessions.list.map { |row| row[:id] }).to eq([other])
559
+ allow(described_class).to receive(:legacy_owners).and_return([])
560
+ result = described_class.sweep(dry_run: false)
561
+ expect(result[:deleted_ids]).to eq([id])
562
+ expect(File.binread(store)).to eq(good)
563
+ expect(File).not_to exist(path)
564
+ expect(result[:dependency_removals].map { |row| row[:path] }).to eq([store])
565
+ expect(File.binread(result[:dependency_removals].first[:backup])).to include(id, other)
566
+ end
567
+
568
+ it 'uses a shared dependency snapshot for a dry-run inventory' do
569
+ 4.times { |index| PWN::Sessions.create(id: "20200101_010203_abcdef2#{index}") }
570
+ allow(described_class).to receive(:legacy_owners).and_return([])
571
+ expect(PWN::Sessions).to receive(:dependency_index).at_most(3).times.and_call_original
572
+ expect(described_class.sweep[:candidate_ids].length).to eq(5)
573
+ end
574
+
575
+ it 'keeps active erroneous sessions physically intact but hidden' do
576
+ store = File.join(Dir.home, '.pwn', 'learning.jsonl')
577
+ bytes = JSON.generate(session_id: id, evidence_ref: {})
578
+ File.write(store, bytes)
579
+ lease = PWN::Sessions.activate(session_id: id)
580
+ allow(described_class).to receive(:legacy_owners).and_return([])
581
+ expect(PWN::Sessions.list).to eq([])
582
+ result = described_class.sweep(dry_run: false)
583
+ expect(result[:deleted_ids]).to eq([])
584
+ expect(result[:skipped][:active]).to eq(1)
585
+ expect(File.binread(store)).to eq(bytes)
586
+ expect(File).to exist(path)
587
+ ensure
588
+ PWN::Sessions.deactivate(lease: lease)
589
+ end
590
+
591
+ it 'preserves valid map entries while reporting attributable and unresolved remediation separately' do
592
+ other = PWN::Sessions.create(id: '20200101_010203_abcdef13')[:id]
593
+ store = File.join(Dir.home, '.pwn', 'mistakes.json')
594
+ bytes = JSON.generate(bad: { sessions: [id], verified_sessions: {} }, good: { sessions: [other] })
595
+ File.write(store, bytes)
596
+ index = PWN::Sessions.dependency_index
597
+ expect(index[:session_errors]).to eq(id => [store])
598
+ expect(index[:dependencies]).to eq(other => [store])
599
+ expect(PWN::Sessions.list.map { |row| row[:id] }).to eq([other])
600
+ allow(described_class).to receive(:legacy_owners).and_return([])
601
+ result = described_class.sweep(dry_run: false)
602
+ expect(result[:deleted_ids]).to eq([id])
603
+ expect(JSON.parse(File.binread(store))).to eq('good' => { 'sessions' => [other] })
604
+ expect(File.binread(result[:dependency_removals].first[:backup])).to eq(bytes)
605
+ end
606
+
607
+ it 'never guesses ownership for corrupt global bytes and keeps valid summaries visible' do
608
+ metadata(title: 'Valid summary title', title_source: 'summary', summary: 'Conversation summary', summary_turns: 2)
609
+ store = File.join(Dir.home, '.pwn', 'memory.json')
610
+ File.write(store, '{')
611
+ expect(PWN::Sessions.list.map { |row| row[:id] }).to eq([id])
612
+ expect(PWN::Sessions.list.first[:dependency_errors]).to eq([])
613
+ allow(described_class).to receive(:legacy_owners).and_return([])
614
+ result = described_class.sweep(dry_run: false)
615
+ expect(result[:unresolved_store_remediation]).to eq([store])
616
+ expect(result[:deleted_ids]).to eq([])
617
+ expect(File.read(store)).to eq('{')
618
+ end
619
+
620
+ it 'rechecks invalid evidence after backup and does not remove repaired records' do
621
+ store = File.join(Dir.home, '.pwn', 'learning.jsonl')
622
+ File.write(store, JSON.generate(session_id: id, evidence_ref: {}))
623
+ repaired = JSON.generate(session_id: id, score: 1)
624
+ allow(described_class).to receive(:legacy_owners).and_return([])
625
+ allow(described_class).to receive(:backup).and_wrap_original do |original, *args|
626
+ original.call(*args)
627
+ File.write(store, repaired)
628
+ end
629
+ expect(described_class.sweep(dry_run: false)[:deleted_ids]).to eq([])
630
+ expect(File.read(store)).to eq(repaired)
631
+ expect(File).to exist(path)
632
+ end
633
+
204
634
  it 'keeps referenced placeholders visible with the exact dependency paths' do
205
635
  allow(described_class).to receive(:legacy_owners).and_return([])
206
636
  dependency = File.join(Dir.home, '.pwn', 'jobs', '123456abcdef.json')
207
637
  FileUtils.mkdir_p(File.dirname(dependency))
208
638
  File.write(dependency, JSON.generate(session_id: id))
209
- expect(described_class.run(dry_run: false)[:deleted_ids]).to eq([])
639
+ expect(described_class.sweep(dry_run: false)[:deleted_ids]).to eq([])
210
640
  row = PWN::Sessions.list.find { |session| session[:id] == id }
211
641
  expect(row[:dependencies]).to eq([dependency])
212
642
  expect(PWN::Sessions.resolve(identifier: id)).to eq(id)
@@ -222,7 +652,7 @@ RSpec.describe PWN::SessionCleanup do
222
652
  end
223
653
 
224
654
  it 'lists an explicit empty dependency array for unreferenced sessions' do
225
- expect(PWN::Sessions.list.find { |session| session[:id] == id }[:dependencies]).to eq([])
655
+ expect(PWN::Sessions.list(include_hidden: true).find { |session| session[:id] == id }[:dependencies]).to eq([])
226
656
  end
227
657
 
228
658
  it 'fails closed without reading through a symlinked artifact parent' do
@@ -234,21 +664,21 @@ RSpec.describe PWN::SessionCleanup do
234
664
  FileUtils.mkdir_p(root)
235
665
  File.symlink(target, File.join(root, id))
236
666
  expect(PWN::Sessions.dependency_index[:errors]).not_to be_empty
237
- expect(described_class.run(dry_run: false)[:deleted_ids]).to eq([])
667
+ expect(described_class.sweep(dry_run: false)[:deleted_ids]).to eq([])
238
668
  end
239
669
 
240
670
  it 'fails closed for malformed run dependency stores' do
241
671
  dependency = File.join(Dir.home, '.pwn', 'runs', 'fixture', 'state.json')
242
672
  FileUtils.mkdir_p(File.dirname(dependency))
243
673
  File.write(dependency, '{')
244
- expect(described_class.run(dry_run: false)[:deleted_ids]).to eq([])
245
- expect(described_class.run[:skipped][:unknown_pins]).to eq(1)
674
+ expect(described_class.sweep(dry_run: false)[:deleted_ids]).to eq([])
675
+ expect(described_class.sweep[:skipped][:unknown_pins]).to eq(1)
246
676
  end
247
677
 
248
678
  it 'never treats a placeholder as a generated title despite summary markers' do
249
679
  metadata(title_source: 'summary', summary: 'A valid conversation summary', summary_turns: 2)
250
680
  expect(PWN::Sessions.title_status(session_id: id)).to include(classification: :unnamed, is_summarized: false, title_reason: 'placeholder_not_summary')
251
- expect(PWN::Sessions.list.find { |row| row[:id] == id }[:is_summarized]).to be(false)
681
+ expect(PWN::Sessions.list(include_hidden: true).find { |row| row[:id] == id }[:is_summarized]).to be(false)
252
682
  end
253
683
 
254
684
  it 'accepts an exact current placeholder with provisional non-summary metadata' do
@@ -294,7 +724,7 @@ RSpec.describe PWN::SessionCleanup do
294
724
  { title_aliases: ['Human name'] }, { title_history: ['Prior topic'] }
295
725
  ].each do |value|
296
726
  metadata(value)
297
- row = PWN::Sessions.list.find { |session| session[:id] == id }
727
+ row = PWN::Sessions.list(include_hidden: true).find { |session| session[:id] == id }
298
728
  expect(row).to include(is_summarized: false, classification: :unnamed)
299
729
  expect(described_class.classify(path, id)).to eq(row[:classification])
300
730
  end
@@ -348,7 +778,7 @@ RSpec.describe PWN::SessionCleanup do
348
778
  metadata(source: 'pwn-ai-swarm', title: 'swarm:fixture persona:critic')
349
779
  expect(PWN::Sessions.title_status(session_id: id)).to include(classification: :unnamed, title_reason: 'missing_summary_provenance')
350
780
  allow(described_class).to receive(:legacy_owners).and_return([])
351
- expect(described_class.run(dry_run: false)[:deleted_ids]).to eq([id])
781
+ expect(described_class.sweep(dry_run: false)[:deleted_ids]).to eq([id])
352
782
  end
353
783
 
354
784
  it 'recognizes native scalar step rewards without accepting unknown reward shapes' do
@@ -379,12 +809,12 @@ RSpec.describe PWN::SessionCleanup do
379
809
  old = Time.utc(2020)
380
810
  File.utime(old, old, path)
381
811
  allow(described_class).to receive(:legacy_owners).and_return([])
382
- dry = described_class.run
812
+ dry = described_class.sweep
383
813
  expect(dry[:candidate_ids]).to include(id)
384
814
  expect(dry[:deleted_ids]).to eq([])
385
815
  expect(File).to exist(path)
386
816
  expect(PWN::Sessions).to receive(:delete).with(session_id: id, current_session_id: nil, force: true, cleanup_lock: an_instance_of(File)).and_call_original
387
- expect(described_class.run(dry_run: false)[:deleted_ids]).to eq([id])
817
+ expect(described_class.sweep(dry_run: false)[:deleted_ids]).to eq([id])
388
818
  expect(File).not_to exist(path)
389
819
  expect(File).not_to exist(meta_path)
390
820
  end
@@ -392,11 +822,11 @@ RSpec.describe PWN::SessionCleanup do
392
822
  it 'archives and removes hot placeholders without deleting current sessions' do
393
823
  allow(described_class).to receive(:legacy_owners).and_return([])
394
824
  original = File.binread(path)
395
- expect(described_class.run(dry_run: false, current_session_id: id)[:deleted_ids]).to eq([])
396
- dry = described_class.run
825
+ expect(described_class.sweep(dry_run: false, current_session_id: id)[:deleted_ids]).to eq([])
826
+ dry = described_class.sweep
397
827
  expect(dry[:candidate_ids]).to eq([id])
398
828
  expect(Dir.glob(File.join(Dir.home, '.pwn', 'session_cleanup_archive', '**', '*.jsonl'))).to eq([])
399
- result = described_class.run(dry_run: false)
829
+ result = described_class.sweep(dry_run: false)
400
830
  expect(result[:deleted_ids]).to eq([id])
401
831
  archived = File.join(result[:archive_dir], "#{id}.jsonl")
402
832
  expect(File.binread(archived)).to eq(original)
@@ -416,6 +846,7 @@ RSpec.describe PWN::SessionCleanup do
416
846
  before do
417
847
  allow(Dir).to receive(:home).and_return(File.join(@tmp, 'home'))
418
848
  FileUtils.mkdir_p(File.join(Dir.home, '.pwn'))
849
+ stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(Dir.home, '.pwn', 'learning.jsonl'))
419
850
  PWN::Sessions.create(id: id)
420
851
  end
421
852
 
@@ -423,14 +854,14 @@ RSpec.describe PWN::SessionCleanup do
423
854
  allow(described_class).to receive(:legacy_owners).and_return([])
424
855
  old = Time.utc(2020)
425
856
  File.utime(old, old, path)
426
- expect(described_class.run(dry_run: false, current_session_id: id)[:deleted_ids]).to eq([])
857
+ expect(described_class.sweep(dry_run: false, current_session_id: id)[:deleted_ids]).to eq([])
427
858
  allow(described_class).to receive(:legacy_owners).and_return([{ pid: 123, reason: 'legacy_session_ownership_unknown' }])
428
- result = described_class.run(dry_run: false)
859
+ result = described_class.sweep(dry_run: false)
429
860
  expect(result[:skipped][:unknown_legacy_ownership]).to eq(1)
430
861
  expect(File).to exist(path)
431
862
  allow(described_class).to receive(:legacy_owners).and_return([])
432
- allow(described_class).to receive(:referenced_session_ids).and_return([id])
433
- expect(described_class.run(dry_run: false)[:skipped][:referenced]).to eq(1)
863
+ File.write(File.join(Dir.home, '.pwn', 'learning.jsonl'), JSON.generate(session_id: id))
864
+ expect(described_class.sweep(dry_run: false)[:skipped][:referenced]).to eq(1)
434
865
  end
435
866
 
436
867
  it 'ignores shell payload mentions while retaining actual PWN executable processes' do
@@ -469,8 +900,8 @@ RSpec.describe PWN::SessionCleanup do
469
900
  end
470
901
 
471
902
  it 'fails closed on unknown pin stores and unavailable process inventories' do
472
- allow(described_class).to receive(:pins_known?).and_return(false)
473
- expect(described_class.run(dry_run: false)[:skipped][:unknown_pins]).to eq(1)
903
+ File.write(File.join(Dir.home, '.pwn', 'memory.json'), '{')
904
+ expect(described_class.sweep(dry_run: false)[:skipped][:unknown_pins]).to eq(1)
474
905
  expect(described_class.legacy_owners(File.join(@tmp, 'absent'))).not_to be_empty
475
906
  expect(File).to exist(path)
476
907
  end
@@ -482,12 +913,12 @@ RSpec.describe PWN::SessionCleanup do
482
913
  learning = File.join(Dir.home, '.pwn', 'learning.jsonl')
483
914
  mistakes = File.join(Dir.home, '.pwn', 'mistakes.json')
484
915
  File.write(learning, "#{JSON.generate(session_id: id, success: true, score: 1)}\n")
485
- expect(described_class.run(dry_run: false)[:skipped][:referenced]).to eq(1)
916
+ expect(described_class.sweep(dry_run: false)[:skipped][:referenced]).to eq(1)
486
917
  File.unlink(learning)
487
918
  File.write(mistakes, JSON.generate(issue: { sessions: [id], resolved: false }))
488
- expect(described_class.run(dry_run: false)[:skipped][:referenced]).to eq(1)
919
+ expect(described_class.sweep(dry_run: false)[:skipped][:referenced]).to eq(1)
489
920
  File.write(mistakes, '{')
490
- expect(described_class.run(dry_run: false)[:skipped][:unknown_pins]).to eq(1)
921
+ expect(described_class.sweep(dry_run: false)[:skipped][:unknown_pins]).to eq(1)
491
922
  expect(File).to exist(path)
492
923
  end
493
924
 
@@ -503,12 +934,12 @@ RSpec.describe PWN::SessionCleanup do
503
934
  }.each do |name, data|
504
935
  file = File.join(root, name)
505
936
  File.write(file, data)
506
- expect(described_class.run(dry_run: false)[:skipped][:referenced]).to eq(1)
937
+ expect(described_class.sweep(dry_run: false)[:skipped][:referenced]).to eq(1)
507
938
  expect(File).to exist(path)
508
939
  File.unlink(file)
509
940
  end
510
941
  File.write(File.join(root, 'memory.json'), '{')
511
- expect(described_class.run(dry_run: false)[:skipped][:unknown_pins]).to eq(1)
942
+ expect(described_class.sweep(dry_run: false)[:skipped][:unknown_pins]).to eq(1)
512
943
  expect(File).to exist(path)
513
944
  end
514
945
 
@@ -522,7 +953,7 @@ RSpec.describe PWN::SessionCleanup do
522
953
  File.write(meta_path, JSON.generate(title: 'Concurrent human title')) if calls == 2
523
954
  original.call(*args)
524
955
  end
525
- expect(described_class.run(dry_run: false)[:skipped][:changed]).to eq(1)
956
+ expect(described_class.sweep(dry_run: false)[:skipped][:changed]).to eq(1)
526
957
  expect(File).to exist(path)
527
958
  end
528
959
 
@@ -532,7 +963,7 @@ RSpec.describe PWN::SessionCleanup do
532
963
  original.call(*args)
533
964
  File.open(path, 'a') { |file| file.puts(JSON.generate(role: 'user', content: 'concurrent append')) }
534
965
  end
535
- result = described_class.run(dry_run: false)
966
+ result = described_class.sweep(dry_run: false)
536
967
  expect(result[:deleted_ids]).to eq([])
537
968
  expect(result[:skipped][:changed]).to eq(1)
538
969
  expect(File).to exist(path)
@@ -541,13 +972,13 @@ RSpec.describe PWN::SessionCleanup do
541
972
  it 'refuses deletion on an archive failure or newly created evidence reference' do
542
973
  allow(described_class).to receive(:legacy_owners).and_return([])
543
974
  allow(described_class).to receive(:backup).and_raise(IOError)
544
- expect(described_class.run(dry_run: false)[:deleted_ids]).to eq([])
975
+ expect(described_class.sweep(dry_run: false)[:deleted_ids]).to eq([])
545
976
  expect(File).to exist(path)
546
977
  allow(described_class).to receive(:backup).and_wrap_original do |original, *args|
547
978
  original.call(*args)
548
979
  File.write(File.join(Dir.home, '.pwn', 'learning.jsonl'), JSON.generate(session_id: id))
549
980
  end
550
- expect(described_class.run(dry_run: false)[:skipped][:changed]).to eq(1)
981
+ expect(described_class.sweep(dry_run: false)[:skipped][:changed]).to eq(1)
551
982
  expect(File).to exist(path)
552
983
  end
553
984
 
@@ -562,10 +993,10 @@ RSpec.describe PWN::SessionCleanup do
562
993
  end
563
994
  dependency = File.join(Dir.home, '.pwn', 'learning.jsonl')
564
995
  File.write(dependency, JSON.generate(session_id: kept, details: "A prose mention of #{id}"))
565
- dry = described_class.run
996
+ dry = described_class.sweep
566
997
  expect(dry[:candidate_ids]).to eq([id])
567
998
  expect(dry[:deleted_ids]).to eq([])
568
- expect(described_class.run(dry_run: false)[:deleted_ids]).to eq([id])
999
+ expect(described_class.sweep(dry_run: false)[:deleted_ids]).to eq([id])
569
1000
  rows = PWN::Sessions.list
570
1001
  expect(rows.map { |row| row[:id] }).to contain_exactly(kept, '20200101_010203_abcdef14')
571
1002
  expect(rows.find { |row| row[:id] == kept }[:dependencies]).to eq([dependency])
@@ -575,7 +1006,7 @@ RSpec.describe PWN::SessionCleanup do
575
1006
  it 'never puts transcript bodies into its dry run ledger' do
576
1007
  allow(described_class).to receive(:legacy_owners).and_return([])
577
1008
  File.open(path, 'a') { |file| file.puts(JSON.generate(role: 'user', content: 'private body sentinel')) }
578
- result = described_class.run
1009
+ result = described_class.sweep
579
1010
  expect(JSON.generate(result)).not_to include('private body sentinel')
580
1011
  expect(result[:records].first[:files]["#{id}.jsonl"][:sha256]).to eq(Digest::SHA256.file(path).hexdigest)
581
1012
  end
@@ -586,7 +1017,7 @@ RSpec.describe PWN::SessionCleanup do
586
1017
  allow(described_class).to receive(:legacy_owners).and_return([])
587
1018
  File.open(path) do |file|
588
1019
  file.flock(File::LOCK_EX)
589
- expect(described_class.run(dry_run: false)[:skipped][:locked]).to eq(1)
1020
+ expect(described_class.sweep(dry_run: false)[:skipped][:locked]).to eq(1)
590
1021
  end
591
1022
  expect(File).to exist(path)
592
1023
  end