pwn 0.5.781 → 0.5.783
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/documentation/Installation.md +33 -9
- data/documentation/diagrams/dot/extrospection-world-awareness.dot +37 -92
- data/documentation/diagrams/dot/memory-skills-detailed.dot +33 -98
- data/documentation/diagrams/dot/overall-pwn-architecture.dot +54 -32
- data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +53 -30
- data/documentation/diagrams/dot/reinforcement-learning.dot +46 -111
- data/documentation/diagrams/dot/swarm-multi-agent.dot +36 -61
- data/documentation/diagrams/extrospection-world-awareness.svg +139 -510
- data/documentation/diagrams/memory-skills-detailed.svg +126 -475
- data/documentation/diagrams/overall-pwn-architecture.svg +116 -266
- data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +158 -244
- data/documentation/diagrams/reinforcement-learning.svg +238 -529
- data/documentation/diagrams/swarm-multi-agent.svg +104 -233
- data/documentation/pwn-REPL.md +31 -3
- data/documentation/pwn-ai-Agent.md +62 -2
- data/etc/default_cron/session_cleanup.rb +335 -42
- data/lib/pwn/ai/agent/curriculum.rb +2 -2
- data/lib/pwn/ai/agent/learning.rb +11 -4
- data/lib/pwn/ai/agent/mistakes.rb +22 -3
- data/lib/pwn/ai/agent/tools/sessions.rb +6 -6
- data/lib/pwn/ai/http_retry.rb +3 -0
- data/lib/pwn/ai/router.rb +3 -1
- data/lib/pwn/cron.rb +1 -1
- data/lib/pwn/memory.rb +62 -6
- data/lib/pwn/plugins/repl/ai.rb +13 -3
- data/lib/pwn/plugins/repl/ai_console.rb +37 -21
- data/lib/pwn/session_trace.rb +1 -1
- data/lib/pwn/sessions.rb +305 -80
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/learning_spec.rb +65 -2
- data/spec/lib/pwn/ai/agent/swarm_spec.rb +3 -3
- data/spec/lib/pwn/memory_spec.rb +58 -1
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +191 -6
- data/spec/lib/pwn/plugins/repl/ai_result_chronology_spec.rb +2 -2
- data/spec/lib/pwn/plugins/repl/ai_spec.rb +28 -11
- data/spec/lib/pwn/session_cleanup_default_spec.rb +2 -2
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +467 -36
- data/spec/lib/pwn/sessions_spec.rb +116 -16
- data/third_party/pwn_rdoc.jsonl +12 -0
- metadata +1 -1
|
@@ -5,6 +5,34 @@ require 'tmpdir'
|
|
|
5
5
|
require 'fileutils'
|
|
6
6
|
|
|
7
7
|
describe PWN::AI::Agent::Learning do
|
|
8
|
+
describe 'worker lock ownership' do
|
|
9
|
+
include_context 'pwn tmp sandbox'
|
|
10
|
+
|
|
11
|
+
it 'does not inherit learning write ownership through copied request locals' do
|
|
12
|
+
lock = described_class.send(:learning_lock)
|
|
13
|
+
inherited = Thread.current[:pwn_learning_write_lock]
|
|
14
|
+
entered = Queue.new
|
|
15
|
+
acquired = Queue.new
|
|
16
|
+
worker = Thread.new do
|
|
17
|
+
Thread.current[:pwn_learning_write_lock] = inherited
|
|
18
|
+
entered << true
|
|
19
|
+
child_lock = described_class.send(:learning_lock)
|
|
20
|
+
acquired << child_lock
|
|
21
|
+
ensure
|
|
22
|
+
described_class.send(:learning_unlock, lock: child_lock) if child_lock
|
|
23
|
+
end
|
|
24
|
+
Timeout.timeout(2) { entered.pop }
|
|
25
|
+
expect(worker.join(0.05)).to be_nil
|
|
26
|
+
described_class.send(:learning_unlock, lock: lock)
|
|
27
|
+
lock = nil
|
|
28
|
+
expect(worker.join(2)).to eq(worker)
|
|
29
|
+
expect(acquired.pop).not_to be_nil
|
|
30
|
+
ensure
|
|
31
|
+
described_class.send(:learning_unlock, lock: lock) if lock
|
|
32
|
+
worker&.join(2)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
8
36
|
describe 'failure regression tasks' do
|
|
9
37
|
include_context 'pwn tmp sandbox'
|
|
10
38
|
|
|
@@ -47,6 +75,41 @@ describe PWN::AI::Agent::Learning do
|
|
|
47
75
|
|
|
48
76
|
after { FileUtils.rm_rf(@home) }
|
|
49
77
|
|
|
78
|
+
it 'migrates Unicode evidence byte-exactly and rolls back the original index' do
|
|
79
|
+
@trace.last[:content] = "café 日本語 🔒 e\u0301"
|
|
80
|
+
File.binwrite(@source, "#{@trace.map(&:to_json).join("\n")}\r\n")
|
|
81
|
+
original_source = File.binread(@source)
|
|
82
|
+
@original = "#{JSON.generate(session_id: @sid, task: 'résumé 日本語')}\r\n"
|
|
83
|
+
File.binwrite(@learning, @original)
|
|
84
|
+
expect(PWN::Sessions.dependency_index[:dependencies][@sid]).to include(@learning)
|
|
85
|
+
result = described_class.migrate_evidence(dry_run: false)
|
|
86
|
+
expect(result[:migrated_rows]).to eq(1)
|
|
87
|
+
row = JSON.parse(File.binread(@learning), symbolize_names: true)
|
|
88
|
+
expect(described_class.evidence_transcript(row: row)).to eq(@trace)
|
|
89
|
+
expect(described_class.evidence_complete?(row: row)).to be(true)
|
|
90
|
+
expect(File.binread(@source)).to eq(original_source)
|
|
91
|
+
migration = Digest::SHA256.hexdigest(@original)
|
|
92
|
+
backup = File.join(@root, 'learning', 'migrations', "#{migration}.backup.jsonl")
|
|
93
|
+
expect(File.binread(backup)).to eq(@original.b)
|
|
94
|
+
expect(described_class.rollback_evidence(migration: migration)[:restored]).to be(true)
|
|
95
|
+
expect(File.binread(@learning)).to eq(@original.b)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'verifies exact bytes without changing input encoding or accepting altered readback' do
|
|
99
|
+
target = File.join(@root, 'learning', 'probe.bin')
|
|
100
|
+
['ASCII', 'café 日本語 🔒', "e\u0301", "\xFF\x00".b].each do |body|
|
|
101
|
+
encoding = body.encoding
|
|
102
|
+
original = body.dup
|
|
103
|
+
described_class.send(:evidence_write, path: target, body: body)
|
|
104
|
+
expect(File.binread(target)).to eq(original.b)
|
|
105
|
+
expect(body).to eq(original)
|
|
106
|
+
expect(body.encoding).to eq(encoding)
|
|
107
|
+
end
|
|
108
|
+
allow(described_class).to receive(:evidence_read).with(path: target).and_return('altered'.b)
|
|
109
|
+
expect { described_class.send(:evidence_write, path: target, body: 'café') }
|
|
110
|
+
.to raise_error(IOError, 'Evidence write verification failed')
|
|
111
|
+
end
|
|
112
|
+
|
|
50
113
|
it 'keeps original references when artifact creation is interrupted and retries safely' do
|
|
51
114
|
allow(described_class).to receive(:evidence_write).and_wrap_original do |method, opts|
|
|
52
115
|
raise IOError, 'injected interruption' if opts[:path].include?('/evidence/')
|
|
@@ -82,7 +145,7 @@ describe PWN::AI::Agent::Learning do
|
|
|
82
145
|
File.open(@source, 'a') { |file| file.puts(JSON.generate(role: 'user', content: 'new turn')) }
|
|
83
146
|
expect(described_class.evidence_complete?(row: row)).to be(false)
|
|
84
147
|
File.delete(artifact)
|
|
85
|
-
expect(PWN::Sessions.dependency_index[:
|
|
148
|
+
expect(PWN::Sessions.dependency_index[:session_errors][@sid]).to include(@learning)
|
|
86
149
|
expect(PWN::Sessions.protected_session_ids(hot_days: -1)).to include(@sid)
|
|
87
150
|
expect(PWN::Sessions.load(session_id: @sid)).not_to be_empty
|
|
88
151
|
end
|
|
@@ -249,7 +312,7 @@ describe PWN::AI::Agent::Learning do
|
|
|
249
312
|
expect(described_class.migrate_evidence(dry_run: false)[:migrated_rows]).to eq(0)
|
|
250
313
|
artifact = File.join(root, 'learning', 'evidence', "#{row[:evidence_ref][:sha256]}.json")
|
|
251
314
|
File.write(artifact, '{}')
|
|
252
|
-
expect(PWN::Sessions.dependency_index[:
|
|
315
|
+
expect(PWN::Sessions.dependency_index[:session_errors]).to have_key(sid)
|
|
253
316
|
expect { PWN::Sessions.load(session_id: sid) }.to raise_error(IOError)
|
|
254
317
|
end
|
|
255
318
|
end
|
|
@@ -233,9 +233,9 @@ describe PWN::AI::Agent::Swarm do
|
|
|
233
233
|
text_only: true
|
|
234
234
|
)
|
|
235
235
|
expect(PWN::Sessions.sessions_dir).to eq(File.join(@swarm_home, 'sessions'))
|
|
236
|
-
expect(PWN::Sessions.list.length).to eq(1)
|
|
237
|
-
expect(PWN::Sessions.active?(session_id: PWN::Sessions.list.first[:id])).to be(false)
|
|
238
|
-
expect(PWN::Sessions.list.map { |session| session[:path] }).to all(start_with(File.join(@swarm_home, 'sessions', '')))
|
|
236
|
+
expect(PWN::Sessions.list(include_hidden: true).length).to eq(1)
|
|
237
|
+
expect(PWN::Sessions.active?(session_id: PWN::Sessions.list(include_hidden: true).first[:id])).to be(false)
|
|
238
|
+
expect(PWN::Sessions.list(include_hidden: true).map { |session| session[:path] }).to all(start_with(File.join(@swarm_home, 'sessions', '')))
|
|
239
239
|
expect(described_class.honesty_unmet).to eq([])
|
|
240
240
|
expect(Array(Thread.current[:pwn_swarm_honesty])).to eq([])
|
|
241
241
|
expect(Thread.current[:pwn_swarm_id]).to be_nil
|
data/spec/lib/pwn/memory_spec.rb
CHANGED
|
@@ -4,7 +4,64 @@ require 'spec_helper'
|
|
|
4
4
|
require 'tmpdir'
|
|
5
5
|
require 'fileutils'
|
|
6
6
|
|
|
7
|
-
describe PWN::Memory do
|
|
7
|
+
RSpec.describe PWN::Memory do
|
|
8
|
+
describe 'store transactions' do
|
|
9
|
+
include_context 'pwn tmp sandbox'
|
|
10
|
+
|
|
11
|
+
it 'serializes concurrent read modify write and allows nested methods' do
|
|
12
|
+
threads = 4.times.map do |worker|
|
|
13
|
+
Thread.new { 10.times { |index| described_class.remember(key: "worker_#{worker}_#{index}", value: index) } }
|
|
14
|
+
end
|
|
15
|
+
threads.each(&:value)
|
|
16
|
+
expect(described_class.load.keys.grep(/worker_/).size).to eq(40)
|
|
17
|
+
described_class.transaction do
|
|
18
|
+
described_class.forget(key: 'worker_0_0')
|
|
19
|
+
described_class.lean!
|
|
20
|
+
end
|
|
21
|
+
expect(described_class.load.keys.map(&:to_s)).not_to include('worker_0_0')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'serializes mistake writers in separate processes on the permanent inode' do
|
|
25
|
+
children = 3.times.map do |worker|
|
|
26
|
+
fork do
|
|
27
|
+
8.times do |index|
|
|
28
|
+
PWN::AI::Agent::Mistakes.record(tool: "fixture_#{worker}_#{index}", error: 'fixture failure')
|
|
29
|
+
end
|
|
30
|
+
exit! 0
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
children.each { |pid| expect(Process.wait2(pid).last.exitstatus).to eq(0) }
|
|
34
|
+
expect(PWN::AI::Agent::Mistakes.load.size).to eq(24)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'keeps external Learning memory rewrites inside a transaction' do
|
|
38
|
+
described_class.remember(key: :fixture, value: 'retained')
|
|
39
|
+
allow(described_class).to receive(:save).and_wrap_original do |original, options|
|
|
40
|
+
expect(described_class.transaction_owned?).to be(true)
|
|
41
|
+
original.call(options)
|
|
42
|
+
end
|
|
43
|
+
allow(PWN::MemoryIndex).to receive(:available?).and_return(false)
|
|
44
|
+
PWN::AI::Agent::Learning.consolidate
|
|
45
|
+
PWN::AI::Agent::Learning.purge_noise
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'does not expire a refreshed key from an old snapshot' do
|
|
49
|
+
described_class.remember(key: :refresh, value: 'old')
|
|
50
|
+
stale = described_class.load
|
|
51
|
+
described_class.remember(key: :refresh, value: 'new')
|
|
52
|
+
described_class.send(:archive_expired!, rows: stale)
|
|
53
|
+
expect(described_class.load[:refresh][:value]).to eq('new')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'refuses corrupt mistake input instead of overwriting it' do
|
|
57
|
+
path = PWN::AI::Agent::Mistakes::MISTAKES_FILE
|
|
58
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
59
|
+
File.write(path, '{corrupt')
|
|
60
|
+
expect { PWN::AI::Agent::Mistakes.record(tool: 'fixture', error: 'failure') }.to raise_error(JSON::ParserError)
|
|
61
|
+
expect(File.read(path)).to eq('{corrupt')
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
8
65
|
it 'should display information for authors' do
|
|
9
66
|
authors_response = PWN::Memory
|
|
10
67
|
expect(authors_response).to respond_to :authors
|
|
@@ -9,6 +9,7 @@ describe 'operator workspace navigation' do
|
|
|
9
9
|
|
|
10
10
|
before do
|
|
11
11
|
allow(Dir).to receive(:home).and_return(@tmp)
|
|
12
|
+
FileUtils.mkdir_p(File.join(@tmp, '.pwn'))
|
|
12
13
|
stub_const('PWN::AI::Agent::SkillReview::LEDGER', File.join(@tmp, 'skill_review.jsonl'))
|
|
13
14
|
stub_const('PWN::AI::Agent::OpenGoal::GOAL_FILE', File.join(@tmp, 'open_goal.json'))
|
|
14
15
|
allow(Pry).to receive(:history).and_return(Pry::History.new(file_path: File.join(@tmp, 'pwn_history')))
|
|
@@ -249,24 +250,162 @@ describe 'operator workspace navigation' do
|
|
|
249
250
|
text = console.send(:operator_content).join
|
|
250
251
|
expect(text).to include('ruby check.rb', 'observed failure', 'tested-revision', 'cancelled')
|
|
251
252
|
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
describe 'session maintenance command workers' do
|
|
256
|
+
include_context 'pwn tmp sandbox'
|
|
257
|
+
|
|
258
|
+
before do
|
|
259
|
+
allow(Dir).to receive(:home).and_return(@tmp)
|
|
260
|
+
FileUtils.mkdir_p(File.join(@tmp, '.pwn'))
|
|
261
|
+
allow(Pry).to receive(:history).and_return(Pry::History.new(file_path: File.join(@tmp, 'pwn_history')))
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
let(:pry) { Pry.new }
|
|
265
|
+
let(:console) { PWN::Plugins::REPL::AIConsole::Console.new(pry: pry, input: StringIO.new, curses: nil, getch: nil) }
|
|
266
|
+
|
|
267
|
+
def open_view(line)
|
|
268
|
+
console.submit(line)
|
|
269
|
+
Timeout.timeout(5) do
|
|
270
|
+
while console.instance_variable_get(:@operator_view)&.dig(:loading)
|
|
271
|
+
console.instance_variable_get(:@operator_worker)&.join(0.01)
|
|
272
|
+
console.send(:drain)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
after { console.send(:stop_worker) }
|
|
278
|
+
|
|
279
|
+
it 'routes cleanup off the input owner and delivers completion after safe cancellation' do
|
|
280
|
+
require File.expand_path('../../../../../etc/default_cron/session_cleanup', __dir__)
|
|
281
|
+
entered = Queue.new
|
|
282
|
+
release = Queue.new
|
|
283
|
+
owner = Thread.current
|
|
284
|
+
reader, writer = IO.pipe
|
|
285
|
+
console.instance_variable_set(:@input, reader)
|
|
286
|
+
console.instance_variable_set(:@tool_input, writer)
|
|
287
|
+
allow(PWN::SessionCleanup).to receive(:maintain) do |**options|
|
|
288
|
+
entered << [Thread.current, options]
|
|
289
|
+
release.pop
|
|
290
|
+
{ fixture: 'committed deletion' }
|
|
291
|
+
end
|
|
292
|
+
allow(PWN::SessionCleanup).to receive(:summary) { |result| result }
|
|
293
|
+
allow(PWN::Plugins::REPL).to receive(:pwn_ai_print_result) do |value:|
|
|
294
|
+
console.instance_variable_get(:@events) << [:command, value.inspect]
|
|
295
|
+
end
|
|
296
|
+
Timeout.timeout(2) { console.submit('/sessions cleanup') }
|
|
297
|
+
thread, options = Timeout.timeout(2) { entered.pop }
|
|
298
|
+
expect(thread).not_to eq(owner)
|
|
299
|
+
expect(options[:dry_run]).to be(false)
|
|
300
|
+
console.handle("\u0014")
|
|
301
|
+
console.handle(:page_up)
|
|
302
|
+
console.submit('/sessions cleanup')
|
|
303
|
+
expect(PWN::SessionCleanup).to have_received(:maintain).once
|
|
304
|
+
console.handle("\u0003")
|
|
305
|
+
expect(thread).to be_alive
|
|
306
|
+
release << true
|
|
307
|
+
expect(thread.join(2)).to eq(thread)
|
|
308
|
+
console.send(:drain)
|
|
309
|
+
expect(console.send(:busy?)).to be_falsey
|
|
310
|
+
text = console.instance_variable_get(:@timeline).map { |row| row[1] }.join
|
|
311
|
+
expect(text).to include('committed deletion', 'not undone')
|
|
312
|
+
expect(text).not_to include('Request cancelled')
|
|
313
|
+
ensure
|
|
314
|
+
release << true if release
|
|
315
|
+
console.instance_variable_get(:@worker)&.join(2)
|
|
316
|
+
reader&.close
|
|
317
|
+
console.instance_variable_get(:@tool_input)&.close
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
it 'keeps navigation and input alive through the real maintenance learning lock and queues errors' do
|
|
321
|
+
require File.expand_path('../../../../../etc/default_cron/session_cleanup', __dir__)
|
|
322
|
+
allow(PWN::SessionCleanup).to receive(:legacy_owners).and_return([])
|
|
323
|
+
stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(@tmp, '.pwn', 'learning.jsonl'))
|
|
324
|
+
sid = PWN::Sessions.create[:id]
|
|
325
|
+
reader, writer = IO.pipe
|
|
326
|
+
console.instance_variable_set(:@input, reader)
|
|
327
|
+
console.instance_variable_set(:@tool_input, writer)
|
|
328
|
+
events = console.instance_variable_get(:@events)
|
|
329
|
+
output = PWN::Plugins::REPL::AIConsole::EventIO.new(events)
|
|
330
|
+
allow(PWN::Plugins::REPL).to receive(:pwn_ai_print_result).and_wrap_original do |method, **options|
|
|
331
|
+
method.call(options.merge(output: output))
|
|
332
|
+
end
|
|
333
|
+
lock = PWN::AI::Agent::Learning.send(:learning_lock)
|
|
334
|
+
entered = Queue.new
|
|
335
|
+
allow(PWN::AI::Agent::Learning).to receive(:migrate_evidence).and_wrap_original do |method, **options|
|
|
336
|
+
entered << true
|
|
337
|
+
method.call(options)
|
|
338
|
+
end
|
|
339
|
+
Timeout.timeout(2) { console.submit('/sessions cleanup') }
|
|
340
|
+
Timeout.timeout(2) { entered.pop }
|
|
341
|
+
worker = console.instance_variable_get(:@worker)
|
|
342
|
+
expect(worker.join(0.05)).to be_nil
|
|
343
|
+
console.submit('/input fixture')
|
|
344
|
+
expect(reader.read_nonblock(8)).to eq("fixture\n")
|
|
345
|
+
console.handle("\u0014")
|
|
346
|
+
console.handle(:page_up)
|
|
347
|
+
console.handle("\u0003")
|
|
348
|
+
expect(File).to exist(File.join(PWN::Sessions.sessions_dir, "#{sid}.jsonl"))
|
|
349
|
+
PWN::AI::Agent::Learning.send(:learning_unlock, lock: lock)
|
|
350
|
+
lock = nil
|
|
351
|
+
expect(worker.join(5)).to eq(worker)
|
|
352
|
+
console.send(:drain)
|
|
353
|
+
expect(File).not_to exist(File.join(PWN::Sessions.sessions_dir, "#{sid}.jsonl"))
|
|
354
|
+
expect(console.instance_variable_get(:@timeline).select { |row| row[0] == :command }.map { |row| row[1] }.join).to include('deleted: 1')
|
|
355
|
+
allow(PWN::SessionCleanup).to receive(:maintain).and_raise(IOError, 'fixture maintenance failed')
|
|
356
|
+
console.submit('/sessions cleanup')
|
|
357
|
+
expect(console.instance_variable_get(:@worker).join(2)).not_to be_nil
|
|
358
|
+
console.send(:drain)
|
|
359
|
+
expect(console.instance_variable_get(:@timeline).map { |row| row[1] }.join).to include('IOError: fixture maintenance failed')
|
|
360
|
+
expect(console.send(:busy?)).to be_falsey
|
|
361
|
+
ensure
|
|
362
|
+
PWN::AI::Agent::Learning.send(:learning_unlock, lock: lock) if lock
|
|
363
|
+
console.instance_variable_get(:@worker)&.join(5)
|
|
364
|
+
reader&.close
|
|
365
|
+
console.instance_variable_get(:@tool_input)&.close
|
|
366
|
+
end
|
|
252
367
|
|
|
253
368
|
it 'previews cleanup and applies only unchanged explicitly approved candidates' do
|
|
254
369
|
require File.expand_path('../../../../../etc/default_cron/session_cleanup', __dir__)
|
|
255
370
|
allow(PWN::SessionCleanup).to receive(:legacy_owners).and_return([])
|
|
256
371
|
old = PWN::Sessions.create[:id]
|
|
257
|
-
open_view('/sessions cleanup')
|
|
372
|
+
open_view('/sessions cleanup --dry-run')
|
|
258
373
|
expect(console.send(:operator_content).join).to include(old)
|
|
259
374
|
late = PWN::Sessions.create[:id]
|
|
260
375
|
console.handle('a')
|
|
261
376
|
expect(File).to exist(File.join(PWN::Sessions.sessions_dir, "#{old}.jsonl"))
|
|
262
|
-
|
|
377
|
+
reader, writer = IO.pipe
|
|
378
|
+
console.instance_variable_set(:@input, reader)
|
|
379
|
+
console.instance_variable_set(:@tool_input, writer)
|
|
380
|
+
entered = Queue.new
|
|
381
|
+
release = Queue.new
|
|
382
|
+
allow(PWN::SessionCleanup).to receive(:run).and_wrap_original do |method, **options|
|
|
383
|
+
entered << true
|
|
384
|
+
release.pop
|
|
385
|
+
method.call(**options)
|
|
386
|
+
end
|
|
387
|
+
Timeout.timeout(2) { console.handle("\r") }
|
|
388
|
+
Timeout.timeout(2) { entered.pop }
|
|
389
|
+
console.handle("\e")
|
|
390
|
+
console.handle("\u0003")
|
|
391
|
+
release << true
|
|
392
|
+
expect(console.instance_variable_get(:@worker).join(5)).not_to be_nil
|
|
393
|
+
console.send(:drain)
|
|
263
394
|
expect(File).not_to exist(File.join(PWN::Sessions.sessions_dir, "#{old}.jsonl"))
|
|
264
395
|
expect(File).to exist(File.join(PWN::Sessions.sessions_dir, "#{late}.jsonl"))
|
|
396
|
+
ensure
|
|
397
|
+
release << true if release
|
|
398
|
+
console.instance_variable_get(:@worker)&.join(5)
|
|
399
|
+
reader&.close
|
|
400
|
+
console.instance_variable_get(:@tool_input)&.close
|
|
265
401
|
end
|
|
266
402
|
|
|
267
403
|
it 'searches sessions by stable ID and resumes the selected ID without changing a draft' do
|
|
268
404
|
first = PWN::Sessions.create(title: 'Duplicate title')[:id]
|
|
269
405
|
second = PWN::Sessions.create(title: 'Duplicate title')[:id]
|
|
406
|
+
[first, second].each do |id|
|
|
407
|
+
File.write(File.join(PWN::Sessions.sessions_dir, "#{id}.meta.json"), JSON.generate(title: 'Duplicate title', title_source: 'summary', summary: 'Fixture summary', summary_turns: 2))
|
|
408
|
+
end
|
|
270
409
|
pry.config.pwn_ai_session_id = first
|
|
271
410
|
editor = console.instance_variable_get(:@editor)
|
|
272
411
|
editor.replace('unfinished draft')
|
|
@@ -287,6 +426,8 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
287
426
|
include_context 'pwn tmp sandbox'
|
|
288
427
|
|
|
289
428
|
before do
|
|
429
|
+
allow(Dir).to receive(:home).and_return(@tmp)
|
|
430
|
+
FileUtils.mkdir_p(File.join(@tmp, '.pwn'))
|
|
290
431
|
@history_path = File.join(@tmp, 'pwn_history')
|
|
291
432
|
@request_history = Pry::History.new(file_path: @history_path)
|
|
292
433
|
allow(Pry).to receive(:history).and_return(@request_history)
|
|
@@ -373,6 +514,40 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
373
514
|
FileUtils.rm_rf(workspace) if workspace
|
|
374
515
|
end
|
|
375
516
|
|
|
517
|
+
it 'coalesces title jobs per session and never drops a different pending session' do
|
|
518
|
+
ids = 3.times.map { PWN::Sessions.create[:id] }
|
|
519
|
+
ids.each_with_index { |sid, index| PWN::Sessions.append(session_id: sid, role: 'user', content: "Session topic #{index}") }
|
|
520
|
+
reached = Queue.new
|
|
521
|
+
release = Queue.new
|
|
522
|
+
calls = []
|
|
523
|
+
allow(PWN::AI::Router).to receive(:summarize) do |opts|
|
|
524
|
+
calls << opts[:text]
|
|
525
|
+
if calls.length == 1
|
|
526
|
+
reached << true
|
|
527
|
+
release.pop
|
|
528
|
+
end
|
|
529
|
+
JSON.generate(title: "Conversation #{calls.length}", summary: opts[:text])
|
|
530
|
+
end
|
|
531
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
532
|
+
console.send(:queue_title_update, ids[0])
|
|
533
|
+
Timeout.timeout(5) { reached.pop }
|
|
534
|
+
console.send(:queue_title_update, ids[1])
|
|
535
|
+
console.send(:queue_title_update, ids[2])
|
|
536
|
+
console.send(:queue_title_update, ids[1])
|
|
537
|
+
release << true
|
|
538
|
+
Timeout.timeout(5) do
|
|
539
|
+
while console.instance_variable_get(:@title_worker)
|
|
540
|
+
console.send(:drain)
|
|
541
|
+
Thread.pass
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
expect(calls.length).to eq(3)
|
|
545
|
+
ids.each { |sid| expect(PWN::Sessions.title_status(session_id: sid)[:is_summarized]).to be(true) }
|
|
546
|
+
ensure
|
|
547
|
+
release << true if release
|
|
548
|
+
console&.instance_variable_get(:@title_worker)&.kill
|
|
549
|
+
end
|
|
550
|
+
|
|
376
551
|
it 'paints provisional and asynchronously updated summary titles in the actual SESSION pane' do
|
|
377
552
|
reached = Queue.new
|
|
378
553
|
release = Queue.new
|
|
@@ -390,18 +565,27 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
390
565
|
expect(PWN::Sessions.metadata(session_id: opts[:session_id])[:title]).to eq('Investigate login and repair all expired certificates')
|
|
391
566
|
PWN::Sessions.append(session_id: opts[:session_id], content: opts[:request])
|
|
392
567
|
PWN::Sessions.append(session_id: opts[:session_id], role: 'assistant', content: 'Certificate repaired')
|
|
568
|
+
PWN::Env[:ai] = { active: :ollama, ollama: { model: 'later-model', base_uri: 'http://fixture.invalid' } }
|
|
393
569
|
'Certificate repaired'
|
|
394
570
|
end
|
|
395
571
|
submitted = false
|
|
396
572
|
unblocked = false
|
|
397
|
-
|
|
573
|
+
listed = false
|
|
574
|
+
keys = 'Investigate login and repair all expired certificates today'.chars
|
|
575
|
+
launch(keys) do
|
|
398
576
|
unless submitted
|
|
399
577
|
submitted = true
|
|
400
578
|
@pry.config.pwn_ai_session_id = PWN::Sessions.create[:id]
|
|
401
579
|
next "\r"
|
|
402
580
|
end
|
|
403
|
-
if !reached.empty? && !
|
|
581
|
+
if !reached.empty? && !listed
|
|
582
|
+
listed = true
|
|
583
|
+
keys.concat("/sessions list\n".chars)
|
|
584
|
+
end
|
|
585
|
+
if listed && @rendered.map(&:last).join.include?('active: true') && !unblocked
|
|
404
586
|
expect(@paint.join).to include('SESSION Investigate login and repair all expired certificates')
|
|
587
|
+
expect(@rendered.map(&:last).join).to include(@pry.config.pwn_ai_session_id, 'title: Investigate login and repair all expired certificates')
|
|
588
|
+
expect(PWN::Sessions.title_status(session_id: @pry.config.pwn_ai_session_id)[:is_summarized]).to be(false)
|
|
405
589
|
unblocked = true
|
|
406
590
|
release << true
|
|
407
591
|
end
|
|
@@ -416,7 +600,8 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
416
600
|
end
|
|
417
601
|
|
|
418
602
|
it 'resumes the displayed title after async renaming through curses with surrounding whitespace and restores history' do
|
|
419
|
-
sid = PWN::Sessions.create(id: '
|
|
603
|
+
sid = PWN::Sessions.create(id: '20200101_010203_abcdef12', title: 'Café "login" repair')[:id]
|
|
604
|
+
File.write(File.join(PWN::Sessions.sessions_dir, "#{sid}.meta.json"), JSON.generate(title: 'Café "login" repair', title_source: 'summary', summary: 'Fixture summary', summary_turns: 1))
|
|
420
605
|
PWN::Sessions.append(session_id: sid, role: 'user', content: 'Prior fixture request')
|
|
421
606
|
PWN::Sessions.append(session_id: sid, role: 'assistant', content: 'Prior fixture answer')
|
|
422
607
|
requested = false
|
|
@@ -2518,7 +2703,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
2518
2703
|
it 'keeps network slash commands off the event thread so tool prompts can receive explicit input' do
|
|
2519
2704
|
entered = Queue.new
|
|
2520
2705
|
expect(PWN::AI::Agent::Loop).not_to receive(:run)
|
|
2521
|
-
expect(PWN::Plugins::REPL).to receive(:pwn_ai_dispatch_slash!).with(request: '/mcp call fixture', pry: anything) do
|
|
2706
|
+
expect(PWN::Plugins::REPL).to receive(:pwn_ai_dispatch_slash!).with(request: '/mcp call fixture', pry: anything, raise_errors: true) do
|
|
2522
2707
|
entered << true
|
|
2523
2708
|
puts "local command received: #{$stdin.gets}"
|
|
2524
2709
|
true
|
|
@@ -158,7 +158,7 @@ describe 'menu chronology routing' do
|
|
|
158
158
|
let(:records) { [{ id: 'new-record', timestamp: 10 }, { id: 'old-record', timestamp: 0 }] }
|
|
159
159
|
|
|
160
160
|
{
|
|
161
|
-
'/sessions list' => ['PWN::Sessions', :
|
|
161
|
+
'/sessions list' => ['PWN::Sessions', :catalog, :nested],
|
|
162
162
|
'/cron list' => ['PWN::Cron', :list, :map],
|
|
163
163
|
'/cron create hourly fixture' => ['PWN::Cron', :create, :nested],
|
|
164
164
|
'/cron run fixture' => ['PWN::Cron', :run, :json],
|
|
@@ -176,7 +176,7 @@ describe 'menu chronology routing' do
|
|
|
176
176
|
when :nested then { results: records }
|
|
177
177
|
when :json then JSON.generate(results: records)
|
|
178
178
|
end
|
|
179
|
-
result = [{ id: 'new-record', mtime: 10 }, { id: 'old-record', mtime: 0 }] if line == '/sessions list'
|
|
179
|
+
result = { sessions: [{ id: 'new-record', mtime: 10 }, { id: 'old-record', mtime: 0 }] } if line == '/sessions list'
|
|
180
180
|
original = Marshal.dump(result)
|
|
181
181
|
allow(Object.const_get(namespace)).to receive(method).and_return(result)
|
|
182
182
|
allow(PWN::AI::MCP).to receive(:backends).and_return([{ name: 'fixture' }]) if namespace == 'PWN::AI::MCP'
|
|
@@ -93,14 +93,15 @@ describe 'menu command result routes' do
|
|
|
93
93
|
end
|
|
94
94
|
|
|
95
95
|
it 'resumes by exact Unicode title or id through plural commands and lists both fields' do
|
|
96
|
-
session = PWN::Sessions.create(id: '
|
|
97
|
-
|
|
96
|
+
session = PWN::Sessions.create(id: '20200101_010203_abcdef13', title: 'Café "login" repair')
|
|
97
|
+
File.write(File.join(PWN::Sessions.sessions_dir, '20200101_010203_abcdef13.meta.json'), JSON.generate(title: 'Café "login" repair', title_source: 'summary', summary: 'Reviewed the login repair.', summary_turns: 1))
|
|
98
|
+
['/sessions resume Café "login" repair', '/sessions resume \'Café "login" repair\'', '/sessions resume 20200101_010203_abcdef13', ' /sessions resume Café "login" repair '].each do |line|
|
|
98
99
|
pry.config.pwn_ai_session_id = 'before'
|
|
99
100
|
expect(PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: line, pry: pry)).to be(true)
|
|
100
101
|
expect(pry.config.pwn_ai_session_id).to eq(session[:id])
|
|
101
102
|
end
|
|
102
103
|
['/sessions list'].each do |line|
|
|
103
|
-
expect { PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: line, pry: pry) }.to output(a_string_including('id:
|
|
104
|
+
expect { PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: line, pry: pry) }.to output(a_string_including('id: 20200101_010203_abcdef13', 'title: Café "login" repair')).to_stdout
|
|
104
105
|
end
|
|
105
106
|
end
|
|
106
107
|
|
|
@@ -118,14 +119,14 @@ describe 'menu command result routes' do
|
|
|
118
119
|
|
|
119
120
|
it 'lists and resumes the same seven-word legacy title with stable id fallback on collisions' do
|
|
120
121
|
title = 'One two three four five six seven'
|
|
121
|
-
PWN::Sessions.create(id: '
|
|
122
|
-
File.write(File.join(PWN::Sessions.sessions_dir, '
|
|
122
|
+
PWN::Sessions.create(id: '20200101_010203_abcdef14')
|
|
123
|
+
File.write(File.join(PWN::Sessions.sessions_dir, '20200101_010203_abcdef14.meta.json'), JSON.generate(title: "#{title} eight nine", title_source: 'summary', summary: 'Summarized the prior conversation.', summary_turns: 1))
|
|
123
124
|
expect { PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: '/sessions list', pry: pry) }.to output(a_string_including("title: #{title}\n")).to_stdout
|
|
124
125
|
PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: "/sessions resume '#{title}'", pry: pry)
|
|
125
|
-
expect(pry.config.pwn_ai_session_id).to eq('
|
|
126
|
+
expect(pry.config.pwn_ai_session_id).to eq('20200101_010203_abcdef14')
|
|
126
127
|
PWN::Sessions.create(id: 'capped_duplicate', title: "#{title} ten")
|
|
127
|
-
expect { PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: "/sessions resume #{title}", pry: pry) }.to output(a_string_including('NOTICE', 'Ambiguous', '
|
|
128
|
-
expect(pry.config.pwn_ai_session_id).to eq('
|
|
128
|
+
expect { PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: "/sessions resume #{title}", pry: pry) }.to output(a_string_including('NOTICE', 'Ambiguous', '20200101_010203_abcdef14', 'capped_duplicate', '/sessions resume <ID>')).to_stdout
|
|
129
|
+
expect(pry.config.pwn_ai_session_id).to eq('20200101_010203_abcdef14')
|
|
129
130
|
PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: '/sessions resume capped_duplicate', pry: pry)
|
|
130
131
|
expect(pry.config.pwn_ai_session_id).to eq('capped_duplicate')
|
|
131
132
|
end
|
|
@@ -139,11 +140,27 @@ describe 'menu command result routes' do
|
|
|
139
140
|
expect(pry.config.pwn_ai_session_id).to eq('original')
|
|
140
141
|
end
|
|
141
142
|
|
|
143
|
+
it 'keeps the current provisional session in both command routes without summary provenance' do
|
|
144
|
+
allow(Dir).to receive(:home).and_return(@tmp)
|
|
145
|
+
sid = PWN::Sessions.create[:id]
|
|
146
|
+
PWN::Sessions.provisional_title(session_id: sid, request: 'Investigate certificate chain')
|
|
147
|
+
pry.config.pwn_ai_session_id = sid
|
|
148
|
+
expect { PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: '/sessions list', pry: pry) }.to output(a_string_including("id: #{sid}", 'active: true', 'title: Investigate certificate chain')).to_stdout
|
|
149
|
+
PWN::Plugins::REPL::AI.add_commands
|
|
150
|
+
allow(Pry).to receive(:history).and_return(Pry::History.new(file_path: File.join(@tmp, 'active_history')))
|
|
151
|
+
output = StringIO.new
|
|
152
|
+
legacy = Pry.new(output: output, quiet: true)
|
|
153
|
+
legacy.config.pwn_ai_session_id = sid
|
|
154
|
+
legacy.eval('pwn-ai-sessions list')
|
|
155
|
+
expect(output.string).to include("id: #{sid}", 'active: true', 'title: Investigate certificate chain')
|
|
156
|
+
expect(PWN::Sessions.title_status(session_id: sid)[:is_summarized]).to be(false)
|
|
157
|
+
end
|
|
158
|
+
|
|
142
159
|
it 'preserves the configured Pry output stream for legacy command aliases' do
|
|
143
160
|
PWN::Plugins::REPL::AI.add_commands
|
|
144
161
|
allow(Pry).to receive(:history).and_return(Pry::History.new(file_path: File.join(@tmp, 'alias_history')))
|
|
145
162
|
allow(PWN::Memory).to receive(:recall).and_return(record)
|
|
146
|
-
allow(PWN::Sessions).to receive_messages(
|
|
163
|
+
allow(PWN::Sessions).to receive_messages(catalog: record, stats: record)
|
|
147
164
|
allow(PWN::Cron).to receive_messages(list: record, run: record)
|
|
148
165
|
['pwn-ai-memory list', 'pwn-ai-memory recall fixture', 'pwn-ai-sessions list', 'pwn-ai-sessions stats', 'pwn-ai-cron list', 'pwn-ai-cron run fixture'].each do |line|
|
|
149
166
|
output = StringIO.new
|
|
@@ -166,8 +183,8 @@ describe 'menu command result routes' do
|
|
|
166
183
|
'/cron' => ['PWN::Cron', :list],
|
|
167
184
|
'/cron list' => ['PWN::Cron', :list],
|
|
168
185
|
'/cron run fixture' => ['PWN::Cron', :run],
|
|
169
|
-
'/sessions' => ['PWN::Sessions', :
|
|
170
|
-
'/sessions list' => ['PWN::Sessions', :
|
|
186
|
+
'/sessions' => ['PWN::Sessions', :catalog],
|
|
187
|
+
'/sessions list' => ['PWN::Sessions', :catalog],
|
|
171
188
|
'/sessions stats' => ['PWN::Sessions', :stats],
|
|
172
189
|
'/memory' => ['PWN::Memory', :recall],
|
|
173
190
|
'/memory list' => ['PWN::Memory', :recall],
|
|
@@ -40,7 +40,7 @@ RSpec.describe 'Bundled session cleanup' do
|
|
|
40
40
|
bytes = File.read(PWN::Cron.jobs_file)
|
|
41
41
|
expect(PWN::Cron).not_to receive(:install_native_job)
|
|
42
42
|
PWN::Cron.install_defaults
|
|
43
|
-
expect(File.read(helper)).to include('# PWN managed default: session_cleanup
|
|
43
|
+
expect(File.read(helper)).to include('# PWN managed default: session_cleanup v9')
|
|
44
44
|
expect(File.read(PWN::Cron.jobs_file)).to eq(bytes)
|
|
45
45
|
end
|
|
46
46
|
|
|
@@ -67,6 +67,6 @@ RSpec.describe 'Bundled session cleanup' do
|
|
|
67
67
|
RbConfig.ruby, '-I', File.expand_path('../../../lib', __dir__), helper, '--dry-run'
|
|
68
68
|
)
|
|
69
69
|
expect(status.success?).to be(true), error
|
|
70
|
-
expect(JSON.parse(output)).to include('dry_run' => true, 'scanned' => 0, '
|
|
70
|
+
expect(JSON.parse(output)).to include('dry_run' => true, 'scanned' => 0, 'deleted' => 0)
|
|
71
71
|
end
|
|
72
72
|
end
|