ace-overseer 0.28.1 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +2 -2
  4. data/docs/getting-started.md +1 -1
  5. data/docs/usage.md +47 -25
  6. data/handbook/workflow-instructions/overseer/bootstrap-delegation.wf.md +224 -37
  7. data/handbook/workflow-instructions/overseer/lab-start.wf.md +49 -21
  8. data/handbook/workflow-instructions/overseer/queue.wf.md +13 -8
  9. data/handbook/workflow-instructions/overseer/start.wf.md +198 -21
  10. data/lib/ace/overseer/atoms/observation_sources.rb +17 -0
  11. data/lib/ace/overseer/atoms/start_contract.rb +20 -0
  12. data/lib/ace/overseer/cli/commands/handoff.rb +4 -0
  13. data/lib/ace/overseer/cli/commands/process_reports.rb +4 -2
  14. data/lib/ace/overseer/cli/commands/start.rb +45 -5
  15. data/lib/ace/overseer/cli/commands/work_on.rb +6 -2
  16. data/lib/ace/overseer/cli.rb +2 -0
  17. data/lib/ace/overseer/molecules/coworker_launcher.rb +189 -0
  18. data/lib/ace/overseer/molecules/event_observation.rb +54 -19
  19. data/lib/ace/overseer/molecules/eyes_evidence.rb +103 -0
  20. data/lib/ace/overseer/molecules/eyes_readiness.rb +21 -0
  21. data/lib/ace/overseer/molecules/eyes_session_evidence.rb +132 -0
  22. data/lib/ace/overseer/molecules/hitl_inbox.rb +13 -5
  23. data/lib/ace/overseer/molecules/local_sources.rb +83 -19
  24. data/lib/ace/overseer/molecules/project_bundle.rb +1 -1
  25. data/lib/ace/overseer/molecules/project_observations.rb +82 -0
  26. data/lib/ace/overseer/molecules/project_sessions.rb +99 -0
  27. data/lib/ace/overseer/molecules/remote_sources.rb +46 -16
  28. data/lib/ace/overseer/molecules/session_environment.rb +127 -0
  29. data/lib/ace/overseer/organisms/handoff_orchestrator.rb +26 -15
  30. data/lib/ace/overseer/organisms/report_processor.rb +42 -10
  31. data/lib/ace/overseer/organisms/start_orchestrator.rb +105 -55
  32. data/lib/ace/overseer/organisms/work_on_orchestrator.rb +43 -1
  33. data/lib/ace/overseer/version.rb +1 -1
  34. data/lib/ace/overseer.rb +2 -1
  35. metadata +64 -2
@@ -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 the six local source classes — HITL
13
+ # the started overseer with local source classes — HITL
12
14
  # events plus LocalSources (project tasks, _overseer tasks,
13
- # worktrees, coworker availability, assignment progress) — every
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
- managers = @tasks.list(in_folder: "all").select { |task| !task.metadata["project_task"].to_s.empty? }
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
- "hitl" => {"status" => "degraded", "error" => "#{hitl_error.class}: #{hitl_error.message}"}
64
+ Atoms::ObservationSources::HITL => {"status" => "degraded", "error" => "#{hitl_error.class}: #{hitl_error.message}"}
46
65
  )
47
- ensure_budget!(events.length, limit)
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
- 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")))
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" => local.fetch("sources").merge(remote.fetch("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.pull_requests list — never a scan of accessible
93
- # accounts. No configuration means no remote rows and no remote
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
- return {"events" => [], "sources" => {}} if entries.empty?
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
- reports = @manager.list(in_folder: "next", type: "report")
23
- questions = @manager.list(in_folder: "next", type: "question", status: "pending")
24
-
25
- (reports + questions).sort_by { |event| event.created_at || Time.now }.map { |event| normalize(event) }
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