ace-overseer 0.28.1 → 0.30.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 +12 -0
- data/README.md +2 -2
- data/docs/getting-started.md +1 -1
- data/docs/usage.md +47 -25
- data/handbook/workflow-instructions/overseer/bootstrap-delegation.wf.md +224 -37
- data/handbook/workflow-instructions/overseer/lab-start.wf.md +49 -21
- data/handbook/workflow-instructions/overseer/queue.wf.md +13 -8
- data/handbook/workflow-instructions/overseer/start.wf.md +198 -21
- data/lib/ace/overseer/atoms/observation_sources.rb +17 -0
- data/lib/ace/overseer/atoms/start_contract.rb +20 -0
- data/lib/ace/overseer/cli/commands/handoff.rb +4 -0
- data/lib/ace/overseer/cli/commands/process_reports.rb +4 -2
- data/lib/ace/overseer/cli/commands/start.rb +45 -5
- data/lib/ace/overseer/cli/commands/work_on.rb +6 -2
- data/lib/ace/overseer/cli.rb +2 -0
- data/lib/ace/overseer/molecules/coworker_launcher.rb +189 -0
- data/lib/ace/overseer/molecules/event_observation.rb +54 -19
- data/lib/ace/overseer/molecules/eyes_evidence.rb +103 -0
- data/lib/ace/overseer/molecules/eyes_readiness.rb +21 -0
- data/lib/ace/overseer/molecules/eyes_session_evidence.rb +132 -0
- data/lib/ace/overseer/molecules/hitl_inbox.rb +13 -5
- data/lib/ace/overseer/molecules/local_sources.rb +83 -19
- data/lib/ace/overseer/molecules/project_bundle.rb +1 -1
- data/lib/ace/overseer/molecules/project_observations.rb +82 -0
- data/lib/ace/overseer/molecules/project_sessions.rb +99 -0
- data/lib/ace/overseer/molecules/remote_sources.rb +46 -16
- data/lib/ace/overseer/molecules/session_environment.rb +127 -0
- data/lib/ace/overseer/organisms/handoff_orchestrator.rb +26 -15
- data/lib/ace/overseer/organisms/report_processor.rb +42 -10
- data/lib/ace/overseer/organisms/start_orchestrator.rb +105 -55
- data/lib/ace/overseer/organisms/work_on_orchestrator.rb +43 -1
- data/lib/ace/overseer/version.rb +1 -1
- data/lib/ace/overseer.rb +2 -1
- metadata +64 -2
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "json"
|
|
5
|
+
require_relative "../atoms/observation_sources"
|
|
6
|
+
require "ace/herdr/molecules/herdr_executor"
|
|
5
7
|
|
|
6
8
|
module Ace
|
|
7
9
|
module Overseer
|
|
8
10
|
module Molecules
|
|
9
11
|
# Standing local-state observation (task 8xs.t.pu0.3): projects the
|
|
10
|
-
#
|
|
12
|
+
# local source classes the started overseer watches — project
|
|
11
13
|
# tasks, private _overseer tasks, worktrees, coworker availability,
|
|
12
|
-
# assignment progress and HITL events — each read from its STATE
|
|
14
|
+
# assignment progress, project sessions and HITL events — each read from its STATE
|
|
13
15
|
# OWNER (ace-task, ace-git-worktree, ace-coworker probe, ace-assign
|
|
14
16
|
# inventory, ace-hitl). This molecule owns no second state: every
|
|
15
17
|
# row is a {key, revision, type} projection whose revision changes
|
|
@@ -21,12 +23,18 @@ module Ace
|
|
|
21
23
|
# (an identical repeated failure never re-wakes; a new failure mode
|
|
22
24
|
# wakes once), while the healthy sources keep projecting.
|
|
23
25
|
class LocalSources
|
|
24
|
-
|
|
26
|
+
EXECUTOR_ROLES = %w[worker verifier integrator].freeze
|
|
27
|
+
|
|
28
|
+
def initialize(task_manager: nil, worktree_manager: nil, probe_factory: nil, project_sessions: nil,
|
|
29
|
+
herdr: Ace::Herdr::Molecules::HerdrExecutor.new, env: ENV)
|
|
25
30
|
@tasks = task_manager || Ace::Task::Organisms::TaskManager.new
|
|
26
31
|
@worktrees = worktree_manager || Ace::Git::Worktree::Organisms::WorktreeManager.new
|
|
27
32
|
@probe_factory = probe_factory || ->(path) {
|
|
28
33
|
Ace::Coworker::Molecules::WorktreeExecutorProbe.new(project_root: path)
|
|
29
34
|
}
|
|
35
|
+
@project_sessions = project_sessions || ProjectSessions.new
|
|
36
|
+
@herdr = herdr
|
|
37
|
+
@env = env
|
|
30
38
|
end
|
|
31
39
|
|
|
32
40
|
# @return [Hash] {"events" => Array<{key, revision, type, ...}>,
|
|
@@ -35,10 +43,25 @@ module Ace
|
|
|
35
43
|
sources = {}
|
|
36
44
|
events = []
|
|
37
45
|
|
|
38
|
-
collect(sources, events,
|
|
39
|
-
|
|
40
|
-
collect(sources, events,
|
|
41
|
-
|
|
46
|
+
collect(sources, events, Atoms::ObservationSources::PROJECT_TASKS) { project_task_events }
|
|
47
|
+
overseer_tasks = []
|
|
48
|
+
collect(sources, events, Atoms::ObservationSources::OVERSEER_TASKS) do
|
|
49
|
+
overseer_tasks = @tasks.list(in_folder: "_overseer")
|
|
50
|
+
task_events(overseer_tasks, "overseer-task")
|
|
51
|
+
end
|
|
52
|
+
collect(sources, events, Atoms::ObservationSources::WORKTREES) { worktree_events }
|
|
53
|
+
collect(sources, events, Atoms::ObservationSources::COWORKERS) { coworker_events(overseer_tasks) }
|
|
54
|
+
if @project_sessions.configured?
|
|
55
|
+
collect(sources, events, Atoms::ObservationSources::PROJECT_SESSIONS) { @project_sessions.call }
|
|
56
|
+
failed_sessions = events.select { |event| event["type"] == "project-session" && event["state"] == "unavailable" }
|
|
57
|
+
unless failed_sessions.empty?
|
|
58
|
+
sources[Atoms::ObservationSources::PROJECT_SESSIONS] = {"status" => "degraded", "error" => "one or more configured project sessions are unavailable"}
|
|
59
|
+
failures = failed_sessions.map { |event| [event.fetch("key"), event.fetch("revision")] }.sort
|
|
60
|
+
events << {"key" => "source:project_sessions", "revision" => revision(failures),
|
|
61
|
+
"type" => "source-health", "status" => "degraded",
|
|
62
|
+
"error" => "one or more configured project sessions are unavailable"}
|
|
63
|
+
end
|
|
64
|
+
end
|
|
42
65
|
|
|
43
66
|
{"events" => events, "sources" => sources}
|
|
44
67
|
end
|
|
@@ -73,10 +96,6 @@ module Ace
|
|
|
73
96
|
task_events(@tasks.list(in_folder: "next"), "task")
|
|
74
97
|
end
|
|
75
98
|
|
|
76
|
-
def overseer_task_events
|
|
77
|
-
task_events(@tasks.list(in_folder: "_overseer"), "overseer-task")
|
|
78
|
-
end
|
|
79
|
-
|
|
80
99
|
# Task rows revision on real lifecycle facts (status, dependency
|
|
81
100
|
# set, subtask statuses) — not on cosmetic frontmatter churn.
|
|
82
101
|
def task_events(tasks, type)
|
|
@@ -108,23 +127,30 @@ module Ace
|
|
|
108
127
|
# ids, never pane names), the ActiveExecutorRule decides live vs
|
|
109
128
|
# stale, and each in-flight assignment projects its own progress
|
|
110
129
|
# row so a step report revises that assignment only.
|
|
111
|
-
def coworker_events
|
|
130
|
+
def coworker_events(overseer_tasks)
|
|
112
131
|
result = @worktrees.list_all
|
|
113
132
|
raise Error, result[:error] || "failed to list worktrees" unless result[:success]
|
|
114
133
|
|
|
115
|
-
Array(result[:worktrees]).reject(&:bare)
|
|
134
|
+
worktrees = Array(result[:worktrees]).reject(&:bare)
|
|
135
|
+
native = !worktrees.empty? && (@env["ACE_RUNTIME"] == "herdr" || !@env["HERDR_PANE_ID"].to_s.empty? || !@env["HERDR_PANE"].to_s.empty?)
|
|
136
|
+
sessions = native ? executor_sessions : {}
|
|
137
|
+
bindings = native ? executor_bindings(overseer_tasks) : {}
|
|
138
|
+
worktrees.flat_map do |worktree|
|
|
116
139
|
probe = @probe_factory.call(worktree.path)
|
|
117
140
|
report = probe.probe(worktree.path)
|
|
118
141
|
verdict = Ace::Coworker::Atoms::ActiveExecutorRule.evaluate(
|
|
119
142
|
config: report[:config], assignments: report[:assignments]
|
|
120
143
|
)
|
|
121
144
|
identity = Digest::SHA256.hexdigest(worktree.path)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
145
|
+
visible = sessions.fetch(worktree.path, [])
|
|
146
|
+
completed = report[:assignments].filter_map { |row| row["id"] if row["state"] == "completed" }
|
|
147
|
+
missing = bindings.fetch(worktree.path, []).reject do |binding|
|
|
148
|
+
completed.include?(binding["assignment"]) || visible.any? { |session| session["terminal"] == binding["terminal"] }
|
|
149
|
+
end
|
|
150
|
+
payload = {"path" => worktree.path, "active" => verdict[:active],
|
|
151
|
+
"evidence" => verdict[:evidence], "sessions" => visible, "missing_sessions" => missing}
|
|
152
|
+
executor = {"key" => "executor:#{identity}", "revision" => revision(payload),
|
|
153
|
+
"type" => "executor"}.merge(payload)
|
|
128
154
|
assignments = report[:assignments].map do |assignment|
|
|
129
155
|
{"key" => "assignment:#{assignment["id"]}",
|
|
130
156
|
"revision" => revision({"id" => assignment["id"], "state" => assignment["state"],
|
|
@@ -135,6 +161,44 @@ module Ace
|
|
|
135
161
|
[executor] + assignments
|
|
136
162
|
end
|
|
137
163
|
end
|
|
164
|
+
|
|
165
|
+
# A coordinator's durable binding also detects a session that
|
|
166
|
+
# started and exited between two native observations.
|
|
167
|
+
def executor_bindings(tasks)
|
|
168
|
+
tasks.flat_map { |task| [task, *Array(task.subtasks)] }.filter_map do |step|
|
|
169
|
+
next if %w[done canceled cancelled].include?(step.status)
|
|
170
|
+
|
|
171
|
+
metadata = step.metadata || {}
|
|
172
|
+
worktree, assignment, terminal = metadata.values_at("worktree", "assignment", "terminal")
|
|
173
|
+
next unless worktree.is_a?(String) && worktree.start_with?("/") &&
|
|
174
|
+
assignment.is_a?(String) && assignment.match?(/\A[a-zA-Z0-9_-]+\z/) &&
|
|
175
|
+
terminal.is_a?(String) && terminal.match?(/\Aterm_[0-9a-f]+\z/)
|
|
176
|
+
|
|
177
|
+
[worktree, {"task" => step.id, "assignment" => assignment, "terminal" => terminal}]
|
|
178
|
+
end.group_by(&:first).transform_values { |rows| rows.map(&:last).sort_by { |row| row.fetch("task") } }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Native sessions can disappear without writing an assignment report.
|
|
182
|
+
# Project their presence on the existing stable worktree row so that
|
|
183
|
+
# even an interruption before the first assignment step is visible.
|
|
184
|
+
def executor_sessions
|
|
185
|
+
response = @herdr.agent_list.parsed_json
|
|
186
|
+
agents = response.is_a?(Hash) && !response.key?("error") && response.dig("result", "agents")
|
|
187
|
+
raise Error, "native executor inventory is unavailable" unless agents.is_a?(Array)
|
|
188
|
+
|
|
189
|
+
sessions = Hash.new { |index, cwd| index[cwd] = [] }
|
|
190
|
+
agents.each do |agent|
|
|
191
|
+
next unless agent.is_a?(Hash) && agent["name"].to_s.start_with?("coworker-")
|
|
192
|
+
|
|
193
|
+
terminal, cwd = agent.values_at("terminal_id", "cwd")
|
|
194
|
+
unless terminal.is_a?(String) && terminal.match?(/\Aterm_[0-9a-f]+\z/) &&
|
|
195
|
+
cwd.is_a?(String) && cwd.start_with?("/")
|
|
196
|
+
raise Error, "executor session identity is invalid"
|
|
197
|
+
end
|
|
198
|
+
sessions[cwd] << {"terminal" => terminal, "agent" => agent["name"]}
|
|
199
|
+
end
|
|
200
|
+
sessions.transform_values { |rows| rows.sort_by { |row| row.fetch("terminal") } }
|
|
201
|
+
end
|
|
138
202
|
end
|
|
139
203
|
end
|
|
140
204
|
end
|
|
@@ -144,7 +144,7 @@ module Ace
|
|
|
144
144
|
unless report.is_a?(Hash) && report["scope"] == "project" && report["status"].is_a?(String)
|
|
145
145
|
raise Ace::Overseer::Error, "project start returned no project-scope report"
|
|
146
146
|
end
|
|
147
|
-
if !status.success? && %w[started reused ready].include?(report["status"])
|
|
147
|
+
if !status.success? && %w[started reused ready].include?(report["status"]) && report["prompt_failed"] != true
|
|
148
148
|
raise Ace::Overseer::Error, "project start exited #{status.exitstatus} despite a ready report"
|
|
149
149
|
end
|
|
150
150
|
report
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "digest"
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../atoms/observation_sources"
|
|
5
|
+
require "ace/lab/molecules/install_manifest_loader"
|
|
6
|
+
require "ace/herdr/molecules/bounded_process"
|
|
7
|
+
require "ace/support/cli/toolchain"
|
|
8
|
+
require_relative "session_environment"
|
|
9
|
+
|
|
10
|
+
module Ace::Overseer::Molecules
|
|
11
|
+
# The lab reads the same public projection as each configured project.
|
|
12
|
+
# Tasks, reports and assignments remain in that project's existing owners.
|
|
13
|
+
class ProjectObservations
|
|
14
|
+
BUDGET_SECONDS = 20
|
|
15
|
+
def initialize(root: Dir.pwd, env: ENV, loader: -> { Ace::Lab::Molecules::InstallManifestLoader.load },
|
|
16
|
+
runner: Ace::Herdr::Molecules::BoundedProcess.method(:call),
|
|
17
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, environment: nil)
|
|
18
|
+
@root, @env, @loader, @runner, @clock = root, env, loader, runner, clock
|
|
19
|
+
@environment = environment || SessionEnvironment.new(env: env, manifest_loader: loader)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(owner:, scope:, limit:)
|
|
23
|
+
empty = {"events" => [], "sources" => {}}
|
|
24
|
+
return empty unless @env["ACE_TOOLCHAIN"] == "installed" &&
|
|
25
|
+
File.exist?(Ace::Lab.install_manifest_path)
|
|
26
|
+
document = @loader.call
|
|
27
|
+
return empty unless document.dig("coordinator", "path") == File.realpath(@root)
|
|
28
|
+
events, sources = [], {}
|
|
29
|
+
deadline = @clock.call + BUDGET_SECONDS
|
|
30
|
+
document.fetch("projects").each do |project|
|
|
31
|
+
id, root = project.values_at("id", "path")
|
|
32
|
+
source = "project:#{id}"
|
|
33
|
+
begin
|
|
34
|
+
raise Ace::Overseer::Error, "project root is not canonical" unless root != @root && File.realpath(root) == root
|
|
35
|
+
remaining = deadline - @clock.call
|
|
36
|
+
raise Timeout::Error, "project observation budget exhausted" unless remaining.positive?
|
|
37
|
+
result = @runner.call([Ace::Support::Cli::Toolchain.executable("ace-overseer"), "observe",
|
|
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"
|
|
47
|
+
end
|
|
48
|
+
rows = (value.fetch("events") + value.fetch("settled")).map do |row|
|
|
49
|
+
unless row.is_a?(Hash) && %w[key revision id type].all? { |key| row[key].is_a?(String) }
|
|
50
|
+
raise Ace::Overseer::Error, "project event is incomplete"
|
|
51
|
+
end
|
|
52
|
+
row.merge("key" => Digest::SHA256.hexdigest("#{root}:#{row.fetch('key')}"),
|
|
53
|
+
"project" => id, "project_root" => root, "owner_key" => row.fetch("key"))
|
|
54
|
+
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
|
+
sources[source] = {"status" => "ok"}
|
|
65
|
+
events << project_health(root, id, "ok")
|
|
66
|
+
rescue StandardError => error
|
|
67
|
+
sources[source] = {"status" => "degraded", "error" => error.class.name}
|
|
68
|
+
events << project_health(root, id, "degraded", error.class.name)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
{"events" => events, "sources" => sources}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def project_health(root, id, status, revision = status)
|
|
77
|
+
key = Digest::SHA256.hexdigest("#{root}:project-observation")
|
|
78
|
+
{"key" => key, "id" => key, "revision" => Digest::SHA256.hexdigest(revision),
|
|
79
|
+
"type" => "source-health", "status" => status, "project" => id, "project_root" => root}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/lab"
|
|
4
|
+
require "ace/herdr"
|
|
5
|
+
require "digest"
|
|
6
|
+
require "json"
|
|
7
|
+
require_relative "project_root_resolver"
|
|
8
|
+
|
|
9
|
+
module Ace
|
|
10
|
+
module Overseer
|
|
11
|
+
module Molecules
|
|
12
|
+
# Project locations and terminal sessions remain owned by ace-lab and
|
|
13
|
+
# herdr. This projection has no persisted copy of either.
|
|
14
|
+
class ProjectSessions
|
|
15
|
+
def initialize(root: Dir.pwd, control: nil, locations: Ace::Lab::Molecules::ProjectLocations,
|
|
16
|
+
root_resolver: ProjectRootResolver.new, executor: Ace::Herdr::Molecules::HerdrExecutor.new, env: ENV)
|
|
17
|
+
@root = File.realpath(root)
|
|
18
|
+
@control = control || Ace::Herdr::Organisms::ControlSurface.new
|
|
19
|
+
@locations = locations
|
|
20
|
+
@root_resolver = root_resolver
|
|
21
|
+
@executor, @env = executor, env
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def configured?
|
|
25
|
+
File.file?(File.join(@root, ".ace/lab/config.yml"))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call
|
|
29
|
+
return [] unless configured?
|
|
30
|
+
|
|
31
|
+
projects = @locations.read(@root)
|
|
32
|
+
return [] if projects.empty?
|
|
33
|
+
|
|
34
|
+
seen = {}
|
|
35
|
+
failures = []
|
|
36
|
+
resolved = projects.filter_map do |project|
|
|
37
|
+
begin
|
|
38
|
+
path = project.fetch("path")
|
|
39
|
+
raise Error, "project has no configured path" if path.to_s.empty?
|
|
40
|
+
canonical = File.realpath(path, @root)
|
|
41
|
+
next if canonical == @root
|
|
42
|
+
canonical = @root_resolver.resolve(cwd: canonical)
|
|
43
|
+
next if canonical == @root
|
|
44
|
+
raise Error, "multiple projects use the same root" if seen[canonical]
|
|
45
|
+
|
|
46
|
+
seen[canonical] = true
|
|
47
|
+
[project.fetch("id"), canonical, project]
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
failures << unavailable_row(project, e)
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
return failures if resolved.empty?
|
|
54
|
+
|
|
55
|
+
tabs = @control.list_tabs
|
|
56
|
+
panes = @control.list_panes
|
|
57
|
+
failures + resolved.map do |id, root, project|
|
|
58
|
+
begin
|
|
59
|
+
project_row(id, root, tabs, panes)
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
unavailable_row(project, e)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def unavailable_row(project, error)
|
|
69
|
+
identity = Digest::SHA256.hexdigest(JSON.generate([project["id"], project["path"]]))
|
|
70
|
+
payload = {"project" => project["id"], "state" => "unavailable", "error" => "#{error.class}: #{error.message}"}
|
|
71
|
+
{"key" => "project-session-error:#{identity}",
|
|
72
|
+
"revision" => Digest::SHA256.hexdigest(JSON.generate(payload)),
|
|
73
|
+
"type" => "project-session"}.merge(payload)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def project_row(id, root, tabs, panes)
|
|
78
|
+
label = Atoms::StartContract.session_label(root)
|
|
79
|
+
owners = tabs.select { |tab| tab[:title] == label }.flat_map do |tab|
|
|
80
|
+
matches = panes.select { |pane| pane[:tab] == tab[:id] }
|
|
81
|
+
raise Error, "project overseer tab has no unique pane: #{label}" unless matches.length == 1
|
|
82
|
+
matches
|
|
83
|
+
end
|
|
84
|
+
raise Error, "project overseer has ambiguous sessions: #{label}" if owners.length > 1
|
|
85
|
+
pane = owners.first
|
|
86
|
+
raise Error, "project overseer session did not report a working directory: #{label}" if pane && pane[:cwd].to_s.empty?
|
|
87
|
+
if pane && @root_resolver.resolve(cwd: pane[:cwd]) != root
|
|
88
|
+
raise Error, "project overseer session has a different root: #{label}"
|
|
89
|
+
end
|
|
90
|
+
state = pane ? (pane[:agent_status] || "unknown").to_s : "absent"
|
|
91
|
+
payload = {"project" => id, "root" => root, "pane" => pane && pane[:id], "state" => state}
|
|
92
|
+
{"key" => "project-session:#{Digest::SHA256.hexdigest(root)}",
|
|
93
|
+
"revision" => Digest::SHA256.hexdigest(JSON.generate(payload)),
|
|
94
|
+
"type" => "project-session"}.merge(payload)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -6,12 +6,10 @@ require "json"
|
|
|
6
6
|
module Ace
|
|
7
7
|
module Overseer
|
|
8
8
|
module Molecules
|
|
9
|
-
# Remote PR
|
|
10
|
-
# pull requests through the EXISTING forge provider adapters
|
|
9
|
+
# Remote issue and PR observation through the existing forge adapters
|
|
11
10
|
# (Ace::Git::Providers.for — Forgejo and GitHub; no separate HTTP
|
|
12
|
-
# client here). Observed PRs come from the project's
|
|
13
|
-
# configuration
|
|
14
|
-
# cascade) — never a scan of every accessible account.
|
|
11
|
+
# client here). Observed repositories and PRs come from the project's
|
|
12
|
+
# own configuration — never a scan of every accessible account.
|
|
15
13
|
#
|
|
16
14
|
# Rows follow the standing-observation contract of LocalSources:
|
|
17
15
|
# stable key + SEMANTIC revision (state, head SHA, review/comment
|
|
@@ -26,6 +24,8 @@ module Ace
|
|
|
26
24
|
# class wakes once per failure mode, never as an empty healthy
|
|
27
25
|
# list — and recovery re-observes the real state.
|
|
28
26
|
class RemoteSources
|
|
27
|
+
ISSUE_LIMIT = 500
|
|
28
|
+
|
|
29
29
|
def initialize(provider_factory: nil, timeout: nil)
|
|
30
30
|
@provider_factory = provider_factory || ->(server_name) {
|
|
31
31
|
server = Ace::Git::ServerRegistry.resolve(server_name)
|
|
@@ -35,8 +35,9 @@ module Ace
|
|
|
35
35
|
|
|
36
36
|
# @param pull_requests [Array<Hash>] {"server" =>, "number" =>}
|
|
37
37
|
# entries from the project's observe.remote configuration
|
|
38
|
+
# @param issues [Array<Hash>] {"server" =>} repository entries
|
|
38
39
|
# @return [Hash] {"events" => Array, "sources" => {name => health}}
|
|
39
|
-
def call(pull_requests:)
|
|
40
|
+
def call(pull_requests:, issues: [])
|
|
40
41
|
sources = {}
|
|
41
42
|
events = []
|
|
42
43
|
|
|
@@ -46,16 +47,9 @@ module Ace
|
|
|
46
47
|
valid, invalid = Array(pull_requests).partition do |entry|
|
|
47
48
|
entry.is_a?(Hash) && !entry["server"].to_s.empty? && !entry["number"].to_s.empty?
|
|
48
49
|
end
|
|
49
|
-
invalid.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
# markers, never silently disappear.
|
|
53
|
-
sources["observe.remote"] ||= {"state" => "degraded",
|
|
54
|
-
"reason" => "invalid observe.remote.pull_requests entry: #{entries.first.inspect}"}
|
|
55
|
-
else
|
|
56
|
-
sources["pr:#{server}"] ||= {"state" => "degraded",
|
|
57
|
-
"reason" => "invalid observe.remote.pull_requests entry: #{entries.first.inspect}"}
|
|
58
|
-
end
|
|
50
|
+
unless invalid.empty?
|
|
51
|
+
index = Array(pull_requests).index(invalid.first)
|
|
52
|
+
collect(sources, events, "pr:configuration") { raise ArgumentError, "invalid observe.remote.pull_requests entry at index #{index}" }
|
|
59
53
|
end
|
|
60
54
|
|
|
61
55
|
valid.group_by { |entry| entry["server"] }.each do |server, entries|
|
|
@@ -64,11 +58,47 @@ module Ace
|
|
|
64
58
|
end
|
|
65
59
|
end
|
|
66
60
|
|
|
61
|
+
Array(issues).each_with_index do |entry, index|
|
|
62
|
+
unless entry.is_a?(Hash) && entry["server"].is_a?(String) && !entry["server"].strip.empty?
|
|
63
|
+
unless sources.key?("issues:configuration")
|
|
64
|
+
collect(sources, events, "issues:configuration") { raise ArgumentError, "invalid observe.remote.issues entry at index #{index}" }
|
|
65
|
+
end
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
server = entry["server"]
|
|
70
|
+
next if sources.key?("issues:#{server}")
|
|
71
|
+
|
|
72
|
+
collect(sources, events, "issues:#{server}") { [issue_snapshot(server)] }
|
|
73
|
+
end
|
|
74
|
+
|
|
67
75
|
{"events" => events, "sources" => sources}
|
|
68
76
|
end
|
|
69
77
|
|
|
70
78
|
private
|
|
71
79
|
|
|
80
|
+
# A single collection revision also detects a closed/deleted issue,
|
|
81
|
+
# which would simply disappear if each open issue had its own row.
|
|
82
|
+
# Refuse a truncated collection instead of silently missing work. The
|
|
83
|
+
# provider must paginate completely and return min(limit, total).
|
|
84
|
+
def issue_snapshot(server)
|
|
85
|
+
provider = @provider_factory.call(server)
|
|
86
|
+
unless provider.respond_to?(:recent_issues)
|
|
87
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
88
|
+
"the selected provider has no issue-listing API; issues are unavailable, not empty"
|
|
89
|
+
end
|
|
90
|
+
issues = provider.recent_issues(limit: ISSUE_LIMIT + 1)
|
|
91
|
+
raise ArgumentError, "too many open issues to observe completely" if issues.length > ISSUE_LIMIT
|
|
92
|
+
|
|
93
|
+
items = issues.map do |issue|
|
|
94
|
+
{"number" => issue.number, "title" => issue.title, "body" => issue.body, "state" => issue.state.to_s,
|
|
95
|
+
"author" => issue.author, "url" => issue.url, "labels" => Array(issue.labels).map(&:to_s).sort}
|
|
96
|
+
end.sort_by { |item| item.fetch("number") }
|
|
97
|
+
{"key" => "issues:#{server}", "revision" => digest(items), "type" => "issues",
|
|
98
|
+
"forge" => server, "count" => items.length,
|
|
99
|
+
"issue_numbers" => items.first(20).map { |item| item.fetch("number") }}
|
|
100
|
+
end
|
|
101
|
+
|
|
72
102
|
def collect(sources, events, name)
|
|
73
103
|
rows = yield
|
|
74
104
|
sources[name] = {"status" => "ok"}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require "tempfile"
|
|
7
|
+
require "ace/lab/auth/session_configuration"
|
|
8
|
+
require "ace/support/cli/toolchain"
|
|
9
|
+
|
|
10
|
+
module Ace::Overseer::Molecules
|
|
11
|
+
# Passive deployment data becomes an explicit environment for one ordinary
|
|
12
|
+
# child. Files here are transient credentials, not a session state store.
|
|
13
|
+
class SessionEnvironment
|
|
14
|
+
BASE_KEYS = %w[HOME USER LOGNAME SHELL TERM LANG LC_ALL TZ TMPDIR PATH GEM_HOME GEM_PATH ACE_TOOLCHAIN
|
|
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
|
|
17
|
+
MAX_BYTES = 65_536
|
|
18
|
+
ENVIRONMENT_ROLES = {"reviewer" => "verifier", "bootstrap-verifier" => "verifier", "bootstrap-worker" => "worker"}.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(env: ENV, manifest_loader: -> { Ace::Lab::Molecules::InstallManifestLoader.load },
|
|
21
|
+
manifest_path: Ace::Lab.install_manifest_path)
|
|
22
|
+
@env, @loader, @manifest_path = env, manifest_loader, manifest_path
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def with_environment(root:, role:)
|
|
26
|
+
environment = @env.to_h.slice(*BASE_KEYS)
|
|
27
|
+
directory = nil
|
|
28
|
+
handed_off = false
|
|
29
|
+
args = []
|
|
30
|
+
if @env["ACE_TOOLCHAIN"] == "installed"
|
|
31
|
+
selection = selection_for(root, role)
|
|
32
|
+
runtime = selection.runtime
|
|
33
|
+
args = ["--model", "#{runtime.fetch('provider')}/#{runtime.fetch('model')}"]
|
|
34
|
+
[runtime.fetch("node"), runtime.fetch("pi")].each do |path|
|
|
35
|
+
raise Ace::Overseer::Error, "installed runtime executable is unavailable: #{path}" unless File.executable?(path)
|
|
36
|
+
end
|
|
37
|
+
directory = Dir.mktmpdir("ace-session-")
|
|
38
|
+
File.chmod(0o700, directory)
|
|
39
|
+
provider = runtime.fetch("provider")
|
|
40
|
+
auth = read_private_json(runtime.fetch("credentials"))
|
|
41
|
+
raise Ace::Overseer::Error, "model provider credentials are unavailable" unless auth[provider].is_a?(Hash) && !auth[provider].empty?
|
|
42
|
+
write_private(File.join(directory, "auth.json"), provider => auth.fetch(provider))
|
|
43
|
+
write_private(File.join(directory, "settings.json"), "defaultProjectTrust" => "never")
|
|
44
|
+
environment = configured_environment(selection)
|
|
45
|
+
environment.merge!("PI_CODING_AGENT_DIR" => directory, "ACE_SESSION_CREDENTIALS_DIRECTORY" => directory)
|
|
46
|
+
else
|
|
47
|
+
environment.merge!(ProjectBundle.environment(root, env: @env)).compact!
|
|
48
|
+
end
|
|
49
|
+
Tempfile.create(["ace-environment-", ".json"]) do |file|
|
|
50
|
+
file.chmod(0o600)
|
|
51
|
+
file.write(JSON.generate(environment))
|
|
52
|
+
file.flush
|
|
53
|
+
file.fsync
|
|
54
|
+
handed_off = true
|
|
55
|
+
yield file.path, directory, args
|
|
56
|
+
end
|
|
57
|
+
rescue StandardError
|
|
58
|
+
FileUtils.remove_entry(directory) if !handed_off && directory && File.directory?(directory)
|
|
59
|
+
raise
|
|
60
|
+
end
|
|
61
|
+
|
|
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
|
+
def self.cleanup(directory)
|
|
69
|
+
return unless directory
|
|
70
|
+
# Only our exact scratch directory; never a caller's home/config root.
|
|
71
|
+
unless File.absolute_path(directory) == directory && File.dirname(directory) == Dir.tmpdir.chomp(File::SEPARATOR) &&
|
|
72
|
+
File.basename(directory).start_with?("ace-session-") && !File.symlink?(directory)
|
|
73
|
+
raise Ace::Overseer::Error, "invalid session credential directory"
|
|
74
|
+
end
|
|
75
|
+
# Pi's transcript may live beside auth.json. Keep execution evidence.
|
|
76
|
+
File.unlink(File.join(directory, "auth.json")) if File.file?(File.join(directory, "auth.json"))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def selection_for(root, role)
|
|
82
|
+
raise Ace::Overseer::Error, "installed deployment manifest is unavailable" unless File.file?(@manifest_path)
|
|
83
|
+
document = @loader.call
|
|
84
|
+
project = ([document["coordinator"]] + document.fetch("projects")).compact.find do |entry|
|
|
85
|
+
root == entry["path"] || root.start_with?("#{entry['path']}/.ace-wt/")
|
|
86
|
+
end
|
|
87
|
+
raise Ace::Overseer::Error, "execution directory is not a configured project" unless project
|
|
88
|
+
Ace::Lab::Auth::SessionConfiguration.new(loader: -> { document }).resolve(
|
|
89
|
+
project: project.fetch("id"), role: ENVIRONMENT_ROLES.fetch(role, role))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def configured_environment(selection)
|
|
93
|
+
environment = @env.to_h.slice(*BASE_KEYS)
|
|
94
|
+
if selection.environment_file
|
|
95
|
+
values = read_private_json(selection.environment_file)
|
|
96
|
+
unless values.all? { |key, value| key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/) && value.is_a?(String) && !value.include?("\0") } &&
|
|
97
|
+
(values.keys & RESERVED).empty? && values.keys.none? { |key| key.start_with?("BUNDLE", "BUNDLER_") }
|
|
98
|
+
raise Ace::Overseer::Error, "role environment contains invalid or reserved variables"
|
|
99
|
+
end
|
|
100
|
+
environment.merge!(values)
|
|
101
|
+
end
|
|
102
|
+
runtime = selection.runtime
|
|
103
|
+
environment.merge("HERDR_SOCKET_PATH" => runtime.fetch("herdr").fetch("socket"),
|
|
104
|
+
"ACE_TOOLCHAIN" => "installed", "GEM_HOME" => selection.gem_home,
|
|
105
|
+
"GEM_PATH" => selection.gem_home,
|
|
106
|
+
"PATH" => ([File.join(selection.gem_home, "bin"), File.dirname(runtime.fetch("node")), File.dirname(runtime.fetch("pi"))] + runtime.fetch("path")).uniq.join(File::PATH_SEPARATOR))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def read_private_json(path)
|
|
110
|
+
File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
111
|
+
stat = file.stat
|
|
112
|
+
unless stat.file? && [0, Process.uid].include?(stat.uid) && (stat.mode & 0o777) == 0o600 && stat.size <= MAX_BYTES
|
|
113
|
+
raise Ace::Overseer::Error, "credential source must be a private regular file"
|
|
114
|
+
end
|
|
115
|
+
value = JSON.parse(file.read(MAX_BYTES + 1), create_additions: false, max_nesting: 8)
|
|
116
|
+
raise Ace::Overseer::Error, "credential source must be a JSON object" unless value.is_a?(Hash)
|
|
117
|
+
value
|
|
118
|
+
end
|
|
119
|
+
rescue JSON::ParserError, SystemCallError
|
|
120
|
+
raise Ace::Overseer::Error, "configured credential source is unavailable or invalid"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def write_private(path, value)
|
|
124
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(JSON.generate(value)) }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|