pwn 0.5.783 → 0.5.784
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile +3 -3
- data/Rakefile +19 -1
- data/bin/pwn_setup +1 -1
- data/build_gem.sh +1 -1
- data/documentation/Backup-Retention.md +27 -0
- data/documentation/Cron.md +64 -35
- data/documentation/How-PWN-Works.md +1 -1
- data/documentation/Installation.md +247 -6
- data/documentation/Persistence.md +1 -1
- data/documentation/Reinforcement-Learning.md +5 -6
- data/documentation/diagrams/cron-scheduling.svg +130 -128
- data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
- data/etc/default_cron/session_cleanup.rb +1217 -10
- data/lib/pwn/ai/agent/extrospection.rb +6 -7
- data/lib/pwn/ai/agent/learning.rb +48 -7
- data/lib/pwn/ai/agent/policy.rb +5 -0
- data/lib/pwn/ai/agent/swarm.rb +31 -16
- data/lib/pwn/ai/router.rb +45 -2
- data/lib/pwn/cron.rb +120 -49
- data/lib/pwn/migrate.rb +292 -15
- data/lib/pwn/plugins/binary_parser.rb +5 -4
- data/lib/pwn/plugins/ghidra.rb +4 -3
- data/lib/pwn/plugins/log.rb +18 -2
- data/lib/pwn/plugins/repl/ai.rb +12 -1
- data/lib/pwn/plugins/repl/ai_console.rb +6 -2
- data/lib/pwn/plugins/repl/ai_swarm.rb +4 -4
- data/lib/pwn/sessions.rb +58 -16
- data/lib/pwn/setup.rb +9 -17
- data/lib/pwn/version.rb +1 -1
- data/scripts/build_gate.rb +58 -0
- data/spec/integration/build_gate_spec.rb +73 -0
- data/spec/integration/persistence_roundtrip_spec.rb +1 -7
- data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
- data/spec/lib/pwn/ai/router_spec.rb +30 -2
- data/spec/lib/pwn/cron_spec.rb +113 -6
- data/spec/lib/pwn/migrate_spec.rb +160 -5
- data/spec/lib/pwn/plugins/log_spec.rb +7 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +16 -4
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +11 -0
- data/spec/lib/pwn/redaction_spec.rb +13 -10
- data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +165 -2
- data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +389 -0
- data/spec/lib/pwn/sessions_spec.rb +112 -0
- data/spec/lib/pwn/setup_spec.rb +3 -1
- data/spec/support/gate_failure_formatter.rb +14 -0
- data/third_party/pwn_rdoc.jsonl +15 -1
- metadata +14 -7
|
@@ -3572,16 +3572,15 @@ module PWN
|
|
|
3572
3572
|
[true, log]
|
|
3573
3573
|
when :festival
|
|
3574
3574
|
# Use plugin if text file available
|
|
3575
|
-
|
|
3576
|
-
File.write(tmp, text)
|
|
3575
|
+
require_relative '../../../../etc/default_cron/session_cleanup' unless defined?(PWN::SessionCleanup)
|
|
3577
3576
|
begin
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
data/lib/pwn/ai/agent/policy.rb
CHANGED
|
@@ -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
|
|
data/lib/pwn/ai/agent/swarm.rb
CHANGED
|
@@ -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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
137
|
-
|
|
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 = {})
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
222
|
-
#
|
|
223
|
-
#
|
|
224
|
-
#
|
|
225
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|