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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# PWN managed default: session_cleanup
|
|
3
|
+
# PWN managed default: session_cleanup v9
|
|
4
4
|
# No provider calls or broad store GC. Unsummarized candidates are backed up first.
|
|
5
5
|
require 'pwn'
|
|
6
6
|
require 'json'
|
|
@@ -12,7 +12,7 @@ require 'securerandom'
|
|
|
12
12
|
module PWN
|
|
13
13
|
# Provenance-based session cleanup, shared by seeded cron payloads.
|
|
14
14
|
module SessionCleanup
|
|
15
|
-
VERSION =
|
|
15
|
+
VERSION = 9
|
|
16
16
|
|
|
17
17
|
def self.classify(path, id)
|
|
18
18
|
return :unknown unless path == File.join(PWN::Sessions.sessions_dir, "#{id}.jsonl")
|
|
@@ -100,7 +100,7 @@ module PWN
|
|
|
100
100
|
|
|
101
101
|
# Fingerprints include inode identity and nanosecond timestamps, not bodies.
|
|
102
102
|
def self.snapshot(path)
|
|
103
|
-
[path,
|
|
103
|
+
[path, PWN::Sessions.support_path(kind: :metadata, session_id: File.basename(path, '.jsonl'))].to_h do |file|
|
|
104
104
|
raise 'Refusing symlink' if File.symlink?(file)
|
|
105
105
|
next [File.basename(file), nil] unless File.exist?(file)
|
|
106
106
|
|
|
@@ -138,7 +138,7 @@ module PWN
|
|
|
138
138
|
expected.each do |name, record|
|
|
139
139
|
next unless record
|
|
140
140
|
|
|
141
|
-
source = File.join(File.dirname(path), name)
|
|
141
|
+
source = name.end_with?('.meta.json') ? PWN::Sessions.support_path(kind: :metadata, session_id: File.basename(name, '.meta.json')) : File.join(File.dirname(path), name)
|
|
142
142
|
bytes = File.binread(source)
|
|
143
143
|
raise 'Changed before backup' unless Digest::SHA256.hexdigest(bytes) == record[:sha256]
|
|
144
144
|
|
|
@@ -149,7 +149,278 @@ module PWN
|
|
|
149
149
|
publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result))
|
|
150
150
|
end
|
|
151
151
|
|
|
152
|
+
# Only stores with a cooperating permanent writer lock are auto-remediated.
|
|
153
|
+
# Other attributed errors remain explicit operator work, never broad GC.
|
|
154
|
+
def self.invalid_dependencies(id, references = nil)
|
|
155
|
+
(references || PWN::Sessions.dependency_index)[:invalid_records].select { |row| row[:owners].include?(id) }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.remediable?(id, references = nil)
|
|
159
|
+
rows = invalid_dependencies(id, references)
|
|
160
|
+
!rows.empty? && rows.all? do |row|
|
|
161
|
+
relative = row[:path].delete_prefix("#{File.join(Dir.home, '.pwn')}/")
|
|
162
|
+
supported = (relative == 'learning.jsonl' && row[:locator].is_a?(Integer)) ||
|
|
163
|
+
(%w[memory.json memory.archive.json mistakes.json].include?(relative) && row[:locator].is_a?(String))
|
|
164
|
+
row[:owners] == [id] && supported
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def self.cleanup_state(path, id, references = nil)
|
|
169
|
+
state = classify(path, id)
|
|
170
|
+
return state if state == :unknown
|
|
171
|
+
|
|
172
|
+
status = PWN::Sessions.retention_status(session_id: id, references: references)
|
|
173
|
+
return :invalid_dependency unless status[:dependency_errors].empty?
|
|
174
|
+
|
|
175
|
+
state
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def self.with_dependency_lock(id)
|
|
179
|
+
rows = invalid_dependencies(id)
|
|
180
|
+
raise 'Unresolved dependency remediation' unless rows.empty? || remediable?(id)
|
|
181
|
+
|
|
182
|
+
# Lock even absent stores: a concurrent writer can introduce the first
|
|
183
|
+
# reference between the final index and unlink. These inodes are permanent.
|
|
184
|
+
root = File.join(Dir.home, '.pwn')
|
|
185
|
+
paths = %w[.learning-write.lock memory.json.write.lock mistakes.json.write.lock].map { |name| File.join(root, name) }.sort
|
|
186
|
+
locks = []
|
|
187
|
+
begin
|
|
188
|
+
paths.each do |path|
|
|
189
|
+
lock = File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen -- closed together in ensure
|
|
190
|
+
locks << lock
|
|
191
|
+
raise 'Dependency writer active' unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
192
|
+
end
|
|
193
|
+
yield
|
|
194
|
+
ensure
|
|
195
|
+
locks.reverse_each(&:close)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def self.remove_invalid_dependencies(id, result)
|
|
200
|
+
rows = invalid_dependencies(id)
|
|
201
|
+
return if rows.empty?
|
|
202
|
+
|
|
203
|
+
raise 'Dependency remediation changed' unless remediable?(id)
|
|
204
|
+
|
|
205
|
+
rows.group_by { |row| row[:path] }.each do |path, entries|
|
|
206
|
+
bytes = File.binread(path)
|
|
207
|
+
hash = Digest::SHA256.hexdigest(bytes)
|
|
208
|
+
raise 'Dependency changed' unless entries.all? { |row| row[:sha256] == hash }
|
|
209
|
+
|
|
210
|
+
backup_path = File.join(result[:archive_dir], "dependency-#{SecureRandom.hex(8)}.jsonl")
|
|
211
|
+
publish(backup_path, bytes)
|
|
212
|
+
raise 'Dependency backup failed' unless Digest::SHA256.file(backup_path).hexdigest == hash
|
|
213
|
+
|
|
214
|
+
locators = entries.map { |row| row[:locator] }
|
|
215
|
+
updated = if path.end_with?('.jsonl')
|
|
216
|
+
bytes.lines.each_with_index.reject { |_, index| locators.include?(index) }.map(&:first).join
|
|
217
|
+
else
|
|
218
|
+
map = JSON.parse(bytes)
|
|
219
|
+
raise 'Dependency map changed' unless map.is_a?(Hash) && locators.all? { |key| map.key?(key) }
|
|
220
|
+
|
|
221
|
+
locators.each { |key| map.delete(key) }
|
|
222
|
+
JSON.pretty_generate(map)
|
|
223
|
+
end
|
|
224
|
+
removal = { session_id: id, path: path, backup: backup_path, sha256: hash,
|
|
225
|
+
updated_sha256: Digest::SHA256.hexdigest(updated), locators: locators, state: 'prepared' }
|
|
226
|
+
(result[:dependency_removals] ||= []) << removal
|
|
227
|
+
publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result))
|
|
228
|
+
raise 'Dependency changed after backup' unless File.binread(path) == bytes
|
|
229
|
+
|
|
230
|
+
publish(path, updated)
|
|
231
|
+
# Compare exact bytes even when JSON emits non-ASCII UTF-8 text.
|
|
232
|
+
raise 'Dependency write verification failed' unless File.binread(path) == updated.b
|
|
233
|
+
|
|
234
|
+
removal[:state] = 'committed'
|
|
235
|
+
publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result))
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# The command and bundled cron use this exact maintenance transaction.
|
|
240
|
+
# Legacy writers block all mutations, including evidence migration.
|
|
241
|
+
# One permanent inode serializes CLI, curses and cron maintenance writers.
|
|
242
|
+
# Read-only previews never acquire a writer lock or create lock files.
|
|
243
|
+
def self.with_maintenance_lock(dry_run:)
|
|
244
|
+
return yield if dry_run
|
|
245
|
+
|
|
246
|
+
root = PWN::Sessions.sessions_dir
|
|
247
|
+
raise IOError, 'Unsafe session maintenance directory' unless File.realpath(root) == File.expand_path(root)
|
|
248
|
+
|
|
249
|
+
path = File.join(root, '.maintenance.lock')
|
|
250
|
+
owner = Thread.current[:pwn_session_maintenance_lock]
|
|
251
|
+
return yield if owner && owner[:pid] == Process.pid && owner[:thread] == Thread.current && owner[:path] == path && !owner[:file].closed?
|
|
252
|
+
|
|
253
|
+
File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |file|
|
|
254
|
+
raise IOError, 'Session maintenance already running; wait for its result before retrying' unless file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
255
|
+
|
|
256
|
+
begin
|
|
257
|
+
Thread.current[:pwn_session_maintenance_lock] = { pid: Process.pid, thread: Thread.current, path: path, file: file }
|
|
258
|
+
yield
|
|
259
|
+
ensure
|
|
260
|
+
Thread.current[:pwn_session_maintenance_lock] = owner
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def self.maintain(dry_run: false, current_session_id: nil, proc_root: '/proc')
|
|
266
|
+
with_maintenance_lock(dry_run: dry_run) do
|
|
267
|
+
maintenance_transaction(dry_run: dry_run, current_session_id: current_session_id, proc_root: proc_root)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def self.maintenance_transaction(dry_run:, current_session_id:, proc_root:)
|
|
272
|
+
preflight = sweep(dry_run: true, current_session_id: current_session_id, proc_root: proc_root)
|
|
273
|
+
blocked = !preflight[:legacy_owners].empty? || Array(preflight[:unresolved_store_remediation]).any?
|
|
274
|
+
unless dry_run || blocked
|
|
275
|
+
# Private metadata-only preflight survives an interrupted migration.
|
|
276
|
+
backup(File.join(PWN::Sessions.sessions_dir, 'inventory.jsonl'), {}, preflight)
|
|
277
|
+
ledger = File.join(preflight[:archive_dir], 'maintenance.json')
|
|
278
|
+
publish(ledger, JSON.pretty_generate(state: 'prepared', inventory: preflight))
|
|
279
|
+
blocked = !legacy_owners(proc_root).empty?
|
|
280
|
+
end
|
|
281
|
+
migration = PWN::AI::Agent::Learning.migrate_evidence(dry_run: dry_run || blocked)
|
|
282
|
+
result = if dry_run || blocked
|
|
283
|
+
preflight
|
|
284
|
+
else
|
|
285
|
+
sweep(dry_run: false, current_session_id: current_session_id, proc_root: proc_root)
|
|
286
|
+
end
|
|
287
|
+
result[:dry_run] = dry_run
|
|
288
|
+
result[:apply_blocked] = true if blocked
|
|
289
|
+
result[:evidence_migration] = migration
|
|
290
|
+
result[:remaining_sessions] = Dir.glob(File.join(PWN::Sessions.sessions_dir, '*.jsonl')).length
|
|
291
|
+
result[:visible_sessions] = PWN::Sessions.list.length
|
|
292
|
+
if ledger
|
|
293
|
+
result[:maintenance_ledger] = ledger
|
|
294
|
+
publish(ledger, JSON.pretty_generate(state: blocked ? 'blocked' : 'committed', inventory: preflight, result: result))
|
|
295
|
+
end
|
|
296
|
+
result
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Explicit offline layout migration: never a cron side effect. Stop schedulers
|
|
300
|
+
# and all writers first; old binaries cannot understand the new layout.
|
|
301
|
+
# Transcript files never move. Temporary writer remnants are recoverable.
|
|
302
|
+
def self.reorganize(dry_run: true, writers_stopped: false, proc_root: '/proc')
|
|
303
|
+
raise ArgumentError, 'All writers and schedulers must be stopped' unless dry_run || writers_stopped
|
|
304
|
+
|
|
305
|
+
with_maintenance_lock(dry_run: dry_run) do
|
|
306
|
+
root = PWN::Sessions.sessions_dir
|
|
307
|
+
plan = Dir.children(root).sort.filter_map do |name|
|
|
308
|
+
target = case name
|
|
309
|
+
when /\A\d{8}_\d{6}_[a-f0-9]{8}\.meta\.json\z/ then File.join('support', 'metadata', name)
|
|
310
|
+
when /\A\..+\.cleanup\.lock\z/ then File.join('support', 'leases', name)
|
|
311
|
+
when /\A\.activity-owner-\d+-\d+\.lock\z/ then File.join('support', 'owners', name)
|
|
312
|
+
when /\A\d{8}_\d{6}_[a-f0-9]{8}\.jsonl\.\d+\.tmp\z/ then nil
|
|
313
|
+
else next
|
|
314
|
+
end
|
|
315
|
+
source = File.join(root, name)
|
|
316
|
+
raise IOError, 'Unsafe layout source' unless File.file?(source) && !File.symlink?(source)
|
|
317
|
+
|
|
318
|
+
{ source: source, target: target && File.join(root, target), sha256: Digest::SHA256.file(source).hexdigest,
|
|
319
|
+
inode: File.stat(source).ino, temporary: target.nil? }
|
|
320
|
+
end
|
|
321
|
+
# Existing trace paths may be immutable finding/artifact anchors. Keep
|
|
322
|
+
# those exact paths; new traces use compact storage after the marker.
|
|
323
|
+
retained_support = Dir.glob(File.join(root, '*', 'trace.jsonl'))
|
|
324
|
+
references = PWN::Sessions.dependency_index
|
|
325
|
+
raise IOError, 'Uncertain evidence anchors prevent layout migration' unless references[:errors].empty?
|
|
326
|
+
|
|
327
|
+
plan.reject! do |row|
|
|
328
|
+
next false unless row[:source].end_with?('.meta.json')
|
|
329
|
+
|
|
330
|
+
id = File.basename(row[:source], '.meta.json')
|
|
331
|
+
anchored = !references[:dependencies].fetch(id, []).empty? || !references[:session_errors].fetch(id, []).empty?
|
|
332
|
+
retained_support << row[:source] if anchored
|
|
333
|
+
anchored
|
|
334
|
+
end
|
|
335
|
+
result = { state: 'planned', records: plan, retained_support: retained_support, transcripts: Dir.glob(File.join(root, '*.jsonl')).length }
|
|
336
|
+
next result if dry_run
|
|
337
|
+
|
|
338
|
+
raise IOError, 'Legacy writers remain active' unless legacy_owners(proc_root).empty?
|
|
339
|
+
|
|
340
|
+
handles = []
|
|
341
|
+
begin
|
|
342
|
+
locks = Dir.glob(File.join(root, 'support', '{leases,owners}', '.*.lock'))
|
|
343
|
+
locks.each do |path|
|
|
344
|
+
file = File.open(path, File::RDWR | File::NOFOLLOW) # rubocop:disable Style/FileOpen -- transaction owns all handles
|
|
345
|
+
handles << file
|
|
346
|
+
locked = file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
347
|
+
own_marker = File.basename(path).start_with?(".activity-owner-#{Process.pid}-")
|
|
348
|
+
raise IOError, 'Session writers are active' unless locked || own_marker
|
|
349
|
+
end
|
|
350
|
+
plan.reject! do |row|
|
|
351
|
+
next false unless row[:source].end_with?('.lock')
|
|
352
|
+
|
|
353
|
+
# The migration caller can own its capability marker; leave it in
|
|
354
|
+
# place, not a replacement inode. Other held markers block migration.
|
|
355
|
+
file = File.open(row[:source], File::RDWR | File::NOFOLLOW) # rubocop:disable Style/FileOpen -- transaction holds every lock through commit
|
|
356
|
+
handles << file
|
|
357
|
+
locked = file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
358
|
+
own_marker = File.basename(row[:source]).start_with?(".activity-owner-#{Process.pid}-")
|
|
359
|
+
raise IOError, 'Session writers are active' unless locked || own_marker
|
|
360
|
+
|
|
361
|
+
!locked
|
|
362
|
+
end
|
|
363
|
+
backup(File.join(root, 'layout.jsonl'), {}, result)
|
|
364
|
+
ledger = File.join(result[:archive_dir], 'layout.json')
|
|
365
|
+
result[:state] = 'prepared'
|
|
366
|
+
publish(ledger, JSON.pretty_generate(result))
|
|
367
|
+
# Back up every byte before the first move, including abandoned temp
|
|
368
|
+
# output. Do not infer that a temp is safe merely from its age/PID.
|
|
369
|
+
plan.each_with_index do |row, index|
|
|
370
|
+
raise IOError, 'Layout source changed' unless File.stat(row[:source]).ino == row[:inode] && Digest::SHA256.file(row[:source]).hexdigest == row[:sha256]
|
|
371
|
+
raise IOError, 'Layout destination exists' if row[:target] && (File.exist?(row[:target]) || File.symlink?(row[:target]))
|
|
372
|
+
|
|
373
|
+
row[:backup] = File.join(result[:archive_dir], "layout-#{index}.bin")
|
|
374
|
+
publish(row[:backup], File.binread(row[:source]))
|
|
375
|
+
raise IOError, 'Layout backup mismatch' unless Digest::SHA256.file(row[:backup]).hexdigest == row[:sha256]
|
|
376
|
+
end
|
|
377
|
+
publish(ledger, JSON.pretty_generate(result))
|
|
378
|
+
plan.each do |row|
|
|
379
|
+
raise IOError, 'Layout source changed' unless Digest::SHA256.file(row[:source]).hexdigest == row[:sha256]
|
|
380
|
+
|
|
381
|
+
if row[:temporary]
|
|
382
|
+
File.unlink(row[:source])
|
|
383
|
+
else
|
|
384
|
+
FileUtils.mkdir_p(File.dirname(row[:target]), mode: 0o700)
|
|
385
|
+
raise IOError, 'Unsafe layout destination' unless File.realpath(File.dirname(row[:target])) == File.expand_path(File.dirname(row[:target]))
|
|
386
|
+
|
|
387
|
+
File.rename(row[:source], row[:target])
|
|
388
|
+
raise IOError, 'Layout move mismatch' unless File.stat(row[:target]).ino == row[:inode] && Digest::SHA256.file(row[:target]).hexdigest == row[:sha256]
|
|
389
|
+
end
|
|
390
|
+
row[:moved] = true
|
|
391
|
+
publish(ledger, JSON.pretty_generate(result))
|
|
392
|
+
end
|
|
393
|
+
publish(File.join(root, '.layout-v2'), "2\n")
|
|
394
|
+
result[:state] = 'committed'
|
|
395
|
+
publish(ledger, JSON.pretty_generate(result))
|
|
396
|
+
result
|
|
397
|
+
ensure
|
|
398
|
+
handles.each(&:close)
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def self.summary(result)
|
|
404
|
+
{ version: VERSION, dry_run: result[:dry_run], apply_blocked: result[:apply_blocked] == true,
|
|
405
|
+
scanned: result[:scanned], candidates: result[:candidate_ids].length, deleted: result[:deleted_ids].length,
|
|
406
|
+
remaining: result[:remaining_sessions], visible: result[:visible_sessions], skipped: result[:skipped],
|
|
407
|
+
evidence_migration: result[:evidence_migration], archive_dir: result[:archive_dir],
|
|
408
|
+
unresolved_dependency_stores: result[:dependency_errors] || [], legacy_owners: result[:legacy_owners] }
|
|
409
|
+
end
|
|
410
|
+
|
|
152
411
|
def self.run(dry_run: true, current_session_id: nil, proc_root: '/proc', approved_candidates: nil)
|
|
412
|
+
return sweep(dry_run: dry_run, current_session_id: current_session_id, proc_root: proc_root, approved_candidates: approved_candidates) if approved_candidates
|
|
413
|
+
|
|
414
|
+
maintain(dry_run: dry_run, current_session_id: current_session_id, proc_root: proc_root)
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def self.sweep(dry_run: true, current_session_id: nil, proc_root: '/proc', approved_candidates: nil)
|
|
418
|
+
with_maintenance_lock(dry_run: dry_run) do
|
|
419
|
+
sweep_transaction(dry_run: dry_run, current_session_id: current_session_id, proc_root: proc_root, approved_candidates: approved_candidates)
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def self.sweep_transaction(dry_run:, current_session_id:, proc_root:, approved_candidates:)
|
|
153
424
|
raise 'Session cleanup requires the updated Sessions activity API; load the updated source or deploy it before running this helper' unless PWN::Sessions.respond_to?(:activate)
|
|
154
425
|
|
|
155
426
|
root = PWN::Sessions.sessions_dir
|
|
@@ -158,26 +429,41 @@ module PWN
|
|
|
158
429
|
current = current_session_id || (PWN::Env.dig(:ai, :session_id) if defined?(PWN::Env))
|
|
159
430
|
legacy = legacy_owners(proc_root)
|
|
160
431
|
result = { version: VERSION, dry_run: dry_run, scanned: 0, unnamed: 0, candidate_ids: [], deleted_ids: [], records: [], skipped: Hash.new(0), legacy_owners: legacy }
|
|
161
|
-
|
|
432
|
+
references = PWN::Sessions.dependency_index
|
|
433
|
+
unless references[:errors].empty?
|
|
162
434
|
result[:skipped][:unknown_pins] = 1
|
|
163
|
-
result[:dependency_errors] =
|
|
164
|
-
|
|
435
|
+
result[:dependency_errors] = references[:errors]
|
|
436
|
+
result[:unresolved_store_remediation] = references[:errors]
|
|
165
437
|
end
|
|
166
|
-
|
|
167
|
-
|
|
438
|
+
result[:diagnostics] = references.fetch(:diagnostics, [])
|
|
439
|
+
dependencies = references[:dependencies]
|
|
440
|
+
pins = dependencies.keys
|
|
168
441
|
Dir.glob(File.join(root, '*.jsonl')).each do |path|
|
|
169
442
|
id = File.basename(path, '.jsonl')
|
|
170
443
|
result[:scanned] += 1
|
|
171
|
-
state =
|
|
172
|
-
record = PWN::Sessions.
|
|
444
|
+
state = cleanup_state(path, id, references)
|
|
445
|
+
record = PWN::Sessions.retention_status(session_id: id, references: references).merge(id: id, classification: state, dependencies: dependencies.fetch(id, []))
|
|
173
446
|
result[:records] << record
|
|
174
|
-
unless
|
|
447
|
+
unless references[:errors].empty?
|
|
448
|
+
record[:skip] = :unknown_pins
|
|
449
|
+
next
|
|
450
|
+
end
|
|
451
|
+
unless %i[unnamed invalid_dependency].include?(state)
|
|
175
452
|
result[:skipped][state] += 1
|
|
176
453
|
next
|
|
177
454
|
end
|
|
455
|
+
if state == :invalid_dependency && (!remediable?(id, references) || pins.include?(id))
|
|
456
|
+
record[:skip] = :unresolved_dependency_remediation
|
|
457
|
+
result[:skipped][:unresolved_dependency_remediation] += 1
|
|
458
|
+
next
|
|
459
|
+
end
|
|
178
460
|
expected = snapshot(path)
|
|
461
|
+
expected_dependencies = invalid_dependencies(id, references)
|
|
462
|
+
record[:dependency_records] = expected_dependencies
|
|
179
463
|
record[:files] = expected
|
|
180
|
-
|
|
464
|
+
approval = approved_candidates && approved_candidates[id]
|
|
465
|
+
approved = approval.is_a?(Hash) && approval.key?(:files) ? approval[:files] == expected && approval[:dependency_records] == expected_dependencies : approval == expected && expected_dependencies.empty?
|
|
466
|
+
if approved_candidates && !approved
|
|
181
467
|
record[:skip] = :not_approved_or_changed
|
|
182
468
|
result[:skipped][:not_approved_or_changed] += 1
|
|
183
469
|
next
|
|
@@ -204,7 +490,7 @@ module PWN
|
|
|
204
490
|
next
|
|
205
491
|
end
|
|
206
492
|
# Never unlink lock files: waiters must keep sharing the same inode.
|
|
207
|
-
lock_path =
|
|
493
|
+
lock_path = PWN::Sessions.support_path(kind: :lease, session_id: id)
|
|
208
494
|
raise 'Refusing symlinked cleanup lock' if File.symlink?(lock_path)
|
|
209
495
|
|
|
210
496
|
File.open(lock_path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
|
|
@@ -213,34 +499,39 @@ module PWN
|
|
|
213
499
|
record[:skip] = :locked
|
|
214
500
|
next
|
|
215
501
|
end
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
502
|
+
with_dependency_lock(id) do
|
|
503
|
+
File.open(path, File::RDONLY | File::NOFOLLOW) do |transcript|
|
|
504
|
+
unless transcript.flock(File::LOCK_EX | File::LOCK_NB)
|
|
505
|
+
result[:skipped][:locked] += 1
|
|
506
|
+
record[:skip] = :locked
|
|
507
|
+
next
|
|
508
|
+
end
|
|
509
|
+
# Activation and deletion serialize on the same per-session inode.
|
|
510
|
+
# Legacy ownership uncertainty still requires operator migration.
|
|
511
|
+
unless File.identical?(transcript, path) && cleanup_state(path, id) == state && invalid_dependencies(id) == expected_dependencies && snapshot(path) == expected && legacy_owners(proc_root).empty? && pins_known? && !referenced_session_ids.include?(id)
|
|
512
|
+
result[:skipped][:changed] += 1
|
|
513
|
+
record[:skip] = :changed
|
|
514
|
+
next
|
|
515
|
+
end
|
|
516
|
+
backup(path, expected, result)
|
|
517
|
+
unless snapshot(path) == expected && cleanup_state(path, id) == state && invalid_dependencies(id) == expected_dependencies && legacy_owners(proc_root).empty? && pins_known? && !referenced_session_ids.include?(id)
|
|
518
|
+
result[:skipped][:changed] += 1
|
|
519
|
+
record[:skip] = :changed
|
|
520
|
+
next
|
|
521
|
+
end
|
|
522
|
+
# Only verified, archived, unreferenced unsummarized sessions;
|
|
523
|
+
# current and all known reference pins checked above.
|
|
524
|
+
# The normal delete API's age pin is deliberately bypassed here.
|
|
525
|
+
remove_invalid_dependencies(id, result)
|
|
526
|
+
raise 'Dependencies changed after remediation' unless pins_known? && !referenced_session_ids.include?(id) && invalid_dependencies(id).empty? && snapshot(path) == expected
|
|
527
|
+
|
|
528
|
+
PWN::Sessions.delete(session_id: id, current_session_id: current, force: true, cleanup_lock: lock)
|
|
529
|
+
raise 'Deletion did not remove both session records' if File.exist?(path) || File.exist?(PWN::Sessions.support_path(kind: :metadata, session_id: id))
|
|
530
|
+
|
|
531
|
+
result[:deleted_ids] << id
|
|
532
|
+
record[:deleted] = true
|
|
533
|
+
publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result))
|
|
221
534
|
end
|
|
222
|
-
# Activation and deletion serialize on the same per-session inode.
|
|
223
|
-
# Legacy ownership uncertainty still requires operator migration.
|
|
224
|
-
unless File.identical?(transcript, path) && classify(path, id) == :unnamed && snapshot(path) == expected && legacy_owners(proc_root).empty? && pins_known? && !referenced_session_ids.include?(id)
|
|
225
|
-
result[:skipped][:changed] += 1
|
|
226
|
-
record[:skip] = :changed
|
|
227
|
-
next
|
|
228
|
-
end
|
|
229
|
-
backup(path, expected, result)
|
|
230
|
-
unless snapshot(path) == expected && classify(path, id) == :unnamed && legacy_owners(proc_root).empty? && pins_known? && !referenced_session_ids.include?(id)
|
|
231
|
-
result[:skipped][:changed] += 1
|
|
232
|
-
record[:skip] = :changed
|
|
233
|
-
next
|
|
234
|
-
end
|
|
235
|
-
# Only verified, archived, unreferenced unsummarized sessions;
|
|
236
|
-
# current and all known reference pins checked above.
|
|
237
|
-
# The normal delete API's age pin is deliberately bypassed here.
|
|
238
|
-
PWN::Sessions.delete(session_id: id, current_session_id: current, force: true, cleanup_lock: lock)
|
|
239
|
-
raise 'Deletion did not remove both session records' if File.exist?(path) || File.exist?(path.sub(/\.jsonl\z/, '.meta.json'))
|
|
240
|
-
|
|
241
|
-
result[:deleted_ids] << id
|
|
242
|
-
record[:deleted] = true
|
|
243
|
-
publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result))
|
|
244
535
|
end
|
|
245
536
|
end
|
|
246
537
|
rescue StandardError => e
|
|
@@ -250,11 +541,13 @@ module PWN
|
|
|
250
541
|
publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result)) if result[:archive_dir]
|
|
251
542
|
result
|
|
252
543
|
end
|
|
544
|
+
|
|
545
|
+
private_class_method :with_maintenance_lock, :maintenance_transaction, :sweep_transaction
|
|
253
546
|
end
|
|
254
547
|
end
|
|
255
548
|
|
|
256
549
|
if $PROGRAM_NAME == __FILE__
|
|
257
550
|
raise 'Use --dry-run or --apply' unless ARGV.length == 1 && %w[--dry-run --apply].include?(ARGV.first)
|
|
258
551
|
|
|
259
|
-
puts JSON.generate(PWN::SessionCleanup.
|
|
552
|
+
puts JSON.generate(PWN::SessionCleanup.summary(PWN::SessionCleanup.maintain(dry_run: ARGV.first == '--dry-run')))
|
|
260
553
|
end
|
|
@@ -229,9 +229,9 @@ module PWN
|
|
|
229
229
|
commit = opts.key?(:commit) ? opts[:commit] : true
|
|
230
230
|
cutoff = Time.now.utc - (since_h * 3_600)
|
|
231
231
|
|
|
232
|
-
#
|
|
232
|
+
# Offline evidence discovery must include sessions hidden from operator lists.
|
|
233
233
|
sids = if PWN::Sessions.respond_to?(:list)
|
|
234
|
-
Array(PWN::Sessions.list)
|
|
234
|
+
Array(PWN::Sessions.list(include_hidden: true))
|
|
235
235
|
.sort_by { |s| s.is_a?(Hash) ? s[:mtime].to_s : '' }
|
|
236
236
|
.reverse
|
|
237
237
|
.first(limit * 2)
|
|
@@ -262,7 +262,9 @@ module PWN
|
|
|
262
262
|
|
|
263
263
|
private_class_method def self.evidence_write(opts = {})
|
|
264
264
|
path = opts[:path]
|
|
265
|
-
|
|
265
|
+
# Verification is byte equality, not Ruby's encoding-sensitive text equality.
|
|
266
|
+
# Retag a copy only: do not transcode, normalize or scrub evidence/backups.
|
|
267
|
+
body = opts[:body].b
|
|
266
268
|
dir = File.dirname(path)
|
|
267
269
|
ancestor = dir
|
|
268
270
|
ancestor = File.dirname(ancestor) until File.exist?(ancestor) || File.symlink?(ancestor)
|
|
@@ -332,7 +334,8 @@ module PWN
|
|
|
332
334
|
# Reentrant across note_outcome -> pruning; flock serializes cooperating processes.
|
|
333
335
|
private_class_method def self.learning_lock
|
|
334
336
|
key = :pwn_learning_write_lock
|
|
335
|
-
|
|
337
|
+
owner = Thread.current[key]
|
|
338
|
+
return nil if owner.is_a?(Hash) && owner[:pid] == Process.pid && owner[:thread] == Thread.current && !owner[:file].closed?
|
|
336
339
|
|
|
337
340
|
root = File.dirname(LEARNING_FILE)
|
|
338
341
|
FileUtils.mkdir_p(root, mode: 0o700)
|
|
@@ -340,9 +343,9 @@ module PWN
|
|
|
340
343
|
raise IOError, 'Unsafe learning lock' if File.symlink?(path) || File.realpath(root) != File.expand_path(root)
|
|
341
344
|
|
|
342
345
|
# Ownership transfers to the caller's ensure/learning_unlock.
|
|
343
|
-
file = File.open(path, File::RDWR | File::CREAT, 0o600) # rubocop:disable Style/FileOpen
|
|
346
|
+
file = File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen
|
|
344
347
|
file.flock(File::LOCK_EX)
|
|
345
|
-
Thread.current[key] = file
|
|
348
|
+
Thread.current[key] = { pid: Process.pid, thread: Thread.current, file: file }
|
|
346
349
|
file
|
|
347
350
|
end
|
|
348
351
|
|
|
@@ -1250,6 +1253,8 @@ module PWN
|
|
|
1250
1253
|
# stays high-signal.
|
|
1251
1254
|
|
|
1252
1255
|
public_class_method def self.consolidate(opts = {})
|
|
1256
|
+
return PWN::Memory.transaction(path: PWN::Memory::MEMORY_FILE) { consolidate(opts) } unless PWN::Memory.transaction_owned?(path: PWN::Memory::MEMORY_FILE)
|
|
1257
|
+
|
|
1253
1258
|
cap = opts[:max_entries] || MAX_MEMORY_ENTRIES
|
|
1254
1259
|
return { removed: 0 } unless defined?(PWN::Memory)
|
|
1255
1260
|
|
|
@@ -2221,6 +2226,8 @@ module PWN
|
|
|
2221
2226
|
# after upgrading; subsequent writes never produce these.
|
|
2222
2227
|
|
|
2223
2228
|
public_class_method def self.purge_noise
|
|
2229
|
+
return PWN::Memory.transaction(path: PWN::Memory::MEMORY_FILE) { purge_noise } unless PWN::Memory.transaction_owned?(path: PWN::Memory::MEMORY_FILE)
|
|
2230
|
+
|
|
2224
2231
|
return { removed: 0 } unless defined?(PWN::Memory)
|
|
2225
2232
|
|
|
2226
2233
|
mem = nil
|
|
@@ -63,15 +63,18 @@ module PWN
|
|
|
63
63
|
FileUtils.mkdir_p(File.dirname(MISTAKES_FILE))
|
|
64
64
|
return {} unless File.exist?(MISTAKES_FILE)
|
|
65
65
|
|
|
66
|
-
JSON.parse(File.read(MISTAKES_FILE), symbolize_names: true)
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
store = JSON.parse(File.read(MISTAKES_FILE), symbolize_names: true)
|
|
67
|
+
raise IOError, 'Invalid mistakes map' unless store.is_a?(Hash)
|
|
68
|
+
|
|
69
|
+
store
|
|
69
70
|
end
|
|
70
71
|
|
|
71
72
|
# Supported Method Parameters::
|
|
72
73
|
# PWN::AI::Agent::Mistakes.save(store: hash)
|
|
73
74
|
|
|
74
75
|
public_class_method def self.save(opts = {})
|
|
76
|
+
return PWN::Memory.transaction(path: MISTAKES_FILE) { save(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
|
|
77
|
+
|
|
75
78
|
store = opts[:store] ||= {}
|
|
76
79
|
FileUtils.mkdir_p(File.dirname(MISTAKES_FILE))
|
|
77
80
|
atomic_write(path: MISTAKES_FILE, body: JSON.pretty_generate(store))
|
|
@@ -195,6 +198,8 @@ module PWN
|
|
|
195
198
|
# the caller (Loop.run) can drive cross-session repeat detection.
|
|
196
199
|
|
|
197
200
|
public_class_method def self.record(opts = {})
|
|
201
|
+
return PWN::Memory.transaction(path: MISTAKES_FILE) { record(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
|
|
202
|
+
|
|
198
203
|
tool = opts[:tool].to_s
|
|
199
204
|
error = opts[:error].to_s
|
|
200
205
|
return nil if tool.empty? || error.strip.empty?
|
|
@@ -272,6 +277,8 @@ module PWN
|
|
|
272
277
|
# )
|
|
273
278
|
|
|
274
279
|
public_class_method def self.resolve(opts = {})
|
|
280
|
+
return PWN::Memory.transaction(path: MISTAKES_FILE) { resolve(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
|
|
281
|
+
|
|
275
282
|
sig = opts[:signature].to_s
|
|
276
283
|
fix = opts[:fix].to_s
|
|
277
284
|
raise 'ERROR: signature is required' if sig.empty?
|
|
@@ -416,6 +423,8 @@ module PWN
|
|
|
416
423
|
}.freeze
|
|
417
424
|
|
|
418
425
|
public_class_method def self.extinguish!(opts = {})
|
|
426
|
+
return PWN::Memory.transaction(path: MISTAKES_FILE) { extinguish!(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
|
|
427
|
+
|
|
419
428
|
sig = opts[:signature].to_s
|
|
420
429
|
return nil if sig.empty?
|
|
421
430
|
|
|
@@ -457,6 +466,8 @@ module PWN
|
|
|
457
466
|
# Auto-resolve parked items that already have a known extinguish recipe
|
|
458
467
|
# so the operator inbox does not keep scars the loop can close itself.
|
|
459
468
|
public_class_method def self.extinguish_parked!(opts = {})
|
|
469
|
+
return PWN::Memory.transaction(path: MISTAKES_FILE) { extinguish_parked!(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
|
|
470
|
+
|
|
460
471
|
limit = (opts[:limit] || 20).to_i
|
|
461
472
|
dry = opts[:dry_run] ? true : false
|
|
462
473
|
rows = load.values.select do |m|
|
|
@@ -495,6 +506,8 @@ module PWN
|
|
|
495
506
|
end
|
|
496
507
|
|
|
497
508
|
public_class_method def self.park(opts = {})
|
|
509
|
+
return PWN::Memory.transaction(path: MISTAKES_FILE) { park(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
|
|
510
|
+
|
|
498
511
|
sig = opts[:signature].to_s
|
|
499
512
|
raise 'ERROR: signature is required' if sig.empty?
|
|
500
513
|
|
|
@@ -692,6 +705,8 @@ module PWN
|
|
|
692
705
|
end
|
|
693
706
|
|
|
694
707
|
public_class_method def self.note_hint_outcome(opts = {})
|
|
708
|
+
return PWN::Memory.transaction(path: MISTAKES_FILE) { note_hint_outcome(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
|
|
709
|
+
|
|
695
710
|
sig = (opts[:signature] || signature(tool: opts[:tool], error: opts[:error])).to_s
|
|
696
711
|
return nil if sig.empty?
|
|
697
712
|
|
|
@@ -777,6 +792,8 @@ module PWN
|
|
|
777
792
|
# drop after Memory already holds mistake_fix_<sig>.
|
|
778
793
|
|
|
779
794
|
public_class_method def self.lean!(opts = {})
|
|
795
|
+
return PWN::Memory.transaction(path: MISTAKES_FILE) { lean!(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
|
|
796
|
+
|
|
780
797
|
dry = opts[:dry_run] ? true : false
|
|
781
798
|
max_resolved = (opts[:max_resolved_kept] || MAX_RESOLVED_KEPT).to_i
|
|
782
799
|
min_age = (opts[:resolved_min_age_days] || RESOLVED_MIN_AGE_DAYS).to_f
|
|
@@ -878,6 +895,8 @@ module PWN
|
|
|
878
895
|
# PWN::AI::Agent::Mistakes.reset
|
|
879
896
|
|
|
880
897
|
public_class_method def self.reset
|
|
898
|
+
return PWN::Memory.transaction(path: MISTAKES_FILE) { reset } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
|
|
899
|
+
|
|
881
900
|
FileUtils.rm_f(MISTAKES_FILE)
|
|
882
901
|
{}
|
|
883
902
|
end
|
|
@@ -52,23 +52,23 @@ PWN::AI::Agent::Registry.register(
|
|
|
52
52
|
toolset: 'sessions',
|
|
53
53
|
schema: {
|
|
54
54
|
name: 'sessions_list',
|
|
55
|
-
description: 'List
|
|
56
|
-
'(id, title,
|
|
55
|
+
description: 'List the current active session and valid summarized or dependency-backed pwn-ai sessions in ~/.pwn/sessions ' \
|
|
56
|
+
'as paginated concise rows (id, title, mtime, dependencies), oldest-first, with total and global diagnostics. ' \
|
|
57
57
|
'dependencies lists files referencing this session through structured links, not prose mentions. ' \
|
|
58
58
|
'Use to discover a session_id for learning_reflect / ' \
|
|
59
59
|
'learning_distill_skill / sessions_view.',
|
|
60
60
|
parameters: {
|
|
61
61
|
type: 'object',
|
|
62
62
|
properties: {
|
|
63
|
-
limit: { type: 'integer', default: 25, description: 'Max sessions to return (
|
|
63
|
+
limit: { type: 'integer', default: 25, description: 'Max sessions to return (oldest first).' },
|
|
64
|
+
offset: { type: 'integer', description: 'Number of retained sessions to skip; omitted selects newest page. Current active session is also included.' }
|
|
64
65
|
},
|
|
65
66
|
required: []
|
|
66
67
|
}
|
|
67
68
|
},
|
|
68
69
|
check: -> { defined?(PWN::Sessions) },
|
|
69
70
|
handler: lambda { |args|
|
|
70
|
-
limit
|
|
71
|
-
PWN::Sessions.list.sort_by { |s| s[:mtime].to_s }.reverse.first(limit)
|
|
71
|
+
PWN::Sessions.catalog(limit: args[:limit] || 25, offset: args[:offset], current_session_id: Thread.current[:pwn_session_id])
|
|
72
72
|
}
|
|
73
73
|
)
|
|
74
74
|
|
|
@@ -152,7 +152,7 @@ PWN::AI::Agent::Registry.register(
|
|
|
152
152
|
},
|
|
153
153
|
check: -> { defined?(PWN::Sessions) },
|
|
154
154
|
handler: lambda { |_args|
|
|
155
|
-
listing = PWN::Sessions.list
|
|
155
|
+
listing = PWN::Sessions.list(include_hidden: true)
|
|
156
156
|
PWN::Sessions.stats.merge(
|
|
157
157
|
disk_bytes: listing.sum { |s| s[:size].to_i },
|
|
158
158
|
dir: PWN::Sessions.sessions_dir
|
data/lib/pwn/ai/http_retry.rb
CHANGED
|
@@ -31,6 +31,9 @@ module PWN
|
|
|
31
31
|
# Resolve provider defaults from the active request without mutating Env.
|
|
32
32
|
public_class_method def self.provider_config(opts = {})
|
|
33
33
|
engine = opts[:engine]
|
|
34
|
+
title_config = Thread.current[:pwn_session_title_config]
|
|
35
|
+
return title_config[engine] if title_config
|
|
36
|
+
|
|
34
37
|
owner = Thread.current[:pwn_request_runtime]
|
|
35
38
|
return owner.provider_config(engine) if owner.respond_to?(:provider_config)
|
|
36
39
|
|