pwn 0.5.782 → 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 (64) 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 +280 -15
  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/documentation/pwn-REPL.md +31 -3
  15. data/documentation/pwn-ai-Agent.md +62 -2
  16. data/etc/default_cron/session_cleanup.rb +1544 -44
  17. data/lib/pwn/ai/agent/curriculum.rb +2 -2
  18. data/lib/pwn/ai/agent/extrospection.rb +6 -7
  19. data/lib/pwn/ai/agent/learning.rb +59 -11
  20. data/lib/pwn/ai/agent/mistakes.rb +22 -3
  21. data/lib/pwn/ai/agent/policy.rb +5 -0
  22. data/lib/pwn/ai/agent/swarm.rb +31 -16
  23. data/lib/pwn/ai/agent/tools/sessions.rb +6 -6
  24. data/lib/pwn/ai/http_retry.rb +3 -0
  25. data/lib/pwn/ai/router.rb +48 -3
  26. data/lib/pwn/cron.rb +120 -49
  27. data/lib/pwn/memory.rb +62 -6
  28. data/lib/pwn/migrate.rb +292 -15
  29. data/lib/pwn/plugins/binary_parser.rb +5 -4
  30. data/lib/pwn/plugins/ghidra.rb +4 -3
  31. data/lib/pwn/plugins/log.rb +18 -2
  32. data/lib/pwn/plugins/repl/ai.rb +25 -4
  33. data/lib/pwn/plugins/repl/ai_console.rb +41 -21
  34. data/lib/pwn/plugins/repl/ai_swarm.rb +4 -4
  35. data/lib/pwn/session_trace.rb +1 -1
  36. data/lib/pwn/sessions.rb +359 -92
  37. data/lib/pwn/setup.rb +9 -17
  38. data/lib/pwn/version.rb +1 -1
  39. data/scripts/build_gate.rb +58 -0
  40. data/spec/integration/build_gate_spec.rb +73 -0
  41. data/spec/integration/persistence_roundtrip_spec.rb +1 -7
  42. data/spec/lib/pwn/ai/agent/learning_spec.rb +65 -2
  43. data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
  44. data/spec/lib/pwn/ai/agent/swarm_spec.rb +3 -3
  45. data/spec/lib/pwn/ai/router_spec.rb +30 -2
  46. data/spec/lib/pwn/cron_spec.rb +113 -6
  47. data/spec/lib/pwn/memory_spec.rb +58 -1
  48. data/spec/lib/pwn/migrate_spec.rb +160 -5
  49. data/spec/lib/pwn/plugins/log_spec.rb +7 -0
  50. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +203 -6
  51. data/spec/lib/pwn/plugins/repl/ai_result_chronology_spec.rb +2 -2
  52. data/spec/lib/pwn/plugins/repl/ai_spec.rb +28 -11
  53. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +11 -0
  54. data/spec/lib/pwn/redaction_spec.rb +13 -10
  55. data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
  56. data/spec/lib/pwn/session_cleanup_default_spec.rb +166 -3
  57. data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
  58. data/spec/lib/pwn/session_cleanup_payload_spec.rb +800 -36
  59. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +389 -0
  60. data/spec/lib/pwn/sessions_spec.rb +228 -16
  61. data/spec/lib/pwn/setup_spec.rb +3 -1
  62. data/spec/support/gate_failure_formatter.rb +14 -0
  63. data/third_party/pwn_rdoc.jsonl +27 -1
  64. metadata +14 -7
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.run(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
data/lib/pwn/memory.rb CHANGED
@@ -23,6 +23,31 @@ module PWN
23
23
  EPHEMERAL_KEY_PREFIXES = %w[session_].freeze
24
24
  EPHEMERAL_TTL_SECS = 7 * 86_400
25
25
 
26
+ # Permanent writer inode; nested calls share ownership only in this thread
27
+ # and process. Never unlink this lock when replacing a data file.
28
+ public_class_method def self.transaction(opts = {})
29
+ path = File.expand_path(opts[:path] || MEMORY_FILE)
30
+ return yield if transaction_owned?(path: path)
31
+
32
+ FileUtils.mkdir_p(File.dirname(path))
33
+ File.open("#{path}.write.lock", File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
34
+ lock.flock(File::LOCK_EX)
35
+ owners = (Thread.current[:pwn_store_transactions] ||= {})
36
+ owners[path] = Process.pid
37
+ begin
38
+ yield
39
+ ensure
40
+ owners.delete(path)
41
+ end
42
+ end
43
+ end
44
+
45
+ # Test ownership without mistaking an inherited post-fork marker for a lock.
46
+ public_class_method def self.transaction_owned?(opts = {})
47
+ path = File.expand_path(opts[:path] || MEMORY_FILE)
48
+ Thread.current[:pwn_store_transactions]&.fetch(path, nil) == Process.pid
49
+ end
50
+
26
51
  # Supported Method Parameters::
27
52
  # memory = PWN::Memory.load
28
53
  public_class_method def self.load
@@ -47,6 +72,8 @@ module PWN
47
72
  # Supported Method Parameters::
48
73
  # PWN::Memory.save(mem: memory_hash)
49
74
  public_class_method def self.save(opts = {})
75
+ return transaction(path: MEMORY_FILE) { save(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
76
+
50
77
  mem = opts[:mem] ||= {}
51
78
  force = opts[:force] ? true : false
52
79
  FileUtils.mkdir_p(File.dirname(MEMORY_FILE))
@@ -58,8 +85,15 @@ module PWN
58
85
  warn "[pwn-ai/memory] refusing empty overwrite of #{File.size(path)}B #{path} (pass force:true to clear)"
59
86
  return load_raw_or_empty(path: path)
60
87
  end
88
+ atomic_write(path: path, body: JSON.pretty_generate(mem))
89
+ mem
90
+ end
91
+
92
+ # Caller holds the permanent store transaction across read and replacement.
93
+ private_class_method def self.atomic_write(opts = {})
94
+ path = opts[:path]
95
+ body = opts[:body]
61
96
  tmp = File.join(File.dirname(path), ".#{File.basename(path)}.#{Process.pid}.tmp")
62
- body = JSON.pretty_generate(mem)
63
97
  File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o644) do |f|
64
98
  f.flock(File::LOCK_EX)
65
99
  f.write(body)
@@ -67,7 +101,7 @@ module PWN
67
101
  f.fsync
68
102
  end
69
103
  File.rename(tmp, path)
70
- mem
104
+ File.open(File.dirname(path), File::RDONLY, &:fsync)
71
105
  ensure
72
106
  FileUtils.rm_f(tmp) if defined?(tmp) && tmp && File.exist?(tmp)
73
107
  end
@@ -93,6 +127,8 @@ module PWN
93
127
  # ttl: 'optional - seconds until stale (M3; consolidate evicts stale low-conf first)'
94
128
  # )
95
129
  public_class_method def self.remember(opts = {})
130
+ return transaction(path: MEMORY_FILE) { remember(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
131
+
96
132
  key = opts[:key]
97
133
  value = opts[:value]
98
134
  category = opts[:category] || :fact
@@ -479,7 +515,9 @@ module PWN
479
515
  # force: 'optional - Boolean bypass protect policy (default false)'
480
516
  # )
481
517
  # Refuses PROTECT_KEY_PREFIXES / PROTECT_CATEGORIES unless force:true.
482
- public_class_method def self.forget(opts = {}) # rubocop:disable Naming/PredicateMethod
518
+ public_class_method def self.forget(opts = {})
519
+ return transaction(path: MEMORY_FILE) { forget(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
520
+
483
521
  key = opts[:key]
484
522
  raise 'ERROR: key is required' if key.nil?
485
523
 
@@ -498,6 +536,8 @@ module PWN
498
536
  # PWN::Memory.clear(force: true)
499
537
  # Requires force:true — protected prefs/SOPs must not vanish via bare clear.
500
538
  public_class_method def self.clear(opts = {})
539
+ return transaction(path: MEMORY_FILE) { clear(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
540
+
501
541
  force = if opts.is_a?(Hash)
502
542
  opts[:force] ? true : false
503
543
  else
@@ -514,6 +554,8 @@ module PWN
514
554
  # context = PWN::Memory.to_context(limit: 20)
515
555
  # (used internally by pwn-ai hook to inject into system prompt)
516
556
  public_class_method def self.to_context(opts = {})
557
+ return transaction(path: MEMORY_FILE) { to_context(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
558
+
517
559
  limit = (opts[:limit] || 12).to_i
518
560
  limit = 12 if limit <= 0
519
561
  mem = load
@@ -556,14 +598,20 @@ module PWN
556
598
  end
557
599
 
558
600
  private_class_method def self.archive_expired!(opts = {})
601
+ return transaction(path: MEMORY_FILE) { archive_expired!(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
602
+
559
603
  rows = opts[:rows]
560
604
  return if rows.nil? || rows.empty?
561
605
 
606
+ mem = load
607
+ rows = rows.select { |key, value| mem[key] == value }
608
+ return if rows.empty?
609
+
562
610
  arch = File.file?(ARCHIVE_FILE) ? JSON.parse(File.read(ARCHIVE_FILE), symbolize_names: true) : {}
611
+ raise IOError, 'Invalid memory archive' unless arch.is_a?(Hash)
612
+
563
613
  arch.merge!(rows)
564
- FileUtils.mkdir_p(File.dirname(ARCHIVE_FILE))
565
- File.write(ARCHIVE_FILE, JSON.pretty_generate(arch))
566
- mem = load
614
+ atomic_write(path: ARCHIVE_FILE, body: JSON.pretty_generate(arch))
567
615
  rows.each_key { |k| mem.delete(k) }
568
616
  save(mem: mem, force: mem.empty?)
569
617
  rescue StandardError
@@ -590,6 +638,8 @@ module PWN
590
638
  # Compact overlong values and drop expired ephemeral session_* keys.
591
639
  # Never removes protected prefs/SOPs/fix lessons. Safe with empty-save guard.
592
640
  public_class_method def self.lean!(opts = {})
641
+ return transaction(path: MEMORY_FILE) { lean!(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
642
+
593
643
  dry = opts[:dry_run] ? true : false
594
644
  vmax = (opts[:value_max_chars] || VALUE_MAX_CHARS).to_i
595
645
  ttl_secs = (opts[:ephemeral_ttl_secs] || EPHEMERAL_TTL_SECS).to_i
@@ -650,6 +700,12 @@ module PWN
650
700
  # Display Usage for this Module
651
701
  public_class_method def self.help
652
702
  puts "USAGE:
703
+ # Serialize a complete read-modify-write operation.
704
+ #{self}.transaction(path: 'optional - data store path, defaults to memory') { operation }
705
+
706
+ # Check current process and thread ownership.
707
+ #{self}.transaction_owned?(path: 'optional - data store path, defaults to memory')
708
+
653
709
  # Run load and return its result
654
710
  #{self}.load
655
711