ace-overseer 0.30.2 → 0.31.0

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/handbook/workflow-instructions/overseer/bootstrap-delegation.wf.md +35 -298
  4. data/handbook/workflow-instructions/overseer/lab-start.wf.md +69 -105
  5. data/handbook/workflow-instructions/overseer/queue.wf.md +39 -219
  6. data/handbook/workflow-instructions/overseer/start.wf.md +66 -298
  7. data/lib/ace/overseer/atoms/start_contract.rb +2 -2
  8. data/lib/ace/overseer/cli/commands/handoff.rb +15 -3
  9. data/lib/ace/overseer/cli/commands/process_reports.rb +33 -4
  10. data/lib/ace/overseer/cli/commands/start.rb +6 -1
  11. data/lib/ace/overseer/cli/commands/status.rb +1 -1
  12. data/lib/ace/overseer/cli/commands/work_on.rb +15 -3
  13. data/lib/ace/overseer/molecules/coworker_launcher.rb +9 -3
  14. data/lib/ace/overseer/molecules/event_observation.rb +4 -0
  15. data/lib/ace/overseer/molecules/eyes_configuration.rb +26 -0
  16. data/lib/ace/overseer/molecules/eyes_evidence.rb +6 -2
  17. data/lib/ace/overseer/molecules/eyes_readiness.rb +6 -1
  18. data/lib/ace/overseer/molecules/eyes_session_evidence.rb +2 -2
  19. data/lib/ace/overseer/molecules/handoff_probe.rb +2 -1
  20. data/lib/ace/overseer/molecules/hitl_inbox.rb +2 -2
  21. data/lib/ace/overseer/molecules/local_sources.rb +25 -5
  22. data/lib/ace/overseer/molecules/manager_binding.rb +138 -0
  23. data/lib/ace/overseer/molecules/project_observations.rb +19 -43
  24. data/lib/ace/overseer/molecules/project_sessions.rb +49 -13
  25. data/lib/ace/overseer/molecules/report_completion_check.rb +3 -1
  26. data/lib/ace/overseer/molecules/session_environment.rb +3 -8
  27. data/lib/ace/overseer/molecules/worktree_provisioner.rb +2 -2
  28. data/lib/ace/overseer/organisms/handoff_orchestrator.rb +43 -1
  29. data/lib/ace/overseer/organisms/report_processor.rb +137 -17
  30. data/lib/ace/overseer/organisms/start_orchestrator.rb +28 -7
  31. data/lib/ace/overseer/organisms/status_attention.rb +27 -3
  32. data/lib/ace/overseer/organisms/status_collector.rb +22 -2
  33. data/lib/ace/overseer/organisms/work_on_orchestrator.rb +33 -7
  34. data/lib/ace/overseer/version.rb +1 -1
  35. metadata +19 -17
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require_relative "../molecules/coworker_launcher"
3
+ require_relative "../molecules/manager_binding"
3
4
 
4
5
  module Ace
5
6
  module Overseer
@@ -41,7 +42,21 @@ module Ace
41
42
  # "start_config"}|nil, legs: [...], dry_run}
42
43
  # @raise [Error] when the task or its worktree cannot be
43
44
  # resolved, or the handoff is refused (with every reason)
44
- def call(task_ref:, dry_run: false, on_progress: nil, runtime: nil)
45
+ def call(task_ref:, dry_run: false, on_progress: nil, runtime: nil, manager_ref: nil)
46
+ arguments = {task_ref: task_ref, dry_run: dry_run, on_progress: on_progress,
47
+ runtime: runtime, manager_ref: manager_ref}
48
+ return perform(**arguments) unless manager_ref
49
+
50
+ manager = @task_manager.show(manager_ref)
51
+ raise Error, "Manager not found: #{manager_ref}" unless manager
52
+ lifecycle_exclusion.with_exclusive("manager:#{manager.id}") do
53
+ perform(**arguments.merge(manager_ref: manager.id))
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def perform(task_ref:, dry_run:, on_progress:, runtime:, manager_ref:)
45
60
  progress = on_progress || ->(_msg) {}
46
61
 
47
62
  task = @task_manager.show(task_ref.to_s)
@@ -62,16 +77,43 @@ module Ace
62
77
  progress.call("Reading role-sequence state in #{worktree_path}...")
63
78
  state = probe.probe(worktree_path, task_id: task.id.to_s)
64
79
 
80
+ binding = Molecules::ManagerBinding.new(task_manager: @task_manager) if manager_ref
81
+ if binding
82
+ roles = state.fetch(:legs).map { |leg| leg.fetch("role") }
83
+ binding.validate!(manager_ref: manager_ref, task: task, roles: roles)
84
+ unless dry_run
85
+ binding.prepare(manager_ref: manager_ref, task: task, sequence: state.fetch(:legs),
86
+ worktree_path: worktree_path,
87
+ project_root: Molecules::ProjectRootResolver.new.resolve(cwd: worktree_path))
88
+ end
89
+ end
65
90
  active = Ace::Coworker::Atoms::ActiveExecutorRule.evaluate(
66
91
  config: state[:executor][:config], assignments: state[:executor][:assignments])
67
92
  verdict = Atoms::HandoffReadiness.evaluate(legs: state[:legs], active_executor: active)
68
93
 
94
+ # Recover a launched leg through the launcher's existing identity
95
+ # check, rather than treating a retry as a new handoff.
96
+ next_leg = state[:legs].find { |leg| leg["state"] != "completed" }
97
+ selected = Atoms::RuntimeSetting.normalize(runtime || @config["runtime"]) || ENV["ACE_RUNTIME"]
98
+ native = selected == "herdr" || selected.to_s.empty? && Ace::Runtime::Atoms::Detector.detect == :herdr
99
+ if native && next_leg && %w[paused empty running].include?(next_leg["state"]) &&
100
+ next_leg["execution"].is_a?(Hash) &&
101
+ %w[launching started running resuming resumed].include?(next_leg["execution"]["state"]) &&
102
+ (!active[:active] || active.dig(:evidence, "assignment") == next_leg["assignment"])
103
+ verdict = {status: :ready, next: next_leg}
104
+ end
69
105
  if verdict[:status] == :refused
70
106
  raise Error, "Handoff refused: #{verdict[:reasons].join("; ")}"
71
107
  end
72
108
 
109
+ binding&.ready!(manager_ref: manager_ref, assignment: verdict[:next]["assignment"], allow_unbound: dry_run) if verdict[:next]
73
110
  outcome = ready_outcome(task: task, worktree_path: worktree_path, branch: found[:branch],
74
111
  verdict: verdict, dry_run: dry_run, runtime: runtime, progress: progress)
112
+ if binding && outcome[:execution]
113
+ binding.record_execution(manager_ref: manager_ref,
114
+ assignment: outcome.fetch(:next).fetch("assignment"), execution: outcome[:execution])
115
+ end
116
+ outcome[:manager_ref] = manager_ref if manager_ref
75
117
  end
76
118
  end
77
119
 
@@ -16,7 +16,8 @@ module Ace
16
16
  class ReportProcessor
17
17
  OUTCOME_STATUSES = %i[success error needs_answer duplicate unmatched].freeze
18
18
 
19
- def initialize(inbox: nil, task_manager: nil, handoff: nil, work_on: nil, completion_check: nil, owner: "overseer", scope: nil, retire: nil)
19
+ def initialize(inbox: nil, task_manager: nil, handoff: nil, work_on: nil, completion_check: nil, owner: "overseer", scope: nil, retire: nil, decision_check: nil)
20
+ @decision_check = decision_check || method(:pending_decision)
20
21
  @completion_check = completion_check || Molecules::ReportCompletionCheck.new
21
22
  @inbox = inbox || Molecules::HitlInbox.new(owner: owner, scope: scope)
22
23
  @task_manager = task_manager || Ace::Task::Organisms::TaskManager.new
@@ -31,25 +32,73 @@ module Ace
31
32
  # @return [Hash] {dry_run:, outcomes: [...], counts: {...}}
32
33
  def call(dry_run: false, on_progress: nil)
33
34
  progress = on_progress || ->(_msg) {}
34
- events = @inbox.incoming_events
35
+ events = timed(progress, "collect reports") { @inbox.incoming_events }
36
+ @manager_snapshot = timed(progress, "read private tasks") { @task_manager.list(in_folder: "_overseer") }
35
37
  progress.call("Incoming ace-hitl events: #{events.size}")
36
38
 
37
39
  outcomes = events.map do |event|
38
- process_event(event, dry_run: dry_run, progress: progress)
40
+ result = process_event(event, dry_run: dry_run, progress: progress)
41
+ if result[:acknowledge]
42
+ receipt = {"key" => result[:key], "revision" => result[:revision],
43
+ "cleanup" => "complete", "continuation" => "complete",
44
+ "timings" => result[:timings]}
45
+ unless @task_manager.update(result[:step_ref], set: {"report_receipt" => receipt})
46
+ result[:acknowledge] = false
47
+ result[:continuation] = {action: "blocked", reason: "report receipt could not be recorded"}
48
+ end
49
+ end
50
+ if !dry_run && result[:manager_ref]
51
+ ref = result[:manager_ref]
52
+ current = @task_manager.show(ref)
53
+ @manager_snapshot = @manager_snapshot.reject { |manager| manager.id.to_s == ref }
54
+ @manager_snapshot << current if current
55
+ end
56
+ result
39
57
  end
40
58
 
41
59
  counts = OUTCOME_STATUSES.to_h { |status| [status, outcomes.count { |o| o[:status] == status }] }
42
- {dry_run: dry_run, outcomes: outcomes, counts: counts}
60
+ operation = {
61
+ completed: outcomes.select { |row| row[:accepted] }.map { |row| row[:event_id] },
62
+ running: outcomes.filter_map { |row| row.dig(:continuation, :execution, "agent") }.uniq,
63
+ waiting: outcomes.reject { |row| row[:acknowledge] }.map { |row| row[:event_id] },
64
+ blocked: outcomes.filter_map { |row| row.dig(:continuation, :reason) if row.dig(:continuation, :action) == "blocked" },
65
+ evidence: outcomes.map { |row| row.slice(:event_id, :key, :revision, :manager_ref, :step_ref) }}
66
+ {dry_run: dry_run, outcomes: outcomes, counts: counts, operation: operation}
67
+ ensure
68
+ @manager_snapshot = nil
69
+ @receipt = nil
43
70
  end
44
71
 
45
72
  private
46
73
 
47
74
  def process_event(event, dry_run:, progress:)
48
- verdict = Atoms::ReportInterpretation.evaluate(event: event, managers: private_managers)
75
+ @receipt = {accepted: false, cleanup: false, timings: {}, dry_run: dry_run}
76
+ verdict = timed(progress, "match #{event['id']}") do
77
+ Atoms::ReportInterpretation.evaluate(event: event, managers: private_managers)
78
+ end
79
+ if verdict[:status] == :duplicate
80
+ step = @task_manager.show(verdict[:step_ref])
81
+ recorded = step&.metadata&.fetch("processed_report_revision", nil)
82
+ if event["revision"] && recorded != event["revision"]
83
+ return outcome({status: :unmatched}, event, nil,
84
+ "accepted report revision is missing or changed; inspect the revision before reconciling")
85
+ end
86
+ @receipt[:accepted] = true
87
+ end
88
+ if verdict[:status] == :duplicate && (reason = duplicate_identity_error(verdict, event))
89
+ return outcome({status: :unmatched, reason: reason}, event, nil, reason)
90
+ end
91
+ if accepted_closed_report?(verdict, event)
92
+ progress.call("Event #{event['id']}: accepted closed history; only reconcile executor cleanup")
93
+ retirement = dry_run ? nil : timed(progress, "retire #{event['id']}") { retire_executor(verdict, event) }
94
+ return outcome(verdict, event, retirement, retirement ? retirement[:reason] : "closed report already accepted; no continuation")
95
+ end
49
96
 
50
97
  if verdict[:status] == :success || (verdict[:status] == :duplicate && verdict[:recorded_result] == "success")
51
- refusal = @completion_check.call(event: event,
52
- manager: @task_manager.show(verdict[:manager_ref]), step: @task_manager.show(verdict[:step_ref]))
98
+ refusal = timed(progress, "verify #{event['id']}") do
99
+ @completion_check.call(event: event,
100
+ manager: @task_manager.show(verdict[:manager_ref]), step: @task_manager.show(verdict[:step_ref]))
101
+ end
53
102
  verdict = {status: :unmatched, reason: refusal} if refusal
54
103
  end
55
104
 
@@ -84,7 +133,7 @@ module Ace
84
133
  # incomplete executor cleanup. A dry run changes nothing.
85
134
  def resume_duplicate_continuation(verdict, event, dry_run:, progress:)
86
135
  unless dry_run
87
- retirement = retire_executor(verdict, event)
136
+ retirement = timed(progress, "retire #{event['id']}") { retire_executor(verdict, event) }
88
137
  return outcome(verdict, event, retirement, retirement[:reason]) if retirement
89
138
  end
90
139
  unless verdict[:recorded_result] == Atoms::ReportInterpretation::SUCCESS_RESULT && !dry_run
@@ -93,7 +142,7 @@ module Ace
93
142
  "nothing re-run")
94
143
  end
95
144
 
96
- continuation = continue_queue(verdict[:manager_ref], progress: progress)
145
+ continuation = timed(progress, "continue #{event['id']}") { continue_queue(verdict[:manager_ref], progress: progress) }
97
146
  outcome(verdict, event, continuation,
98
147
  "report #{event['id']} already recorded on #{verdict[:step_ref]} (result: #{verdict[:recorded_result]}); " \
99
148
  "continuation re-checked - #{continuation_description(continuation)}")
@@ -108,14 +157,16 @@ module Ace
108
157
  return outcome(verdict, event, nil, message)
109
158
  end
110
159
 
111
- unless @task_manager.update(verdict[:step_ref], set: verdict[:set])
160
+ verdict[:set] = verdict[:set].merge("processed_report_revision" => event["revision"]) if event["revision"]
161
+ unless timed(progress, "accept #{event['id']}") { @task_manager.update(verdict[:step_ref], set: verdict[:set]) }
112
162
  reason = "task #{verdict[:step_ref]} no longer resolves - moved or archived since the event matched"
113
163
  message = "report #{event['id']} not recorded: #{reason}; kept in ace-hitl for clarification"
114
164
  progress.call("Event #{event['id']}: #{message}")
115
165
  return outcome(verdict, event, {action: "blocked", reason: reason}, message)
116
166
  end
167
+ @receipt[:accepted] = true
117
168
  progress.call("Event #{event['id']}: recorded #{verdict[:status]} on #{verdict[:step_ref]}")
118
- retirement = retire_executor(verdict, event)
169
+ retirement = timed(progress, "retire #{event['id']}") { retire_executor(verdict, event) }
119
170
  return outcome(verdict, event, retirement, retirement[:reason]) if retirement
120
171
 
121
172
  if verdict[:status] == :error
@@ -125,7 +176,7 @@ module Ace
125
176
  return outcome(verdict, event, nil, message)
126
177
  end
127
178
 
128
- continuation = continue_queue(verdict[:manager_ref], progress: progress)
179
+ continuation = timed(progress, "continue #{event['id']}") { continue_queue(verdict[:manager_ref], progress: progress) }
129
180
  outcome(verdict, event, continuation,
130
181
  "step #{verdict[:step_ref]} done (report #{event['id']}); #{continuation_description(continuation)}")
131
182
  end
@@ -143,7 +194,17 @@ module Ace
143
194
  if next_step.nil?
144
195
  active = active_steps(manager)
145
196
  reason = if active.empty?
146
- "every step of #{manager_ref} is terminal; close the managing task per the queue workflow"
197
+ required = Array(manager.subtasks)
198
+ if required.any? { |step| step.status.to_s != "done" }
199
+ return {action: "blocked", reason: "required steps were cancelled or skipped; manager is not successful"}
200
+ end
201
+ if (blocker = @decision_check.call(manager))
202
+ return {action: "blocked", reason: blocker}
203
+ end
204
+ unless @task_manager.update(manager_ref, set: {"status" => "done"})
205
+ return {action: "blocked", reason: "manager disappeared before closure"}
206
+ end
207
+ "managing task #{manager_ref} closed by ace-task after accepted required steps"
147
208
  else
148
209
  "no unblocked step in #{manager_ref}: #{active.map { |s| blocking_summary(s) }.join(' | ')}"
149
210
  end
@@ -159,14 +220,14 @@ module Ace
159
220
  if family_worktree(manager)
160
221
  project_root = manager.metadata["project_root"].to_s
161
222
  result = if @handoff
162
- @handoff.call(task_ref: manager.metadata["project_task"].to_s)
223
+ @handoff.call(task_ref: manager.metadata["project_task"].to_s, manager_ref: manager.id.to_s)
163
224
  else
164
225
  Dir.chdir(project_root.empty? ? Dir.pwd : project_root) do
165
- Organisms::HandoffOrchestrator.new.call(task_ref: manager.metadata["project_task"].to_s,
226
+ Organisms::HandoffOrchestrator.new(task_loader: @task_manager).call(task_ref: manager.metadata["project_task"].to_s,
227
+ manager_ref: manager.id.to_s,
166
228
  runtime: Ace::Support::Cli::Toolchain.installed? ? "herdr" : nil)
167
229
  end
168
230
  end
169
- record_allocation(next_step, result) if result[:execution]
170
231
  {action: "handoff", step_ref: next_step.id.to_s,
171
232
  next: result[:next], status: result[:status], worktree_path: result[:worktree_path],
172
233
  execution: result[:execution], start_command: start_command_of(result[:next])}
@@ -196,6 +257,20 @@ module Ace
196
257
  {action: "blocked", reason: "report recorded; executor retirement failed: #{e.message}"}
197
258
  end
198
259
 
260
+ def pending_decision(manager)
261
+ refs = [manager, *Array(manager.subtasks)].filter_map { |task| task.metadata["hitl_case"] }.uniq
262
+ refs.each do |ref|
263
+ shown = Ace::Hitl::Organisms::HitlManager.new.show(ref, scope: "all")
264
+ event = shown.is_a?(Hash) ? shown[:event] : shown
265
+ unless event && event.answered? && %w[applied closed].include?(event.metadata.dig("application", "state"))
266
+ return "decision #{ref} is not applied; manager remains open"
267
+ end
268
+ end
269
+ nil
270
+ rescue StandardError => error
271
+ "decision state unavailable: #{error.message}"
272
+ end
273
+
199
274
  def record_allocation(step, result)
200
275
  execution = result.fetch(:execution)
201
276
  unless @task_manager.update(step.id, set: {"assignment" => execution.fetch("assignment"),
@@ -247,12 +322,57 @@ module Ace
247
322
  end
248
323
 
249
324
  def private_managers
250
- @task_manager.list(in_folder: "_overseer")
325
+ @manager_snapshot || @task_manager.list(in_folder: "_overseer")
326
+ end
327
+
328
+ def duplicate_identity_error(verdict, event)
329
+ manager = @task_manager.show(verdict[:manager_ref])
330
+ step = @task_manager.show(verdict[:step_ref])
331
+ return "accepted report allocation no longer exists" unless manager && step
332
+ return "duplicate report task differs from accepted allocation" unless event["task"].to_s == manager.metadata["project_task"].to_s
333
+ return "duplicate report assignment differs from accepted allocation" unless event["assignment"].to_s == step.metadata["assignment"].to_s
334
+ return "duplicate report result differs from accepted result" unless event["result"].to_s == verdict[:recorded_result].to_s
335
+ if !event["role"].to_s.empty? && event["role"] != step.metadata["role"]
336
+ return "duplicate report role differs from accepted allocation"
337
+ end
338
+ nil
339
+ end
340
+
341
+ def accepted_closed_report?(verdict, event)
342
+ return false unless verdict[:status] == :duplicate && verdict[:recorded_result] == "success"
343
+ manager = private_managers.find { |candidate| candidate.id.to_s == verdict[:manager_ref] }
344
+ return false unless manager
345
+ family = [manager, *Array(manager.subtasks)]
346
+ step = family.find { |candidate| candidate.id.to_s == verdict[:step_ref] }
347
+ step && step.metadata["processed_report"] == event["id"] && family.all? do |candidate|
348
+ Ace::Task::Atoms::TaskValidationRules.terminal_status?(candidate.status.to_s)
349
+ end
350
+ end
351
+
352
+ def timed(progress, label)
353
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
354
+ progress.call("Starting #{label}")
355
+ result = yield
356
+ @receipt[:timings][label] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started if @receipt
357
+ @receipt[:cleanup] = result.nil? if @receipt && label.start_with?("retire ")
358
+ progress.call("Completed #{label} (#{format('%.3f', Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)}s)")
359
+ result
360
+ rescue StandardError
361
+ progress.call("Failed #{label} (#{format('%.3f', Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)}s)")
362
+ raise
251
363
  end
252
364
 
253
365
  def outcome(verdict, event, continuation, message)
254
366
  {
255
367
  event_id: event["id"],
368
+ key: event["key"], revision: event["revision"],
369
+ accepted: @receipt[:accepted], cleanup: @receipt[:cleanup] ? "complete" : "pending",
370
+ timings: @receipt[:timings].dup,
371
+ acknowledge: !@receipt[:dry_run] && @receipt[:accepted] && @receipt[:cleanup] &&
372
+ !!event["key"] && !!event["revision"] && event["type"] == "report" &&
373
+ (verdict[:status] == :success || verdict[:status] == :duplicate && verdict[:recorded_result] == "success") &&
374
+ (continuation.nil? || continuation[:action] == "none" ||
375
+ %w[handoff work-on].include?(continuation[:action]) && !!continuation[:execution]),
256
376
  status: verdict[:status],
257
377
  manager_ref: verdict[:manager_ref],
258
378
  step_ref: verdict[:step_ref],
@@ -3,8 +3,10 @@
3
3
  require_relative "../atoms/start_contract"
4
4
  require_relative "../atoms/observation_sources"
5
5
  require_relative "../molecules/project_root_resolver"
6
+ require "pathname"
6
7
  require_relative "../molecules/project_bundle"
7
8
  require_relative "../molecules/eyes_readiness"
9
+ require_relative "../molecules/eyes_configuration"
8
10
  require_relative "../molecules/session_environment"
9
11
  require "ace/herdr"
10
12
  require "ace/herdr/molecules/installed_pi_extensions"
@@ -103,6 +105,7 @@ module Ace
103
105
  "message" => "dry-run: session would start with this plan; nothing was created")
104
106
  end
105
107
 
108
+ prepare_eyes(root, plan)
106
109
  owner = owner_pane(label)
107
110
  if owner
108
111
  conflict = owner_conflict(owner, root)
@@ -155,7 +158,13 @@ module Ace
155
158
  unless plan["profile"]["harness"] == "pi"
156
159
  raise Ace::Overseer::Error, "foreground start requires the configured pi harness"
157
160
  end
161
+ raise Errno::ENOENT, @cwd unless File.directory?(@cwd)
158
162
  root = File.realpath(@cwd)
163
+ # A new lab may not yet be a repository; an existing project/worktree
164
+ # must resolve through Git to its canonical owner.
165
+ if Pathname.new(root).ascend.any? { |directory| directory.join(".git").exist? }
166
+ root = @root_resolver.resolve(cwd: root)
167
+ end
159
168
  source = plan["instructions"]
160
169
  guide = if %w[wfi://overseer/lab-start wfi://overseer/start].include?(source)
161
170
  filename = source.end_with?("lab-start") ? "lab-start.wf.md" : "start.wf.md"
@@ -169,7 +178,8 @@ module Ace
169
178
  thinking = plan["profile"]["thinking"]
170
179
  command += ["--thinking", thinking] if thinking
171
180
  command += ["--append-system-prompt", guide, Atoms::StartContract::FIRST_TURN_PROMPT]
172
- {"status" => dry_run ? "dry_run" : "foreground", "scope" => plan["scope"],
181
+ eyes_path = dry_run ? nil : prepare_eyes(root, plan)
182
+ {"eyes_config" => eyes_path, "status" => dry_run ? "dry_run" : "foreground", "scope" => plan["scope"],
173
183
  "root" => root, "profile" => plan["profile_name"],
174
184
  "instructions" => {"source" => source, "path" => guide},
175
185
  "command" => command, "message" => "launch the coordinator in the current terminal"}
@@ -338,6 +348,7 @@ module Ace
338
348
  # owner is matched by its cwd directly — never through
339
349
  # project-root resolution.
340
350
  def boot_lab_session(workspace, plan, instructions_path, lab_root)
351
+ prepare_eyes(lab_root, plan)
341
352
  label = Atoms::StartContract.session_label(lab_root)
342
353
  owner = owner_pane(label)
343
354
  replaced = nil
@@ -507,10 +518,12 @@ module Ace
507
518
  end
508
519
  if owners.length > 1
509
520
  # A replaced dead owner may retain its tab. Only a single live
510
- # agent identifies an unambiguous current owner; never pick the
511
- # first of multiple live or dead candidates.
521
+ # agent identifies an unambiguous current owner. When all agents
522
+ # exited, keep their panels for diagnostics and create a fresh one;
523
+ # there is no live owner to choose or take over.
512
524
  live = owners.select { |pane| !agent_state(pane[:id]).nil? }
513
- unless live.length == 1
525
+ return nil if live.empty?
526
+ if live.length > 1
514
527
  raise Ace::Overseer::Error,
515
528
  "multiple overseer tabs '#{label}' have no unique live owner; refusing an ambiguous session"
516
529
  end
@@ -595,8 +608,11 @@ module Ace
595
608
  @executor.pane_run(pane, command)
596
609
  @executor.pane_wait_output(pane, pattern: marker, timeout_ms: START_TIMEOUT_MS)
597
610
  end
598
- @executor.agent_start(name: "#{AGENT_NAME}-#{Digest::SHA256.hexdigest(root)[0, 12]}", kind: plan["profile"]["harness"],
599
- pane: pane, timeout_ms: START_TIMEOUT_MS, args: args)
611
+ Molecules::SessionEnvironment.new(env: @env).with_environment(root: root, role: "project-overseer",
612
+ eyes_config: prepare_eyes(root, plan)) do |file, _directory, _profile|
613
+ @executor.agent_start(name: "#{AGENT_NAME}-#{Digest::SHA256.hexdigest(root)[0, 12]}", kind: plan["profile"]["harness"],
614
+ pane: pane, timeout_ms: START_TIMEOUT_MS, args: args, env_file: file)
615
+ end
600
616
  end
601
617
  pane
602
618
  rescue Ace::Herdr::WaitTimeoutError => e
@@ -608,9 +624,14 @@ module Ace
608
624
  # A failed native start can still have created a process. Close the
609
625
  # pane before deleting its credentials; if closure is unconfirmed,
610
626
  # preserve the directory and report that recovery is required.
627
+ def prepare_eyes(root, plan)
628
+ Molecules::EyesConfiguration.prepare(root: root,
629
+ recipient: @config["owner"] || (plan["scope"] == "lab" ? "lab-overseer" : "overseer"))
630
+ end
631
+
611
632
  def start_installed_agent(pane, root, role, plan, args)
612
633
  credentials = nil
613
- Molecules::SessionEnvironment.new(env: @env).with_environment(root: root, role: role) do |file, directory, _profile|
634
+ Molecules::SessionEnvironment.new(env: @env).with_environment(root: root, role: role, eyes_config: prepare_eyes(root, plan)) do |file, directory, _profile|
614
635
  credentials = directory
615
636
  @executor.agent_start(name: "#{AGENT_NAME}-#{Digest::SHA256.hexdigest(root)[0, 12]}", kind: plan["profile"]["harness"],
616
637
  pane: pane, timeout_ms: START_TIMEOUT_MS, args: args, env_file: file)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require "set"
4
5
 
5
6
  module Ace
@@ -38,8 +39,9 @@ module Ace
38
39
 
39
40
  # @param hitl_events [Proc] -> Array<Hash> incoming ace-hitl
40
41
  # events (the existing HitlInbox molecule, read-only)
41
- def initialize(hitl_events: default_hitl_events)
42
- @hitl_events = hitl_events
42
+ def initialize(hitl_events: nil, config: nil)
43
+ @config = config
44
+ @hitl_events = hitl_events || default_hitl_events
43
45
  end
44
46
 
45
47
  # @param snapshot [Hash] {sources:, unknown_keys:} as produced by
@@ -74,7 +76,29 @@ module Ace
74
76
  end
75
77
 
76
78
  def default_hitl_events
77
- -> { Molecules::HitlInbox.new.incoming_events }
79
+ -> { Molecules::HitlInbox.new(owner: configured_owner).incoming_events }
80
+ end
81
+
82
+ def configured_owner
83
+ config = @config || Ace::Overseer.config
84
+ owner = config["owner"].to_s.strip
85
+ return owner unless owner.empty?
86
+
87
+ scope = config.dig("start", "scope").to_s.strip
88
+ return "lab-overseer" if scope == "lab"
89
+ return "overseer" if scope == "project"
90
+
91
+ eyes_recipient || "overseer"
92
+ end
93
+
94
+ def eyes_recipient
95
+ return nil unless File.file?(".ace-local/overseer/eyes.json")
96
+
97
+ data = JSON.parse(File.read(".ace-local/overseer/eyes.json"))
98
+ recipient = data.is_a?(Hash) ? data["recipient"].to_s.strip : ""
99
+ recipient.empty? ? nil : recipient
100
+ rescue JSON::ParserError, SystemCallError
101
+ nil
78
102
  end
79
103
 
80
104
  def fetch_hitl_events
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "open3"
5
+ require "rbconfig"
5
6
 
6
7
  module Ace
7
8
  module Overseer
@@ -22,7 +23,7 @@ module Ace
22
23
  @project_root = project_root
23
24
  @config = config
24
25
  @sources = sources
25
- @attention = attention || Organisms::StatusAttention.new
26
+ @attention = attention || Organisms::StatusAttention.new(config: config)
26
27
  end
27
28
 
28
29
  # Aggregate the configurable status sources (L7.2). Each enabled
@@ -48,6 +49,7 @@ module Ace
48
49
  attention = snapshot[:attention]
49
50
  {
50
51
  sources: snapshot[:sources],
52
+ operation: operation_summary(snapshot),
51
53
  unknown_keys: Array(snapshot[:unknown_keys]),
52
54
  # Always the three-array contract (never null), so partial or
53
55
  # hand-built snapshots render consistently with the table.
@@ -57,6 +59,24 @@ module Ace
57
59
  }
58
60
  end
59
61
 
62
+ def operation_summary(snapshot)
63
+ sources = snapshot[:sources] || {}
64
+ tasks = %w[project_tasks overseer_tree].flat_map do |name|
65
+ source = sources[name]
66
+ next [] unless source && source[:status] == "ok" && source[:data].is_a?(Array)
67
+
68
+ source[:data].reject { |row| name == "project_tasks" && !row[:special_folder].to_s.empty? }
69
+ end
70
+ coworkers = sources.dig("coworkers", :data)
71
+ coworkers = [] unless coworkers.is_a?(Array)
72
+ attention = snapshot[:attention] || {}
73
+ {completed: tasks.select { |row| row[:status] == "done" }.map { |row| row[:id] }.uniq,
74
+ running: coworkers.select { |row| row[:active] }.map { |row| row[:worktree] }.uniq,
75
+ waiting: tasks.reject { |row| %w[done cancelled skipped].include?(row[:status]) }.map { |row| row[:id] }.uniq,
76
+ blocked: Array(attention[:blockers]) + Array(attention[:failures]) + Array(attention[:missing_data]),
77
+ evidence: sources.map { |name, source| {source: name, origin: source[:origin], status: source[:status]} }}
78
+ end
79
+
60
80
  def to_sources_table(snapshot)
61
81
  sections = [render_attention(snapshot[:attention])]
62
82
  source_sections = STATUS_SOURCE_ORDER.filter_map do |key|
@@ -390,7 +410,7 @@ module Ace
390
410
  pipe_read.close
391
411
  $stdout.reopen(pipe_write)
392
412
  pipe_write.close
393
- exec("ruby", "-e", worker_script, worktree.path.to_s)
413
+ exec(RbConfig.ruby, "-e", worker_script, worktree.path.to_s)
394
414
  exit!(1) # unreachable unless exec fails
395
415
  end
396
416
  pipe_write.close
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  require_relative "../molecules/coworker_launcher"
3
+ require_relative "../molecules/manager_binding"
4
+ require "ace/task/molecules/git_ignored_path"
3
5
 
4
6
  module Ace
5
7
  module Overseer
@@ -20,7 +22,7 @@ module Ace
20
22
  @coworker_launcher = coworker_launcher
21
23
  end
22
24
 
23
- def call(task_ref:, task_refs: nil, cli_preset: nil, on_progress: nil, runtime: nil)
25
+ def call(task_ref:, task_refs: nil, cli_preset: nil, on_progress: nil, runtime: nil, manager_ref: nil)
24
26
  progress = on_progress || ->(_msg) {}
25
27
 
26
28
  requested_refs = normalize_requested_refs(task_ref, task_refs)
@@ -43,6 +45,13 @@ module Ace
43
45
  guard_dependencies_satisfied!(resolved_refs)
44
46
  if native_delegation?(runtime)
45
47
  raise Error, "delegate one task per worktree" unless resolved_refs.size == 1
48
+ if manager_ref
49
+ manager = @task_manager.show(manager_ref)
50
+ raise Error, "Manager not found: #{manager_ref}" unless manager
51
+ return lifecycle_exclusion.with_exclusive("manager:#{manager.id}") do
52
+ delegate(resolved_refs.first, progress: progress, manager_ref: manager.id)
53
+ end
54
+ end
46
55
  return delegate(resolved_refs.first, progress: progress)
47
56
  end
48
57
 
@@ -129,19 +138,34 @@ module Ace
129
138
  selected == "herdr" || selected.to_s.empty? && Ace::Runtime::Atoms::Detector.detect == :herdr
130
139
  end
131
140
 
132
- def delegate(entry, progress:)
141
+ def delegate(entry, progress:, manager_ref: nil)
133
142
  launcher = @coworker_launcher || Molecules::CoworkerLauncher.new
134
- launcher.ready!
135
- ref = entry.fetch(:ref)
136
143
  task = entry.fetch(:task)
144
+ ref = task.id.to_s
145
+ if Ace::Task::Molecules::GitIgnoredPath.ignored_task?(task)
146
+ raise Error, "task #{task.id} is Git-ignored coordination state and cannot be loaded in an executor worktree; " \
147
+ "create a separate project task with the commission, roles and existing hitl_case, pass this private task with --manager"
148
+ end
149
+ launcher.ready!
150
+ roles = task.metadata["roles"] || (task.metadata["role"] && [task.metadata["role"]])
151
+ unless roles.is_a?(Array) && !roles.empty? && roles.all? { |role| role.is_a?(String) && !role.empty? && role == role.strip } && roles.uniq == roles
152
+ raise Error, "task #{task.id} requires an explicit nonempty role sequence (roles or role); choose roles for this commission before work-on"
153
+ end
154
+ if roles.include?("second-captain") && task.metadata["hitl_case"].to_s.strip.empty?
155
+ raise Error, "second-captain commission requires hitl_case naming the existing decision; do not create another case on retry"
156
+ end
137
157
  lifecycle_exclusion.with_shared(lifecycle_exclusion.task_key(ref), reset_removed: true) do
138
158
  lifecycle_exclusion.with_exclusive("delegate:#{ref}") do
159
+ binding = Molecules::ManagerBinding.new(task_manager: @task_manager) if manager_ref
160
+ binding&.validate!(manager_ref: manager_ref, task: task, roles: roles)
139
161
  worktree = @worktree_provisioner.provision(ref)
140
162
  root = worktree.fetch(:worktree_path)
141
- roles = task.metadata["roles"] || (task.metadata["role"] ? [task.metadata["role"]] : %w[worker reviewer integrator])
142
163
  sequence = Ace::Coworker::Molecules::RoleSequenceComposer.new(project_root: root).call(task: task.id, roles: roles)
143
164
  configs = Ace::Coworker::Organisms::StartConfigWriter.new(worktree_path: root).call(
144
- task: task.id, sequence: sequence, overseer: @config["owner"] || launcher.recipient)
165
+ task: task.id, sequence: sequence, overseer: @config["owner"] || launcher.recipient,
166
+ hitl_case: task.metadata["hitl_case"])
167
+ binding&.prepare(manager_ref: manager_ref, task: task, sequence: sequence,
168
+ worktree_path: root, project_root: Molecules::ProjectRootResolver.new.resolve(cwd: root))
145
169
  state = Molecules::HandoffProbe.new.probe(root, task_id: task.id)
146
170
  leg = state.fetch(:legs).find { |candidate| candidate["state"] != "completed" }
147
171
  raise Error, "all prepared roles are complete; reconcile the task instead of starting again" unless leg
@@ -150,9 +174,11 @@ module Ace
150
174
  end
151
175
  config = configs.find { |row| row["assignment"] == leg["assignment"] }
152
176
  raise Error, "prepared role has no start configuration" unless config
177
+ binding&.ready!(manager_ref: manager_ref, assignment: leg["assignment"])
153
178
  execution = launcher.call(worktree_path: root, config_path: config.fetch("path"))
179
+ binding&.record_execution(manager_ref: manager_ref, assignment: leg["assignment"], execution: execution)
154
180
  progress.call("Executor #{execution['agent']} bound to assignment #{leg['assignment']}")
155
- {task_ref: task.id, task_refs: [task.id], worktree_path: root, branch: worktree[:branch],
181
+ {manager_ref: manager_ref, task_ref: task.id, task_refs: [task.id], worktree_path: root, branch: worktree[:branch],
156
182
  worktree_created: worktree[:created], assignment_id: leg["assignment"],
157
183
  assignment_created: sequence.find { |row| row["assignment"] == leg["assignment"] }["disposition"] == "created",
158
184
  execution: execution}
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Overseer
5
- VERSION = "0.30.2"
5
+ VERSION = "0.31.0"
6
6
  end
7
7
  end