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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -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 +258 -40
  7. data/handbook/workflow-instructions/overseer/lab-start.wf.md +60 -24
  8. data/handbook/workflow-instructions/overseer/queue.wf.md +33 -13
  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/report_interpretation.rb +28 -14
  12. data/lib/ace/overseer/atoms/start_contract.rb +20 -0
  13. data/lib/ace/overseer/cli/commands/handoff.rb +4 -0
  14. data/lib/ace/overseer/cli/commands/process_reports.rb +4 -2
  15. data/lib/ace/overseer/cli/commands/start.rb +37 -5
  16. data/lib/ace/overseer/cli/commands/work_on.rb +6 -2
  17. data/lib/ace/overseer/cli.rb +2 -0
  18. data/lib/ace/overseer/molecules/coworker_launcher.rb +189 -0
  19. data/lib/ace/overseer/molecules/event_observation.rb +54 -19
  20. data/lib/ace/overseer/molecules/eyes_evidence.rb +103 -0
  21. data/lib/ace/overseer/molecules/eyes_readiness.rb +21 -0
  22. data/lib/ace/overseer/molecules/eyes_session_evidence.rb +132 -0
  23. data/lib/ace/overseer/molecules/hitl_inbox.rb +14 -5
  24. data/lib/ace/overseer/molecules/local_sources.rb +83 -19
  25. data/lib/ace/overseer/molecules/project_bundle.rb +50 -6
  26. data/lib/ace/overseer/molecules/project_observations.rb +82 -0
  27. data/lib/ace/overseer/molecules/project_sessions.rb +99 -0
  28. data/lib/ace/overseer/molecules/remote_sources.rb +46 -16
  29. data/lib/ace/overseer/molecules/report_completion_check.rb +52 -0
  30. data/lib/ace/overseer/molecules/session_environment.rb +126 -0
  31. data/lib/ace/overseer/organisms/handoff_orchestrator.rb +26 -15
  32. data/lib/ace/overseer/organisms/report_processor.rb +56 -11
  33. data/lib/ace/overseer/organisms/start_orchestrator.rb +107 -52
  34. data/lib/ace/overseer/organisms/work_on_orchestrator.rb +43 -1
  35. data/lib/ace/overseer/version.rb +1 -1
  36. data/lib/ace/overseer.rb +3 -1
  37. 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
- # Exit 0 only for started/reused (dry-run reports the plan with
13
- # exit 0 but creates nothing); a blocked start is a CLI error.
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 || ->(argv) { Kernel.exec(*argv) }
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 @launcher.call(report.fetch("command")) if report["status"] == "foreground"
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
- puts "Done. Switch to tmux window and run /ace-assign-drive"
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
@@ -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