ace-overseer 0.27.0 → 0.28.1

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.
@@ -16,19 +16,36 @@ module Ace
16
16
 
17
17
  desc "Start or reuse the project overseer session from .ace/overseer/config.yml"
18
18
 
19
+ option :reconcile, type: :boolean, default: false, desc: "Explicitly reconcile configured project overseers without restarting the lab coordinator"
20
+ option :scope, type: :string, desc: "Coordinator role (lab or project); omitted means lab bootstrap"
19
21
  option :dry_run, type: :boolean, default: false, desc: "Show the start plan without creating a session"
20
22
  option :format, default: "table", desc: "Output format (table, json)"
21
23
  option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
22
24
  option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
23
25
 
24
- def initialize(orchestrator: nil, config: nil)
26
+ def initialize(orchestrator: nil, config: nil, launcher: nil)
25
27
  super()
26
28
  @orchestrator = orchestrator
27
29
  @config = config
30
+ @launcher = launcher || ->(argv) { Kernel.exec(*argv) }
28
31
  end
29
32
 
30
- def call(dry_run: false, format: "table", **options)
31
- report = orchestrator.call(dry_run: dry_run)
33
+ def call(dry_run: false, format: "table", scope: nil, reconcile: false, **options)
34
+ unless scope.nil? || Atoms::StartContract::SCOPES.include?(scope)
35
+ raise Ace::Support::Cli::Error, "scope must be lab or project"
36
+ end
37
+ if reconcile && scope == "project"
38
+ raise Ace::Support::Cli::Error, "--reconcile requires lab scope"
39
+ end
40
+ operation = orchestrator(scope: scope)
41
+ report = if reconcile
42
+ operation.reconcile(dry_run: dry_run)
43
+ elsif scope
44
+ operation.call(dry_run: dry_run)
45
+ else
46
+ operation.bootstrap(dry_run: dry_run)
47
+ end
48
+ return @launcher.call(report.fetch("command")) if report["status"] == "foreground"
32
49
 
33
50
  if options[:quiet]
34
51
  # quiet still fails loudly on a blocked start
@@ -51,24 +68,24 @@ module Ace
51
68
 
52
69
  private
53
70
 
54
- def orchestrator
55
- @orchestrator || Organisms::StartOrchestrator.new(config: @config || Ace::Overseer.config)
71
+ def orchestrator(scope: nil)
72
+ @orchestrator || Organisms::StartOrchestrator.new(config: @config || Ace::Overseer.config, scope: scope)
56
73
  end
57
74
 
58
75
  def render_table(report)
59
- puts "ace-overseer start: #{report['status']}"
60
- puts " scope: #{report['scope'] || '-'}"
61
- puts " root: #{report['root'] || '-'}"
62
- puts " label: #{report['label'] || '-'}"
63
- puts " profile: #{report['profile'] || '-'}"
76
+ puts "ace-overseer start: #{report["status"]}"
77
+ puts " scope: #{report["scope"] || "-"}"
78
+ puts " root: #{report["root"] || "-"}"
79
+ puts " label: #{report["label"] || "-"}"
80
+ puts " profile: #{report["profile"] || "-"}"
64
81
  if report["instructions"]
65
- puts " instructions: #{report['instructions']['source']} -> #{report['instructions']['path']}"
82
+ puts " instructions: #{report["instructions"]["source"]} -> #{report["instructions"]["path"]}"
66
83
  end
67
- puts " workspace: #{report['workspace'] || '-'}"
68
- puts " pane: #{report['pane'] || '-'}"
69
- puts " agent: #{report['agent']}"
70
- puts " replaced pane: #{report['replaced_pane'] || '-'}" if report["replaced_pane"]
71
- puts " #{report['message']}" if report["message"]
84
+ puts " workspace: #{report["workspace"] || "-"}"
85
+ puts " pane: #{report["pane"] || "-"}"
86
+ puts " agent: #{report["agent"]}"
87
+ puts " replaced pane: #{report["replaced_pane"] || "-"}" if report["replaced_pane"]
88
+ puts " #{report["message"]}" if report["message"]
72
89
  end
73
90
  end
74
91
  end
@@ -44,8 +44,9 @@ module Ace
44
44
  sources = local.fetch("sources").merge(remote.fetch("sources")).merge(
45
45
  "hitl" => {"status" => "degraded", "error" => "#{hitl_error.class}: #{hitl_error.message}"}
46
46
  )
47
+ ensure_budget!(events.length, limit)
47
48
  return {"schema" => "ace.hitl.observation/v1",
48
- "events" => events.sort_by { |row| row["key"] },
49
+ "events" => wire_events(events).sort_by { |row| row["key"] },
49
50
  "settled" => [], "sources" => sources}
50
51
  end
51
52
 
@@ -60,7 +61,8 @@ module Ace
60
61
  manager && ([manager] + Array(manager.subtasks)).all? { |task| Ace::Task::Atoms::TaskValidationRules.terminal_status?(task.status.to_s) }
61
62
  end
62
63
 
63
- projection.merge("events" => (pending + local.fetch("events") + remote.fetch("events"))
64
+ ensure_budget!(pending.length + settled.length + local.fetch("events").length + remote.fetch("events").length, limit)
65
+ projection.merge("events" => (pending + wire_events(local.fetch("events") + remote.fetch("events")))
64
66
  .sort_by { |row| row["key"] },
65
67
  "settled" => settled,
66
68
  "sources" => local.fetch("sources").merge(remote.fetch("sources"))
@@ -69,6 +71,23 @@ module Ace
69
71
 
70
72
  private
71
73
 
74
+ def ensure_budget!(count, limit)
75
+ return if count <= limit
76
+
77
+ raise ArgumentError, "observation has #{count} rows, exceeding limit #{limit}; increase maxEvents/--limit or archive accepted history before arming eyes"
78
+ end
79
+
80
+ # Local owners expose readable keys, whereas eyes consumes the HITL
81
+ # wire envelope (hashed key, revision and a safe id). Normalize only
82
+ # local/remote rows; canonical HITL identities remain untouched.
83
+ def wire_events(rows)
84
+ rows.map do |row|
85
+ key = Digest::SHA256.hexdigest("overseer:#{row.fetch('key')}")
86
+ row.merge("source_key" => row.fetch("key"), "source_id" => row["id"],
87
+ "key" => key, "id" => key)
88
+ end
89
+ end
90
+
72
91
  # Configured remote observation (8xs.t.pu0.4): the project's own
73
92
  # observe.remote.pull_requests list — never a scan of accessible
74
93
  # accounts. No configuration means no remote rows and no remote
@@ -34,6 +34,7 @@ module Ace
34
34
  "result" => event.result,
35
35
  "task" => event.metadata["task"],
36
36
  "assignment" => event.metadata["assignment"],
37
+ "role" => event.metadata["role"],
37
38
  "title" => event.title
38
39
  }
39
40
  end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/herdr/molecules/bounded_process"
5
+ require "shellwords"
6
+ require "tempfile"
7
+ require "securerandom"
8
+ require "fileutils"
9
+ require "ace/support/cli/toolchain"
10
+
11
+ module Ace
12
+ module Overseer
13
+ module Molecules
14
+ # A project's own Gemfile/lock and binstub select its ACE version.
15
+ # Never load child project gems into the lab coordinator's process.
16
+ class ProjectBundle
17
+ # A new Herdr shell inherits the server environment, which may differ
18
+ # from this CLI's environment. Clear known Bundler selectors even
19
+ # when the requesting process did not have them set.
20
+ BUNDLE_SELECTORS = %w[
21
+ BUNDLE_ALLOW_OFFLINE_INSTALL BUNDLE_APP_CONFIG BUNDLE_AUTO_INSTALL
22
+ BUNDLE_BIN BUNDLE_BIN_PATH BUNDLE_CACHE_ALL BUNDLE_CACHE_ALL_PLATFORMS
23
+ BUNDLE_CACHE_PATH BUNDLE_CLEAN BUNDLE_CONSOLE BUNDLE_DEPLOYMENT
24
+ BUNDLE_DISABLE_CHECKSUM_VALIDATION BUNDLE_DISABLE_EXEC_LOAD
25
+ BUNDLE_DISABLE_LOCAL_BRANCH_CHECK BUNDLE_DISABLE_LOCAL_REVISION_CHECK
26
+ BUNDLE_DISABLE_SHARED_GEMS BUNDLE_DISABLE_VERSION_CHECK
27
+ BUNDLE_FORCE_RUBY_PLATFORM BUNDLE_FORGET_CLI_OPTIONS BUNDLE_FROZEN
28
+ BUNDLE_GEMFILE BUNDLE_GEM__BUNDLE BUNDLE_GEM__CHANGELOG BUNDLE_GEM__CI
29
+ BUNDLE_GEM__COC BUNDLE_GEM__GITHUB_USERNAME BUNDLE_GEM__LINTER
30
+ BUNDLE_GEM__MIT BUNDLE_GEM__RUBOCOP BUNDLE_GEM__TEST
31
+ BUNDLE_GIT__ALLOW_INSECURE BUNDLE_GLOBAL_GEM_CACHE BUNDLE_IGNORE_CONFIG
32
+ BUNDLE_IGNORE_MESSAGES BUNDLE_INIT_GEMS_RB BUNDLE_INLINE BUNDLE_JOBS
33
+ BUNDLE_LOCKFILE_CHECKSUMS BUNDLE_NO_INSTALL BUNDLE_NO_PRUNE BUNDLE_ONLY
34
+ BUNDLE_PATH BUNDLE_PATH__SYSTEM BUNDLE_PLUGINS BUNDLE_PREFER_PATCH
35
+ BUNDLE_REDIRECT BUNDLE_RETRY BUNDLE_SHEBANG BUNDLE_SILENCE_DEPRECATIONS
36
+ BUNDLE_SILENCE_ROOT_WARNING BUNDLE_SIMULATE_VERSION BUNDLE_SSL_CA_CERT
37
+ BUNDLE_SSL_CLIENT_CERT BUNDLE_SSL_VERIFY_MODE BUNDLE_SYSTEM_BINDIR
38
+ BUNDLE_TIMEOUT BUNDLE_TRUST___POLICY BUNDLE_UPDATE_REQUIRES_ALL_FLAG
39
+ BUNDLE_USER_HOME BUNDLE_USER_CONFIG BUNDLE_USER_CACHE BUNDLE_USER_PLUGIN
40
+ BUNDLE_VERBOSE BUNDLE_VERSION BUNDLE_WITH BUNDLE_WITHOUT
41
+ BUNDLER_SETUP BUNDLER_VERSION
42
+ ].freeze
43
+ RUBY_SELECTORS = %w[RUBYOPT RUBYLIB GEM_HOME GEM_PATH ACE_TOOLCHAIN].freeze
44
+
45
+ def self.environment(root, env: ENV)
46
+ if Ace::Support::Cli::Toolchain.installed?
47
+ # Reuse the installed closure; the project checkout is domain data.
48
+ # Do not let a project binstub or Gemfile select source dependencies.
49
+ return Ace::Support::Cli::Toolchain.child_environment.merge(
50
+ "HERDR_PANE" => nil, "HERDR_TAB" => nil,
51
+ "HERDR_PANE_ID" => nil, "HERDR_TAB_ID" => nil
52
+ )
53
+ end
54
+ known = BUNDLE_SELECTORS + RUBY_SELECTORS
55
+ names = env.keys.grep(/\ABUNDLER?_/) + known +
56
+ (known + %w[PATH]).map { |key| "BUNDLER_ORIG_#{key}" } +
57
+ %w[HERDR_PANE HERDR_TAB HERDR_PANE_ID HERDR_TAB_ID]
58
+ names.to_h { |name| [name, nil] }.merge(
59
+ "BUNDLE_GEMFILE" => File.file?(File.join(root, "Gemfile")) ? File.join(root, "Gemfile") : nil,
60
+ "PATH" => [File.join(root, "bin"), env.fetch("PATH", "/usr/bin:/bin")].join(File::PATH_SEPARATOR)
61
+ )
62
+ end
63
+
64
+ def self.shell_setup(root, env: ENV)
65
+ values = environment(root, env: env).reject { |name, _| %w[HERDR_PANE HERDR_TAB HERDR_PANE_ID HERDR_TAB_ID].include?(name) }
66
+ if Ace::Support::Cli::Toolchain.installed?
67
+ # A server-created shell may contain selectors absent from this
68
+ # caller. Clear the same known selectors before applying the
69
+ # installed closure; never inherit server-side Bundler injection.
70
+ names = BUNDLE_SELECTORS + BUNDLE_SELECTORS.map { |key| "BUNDLER_ORIG_#{key}" }
71
+ values = names.to_h { |name| [name, nil] }.merge(values)
72
+ end
73
+
74
+ cleared, assigned = values.partition { |_, value| value.nil? }
75
+ # Guard every operation: e.g. a readonly inherited selector must
76
+ # refuse setup rather than print readiness after a failed unset.
77
+ commands = cleared.map { |name, _| "unset #{Shellwords.escape(name)}" } +
78
+ assigned.map { |name, value| "export #{name}=#{Shellwords.escape(value)}" }
79
+ commands.join(" &&\n")
80
+ end
81
+
82
+ # pane_run types through a terminal: Linux canonical input truncates
83
+ # a line after 4095 bytes. Keep the environment in a private shared
84
+ # project file, not /tmp (Herdr can use a different PrivateTmp).
85
+ # The caller must observe the marker before launching the harness.
86
+ def self.with_shell_setup(root, env: ENV)
87
+ directory = root
88
+ %w[.ace-local overseer runtime-start].each do |part|
89
+ directory = File.join(directory, part)
90
+ raise Ace::Overseer::Error, "runtime setup directory is a symlink: #{directory}" if File.symlink?(directory)
91
+
92
+ Dir.mkdir(directory, 0o700) unless File.exist?(directory)
93
+ raise Ace::Overseer::Error, "runtime setup path is not a directory: #{directory}" unless File.directory?(directory)
94
+ end
95
+ info = File.stat(directory)
96
+ unless info.uid == Process.uid && (info.mode & 0o077).zero?
97
+ raise Ace::Overseer::Error, "runtime setup directory must be private and owned by the current user: #{directory}"
98
+ end
99
+ marker = "ACE_OVERSEER_ENV_READY_#{SecureRandom.hex(16)}"
100
+ Tempfile.create(["environment-", ".sh"], directory) do |file|
101
+ setup = shell_setup(root, env: env).strip.sub(/;\z/, "")
102
+ file.write("#{setup} && printf '%s\\n' #{Shellwords.escape(marker)}\n")
103
+ file.flush
104
+ file.fsync
105
+ # The tab is created in root. An explicit ./ prevents dot from
106
+ # searching PATH; a relative path also keeps terminal input short.
107
+ relative = "./.ace-local/overseer/runtime-start/#{File.basename(file.path)}"
108
+ yield ". #{Shellwords.escape(relative)}", marker
109
+ end
110
+ rescue SystemCallError => e
111
+ raise Ace::Overseer::Error, "runtime setup file could not be prepared: #{e.message}"
112
+ end
113
+
114
+ def initialize(timeout_s: 240)
115
+ @timeout_s = timeout_s
116
+ end
117
+
118
+ def call(root:, workspace:, env: ENV)
119
+ installed = Ace::Support::Cli::Toolchain.installed?
120
+ unless installed
121
+ %w[Gemfile Gemfile.lock].each do |name|
122
+ unless File.file?(File.join(root, name))
123
+ raise Ace::Overseer::Error, "project bundle missing #{name}; prepare this project's own bundle first"
124
+ end
125
+ end
126
+ end
127
+ binary = installed ? Ace::Support::Cli::Toolchain.executable("ace-overseer") : File.join(root, "bin", "ace-overseer")
128
+ command = File.executable?(binary) ? [binary] : ["bundle", "exec", "ace-overseer"]
129
+ command += ["start", "--scope", "project", "--format", "json"]
130
+ child_env = self.class.environment(root, env: env).merge("HERDR_WORKSPACE_ID" => workspace)
131
+ result = Ace::Herdr::Molecules::BoundedProcess.call(command,
132
+ environment: ENV.to_h.merge(child_env).compact, chdir: root,
133
+ timeout_s: @timeout_s, output_limit: 1_048_576)
134
+ raise Ace::Overseer::Error, "project start output exceeded its limit" if result.oversized
135
+ out, err, status = result.stdout, result.stderr, result.status
136
+ report = JSON.parse(out)
137
+ # Startup can fail before it establishes project scope. Keep that
138
+ # concrete failure without admitting an unscoped report as ready.
139
+ if report.is_a?(Hash) && report["status"] == "blocked" &&
140
+ report.key?("scope") && report["scope"].nil? &&
141
+ report["message"].is_a?(String) && !report["message"].strip.empty?
142
+ raise Ace::Overseer::Error, report["message"]
143
+ end
144
+ unless report.is_a?(Hash) && report["scope"] == "project" && report["status"].is_a?(String)
145
+ raise Ace::Overseer::Error, "project start returned no project-scope report"
146
+ end
147
+ if !status.success? && %w[started reused ready].include?(report["status"])
148
+ raise Ace::Overseer::Error, "project start exited #{status.exitstatus} despite a ready report"
149
+ end
150
+ report
151
+ rescue Timeout::Error
152
+ raise Ace::Overseer::Error, "project start timed out after #{@timeout_s}s; inspect the existing session before retrying"
153
+ rescue Ace::Herdr::Molecules::BoundedProcess::PostLaunchError => e
154
+ raise Ace::Overseer::Error, "project start outcome is uncertain: #{e.message}"
155
+ rescue JSON::ParserError
156
+ raise Ace::Overseer::Error, "project bundle start returned invalid JSON#{": " + err.strip[0, 300] unless err.to_s.empty?}"
157
+ rescue SystemCallError => e
158
+ raise Ace::Overseer::Error, "project bundle could not start: #{e.message}"
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Overseer
5
+ module Molecules
6
+ # Read existing coworker allocation and ace-assign completion truth before
7
+ # the queue accepts success. This check owns no persisted state.
8
+ class ReportCompletionCheck
9
+ def initialize(handoff_probe: nil, assignment_probe_factory: nil, worktree_finder: nil)
10
+ @worktree_finder = worktree_finder || Ace::Git::Worktree::Organisms::WorktreeManager.new
11
+ @handoff_probe = handoff_probe || HandoffProbe.new
12
+ @assignment_probe_factory = assignment_probe_factory || ->(root) {
13
+ Ace::Coworker::Molecules::AssignmentProbe.new(project_root: root)
14
+ }
15
+ end
16
+
17
+ def call(event:, manager:, step:)
18
+ return "matched task allocation no longer exists" unless manager && step
19
+ assignment = event["assignment"].to_s
20
+ if assignment.empty?
21
+ return nil if step.metadata["role"] == "planner" && step.metadata["assignment"].to_s.empty?
22
+ return "unassigned readiness is not execution completion"
23
+ end
24
+ task = manager.metadata["project_task"].to_s
25
+ role = step.metadata["role"].to_s
26
+ worktree = step.metadata["worktree"].to_s
27
+ worktree = manager.metadata["worktree"].to_s if worktree.empty?
28
+ return "success requires recorded project task, role and worktree" if [task, role, worktree].any?(&:empty?)
29
+ return "report task disagrees with the allocation" unless event["task"].to_s == task
30
+ return "confirm-start requires coordinator session evidence" if role == "confirm-start"
31
+ return "report assignment disagrees with the allocation" unless step.metadata["assignment"].to_s == assignment
32
+
33
+ found = @worktree_finder.switch(task)
34
+ return "task has no usable worktree: #{found[:error] || found[:message]}" unless found[:success]
35
+ root = found.fetch(:worktree_path)
36
+ legs = @handoff_probe.probe(root, task_id: task).fetch(:legs)
37
+ matching = legs.select { |leg| leg["assignment"] == assignment }
38
+ return "assignment has no unique allocation for this task" unless matching.size == 1
39
+ return "assignment role disagrees with the recorded allocation" unless matching.first["role"] == role
40
+ return "report role disagrees with the allocation" unless event["role"].to_s.empty? || event["role"] == role
41
+
42
+ snapshot = @assignment_probe_factory.call(root).snapshot(assignment)
43
+ return "assignment owner returned a different identity" unless snapshot.id == assignment
44
+ return "assignment is not complete: #{snapshot.incomplete_reasons.join('; ')}" unless snapshot.completion_verdict == :complete
45
+ nil
46
+ rescue Ace::Coworker::Error, Ace::Overseer::Error, SystemCallError, KeyError, Psych::Exception => e
47
+ "assignment completion could not be verified in #{worktree}: #{e.message}"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -16,7 +16,8 @@ module Ace
16
16
  class ReportProcessor
17
17
  OUTCOME_STATUSES = %i[success error needs_answer duplicate unmatched].freeze
18
18
 
19
- def initialize(inbox: nil, task_manager: nil, handoff: nil, work_on: nil)
19
+ def initialize(inbox: nil, task_manager: nil, handoff: nil, work_on: nil, completion_check: nil)
20
+ @completion_check = completion_check || Molecules::ReportCompletionCheck.new
20
21
  @inbox = inbox || Molecules::HitlInbox.new
21
22
  @task_manager = task_manager || Ace::Task::Organisms::TaskManager.new
22
23
  @handoff = handoff || Organisms::HandoffOrchestrator.new(task_loader: @task_manager)
@@ -45,6 +46,12 @@ module Ace
45
46
  def process_event(event, dry_run:, progress:)
46
47
  verdict = Atoms::ReportInterpretation.evaluate(event: event, managers: private_managers)
47
48
 
49
+ if verdict[:status] == :success || (verdict[:status] == :duplicate && verdict[:recorded_result] == "success")
50
+ refusal = @completion_check.call(event: event,
51
+ manager: @task_manager.show(verdict[:manager_ref]), step: @task_manager.show(verdict[:step_ref]))
52
+ verdict = {status: :unmatched, reason: refusal} if refusal
53
+ end
54
+
48
55
  case verdict[:status]
49
56
  when :unmatched
50
57
  progress.call("Event #{event['id']}: #{verdict[:reason]}")
@@ -135,6 +142,11 @@ module Ace
135
142
  return {action: "none", reason: reason}
136
143
  end
137
144
 
145
+ if next_step.metadata["role"] == "confirm-start"
146
+ return {action: "coordinator", step_ref: next_step.id.to_s,
147
+ reason: "confirm project first turn and active eyes through the standard start operation; keep this step and manager open until proven"}
148
+ end
149
+
138
150
  begin
139
151
  if family_worktree(manager)
140
152
  result = @handoff.call(task_ref: manager.metadata["project_task"].to_s)
@@ -217,7 +229,7 @@ module Ace
217
229
  case continuation[:action]
218
230
  when "handoff"
219
231
  if continuation[:status] == "sequence_complete"
220
- "sequence complete - the worktree has no next role; close the managing task per the queue workflow"
232
+ "sequence complete - the worktree has no next role; pending coordination steps still require acceptance before closing the managing task"
221
233
  elsif continuation[:start_command]
222
234
  "window ensured in #{continuation[:worktree_path]} for the next role " \
223
235
  "(#{continuation.dig(:next, 'role')}); run there: #{continuation[:start_command]}"
@@ -231,6 +243,7 @@ module Ace
231
243
  else
232
244
  "next step #{continuation[:step_ref]} started via work-on"
233
245
  end
246
+ when "coordinator" then "coordinator action for #{continuation[:step_ref]}: #{continuation[:reason]}"
234
247
  when "blocked" then "continuation blocked: #{continuation[:reason]}"
235
248
  when "none" then continuation[:reason]
236
249
  end