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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/handbook/workflow-instructions/overseer/bootstrap-delegation.wf.md +35 -298
- data/handbook/workflow-instructions/overseer/lab-start.wf.md +69 -105
- data/handbook/workflow-instructions/overseer/queue.wf.md +39 -219
- data/handbook/workflow-instructions/overseer/start.wf.md +66 -298
- data/lib/ace/overseer/atoms/start_contract.rb +2 -2
- data/lib/ace/overseer/cli/commands/handoff.rb +15 -3
- data/lib/ace/overseer/cli/commands/process_reports.rb +33 -4
- data/lib/ace/overseer/cli/commands/start.rb +6 -1
- data/lib/ace/overseer/cli/commands/status.rb +1 -1
- data/lib/ace/overseer/cli/commands/work_on.rb +15 -3
- data/lib/ace/overseer/molecules/coworker_launcher.rb +9 -3
- data/lib/ace/overseer/molecules/event_observation.rb +4 -0
- data/lib/ace/overseer/molecules/eyes_configuration.rb +26 -0
- data/lib/ace/overseer/molecules/eyes_evidence.rb +6 -2
- data/lib/ace/overseer/molecules/eyes_readiness.rb +6 -1
- data/lib/ace/overseer/molecules/eyes_session_evidence.rb +2 -2
- data/lib/ace/overseer/molecules/handoff_probe.rb +2 -1
- data/lib/ace/overseer/molecules/hitl_inbox.rb +2 -2
- data/lib/ace/overseer/molecules/local_sources.rb +25 -5
- data/lib/ace/overseer/molecules/manager_binding.rb +138 -0
- data/lib/ace/overseer/molecules/project_observations.rb +19 -43
- data/lib/ace/overseer/molecules/project_sessions.rb +49 -13
- data/lib/ace/overseer/molecules/report_completion_check.rb +3 -1
- data/lib/ace/overseer/molecules/session_environment.rb +3 -8
- data/lib/ace/overseer/molecules/worktree_provisioner.rb +2 -2
- data/lib/ace/overseer/organisms/handoff_orchestrator.rb +43 -1
- data/lib/ace/overseer/organisms/report_processor.rb +137 -17
- data/lib/ace/overseer/organisms/start_orchestrator.rb +28 -7
- data/lib/ace/overseer/organisms/status_attention.rb +27 -3
- data/lib/ace/overseer/organisms/status_collector.rb +22 -2
- data/lib/ace/overseer/organisms/work_on_orchestrator.rb +33 -7
- data/lib/ace/overseer/version.rb +1 -1
- 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
|
-
|
|
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
|
-
|
|
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 =
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
511
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
599
|
-
|
|
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:
|
|
42
|
-
@
|
|
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(
|
|
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}
|