ace-overseer 0.28.1 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +2 -2
- data/docs/getting-started.md +1 -1
- data/docs/usage.md +47 -25
- data/handbook/workflow-instructions/overseer/bootstrap-delegation.wf.md +224 -37
- data/handbook/workflow-instructions/overseer/lab-start.wf.md +49 -21
- data/handbook/workflow-instructions/overseer/queue.wf.md +13 -8
- data/handbook/workflow-instructions/overseer/start.wf.md +198 -21
- data/lib/ace/overseer/atoms/observation_sources.rb +17 -0
- data/lib/ace/overseer/atoms/start_contract.rb +20 -0
- data/lib/ace/overseer/cli/commands/handoff.rb +4 -0
- data/lib/ace/overseer/cli/commands/process_reports.rb +4 -2
- data/lib/ace/overseer/cli/commands/start.rb +45 -5
- data/lib/ace/overseer/cli/commands/work_on.rb +6 -2
- data/lib/ace/overseer/cli.rb +2 -0
- data/lib/ace/overseer/molecules/coworker_launcher.rb +189 -0
- data/lib/ace/overseer/molecules/event_observation.rb +54 -19
- data/lib/ace/overseer/molecules/eyes_evidence.rb +103 -0
- data/lib/ace/overseer/molecules/eyes_readiness.rb +21 -0
- data/lib/ace/overseer/molecules/eyes_session_evidence.rb +132 -0
- data/lib/ace/overseer/molecules/hitl_inbox.rb +13 -5
- data/lib/ace/overseer/molecules/local_sources.rb +83 -19
- data/lib/ace/overseer/molecules/project_bundle.rb +1 -1
- data/lib/ace/overseer/molecules/project_observations.rb +82 -0
- data/lib/ace/overseer/molecules/project_sessions.rb +99 -0
- data/lib/ace/overseer/molecules/remote_sources.rb +46 -16
- data/lib/ace/overseer/molecules/session_environment.rb +127 -0
- data/lib/ace/overseer/organisms/handoff_orchestrator.rb +26 -15
- data/lib/ace/overseer/organisms/report_processor.rb +42 -10
- data/lib/ace/overseer/organisms/start_orchestrator.rb +105 -55
- data/lib/ace/overseer/organisms/work_on_orchestrator.rb +43 -1
- data/lib/ace/overseer/version.rb +1 -1
- data/lib/ace/overseer.rb +2 -1
- metadata +64 -2
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
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "ace/herdr"
|
|
5
|
+
require "ace/herdr/molecules/bounded_observation"
|
|
6
|
+
require "ace/runtime/molecules/process_identity"
|
|
7
|
+
require_relative "eyes_evidence"
|
|
8
|
+
|
|
9
|
+
module Ace
|
|
10
|
+
module Overseer
|
|
11
|
+
module Molecules
|
|
12
|
+
# Reconstruct Pi's current persisted branch and bind its eyes evidence
|
|
13
|
+
# to the live Herdr pane and process, without persisting another copy.
|
|
14
|
+
class EyesSessionEvidence
|
|
15
|
+
MAX_BYTES = 64 * 1024 * 1024
|
|
16
|
+
MAX_ENTRIES = 200_000
|
|
17
|
+
SESSION_PATH = %r{\A/[^\0]*_([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})\.jsonl\z}
|
|
18
|
+
|
|
19
|
+
# Preserve exact kernel birth identity on Linux and Darwin. A stale
|
|
20
|
+
# PID or zombie never proves observation readiness.
|
|
21
|
+
class NativeIdentity
|
|
22
|
+
def initialize(birth: Ace::Runtime::Molecules::ProcessIdentity.new)
|
|
23
|
+
@birth = birth
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def capture(pid)
|
|
27
|
+
return nil unless pid.is_a?(Integer) && pid.positive?
|
|
28
|
+
return @birth.capture(pid) if RUBY_PLATFORM.include?("darwin")
|
|
29
|
+
return nil unless RUBY_PLATFORM.include?("linux")
|
|
30
|
+
before = @birth.native_birth(pid)
|
|
31
|
+
return nil unless before
|
|
32
|
+
stat = File.read("/proc/#{pid}/stat")
|
|
33
|
+
match = stat.match(/\A#{pid} \(.*\) ([A-Za-z]) /m)
|
|
34
|
+
return nil unless match && !%w[Z X].include?(match[1])
|
|
35
|
+
uid = File.stat("/proc/#{pid}").uid
|
|
36
|
+
return nil unless @birth.native_birth(pid) == before
|
|
37
|
+
{"pid" => pid, "uid" => uid, "started_at" => before}
|
|
38
|
+
rescue SystemCallError, IOError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(executor: nil, identity: NativeIdentity.new,
|
|
44
|
+
clock: -> { (Time.now.to_f * 1000).to_i },
|
|
45
|
+
file_opener: ->(path) { File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) })
|
|
46
|
+
@executor, @identity, @clock, @file_opener = executor, identity, clock, file_opener
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def ready?(pane:, root:, prompt:, required_sources:, expected_terminal: nil)
|
|
50
|
+
executor = @executor || Ace::Herdr::Molecules::BoundedObservation.new(
|
|
51
|
+
deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5)
|
|
52
|
+
before = pane_binding(executor, pane)
|
|
53
|
+
return false unless before && (expected_terminal.nil? || before["terminal_id"] == expected_terminal)
|
|
54
|
+
path = before.fetch("path")
|
|
55
|
+
file = @file_opener.call(path)
|
|
56
|
+
stat = file.stat
|
|
57
|
+
return false unless stat.file? && stat.size.between?(1, MAX_BYTES)
|
|
58
|
+
content = file.read(MAX_BYTES + 1)
|
|
59
|
+
return false unless content && content.bytesize == stat.size && content.end_with?("\n")
|
|
60
|
+
again = file.stat
|
|
61
|
+
return false unless [stat.dev, stat.ino, stat.size, stat.mtime] == [again.dev, again.ino, again.size, again.mtime]
|
|
62
|
+
header, branch = branch_for(content)
|
|
63
|
+
return false unless header["cwd"] == root && header["id"].to_s.downcase == before.fetch("session_id").downcase
|
|
64
|
+
|
|
65
|
+
eye = branch.reverse.find { |entry| entry["type"] == "custom" && entry["customType"] == EyesEvidence::ENTRY }
|
|
66
|
+
pid = eye&.dig("data", "observerEvidence", "process", "pid")
|
|
67
|
+
return false unless pid.is_a?(Integer) && pid.positive? && foreground?(executor, pane, pid)
|
|
68
|
+
process = @identity.capture(pid)
|
|
69
|
+
return false unless process && process["uid"] == stat.uid
|
|
70
|
+
return false unless EyesEvidence.ready?(branch: branch, root: root, process: process,
|
|
71
|
+
prompt: prompt, required_sources: required_sources, now_ms: @clock.call)
|
|
72
|
+
# The owner or its session may change while the file is read.
|
|
73
|
+
return false unless @identity.capture(pid) == process && foreground?(executor, pane, pid) && pane_binding(executor, pane) == before
|
|
74
|
+
final = file.stat
|
|
75
|
+
named = File.lstat(path)
|
|
76
|
+
named.file? && !named.symlink? &&
|
|
77
|
+
[stat.dev, stat.ino, stat.size, stat.mtime] == [final.dev, final.ino, final.size, final.mtime] &&
|
|
78
|
+
[stat.dev, stat.ino] == [named.dev, named.ino]
|
|
79
|
+
rescue Ace::Herdr::ExecutorError, SystemCallError, IOError, JSON::ParserError, ArgumentError, TypeError, KeyError
|
|
80
|
+
false
|
|
81
|
+
ensure
|
|
82
|
+
file&.close
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def pane_binding(executor, pane)
|
|
88
|
+
json = executor.pane_get(pane).parsed_json
|
|
89
|
+
row = json.is_a?(Hash) && json["result"].is_a?(Hash) ? json["result"]["pane"] : nil
|
|
90
|
+
return nil unless row.is_a?(Hash) && row["pane_id"] == pane && row["agent"] == "pi" &&
|
|
91
|
+
(row["terminal_id"].is_a?(String) || row["terminal_id"].is_a?(Integer)) && !row["terminal_id"].to_s.empty?
|
|
92
|
+
session = row["agent_session"]
|
|
93
|
+
return nil unless session.is_a?(Hash) && session["agent"] == "pi" && session["kind"] == "path"
|
|
94
|
+
path = session["value"]
|
|
95
|
+
match = path.is_a?(String) && SESSION_PATH.match(path)
|
|
96
|
+
return nil unless match && path.bytesize <= 4096 && File.expand_path(path) == path
|
|
97
|
+
{"path" => path, "session_id" => match[1], "terminal_id" => row["terminal_id"].to_s}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def foreground?(executor, pane, pid)
|
|
101
|
+
json = executor.pane_process_info(pane).parsed_json
|
|
102
|
+
info = json.is_a?(Hash) && json["result"].is_a?(Hash) ? json["result"]["process_info"] : nil
|
|
103
|
+
list = info.is_a?(Hash) ? info["foreground_processes"] : nil
|
|
104
|
+
list.is_a?(Array) && list.any? { |row| row.is_a?(Hash) && row["pid"] == pid }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def branch_for(content)
|
|
108
|
+
lines = content.lines
|
|
109
|
+
raise ArgumentError, "session entry limit" unless lines.size.between?(2, MAX_ENTRIES + 1)
|
|
110
|
+
header = JSON.parse(lines.shift, create_additions: false, max_nesting: 64, allow_duplicate_key: false)
|
|
111
|
+
raise ArgumentError, "invalid Pi session" unless header.is_a?(Hash) && header["type"] == "session" && header["version"] == 3
|
|
112
|
+
entries = {}
|
|
113
|
+
last = nil
|
|
114
|
+
lines.each do |line|
|
|
115
|
+
entry = JSON.parse(line, create_additions: false, max_nesting: 64, allow_duplicate_key: false)
|
|
116
|
+
raise ArgumentError, "invalid session entry" unless entry.is_a?(Hash) && entry["id"].is_a?(String) &&
|
|
117
|
+
!entry["id"].empty? && !entries.key?(entry["id"]) &&
|
|
118
|
+
(entry["parentId"].nil? || entries.key?(entry["parentId"]))
|
|
119
|
+
entries[entry["id"]] = entry
|
|
120
|
+
last = entry
|
|
121
|
+
end
|
|
122
|
+
branch = []
|
|
123
|
+
while last
|
|
124
|
+
branch << last
|
|
125
|
+
last = last["parentId"] && entries.fetch(last["parentId"])
|
|
126
|
+
end
|
|
127
|
+
[header, branch.reverse]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "ace/hitl/molecules/event_observation"
|
|
4
|
+
|
|
3
5
|
module Ace
|
|
4
6
|
module Overseer
|
|
5
7
|
module Molecules
|
|
@@ -10,8 +12,14 @@ module Ace
|
|
|
10
12
|
# until answered — both are the overseer's inbox.
|
|
11
13
|
class HitlInbox
|
|
12
14
|
# @param manager [Ace::Hitl::Organisms::HitlManager, nil]
|
|
13
|
-
def initialize(manager: nil)
|
|
15
|
+
def initialize(manager: nil, owner: "overseer", scope: nil)
|
|
14
16
|
@manager = manager || Ace::Hitl::Organisms::HitlManager.new
|
|
17
|
+
raise ArgumentError, "configured HITL owner is required" if owner.to_s.strip.empty?
|
|
18
|
+
unless scope.nil? || %w[current all].include?(scope)
|
|
19
|
+
raise ArgumentError, "HITL scope must be current or all"
|
|
20
|
+
end
|
|
21
|
+
@owner, @scope = owner, scope
|
|
22
|
+
@observation = Ace::Hitl::Molecules::EventObservation.new(manager: @manager)
|
|
15
23
|
end
|
|
16
24
|
|
|
17
25
|
# @return [Array<Hash>] {"id","type","result","task","assignment",
|
|
@@ -19,10 +27,10 @@ module Ace
|
|
|
19
27
|
# atom matches on are carried: assignment, then the managing
|
|
20
28
|
# task's project_task.
|
|
21
29
|
def incoming_events
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
events = @observation.incoming_events(owner: @owner, scope: @scope).select do |event|
|
|
31
|
+
event.type == "report" || (event.type == "question" && event.status == "pending")
|
|
32
|
+
end
|
|
33
|
+
events.sort_by { |event| event.created_at || Time.now }.map { |event| normalize(event) }
|
|
26
34
|
end
|
|
27
35
|
|
|
28
36
|
private
|