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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +2 -2
  4. data/docs/getting-started.md +1 -1
  5. data/docs/usage.md +47 -25
  6. data/handbook/workflow-instructions/overseer/bootstrap-delegation.wf.md +224 -37
  7. data/handbook/workflow-instructions/overseer/lab-start.wf.md +49 -21
  8. data/handbook/workflow-instructions/overseer/queue.wf.md +13 -8
  9. data/handbook/workflow-instructions/overseer/start.wf.md +198 -21
  10. data/lib/ace/overseer/atoms/observation_sources.rb +17 -0
  11. data/lib/ace/overseer/atoms/start_contract.rb +20 -0
  12. data/lib/ace/overseer/cli/commands/handoff.rb +4 -0
  13. data/lib/ace/overseer/cli/commands/process_reports.rb +4 -2
  14. data/lib/ace/overseer/cli/commands/start.rb +45 -5
  15. data/lib/ace/overseer/cli/commands/work_on.rb +6 -2
  16. data/lib/ace/overseer/cli.rb +2 -0
  17. data/lib/ace/overseer/molecules/coworker_launcher.rb +189 -0
  18. data/lib/ace/overseer/molecules/event_observation.rb +54 -19
  19. data/lib/ace/overseer/molecules/eyes_evidence.rb +103 -0
  20. data/lib/ace/overseer/molecules/eyes_readiness.rb +21 -0
  21. data/lib/ace/overseer/molecules/eyes_session_evidence.rb +132 -0
  22. data/lib/ace/overseer/molecules/hitl_inbox.rb +13 -5
  23. data/lib/ace/overseer/molecules/local_sources.rb +83 -19
  24. data/lib/ace/overseer/molecules/project_bundle.rb +1 -1
  25. data/lib/ace/overseer/molecules/project_observations.rb +82 -0
  26. data/lib/ace/overseer/molecules/project_sessions.rb +99 -0
  27. data/lib/ace/overseer/molecules/remote_sources.rb +46 -16
  28. data/lib/ace/overseer/molecules/session_environment.rb +127 -0
  29. data/lib/ace/overseer/organisms/handoff_orchestrator.rb +26 -15
  30. data/lib/ace/overseer/organisms/report_processor.rb +42 -10
  31. data/lib/ace/overseer/organisms/start_orchestrator.rb +105 -55
  32. data/lib/ace/overseer/organisms/work_on_orchestrator.rb +43 -1
  33. data/lib/ace/overseer/version.rb +1 -1
  34. data/lib/ace/overseer.rb +2 -1
  35. 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
- # six local source classes the started overseer watches — project
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
- def initialize(task_manager: nil, worktree_manager: nil, probe_factory: nil)
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, "project_tasks") { project_task_events }
39
- collect(sources, events, "overseer_tasks") { overseer_task_events }
40
- collect(sources, events, "worktrees") { worktree_events }
41
- collect(sources, events, "coworkers") { coworker_events }
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).flat_map do |worktree|
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
- executor = {"key" => "executor:#{identity}",
123
- "revision" => revision({"path" => worktree.path,
124
- "active" => verdict[:active],
125
- "evidence" => verdict[:evidence]}),
126
- "type" => "executor", "path" => worktree.path,
127
- "active" => verdict[:active], "evidence" => verdict[:evidence]}
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/repo observation (task 8xs.t.pu0.4): projects configured
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 own
13
- # configuration (`observe.remote.pull_requests` in the overseer
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.group_by { |entry| entry.is_a?(Hash) ? entry["server"].to_s : "" }.each do |server, entries|
50
- if server.empty?
51
- # Non-Hash or server-less entries still surface as health
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