pwn 0.5.783 → 0.5.784

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -3
  3. data/Rakefile +19 -1
  4. data/bin/pwn_setup +1 -1
  5. data/build_gem.sh +1 -1
  6. data/documentation/Backup-Retention.md +27 -0
  7. data/documentation/Cron.md +64 -35
  8. data/documentation/How-PWN-Works.md +1 -1
  9. data/documentation/Installation.md +247 -6
  10. data/documentation/Persistence.md +1 -1
  11. data/documentation/Reinforcement-Learning.md +5 -6
  12. data/documentation/diagrams/cron-scheduling.svg +130 -128
  13. data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
  14. data/etc/default_cron/session_cleanup.rb +1217 -10
  15. data/lib/pwn/ai/agent/extrospection.rb +6 -7
  16. data/lib/pwn/ai/agent/learning.rb +48 -7
  17. data/lib/pwn/ai/agent/policy.rb +5 -0
  18. data/lib/pwn/ai/agent/swarm.rb +31 -16
  19. data/lib/pwn/ai/router.rb +45 -2
  20. data/lib/pwn/cron.rb +120 -49
  21. data/lib/pwn/migrate.rb +292 -15
  22. data/lib/pwn/plugins/binary_parser.rb +5 -4
  23. data/lib/pwn/plugins/ghidra.rb +4 -3
  24. data/lib/pwn/plugins/log.rb +18 -2
  25. data/lib/pwn/plugins/repl/ai.rb +12 -1
  26. data/lib/pwn/plugins/repl/ai_console.rb +6 -2
  27. data/lib/pwn/plugins/repl/ai_swarm.rb +4 -4
  28. data/lib/pwn/sessions.rb +58 -16
  29. data/lib/pwn/setup.rb +9 -17
  30. data/lib/pwn/version.rb +1 -1
  31. data/scripts/build_gate.rb +58 -0
  32. data/spec/integration/build_gate_spec.rb +73 -0
  33. data/spec/integration/persistence_roundtrip_spec.rb +1 -7
  34. data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
  35. data/spec/lib/pwn/ai/router_spec.rb +30 -2
  36. data/spec/lib/pwn/cron_spec.rb +113 -6
  37. data/spec/lib/pwn/migrate_spec.rb +160 -5
  38. data/spec/lib/pwn/plugins/log_spec.rb +7 -0
  39. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +16 -4
  40. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +11 -0
  41. data/spec/lib/pwn/redaction_spec.rb +13 -10
  42. data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
  43. data/spec/lib/pwn/session_cleanup_default_spec.rb +165 -2
  44. data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
  45. data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
  46. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +389 -0
  47. data/spec/lib/pwn/sessions_spec.rb +112 -0
  48. data/spec/lib/pwn/setup_spec.rb +3 -1
  49. data/spec/support/gate_failure_formatter.rb +14 -0
  50. data/third_party/pwn_rdoc.jsonl +15 -1
  51. metadata +14 -7
@@ -3572,16 +3572,15 @@ module PWN
3572
3572
  [true, log]
3573
3573
  when :festival
3574
3574
  # Use plugin if text file available
3575
- tmp = File.join(Dir.tmpdir, "pwn_tts_#{Process.pid}.txt")
3576
- File.write(tmp, text)
3575
+ require_relative '../../../../etc/default_cron/session_cleanup' unless defined?(PWN::SessionCleanup)
3577
3576
  begin
3578
- require 'pwn/plugins/voice' unless defined?(PWN::Plugins::Voice)
3579
- PWN::Plugins::Voice.text_to_speech(text_path: tmp)
3580
- [true, 'festival via PWN::Plugins::Voice']
3577
+ PWN::SessionCleanup.temporary(bytes: text) do |tmp|
3578
+ require 'pwn/plugins/voice' unless defined?(PWN::Plugins::Voice)
3579
+ PWN::Plugins::Voice.text_to_speech(text_path: tmp)
3580
+ [true, 'festival via PWN::Plugins::Voice']
3581
+ end
3581
3582
  rescue StandardError => e
3582
3583
  [false, "#{e.class}: #{e.message}"]
3583
- ensure
3584
- FileUtils.rm_f(tmp)
3585
3584
  end
3586
3585
  else
3587
3586
  [false, "unsupported TTS engine: #{engine}"]
@@ -101,7 +101,14 @@ module PWN
101
101
  # Plan by default; no provider calls, vault reads, transcript logging or deletion.
102
102
  public_class_method def self.migrate_evidence(opts = {})
103
103
  dry = opts[:dry_run] != false
104
- lock = learning_lock unless dry
104
+ max_sessions = opts[:max_sessions]
105
+ stop = opts[:stop]
106
+ raise ArgumentError, 'max_sessions must be positive' if max_sessions && (!max_sessions.is_a?(Integer) || max_sessions <= 0)
107
+
108
+ leases = []
109
+ lock = learning_lock(nonblocking: opts[:nonblocking]) unless dry
110
+ return { dry_run: false, deferred: 'learning_writer_active', migrated_rows: 0 } if lock == :busy
111
+
105
112
  original = File.exist?(LEARNING_FILE) ? evidence_read(path: LEARNING_FILE) : ''
106
113
  malformed = 0
107
114
  rows = original.lines.filter_map do |line|
@@ -116,9 +123,13 @@ module PWN
116
123
 
117
124
  report = { dry_run: dry, rows: rows.length, malformed_rows: malformed, session_refs: 0, migratable_sessions: 0,
118
125
  missing_sessions: 0, malformed_sessions: 0, malformed_refs: 0, verified_rows: 0,
119
- estimated_source_bytes: 0, migrated_rows: 0 }
126
+ estimated_source_bytes: 0, migrated_rows: 0, deferred_active_sessions: 0 }
120
127
  snapshots = {}
121
128
  rows.each do |row|
129
+ if stop&.call || (max_sessions && report[:migratable_sessions] >= max_sessions)
130
+ report[:bounded] = true
131
+ break
132
+ end
122
133
  sid = row[:session_id]
123
134
  next if sid.nil? || sid == ''
124
135
 
@@ -140,6 +151,22 @@ module PWN
140
151
  next
141
152
  end
142
153
  begin
154
+ # Keep the permanent exclusive lease through index publication;
155
+ # a point-in-time active? check alone races subsequent activation.
156
+ if dry
157
+ if PWN::Sessions.active?(session_id: sid)
158
+ report[:deferred_active_sessions] += 1
159
+ next
160
+ end
161
+ else
162
+ lease = File.open(PWN::Sessions.support_path(kind: :lease, session_id: sid), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen -- released together after commit
163
+ leases << lease
164
+ unless lease.flock(File::LOCK_EX | File::LOCK_NB)
165
+ lease.close
166
+ report[:deferred_active_sessions] += 1
167
+ next
168
+ end
169
+ end
143
170
  body = evidence_read(path: path)
144
171
  transcript = body.lines.map { |line| JSON.parse(line, symbolize_names: true) }
145
172
  raise IOError, 'Malformed source transcript' if transcript.empty? || !transcript.all?(Hash)
@@ -179,7 +206,8 @@ module PWN
179
206
 
180
207
  evidence_commit(rows: rows, original: original, snapshots: snapshots, report: report)
181
208
  ensure
182
- learning_unlock(lock: lock) if lock
209
+ leases&.each { |lease| lease.close unless lease.closed? }
210
+ learning_unlock(lock: lock) if lock && lock != :busy
183
211
  end
184
212
 
185
213
  private_class_method def self.evidence_read(opts = {})
@@ -253,6 +281,8 @@ module PWN
253
281
 
254
282
  raise IOError, 'Restore source sessions before rollback' unless evidence_complete?(row: row) && File.file?(File.join(PWN::Sessions::SESSIONS_DIR, "#{sid}.jsonl"))
255
283
  end
284
+ return { dry_run: true, restored: false } if opts[:dry_run] == true
285
+
256
286
  evidence_write(path: LEARNING_FILE, body: backup)
257
287
  evidence_write(path: path, body: JSON.generate(record.merge(state: 'rolled_back')))
258
288
  { restored: true }
@@ -332,7 +362,7 @@ module PWN
332
362
  end
333
363
 
334
364
  # Reentrant across note_outcome -> pruning; flock serializes cooperating processes.
335
- private_class_method def self.learning_lock
365
+ private_class_method def self.learning_lock(opts = {})
336
366
  key = :pwn_learning_write_lock
337
367
  owner = Thread.current[key]
338
368
  return nil if owner.is_a?(Hash) && owner[:pid] == Process.pid && owner[:thread] == Thread.current && !owner[:file].closed?
@@ -344,7 +374,10 @@ module PWN
344
374
 
345
375
  # Ownership transfers to the caller's ensure/learning_unlock.
346
376
  file = File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen
347
- file.flock(File::LOCK_EX)
377
+ unless file.flock(File::LOCK_EX | (opts[:nonblocking] ? File::LOCK_NB : 0))
378
+ file.close
379
+ return :busy
380
+ end
348
381
  Thread.current[key] = { pid: Process.pid, thread: Thread.current, file: file }
349
382
  file
350
383
  end
@@ -2388,10 +2421,18 @@ module PWN
2388
2421
  public_class_method def self.help
2389
2422
  puts "
2390
2423
  # Plan a full-snapshot evidence migration, or explicitly apply it.
2391
- #{self}.migrate_evidence(dry_run: 'optional - Boolean; defaults true, false writes evidence and references')
2424
+ #{self}.migrate_evidence(
2425
+ dry_run: 'optional - Boolean; defaults true, false writes evidence and references',
2426
+ max_sessions: 'optional - Positive maximum source snapshots per transaction',
2427
+ nonblocking: 'optional - Boolean; defer instead of waiting for a cooperating learning writer',
2428
+ stop: 'optional - Cooperative stop callable checked between source snapshots'
2429
+ )
2392
2430
 
2393
2431
  # Restore an unchanged learning ledger before any source-session cleanup.
2394
- #{self}.rollback_evidence(migration: 'required - SHA-256 migration ID from the ledger filename')
2432
+ #{self}.rollback_evidence(
2433
+ migration: 'required - SHA-256 migration ID from the ledger filename',
2434
+ dry_run: 'optional - validate rollback without replacing learning bytes; defaults to false'
2435
+ )
2395
2436
 
2396
2437
  # Persist derived PRM rewards without rewriting immutable supporting content.
2397
2438
  #{self}.annotate_evidence(
@@ -4,6 +4,7 @@ require 'json'
4
4
  require 'fileutils'
5
5
  require 'time'
6
6
  require 'digest'
7
+ require_relative '../../memory'
7
8
 
8
9
  module PWN
9
10
  module AI
@@ -696,6 +697,8 @@ module PWN
696
697
 
697
698
  public_class_method def self.lean!(opts = {})
698
699
  dry = opts[:dry_run] ? true : false
700
+ return PWN::Memory.transaction(path: TRAJECTORY_FILE) { lean!(opts) } unless dry || PWN::Memory.transaction_owned?(path: TRAJECTORY_FILE)
701
+
699
702
  return { skipped: true } unless File.exist?(TRAJECTORY_FILE)
700
703
 
701
704
  rows = File.readlines(TRAJECTORY_FILE)
@@ -1385,6 +1388,8 @@ module PWN
1385
1388
  end
1386
1389
 
1387
1390
  private_class_method def self.persist_episode!(opts = {})
1391
+ return PWN::Memory.transaction(path: TRAJECTORY_FILE) { persist_episode!(opts) } unless PWN::Memory.transaction_owned?(path: TRAJECTORY_FILE)
1392
+
1388
1393
  ep = opts[:episode]
1389
1394
  return unless ep.is_a?(Hash)
1390
1395
 
@@ -120,12 +120,19 @@ module PWN
120
120
  public_class_method def self.retire(opts = {})
121
121
  name = opts[:name].to_s
122
122
  sid = opts[:swarm_id].to_s
123
+ continuation = nil
124
+ if opts[:continuation]
125
+ raise ArgumentError, 'Continuation retirement requires local and swarm_id' unless opts[:local] == true && !sid.empty?
126
+
127
+ require File.expand_path('../../../../etc/default_cron/session_cleanup', __dir__)
128
+ continuation = PWN::SessionCleanup.retire_continuation(root: SWARM_ROOT, swarm_id: sid, name: name)
129
+ end
123
130
  if opts[:local] == true && !sid.empty?
124
131
  path = File.join(SWARM_ROOT, sid, 'agents.yml')
125
132
  all = load_personas_file(path: path)
126
133
  gone = all.delete(name.to_sym)
127
134
  File.write(path, YAML.dump(deep_stringify(hash: all))) if gone
128
- return { name: name, removed: !gone.nil?, scope: :swarm }
135
+ return { name: name, removed: !gone.nil?, scope: :swarm, continuation: continuation }
129
136
  end
130
137
 
131
138
  all = personas
@@ -414,21 +421,28 @@ module PWN
414
421
  sid = opts[:swarm_id].to_s
415
422
  name = opts[:name].to_s
416
423
  map_path = File.join(SWARM_ROOT, sid, 'personas.json')
417
- map = File.exist?(map_path) ? JSON.parse(File.read(map_path)) : {}
418
- if map[name]
419
- opts[:leases] << PWN::Sessions.activate(session_id: map[name]) if opts[:leases]
420
- return map[name]
421
- end
424
+ require File.expand_path('../../../../etc/default_cron/session_cleanup', __dir__)
425
+ PWN::SessionCleanup.with_continuation_lock(root: File.dirname(SWARM_ROOT)) do
426
+ raise IOError, 'Unsafe continuation map' if File.symlink?(map_path)
422
427
 
423
- sess = PWN::Sessions.create(
424
- title: "swarm:#{sid} persona:#{name}",
425
- source: 'pwn-ai-swarm',
426
- active: !opts[:leases].nil?
427
- )
428
- opts[:leases] << sess[:lease] if opts[:leases]
429
- map[name] = sess[:id]
430
- File.write(map_path, JSON.pretty_generate(map))
431
- sess[:id]
428
+ map = File.exist?(map_path) ? JSON.parse(File.read(map_path)) : {}
429
+ raise IOError, 'Unknown continuation map' unless map.is_a?(Hash)
430
+
431
+ if map[name]
432
+ opts[:leases] << PWN::Sessions.activate(session_id: map[name]) if opts[:leases]
433
+ next map[name]
434
+ end
435
+
436
+ sess = PWN::Sessions.create(
437
+ title: "swarm:#{sid} persona:#{name}",
438
+ source: 'pwn-ai-swarm',
439
+ active: !opts[:leases].nil?
440
+ )
441
+ opts[:leases] << sess[:lease] if opts[:leases]
442
+ map[name] = sess[:id]
443
+ PWN::SessionCleanup.publish(map_path, JSON.pretty_generate(map))
444
+ sess[:id]
445
+ end
432
446
  end
433
447
 
434
448
  private_class_method def self.build_persona_prompt(opts = {})
@@ -759,7 +773,8 @@ module PWN
759
773
  #{self}.retire(
760
774
  name: 'required - persona name',
761
775
  swarm_id: 'optional - swarm directory when local is true',
762
- local: 'optional - true retires only that swarm registry'
776
+ local: 'optional - true retires only that swarm registry',
777
+ continuation: 'optional - explicitly retire the inactive saved conversation association; requires local and swarm_id'
763
778
  )
764
779
 
765
780
  # Swarm lifecycle & bus
data/lib/pwn/ai/router.rb CHANGED
@@ -59,7 +59,7 @@ module PWN
59
59
 
60
60
  extractive(text: text)
61
61
  rescue StandardError
62
- return '' if session
62
+ raise if session
63
63
 
64
64
  extractive(text: text)
65
65
  end
@@ -163,14 +163,57 @@ module PWN
163
163
  return nil unless mod.respond_to?(:chat)
164
164
 
165
165
  resp = mod.chat(request: opts[:request], model: endpoint[:model], spinner: false, quiet: true, timeout: 8)
166
- text = resp.is_a?(Hash) ? (resp[:content] || resp['content'] || resp.dig(:choices, -1, :content)).to_s : resp.to_s
166
+ text = response_text(response: resp)
167
167
  text.strip.empty? ? nil : text
168
168
  rescue StandardError
169
+ raise if opts[:session]
170
+
169
171
  nil
170
172
  ensure
171
173
  Thread.current[:pwn_session_title] = previous_title_worker
172
174
  end
173
175
 
176
+ # Only decode documented assistant-content locations, never container inspect
177
+ # strings, reasoning blocks, tool arguments or echoed user messages.
178
+ private_class_method def self.response_text(opts = {})
179
+ response = opts[:response]
180
+ return response if response.is_a?(String)
181
+ return '' unless response.is_a?(Hash)
182
+
183
+ row = response.transform_keys(&:to_s)
184
+ return JSON.generate(row) if row['title'].is_a?(String) && row['summary'].is_a?(String)
185
+ return content_text(content: row['content']) if row.key?('content')
186
+
187
+ choice = Array(row['choices']).last
188
+ if choice.is_a?(Hash)
189
+ choice = choice.transform_keys(&:to_s)
190
+ message = choice['message'] || choice
191
+ if message.is_a?(Hash)
192
+ message = message.transform_keys(&:to_s)
193
+ return '' if message['role'] && message['role'] != 'assistant'
194
+
195
+ return content_text(content: message['content'])
196
+ end
197
+ end
198
+ message = row['message']
199
+ return content_text(content: message[:content] || message['content']) if message.is_a?(Hash)
200
+
201
+ ''
202
+ end
203
+
204
+ private_class_method def self.content_text(opts = {})
205
+ content = opts[:content]
206
+ return content if content.is_a?(String)
207
+ return '' unless content.is_a?(Array)
208
+
209
+ content.filter_map do |block|
210
+ next unless block.is_a?(Hash)
211
+
212
+ block = block.transform_keys(&:to_s)
213
+ block['text'] if %w[text output_text].include?(block['type']) && block['text'].is_a?(String)
214
+ end.join
215
+ end
216
+
174
217
  private_class_method def self.active_session_endpoint
175
218
  engine = env_dig(keys: %i[ai active]).to_s.downcase
176
219
  { engine: engine, model: env_dig(keys: [:ai, engine.to_sym, :model]) }
data/lib/pwn/cron.rb CHANGED
@@ -48,6 +48,8 @@ module PWN
48
48
  # enabled: true
49
49
  # )
50
50
  public_class_method def self.create(opts = {})
51
+ return with_registry_lock { create(opts) } unless registry_owned?
52
+
51
53
  jobs = load_jobs
52
54
  id = SecureRandom.hex(6)
53
55
  name = opts[:name] || "job-#{id}"
@@ -69,7 +71,7 @@ module PWN
69
71
 
70
72
  # Optionally install a crontab entry (user must have permission)
71
73
  install_crontab_entry(job: job) if opts[:install_crontab]
72
- sync_one_job(job: job) if cron_enabled? && !opts[:install_crontab]
74
+ sync_one_job(job: job) if cron_enabled? && !opts[:install_crontab] && opts.fetch(:sync_native, true)
73
75
 
74
76
  job
75
77
  end
@@ -83,6 +85,7 @@ module PWN
83
85
  jobs = load_jobs
84
86
  job = jobs[id] || jobs.values.find { |j| j[:name] == id || j[:id] == id }
85
87
  raise "Job #{id} not found" unless job
88
+ return { skipped: true, reason: :housekeeping_internal_only, status: 'skipped' } if native_execution? && housekeeping_job?(job: job)
86
89
 
87
90
  start = Time.now
88
91
  result = nil
@@ -133,20 +136,28 @@ module PWN
133
136
 
134
137
  job[:last_run] = Time.now.utc.iso8601
135
138
  job[:last_status] = status
136
- jobs[job[:id]] = job
137
- save_jobs(jobs: jobs)
139
+ with_registry_lock do
140
+ current = load_jobs
141
+ if current.key?(job[:id])
142
+ current[job[:id]][:last_run] = job[:last_run]
143
+ current[job[:id]][:last_status] = status
144
+ save_jobs(jobs: current)
145
+ end
146
+ end
138
147
 
139
148
  { job: job, result: result, duration: Time.now - start, status: status }
140
149
  end
141
150
 
142
151
  # Supported Method Parameters::
143
- # PWN::Cron.remove(id:)
144
- public_class_method def self.remove(opts = {}) # rubocop:disable Naming/PredicateMethod
152
+ # PWN::Cron.remove(id:, sync_native: false)
153
+ public_class_method def self.remove(opts = {})
154
+ return with_registry_lock { remove(opts) } unless registry_owned?
155
+
145
156
  id = opts[:id].to_s
146
157
  jobs = load_jobs
147
158
  gone = jobs.delete(id)
148
159
  save_jobs(jobs: jobs)
149
- remove_native_job(job: gone) if gone
160
+ remove_native_job(job: gone) if gone && opts.fetch(:sync_native, true)
150
161
  true
151
162
  end
152
163
 
@@ -181,16 +192,48 @@ module PWN
181
192
  # and break jobs[id] / jobs.delete(id) / toggle lookups).
182
193
  raw.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
183
194
  rescue StandardError => e
184
- warn("[PWN::Cron] load_jobs failed: #{e.class}: #{e.message}")
185
- {}
195
+ raise IOError, "Cron registry unreadable (#{e.class}); refusing an empty replacement"
196
+ end
197
+
198
+ # A permanent inode coordinates registry writers, never job execution.
199
+ private_class_method def self.with_registry_lock
200
+ return yield if registry_owned?
201
+
202
+ owner = Thread.current[:pwn_cron_registry_owner]
203
+ File.open(File.join(cron_dir, '.jobs.write.lock'), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |file|
204
+ file.flock(File::LOCK_EX)
205
+ begin
206
+ Thread.current[:pwn_cron_registry_owner] = [Process.pid, Thread.current, jobs_file]
207
+ yield
208
+ ensure
209
+ Thread.current[:pwn_cron_registry_owner] = owner
210
+ end
211
+ end
212
+ end
213
+
214
+ private_class_method def self.registry_owned?
215
+ Thread.current[:pwn_cron_registry_owner] == [Process.pid, Thread.current, jobs_file]
186
216
  end
187
217
 
188
218
  private_class_method def self.save_jobs(opts = {})
189
219
  jobs = opts[:jobs] ||= {}
190
- File.write(jobs_file, YAML.dump(jobs))
220
+ temporary = "#{jobs_file}.#{SecureRandom.hex(8)}.tmp"
221
+ begin
222
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
223
+ file.write(YAML.dump(jobs))
224
+ file.flush
225
+ file.fsync
226
+ end
227
+ File.rename(temporary, jobs_file)
228
+ File.open(cron_dir, File::RDONLY, &:fsync)
229
+ ensure
230
+ FileUtils.rm_f(temporary)
231
+ end
191
232
  end
192
233
 
193
234
  private_class_method def self.toggle(opts = {})
235
+ return with_registry_lock { toggle(opts) } unless registry_owned?
236
+
194
237
  id = opts[:id].to_s
195
238
  enabled = opts[:enabled]
196
239
  jobs = load_jobs
@@ -202,9 +245,29 @@ module PWN
202
245
  jobs[id]
203
246
  end
204
247
 
248
+ private_class_method def self.native_execution?
249
+ return true if ENV['PWN_CRON_NATIVE'] == '1'
250
+
251
+ # An interactive terminal may inherit INVOCATION_ID; only PWN scheduler
252
+ # cgroups identify legacy units that predate the explicit marker.
253
+ File.read('/proc/self/cgroup').match?(%r{/pwn-cron-(?:worker|j[^/]+)\.service(?:/|$)})
254
+ rescue Errno::ENOENT, Errno::EACCES
255
+ false
256
+ end
257
+
258
+ # Housekeeping is owned exclusively by the portable worker/manual commands.
259
+ private_class_method def self.housekeeping_job?(opts = {})
260
+ job = opts[:job]
261
+ return false unless job.is_a?(Hash)
262
+
263
+ %w[session_cleanup learning_consolidate_nightly pwn_stores_lean_nightly].include?(job[:name].to_s) ||
264
+ [job[:ruby], job[:script]].any? { |payload| payload.to_s.match?(/SessionCleanup|session_cleanup\.rb/) }
265
+ end
266
+
205
267
  private_class_method def self.sync_one_job(opts = {})
206
268
  job = opts[:job]
207
269
  return unless job.is_a?(Hash)
270
+ return if housekeeping_job?(job: job)
208
271
 
209
272
  if job[:enabled] && cron_enabled?
210
273
  install_native_job(job: job)
@@ -218,15 +281,11 @@ module PWN
218
281
 
219
282
  # Supported Method Parameters::
220
283
  # PWN::Cron.install_defaults
221
- # Idempotently seed the RL feedback-loop cron jobs so a fresh install
222
- # closes the loop by default (S1 practice + P3 offline_judge + W2 train dry-run + M1 consolidate).
223
- # Re-running is a no-op if jobs with these names already exist.
224
- # These are seeded to jobs.yml only — pass install_crontab: true to
225
- # PWN::Cron.create yourself if you also want a system crontab entry.
226
- # Hygiene jobs are enabled. Curriculum self-play / judge / LoRA are
227
- # seeded disabled: practice runs Loop.run (real tools) overnight;
228
- # offline_judge spends API tokens; train needs a GPU trainer.
229
- DEFAULT_JOBS = [
284
+ # Fresh installs seed one internal housekeeping coordinator. Existing
285
+ # schedules are preserved, not automatically merged or re-enabled.
286
+ # Curriculum templates below require explicit create; installation and
287
+ # migration never seed them, including after operator deletion.
288
+ CURRICULUM_JOBS = [
230
289
  {
231
290
  name: 'curriculum_practice_nightly',
232
291
  schedule: '0 3 * * *',
@@ -245,29 +304,24 @@ module PWN
245
304
  ruby: 'PWN::AI::Agent::Curriculum.offline_judge(since_hours: 24) if defined?(PWN::AI::Agent::Curriculum)',
246
305
  aliases: %w[offline_judge_nightly],
247
306
  enabled: false
248
- },
249
- {
250
- name: 'learning_consolidate_nightly',
251
- schedule: '0 5 * * *',
252
- ruby: 'PWN::AI::Agent::Learning.consolidate if defined?(PWN::AI::Agent::Learning)',
253
- enabled: true
254
- },
307
+ }
308
+ ].freeze
309
+
310
+ DEFAULT_JOBS = [
255
311
  {
256
312
  name: 'session_cleanup',
257
313
  schedule: '45 3 * * *',
258
- ruby: "require File.join(PWN::Cron.cron_dir, 'session_cleanup.rb'); PWN::SessionCleanup.maintain(dry_run: false)",
259
- enabled: true
260
- },
261
- {
262
- name: 'pwn_stores_lean_nightly',
263
- schedule: '15 5 * * *',
264
- ruby: 'PWN::AI::Agent::Learning.gc_stores! if defined?(PWN::AI::Agent::Learning)',
314
+ ruby: "require File.join(PWN::Cron.cron_dir, 'session_cleanup.rb'); PWN::SessionCleanup.coordinate(dry_run: false)",
265
315
  enabled: true
266
316
  }
267
317
  ].freeze
268
318
 
269
319
  public_class_method def self.install_defaults
320
+ return with_registry_lock { install_defaults } unless registry_owned?
321
+
270
322
  install_default_payloads
323
+ require File.expand_path('../../etc/default_cron/session_cleanup', __dir__)
324
+ PWN::SessionCleanup.install(root: File.dirname(cron_dir))
271
325
  jobs = load_jobs
272
326
  names = jobs.values.map { |j| j[:name].to_s }
273
327
  seeded = []
@@ -282,21 +336,13 @@ module PWN
282
336
  schedule: spec[:schedule],
283
337
  ruby: default_payload(spec: spec),
284
338
  delivery: 'log',
339
+ sync_native: false,
285
340
  enabled: spec.fetch(:enabled, true)
286
341
  )
287
342
  names << spec[:name].to_s
288
343
  end
289
344
 
290
- # P13 - disable the legacy alias when both occupy the 30 3 * * * slot.
291
- if names.include?('curriculum_offline_judge') && names.include?('offline_judge_nightly')
292
- begin
293
- dup = jobs.values.find { |j| j[:name].to_s == 'offline_judge_nightly' }
294
- disable(id: dup[:id]) if dup && dup[:enabled]
295
- rescue StandardError
296
- nil
297
- end
298
- end
299
-
345
+ PWN::SessionCleanup.request_background(root: File.dirname(cron_dir))
300
346
  seeded
301
347
  rescue StandardError => e
302
348
  warn("[PWN::Cron] install_defaults failed: #{e.class}: #{e.message}")
@@ -327,7 +373,9 @@ module PWN
327
373
 
328
374
  slot = Time.at(((Time.now.to_i / 60) + 1) * 60)
329
375
  slot += 60 until slot.hour == 3 && slot.min == 45
330
- "if Time.now.to_i < #{slot.to_i}; { deferred: true, deleted: 0 }; else; #{spec[:ruby]}; end"
376
+ payload = spec[:ruby]
377
+ payload = payload.sub('SessionCleanup.coordinate(', 'SessionCleanup.maintain(') if load_jobs.values.any? { |job| %w[learning_consolidate_nightly pwn_stores_lean_nightly].include?(job[:name]) }
378
+ "if Time.now.to_i < #{slot.to_i}; { deferred: true, deleted: 0 }; else; #{payload}; end"
331
379
  end
332
380
 
333
381
  # ------------------------------------------------------------------
@@ -410,6 +458,7 @@ module PWN
410
458
  load_jobs.each_with_object({}) do |(id, job), acc|
411
459
  next unless job.is_a?(Hash)
412
460
  next unless job[:enabled]
461
+ next if native_execution? && housekeeping_job?(job: job)
413
462
  next unless due?(schedule: job[:schedule], last_run: job[:last_run], now: now)
414
463
 
415
464
  acc[id] = job
@@ -533,7 +582,16 @@ module PWN
533
582
 
534
583
  # Single tick used by the loop AND by tests (no sleep).
535
584
  public_class_method def self.tick(opts = {})
536
- run_due(now: opts[:now] || Time.now)
585
+ results = run_due(now: opts[:now] || Time.now)
586
+ return results if native_execution?
587
+
588
+ require File.expand_path('../../etc/default_cron/session_cleanup', __dir__)
589
+ background = PWN::SessionCleanup.background_request(root: File.dirname(cron_dir)) do
590
+ job = load_jobs.values.find { |entry| entry[:name] == 'session_cleanup' && entry[:enabled] }
591
+ results.find { |entry| entry[:id] == job[:id] } || run(id: job[:id]).merge(id: job[:id]) if job
592
+ end
593
+ results << background if background && results.none? { |entry| entry[:id] == background[:id] }
594
+ results
537
595
  end
538
596
 
539
597
  # Blocking poll loop. `once: true` runs a single tick then returns
@@ -1064,6 +1122,7 @@ module PWN
1064
1122
 
1065
1123
  public_class_method def self.install_native_job(opts = {})
1066
1124
  job = opts[:job]
1125
+ return { skipped: true, reason: :housekeeping_internal_only } if housekeeping_job?(job: job)
1067
1126
  return nil unless job.is_a?(Hash) && job[:id]
1068
1127
 
1069
1128
  backend = (opts[:backend] || scheduler_backend).to_sym
@@ -1080,6 +1139,8 @@ module PWN
1080
1139
 
1081
1140
  public_class_method def self.remove_native_job(opts = {})
1082
1141
  job = opts[:job] || { id: opts[:id], name: opts[:name] }
1142
+ return { skipped: true, reason: :housekeeping_internal_only } if housekeeping_job?(job: job)
1143
+
1083
1144
  backend = (opts[:backend] || scheduler_backend).to_sym
1084
1145
  apply = apply_native?(opts)
1085
1146
  case backend
@@ -1110,7 +1171,7 @@ module PWN
1110
1171
  public_class_method def self.install_crontab_worker(opts = {})
1111
1172
  apply = apply_native?(opts)
1112
1173
  marker = 'PWN::Cron.ensure_worker'
1113
- line = "@reboot #{ruby_eval_shell(snippet: 'require "pwn"; PWN::Cron.ensure_worker(restart: false)', log: worker_log)}"
1174
+ line = "@reboot #{ruby_eval_shell(snippet: 'ENV["PWN_CRON_NATIVE"] = "1"; require "pwn"; PWN::Cron.ensure_worker(restart: false)', log: worker_log)}"
1114
1175
  preview = "# pwn-cron worker\n#{line}\n"
1115
1176
  write_native_preview(name: 'worker.crontab', body: preview)
1116
1177
  return { backend: :crontab, apply: false, line: line } unless apply && crontab_available?
@@ -1124,6 +1185,8 @@ module PWN
1124
1185
 
1125
1186
  public_class_method def self.install_crontab_job(opts = {})
1126
1187
  job = opts[:job]
1188
+ return { skipped: true, reason: :housekeeping_internal_only } if housekeeping_job?(job: job)
1189
+
1127
1190
  apply = apply_native?(opts)
1128
1191
  snippet = "require \"pwn\"; PWN::Cron.run(id: #{job[:id].to_s.inspect})"
1129
1192
  line = "#{job[:schedule]} #{ruby_eval_shell(snippet: snippet, log: File.join(cron_dir, 'cron.log'))}"
@@ -1165,7 +1228,7 @@ module PWN
1165
1228
  public_class_method def self.install_systemd_user_worker(opts = {})
1166
1229
  apply = apply_native?(opts)
1167
1230
  exec_start = ruby_eval_shell(
1168
- snippet: 'require "pwn"; PWN::Cron.start_worker(restart: false, foreground: true)'
1231
+ snippet: 'ENV["PWN_CRON_NATIVE"] = "1"; require "pwn"; PWN::Cron.start_worker(restart: false, foreground: true)'
1169
1232
  )
1170
1233
  body = <<~UNIT
1171
1234
  [Unit]
@@ -1194,6 +1257,8 @@ module PWN
1194
1257
 
1195
1258
  public_class_method def self.install_systemd_user_job(opts = {})
1196
1259
  job = opts[:job]
1260
+ return { skipped: true, reason: :housekeeping_internal_only } if housekeeping_job?(job: job)
1261
+
1197
1262
  apply = apply_native?(opts)
1198
1263
  cal = cron_to_calendar(schedule: job[:schedule])
1199
1264
  oncal = systemd_on_calendar(calendar: cal)
@@ -1272,7 +1337,7 @@ module PWN
1272
1337
  label = LAUNCHD_WORKER_LABEL
1273
1338
  plist = launchd_plist(
1274
1339
  label: label,
1275
- argv: ruby_eval_argv(snippet: 'require "pwn"; PWN::Cron.start_worker(restart: false, foreground: true)'),
1340
+ argv: ruby_eval_argv(snippet: 'ENV["PWN_CRON_NATIVE"] = "1"; require "pwn"; PWN::Cron.start_worker(restart: false, foreground: true)'),
1276
1341
  keepalive: true,
1277
1342
  run_at_load: true
1278
1343
  )
@@ -1283,6 +1348,8 @@ module PWN
1283
1348
 
1284
1349
  public_class_method def self.install_launchd_job(opts = {})
1285
1350
  job = opts[:job]
1351
+ return { skipped: true, reason: :housekeeping_internal_only } if housekeeping_job?(job: job)
1352
+
1286
1353
  apply = apply_native?(opts)
1287
1354
  ints = launchd_calendar_interval(schedule: job[:schedule])
1288
1355
  return { backend: :launchd, id: job[:id], skipped: true, reason: :no_calendar } unless ints
@@ -1325,7 +1392,7 @@ module PWN
1325
1392
 
1326
1393
  public_class_method def self.install_schtasks_worker(opts = {})
1327
1394
  apply = apply_native?(opts)
1328
- tr = ruby_eval_shell(snippet: 'require "pwn"; PWN::Cron.ensure_worker(restart: false)')
1395
+ tr = ruby_eval_shell(snippet: 'ENV["PWN_CRON_NATIVE"] = "1"; require "pwn"; PWN::Cron.ensure_worker(restart: false)')
1329
1396
  argv = ['schtasks', '/Create', '/F', '/TN', SCHTASKS_WORKER_NAME, '/SC', 'ONLOGON', '/RL', 'LIMITED', '/TR', tr]
1330
1397
  write_native_preview(name: 'worker.schtasks.txt', body: argv.inspect)
1331
1398
  run_argv(argv: argv) if apply && schtasks_available?
@@ -1334,6 +1401,8 @@ module PWN
1334
1401
 
1335
1402
  public_class_method def self.install_schtasks_job(opts = {})
1336
1403
  job = opts[:job]
1404
+ return { skipped: true, reason: :housekeeping_internal_only } if housekeeping_job?(job: job)
1405
+
1337
1406
  apply = apply_native?(opts)
1338
1407
  spec = schtasks_spec(schedule: job[:schedule])
1339
1408
  return { backend: :schtasks, id: job[:id], skipped: true, reason: :no_calendar } unless spec
@@ -1568,6 +1637,7 @@ module PWN
1568
1637
  script: 'optional - path to external script',
1569
1638
  delivery: 'optional - log|stdout (default log)',
1570
1639
  enabled: 'optional - enabled value consumed by #create',
1640
+ sync_native: 'optional - synchronize generic native jobs (default true); defaults are internal only',
1571
1641
  install_crontab: 'optional - install crontab value consumed by #create'
1572
1642
  )
1573
1643
 
@@ -1578,7 +1648,8 @@ module PWN
1578
1648
 
1579
1649
  # Run remove and return its result
1580
1650
  #{self}.remove(
1581
- id: 'optional - id value consumed by #remove'
1651
+ id: 'optional - id value consumed by #remove',
1652
+ sync_native: 'optional - false deletes only the schedule, preserving native schedulers and evidence'
1582
1653
  )
1583
1654
 
1584
1655
  # Run enable and return its result