ace-overseer 0.20.0 → 0.20.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/nav/protocols/wfi-sources/ace-overseer.yml +1 -1
  3. data/CHANGELOG.md +27 -0
  4. data/README.md +14 -14
  5. data/docs/usage.md +66 -41
  6. data/handbook/workflow-instructions/overseer.wf.md +13 -9
  7. data/handbook/workflow-instructions/roles/lab-coordinator.wf.md +99 -0
  8. data/handbook/workflow-instructions/roles/project-overseer.wf.md +103 -0
  9. data/handbook/workflow-instructions/roles/second-commander.wf.md +93 -0
  10. data/lib/ace/overseer/cli/commands/agents.rb +12 -7
  11. data/lib/ace/overseer/cli/commands/projects.rb +11 -7
  12. data/lib/ace/overseer/cli/commands/prompt.rb +44 -16
  13. data/lib/ace/overseer/cli/commands/prune.rb +51 -123
  14. data/lib/ace/overseer/cli/commands/review.rb +28 -8
  15. data/lib/ace/overseer/cli/commands/status.rb +33 -19
  16. data/lib/ace/overseer/cli/commands/stop.rb +18 -8
  17. data/lib/ace/overseer/cli/commands/work_on.rb +52 -16
  18. data/lib/ace/overseer/cli.rb +6 -9
  19. data/lib/ace/overseer/molecules/launch_request.rb +216 -0
  20. data/lib/ace/overseer/molecules/original_launch_child.rb +165 -0
  21. data/lib/ace/overseer/molecules/proposal_tick.rb +37 -9
  22. data/lib/ace/overseer/molecules/protected_selection.rb +46 -0
  23. data/lib/ace/overseer/molecules/window_opener.rb +9 -2
  24. data/lib/ace/overseer/organisms/launch_recovery.rb +65 -0
  25. data/lib/ace/overseer/organisms/protected_prune.rb +116 -0
  26. data/lib/ace/overseer/organisms/protected_review.rb +167 -0
  27. data/lib/ace/overseer/organisms/protected_status.rb +157 -0
  28. data/lib/ace/overseer/organisms/protected_steering.rb +78 -0
  29. data/lib/ace/overseer/organisms/protected_work_on.rb +102 -0
  30. data/lib/ace/overseer/organisms/work_on_orchestrator.rb +4 -5
  31. data/lib/ace/overseer/version.rb +1 -1
  32. data/lib/ace/overseer.rb +1 -2
  33. metadata +50 -13
  34. data/lib/ace/overseer/cli/commands/prepare.rb +0 -38
  35. data/lib/ace/overseer/molecules/lab_client.rb +0 -56
  36. data/lib/ace/overseer/molecules/lab_prune_safety_checker.rb +0 -69
@@ -1,20 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
  require "json"
3
+ require "ace/lab"
3
4
  module Ace
4
5
  module Overseer
5
6
  module CLI
6
7
  module Commands
7
8
  class Agents < Ace::Support::Cli::Command
8
9
  include Ace::Support::Cli::Base
9
- desc "List configured Lab agents"
10
- def initialize(client: nil)
10
+ desc "List agents visible through maintained public topology"
11
+ option :project, required: true, desc: "Visible installed project ID"
12
+ def initialize(topology: nil)
11
13
  super()
12
- @client = client || Molecules::LabClient.new
14
+ @topology = topology || Ace::Lab::Organisms::TopologyService.from_config
13
15
  end
14
- def call(**)
15
- puts JSON.pretty_generate(@client.call("agents", "--json"))
16
- rescue => error
17
- raise Ace::Support::Cli::Error.new(error.message)
16
+ def call(project:, **extra)
17
+ raise Error, "Unsupported topology options" unless extra.empty?
18
+ result = @topology.agents(project: project)
19
+ puts JSON.generate(result.envelope)
20
+ raise Error, "#{result.error_code}: #{result.message}" unless result.ok?
21
+ rescue StandardError => error
22
+ raise Ace::Support::Cli::Error, error.message
18
23
  end
19
24
  end
20
25
  end
@@ -1,20 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
  require "json"
3
+ require "ace/lab"
3
4
  module Ace
4
5
  module Overseer
5
6
  module CLI
6
7
  module Commands
7
8
  class Projects < Ace::Support::Cli::Command
8
9
  include Ace::Support::Cli::Base
9
- desc "List configured Lab projects"
10
- def initialize(client: nil)
10
+ desc "List projects visible through maintained public topology"
11
+ def initialize(topology: nil)
11
12
  super()
12
- @client = client || Molecules::LabClient.new
13
+ @topology = topology || Ace::Lab::Organisms::TopologyService.from_config
13
14
  end
14
- def call(**)
15
- puts JSON.pretty_generate(@client.call("project", "list", "--json"))
16
- rescue => error
17
- raise Ace::Support::Cli::Error.new(error.message)
15
+ def call(**extra)
16
+ raise Error, "Unsupported topology options" unless extra.empty?
17
+ result = @topology.projects
18
+ puts JSON.generate(result.envelope)
19
+ raise Error, "#{result.error_code}: #{result.message}" unless result.ok?
20
+ rescue StandardError => error
21
+ raise Ace::Support::Cli::Error, error.message
18
22
  end
19
23
  end
20
24
  end
@@ -1,32 +1,60 @@
1
1
  # frozen_string_literal: true
2
+ require "json"
3
+ require_relative "../../organisms/protected_steering"
2
4
  module Ace
3
5
  module Overseer
4
6
  module CLI
5
7
  module Commands
6
8
  class Prompt < Ace::Support::Cli::Command
7
9
  include Ace::Support::Cli::Base
8
- desc "Prompt a running Lab Work from stdin"
9
- option :work, required: true, desc: "Work ID"
10
- option :file, desc: "Read prompt text from a file instead of stdin"
11
- def initialize(client: nil, input: $stdin)
10
+ desc "Prompt an exact protected attempt or inspect its original prompt mutation"
11
+ option :project, required: true, desc: "Installed project ID"
12
+ option :agent, required: true, desc: "Literal installed mapping ID"
13
+ option :assignment, required: true, desc: "Original assignment ID"
14
+ option :attempt, required: true, desc: "Original attempt ID"
15
+ option :mutation, required: true, desc: "Stable original prompt mutation ID"
16
+ option :expected_generation, type: :integer, desc: "Observed original generation; never refreshed"
17
+ option :file, desc: "Read exact bounded prompt bytes from this regular file"
18
+ option :stdin, type: :boolean, default: false, desc: "Read exact bounded prompt bytes from redirected stdin"
19
+ option :status, type: :boolean, default: false, desc: "Read original prompt status without sending input"
20
+ def initialize(steering: nil, input: $stdin)
12
21
  super()
13
- @client = client || Molecules::LabClient.new
22
+ @steering = steering || Organisms::ProtectedSteering.new
14
23
  @input = input
15
24
  end
16
- def call(work:, file: nil, **)
17
- if file
18
- prompt = File.read(file)
25
+ def call(project:, agent:, assignment:, attempt:, mutation:, expected_generation: nil,
26
+ file: nil, stdin: false, status: false, **extra)
27
+ raise Error, "Unsupported prompt options" unless extra.empty?
28
+ text = if status
29
+ raise Error, "Prompt status forbids input selectors and expected generation" if file || stdin || expected_generation
30
+ nil
19
31
  else
20
- if @input.respond_to?(:tty?) && @input.tty?
21
- raise Ace::Support::Cli::Error, "prompt text is required via --file or stdin"
32
+ raise Error, "Select exactly one --file or --stdin" unless (!!file ^ !!stdin)
33
+ file ? file_bytes(file) : stdin_bytes
34
+ end
35
+ result = @steering.prompt(project: project, agent: agent, assignment: assignment, attempt: attempt,
36
+ mutation: mutation, expected_generation: expected_generation, text: text, status: status)
37
+ puts JSON.pretty_generate(result)
38
+ rescue StandardError => error
39
+ raise Ace::Support::Cli::Error, error.message
40
+ end
41
+ private
42
+ def stdin_bytes
43
+ raise Error, "--stdin requires redirected input" if @input.respond_to?(:tty?) && @input.tty?
44
+ @input.read(16_385) || ""
45
+ end
46
+ def file_bytes(path)
47
+ File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
48
+ before = file.stat
49
+ raise Error, "Prompt file must be bounded and regular" unless before.file? && before.size.between?(1, 16_384)
50
+ bytes = file.read(16_385) || ""
51
+ after = file.stat
52
+ identity = ->(stat) { [stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime] }
53
+ unless bytes.bytesize == before.size && identity.call(before) == identity.call(after)
54
+ raise Error, "Prompt file changed during held read"
22
55
  end
23
- prompt = @input.read
56
+ bytes
24
57
  end
25
- raise Ace::Support::Cli::Error, "prompt text must contain non-whitespace characters" if prompt.strip.empty?
26
-
27
- puts @client.call("work", "prompt", work, stdin_data: prompt, json: false)
28
- rescue => error
29
- raise Ace::Support::Cli::Error.new(error.message)
30
58
  end
31
59
  end
32
60
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../../organisms/protected_prune"
4
+
3
5
  module Ace
4
6
  module Overseer
5
7
  module CLI
@@ -19,31 +21,68 @@ module Ace
19
21
  option :quiet, aliases: ["-q"], type: :boolean, default: false,
20
22
  desc: "Suppress progress output; failures still print and signal via exit code"
21
23
  option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
22
- option :runtime, default: "tmux", desc: "Runtime (tmux, lab)"
23
24
  option :preservation, type: :string,
24
25
  desc: "YAML manifest (version 1) declaring verified cross-repository destinations"
25
-
26
- def initialize(orchestrator: nil, input: $stdin, output: $stdout, lab_client: nil,
27
- lab_safety_checker: nil, preservation_checker: nil)
26
+ option :project, desc: "Protected target project"
27
+ option :agent, desc: "Exact protected target mapping"
28
+ option :attempt, desc: "Exact protected target attempt"
29
+ option :request, desc: "Bounded protected prune JSON FILE"
30
+ option :status, type: :boolean, default: false, desc: "Observe the existing protected service request"
31
+ option :mutation, desc: "Stable explicit protected service request ID"
32
+ option :expected_generation, type: :integer, desc: "Expected current authority mutation generation"
33
+ option :authorization, desc: "Existing exact service authorization reference"
34
+
35
+ def initialize(orchestrator: nil, protected_prune: nil, input: $stdin, output: $stdout)
28
36
  super()
29
- @orchestrator = orchestrator || Organisms::PruneOrchestrator.new
37
+ @orchestrator = orchestrator
30
38
  @input = input
31
39
  @output = output
32
- @lab_client = lab_client || Molecules::LabClient.new
33
- @lab_safety_checker = lab_safety_checker || Molecules::LabPruneSafetyChecker.new
34
- @preservation_checker = preservation_checker || Molecules::GitPreservationChecker.new
40
+ @protected_prune = protected_prune || Organisms::ProtectedPrune.new
35
41
  end
36
42
 
37
43
  def call(**options)
38
- runtime = options.fetch(:runtime, "tmux")
39
- if runtime == "lab"
40
- prune_lab(**options)
44
+ if options[:status]
45
+ if options.values_at(:project, :agent, :assignment, :attempt).any?
46
+ raise Ace::Support::Cli::Error, "Protected prune status selects its target only from FILE"
47
+ end
48
+ if options[:dry_run] || options[:yes] || options[:force] || options[:authorization] || options[:expected_generation] ||
49
+ options[:preservation] || !Array(options[:targets]).empty? || options.key?(:runtime)
50
+ raise Ace::Support::Cli::Error, "Protected prune status forbids effect/local options"
51
+ end
52
+ @output.puts JSON.generate(@protected_prune.status(request: options[:request], mutation: options[:mutation]))
53
+ return
54
+ end
55
+ if options[:project]
56
+ unless options.values_at(:project, :agent, :assignment, :attempt, :request).all? { |value| value.is_a?(String) && !value.empty? }
57
+ raise Ace::Support::Cli::Error, "Protected prune requires exact project, agent, assignment, attempt and request FILE"
58
+ end
59
+ unless !options[:force] && !options[:preservation] &&
60
+ Array(options[:targets]).empty? && !options.key?(:runtime)
61
+ raise Ace::Support::Cli::Error, "Protected prune forbids local options"
62
+ end
63
+ selectors = options.slice(:project, :agent, :assignment, :attempt, :request)
64
+ value = if options[:dry_run]
65
+ if options[:yes] || options[:mutation] || options[:authorization] || options[:expected_generation]
66
+ raise Ace::Support::Cli::Error, "Protected preview forbids effect options"
67
+ end
68
+ @protected_prune.preview(**selectors)
69
+ else
70
+ raise Ace::Support::Cli::Error, "Protected apply requires --yes" unless options[:yes]
71
+ @protected_prune.apply(**selectors, mutation: options[:mutation],
72
+ expected_generation: options[:expected_generation], authorization: options[:authorization])
73
+ end
74
+ @output.puts JSON.generate(value)
41
75
  return
42
76
  end
43
- raise Ace::Support::Cli::Error, "unsupported runtime: #{runtime}" unless runtime == "tmux"
77
+ if options.values_at(:agent, :attempt, :request, :mutation, :authorization, :expected_generation).any?
78
+ raise Ace::Support::Cli::Error, "Protected prune options require --project"
79
+ end
80
+ raise Ace::Support::Cli::Error, "Prune uses the configured local runtime; protected physical cleanup has its own owner" if options.key?(:runtime)
44
81
 
45
82
  Atoms::RepoGuard.ensure_repo!
46
83
 
84
+ @orchestrator ||= Organisms::PruneOrchestrator.new
85
+
47
86
  targets = Array(options[:targets] || [])
48
87
  progress = options[:quiet] ? nil : ->(msg) { puts msg }
49
88
  result = @orchestrator.call(
@@ -93,117 +132,6 @@ module Ace
93
132
 
94
133
  private
95
134
 
96
- def prune_lab(**options)
97
- raise Ace::Support::Cli::Error, "--assignment is not supported with Lab runtime; provide exact Work IDs" if options[:assignment]
98
- raise Ace::Support::Cli::Error, "--force is not supported with Lab runtime" if options[:force]
99
- raise Ace::Support::Cli::Error, "--preservation is not supported with Lab runtime" if options[:preservation]
100
-
101
- works = Array(options[:targets]).map(&:to_s)
102
- raise Ace::Support::Cli::Error, "provide at least one exact Lab Work ID to prune" if works.empty?
103
-
104
- classifications = if atomic_destroy_supported?
105
- works.to_h do |work|
106
- [work, @lab_safety_checker.check(
107
- lab_client: @lab_client,
108
- work_id: work,
109
- preservation_proof: lab_preservation_proof(work)
110
- )]
111
- end
112
- else
113
- # The raw Lab surface cannot make the no-writer check and the
114
- # destruction atomic; claiming a probe was safe would be a
115
- # lie, so the whole path is unsupported and preserved.
116
- unsupported = Molecules::LabPruneSafetyChecker::Classification.new(
117
- safe?: false,
118
- reason: "lab runtime cannot make the no-writer check and destruction atomic; " \
119
- "preserve the Work instead"
120
- )
121
- works.to_h { |work| [work, unsupported] }
122
- end
123
-
124
- if options[:dry_run]
125
- print_lab_dry_run(classifications)
126
- return
127
- end
128
-
129
- raise Ace::Support::Cli::Error, "Lab prune requires --yes after reviewing --dry-run" unless options[:yes]
130
-
131
- blocked = []
132
- destroyed = []
133
- works.each do |work|
134
- classification = classifications[work]
135
- unless classification.safe?
136
- blocked << [work, classification.reason]
137
- next
138
- end
139
-
140
- # Guarded delegation: re-read the authoritative state
141
- # immediately before destroy; a changed state aborts.
142
- recheck = @lab_safety_checker.check(
143
- lab_client: @lab_client,
144
- work_id: work,
145
- preservation_proof: lab_preservation_proof(work)
146
- )
147
- unless recheck.safe?
148
- blocked << [work, "state changed before destroy: #{recheck.reason}"]
149
- next
150
- end
151
-
152
- result = @lab_client.call("work", "destroy", work, "--confirm", json: false)
153
- destroyed << work
154
- puts result unless options[:quiet]
155
- end
156
-
157
- blocked.each do |work, reason|
158
- puts "Blocked: lab work #{work}: #{reason}"
159
- end
160
- puts "#{destroyed.length} lab work(s) destroyed." unless options[:quiet]
161
- return if blocked.empty?
162
-
163
- raise Ace::Support::Cli::Error, "#{blocked.length} lab work(s) blocked; nothing unsafe was destroyed"
164
- end
165
-
166
- # Delegation is only honest when the Lab surface itself can make
167
- # the state check and the destruction atomic. The raw CLI adapter
168
- # cannot, so it reports unsupported; adapters with an atomic
169
- # guarded destroy opt in.
170
- def atomic_destroy_supported?
171
- @lab_client.respond_to?(:supports_atomic_destroy?) && @lab_client.supports_atomic_destroy?
172
- end
173
-
174
- # Preservation evidence for a Lab Work comes from the Work's own
175
- # documented surviving identity (repo/head/branch). A surface that
176
- # does not document it cannot prove preservation.
177
- def lab_preservation_proof(work)
178
- entry = @lab_client.work_entry(work)
179
- return Models::PreservationProof.blocked("lab work #{work} not found in authoritative status") if entry.nil?
180
-
181
- repo = entry["repo"].to_s
182
- head = entry["head"].to_s
183
- branch = entry["branch"].to_s
184
- if repo.empty? || head.empty? || branch.empty? || !repo.start_with?("/")
185
- return Models::PreservationProof.blocked(
186
- "lab work #{work} does not document preservation data (repo/head/branch)"
187
- )
188
- end
189
-
190
- accepted_base = @preservation_checker.accepted_base_for(repo)
191
- @preservation_checker.ancestry_proof(repo: repo, head: head, accepted_base: accepted_base)
192
- rescue Ace::Overseer::Error => e
193
- Models::PreservationProof.blocked("lab preservation evidence unavailable: #{e.message}")
194
- end
195
-
196
- def print_lab_dry_run(classifications)
197
- puts "Lab prune classification:"
198
- classifications.each do |work, classification|
199
- if classification.safe?
200
- puts " #{work}: safe (terminal, preserved, no in-flight work)"
201
- else
202
- puts " #{work}: BLOCKED — #{classification.reason}"
203
- end
204
- end
205
- end
206
-
207
135
  def print_assignment_result(result)
208
136
  candidate = result[:assignment_candidate]
209
137
  if result[:dry_run]
@@ -1,20 +1,40 @@
1
1
  # frozen_string_literal: true
2
+ require "json"
3
+ require_relative "../../organisms/protected_review"
4
+
2
5
  module Ace
3
6
  module Overseer
4
7
  module CLI
5
8
  module Commands
6
9
  class Review < Ace::Support::Cli::Command
7
10
  include Ace::Support::Cli::Base
8
- desc "Start an exact-head Lab review"
9
- option :work, required: true, desc: "Work ID"
10
- option :pr, required: true, type: :integer, desc: "Forgejo PR number"
11
- def initialize(client: nil)
11
+ desc "Review an exact protected candidate, inspect its request, or cancel its reservation"
12
+ option :project, required: true, desc: "Installed project ID"
13
+ option :agent, required: true, desc: "Installed agent mapping ID"
14
+ option :assignment, required: true, desc: "Assignment ID"
15
+ option :attempt, required: true, desc: "Attempt ID"
16
+ option :mutation, required: true, desc: "Request or cancel mutation; original request ID for status"
17
+ option :status, type: :boolean, default: false, desc: "Observe the original review request without execution"
18
+ option :cancel, type: :boolean, default: false, desc: "Cancel the exact review reservation"
19
+ option :head, desc: "Exact candidate commit"
20
+ option :candidate_generation, type: :integer, desc: "Exact candidate generation"
21
+ option :expected_generation, type: :integer, desc: "Observed authority generation; never refreshed implicitly"
22
+ option :accept_mutation, desc: "Distinct acceptance mutation for a new review"
23
+ option :review_event, desc: "Exact reservation event to cancel"
24
+
25
+ def initialize(review: nil)
12
26
  super()
13
- @client = client || Molecules::LabClient.new
27
+ @review = review || Organisms::ProtectedReview.new
14
28
  end
15
- def call(work:, pr:, **)
16
- puts @client.call("work", "review", work, "--pr", pr, json: false)
17
- rescue => error
29
+
30
+ def call(project:, agent:, assignment:, attempt:, mutation:, status: false, cancel: false,
31
+ head: nil, candidate_generation: nil, expected_generation: nil, accept_mutation: nil, review_event: nil, **extra)
32
+ raise Error, "Unsupported review options" unless extra.empty?
33
+ result = @review.call(project: project, agent: agent, assignment: assignment, attempt: attempt,
34
+ mutation: mutation, status: status, cancel: cancel, head: head, candidate_generation: candidate_generation,
35
+ expected_generation: expected_generation, accept_mutation: accept_mutation, review_event: review_event)
36
+ puts JSON.pretty_generate(result)
37
+ rescue StandardError => error
18
38
  raise Ace::Support::Cli::Error.new(error.message)
19
39
  end
20
40
  end
@@ -16,39 +16,38 @@ module Ace
16
16
  option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
17
17
  option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
18
18
  option :watch, aliases: ["-w"], type: :boolean, default: false, desc: "Auto-refresh dashboard"
19
- option :runtime, default: "tmux", desc: "Runtime (tmux, lab)"
20
- option :project, desc: "Filter Lab status by project"
19
+ option :runtime, desc: "Runtime (auto, tmux, herdr)"
20
+ option :project, desc: "Read protected canonical status for this project"
21
+ option :agent, desc: "Exact visible protected agent ID (requires --project)"
21
22
 
22
- def initialize(collector: nil, config: nil, lab_client: nil, proposal_tick: nil)
23
+ def initialize(collector: nil, config: nil, protected_status: nil, proposal_tick: nil)
23
24
  super()
24
25
  @collector = collector || Organisms::StatusCollector.new
25
26
  @config = config
26
- @lab_client = lab_client || Molecules::LabClient.new
27
+ @protected_status = protected_status || Organisms::ProtectedStatus.new
27
28
  @proposal_tick = proposal_tick || Molecules::ProposalTick.new
28
29
  end
29
30
 
30
- def call(format:, runtime: "tmux", project: nil, **options)
31
- tick_proposals
32
- if runtime == "lab"
31
+ def call(format:, runtime: nil, project: nil, agent: nil, **options)
32
+ raise Ace::Support::Cli::Error, "--agent requires --project" if agent && project.to_s.empty?
33
+ selected_runtime = runtime || (@config || Ace::Overseer.config)["runtime"] || "auto"
34
+ unless project.to_s.empty?
35
+ unless %w[auto tmux herdr].include?(selected_runtime)
36
+ raise Ace::Support::Cli::Error, "unsupported runtime for protected inventory"
37
+ end
33
38
  if options[:watch]
34
- raise Ace::Support::Cli::Error, "Lab watch runs in the project Herdr status pane; omit --watch"
39
+ raise Ace::Support::Cli::Error, "Protected status is a bounded snapshot; omit --watch"
35
40
  end
36
- arguments = ["work", "status"]
37
- arguments.concat(["--project", project]) unless project.to_s.empty?
38
- arguments << "--json" if format == "json"
39
- output = @lab_client.call(*arguments, json: false)
41
+ value = @protected_status.collect(project: project, agent: agent)
40
42
  unless options[:quiet]
41
43
  warn @proposal_error if @proposal_error
42
- if format == "json" && @proposal_error
43
- value = JSON.parse(output)
44
- output = JSON.pretty_generate(value.merge("proposal_resolution" =>
45
- {"status" => "deferred", "error" => @proposal_error})) if value.is_a?(Hash)
46
- end
47
- puts output
44
+ value = value.merge("proposal_resolution" => {"status" => "deferred", "error" => @proposal_error}) if @proposal_error
45
+ puts(format == "json" ? JSON.pretty_generate(value) : protected_table(value))
48
46
  end
49
47
  return
50
48
  end
51
- raise Ace::Support::Cli::Error, "unsupported runtime: #{runtime}" unless runtime == "tmux"
49
+ raise Ace::Support::Cli::Error, "unsupported local runtime: #{selected_runtime}" unless %w[auto tmux].include?(selected_runtime)
50
+ tick_proposals
52
51
 
53
52
  Atoms::RepoGuard.ensure_repo!
54
53
  return if options[:quiet]
@@ -66,6 +65,21 @@ module Ace
66
65
 
67
66
  private
68
67
 
68
+ def protected_table(value)
69
+ lines = ["#{value.fetch('project_id')}: #{value.fetch('visible_capacity')}/#{value.fetch('provisioned_capacity')} visible mappings (#{value.fetch('visibility')})"]
70
+ value.fetch("agents").each do |agent|
71
+ if agent.fetch("status") != "ok"
72
+ lines << "#{agent.fetch('agent_id')}\tunavailable"
73
+ next
74
+ end
75
+ agent.fetch("inventory").fetch("items").each do |row|
76
+ lines << [agent.fetch("agent_id"), row.fetch("assignment_id"), row.fetch("attempt_id") || "registration",
77
+ row.fetch("canonical_state") || "registered", row.fetch("reservation_release_event_id") ? "released" : "retained"].join("\t")
78
+ end
79
+ end
80
+ lines.join("\n")
81
+ end
82
+
69
83
  def tick_proposals
70
84
  @proposal_tick.call
71
85
  @proposal_error = nil
@@ -1,20 +1,30 @@
1
1
  # frozen_string_literal: true
2
+ require "json"
3
+ require_relative "../../organisms/protected_steering"
2
4
  module Ace
3
5
  module Overseer
4
6
  module CLI
5
7
  module Commands
6
8
  class Stop < Ace::Support::Cli::Command
7
9
  include Ace::Support::Cli::Base
8
- desc "Stop a Lab Work"
9
- option :work, required: true, desc: "Work ID"
10
- def initialize(client: nil)
10
+ desc "Request proof-authenticated stop of an exact protected attempt"
11
+ option :project, required: true, desc: "Installed project ID"
12
+ option :agent, required: true, desc: "Literal installed mapping ID"
13
+ option :assignment, required: true, desc: "Original assignment ID"
14
+ option :attempt, required: true, desc: "Original attempt ID"
15
+ option :mutation, required: true, desc: "Stable stop mutation ID"
16
+ option :expected_generation, type: :integer, required: true, desc: "Observed original generation; never refreshed"
17
+ def initialize(steering: nil)
11
18
  super()
12
- @client = client || Molecules::LabClient.new
19
+ @steering = steering || Organisms::ProtectedSteering.new
13
20
  end
14
- def call(work:, **)
15
- puts @client.call("work", "stop", work, json: false)
16
- rescue => error
17
- raise Ace::Support::Cli::Error.new(error.message)
21
+ def call(project:, agent:, assignment:, attempt:, mutation:, expected_generation:, **extra)
22
+ raise Error, "Unsupported stop options" unless extra.empty?
23
+ result = @steering.stop(project: project, agent: agent, assignment: assignment, attempt: attempt,
24
+ mutation: mutation, expected_generation: expected_generation)
25
+ puts JSON.pretty_generate(result)
26
+ rescue StandardError => error
27
+ raise Ace::Support::Cli::Error, error.message
18
28
  end
19
29
  end
20
30
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../../organisms/launch_recovery"
4
+ require_relative "../../organisms/protected_work_on"
5
+
3
6
  module Ace
4
7
  module Overseer
5
8
  module CLI
@@ -7,35 +10,68 @@ module Ace
7
10
  class WorkOn < Ace::Support::Cli::Command
8
11
  include Ace::Support::Cli::Base
9
12
 
10
- desc "Create worktree, open tmux window, and prepare assignment"
13
+ desc "Start local task work or retain one original protected launch"
11
14
 
12
15
  option :task, aliases: ["-t"], type: :array,
13
16
  desc: "Task reference(s), repeatable and comma-separated (e.g., 230 --task 231,232)"
17
+ option :recover_request, desc: "Read retained protected invocation input and original canonical reservation"
14
18
  option :preset, aliases: ["-p"], desc: "Assignment preset name"
15
- option :runtime, default: "tmux", desc: "Runtime (tmux, lab)"
16
- option :work, desc: "Existing Lab Work ID"
17
- option :agent, desc: "Configured Lab agent ID"
19
+ option :runtime, desc: "Terminal backend (auto, tmux, herdr)"
20
+ option :project, desc: "Explicit protected project ID"
21
+ option :base_head, desc: "Explicit reviewed code base (must match current source HEAD)"
22
+ option :mutation, desc: "Stable retained protected invocation ID"
23
+ option :dependency_report, type: :array, desc: "Selected task:assignment:step report identities"
24
+ option :agent, desc: "Exact visible protected mapping ID (requires project)"
18
25
  option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
19
26
  option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
20
27
 
21
- def initialize(orchestrator: nil, lab_client: nil)
28
+ def initialize(orchestrator: nil, recovery: nil, protected_work_on: nil, config: nil)
22
29
  super()
30
+ @recovery = recovery || Organisms::LaunchRecovery.new
23
31
  @orchestrator = orchestrator || Organisms::WorkOnOrchestrator.new
24
- @lab_client = lab_client || Molecules::LabClient.new
32
+ @protected_work_on = protected_work_on || Organisms::ProtectedWorkOn.new
33
+ @config = config
25
34
  end
26
35
 
27
- def call(task: nil, preset: nil, runtime: "tmux", work: nil, agent: nil, **options)
28
- if runtime == "lab"
29
- if task || preset
30
- raise Ace::Support::Cli::Error, "--task and --preset are not supported with Lab runtime"
36
+ def call(task: nil, preset: nil, runtime: nil, project: nil, agent: nil, mutation: nil, base_head: nil, dependency_report: nil, recover_request: nil, **options)
37
+ raise Ace::Support::Cli::Error, "--work is obsolete; select task/project or retained request" if options.key?(:work)
38
+ if recover_request
39
+ if task || preset || project || agent || runtime || mutation || base_head || dependency_report
40
+ raise Ace::Support::Cli::Error, "--recover-request cannot be combined with launch mode overrides"
31
41
  end
32
- raise Ace::Support::Cli::Error, "--work and --agent are required for Lab runtime" if work.to_s.empty? || agent.to_s.empty?
33
-
34
- output = @lab_client.call("work", "dispatch", work, "--agent", agent, json: false)
35
- puts output unless options[:quiet]
42
+ # Retained identity/canonical attribution remains mandatory even
43
+ # under --quiet. This path never enters preparation or launch.
44
+ puts JSON.generate(@recovery.call(path: recover_request))
36
45
  return
37
46
  end
38
- raise Ace::Support::Cli::Error, "unsupported runtime: #{runtime}" unless runtime == "tmux"
47
+ selected_runtime = runtime || (@config || Ace::Overseer.config)["runtime"] || "auto"
48
+ raise Ace::Support::Cli::Error, "unsupported runtime: #{selected_runtime}" unless %w[auto tmux herdr].include?(selected_runtime)
49
+ if project
50
+ refs = normalize_task_refs(task)
51
+ raise Ace::Support::Cli::Error, "Protected launch requires exactly one reviewed leaf task and no preset override" unless refs.one? && preset.nil?
52
+ reports = Array(dependency_report).map do |entry|
53
+ fields = entry.split(":", -1)
54
+ raise Ace::Support::Cli::Error, "Report identity must be task:assignment:step" unless fields.size == 3 && fields.none?(&:empty?)
55
+ %w[task_id assignment_id number].zip(fields).to_h
56
+ end
57
+ report = ->(identity) { $stdout.write(JSON.generate(identity) + "\n"); $stdout.flush }
58
+ result = @protected_work_on.start(task_ref: refs.first, project: project, agent: agent, runtime: selected_runtime,
59
+ dependency_reports: reports, mutation_id: mutation, base_head: base_head, report: report)
60
+ child = result.fetch("child")
61
+ begin
62
+ puts JSON.generate("type" => "original_launch_observation", "pid" => child.pid, "state" => child.state,
63
+ "ready" => child.ready, "error" => child.error)
64
+ $stdout.flush
65
+ ensure
66
+ # An output failure after creation cannot discard the original
67
+ # living child or turn local observation into canonical release.
68
+ @protected_work_on.serve(child)
69
+ end
70
+ return
71
+ end
72
+ if agent || mutation || base_head || dependency_report
73
+ raise Ace::Support::Cli::Error, "Protected mapping/invocation/report selection requires --project"
74
+ end
39
75
 
40
76
  Atoms::RepoGuard.ensure_repo!
41
77
 
@@ -49,7 +85,7 @@ module Ace
49
85
  task_ref: task_refs.first,
50
86
  task_refs: task_refs,
51
87
  cli_preset: preset,
52
- on_progress: progress
88
+ on_progress: progress, runtime: selected_runtime
53
89
  )
54
90
 
55
91
  return if options[:quiet]