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
|
@@ -76,6 +76,10 @@ module Ace
|
|
|
76
76
|
verdict = Atoms::ReportInterpretation.evaluate(event: event, managers: managers)
|
|
77
77
|
next false unless verdict[:status] == :duplicate
|
|
78
78
|
manager = managers.find { |task| task.id.to_s == verdict[:manager_ref] }
|
|
79
|
+
step = Array(manager&.subtasks).find { |candidate| candidate.id.to_s == verdict[:step_ref] } || manager
|
|
80
|
+
receipt = step&.metadata&.fetch("report_receipt", nil)
|
|
81
|
+
next false unless receipt.is_a?(Hash) && receipt["key"] == event["key"] &&
|
|
82
|
+
receipt["revision"] == event["revision"] && receipt["cleanup"] == "complete" && receipt["continuation"] == "complete"
|
|
79
83
|
# A persisted report alone is not proof its continuation ran. Keep
|
|
80
84
|
# active managers eligible after restart; their existing gates reconcile
|
|
81
85
|
# the same attempt. Only completed trees suppress historical reports.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module Ace::Overseer::Molecules
|
|
7
|
+
# Start owns the standard session input; Pi owns observation checkpoints.
|
|
8
|
+
class EyesConfiguration
|
|
9
|
+
def self.prepare(root:, recipient:)
|
|
10
|
+
path = File.join(root, ".ace-local/overseer/eyes.json")
|
|
11
|
+
existing = File.file?(path) ? JSON.parse(File.read(path)) : {}
|
|
12
|
+
raise Ace::Overseer::Error, "eyes configuration must be an object" unless existing.is_a?(Hash)
|
|
13
|
+
config = existing.merge("scope" => "all", "recipient" => recipient)
|
|
14
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
15
|
+
Tempfile.create(["eyes-", ".json"], File.dirname(path)) do |file|
|
|
16
|
+
file.write(JSON.pretty_generate(config) + "\n")
|
|
17
|
+
file.flush
|
|
18
|
+
file.fsync
|
|
19
|
+
File.rename(file.path, path)
|
|
20
|
+
end
|
|
21
|
+
path
|
|
22
|
+
rescue JSON::ParserError, SystemCallError => error
|
|
23
|
+
raise Ace::Overseer::Error, "cannot prepare eyes configuration: #{error.message}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -12,7 +12,7 @@ module Ace
|
|
|
12
12
|
BINDING = "ace-wake-eyes-binding"
|
|
13
13
|
BIRTH = /\A(?:linux:[0-9a-f-]{36}:[1-9][0-9]*|darwin:[1-9][0-9]*:[0-9]{1,6})\z/
|
|
14
14
|
|
|
15
|
-
def self.ready?(branch:, root:, process:, prompt:, required_sources:, now_ms:)
|
|
15
|
+
def self.ready?(branch:, root:, process:, prompt:, required_sources:, now_ms:, expected_config: nil)
|
|
16
16
|
return false unless branch.is_a?(Array) && root.is_a?(String) && process.is_a?(Hash) &&
|
|
17
17
|
prompt.is_a?(String) && !prompt.empty? && required_sources.is_a?(Array) &&
|
|
18
18
|
!required_sources.empty? && required_sources.all? { |name| name.is_a?(String) && !name.empty? } &&
|
|
@@ -20,6 +20,10 @@ module Ace
|
|
|
20
20
|
process["started_at"].match?(BIRTH) && now_ms.is_a?(Integer)
|
|
21
21
|
eye_index = branch.rindex { |entry| entry.is_a?(Hash) && entry["type"] == "custom" && entry["customType"] == ENTRY }
|
|
22
22
|
return false unless eye_index
|
|
23
|
+
if expected_config
|
|
24
|
+
actual = branch[eye_index].dig("data", "config")
|
|
25
|
+
return false unless actual.is_a?(Hash) && expected_config.all? { |key, value| actual[key] == value }
|
|
26
|
+
end
|
|
23
27
|
|
|
24
28
|
identity = {"pid" => process["pid"], "startedAt" => process["started_at"]}
|
|
25
29
|
|
|
@@ -86,7 +90,7 @@ module Ace
|
|
|
86
90
|
evidence = eye["observerEvidence"]
|
|
87
91
|
return false unless evidence.is_a?(Hash) && evidence["process"] == identity
|
|
88
92
|
observed, sources, config = evidence.values_at("observedAt", "sources") + [eye["config"]]
|
|
89
|
-
return false unless observed.is_a?(Integer) && observed
|
|
93
|
+
return false unless observed.is_a?(Integer) && observed <= now_ms && config.is_a?(Hash) &&
|
|
90
94
|
config["intervalSeconds"].is_a?(Integer) && config["intervalSeconds"].between?(1, 3600) &&
|
|
91
95
|
config["maxObservationsPerHour"].is_a?(Integer) && config["maxObservationsPerHour"].between?(1, 3600) &&
|
|
92
96
|
config["timeoutMs"].is_a?(Integer) && config["timeoutMs"].between?(100, 120_000)
|
|
@@ -13,7 +13,12 @@ module Ace
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def ready?(pane:, root:, prompt:, required_sources:)
|
|
16
|
-
|
|
16
|
+
path = File.join(root, ".ace-local/overseer/eyes.json")
|
|
17
|
+
expected = JSON.parse(File.read(path))
|
|
18
|
+
return false unless expected.is_a?(Hash) && expected["scope"] == "all" && !expected["recipient"].to_s.empty?
|
|
19
|
+
@local.ready?(expected_config: expected.slice("scope", "recipient"), pane: pane, root: root, prompt: prompt, required_sources: required_sources)
|
|
20
|
+
rescue SystemCallError, JSON::ParserError
|
|
21
|
+
false
|
|
17
22
|
end
|
|
18
23
|
end
|
|
19
24
|
end
|
|
@@ -46,7 +46,7 @@ module Ace
|
|
|
46
46
|
@executor, @identity, @clock, @file_opener = executor, identity, clock, file_opener
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
def ready?(pane:, root:, prompt:, required_sources:, expected_terminal: nil)
|
|
49
|
+
def ready?(pane:, root:, prompt:, required_sources:, expected_terminal: nil, expected_config: nil)
|
|
50
50
|
executor = @executor || Ace::Herdr::Molecules::BoundedObservation.new(
|
|
51
51
|
deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5)
|
|
52
52
|
before = pane_binding(executor, pane)
|
|
@@ -68,7 +68,7 @@ module Ace
|
|
|
68
68
|
process = @identity.capture(pid)
|
|
69
69
|
return false unless process && process["uid"] == stat.uid
|
|
70
70
|
return false unless EyesEvidence.ready?(branch: branch, root: root, process: process,
|
|
71
|
-
prompt: prompt, required_sources: required_sources, now_ms: @clock.call)
|
|
71
|
+
prompt: prompt, required_sources: required_sources, expected_config: expected_config, now_ms: @clock.call)
|
|
72
72
|
# The owner or its session may change while the file is read.
|
|
73
73
|
return false unless @identity.capture(pid) == process && foreground?(executor, pane, pid) && pane_binding(executor, pane) == before
|
|
74
74
|
final = file.stat
|
|
@@ -43,6 +43,7 @@ module Ace
|
|
|
43
43
|
{
|
|
44
44
|
"role" => mapping["role"],
|
|
45
45
|
"start_config" => mapping["start_config"],
|
|
46
|
+
"execution" => mapping["execution"],
|
|
46
47
|
"assignment" => record["id"],
|
|
47
48
|
"name" => record["name"],
|
|
48
49
|
"state" => states.fetch(record["id"], "")
|
|
@@ -91,7 +92,7 @@ module Ace
|
|
|
91
92
|
role = data["role"].to_s
|
|
92
93
|
next if assignment.empty? || role.strip.empty?
|
|
93
94
|
|
|
94
|
-
[assignment, {"role" => role,
|
|
95
|
+
[assignment, {"role" => role, "execution" => data["execution"],
|
|
95
96
|
"start_config" => File.join(PREPARE_DIR, File.basename(path))}]
|
|
96
97
|
end.to_h
|
|
97
98
|
end
|
|
@@ -49,7 +49,7 @@ module Ace
|
|
|
49
49
|
private
|
|
50
50
|
|
|
51
51
|
def normalize(event)
|
|
52
|
-
|
|
52
|
+
@observation.project(event).merge(
|
|
53
53
|
"id" => event.id,
|
|
54
54
|
"type" => event.type,
|
|
55
55
|
"result" => event.result,
|
|
@@ -57,7 +57,7 @@ module Ace
|
|
|
57
57
|
"assignment" => event.metadata["assignment"],
|
|
58
58
|
"role" => event.metadata["role"],
|
|
59
59
|
"title" => event.title
|
|
60
|
-
|
|
60
|
+
)
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
end
|
|
@@ -125,8 +125,9 @@ module Ace
|
|
|
125
125
|
# executor probe reads the worktree's commissioned config and the
|
|
126
126
|
# ace-assign inventory (existing run identifiers — assignment
|
|
127
127
|
# ids, never pane names), the ActiveExecutorRule decides live vs
|
|
128
|
-
# stale
|
|
129
|
-
#
|
|
128
|
+
# stale. Progress remains readable; between-step paused/running
|
|
129
|
+
# transitions of an identified live native executor are not wake
|
|
130
|
+
# reasons. Native completion/disappearance and reports remain signals.
|
|
130
131
|
def coworker_events(overseer_tasks)
|
|
131
132
|
result = @worktrees.list_all
|
|
132
133
|
raise Error, result[:error] || "failed to list worktrees" unless result[:success]
|
|
@@ -149,12 +150,24 @@ module Ace
|
|
|
149
150
|
end
|
|
150
151
|
payload = {"path" => worktree.path, "active" => verdict[:active],
|
|
151
152
|
"evidence" => verdict[:evidence], "sessions" => visible, "missing_sessions" => missing}
|
|
152
|
-
|
|
153
|
+
live_assignments = report[:assignments].filter_map do |assignment|
|
|
154
|
+
id = assignment["id"]
|
|
155
|
+
id if visible.any? { |session| session["agent"] == "coworker-#{id}" } &&
|
|
156
|
+
missing.none? { |binding| binding["assignment"] == id }
|
|
157
|
+
end
|
|
158
|
+
actionable_assignments = report[:assignments].map do |assignment|
|
|
159
|
+
assignment.merge("state" => actionable_assignment_state(assignment, live_assignments))
|
|
160
|
+
end
|
|
161
|
+
actionable_verdict = Ace::Coworker::Atoms::ActiveExecutorRule.evaluate(
|
|
162
|
+
config: report[:config], assignments: actionable_assignments
|
|
163
|
+
)
|
|
164
|
+
fingerprint = payload.merge("active" => actionable_verdict[:active], "evidence" => actionable_verdict[:evidence])
|
|
165
|
+
executor = {"key" => "executor:#{identity}", "revision" => revision(fingerprint),
|
|
153
166
|
"type" => "executor"}.merge(payload)
|
|
154
167
|
assignments = report[:assignments].map do |assignment|
|
|
155
168
|
{"key" => "assignment:#{assignment["id"]}",
|
|
156
|
-
"revision" => revision({"id" => assignment["id"],
|
|
157
|
-
|
|
169
|
+
"revision" => revision({"id" => assignment["id"],
|
|
170
|
+
"state" => actionable_assignment_state(assignment, live_assignments)}),
|
|
158
171
|
"type" => "assignment", "id" => assignment["id"],
|
|
159
172
|
"state" => assignment["state"], "progress" => assignment["progress"]}
|
|
160
173
|
end
|
|
@@ -162,6 +175,13 @@ module Ace
|
|
|
162
175
|
end
|
|
163
176
|
end
|
|
164
177
|
|
|
178
|
+
def actionable_assignment_state(assignment, live_assignments)
|
|
179
|
+
state = assignment["state"]
|
|
180
|
+
return "running" if state == "paused" && live_assignments.include?(assignment["id"])
|
|
181
|
+
|
|
182
|
+
state
|
|
183
|
+
end
|
|
184
|
+
|
|
165
185
|
# A coordinator's durable binding also detects a session that
|
|
166
186
|
# started and exited between two native observations.
|
|
167
187
|
def executor_bindings(tasks)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/task/molecules/git_ignored_path"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Overseer
|
|
7
|
+
module Molecules
|
|
8
|
+
# References live in ace-task; execution truth stays in coworker/Herdr.
|
|
9
|
+
# Call under the manager and task delegation exclusions. Creation tags are
|
|
10
|
+
# written by ace-task with the child, so a crash before update is recoverable.
|
|
11
|
+
class ManagerBinding
|
|
12
|
+
def initialize(task_manager:)
|
|
13
|
+
@tasks = task_manager
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def validate!(manager_ref:, task:, roles:)
|
|
17
|
+
manager = @tasks.show(manager_ref)
|
|
18
|
+
raise Error, "Manager not found: #{manager_ref}" unless manager
|
|
19
|
+
unless Ace::Task::Molecules::GitIgnoredPath.ignored_task?(manager)
|
|
20
|
+
raise Error, "manager #{manager.id} must be a private Git-ignored task"
|
|
21
|
+
end
|
|
22
|
+
raise Error, "manager must be a top-level task" if manager.metadata["parent"]
|
|
23
|
+
if Ace::Task::Atoms::TaskValidationRules.terminal_status?(manager.status.to_s)
|
|
24
|
+
raise Error, "manager #{manager.id} is terminal"
|
|
25
|
+
end
|
|
26
|
+
expected = manager.metadata["project_task"].to_s
|
|
27
|
+
raise Error, "manager #{manager.id} belongs to task #{expected}" if !expected.empty? && expected != task.id.to_s
|
|
28
|
+
existing_roles = manager.metadata["roles"]
|
|
29
|
+
if existing_roles && existing_roles != roles
|
|
30
|
+
raise Error, "manager #{manager.id} has a different role sequence"
|
|
31
|
+
end
|
|
32
|
+
managers = @tasks.list(in_folder: "_overseer")
|
|
33
|
+
unless managers.any? { |other| other.id == manager.id }
|
|
34
|
+
raise Error, "manager #{manager.id} must belong to the private _overseer tree"
|
|
35
|
+
end
|
|
36
|
+
conflicts = managers.reject { |other| other.id == manager.id }.select do |other|
|
|
37
|
+
other.metadata["project_task"].to_s == task.id.to_s
|
|
38
|
+
end
|
|
39
|
+
raise Error, "task #{task.id} already belongs to manager #{conflicts.first.id}" unless conflicts.empty?
|
|
40
|
+
manager
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def prepare(manager_ref:, task:, sequence:, worktree_path:, project_root:)
|
|
44
|
+
roles = sequence.map { |leg| leg.fetch("role") }
|
|
45
|
+
manager = validate!(manager_ref: manager_ref, task: task, roles: roles)
|
|
46
|
+
# Refuse conflicts before creating any child. Otherwise a malformed
|
|
47
|
+
# later role could leave a newly created earlier role on every retry.
|
|
48
|
+
matched_roles = {}
|
|
49
|
+
sequence.each do |leg|
|
|
50
|
+
role = leg.fetch("role")
|
|
51
|
+
children = Array(manager.subtasks)
|
|
52
|
+
matches = children.select { |step| step.metadata["role"] == role || Array(step.tags).include?("overseer-role:#{role}") }
|
|
53
|
+
raise Error, "manager #{manager.id} has ambiguous children for #{role}" if matches.size > 1
|
|
54
|
+
matches.each do |step|
|
|
55
|
+
if matched_roles.key?(step.id) && matched_roles[step.id] != role
|
|
56
|
+
raise Error, "manager child #{step.id} matches conflicting roles"
|
|
57
|
+
end
|
|
58
|
+
matched_roles[step.id] = role
|
|
59
|
+
end
|
|
60
|
+
assigned = children.select { |step| step.metadata["assignment"].to_s == leg.fetch("assignment") }
|
|
61
|
+
if assigned.any? { |step| !matches.include?(step) }
|
|
62
|
+
raise Error, "assignment #{leg.fetch('assignment')} belongs to a different manager role"
|
|
63
|
+
end
|
|
64
|
+
assignment = matches.first&.metadata&.fetch("assignment", nil)
|
|
65
|
+
if assignment && assignment != leg.fetch("assignment")
|
|
66
|
+
raise Error, "role #{role} already belongs to assignment #{assignment}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
if roles.include?("bootstrap-verifier") && Array(manager.subtasks).count { |step|
|
|
70
|
+
step.metadata["role"] == "confirm-start" || Array(step.tags).include?("overseer-role:confirm-start")
|
|
71
|
+
} > 1
|
|
72
|
+
raise Error, "manager has ambiguous confirm-start children"
|
|
73
|
+
end
|
|
74
|
+
if Array(manager.subtasks).any? { |step| matched_roles.key?(step.id) &&
|
|
75
|
+
(step.metadata["role"] == "confirm-start" || Array(step.tags).include?("overseer-role:confirm-start")) }
|
|
76
|
+
raise Error, "confirm-start child also matches an execution role"
|
|
77
|
+
end
|
|
78
|
+
update!(manager.id, set: {"project_task" => task.id.to_s,
|
|
79
|
+
"project_root" => project_root, "roles" => roles, "worktree" => worktree_path})
|
|
80
|
+
previous = nil
|
|
81
|
+
bindings = sequence.map do |leg|
|
|
82
|
+
manager = @tasks.show(manager.id)
|
|
83
|
+
role = leg.fetch("role")
|
|
84
|
+
tag = "overseer-role:#{role}"
|
|
85
|
+
matches = Array(manager.subtasks).select do |step|
|
|
86
|
+
step.metadata["role"] == role || Array(step.tags).include?(tag)
|
|
87
|
+
end
|
|
88
|
+
raise Error, "manager #{manager.id} has ambiguous children for #{role}" if matches.size > 1
|
|
89
|
+
step = matches.first || @tasks.create_subtask(manager.id, role, tags: [tag], status: "pending")
|
|
90
|
+
assignment = step.metadata["assignment"].to_s
|
|
91
|
+
if !assignment.empty? && assignment != leg.fetch("assignment")
|
|
92
|
+
raise Error, "role #{role} already belongs to assignment #{assignment}"
|
|
93
|
+
end
|
|
94
|
+
update!(step.id, set: {"role" => role, "assignment" => leg.fetch("assignment"),
|
|
95
|
+
"project_task" => task.id.to_s, "worktree" => worktree_path,
|
|
96
|
+
"dependencies" => (Array(step.dependencies) + Array(previous)).uniq})
|
|
97
|
+
previous = step.id.to_s
|
|
98
|
+
{"role" => role, "assignment" => leg.fetch("assignment"), "step" => previous}
|
|
99
|
+
end
|
|
100
|
+
if roles.include?("bootstrap-verifier")
|
|
101
|
+
manager = @tasks.show(manager.id)
|
|
102
|
+
matches = Array(manager.subtasks).select do |step|
|
|
103
|
+
step.metadata["role"] == "confirm-start" || Array(step.tags).include?("overseer-role:confirm-start")
|
|
104
|
+
end
|
|
105
|
+
raise Error, "manager has ambiguous confirm-start children" if matches.size > 1
|
|
106
|
+
step = matches.first || @tasks.create_subtask(manager.id, "confirm-start",
|
|
107
|
+
tags: ["overseer-role:confirm-start"], status: "pending")
|
|
108
|
+
update!(step.id, set: {"role" => "confirm-start",
|
|
109
|
+
"dependencies" => (Array(step.dependencies) + Array(previous)).uniq})
|
|
110
|
+
end
|
|
111
|
+
bindings
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def ready!(manager_ref:, assignment:, allow_unbound: false)
|
|
115
|
+
manager = @tasks.show(manager_ref)
|
|
116
|
+
step = Array(manager.subtasks).find { |child| child.metadata["assignment"] == assignment }
|
|
117
|
+
raise Error, "assignment #{assignment} has no manager step" unless step || allow_unbound
|
|
118
|
+
[manager, step].compact.each do |task|
|
|
119
|
+
result = Atoms::DependencyReadiness.check(task, lookup: ->(ref) { @tasks.show(ref) })
|
|
120
|
+
raise Error, "delegation blocked: #{result[:reasons].join('; ')}" if result[:status] == :blocked
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private def update!(ref, **options)
|
|
125
|
+
@tasks.update(ref, **options) || raise(Error, "task #{ref} disappeared while recording delegation; retry to reconcile")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def record_execution(manager_ref:, assignment:, execution:)
|
|
129
|
+
manager = @tasks.show(manager_ref)
|
|
130
|
+
matches = Array(manager.subtasks).select { |step| step.metadata["assignment"] == assignment }
|
|
131
|
+
raise Error, "assignment #{assignment} has no unique manager step" unless matches.size == 1
|
|
132
|
+
update!(matches.first.id, set: {"terminal" => execution["terminal_id"],
|
|
133
|
+
"executor" => execution["agent"], "start_config" => execution["config_path"]}.compact)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -1,66 +1,42 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require "digest"
|
|
3
|
-
require "json"
|
|
4
|
-
require_relative "../atoms/observation_sources"
|
|
5
3
|
require "ace/lab/molecules/install_manifest_loader"
|
|
6
|
-
require "ace/
|
|
7
|
-
require "ace/support/cli/toolchain"
|
|
8
|
-
require_relative "session_environment"
|
|
4
|
+
require "ace/hitl/molecules/event_observation"
|
|
9
5
|
|
|
10
6
|
module Ace::Overseer::Molecules
|
|
11
|
-
# The lab reads
|
|
12
|
-
#
|
|
7
|
+
# The lab reads only its own incoming messages from configured project HITL
|
|
8
|
+
# owners. It does not run the project coordinator's complete observation or
|
|
9
|
+
# borrow that project's interpretation of the lab's recipient alias.
|
|
13
10
|
class ProjectObservations
|
|
14
|
-
BUDGET_SECONDS = 20
|
|
15
11
|
def initialize(root: Dir.pwd, env: ENV, loader: -> { Ace::Lab::Molecules::InstallManifestLoader.load },
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@
|
|
19
|
-
@environment = environment || SessionEnvironment.new(env: env, manifest_loader: loader)
|
|
12
|
+
hitl_config: nil)
|
|
13
|
+
@root, @env, @loader = root, env, loader
|
|
14
|
+
@hitl_config = hitl_config
|
|
20
15
|
end
|
|
21
16
|
|
|
22
17
|
def call(owner:, scope:, limit:)
|
|
23
18
|
empty = {"events" => [], "sources" => {}}
|
|
24
|
-
return empty unless @env["ACE_TOOLCHAIN"] == "installed" &&
|
|
25
|
-
File.exist?(Ace::Lab.install_manifest_path)
|
|
19
|
+
return empty unless @env["ACE_TOOLCHAIN"] == "installed" && File.exist?(Ace::Lab.install_manifest_path)
|
|
26
20
|
document = @loader.call
|
|
27
21
|
return empty unless document.dig("coordinator", "path") == File.realpath(@root)
|
|
22
|
+
config = @hitl_config || Dir.chdir(@root) { Ace::Hitl::Molecules::HitlConfigLoader.load }
|
|
23
|
+
address = Ace::Hitl::Molecules::HitlAddressResolver.new(config: config).resolve(owner)
|
|
28
24
|
events, sources = [], {}
|
|
29
|
-
deadline = @clock.call + BUDGET_SECONDS
|
|
30
25
|
document.fetch("projects").each do |project|
|
|
31
26
|
id, root = project.values_at("id", "path")
|
|
32
|
-
source = "project:#{id}"
|
|
27
|
+
source = "project:#{id}:hitl"
|
|
33
28
|
begin
|
|
34
29
|
raise Ace::Overseer::Error, "project root is not canonical" unless root != @root && File.realpath(root) == root
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"--owner", owner, "--scope", scope, "--limit", limit.to_s],
|
|
39
|
-
environment: @environment.observation_environment(root: root),
|
|
40
|
-
chdir: root, timeout_s: remaining, output_limit: 2_097_152, cleanup_group: true)
|
|
41
|
-
raise Ace::Overseer::Error, "project observation failed" unless result.status.success? && !result.oversized
|
|
42
|
-
value = JSON.parse(result.stdout)
|
|
43
|
-
unless value["schema"] == "ace.hitl.observation/v1" && value["events"].is_a?(Array) &&
|
|
44
|
-
value["settled"].is_a?(Array) && value["sources"].is_a?(Hash) &&
|
|
45
|
-
(Ace::Overseer::Atoms::ObservationSources::REQUIRED - value["sources"].keys).empty?
|
|
46
|
-
raise Ace::Overseer::Error, "project observation is incomplete"
|
|
30
|
+
projection = Dir.chdir(root) do
|
|
31
|
+
manager = Ace::Hitl::Organisms::HitlManager.new
|
|
32
|
+
Ace::Hitl::Molecules::EventObservation.new(manager: manager).call(scope: scope, limit: limit)
|
|
47
33
|
end
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
row.merge("key" => Digest::SHA256.hexdigest("#{root}:#{row.fetch('key')}"),
|
|
34
|
+
projection.fetch("events").each do |row|
|
|
35
|
+
recipient = row["recipient"]
|
|
36
|
+
next unless recipient.is_a?(Hash) && address.all? { |key, value| recipient[key] == value }
|
|
37
|
+
events << row.merge("key" => Digest::SHA256.hexdigest("#{root}:#{row.fetch('key')}"),
|
|
53
38
|
"project" => id, "project_root" => root, "owner_key" => row.fetch("key"))
|
|
54
39
|
end
|
|
55
|
-
health = value.fetch("sources").to_h do |name, state|
|
|
56
|
-
unless name.is_a?(String) && name.match?(/\A[a-zA-Z0-9_.:-]{1,64}\z/) &&
|
|
57
|
-
state.is_a?(Hash) && %w[ok degraded].include?(state["status"])
|
|
58
|
-
raise Ace::Overseer::Error, "project source health is incomplete"
|
|
59
|
-
end
|
|
60
|
-
["#{source}:#{name}", state]
|
|
61
|
-
end
|
|
62
|
-
events.concat(rows)
|
|
63
|
-
sources.merge!(health)
|
|
64
40
|
sources[source] = {"status" => "ok"}
|
|
65
41
|
events << project_health(root, id, "ok")
|
|
66
42
|
rescue StandardError => error
|
|
@@ -74,7 +50,7 @@ module Ace::Overseer::Molecules
|
|
|
74
50
|
private
|
|
75
51
|
|
|
76
52
|
def project_health(root, id, status, revision = status)
|
|
77
|
-
key = Digest::SHA256.hexdigest("#{root}:project-
|
|
53
|
+
key = Digest::SHA256.hexdigest("#{root}:project-hitl")
|
|
78
54
|
{"key" => key, "id" => key, "revision" => Digest::SHA256.hexdigest(revision),
|
|
79
55
|
"type" => "source-health", "status" => status, "project" => id, "project_root" => root}
|
|
80
56
|
end
|
|
@@ -5,6 +5,7 @@ require "ace/herdr"
|
|
|
5
5
|
require "digest"
|
|
6
6
|
require "json"
|
|
7
7
|
require_relative "project_root_resolver"
|
|
8
|
+
require_relative "eyes_readiness"
|
|
8
9
|
|
|
9
10
|
module Ace
|
|
10
11
|
module Overseer
|
|
@@ -13,12 +14,14 @@ module Ace
|
|
|
13
14
|
# herdr. This projection has no persisted copy of either.
|
|
14
15
|
class ProjectSessions
|
|
15
16
|
def initialize(root: Dir.pwd, control: nil, locations: Ace::Lab::Molecules::ProjectLocations,
|
|
16
|
-
root_resolver: ProjectRootResolver.new, executor: Ace::Herdr::Molecules::HerdrExecutor.new,
|
|
17
|
+
root_resolver: ProjectRootResolver.new, executor: Ace::Herdr::Molecules::HerdrExecutor.new,
|
|
18
|
+
eyes_evidence: EyesReadiness.new, env: ENV)
|
|
17
19
|
@root = File.realpath(root)
|
|
18
20
|
@control = control || Ace::Herdr::Organisms::ControlSurface.new
|
|
19
21
|
@locations = locations
|
|
20
22
|
@root_resolver = root_resolver
|
|
21
23
|
@executor, @env = executor, env
|
|
24
|
+
@eyes_evidence = eyes_evidence
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
def configured?
|
|
@@ -54,7 +57,7 @@ module Ace
|
|
|
54
57
|
|
|
55
58
|
tabs = @control.list_tabs
|
|
56
59
|
panes = @control.list_panes
|
|
57
|
-
failures + resolved.
|
|
60
|
+
failures + resolved.flat_map do |id, root, project|
|
|
58
61
|
begin
|
|
59
62
|
project_row(id, root, tabs, panes)
|
|
60
63
|
rescue StandardError => e
|
|
@@ -74,16 +77,16 @@ module Ace
|
|
|
74
77
|
end
|
|
75
78
|
|
|
76
79
|
|
|
77
|
-
def live_agent
|
|
80
|
+
def live_agent(pane)
|
|
78
81
|
json = @executor.agent_get(pane).parsed_json
|
|
79
82
|
raise Error, "herdr returned malformed agent state" unless json.is_a?(Hash)
|
|
80
83
|
agent = json["agent"]
|
|
81
84
|
agent ||= json.dig("result", "agent") if json["result"].is_a?(Hash)
|
|
82
85
|
agent ||= json["result"] if json["result"].is_a?(Hash)
|
|
83
86
|
raise Error, "herdr returned no agent identity" unless agent.is_a?(Hash)
|
|
84
|
-
|
|
87
|
+
agent
|
|
85
88
|
rescue Ace::Herdr::AgentNotFoundError
|
|
86
|
-
|
|
89
|
+
nil
|
|
87
90
|
end
|
|
88
91
|
|
|
89
92
|
def project_row(id, root, tabs, panes)
|
|
@@ -93,22 +96,55 @@ module Ace
|
|
|
93
96
|
raise Error, "project overseer tab has no unique pane: #{label}" unless matches.length == 1
|
|
94
97
|
matches
|
|
95
98
|
end
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
# Reconciliation preserves dead panels for diagnostics. Native agent
|
|
100
|
+
# presence, not retained tabs, identifies the live owner.
|
|
101
|
+
live = owners.filter_map do |owner|
|
|
102
|
+
agent = live_agent(owner.fetch(:id))
|
|
103
|
+
[owner, agent] if agent
|
|
101
104
|
end
|
|
102
|
-
|
|
105
|
+
raise Error, "project overseer has ambiguous sessions: #{label}" if live.length > 1
|
|
106
|
+
pane, agent = live.first
|
|
103
107
|
raise Error, "project overseer session did not report a working directory: #{label}" if pane && pane[:cwd].to_s.empty?
|
|
104
108
|
if pane && @root_resolver.resolve(cwd: pane[:cwd]) != root
|
|
105
109
|
raise Error, "project overseer session has a different root: #{label}"
|
|
106
110
|
end
|
|
107
111
|
state = pane ? (pane[:agent_status] || "unknown").to_s : "absent"
|
|
108
112
|
payload = {"project" => id, "root" => root, "pane" => pane && pane[:id], "state" => state}
|
|
109
|
-
{"key" => "project-session:#{Digest::SHA256.hexdigest(root)}",
|
|
110
|
-
"revision" => Digest::SHA256.hexdigest(JSON.generate(payload
|
|
113
|
+
row = {"key" => "project-session:#{Digest::SHA256.hexdigest(root)}",
|
|
114
|
+
"revision" => Digest::SHA256.hexdigest(JSON.generate(payload.merge(
|
|
115
|
+
"state" => (%w[working idle done].include?(state) ? "available" : state)))),
|
|
111
116
|
"type" => "project-session"}.merge(payload)
|
|
117
|
+
[row, readiness_event(id, root, pane, agent)].compact
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A successful first-turn proof is a stable confirmation, not a sampled
|
|
121
|
+
# boolean lifecycle. A concurrent transcript append can make a proof
|
|
122
|
+
# inconclusive; omitting this marker then does not undo eyes' existing ack.
|
|
123
|
+
# The recipient still rechecks start readiness before recording success.
|
|
124
|
+
def readiness_event(id, root, pane, agent)
|
|
125
|
+
return unless pane
|
|
126
|
+
required = Atoms::ObservationSources::REQUIRED.dup
|
|
127
|
+
required << Atoms::ObservationSources::PROJECT_SESSIONS if File.file?(File.join(root, ".ace/lab/config.yml"))
|
|
128
|
+
return unless @eyes_evidence.ready?(pane: pane.fetch(:id), root: root,
|
|
129
|
+
prompt: Atoms::StartContract::FIRST_TURN_PROMPT, required_sources: required)
|
|
130
|
+
identity = readiness_identity(agent)
|
|
131
|
+
current = live_agent(pane.fetch(:id))
|
|
132
|
+
return unless current && readiness_identity(current) == identity
|
|
133
|
+
|
|
134
|
+
payload = {"project" => id, "root" => root, "pane" => pane.fetch(:id),
|
|
135
|
+
"terminal" => identity[0], "session" => identity[1], "ready" => true}
|
|
136
|
+
key = Digest::SHA256.hexdigest(JSON.generate([root, *identity]))
|
|
137
|
+
{"key" => "project-ready:#{key}", "revision" => Digest::SHA256.hexdigest("ready:#{key}"),
|
|
138
|
+
"type" => "project-ready"}.merge(payload)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def readiness_identity(agent)
|
|
142
|
+
terminal = agent["terminal_id"]
|
|
143
|
+
session = agent.dig("agent_session", "value")
|
|
144
|
+
unless terminal.is_a?(String) && !terminal.empty? && session.is_a?(String) && !session.empty?
|
|
145
|
+
raise Error, "ready project agent has no terminal/session identity"
|
|
146
|
+
end
|
|
147
|
+
[terminal, session]
|
|
112
148
|
end
|
|
113
149
|
end
|
|
114
150
|
end
|
|
@@ -25,7 +25,9 @@ module Ace
|
|
|
25
25
|
role = step.metadata["role"].to_s
|
|
26
26
|
worktree = step.metadata["worktree"].to_s
|
|
27
27
|
worktree = manager.metadata["worktree"].to_s if worktree.empty?
|
|
28
|
-
return "
|
|
28
|
+
return "private coordination task #{manager.id} is missing project_task" if task.empty?
|
|
29
|
+
return "private coordination step #{step.id} is missing role; record its commissioned role with ace-task update #{step.id} --set role=<role>" if role.empty?
|
|
30
|
+
return "private coordination step #{step.id} (or its parent) is missing worktree" if worktree.empty?
|
|
29
31
|
return "report task disagrees with the allocation" unless event["task"].to_s == task
|
|
30
32
|
return "confirm-start requires coordinator session evidence" if role == "confirm-start"
|
|
31
33
|
return "report assignment disagrees with the allocation" unless step.metadata["assignment"].to_s == assignment
|
|
@@ -13,7 +13,7 @@ module Ace::Overseer::Molecules
|
|
|
13
13
|
class SessionEnvironment
|
|
14
14
|
BASE_KEYS = %w[HOME USER LOGNAME SHELL TERM LANG LC_ALL TZ TMPDIR PATH GEM_HOME GEM_PATH ACE_TOOLCHAIN
|
|
15
15
|
HERDR_SOCKET_PATH HERDR_WORKSPACE_ID].freeze
|
|
16
|
-
RESERVED = %w[ACE_TOOLCHAIN GEM_HOME GEM_PATH PATH PI_CODING_AGENT_DIR ACE_SESSION_CREDENTIALS_DIRECTORY BUNDLE_GEMFILE RUBYOPT RUBYLIB].freeze
|
|
16
|
+
RESERVED = %w[ACE_TOOLCHAIN GEM_HOME GEM_PATH PATH PI_CODING_AGENT_DIR ACE_SESSION_CREDENTIALS_DIRECTORY BUNDLE_GEMFILE RUBYOPT RUBYLIB ACE_OVERSEER_EYES_CONFIG].freeze
|
|
17
17
|
MAX_BYTES = 65_536
|
|
18
18
|
ENVIRONMENT_ROLES = {"reviewer" => "verifier", "bootstrap-verifier" => "verifier", "bootstrap-worker" => "worker"}.freeze
|
|
19
19
|
|
|
@@ -22,7 +22,7 @@ module Ace::Overseer::Molecules
|
|
|
22
22
|
@env, @loader, @manifest_path = env, manifest_loader, manifest_path
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
def with_environment(root:, role:)
|
|
25
|
+
def with_environment(root:, role:, eyes_config: nil)
|
|
26
26
|
environment = @env.to_h.slice(*BASE_KEYS)
|
|
27
27
|
directory = nil
|
|
28
28
|
handed_off = false
|
|
@@ -46,6 +46,7 @@ module Ace::Overseer::Molecules
|
|
|
46
46
|
else
|
|
47
47
|
environment.merge!(ProjectBundle.environment(root, env: @env)).compact!
|
|
48
48
|
end
|
|
49
|
+
environment["ACE_OVERSEER_EYES_CONFIG"] = eyes_config if eyes_config
|
|
49
50
|
Tempfile.create(["ace-environment-", ".json"]) do |file|
|
|
50
51
|
file.chmod(0o600)
|
|
51
52
|
file.write(JSON.generate(environment))
|
|
@@ -59,12 +60,6 @@ module Ace::Overseer::Molecules
|
|
|
59
60
|
raise
|
|
60
61
|
end
|
|
61
62
|
|
|
62
|
-
# A projection needs this project's read credentials and installed CLI,
|
|
63
|
-
# not the coordinator's token or a new model-auth directory per poll.
|
|
64
|
-
def observation_environment(root:)
|
|
65
|
-
configured_environment(selection_for(root, "project-overseer"))
|
|
66
|
-
end
|
|
67
|
-
|
|
68
63
|
def self.cleanup(directory)
|
|
69
64
|
return unless directory
|
|
70
65
|
# Only our exact scratch directory; never a caller's home/config root.
|
|
@@ -17,7 +17,7 @@ module Ace
|
|
|
17
17
|
return recover_from_stale_worktree(task_ref_str, source: :switch, previous_result: switch_result)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
create_result = @manager.create_task(task_ref_str)
|
|
20
|
+
create_result = @manager.create_task(task_ref_str, defer_setup: true)
|
|
21
21
|
raise Error, create_result[:error] || "Failed to provision worktree for task #{task_ref}" unless create_result[:success]
|
|
22
22
|
|
|
23
23
|
created = !create_result[:existing]
|
|
@@ -44,7 +44,7 @@ module Ace
|
|
|
44
44
|
def recover_from_stale_worktree(task_ref, source:, previous_result:)
|
|
45
45
|
@manager.prune
|
|
46
46
|
|
|
47
|
-
retry_result = @manager.create_task(task_ref)
|
|
47
|
+
retry_result = @manager.create_task(task_ref, defer_setup: true)
|
|
48
48
|
raise Error, retry_result[:error] || "Failed to provision worktree for task #{task_ref}" unless retry_result[:success]
|
|
49
49
|
|
|
50
50
|
created = !retry_result[:existing]
|