pwn 0.5.782 → 0.5.783
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/documentation/Installation.md +33 -9
- data/documentation/pwn-REPL.md +31 -3
- data/documentation/pwn-ai-Agent.md +62 -2
- data/etc/default_cron/session_cleanup.rb +335 -42
- data/lib/pwn/ai/agent/curriculum.rb +2 -2
- data/lib/pwn/ai/agent/learning.rb +11 -4
- data/lib/pwn/ai/agent/mistakes.rb +22 -3
- data/lib/pwn/ai/agent/tools/sessions.rb +6 -6
- data/lib/pwn/ai/http_retry.rb +3 -0
- data/lib/pwn/ai/router.rb +3 -1
- data/lib/pwn/cron.rb +1 -1
- data/lib/pwn/memory.rb +62 -6
- data/lib/pwn/plugins/repl/ai.rb +13 -3
- data/lib/pwn/plugins/repl/ai_console.rb +37 -21
- data/lib/pwn/session_trace.rb +1 -1
- data/lib/pwn/sessions.rb +305 -80
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/learning_spec.rb +65 -2
- data/spec/lib/pwn/ai/agent/swarm_spec.rb +3 -3
- data/spec/lib/pwn/memory_spec.rb +58 -1
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +191 -6
- data/spec/lib/pwn/plugins/repl/ai_result_chronology_spec.rb +2 -2
- data/spec/lib/pwn/plugins/repl/ai_spec.rb +28 -11
- data/spec/lib/pwn/session_cleanup_default_spec.rb +2 -2
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +467 -36
- data/spec/lib/pwn/sessions_spec.rb +116 -16
- data/third_party/pwn_rdoc.jsonl +12 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 43f675e7e241c2f9c2ac2b96c52b43259b337871bc3227fb2cbac832457f7f23
|
|
4
|
+
data.tar.gz: c053c15cff377d88ee43b25103203928eaa037c3bca976fd6d6483d5543ad5fd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eca4d79da6a33b24ec42cbc39c7c07afd0cfec053f98a49655b2bd6ef3c93bbba32f91aa90e755fe88eb8ec7bb13db892f87ade9e6b715b2d14f0b867415e576
|
|
7
|
+
data.tar.gz: 8c8dad0dd07d97a0b4941fadf03683dd14cfe7fe045c06431d7f725c39ea4dadaac76988a722efaa9186abe71fa4ba731b95c8ae0fa58b1efd0d7baa367f45b1
|
|
@@ -221,12 +221,18 @@ incomplete metadata is unknown, never named. `/sessions list` and cleanup share
|
|
|
221
221
|
`title_source`, `is_summarized`, `classification` and `title_reason`. Classification
|
|
222
222
|
does not establish deletion safety: malformed records, symlinks and uncertain pin
|
|
223
223
|
stores remain protected. Age alone does not preserve an unsummarized session.
|
|
224
|
-
Version
|
|
225
|
-
call that bypasses the ordinary age pin.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
224
|
+
Version 7 backs up unreferenced unsummarized sessions before a guarded `Sessions.delete`
|
|
225
|
+
call that bypasses the ordinary age pin. `/sessions list` shows only valid
|
|
226
|
+
LLM-summary-title or valid dependency-backed sessions, excluding sessions with
|
|
227
|
+
attributed dependency errors. Active leases protect bytes even when a session
|
|
228
|
+
is hidden. Explicit resume and `Sessions.list(include_hidden: true)` still reach
|
|
229
|
+
hidden records. `dependencies` lists files **referencing this session**, not
|
|
230
|
+
files it depends on. Per-session `dependency_errors` contains only safely
|
|
231
|
+
attributed errors; `unresolved_dependency_stores` reports global uncertainty.
|
|
232
|
+
The index's `errors` remains the unattributed store list: it blocks apply and is
|
|
233
|
+
reported as `unresolved_store_remediation`, never copied onto every session.
|
|
234
|
+
Parsed rows are validated independently so an invalid shared row does not
|
|
235
|
+
invalidate unrelated good records.
|
|
230
236
|
The shared index follows defined structural fields, not ID-looking text.
|
|
231
237
|
Dependency-backed sessions remain visible and resumable, not archived away.
|
|
232
238
|
Vault/config/credential payloads and historical backups are not scanned.
|
|
@@ -242,9 +248,27 @@ are rechecked under per-ID/transcript locks. `ledger.json` records exact IDs,
|
|
|
242
248
|
hashes, skips and completed deletions; dry-run JSON contains metadata only and
|
|
243
249
|
creates no archive. Backups are for recovery, not an automatic reference
|
|
244
250
|
resolver: referenced placeholders remain in the active list until a separately
|
|
245
|
-
verified archive-aware lookup policy exists.
|
|
246
|
-
|
|
247
|
-
|
|
251
|
+
verified archive-aware lookup policy exists. Invalid learning evidence rows with
|
|
252
|
+
one proven session owner can be removed with their inactive session under the
|
|
253
|
+
Learning writer lock. The entire original shared store is backed up; only the
|
|
254
|
+
invalid row is removed, keeping unrelated rows byte-for-byte. Prepared/committed
|
|
255
|
+
`dependency_removals` ledger entries record hashes, row locators and backup paths.
|
|
256
|
+
Approval and locked rechecks bind both transcript and dependency fingerprints.
|
|
257
|
+
Valid references still protect a session; conflicting valid/invalid references,
|
|
258
|
+
multiple owners, and attributed stores without a cooperating permanent writer
|
|
259
|
+
lock report `unresolved_dependency_remediation` and remain physically untouched.
|
|
260
|
+
Memory, its archive, and Mistakes now use permanent reentrant store transactions
|
|
261
|
+
covering complete read-modify-write operations, including Learning consolidation
|
|
262
|
+
and noise purging. Single-owner invalid map entries can be removed under those
|
|
263
|
+
locks with whole-store backups and prepared/committed ledgers; unrelated map
|
|
264
|
+
entries retain their values (JSON formatting may change). Stale expiry snapshots
|
|
265
|
+
cannot remove refreshed memory. Corrupt Mistakes input raises rather than
|
|
266
|
+
falling back to an empty map. Other stores (including disputed learning, findings,
|
|
267
|
+
policy trajectories and event logs) remain unresolved when no safe targeted writer
|
|
268
|
+
protocol exists. Deploy all cooperating writers before enabling remediation; old
|
|
269
|
+
installed writers do not honor the new locks. No shared store is wiped. The process
|
|
270
|
+
checks and advisory locks do not claim a transactional guarantee against
|
|
271
|
+
arbitrary non-cooperating external writers.
|
|
248
272
|
|
|
249
273
|
The cleanup-specific pins additionally preserve every session ID referenced in
|
|
250
274
|
retained learning, mistake and durable memory stores, including non-gold learning
|
data/documentation/pwn-REPL.md
CHANGED
|
@@ -45,9 +45,37 @@ Aliases shared by multiple sessions are ambiguous too; use a stable ID.
|
|
|
45
45
|
`dependencies` means **referenced by**: files containing defined structural
|
|
46
46
|
references to that session. Transcript prose, prompts, tool results and quoted
|
|
47
47
|
JSON—including previous `/sessions list` output—are not dependencies. An empty
|
|
48
|
-
array means no known structural references
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
array means no known structural references. `dependency_errors` contains only
|
|
49
|
+
errors attributed to this session; `unresolved_dependency_stores` reports global
|
|
50
|
+
uncertainty without assigning blame. Lists always include the current active
|
|
51
|
+
session, even while its title is provisional or summary generation failed.
|
|
52
|
+
Other rows require valid summary provenance or valid structural dependencies.
|
|
53
|
+
This display exception does not confer summary provenance or bypass cleanup:
|
|
54
|
+
activity leases still prevent physical deletion. Explicit resume remains available.
|
|
55
|
+
`/sessions list [offset] [limit]` defaults to the newest page, ordered oldest-first
|
|
56
|
+
within that page. An explicit offset starts at that oldest-first position.
|
|
57
|
+
The current session is marked `active: true` and included once even outside the
|
|
58
|
+
requested page (so at most limit + 1 rows). `total` counts unique visible sessions;
|
|
59
|
+
`offset` and `has_more` describe the base page, not the extra active row.
|
|
60
|
+
Global unresolved stores and specific diagnostics are reported once in the envelope.
|
|
61
|
+
Title workers capture the active provider/model when the request starts, summarize
|
|
62
|
+
only that session's persisted conversation, and retry on resume. Pending work is
|
|
63
|
+
coalesced per session rather than dropping another session's update. Failed or
|
|
64
|
+
invalid generation keeps the previous title and cursor, and reports a warning
|
|
65
|
+
without exposing provider payloads. It never fabricates `title_source: summary`.
|
|
66
|
+
`/sessions cleanup` applies the bundled cron maintenance operation; use
|
|
67
|
+
`/sessions cleanup --dry-run` for a read-only preview. Migration preserves full
|
|
68
|
+
redacted learning snapshots and their offline readers; it does not manufacture
|
|
69
|
+
lossless migrations for other stores. Unknown global ownership blocks apply.
|
|
70
|
+
Prepared/committed maintenance ledgers and recoverable backups live outside
|
|
71
|
+
ordinary backup pruning. Old writers must be quiesced before live deployment;
|
|
72
|
+
updating a helper alone does not update a running process.
|
|
73
|
+
Request checkpoints use a version-1, hash-bound identity adapter: their session
|
|
74
|
+
field is provenance because restore consumes the checkpoint itself. Unknown
|
|
75
|
+
versions, fields and mismatched paths remain unresolved; checkpoint bytes are
|
|
76
|
+
never rewritten by cleanup. Other unmigrated evidence edges remain retained.
|
|
77
|
+
`Sessions.list(include_hidden: true)` is the diagnostic inventory. See
|
|
78
|
+
Installation's session-cleanup schema table and remediation limitations.
|
|
51
79
|
`title_source`, `is_summarized`, `classification` and `title_reason` expose the
|
|
52
80
|
same strict provenance used by cleanup. Only persisted `summary` provenance with
|
|
53
81
|
a nonblank summary/title and positive integer summary cursor counts as named.
|
|
@@ -39,8 +39,68 @@ The curses console exposes these local views through the command menu:
|
|
|
39
39
|
Running phase checkpoints are atomically persisted before publication, including
|
|
40
40
|
actual check records, uncovered requirements and repair reasons.
|
|
41
41
|
Worktrees isolate edits, **not** arbitrary code execution or network access.
|
|
42
|
-
* `/sessions
|
|
43
|
-
|
|
42
|
+
* `/sessions list [offset] [limit]` is a page, not a retention limit. The
|
|
43
|
+
envelope reports `returned`, visible `total`, `physical_sessions`,
|
|
44
|
+
`filtered_sessions`, `has_previous` and `has_more`, plus a disjoint root-entry
|
|
45
|
+
`storage` breakdown. Filtered transcripts remain physically retained and explicitly
|
|
46
|
+
resumable. Neither pagination nor layout migration deletes summarized sessions.
|
|
47
|
+
* Offline housekeeping migration is separate from ordinary cleanup. After deploying
|
|
48
|
+
matching source and stopping **all** launchers, workers and cron schedulers, run
|
|
49
|
+
from the checkout:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
require 'pwn'
|
|
53
|
+
require './etc/default_cron/session_cleanup'
|
|
54
|
+
PWN::SessionCleanup.reorganize # metadata-only plan; no changes
|
|
55
|
+
PWN::SessionCleanup.reorganize(dry_run: false, writers_stopped: true)
|
|
56
|
+
PWN::SessionCleanup.run(dry_run: true) # inspect remaining retention categories
|
|
57
|
+
# Only after reviewing the ledger and approving cleanup:
|
|
58
|
+
PWN::SessionCleanup.run(dry_run: false)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Launch Ruby with `bundle exec ruby -Ilib` so this uses matching source, not an
|
|
62
|
+
older installed gem. `writers_stopped` is an operator assertion, not a way to
|
|
63
|
+
bypass held leases or unknown legacy owners. Backups and prepared/committed
|
|
64
|
+
ledgers live in private `~/.pwn/session_cleanup_archive` directories. The layout
|
|
65
|
+
migration moves unreferenced metadata and lease inodes under `sessions/support`;
|
|
66
|
+
new trace support also uses that layout. Existing trace paths and dependency-backed
|
|
67
|
+
metadata stay at their exact locations to preserve evidence anchors and are
|
|
68
|
+
reported in `retained_support`. Canonical transcripts stay at the root. Abandoned numeric-PID transcript temp
|
|
69
|
+
files are removed only after exact recoverable backups in this offline operation.
|
|
70
|
+
Held own-process capability markers stay in place; other active owners block.
|
|
71
|
+
Readers accept legacy and compact paths, including partially completed moves.
|
|
72
|
+
Do not restart old binaries after applying the layout. Unknown files, mesh
|
|
73
|
+
mappings and request checkpoints remain untouched. Complete policy trajectories
|
|
74
|
+
are self-contained training provenance, not transcript-retention edges; incomplete
|
|
75
|
+
legacy trajectories and other unresolved evidence consumers still retain sources.
|
|
76
|
+
* Historical IDs are not automatically transcript dependencies. The shared index
|
|
77
|
+
validates self-contained Mistakes records (error/fix/counters), Memory reflection
|
|
78
|
+
values, Extrospection drift observations and Curriculum practice results before
|
|
79
|
+
treating their session IDs as provenance. These stores are not rewritten or
|
|
80
|
+
deleted by this classification. Incomplete records still pin their identified
|
|
81
|
+
source; malformed containers remain diagnostics. No summary or training label
|
|
82
|
+
is invented. Mistakes' previous-turn readers and Memory's session-turn reader
|
|
83
|
+
operate on the caller's current session, not historical occurrence IDs.
|
|
84
|
+
Curriculum's practice log is an audit log, not a replay/evaluation queue.
|
|
85
|
+
* Actual Learning transcript consumers retain full redacted, content-addressed
|
|
86
|
+
evidence through the existing migration before transcript deletion. Sessions.load,
|
|
87
|
+
Curriculum.offline_judge discovery and Reward PRM copy-on-write annotation use
|
|
88
|
+
the verified evidence; source hashes and complete source equality are checked
|
|
89
|
+
before releasing a live transcript. Overlapping provenance records no longer
|
|
90
|
+
prevent that preservation/re-index/deletion transaction.
|
|
91
|
+
* Swarm `personas.json` is different: `Swarm.ask` calls `persona_session`, which
|
|
92
|
+
reuses its ID and acquires a writable-session lease for `Loop.run`. `retire`
|
|
93
|
+
removes an agents.yml registration, not that association; an explicit persona
|
|
94
|
+
or later respawn can reuse it. There is no persisted association-completed or
|
|
95
|
+
retired-session marker. Neither old mtime, an absent registry name nor a
|
|
96
|
+
completed bus message proves a map obsolete. Such mappings remain protected,
|
|
97
|
+
even with a Learning snapshot, because a read-only training artifact is not a
|
|
98
|
+
writable continuation. Cleanup does not silently archive them or impose a cap.
|
|
99
|
+
* `/sessions cleanup` runs the same maintenance operation as bundled cron:
|
|
100
|
+
validate, preserve supported learning evidence, remediate attributable invalid
|
|
101
|
+
records with recoverable backups, re-index, and remove eligible inactive sessions.
|
|
102
|
+
`/sessions cleanup --dry-run` previews without changing evidence or deleting
|
|
103
|
+
sessions. Inspect the exact IDs, paths, fingerprints, retention reasons and
|
|
44
104
|
uncertainty. `a`, then Enter confirms only the displayed candidate set.
|
|
45
105
|
Newly discovered or changed records are excluded. Apply rechecks activity,
|
|
46
106
|
references and source bytes under locks, backs up candidate bytes, and reports
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# PWN managed default: session_cleanup
|
|
3
|
+
# PWN managed default: session_cleanup v9
|
|
4
4
|
# No provider calls or broad store GC. Unsummarized candidates are backed up first.
|
|
5
5
|
require 'pwn'
|
|
6
6
|
require 'json'
|
|
@@ -12,7 +12,7 @@ require 'securerandom'
|
|
|
12
12
|
module PWN
|
|
13
13
|
# Provenance-based session cleanup, shared by seeded cron payloads.
|
|
14
14
|
module SessionCleanup
|
|
15
|
-
VERSION =
|
|
15
|
+
VERSION = 9
|
|
16
16
|
|
|
17
17
|
def self.classify(path, id)
|
|
18
18
|
return :unknown unless path == File.join(PWN::Sessions.sessions_dir, "#{id}.jsonl")
|
|
@@ -100,7 +100,7 @@ module PWN
|
|
|
100
100
|
|
|
101
101
|
# Fingerprints include inode identity and nanosecond timestamps, not bodies.
|
|
102
102
|
def self.snapshot(path)
|
|
103
|
-
[path,
|
|
103
|
+
[path, PWN::Sessions.support_path(kind: :metadata, session_id: File.basename(path, '.jsonl'))].to_h do |file|
|
|
104
104
|
raise 'Refusing symlink' if File.symlink?(file)
|
|
105
105
|
next [File.basename(file), nil] unless File.exist?(file)
|
|
106
106
|
|
|
@@ -138,7 +138,7 @@ module PWN
|
|
|
138
138
|
expected.each do |name, record|
|
|
139
139
|
next unless record
|
|
140
140
|
|
|
141
|
-
source = File.join(File.dirname(path), name)
|
|
141
|
+
source = name.end_with?('.meta.json') ? PWN::Sessions.support_path(kind: :metadata, session_id: File.basename(name, '.meta.json')) : File.join(File.dirname(path), name)
|
|
142
142
|
bytes = File.binread(source)
|
|
143
143
|
raise 'Changed before backup' unless Digest::SHA256.hexdigest(bytes) == record[:sha256]
|
|
144
144
|
|
|
@@ -149,7 +149,278 @@ module PWN
|
|
|
149
149
|
publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result))
|
|
150
150
|
end
|
|
151
151
|
|
|
152
|
+
# Only stores with a cooperating permanent writer lock are auto-remediated.
|
|
153
|
+
# Other attributed errors remain explicit operator work, never broad GC.
|
|
154
|
+
def self.invalid_dependencies(id, references = nil)
|
|
155
|
+
(references || PWN::Sessions.dependency_index)[:invalid_records].select { |row| row[:owners].include?(id) }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.remediable?(id, references = nil)
|
|
159
|
+
rows = invalid_dependencies(id, references)
|
|
160
|
+
!rows.empty? && rows.all? do |row|
|
|
161
|
+
relative = row[:path].delete_prefix("#{File.join(Dir.home, '.pwn')}/")
|
|
162
|
+
supported = (relative == 'learning.jsonl' && row[:locator].is_a?(Integer)) ||
|
|
163
|
+
(%w[memory.json memory.archive.json mistakes.json].include?(relative) && row[:locator].is_a?(String))
|
|
164
|
+
row[:owners] == [id] && supported
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def self.cleanup_state(path, id, references = nil)
|
|
169
|
+
state = classify(path, id)
|
|
170
|
+
return state if state == :unknown
|
|
171
|
+
|
|
172
|
+
status = PWN::Sessions.retention_status(session_id: id, references: references)
|
|
173
|
+
return :invalid_dependency unless status[:dependency_errors].empty?
|
|
174
|
+
|
|
175
|
+
state
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def self.with_dependency_lock(id)
|
|
179
|
+
rows = invalid_dependencies(id)
|
|
180
|
+
raise 'Unresolved dependency remediation' unless rows.empty? || remediable?(id)
|
|
181
|
+
|
|
182
|
+
# Lock even absent stores: a concurrent writer can introduce the first
|
|
183
|
+
# reference between the final index and unlink. These inodes are permanent.
|
|
184
|
+
root = File.join(Dir.home, '.pwn')
|
|
185
|
+
paths = %w[.learning-write.lock memory.json.write.lock mistakes.json.write.lock].map { |name| File.join(root, name) }.sort
|
|
186
|
+
locks = []
|
|
187
|
+
begin
|
|
188
|
+
paths.each do |path|
|
|
189
|
+
lock = File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen -- closed together in ensure
|
|
190
|
+
locks << lock
|
|
191
|
+
raise 'Dependency writer active' unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
192
|
+
end
|
|
193
|
+
yield
|
|
194
|
+
ensure
|
|
195
|
+
locks.reverse_each(&:close)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def self.remove_invalid_dependencies(id, result)
|
|
200
|
+
rows = invalid_dependencies(id)
|
|
201
|
+
return if rows.empty?
|
|
202
|
+
|
|
203
|
+
raise 'Dependency remediation changed' unless remediable?(id)
|
|
204
|
+
|
|
205
|
+
rows.group_by { |row| row[:path] }.each do |path, entries|
|
|
206
|
+
bytes = File.binread(path)
|
|
207
|
+
hash = Digest::SHA256.hexdigest(bytes)
|
|
208
|
+
raise 'Dependency changed' unless entries.all? { |row| row[:sha256] == hash }
|
|
209
|
+
|
|
210
|
+
backup_path = File.join(result[:archive_dir], "dependency-#{SecureRandom.hex(8)}.jsonl")
|
|
211
|
+
publish(backup_path, bytes)
|
|
212
|
+
raise 'Dependency backup failed' unless Digest::SHA256.file(backup_path).hexdigest == hash
|
|
213
|
+
|
|
214
|
+
locators = entries.map { |row| row[:locator] }
|
|
215
|
+
updated = if path.end_with?('.jsonl')
|
|
216
|
+
bytes.lines.each_with_index.reject { |_, index| locators.include?(index) }.map(&:first).join
|
|
217
|
+
else
|
|
218
|
+
map = JSON.parse(bytes)
|
|
219
|
+
raise 'Dependency map changed' unless map.is_a?(Hash) && locators.all? { |key| map.key?(key) }
|
|
220
|
+
|
|
221
|
+
locators.each { |key| map.delete(key) }
|
|
222
|
+
JSON.pretty_generate(map)
|
|
223
|
+
end
|
|
224
|
+
removal = { session_id: id, path: path, backup: backup_path, sha256: hash,
|
|
225
|
+
updated_sha256: Digest::SHA256.hexdigest(updated), locators: locators, state: 'prepared' }
|
|
226
|
+
(result[:dependency_removals] ||= []) << removal
|
|
227
|
+
publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result))
|
|
228
|
+
raise 'Dependency changed after backup' unless File.binread(path) == bytes
|
|
229
|
+
|
|
230
|
+
publish(path, updated)
|
|
231
|
+
# Compare exact bytes even when JSON emits non-ASCII UTF-8 text.
|
|
232
|
+
raise 'Dependency write verification failed' unless File.binread(path) == updated.b
|
|
233
|
+
|
|
234
|
+
removal[:state] = 'committed'
|
|
235
|
+
publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result))
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# The command and bundled cron use this exact maintenance transaction.
|
|
240
|
+
# Legacy writers block all mutations, including evidence migration.
|
|
241
|
+
# One permanent inode serializes CLI, curses and cron maintenance writers.
|
|
242
|
+
# Read-only previews never acquire a writer lock or create lock files.
|
|
243
|
+
def self.with_maintenance_lock(dry_run:)
|
|
244
|
+
return yield if dry_run
|
|
245
|
+
|
|
246
|
+
root = PWN::Sessions.sessions_dir
|
|
247
|
+
raise IOError, 'Unsafe session maintenance directory' unless File.realpath(root) == File.expand_path(root)
|
|
248
|
+
|
|
249
|
+
path = File.join(root, '.maintenance.lock')
|
|
250
|
+
owner = Thread.current[:pwn_session_maintenance_lock]
|
|
251
|
+
return yield if owner && owner[:pid] == Process.pid && owner[:thread] == Thread.current && owner[:path] == path && !owner[:file].closed?
|
|
252
|
+
|
|
253
|
+
File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |file|
|
|
254
|
+
raise IOError, 'Session maintenance already running; wait for its result before retrying' unless file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
255
|
+
|
|
256
|
+
begin
|
|
257
|
+
Thread.current[:pwn_session_maintenance_lock] = { pid: Process.pid, thread: Thread.current, path: path, file: file }
|
|
258
|
+
yield
|
|
259
|
+
ensure
|
|
260
|
+
Thread.current[:pwn_session_maintenance_lock] = owner
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def self.maintain(dry_run: false, current_session_id: nil, proc_root: '/proc')
|
|
266
|
+
with_maintenance_lock(dry_run: dry_run) do
|
|
267
|
+
maintenance_transaction(dry_run: dry_run, current_session_id: current_session_id, proc_root: proc_root)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def self.maintenance_transaction(dry_run:, current_session_id:, proc_root:)
|
|
272
|
+
preflight = sweep(dry_run: true, current_session_id: current_session_id, proc_root: proc_root)
|
|
273
|
+
blocked = !preflight[:legacy_owners].empty? || Array(preflight[:unresolved_store_remediation]).any?
|
|
274
|
+
unless dry_run || blocked
|
|
275
|
+
# Private metadata-only preflight survives an interrupted migration.
|
|
276
|
+
backup(File.join(PWN::Sessions.sessions_dir, 'inventory.jsonl'), {}, preflight)
|
|
277
|
+
ledger = File.join(preflight[:archive_dir], 'maintenance.json')
|
|
278
|
+
publish(ledger, JSON.pretty_generate(state: 'prepared', inventory: preflight))
|
|
279
|
+
blocked = !legacy_owners(proc_root).empty?
|
|
280
|
+
end
|
|
281
|
+
migration = PWN::AI::Agent::Learning.migrate_evidence(dry_run: dry_run || blocked)
|
|
282
|
+
result = if dry_run || blocked
|
|
283
|
+
preflight
|
|
284
|
+
else
|
|
285
|
+
sweep(dry_run: false, current_session_id: current_session_id, proc_root: proc_root)
|
|
286
|
+
end
|
|
287
|
+
result[:dry_run] = dry_run
|
|
288
|
+
result[:apply_blocked] = true if blocked
|
|
289
|
+
result[:evidence_migration] = migration
|
|
290
|
+
result[:remaining_sessions] = Dir.glob(File.join(PWN::Sessions.sessions_dir, '*.jsonl')).length
|
|
291
|
+
result[:visible_sessions] = PWN::Sessions.list.length
|
|
292
|
+
if ledger
|
|
293
|
+
result[:maintenance_ledger] = ledger
|
|
294
|
+
publish(ledger, JSON.pretty_generate(state: blocked ? 'blocked' : 'committed', inventory: preflight, result: result))
|
|
295
|
+
end
|
|
296
|
+
result
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Explicit offline layout migration: never a cron side effect. Stop schedulers
|
|
300
|
+
# and all writers first; old binaries cannot understand the new layout.
|
|
301
|
+
# Transcript files never move. Temporary writer remnants are recoverable.
|
|
302
|
+
def self.reorganize(dry_run: true, writers_stopped: false, proc_root: '/proc')
|
|
303
|
+
raise ArgumentError, 'All writers and schedulers must be stopped' unless dry_run || writers_stopped
|
|
304
|
+
|
|
305
|
+
with_maintenance_lock(dry_run: dry_run) do
|
|
306
|
+
root = PWN::Sessions.sessions_dir
|
|
307
|
+
plan = Dir.children(root).sort.filter_map do |name|
|
|
308
|
+
target = case name
|
|
309
|
+
when /\A\d{8}_\d{6}_[a-f0-9]{8}\.meta\.json\z/ then File.join('support', 'metadata', name)
|
|
310
|
+
when /\A\..+\.cleanup\.lock\z/ then File.join('support', 'leases', name)
|
|
311
|
+
when /\A\.activity-owner-\d+-\d+\.lock\z/ then File.join('support', 'owners', name)
|
|
312
|
+
when /\A\d{8}_\d{6}_[a-f0-9]{8}\.jsonl\.\d+\.tmp\z/ then nil
|
|
313
|
+
else next
|
|
314
|
+
end
|
|
315
|
+
source = File.join(root, name)
|
|
316
|
+
raise IOError, 'Unsafe layout source' unless File.file?(source) && !File.symlink?(source)
|
|
317
|
+
|
|
318
|
+
{ source: source, target: target && File.join(root, target), sha256: Digest::SHA256.file(source).hexdigest,
|
|
319
|
+
inode: File.stat(source).ino, temporary: target.nil? }
|
|
320
|
+
end
|
|
321
|
+
# Existing trace paths may be immutable finding/artifact anchors. Keep
|
|
322
|
+
# those exact paths; new traces use compact storage after the marker.
|
|
323
|
+
retained_support = Dir.glob(File.join(root, '*', 'trace.jsonl'))
|
|
324
|
+
references = PWN::Sessions.dependency_index
|
|
325
|
+
raise IOError, 'Uncertain evidence anchors prevent layout migration' unless references[:errors].empty?
|
|
326
|
+
|
|
327
|
+
plan.reject! do |row|
|
|
328
|
+
next false unless row[:source].end_with?('.meta.json')
|
|
329
|
+
|
|
330
|
+
id = File.basename(row[:source], '.meta.json')
|
|
331
|
+
anchored = !references[:dependencies].fetch(id, []).empty? || !references[:session_errors].fetch(id, []).empty?
|
|
332
|
+
retained_support << row[:source] if anchored
|
|
333
|
+
anchored
|
|
334
|
+
end
|
|
335
|
+
result = { state: 'planned', records: plan, retained_support: retained_support, transcripts: Dir.glob(File.join(root, '*.jsonl')).length }
|
|
336
|
+
next result if dry_run
|
|
337
|
+
|
|
338
|
+
raise IOError, 'Legacy writers remain active' unless legacy_owners(proc_root).empty?
|
|
339
|
+
|
|
340
|
+
handles = []
|
|
341
|
+
begin
|
|
342
|
+
locks = Dir.glob(File.join(root, 'support', '{leases,owners}', '.*.lock'))
|
|
343
|
+
locks.each do |path|
|
|
344
|
+
file = File.open(path, File::RDWR | File::NOFOLLOW) # rubocop:disable Style/FileOpen -- transaction owns all handles
|
|
345
|
+
handles << file
|
|
346
|
+
locked = file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
347
|
+
own_marker = File.basename(path).start_with?(".activity-owner-#{Process.pid}-")
|
|
348
|
+
raise IOError, 'Session writers are active' unless locked || own_marker
|
|
349
|
+
end
|
|
350
|
+
plan.reject! do |row|
|
|
351
|
+
next false unless row[:source].end_with?('.lock')
|
|
352
|
+
|
|
353
|
+
# The migration caller can own its capability marker; leave it in
|
|
354
|
+
# place, not a replacement inode. Other held markers block migration.
|
|
355
|
+
file = File.open(row[:source], File::RDWR | File::NOFOLLOW) # rubocop:disable Style/FileOpen -- transaction holds every lock through commit
|
|
356
|
+
handles << file
|
|
357
|
+
locked = file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
358
|
+
own_marker = File.basename(row[:source]).start_with?(".activity-owner-#{Process.pid}-")
|
|
359
|
+
raise IOError, 'Session writers are active' unless locked || own_marker
|
|
360
|
+
|
|
361
|
+
!locked
|
|
362
|
+
end
|
|
363
|
+
backup(File.join(root, 'layout.jsonl'), {}, result)
|
|
364
|
+
ledger = File.join(result[:archive_dir], 'layout.json')
|
|
365
|
+
result[:state] = 'prepared'
|
|
366
|
+
publish(ledger, JSON.pretty_generate(result))
|
|
367
|
+
# Back up every byte before the first move, including abandoned temp
|
|
368
|
+
# output. Do not infer that a temp is safe merely from its age/PID.
|
|
369
|
+
plan.each_with_index do |row, index|
|
|
370
|
+
raise IOError, 'Layout source changed' unless File.stat(row[:source]).ino == row[:inode] && Digest::SHA256.file(row[:source]).hexdigest == row[:sha256]
|
|
371
|
+
raise IOError, 'Layout destination exists' if row[:target] && (File.exist?(row[:target]) || File.symlink?(row[:target]))
|
|
372
|
+
|
|
373
|
+
row[:backup] = File.join(result[:archive_dir], "layout-#{index}.bin")
|
|
374
|
+
publish(row[:backup], File.binread(row[:source]))
|
|
375
|
+
raise IOError, 'Layout backup mismatch' unless Digest::SHA256.file(row[:backup]).hexdigest == row[:sha256]
|
|
376
|
+
end
|
|
377
|
+
publish(ledger, JSON.pretty_generate(result))
|
|
378
|
+
plan.each do |row|
|
|
379
|
+
raise IOError, 'Layout source changed' unless Digest::SHA256.file(row[:source]).hexdigest == row[:sha256]
|
|
380
|
+
|
|
381
|
+
if row[:temporary]
|
|
382
|
+
File.unlink(row[:source])
|
|
383
|
+
else
|
|
384
|
+
FileUtils.mkdir_p(File.dirname(row[:target]), mode: 0o700)
|
|
385
|
+
raise IOError, 'Unsafe layout destination' unless File.realpath(File.dirname(row[:target])) == File.expand_path(File.dirname(row[:target]))
|
|
386
|
+
|
|
387
|
+
File.rename(row[:source], row[:target])
|
|
388
|
+
raise IOError, 'Layout move mismatch' unless File.stat(row[:target]).ino == row[:inode] && Digest::SHA256.file(row[:target]).hexdigest == row[:sha256]
|
|
389
|
+
end
|
|
390
|
+
row[:moved] = true
|
|
391
|
+
publish(ledger, JSON.pretty_generate(result))
|
|
392
|
+
end
|
|
393
|
+
publish(File.join(root, '.layout-v2'), "2\n")
|
|
394
|
+
result[:state] = 'committed'
|
|
395
|
+
publish(ledger, JSON.pretty_generate(result))
|
|
396
|
+
result
|
|
397
|
+
ensure
|
|
398
|
+
handles.each(&:close)
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def self.summary(result)
|
|
404
|
+
{ version: VERSION, dry_run: result[:dry_run], apply_blocked: result[:apply_blocked] == true,
|
|
405
|
+
scanned: result[:scanned], candidates: result[:candidate_ids].length, deleted: result[:deleted_ids].length,
|
|
406
|
+
remaining: result[:remaining_sessions], visible: result[:visible_sessions], skipped: result[:skipped],
|
|
407
|
+
evidence_migration: result[:evidence_migration], archive_dir: result[:archive_dir],
|
|
408
|
+
unresolved_dependency_stores: result[:dependency_errors] || [], legacy_owners: result[:legacy_owners] }
|
|
409
|
+
end
|
|
410
|
+
|
|
152
411
|
def self.run(dry_run: true, current_session_id: nil, proc_root: '/proc', approved_candidates: nil)
|
|
412
|
+
return sweep(dry_run: dry_run, current_session_id: current_session_id, proc_root: proc_root, approved_candidates: approved_candidates) if approved_candidates
|
|
413
|
+
|
|
414
|
+
maintain(dry_run: dry_run, current_session_id: current_session_id, proc_root: proc_root)
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def self.sweep(dry_run: true, current_session_id: nil, proc_root: '/proc', approved_candidates: nil)
|
|
418
|
+
with_maintenance_lock(dry_run: dry_run) do
|
|
419
|
+
sweep_transaction(dry_run: dry_run, current_session_id: current_session_id, proc_root: proc_root, approved_candidates: approved_candidates)
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def self.sweep_transaction(dry_run:, current_session_id:, proc_root:, approved_candidates:)
|
|
153
424
|
raise 'Session cleanup requires the updated Sessions activity API; load the updated source or deploy it before running this helper' unless PWN::Sessions.respond_to?(:activate)
|
|
154
425
|
|
|
155
426
|
root = PWN::Sessions.sessions_dir
|
|
@@ -158,26 +429,41 @@ module PWN
|
|
|
158
429
|
current = current_session_id || (PWN::Env.dig(:ai, :session_id) if defined?(PWN::Env))
|
|
159
430
|
legacy = legacy_owners(proc_root)
|
|
160
431
|
result = { version: VERSION, dry_run: dry_run, scanned: 0, unnamed: 0, candidate_ids: [], deleted_ids: [], records: [], skipped: Hash.new(0), legacy_owners: legacy }
|
|
161
|
-
|
|
432
|
+
references = PWN::Sessions.dependency_index
|
|
433
|
+
unless references[:errors].empty?
|
|
162
434
|
result[:skipped][:unknown_pins] = 1
|
|
163
|
-
result[:dependency_errors] =
|
|
164
|
-
|
|
435
|
+
result[:dependency_errors] = references[:errors]
|
|
436
|
+
result[:unresolved_store_remediation] = references[:errors]
|
|
165
437
|
end
|
|
166
|
-
|
|
167
|
-
|
|
438
|
+
result[:diagnostics] = references.fetch(:diagnostics, [])
|
|
439
|
+
dependencies = references[:dependencies]
|
|
440
|
+
pins = dependencies.keys
|
|
168
441
|
Dir.glob(File.join(root, '*.jsonl')).each do |path|
|
|
169
442
|
id = File.basename(path, '.jsonl')
|
|
170
443
|
result[:scanned] += 1
|
|
171
|
-
state =
|
|
172
|
-
record = PWN::Sessions.
|
|
444
|
+
state = cleanup_state(path, id, references)
|
|
445
|
+
record = PWN::Sessions.retention_status(session_id: id, references: references).merge(id: id, classification: state, dependencies: dependencies.fetch(id, []))
|
|
173
446
|
result[:records] << record
|
|
174
|
-
unless
|
|
447
|
+
unless references[:errors].empty?
|
|
448
|
+
record[:skip] = :unknown_pins
|
|
449
|
+
next
|
|
450
|
+
end
|
|
451
|
+
unless %i[unnamed invalid_dependency].include?(state)
|
|
175
452
|
result[:skipped][state] += 1
|
|
176
453
|
next
|
|
177
454
|
end
|
|
455
|
+
if state == :invalid_dependency && (!remediable?(id, references) || pins.include?(id))
|
|
456
|
+
record[:skip] = :unresolved_dependency_remediation
|
|
457
|
+
result[:skipped][:unresolved_dependency_remediation] += 1
|
|
458
|
+
next
|
|
459
|
+
end
|
|
178
460
|
expected = snapshot(path)
|
|
461
|
+
expected_dependencies = invalid_dependencies(id, references)
|
|
462
|
+
record[:dependency_records] = expected_dependencies
|
|
179
463
|
record[:files] = expected
|
|
180
|
-
|
|
464
|
+
approval = approved_candidates && approved_candidates[id]
|
|
465
|
+
approved = approval.is_a?(Hash) && approval.key?(:files) ? approval[:files] == expected && approval[:dependency_records] == expected_dependencies : approval == expected && expected_dependencies.empty?
|
|
466
|
+
if approved_candidates && !approved
|
|
181
467
|
record[:skip] = :not_approved_or_changed
|
|
182
468
|
result[:skipped][:not_approved_or_changed] += 1
|
|
183
469
|
next
|
|
@@ -204,7 +490,7 @@ module PWN
|
|
|
204
490
|
next
|
|
205
491
|
end
|
|
206
492
|
# Never unlink lock files: waiters must keep sharing the same inode.
|
|
207
|
-
lock_path =
|
|
493
|
+
lock_path = PWN::Sessions.support_path(kind: :lease, session_id: id)
|
|
208
494
|
raise 'Refusing symlinked cleanup lock' if File.symlink?(lock_path)
|
|
209
495
|
|
|
210
496
|
File.open(lock_path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
|
|
@@ -213,34 +499,39 @@ module PWN
|
|
|
213
499
|
record[:skip] = :locked
|
|
214
500
|
next
|
|
215
501
|
end
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
502
|
+
with_dependency_lock(id) do
|
|
503
|
+
File.open(path, File::RDONLY | File::NOFOLLOW) do |transcript|
|
|
504
|
+
unless transcript.flock(File::LOCK_EX | File::LOCK_NB)
|
|
505
|
+
result[:skipped][:locked] += 1
|
|
506
|
+
record[:skip] = :locked
|
|
507
|
+
next
|
|
508
|
+
end
|
|
509
|
+
# Activation and deletion serialize on the same per-session inode.
|
|
510
|
+
# Legacy ownership uncertainty still requires operator migration.
|
|
511
|
+
unless File.identical?(transcript, path) && cleanup_state(path, id) == state && invalid_dependencies(id) == expected_dependencies && snapshot(path) == expected && legacy_owners(proc_root).empty? && pins_known? && !referenced_session_ids.include?(id)
|
|
512
|
+
result[:skipped][:changed] += 1
|
|
513
|
+
record[:skip] = :changed
|
|
514
|
+
next
|
|
515
|
+
end
|
|
516
|
+
backup(path, expected, result)
|
|
517
|
+
unless snapshot(path) == expected && cleanup_state(path, id) == state && invalid_dependencies(id) == expected_dependencies && legacy_owners(proc_root).empty? && pins_known? && !referenced_session_ids.include?(id)
|
|
518
|
+
result[:skipped][:changed] += 1
|
|
519
|
+
record[:skip] = :changed
|
|
520
|
+
next
|
|
521
|
+
end
|
|
522
|
+
# Only verified, archived, unreferenced unsummarized sessions;
|
|
523
|
+
# current and all known reference pins checked above.
|
|
524
|
+
# The normal delete API's age pin is deliberately bypassed here.
|
|
525
|
+
remove_invalid_dependencies(id, result)
|
|
526
|
+
raise 'Dependencies changed after remediation' unless pins_known? && !referenced_session_ids.include?(id) && invalid_dependencies(id).empty? && snapshot(path) == expected
|
|
527
|
+
|
|
528
|
+
PWN::Sessions.delete(session_id: id, current_session_id: current, force: true, cleanup_lock: lock)
|
|
529
|
+
raise 'Deletion did not remove both session records' if File.exist?(path) || File.exist?(PWN::Sessions.support_path(kind: :metadata, session_id: id))
|
|
530
|
+
|
|
531
|
+
result[:deleted_ids] << id
|
|
532
|
+
record[:deleted] = true
|
|
533
|
+
publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result))
|
|
221
534
|
end
|
|
222
|
-
# Activation and deletion serialize on the same per-session inode.
|
|
223
|
-
# Legacy ownership uncertainty still requires operator migration.
|
|
224
|
-
unless File.identical?(transcript, path) && classify(path, id) == :unnamed && snapshot(path) == expected && legacy_owners(proc_root).empty? && pins_known? && !referenced_session_ids.include?(id)
|
|
225
|
-
result[:skipped][:changed] += 1
|
|
226
|
-
record[:skip] = :changed
|
|
227
|
-
next
|
|
228
|
-
end
|
|
229
|
-
backup(path, expected, result)
|
|
230
|
-
unless snapshot(path) == expected && classify(path, id) == :unnamed && legacy_owners(proc_root).empty? && pins_known? && !referenced_session_ids.include?(id)
|
|
231
|
-
result[:skipped][:changed] += 1
|
|
232
|
-
record[:skip] = :changed
|
|
233
|
-
next
|
|
234
|
-
end
|
|
235
|
-
# Only verified, archived, unreferenced unsummarized sessions;
|
|
236
|
-
# current and all known reference pins checked above.
|
|
237
|
-
# The normal delete API's age pin is deliberately bypassed here.
|
|
238
|
-
PWN::Sessions.delete(session_id: id, current_session_id: current, force: true, cleanup_lock: lock)
|
|
239
|
-
raise 'Deletion did not remove both session records' if File.exist?(path) || File.exist?(path.sub(/\.jsonl\z/, '.meta.json'))
|
|
240
|
-
|
|
241
|
-
result[:deleted_ids] << id
|
|
242
|
-
record[:deleted] = true
|
|
243
|
-
publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result))
|
|
244
535
|
end
|
|
245
536
|
end
|
|
246
537
|
rescue StandardError => e
|
|
@@ -250,11 +541,13 @@ module PWN
|
|
|
250
541
|
publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result)) if result[:archive_dir]
|
|
251
542
|
result
|
|
252
543
|
end
|
|
544
|
+
|
|
545
|
+
private_class_method :with_maintenance_lock, :maintenance_transaction, :sweep_transaction
|
|
253
546
|
end
|
|
254
547
|
end
|
|
255
548
|
|
|
256
549
|
if $PROGRAM_NAME == __FILE__
|
|
257
550
|
raise 'Use --dry-run or --apply' unless ARGV.length == 1 && %w[--dry-run --apply].include?(ARGV.first)
|
|
258
551
|
|
|
259
|
-
puts JSON.generate(PWN::SessionCleanup.
|
|
552
|
+
puts JSON.generate(PWN::SessionCleanup.summary(PWN::SessionCleanup.maintain(dry_run: ARGV.first == '--dry-run')))
|
|
260
553
|
end
|
|
@@ -229,9 +229,9 @@ module PWN
|
|
|
229
229
|
commit = opts.key?(:commit) ? opts[:commit] : true
|
|
230
230
|
cutoff = Time.now.utc - (since_h * 3_600)
|
|
231
231
|
|
|
232
|
-
#
|
|
232
|
+
# Offline evidence discovery must include sessions hidden from operator lists.
|
|
233
233
|
sids = if PWN::Sessions.respond_to?(:list)
|
|
234
|
-
Array(PWN::Sessions.list)
|
|
234
|
+
Array(PWN::Sessions.list(include_hidden: true))
|
|
235
235
|
.sort_by { |s| s.is_a?(Hash) ? s[:mtime].to_s : '' }
|
|
236
236
|
.reverse
|
|
237
237
|
.first(limit * 2)
|