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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/docs/usage.md +53 -13
- data/handbook/workflow-instructions/overseer/bootstrap-delegation.wf.md +119 -0
- data/handbook/workflow-instructions/overseer/lab-start.wf.md +49 -9
- data/handbook/workflow-instructions/overseer/queue.wf.md +20 -5
- data/lib/ace/overseer/atoms/report_interpretation.rb +28 -14
- data/lib/ace/overseer/atoms/start_contract.rb +25 -12
- data/lib/ace/overseer/cli/commands/start.rb +33 -16
- data/lib/ace/overseer/molecules/event_observation.rb +21 -2
- data/lib/ace/overseer/molecules/hitl_inbox.rb +1 -0
- data/lib/ace/overseer/molecules/project_bundle.rb +163 -0
- data/lib/ace/overseer/molecules/report_completion_check.rb +52 -0
- data/lib/ace/overseer/organisms/report_processor.rb +15 -2
- data/lib/ace/overseer/organisms/start_orchestrator.rb +168 -86
- data/lib/ace/overseer/version.rb +1 -1
- data/lib/ace/overseer.rb +1 -0
- metadata +9 -12
|
@@ -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
|
-
|
|
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[
|
|
60
|
-
puts " scope: #{report[
|
|
61
|
-
puts " root: #{report[
|
|
62
|
-
puts " label: #{report[
|
|
63
|
-
puts " profile: #{report[
|
|
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[
|
|
82
|
+
puts " instructions: #{report["instructions"]["source"]} -> #{report["instructions"]["path"]}"
|
|
66
83
|
end
|
|
67
|
-
puts " workspace: #{report[
|
|
68
|
-
puts " pane: #{report[
|
|
69
|
-
puts " agent: #{report[
|
|
70
|
-
puts " replaced pane: #{report[
|
|
71
|
-
puts " #{report[
|
|
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
|
-
|
|
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
|
|
@@ -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;
|
|
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
|