ace-overseer 0.28.0 → 0.29.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 +26 -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 +258 -40
- data/handbook/workflow-instructions/overseer/lab-start.wf.md +60 -24
- data/handbook/workflow-instructions/overseer/queue.wf.md +33 -13
- 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/report_interpretation.rb +28 -14
- 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 +37 -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 +14 -5
- data/lib/ace/overseer/molecules/local_sources.rb +83 -19
- data/lib/ace/overseer/molecules/project_bundle.rb +50 -6
- 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/report_completion_check.rb +52 -0
- data/lib/ace/overseer/molecules/session_environment.rb +126 -0
- data/lib/ace/overseer/organisms/handoff_orchestrator.rb +26 -15
- data/lib/ace/overseer/organisms/report_processor.rb +56 -11
- data/lib/ace/overseer/organisms/start_orchestrator.rb +107 -52
- data/lib/ace/overseer/organisms/work_on_orchestrator.rb +43 -1
- data/lib/ace/overseer/version.rb +1 -1
- data/lib/ace/overseer.rb +3 -1
- metadata +65 -2
|
@@ -50,6 +50,10 @@ module Ace
|
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
next_leg = result[:next]
|
|
53
|
+
if result[:execution]
|
|
54
|
+
puts "Executor #{result[:execution]['agent']} started for #{next_leg['assignment']}. End the turn; eyes observes its report."
|
|
55
|
+
return
|
|
56
|
+
end
|
|
53
57
|
unless next_leg && next_leg["start_config"]
|
|
54
58
|
raise Ace::Support::Cli::Error,
|
|
55
59
|
"handoff result is missing the next leg start config - refusing to print a guessed command"
|
|
@@ -9,6 +9,8 @@ module Ace
|
|
|
9
9
|
|
|
10
10
|
desc "Process incoming ace-hitl reports and continue the overseer queue"
|
|
11
11
|
|
|
12
|
+
option :scope, type: :string, desc: "HITL scope: current or all (default: owner checkout scope)"
|
|
13
|
+
option :owner, type: :string, default: "overseer", desc: "Configured HITL recipient, matching the eyes observer"
|
|
12
14
|
option :dry_run, type: :boolean, default: false, desc: "Interpret reports without writes or continuation"
|
|
13
15
|
option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
|
|
14
16
|
option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
|
|
@@ -18,10 +20,10 @@ module Ace
|
|
|
18
20
|
@processor = processor
|
|
19
21
|
end
|
|
20
22
|
|
|
21
|
-
def call(dry_run: false, **options)
|
|
23
|
+
def call(dry_run: false, owner: "overseer", scope: nil, **options)
|
|
22
24
|
Atoms::RepoGuard.ensure_repo!
|
|
23
25
|
|
|
24
|
-
processor = @processor || Organisms::ReportProcessor.new
|
|
26
|
+
processor = @processor || Organisms::ReportProcessor.new(owner: owner, scope: scope)
|
|
25
27
|
result = processor.call(
|
|
26
28
|
dry_run: dry_run,
|
|
27
29
|
on_progress: options[:quiet] ? nil : ->(msg) { puts msg }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "shellwords"
|
|
4
5
|
|
|
5
6
|
module Ace
|
|
6
7
|
module Overseer
|
|
@@ -9,8 +10,8 @@ module Ace
|
|
|
9
10
|
# `ace-overseer start [--dry-run] [--format table|json]`
|
|
10
11
|
# (task 8xs.t.pu0.1): starts or reuses the project overseer
|
|
11
12
|
# session from the directory's explicit start configuration.
|
|
12
|
-
#
|
|
13
|
-
#
|
|
13
|
+
# Ready starts and dry runs exit 0; blocked or partial starts
|
|
14
|
+
# are CLI errors. A dry run creates no session.
|
|
14
15
|
class Start < Ace::Support::Cli::Command
|
|
15
16
|
include Ace::Support::Cli::Base
|
|
16
17
|
|
|
@@ -27,7 +28,7 @@ module Ace
|
|
|
27
28
|
super()
|
|
28
29
|
@orchestrator = orchestrator
|
|
29
30
|
@config = config
|
|
30
|
-
@launcher = launcher
|
|
31
|
+
@launcher = launcher
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
def call(dry_run: false, format: "table", scope: nil, reconcile: false, **options)
|
|
@@ -45,7 +46,7 @@ module Ace
|
|
|
45
46
|
else
|
|
46
47
|
operation.bootstrap(dry_run: dry_run)
|
|
47
48
|
end
|
|
48
|
-
return
|
|
49
|
+
return launch_foreground(report) if report["status"] == "foreground"
|
|
49
50
|
|
|
50
51
|
if options[:quiet]
|
|
51
52
|
# quiet still fails loudly on a blocked start
|
|
@@ -55,11 +56,16 @@ module Ace
|
|
|
55
56
|
render_table(report)
|
|
56
57
|
end
|
|
57
58
|
|
|
59
|
+
if report["prompt_failed"] == true
|
|
60
|
+
raise Ace::Support::Cli::Error, report["message"] || "first turn or readiness is unconfirmed"
|
|
61
|
+
end
|
|
58
62
|
case report["status"]
|
|
59
63
|
when "started", "reused", "ready", "dry_run" then nil
|
|
60
64
|
else
|
|
61
65
|
raise Ace::Support::Cli::Error, report["message"] || "start blocked"
|
|
62
66
|
end
|
|
67
|
+
rescue Interrupt
|
|
68
|
+
raise Ace::Support::Cli::Error.new("start interrupted; inspect existing sessions before retrying", exit_code: 130)
|
|
63
69
|
rescue Ace::Support::Cli::Error
|
|
64
70
|
raise
|
|
65
71
|
rescue => e
|
|
@@ -68,6 +74,25 @@ module Ace
|
|
|
68
74
|
|
|
69
75
|
private
|
|
70
76
|
|
|
77
|
+
def launch_foreground(report)
|
|
78
|
+
return @launcher.call(report.fetch("command")) if @launcher
|
|
79
|
+
|
|
80
|
+
role = report["scope"] == "lab" ? "lab-overseer" : "project-overseer"
|
|
81
|
+
directory = nil
|
|
82
|
+
Molecules::SessionEnvironment.new.with_environment(root: report.fetch("root"), role: role) do |path, credentials, _args|
|
|
83
|
+
directory = credentials
|
|
84
|
+
environment = JSON.parse(File.read(path))
|
|
85
|
+
system(environment, *report.fetch("command"), unsetenv_others: true)
|
|
86
|
+
status = $?
|
|
87
|
+
unless status&.success?
|
|
88
|
+
raise Ace::Support::Cli::Error.new("coordinator process exited unsuccessfully",
|
|
89
|
+
exit_code: status&.exitstatus || 1)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
ensure
|
|
93
|
+
Molecules::SessionEnvironment.cleanup(directory) if directory
|
|
94
|
+
end
|
|
95
|
+
|
|
71
96
|
def orchestrator(scope: nil)
|
|
72
97
|
@orchestrator || Organisms::StartOrchestrator.new(config: @config || Ace::Overseer.config, scope: scope)
|
|
73
98
|
end
|
|
@@ -83,9 +108,16 @@ module Ace
|
|
|
83
108
|
end
|
|
84
109
|
puts " workspace: #{report["workspace"] || "-"}"
|
|
85
110
|
puts " pane: #{report["pane"] || "-"}"
|
|
86
|
-
puts " agent: #{report["agent"]}"
|
|
111
|
+
puts " agent: #{report["agent"] || "-"}"
|
|
112
|
+
puts " command: #{Shellwords.join(report["command"])}" if report["command"]
|
|
87
113
|
puts " replaced pane: #{report["replaced_pane"] || "-"}" if report["replaced_pane"]
|
|
88
114
|
puts " #{report["message"]}" if report["message"]
|
|
115
|
+
Array(report["projects"]).each do |project|
|
|
116
|
+
status = project["status"] || "unknown"
|
|
117
|
+
status += " (first turn unconfirmed)" if project["prompt_failed"]
|
|
118
|
+
puts " project #{project["id"]}: #{status} (#{project["path"] || "-"}, pane: #{project["pane"] || "-"})"
|
|
119
|
+
puts " #{project["message"]}" if project["message"]
|
|
120
|
+
end
|
|
89
121
|
end
|
|
90
122
|
end
|
|
91
123
|
end
|
|
@@ -37,7 +37,7 @@ module Ace
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
progress = options[:quiet] ? nil : ->(msg) { puts msg }
|
|
40
|
-
@orchestrator.call(
|
|
40
|
+
result = @orchestrator.call(
|
|
41
41
|
task_ref: task_refs.first,
|
|
42
42
|
task_refs: task_refs,
|
|
43
43
|
cli_preset: preset,
|
|
@@ -46,7 +46,11 @@ module Ace
|
|
|
46
46
|
|
|
47
47
|
return if options[:quiet]
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
if result[:execution]
|
|
50
|
+
puts "Executor #{result[:execution]['agent']} started for #{result[:assignment_id]}. End the turn; eyes observes its report."
|
|
51
|
+
else
|
|
52
|
+
puts "Done. Switch to the task window and run /ace-assign-drive"
|
|
53
|
+
end
|
|
50
54
|
rescue Ace::Support::Cli::Error
|
|
51
55
|
raise
|
|
52
56
|
rescue => e
|
data/lib/ace/overseer/cli.rb
CHANGED
|
@@ -20,6 +20,7 @@ module Ace
|
|
|
20
20
|
PROGRAM_NAME = "ace-overseer"
|
|
21
21
|
|
|
22
22
|
REGISTERED_COMMANDS = [
|
|
23
|
+
["start", "Start the lab or project overseer"],
|
|
23
24
|
["work-on", "Start local task work"],
|
|
24
25
|
["handoff", "Hand the task worktree to the next role"],
|
|
25
26
|
["process-reports", "Process ace-hitl reports and continue the queue"],
|
|
@@ -31,6 +32,7 @@ module Ace
|
|
|
31
32
|
].freeze
|
|
32
33
|
|
|
33
34
|
HELP_EXAMPLES = [
|
|
35
|
+
"ace-overseer start # Start in the current directory",
|
|
34
36
|
"ace-overseer work-on 148 # Launch task in worktree",
|
|
35
37
|
"ace-overseer handoff --task 148 # Hand the worktree to the next role",
|
|
36
38
|
"ace-overseer process-reports # Interpret reports, continue the queue",
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/coworker/molecules/execution_binding"
|
|
4
|
+
require "ace/herdr"
|
|
5
|
+
require "ace/herdr/atoms/native_agent_session"
|
|
6
|
+
require_relative "session_environment"
|
|
7
|
+
require_relative "eyes_readiness"
|
|
8
|
+
|
|
9
|
+
module Ace::Overseer::Molecules
|
|
10
|
+
# Start and retire a real executor using the existing commission document.
|
|
11
|
+
# A recorded uncertain start is a blocker, never permission to duplicate it.
|
|
12
|
+
class CoworkerLauncher
|
|
13
|
+
def initialize(executor: Ace::Herdr::Molecules::HerdrExecutor.new, env: ENV,
|
|
14
|
+
environment: nil, bindings: Ace::Coworker::Molecules::ExecutionBinding.new,
|
|
15
|
+
brief_factory: nil, readiness: nil, eyes: nil, root_resolver: nil)
|
|
16
|
+
@executor, @env, @bindings = executor, env, bindings
|
|
17
|
+
@environment = environment || SessionEnvironment.new(env: env)
|
|
18
|
+
@brief_factory = brief_factory || ->(root, path) { Ace::Coworker::Organisms::StartOrchestrator.new(project_root: root).call(config_path: path) }
|
|
19
|
+
@readiness = readiness || method(:check_eyes!)
|
|
20
|
+
@eyes = eyes || EyesReadiness.new
|
|
21
|
+
@root_resolver = root_resolver || ProjectRootResolver.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def ready!
|
|
25
|
+
@readiness.call
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def recipient
|
|
29
|
+
context = coordinator_context
|
|
30
|
+
path = File.join(context.fetch(:root), ".ace-local/overseer/eyes.json")
|
|
31
|
+
data = JSON.parse(File.read(path))
|
|
32
|
+
value = data.is_a?(Hash) && data["recipient"]
|
|
33
|
+
unless value.is_a?(String) && !value.strip.empty? && !value.match?(/[[:space:][:cntrl:]]/)
|
|
34
|
+
raise Ace::Overseer::Error, "coordinator eyes recipient is unavailable"
|
|
35
|
+
end
|
|
36
|
+
value
|
|
37
|
+
rescue JSON::ParserError, SystemCallError
|
|
38
|
+
raise Ace::Overseer::Error, "coordinator eyes recipient is unavailable"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call(worktree_path:, config_path:)
|
|
42
|
+
ready!
|
|
43
|
+
path = File.expand_path(config_path, worktree_path)
|
|
44
|
+
brief = @brief_factory.call(worktree_path, path)
|
|
45
|
+
@bindings.with_lock(path) do |document, save|
|
|
46
|
+
unless document["assignment"] == brief.config.assignment && document["role"] == brief.config.role &&
|
|
47
|
+
document["task"] == brief.config.task
|
|
48
|
+
raise Ace::Overseer::Error, "executor commission changed during validation"
|
|
49
|
+
end
|
|
50
|
+
if document["execution"]
|
|
51
|
+
next reuse(document.fetch("execution"))
|
|
52
|
+
end
|
|
53
|
+
unless %w[paused empty].include?(brief.assignment_snapshot&.state.to_s)
|
|
54
|
+
raise Ace::Overseer::Error, "unbound assignment is not ready for a new executor"
|
|
55
|
+
end
|
|
56
|
+
Dir.glob(File.join(File.dirname(path), "*.yml")).each do |other_path|
|
|
57
|
+
next if other_path == path
|
|
58
|
+
other = YAML.safe_load_file(other_path, permitted_classes: [Date, Time], aliases: false)
|
|
59
|
+
if other.is_a?(Hash) && other.key?("execution") &&
|
|
60
|
+
(!other["execution"].is_a?(Hash) || other.dig("execution", "state") != "retired")
|
|
61
|
+
raise Ace::Overseer::Error, "another executor has not been retired from this worktree"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
name = "coworker-#{brief.config.assignment}"
|
|
65
|
+
execution = {"agent" => name, "assignment" => brief.config.assignment,
|
|
66
|
+
"role" => brief.config.role, "state" => "launching"}
|
|
67
|
+
@environment.with_environment(root: worktree_path, role: brief.config.role) do |file, directory, profile_args|
|
|
68
|
+
document["execution"] = execution
|
|
69
|
+
execution["credentials_directory"] = directory if directory
|
|
70
|
+
save.call
|
|
71
|
+
dispatcher = Ace::Herdr::Organisms::Dispatcher.from_config(executor: @executor)
|
|
72
|
+
result = dispatcher.dispatch(label: name, prompt: "", kind: "pi", cwd: worktree_path,
|
|
73
|
+
env_file: file, args: Array(profile_args) + ["--append-system-prompt", brief.instructions_source])
|
|
74
|
+
execution.merge!(result.to_h).merge!("state" => "started")
|
|
75
|
+
pane = pane_identity(execution.fetch("pane"))
|
|
76
|
+
execution["terminal_id"] = pane.fetch("terminal_id")
|
|
77
|
+
execution["agent_session"] = session_identity(pane)
|
|
78
|
+
save.call
|
|
79
|
+
end
|
|
80
|
+
# Record uncertainty BEFORE submission. A crash after input reaches Pi
|
|
81
|
+
# cannot cause the same task to be submitted again automatically.
|
|
82
|
+
execution["state"] = "prompting"
|
|
83
|
+
save.call
|
|
84
|
+
prompt = "#{brief.config.start_prompt}\n\n#{Ace::Coworker::Atoms::BriefFormatter.format_table(brief)}"
|
|
85
|
+
@executor.agent_prompt(pane: execution.fetch("pane"), text: prompt)
|
|
86
|
+
execution["state"] = "running"
|
|
87
|
+
save.call
|
|
88
|
+
execution.reject { |key, _| key == "credentials_directory" }.merge("reused" => false)
|
|
89
|
+
end
|
|
90
|
+
rescue Ace::Coworker::Error, Ace::Herdr::ExecutorError => e
|
|
91
|
+
raise Ace::Overseer::Error, "executor launch blocked: #{e.message}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def retire(worktree_path:, assignment:)
|
|
95
|
+
paths = Dir.glob(File.join(worktree_path, Ace::Coworker::Organisms::StartConfigWriter::PREPARE_DIR, "*.yml"))
|
|
96
|
+
paths.each do |path|
|
|
97
|
+
@bindings.with_lock(path) do |document, save|
|
|
98
|
+
next unless document["assignment"] == assignment
|
|
99
|
+
execution = document["execution"]
|
|
100
|
+
next unless execution
|
|
101
|
+
next if execution["state"] == "retired"
|
|
102
|
+
begin
|
|
103
|
+
pane = pane_identity(execution.fetch("pane"))
|
|
104
|
+
unless same_executor?(pane, execution)
|
|
105
|
+
raise Ace::Overseer::Error, "executor terminal changed; retirement refused"
|
|
106
|
+
end
|
|
107
|
+
execution["state"] = "retiring"
|
|
108
|
+
save.call
|
|
109
|
+
@executor.pane_close(execution.fetch("pane"))
|
|
110
|
+
rescue Ace::Herdr::PaneNotFoundError
|
|
111
|
+
# The native owner positively confirms that this panel is gone.
|
|
112
|
+
end
|
|
113
|
+
SessionEnvironment.cleanup(execution["credentials_directory"])
|
|
114
|
+
execution.delete("credentials_directory")
|
|
115
|
+
execution["state"] = "retired"
|
|
116
|
+
save.call
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def reuse(execution)
|
|
124
|
+
if execution["state"] == "retired"
|
|
125
|
+
raise Ace::Overseer::Error, "recorded executor is retired; reconcile the assignment before starting another"
|
|
126
|
+
end
|
|
127
|
+
unless execution["state"] == "running"
|
|
128
|
+
raise Ace::Overseer::Error, "executor start is unconfirmed; inspect the recorded assignment before retrying"
|
|
129
|
+
end
|
|
130
|
+
pane = pane_identity(execution.fetch("pane"))
|
|
131
|
+
unless same_executor?(pane, execution)
|
|
132
|
+
raise Ace::Overseer::Error, "recorded executor terminal no longer matches"
|
|
133
|
+
end
|
|
134
|
+
agent = @executor.agent_get(execution.fetch("pane")).parsed_json&.dig("result", "agent")
|
|
135
|
+
unless agent.is_a?(Hash) && agent["interactive_ready"] == true && same_executor?(agent, execution)
|
|
136
|
+
raise Ace::Overseer::Error, "recorded executor is not a live interactive agent"
|
|
137
|
+
end
|
|
138
|
+
execution.reject { |key, _| key == "credentials_directory" }.merge("reused" => true)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def pane_identity(pane)
|
|
142
|
+
reply = @executor.pane_get(pane).parsed_json
|
|
143
|
+
row = reply&.dig("result", "pane")
|
|
144
|
+
unless row.is_a?(Hash) && row["pane_id"] == pane && row["terminal_id"]
|
|
145
|
+
raise Ace::Overseer::Error, "native executor identity is unavailable"
|
|
146
|
+
end
|
|
147
|
+
row
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def session_identity(pane)
|
|
151
|
+
identity = Ace::Herdr::Atoms::NativeAgentSession.normalize(pane["agent_session"], agent: "pi")
|
|
152
|
+
raise Ace::Overseer::Error, "native executor session identity is unavailable" unless identity
|
|
153
|
+
identity
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def same_executor?(pane, execution)
|
|
157
|
+
pane["terminal_id"] == execution["terminal_id"] && session_identity(pane) == execution["agent_session"]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def check_eyes!
|
|
161
|
+
context = coordinator_context
|
|
162
|
+
sources = Ace::Overseer::Atoms::ObservationSources::REQUIRED.dup
|
|
163
|
+
sources << Ace::Overseer::Atoms::ObservationSources::PROJECT_SESSIONS if context[:scope] == "lab"
|
|
164
|
+
unless @eyes.ready?(pane: context[:pane], root: context[:root],
|
|
165
|
+
prompt: Ace::Overseer::Atoms::StartContract::FIRST_TURN_PROMPT, required_sources: sources)
|
|
166
|
+
raise Ace::Overseer::Error, "delegation requires the coordinator's active eyes"
|
|
167
|
+
end
|
|
168
|
+
true
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def coordinator_context
|
|
172
|
+
pane = @env["HERDR_PANE_ID"] || @env["HERDR_PANE"]
|
|
173
|
+
raise Ace::Overseer::Error, "coordinator native pane is unavailable" if pane.to_s.empty?
|
|
174
|
+
row = pane_identity(pane)
|
|
175
|
+
cwd = row["cwd"]
|
|
176
|
+
unless cwd.is_a?(String) && cwd.start_with?("/")
|
|
177
|
+
raise Ace::Overseer::Error, "coordinator working directory is unavailable"
|
|
178
|
+
end
|
|
179
|
+
root = @root_resolver.resolve(cwd: cwd)
|
|
180
|
+
config_path = File.join(root, ".ace/overseer/config.yml")
|
|
181
|
+
config = File.file?(config_path) ? YAML.safe_load_file(config_path, permitted_classes: [Date], aliases: true) : {}
|
|
182
|
+
scope = config.is_a?(Hash) ? config.dig("start", "scope") || "lab" : nil
|
|
183
|
+
raise Ace::Overseer::Error, "coordinator scope is invalid" unless %w[lab project].include?(scope)
|
|
184
|
+
{root: root, pane: pane, scope: scope}
|
|
185
|
+
rescue Psych::Exception, SystemCallError, TypeError
|
|
186
|
+
raise Ace::Overseer::Error, "coordinator configuration is unavailable"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require "ace/hitl/molecules/event_observation"
|
|
3
3
|
require "digest"
|
|
4
|
+
require_relative "project_observations"
|
|
5
|
+
require_relative "../atoms/observation_sources"
|
|
4
6
|
|
|
5
7
|
module Ace
|
|
6
8
|
module Overseer
|
|
@@ -8,20 +10,22 @@ module Ace
|
|
|
8
10
|
# Read-only standing observation (task 8xs.t.pu0.3). HITL owns
|
|
9
11
|
# semantic revisions; the existing private task tree owns report
|
|
10
12
|
# adoption and continuation, never this view. The projection arms
|
|
11
|
-
# the started overseer with
|
|
13
|
+
# the started overseer with local source classes — HITL
|
|
12
14
|
# events plus LocalSources (project tasks, _overseer tasks,
|
|
13
|
-
# worktrees, coworker availability, assignment progress
|
|
15
|
+
# worktrees, coworker availability, assignment progress and project
|
|
16
|
+
# sessions) — every
|
|
14
17
|
# row read from its state owner, with per-source health so a
|
|
15
18
|
# failing source degrades only itself and healthy silence projects
|
|
16
19
|
# identical revisions (no model turns).
|
|
17
20
|
class EventObservation
|
|
18
21
|
def initialize(hitl: nil, task_manager: nil, local_sources: nil, remote_sources: nil,
|
|
19
|
-
config: nil)
|
|
22
|
+
config: nil, project_observations: ProjectObservations.new)
|
|
20
23
|
@hitl = hitl || Ace::Hitl::Molecules::EventObservation.new
|
|
21
24
|
@tasks = task_manager || Ace::Task::Organisms::TaskManager.new
|
|
22
25
|
@local_sources = local_sources || LocalSources.new(task_manager: @tasks)
|
|
23
26
|
@remote_sources = remote_sources || RemoteSources.new
|
|
24
27
|
@config = config || Ace::Overseer.config
|
|
28
|
+
@project_observations = project_observations
|
|
25
29
|
end
|
|
26
30
|
|
|
27
31
|
def call(owner:, scope: "current", limit: 500)
|
|
@@ -35,22 +39,39 @@ module Ace
|
|
|
35
39
|
|
|
36
40
|
local = @local_sources.call
|
|
37
41
|
remote = remote_projection
|
|
38
|
-
|
|
42
|
+
projects = @project_observations.call(owner: owner, scope: scope, limit: limit)
|
|
43
|
+
managers = begin
|
|
44
|
+
@tasks.list(in_folder: "all").select { |task| !task.metadata["project_task"].to_s.empty? }
|
|
45
|
+
rescue StandardError => error
|
|
46
|
+
# Report adoption depends on the private owner's state. An unreadable
|
|
47
|
+
# tree must leave reports pending and degrade that source, not abort
|
|
48
|
+
# healthy observations or claim that a report was already accepted.
|
|
49
|
+
name = Atoms::ObservationSources::OVERSEER_TASKS
|
|
50
|
+
local = local.merge(
|
|
51
|
+
"events" => local.fetch("events").reject { |row| row["key"] == "source:#{name}" } +
|
|
52
|
+
[health_marker(name, error)],
|
|
53
|
+
"sources" => local.fetch("sources").merge(name => {
|
|
54
|
+
"status" => "degraded", "error" => "#{error.class}: #{error.message}"
|
|
55
|
+
})
|
|
56
|
+
)
|
|
57
|
+
[]
|
|
58
|
+
end
|
|
39
59
|
|
|
40
60
|
if projection.nil?
|
|
41
61
|
# A degraded HITL source degrades only itself: the local
|
|
42
62
|
# sources keep projecting and the failure is visible data.
|
|
43
|
-
events = local.fetch("events") + remote.fetch("events") + [health_marker("hitl", hitl_error)]
|
|
44
63
|
sources = local.fetch("sources").merge(remote.fetch("sources")).merge(
|
|
45
|
-
|
|
64
|
+
Atoms::ObservationSources::HITL => {"status" => "degraded", "error" => "#{hitl_error.class}: #{hitl_error.message}"}
|
|
46
65
|
)
|
|
47
|
-
|
|
66
|
+
events = local.fetch("events") + remote.fetch("events") + healthy_markers(sources) +
|
|
67
|
+
[health_marker(Atoms::ObservationSources::HITL, hitl_error)]
|
|
68
|
+
ensure_budget!(events.length + projects.fetch("events").length, limit)
|
|
48
69
|
return {"schema" => "ace.hitl.observation/v1",
|
|
49
|
-
"events" => wire_events(events).sort_by { |row| row["key"] },
|
|
50
|
-
"settled" => [], "sources" => sources}
|
|
70
|
+
"events" => (wire_events(events) + projects.fetch("events")).sort_by { |row| row["key"] },
|
|
71
|
+
"settled" => [], "sources" => sources.merge(projects.fetch("sources"))}
|
|
51
72
|
end
|
|
52
73
|
|
|
53
|
-
settled, pending = projection.fetch("events").partition do |event|
|
|
74
|
+
settled, pending = (projection.fetch("events") + projects.fetch("events")).partition do |event|
|
|
54
75
|
next false unless event["type"] == "report"
|
|
55
76
|
verdict = Atoms::ReportInterpretation.evaluate(event: event, managers: managers)
|
|
56
77
|
next false unless verdict[:status] == :duplicate
|
|
@@ -61,12 +82,14 @@ module Ace
|
|
|
61
82
|
manager && ([manager] + Array(manager.subtasks)).all? { |task| Ace::Task::Atoms::TaskValidationRules.terminal_status?(task.status.to_s) }
|
|
62
83
|
end
|
|
63
84
|
|
|
64
|
-
|
|
65
|
-
|
|
85
|
+
sources = local.fetch("sources").merge(remote.fetch("sources")).merge(
|
|
86
|
+
Atoms::ObservationSources::HITL => {"status" => "ok"})
|
|
87
|
+
events = local.fetch("events") + remote.fetch("events") + healthy_markers(sources)
|
|
88
|
+
ensure_budget!(pending.length + settled.length + events.length, limit)
|
|
89
|
+
projection.merge("events" => (pending + wire_events(events))
|
|
66
90
|
.sort_by { |row| row["key"] },
|
|
67
91
|
"settled" => settled,
|
|
68
|
-
"sources" =>
|
|
69
|
-
.merge("hitl" => {"status" => "ok"}))
|
|
92
|
+
"sources" => sources.merge(projects.fetch("sources")))
|
|
70
93
|
end
|
|
71
94
|
|
|
72
95
|
private
|
|
@@ -89,14 +112,15 @@ module Ace
|
|
|
89
112
|
end
|
|
90
113
|
|
|
91
114
|
# Configured remote observation (8xs.t.pu0.4): the project's own
|
|
92
|
-
# observe.remote
|
|
93
|
-
# accounts. No configuration means no remote rows and
|
|
94
|
-
# source to degrade.
|
|
115
|
+
# observe.remote pull request and issue lists — never a scan of
|
|
116
|
+
# accessible accounts. No configuration means no remote rows and
|
|
117
|
+
# no remote source to degrade.
|
|
95
118
|
def remote_projection
|
|
96
119
|
entries = Array(@config.dig("observe", "remote", "pull_requests"))
|
|
97
|
-
|
|
120
|
+
issues = Array(@config.dig("observe", "remote", "issues"))
|
|
121
|
+
return {"events" => [], "sources" => {}} if entries.empty? && issues.empty?
|
|
98
122
|
|
|
99
|
-
@remote_sources.call(pull_requests: entries)
|
|
123
|
+
@remote_sources.call(pull_requests: entries, issues: issues)
|
|
100
124
|
end
|
|
101
125
|
|
|
102
126
|
def health_marker(name, error)
|
|
@@ -105,6 +129,17 @@ module Ace
|
|
|
105
129
|
"type" => "source-health", "status" => "degraded",
|
|
106
130
|
"error" => "#{error.class}: #{error.message}"}
|
|
107
131
|
end
|
|
132
|
+
|
|
133
|
+
# The healthy row shares the degraded row's key. An acknowledged
|
|
134
|
+
# failure can then wake the owner when only source health recovers.
|
|
135
|
+
def healthy_markers(sources)
|
|
136
|
+
sources.filter_map do |name, source|
|
|
137
|
+
next unless source["status"] == "ok"
|
|
138
|
+
|
|
139
|
+
{"key" => "source:#{name}", "revision" => Digest::SHA256.hexdigest("ok"),
|
|
140
|
+
"type" => "source-health", "status" => "ok"}
|
|
141
|
+
end
|
|
142
|
+
end
|
|
108
143
|
end
|
|
109
144
|
end
|
|
110
145
|
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Overseer
|
|
7
|
+
module Molecules
|
|
8
|
+
# Evaluate the current Pi session branch; this owns no additional
|
|
9
|
+
# persisted state and must be paired with a live Herdr/process binding.
|
|
10
|
+
class EyesEvidence
|
|
11
|
+
ENTRY = "ace-wake-eyes"
|
|
12
|
+
BINDING = "ace-wake-eyes-binding"
|
|
13
|
+
BIRTH = /\A(?:linux:[0-9a-f-]{36}:[1-9][0-9]*|darwin:[1-9][0-9]*:[0-9]{1,6})\z/
|
|
14
|
+
|
|
15
|
+
def self.ready?(branch:, root:, process:, prompt:, required_sources:, now_ms:)
|
|
16
|
+
return false unless branch.is_a?(Array) && root.is_a?(String) && process.is_a?(Hash) &&
|
|
17
|
+
prompt.is_a?(String) && !prompt.empty? && required_sources.is_a?(Array) &&
|
|
18
|
+
!required_sources.empty? && required_sources.all? { |name| name.is_a?(String) && !name.empty? } &&
|
|
19
|
+
process["pid"].is_a?(Integer) && process["pid"].positive? && process["started_at"].is_a?(String) &&
|
|
20
|
+
process["started_at"].match?(BIRTH) && now_ms.is_a?(Integer)
|
|
21
|
+
eye_index = branch.rindex { |entry| entry.is_a?(Hash) && entry["type"] == "custom" && entry["customType"] == ENTRY }
|
|
22
|
+
return false unless eye_index
|
|
23
|
+
|
|
24
|
+
identity = {"pid" => process["pid"], "startedAt" => process["started_at"]}
|
|
25
|
+
|
|
26
|
+
# A user prompt in the current branch followed by an assistant
|
|
27
|
+
# completed assistant turn before the eyes entry proves more than
|
|
28
|
+
# an unrelated Herdr activity transition. Reject an intervening
|
|
29
|
+
# user turn or an assistant error during initialization.
|
|
30
|
+
prefix = branch[0...eye_index]
|
|
31
|
+
prompt_index = prefix.rindex do |entry|
|
|
32
|
+
message = entry.is_a?(Hash) && entry["type"] == "message" ? entry["message"] : nil
|
|
33
|
+
next false unless message.is_a?(Hash) && message["role"] == "user"
|
|
34
|
+
content = message["content"]
|
|
35
|
+
content == prompt || content.is_a?(Array) && content.any? { |part| part.is_a?(Hash) && part["type"] == "text" && part["text"] == prompt }
|
|
36
|
+
end
|
|
37
|
+
return false unless prompt_index
|
|
38
|
+
binding_index = prefix.rindex do |entry|
|
|
39
|
+
entry.is_a?(Hash) && entry["type"] == "custom" && entry["customType"] == BINDING &&
|
|
40
|
+
entry["data"].is_a?(Hash) && entry["data"]["ownerCwd"] == root && entry["data"]["process"] == identity
|
|
41
|
+
end
|
|
42
|
+
return false unless binding_index
|
|
43
|
+
prompt_at = (Time.iso8601(prefix[prompt_index].fetch("timestamp")).to_r * 1000).to_i
|
|
44
|
+
return false unless snapshot_healthy?(branch[eye_index], root: root, identity: identity,
|
|
45
|
+
required_sources: required_sources, now_ms: now_ms, prompt_at: prompt_at, fresh: true)
|
|
46
|
+
|
|
47
|
+
complete = false
|
|
48
|
+
known_bindings = {}
|
|
49
|
+
branch[0..prompt_index].each do |entry|
|
|
50
|
+
data = entry.is_a?(Hash) && entry["type"] == "custom" && entry["customType"] == BINDING && entry["data"]
|
|
51
|
+
known_bindings[data["process"]] = true if data.is_a?(Hash) && data["ownerCwd"] == root && data["process"].is_a?(Hash)
|
|
52
|
+
end
|
|
53
|
+
branch[(prompt_index + 1)..eye_index].each do |entry|
|
|
54
|
+
data = entry.is_a?(Hash) && entry["type"] == "custom" && entry["customType"] == BINDING && entry["data"]
|
|
55
|
+
known_bindings[data["process"]] = true if data.is_a?(Hash) && data["ownerCwd"] == root && data["process"].is_a?(Hash)
|
|
56
|
+
prior_identity = entry.is_a?(Hash) && entry.dig("data", "observerEvidence", "process")
|
|
57
|
+
prior_binding = prior_identity.is_a?(Hash) && prior_identity["pid"].is_a?(Integer) &&
|
|
58
|
+
prior_identity["pid"].positive? && prior_identity["startedAt"].is_a?(String) &&
|
|
59
|
+
prior_identity["startedAt"].match?(BIRTH) && known_bindings[prior_identity]
|
|
60
|
+
if complete && prior_binding && snapshot_healthy?(entry, root: root, identity: prior_identity,
|
|
61
|
+
required_sources: required_sources, now_ms: now_ms, prompt_at: prompt_at, fresh: false)
|
|
62
|
+
# The current binding was found in the prefix before the last
|
|
63
|
+
# snapshot; initial proof may precede a process restart.
|
|
64
|
+
return true
|
|
65
|
+
end
|
|
66
|
+
message = entry.is_a?(Hash) && entry["type"] == "message" ? entry["message"] : nil
|
|
67
|
+
next unless message.is_a?(Hash)
|
|
68
|
+
return false if message["role"] == "user"
|
|
69
|
+
next unless message["role"] == "assistant"
|
|
70
|
+
if message["stopReason"] == "stop"
|
|
71
|
+
complete = true
|
|
72
|
+
next
|
|
73
|
+
end
|
|
74
|
+
return false unless message["stopReason"] == "toolUse"
|
|
75
|
+
end
|
|
76
|
+
false
|
|
77
|
+
rescue ArgumentError, KeyError, TypeError
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.snapshot_healthy?(entry, root:, identity:, required_sources:, now_ms:, prompt_at:, fresh:)
|
|
82
|
+
return false unless entry.is_a?(Hash) && entry["type"] == "custom" && entry["customType"] == ENTRY
|
|
83
|
+
eye = entry["data"]
|
|
84
|
+
return false unless eye.is_a?(Hash) && eye["ownerCwd"] == root && eye["enabled"] == true &&
|
|
85
|
+
eye["retired"] == false && eye["breaker"].nil? && eye["errors"] == 0
|
|
86
|
+
evidence = eye["observerEvidence"]
|
|
87
|
+
return false unless evidence.is_a?(Hash) && evidence["process"] == identity
|
|
88
|
+
observed, sources, config = evidence.values_at("observedAt", "sources") + [eye["config"]]
|
|
89
|
+
return false unless observed.is_a?(Integer) && observed >= prompt_at && observed <= now_ms && config.is_a?(Hash) &&
|
|
90
|
+
config["intervalSeconds"].is_a?(Integer) && config["intervalSeconds"].between?(1, 3600) &&
|
|
91
|
+
config["maxObservationsPerHour"].is_a?(Integer) && config["maxObservationsPerHour"].between?(1, 3600) &&
|
|
92
|
+
config["timeoutMs"].is_a?(Integer) && config["timeoutMs"].between?(100, 120_000)
|
|
93
|
+
interval = config["intervalSeconds"] * 1000
|
|
94
|
+
cadence = (3_600_000.0 / (config["maxObservationsPerHour"] * interval)).ceil * interval
|
|
95
|
+
return false if fresh && now_ms - observed > cadence + config["timeoutMs"]
|
|
96
|
+
sources.is_a?(Hash) && sources.size.between?(1, 64) &&
|
|
97
|
+
sources.keys.all? { |name| name.is_a?(String) && name.match?(/\A[a-zA-Z0-9_.:-]{1,128}\z/) } &&
|
|
98
|
+
required_sources.all? { |name| sources[name] == "ok" } && sources.values.all? { |status| status == "ok" }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "eyes_session_evidence"
|
|
3
|
+
require_relative "../atoms/start_contract"
|
|
4
|
+
require_relative "../atoms/observation_sources"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Overseer
|
|
8
|
+
module Molecules
|
|
9
|
+
# Read readiness from the actual native pane and its Pi session.
|
|
10
|
+
class EyesReadiness
|
|
11
|
+
def initialize(env: ENV, local: EyesSessionEvidence.new)
|
|
12
|
+
@local = local
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def ready?(pane:, root:, prompt:, required_sources:)
|
|
16
|
+
@local.ready?(pane: pane, root: root, prompt: prompt, required_sources: required_sources)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|