ace-overseer 0.17.0 → 0.18.2

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.
@@ -4,19 +4,25 @@ module Ace
4
4
  module Overseer
5
5
  module Models
6
6
  class PruneCandidate
7
- attr_reader :task_id, :worktree_path, :assignment_complete, :task_done, :git_clean, :reasons
7
+ attr_reader :task_id, :worktree_path, :assignment_complete, :task_done, :git_clean,
8
+ :attempts_terminal, :preserved, :verified_head, :verified_branch, :reasons
8
9
 
9
- def initialize(task_id:, worktree_path:, assignment_complete:, task_done:, git_clean:, reasons: [])
10
+ def initialize(task_id:, worktree_path:, assignment_complete:, task_done:, git_clean:,
11
+ attempts_terminal: true, preserved: true, verified_head: nil, verified_branch: nil, reasons: [])
10
12
  @task_id = task_id.to_s.freeze
11
13
  @worktree_path = worktree_path.to_s.freeze
12
14
  @assignment_complete = assignment_complete
13
15
  @task_done = task_done
14
16
  @git_clean = git_clean
17
+ @attempts_terminal = attempts_terminal
18
+ @preserved = preserved
19
+ @verified_head = verified_head
20
+ @verified_branch = verified_branch
15
21
  @reasons = reasons.map(&:to_s).freeze
16
22
  end
17
23
 
18
24
  def safe_to_prune?
19
- assignment_complete && task_done && git_clean
25
+ assignment_complete && task_done && git_clean && attempts_terminal && preserved
20
26
  end
21
27
 
22
28
  def to_h
@@ -26,6 +32,8 @@ module Ace
26
32
  assignment_complete: assignment_complete,
27
33
  task_done: task_done,
28
34
  git_clean: git_clean,
35
+ attempts_terminal: attempts_terminal,
36
+ preserved: preserved,
29
37
  reasons: reasons,
30
38
  safe_to_prune: safe_to_prune?
31
39
  }
@@ -3,10 +3,32 @@
3
3
  module Ace
4
4
  module Overseer
5
5
  module Molecules
6
+ # Fail-closed safety classification for assignment cache pruning.
7
+ #
8
+ # The cache directory of an assignment is disposable only when its
9
+ # durable evidence survives outside the deletion target and every
10
+ # attempt is provably terminal:
11
+ #
12
+ # - the queue label must read completed (never proof by itself),
13
+ # - recorded attempts must be terminal — active or uncertain attempts
14
+ # block, and unreadable attempt state blocks,
15
+ # - managed assignments must carry their durable evidence in
16
+ # refs/ace/execution; a missing or unreadable evidence ref blocks,
17
+ # and journal-derived active/uncertain attempts block even when the
18
+ # local records were lost.
19
+ #
20
+ # The journal checkout and the evidence ref are never deletion targets:
21
+ # cleanup removes only the assignment cache directory.
6
22
  class AssignmentPruneSafetyChecker
7
- def initialize(assignment_discoverer_factory: nil, assignment_manager_factory: nil)
23
+ def initialize(assignment_discoverer_factory: nil, assignment_manager_factory: nil,
24
+ journal_factory: nil)
8
25
  @assignment_discoverer_factory = assignment_discoverer_factory || -> { Ace::Assign::Molecules::AssignmentDiscoverer.new }
9
26
  @assignment_manager_factory = assignment_manager_factory || -> { Ace::Assign::Molecules::AssignmentManager.new }
27
+ @journal_factory = journal_factory || -> {
28
+ Ace::Assign::Molecules::EvidenceJournal.new(
29
+ repo_root: Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
30
+ )
31
+ }
10
32
  end
11
33
 
12
34
  def check(assignment_id:)
@@ -20,6 +42,7 @@ module Ace
20
42
  assignment_name: "",
21
43
  assignment_state: "not_found",
22
44
  location_path: "",
45
+ attempts_terminal: false,
23
46
  reasons: ["assignment not found"]
24
47
  )
25
48
  end
@@ -28,14 +51,84 @@ module Ace
28
51
  reasons = []
29
52
  reasons << "assignment still #{state}" unless state == "completed"
30
53
 
54
+ attempts_terminal, attempt_reasons = attempts_evidence(info.assignment, assignment_id)
55
+ reasons.concat(attempt_reasons)
56
+
31
57
  Models::AssignmentPruneCandidate.new(
32
58
  assignment_id: assignment_id,
33
59
  assignment_name: info.assignment.name,
34
60
  assignment_state: state,
35
61
  location_path: info.assignment.cache_dir,
62
+ attempts_terminal: attempts_terminal,
36
63
  reasons: reasons
37
64
  )
38
65
  end
66
+
67
+ private
68
+
69
+ def attempts_evidence(assignment, assignment_id)
70
+ managed = begin
71
+ assignment.managed?
72
+ rescue => e
73
+ return [false, ["assignment lifecycle state unreadable: #{e.message}"]]
74
+ end
75
+
76
+ reasons = []
77
+ local_terminal, local_reasons = local_attempts(assignment_id)
78
+ reasons.concat(local_reasons)
79
+
80
+ if managed
81
+ journal_terminal, journal_reasons = journal_evidence(assignment_id)
82
+ reasons.concat(journal_reasons)
83
+ [local_terminal && journal_terminal, reasons]
84
+ else
85
+ [local_terminal, reasons]
86
+ end
87
+ end
88
+
89
+ def local_attempts(assignment_id)
90
+ attempts = @assignment_manager_factory.call.attempts(assignment_id)
91
+ terminal = true
92
+ reasons = []
93
+ attempts.each do |attempt|
94
+ next unless attempt.active? || attempt.uncertain?
95
+
96
+ terminal = false
97
+ reasons << "attempt #{attempt.attempt_id} is #{attempt.state}"
98
+ end
99
+ [terminal, reasons]
100
+ rescue => e
101
+ [false, ["attempt state unreadable: #{e.message}"]]
102
+ end
103
+
104
+ # The journal is the durable evidence outside the deletion target:
105
+ # managed cleanup requires THIS assignment's journaled attempt
106
+ # history to exist and to agree that no attempt is active or
107
+ # uncertain (an empty journal carries no evidence at all).
108
+ def journal_evidence(assignment_id)
109
+ journal = @journal_factory.call
110
+ ref_value = journal.ref_value
111
+ if ref_value.nil?
112
+ return [false, ["no durable evidence ref for managed assignment #{assignment_id}"]]
113
+ end
114
+
115
+ derived = journal.derived_attempts(assignment_id)
116
+ if derived.empty?
117
+ return [false, ["no durable attempt evidence for managed assignment #{assignment_id} in the journal"]]
118
+ end
119
+
120
+ terminal = true
121
+ reasons = []
122
+ derived.each do |attempt|
123
+ next unless attempt.active? || attempt.uncertain?
124
+
125
+ terminal = false
126
+ reasons << "journaled attempt #{attempt.attempt_id} is #{attempt.state}"
127
+ end
128
+ [terminal, reasons]
129
+ rescue => e
130
+ [false, ["durable evidence unreadable: #{e.message}"]]
131
+ end
39
132
  end
40
133
  end
41
134
  end
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Ace
6
+ module Overseer
7
+ module Molecules
8
+ # Executes Git preservation proofs for prune candidates.
9
+ #
10
+ # A candidate is preserved only through an executed identity:
11
+ #
12
+ # 1. Accepted ancestry — the candidate HEAD is contained in the current
13
+ # tip of a distinct surviving accepted base branch.
14
+ # 2. Declared destination — a verified manifest record whose
15
+ # destination branch accepted both the declared base and head, and
16
+ # whose content matches by full-tree equality or by an exact
17
+ # path-by-path transition (modes, symlinks, binary content) between
18
+ # separate source and destination bases. Patch-range proof requires
19
+ # the independently recorded creation baseline (qjl base_head); a
20
+ # missing or mismatching baseline disables it. The baseline itself
21
+ # must be preserved on a surviving accepted ref.
22
+ #
23
+ # Everything else — missing, failed or ambiguous evidence — blocks.
24
+ # All Git access is argv-based; no refs are fetched into any repo.
25
+ class GitPreservationChecker
26
+ DEFAULT_GIT_RUNNER = ->(repo, *args) { Open3.capture3("git", "-C", repo, *args) }
27
+
28
+ attr_reader :git_runner
29
+
30
+ def initialize(git_runner: DEFAULT_GIT_RUNNER)
31
+ @git_runner = git_runner
32
+ end
33
+
34
+ # The surviving accepted base of the repository: the main checkout's
35
+ # current branch and its live tip.
36
+ #
37
+ # @param repo_root [String] Common repository root
38
+ # @return [Hash, nil] `{branch:, head:}` or nil when unresolvable
39
+ def accepted_base_for(repo_root)
40
+ branch = rev_parse(repo_root, "--abbrev-ref", "HEAD")
41
+ return nil if branch.nil? || branch.empty? || branch == "HEAD"
42
+
43
+ head = rev_parse(repo_root, "--verify", "HEAD")
44
+ return nil if head.nil?
45
+
46
+ {branch: branch, head: head}
47
+ end
48
+
49
+ # The common repository of a worktree candidate.
50
+ #
51
+ # @param worktree_path [String]
52
+ # @return [String, nil] Absolute repository root or nil
53
+ def source_repo_for(worktree_path)
54
+ common_dir = rev_parse(worktree_path, "--path-format=absolute", "--git-common-dir")
55
+ return nil if common_dir.nil? || common_dir.empty?
56
+
57
+ File.dirname(common_dir)
58
+ end
59
+
60
+ # Prove preservation for one candidate.
61
+ #
62
+ # @param worktree_path [String] Candidate worktree
63
+ # @param accepted_base [Hash, nil] `{branch:, head:}` surviving base
64
+ # @param manifest_record [PreservationManifest::Record, nil] Declared
65
+ # destination (a claim; verified here)
66
+ # @param recorded_base [String, nil] Independently recorded creation
67
+ # baseline (attempt base_head), outside the manifest
68
+ # @param candidate_branch [String, nil] The candidate's own branch,
69
+ # scheduled for deletion with the worktree
70
+ # @return [Models::PreservationProof]
71
+ def proof(worktree_path:, accepted_base:, manifest_record: nil, recorded_base: nil, candidate_branch: nil)
72
+ live_head = rev_parse(worktree_path, "--verify", "HEAD")
73
+ return blocked("cannot resolve candidate HEAD") if live_head.nil?
74
+
75
+ if accepted_base && accepted_base[:head] == live_head
76
+ return blocked("candidate HEAD equals the accepted base tip; no distinct work to preserve")
77
+ end
78
+
79
+ if ancestry?(worktree_path, live_head, accepted_base[:head])
80
+ return Models::PreservationProof.preserved(:accepted_ancestor, head: live_head)
81
+ end
82
+ return blocked("HEAD #{live_head[0, 12]} is not contained in accepted base " \
83
+ "#{accepted_base[:branch]} (#{accepted_base[:head][0, 12]}) and no destination is declared") if manifest_record.nil?
84
+
85
+ proof_via_destination(worktree_path, live_head, accepted_base, manifest_record, recorded_base, candidate_branch)
86
+ end
87
+
88
+ # Prove preservation for a static identity that has no local
89
+ # checkout (e.g. a Lab Work documenting repo/head/branch): the head
90
+ # must be contained in the current tip of the surviving accepted
91
+ # base branch.
92
+ #
93
+ # @param repo [String] Repository root
94
+ # @param head [String] Commit whose preservation is in question
95
+ # @param accepted_base [Hash] `{branch:, head:}` surviving base
96
+ # @return [Models::PreservationProof]
97
+ def ancestry_proof(repo:, head:, accepted_base:)
98
+ if accepted_base.nil? || head.to_s.empty?
99
+ return blocked("cannot resolve an accepted surviving base to prove preservation")
100
+ end
101
+
102
+ if ancestry?(repo, head, accepted_base[:head])
103
+ Models::PreservationProof.preserved(:accepted_ancestor)
104
+ else
105
+ blocked("documented head #{head[0, 12]} is not contained in accepted base #{accepted_base[:branch]}")
106
+ end
107
+ end
108
+
109
+ private
110
+
111
+ def proof_via_destination(worktree_path, live_head, accepted_base, record, recorded_base, candidate_branch)
112
+ identity_failure = verify_source_identity(worktree_path, live_head, record)
113
+ return identity_failure if identity_failure
114
+
115
+ destination_failure = verify_destination_survival(record, candidate_branch)
116
+ return destination_failure if destination_failure
117
+
118
+ acceptance_failure = verify_destination_acceptance(record)
119
+ return acceptance_failure if acceptance_failure
120
+
121
+ tree_proof = tree_equality_proof(record)
122
+ return tree_proof if tree_proof&.preserved?
123
+
124
+ unless patch_proof_allowed?(record, recorded_base)
125
+ if recorded_base.nil?
126
+ return blocked("patch-range proof is disabled: no independently recorded creation baseline " \
127
+ "(attempt base_head) is available to authorize the declared source range")
128
+ end
129
+
130
+ return blocked("declared source_base does not match the recorded creation baseline " \
131
+ "#{recorded_base[0, 12]}")
132
+ end
133
+
134
+ range_failure = verify_range(record)
135
+ return range_failure if range_failure
136
+
137
+ unless ancestry?(record.source_repo, record.resolved_source_base, accepted_base[:head])
138
+ return blocked("declared source baseline #{record.source_base[0, 12]} itself is not preserved on " \
139
+ "accepted base #{accepted_base[:branch]}; patch-range proof cannot discard it")
140
+ end
141
+
142
+ transition_proof(record)
143
+ end
144
+
145
+ # The manifest is only a claim: the declared source identity must
146
+ # match the candidate as it exists right now.
147
+ def verify_source_identity(worktree_path, live_head, record)
148
+ actual_repo = source_repo_for(worktree_path)
149
+ if actual_repo.nil? || !same_path?(actual_repo, record.source_repo)
150
+ return blocked("declared source_repo #{record.source_repo} is not the candidate's common repository")
151
+ end
152
+
153
+ if record.resolved_source_head != live_head
154
+ return blocked("declared source_head #{record.source_head[0, 12]} does not match the candidate's " \
155
+ "current HEAD #{live_head[0, 12]}")
156
+ end
157
+
158
+ nil
159
+ end
160
+
161
+ # @return [Models::PreservationProof, nil] Proof when trees match
162
+ def tree_equality_proof(record)
163
+ source_tree = rev_parse(record.source_repo, "--verify", "#{record.resolved_source_head}^{tree}")
164
+ destination_tree = rev_parse(record.destination_repo, "--verify", "#{record.resolved_destination_head}^{tree}")
165
+ if source_tree.nil? || destination_tree.nil?
166
+ return blocked("cannot resolve source/destination trees for tree comparison")
167
+ end
168
+
169
+ if source_tree == destination_tree
170
+ Models::PreservationProof.preserved(:tree_equality, head: record.resolved_source_head)
171
+ end
172
+ end
173
+
174
+ def patch_proof_allowed?(record, recorded_base)
175
+ return false if recorded_base.nil?
176
+
177
+ recorded_base == record.resolved_source_base
178
+ end
179
+
180
+ # A destination that IS the candidate's own branch ref is scheduled
181
+ # for deletion with the worktree and cannot survive as the place the
182
+ # work was preserved. Identity is by ref name, not tip: two distinct
183
+ # refs may legitimately point at the same commit, and deleting the
184
+ # candidate ref leaves the other intact. A destination must name a
185
+ # branch ref — a raw SHA can masquerade as a destination while prune
186
+ # deletes the only real branch.
187
+ def verify_destination_survival(record, candidate_branch)
188
+ unless record.destination_branch.to_s.start_with?("refs/heads/")
189
+ return blocked("declared destination_branch #{record.destination_branch} must name a branch ref " \
190
+ "(refs/heads/...)")
191
+ end
192
+
193
+ return nil unless same_path?(record.source_repo, record.destination_repo)
194
+
195
+ if !candidate_branch.to_s.empty? && record.destination_branch_name == candidate_branch
196
+ return blocked("declared destination branch #{record.destination_branch} is the candidate's own " \
197
+ "branch scheduled for deletion and cannot be its surviving destination")
198
+ end
199
+
200
+ nil
201
+ end
202
+
203
+ def verify_destination_acceptance(record)
204
+ branch_tip = rev_parse(record.destination_repo, "--verify", record.destination_branch)
205
+ if branch_tip.nil?
206
+ return blocked("declared destination branch #{record.destination_branch} does not exist in " \
207
+ "#{record.destination_repo}")
208
+ end
209
+
210
+ unless ancestry?(record.destination_repo, record.resolved_destination_head, branch_tip)
211
+ return blocked("declared destination_head #{record.destination_head[0, 12]} is not accepted on " \
212
+ "#{record.destination_branch}")
213
+ end
214
+
215
+ unless ancestry?(record.destination_repo, record.resolved_destination_base, branch_tip)
216
+ return blocked("declared destination_base #{record.destination_base[0, 12]} is not accepted on " \
217
+ "#{record.destination_branch}")
218
+ end
219
+
220
+ nil
221
+ end
222
+
223
+ # The declared ranges must be non-empty: a caller selected empty or
224
+ # truncated range is not evidence. Content correspondence is proven
225
+ # by the exact transition comparison itself — a squash that lands
226
+ # the same content in fewer commits compares equal, a different
227
+ # content never does.
228
+ def verify_range(record)
229
+ source_range = commit_count(record.source_repo, record.resolved_source_base, record.resolved_source_head)
230
+ destination_range = commit_count(
231
+ record.destination_repo, record.resolved_destination_base, record.resolved_destination_head
232
+ )
233
+ if source_range.nil? || destination_range.nil?
234
+ return blocked("cannot count the declared source/destination ranges")
235
+ end
236
+
237
+ if source_range.zero? || destination_range.zero?
238
+ blocked("empty-range identity is not proof; full-tree equality or accepted ancestry required")
239
+ end
240
+ end
241
+
242
+ # Exact path-by-path comparison of the base..head transition on both
243
+ # sides: identical changed paths, modes and blob SHAs. Blob SHAs are
244
+ # content addresses, so equality covers binary content and symlinks;
245
+ # renames compare as delete plus add (--no-renames).
246
+ def transition_proof(record)
247
+ source_transition = transition_map(record.source_repo, record.resolved_source_base, record.resolved_source_head)
248
+ destination_transition = transition_map(
249
+ record.destination_repo, record.resolved_destination_base, record.resolved_destination_head
250
+ )
251
+ if source_transition.nil? || destination_transition.nil?
252
+ return blocked("cannot compare source/destination transitions")
253
+ end
254
+
255
+ if source_transition == destination_transition
256
+ Models::PreservationProof.preserved(:content_transition, head: record.resolved_source_head)
257
+ else
258
+ blocked("declared destination content differs from the source transition")
259
+ end
260
+ end
261
+
262
+ # @return [Hash, nil] `{path => [src_mode, dst_mode, src_blob, dst_blob]}`
263
+ def transition_map(repo, base, head)
264
+ stdout, _stderr, status = git_runner.call(
265
+ repo, "diff-tree", "-r", "--no-renames", "--raw", "-z", base, head
266
+ )
267
+ return nil unless status.success?
268
+
269
+ entries = stdout.to_s.split("\0")
270
+ map = {}
271
+ index = 0
272
+ while index < entries.length
273
+ meta = entries[index]
274
+ index += 1
275
+ break if meta.nil? || meta.empty?
276
+
277
+ path = entries[index]
278
+ index += 1
279
+ next if path.nil?
280
+
281
+ _colon, src_mode, dst_mode, src_blob, dst_blob = meta.split(" ")
282
+ map[path] = [src_mode, dst_mode, src_blob, dst_blob]
283
+ end
284
+ map
285
+ end
286
+
287
+ def commit_count(repo, base, head)
288
+ stdout, _stderr, status = git_runner.call(repo, "rev-list", "--count", "#{base}..#{head}")
289
+ status.success? ? stdout.to_s.strip.to_i : nil
290
+ end
291
+
292
+ def ancestry?(repo, revision, base)
293
+ _stdout, _stderr, status = git_runner.call(repo, "merge-base", "--is-ancestor", revision, base)
294
+ status.success?
295
+ end
296
+
297
+ def rev_parse(repo, *args)
298
+ stdout, _stderr, status = git_runner.call(repo, "rev-parse", *args)
299
+ status.success? ? stdout.to_s.strip : nil
300
+ end
301
+
302
+ def same_path?(left, right)
303
+ File.realpath(left) == File.realpath(right)
304
+ rescue Errno::ENOENT, Errno::EACCES
305
+ false
306
+ end
307
+
308
+ def blocked(reason)
309
+ Models::PreservationProof.blocked(reason)
310
+ end
311
+ end
312
+ end
313
+ end
314
+ end
@@ -29,6 +29,27 @@ module Ace
29
29
  rescue JSON::ParserError => error
30
30
  raise Error, "Lab returned invalid JSON: #{error.message}"
31
31
  end
32
+
33
+ # Whether this surface can make the no-writer state check and the
34
+ # destruction one atomic operation. The raw CLI cannot: between
35
+ # `work status` and `work destroy` another writer may start. Prune
36
+ # treats the path as unsupported (preserve) unless the adapter
37
+ # guarantees atomicity.
38
+ def supports_atomic_destroy?
39
+ false
40
+ end
41
+
42
+ # The authoritative status entry for one Work, or nil when the Work
43
+ # is absent from the surface. Missing state is a blocking condition
44
+ # for prune — never treated as safe.
45
+ #
46
+ # @param work_id [String] Exact Lab Work ID
47
+ # @return [Hash, nil] Status entry or nil
48
+ def work_entry(work_id)
49
+ data = call("work", "status", "--json")
50
+ works = data.is_a?(Hash) ? Array(data["works"]) : []
51
+ works.find { |entry| entry.is_a?(Hash) && entry["id"].to_s == work_id.to_s }
52
+ end
32
53
  end
33
54
  end
34
55
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Overseer
5
+ module Molecules
6
+ # Fail-closed Lab Work classification for prune.
7
+ #
8
+ # A Lab Work may only be destroyed when the authoritative Lab surface
9
+ # reports a documented terminal state AND preservation evidence is
10
+ # available (declared destination proof). Active work, unknown states,
11
+ # unreadable state, or missing preservation evidence block the
12
+ # candidate — including under --yes. When the Lab surface cannot supply
13
+ # the state, the path reports blocked rather than assuming safety.
14
+ class LabPruneSafetyChecker
15
+ STATES_KEY = %w[state status].freeze
16
+ TERMINAL_STATES = %w[completed done archived cancelled].freeze
17
+ ACTIVE_STATES = %w[
18
+ running in_flight in-flight in_progress in-progress
19
+ active pending reserved claimed started queued
20
+ ].freeze
21
+
22
+ Classification = Struct.new(:safe?, :reason, keyword_init: true)
23
+
24
+ # @param lab_client [LabClient] Lab surface adapter
25
+ # @param work_id [String] Exact Lab Work ID
26
+ # @param preservation_proof [Models::PreservationProof, nil] Proof
27
+ # built from the Work's documented preservation data
28
+ # @return [Classification]
29
+ def check(lab_client:, work_id:, preservation_proof: nil)
30
+ entry = read_entry(lab_client, work_id)
31
+ return classify(false, entry) if entry.is_a?(String)
32
+
33
+ value = STATES_KEY.filter_map { |key| entry[key] }.find { |state| state.is_a?(String) }
34
+ return classify(false, "lab state for #{work_id} is unreadable") if value.nil?
35
+
36
+ normalized = value.strip.downcase
37
+ return classify(false, "lab work #{work_id} is #{normalized} (active writer)") if ACTIVE_STATES.include?(normalized)
38
+
39
+ unless TERMINAL_STATES.include?(normalized)
40
+ return classify(false, "lab work #{work_id} state #{normalized.inspect} is not a documented terminal state")
41
+ end
42
+
43
+ if preservation_proof.nil?
44
+ return classify(false, "no preservation evidence for lab work #{work_id}; " \
45
+ "the Lab surface does not document surviving repo/head/branch data")
46
+ end
47
+ unless preservation_proof.preserved?
48
+ return classify(false, preservation_proof.reason)
49
+ end
50
+
51
+ Classification.new(safe?: true, reason: nil)
52
+ end
53
+
54
+ private
55
+
56
+ def read_entry(lab_client, work_id)
57
+ entry = lab_client.work_entry(work_id)
58
+ entry.nil? ? "lab work #{work_id} not found in authoritative status (missing state)" : entry
59
+ rescue Ace::Overseer::Error => e
60
+ "lab state for #{work_id} is unavailable: #{e.message}"
61
+ end
62
+
63
+ def classify(safe, reason)
64
+ Classification.new(safe?: safe, reason: reason)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end