ace-overseer 0.30.2 → 0.31.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 +23 -0
  3. data/handbook/workflow-instructions/overseer/bootstrap-delegation.wf.md +35 -298
  4. data/handbook/workflow-instructions/overseer/lab-start.wf.md +69 -105
  5. data/handbook/workflow-instructions/overseer/queue.wf.md +39 -219
  6. data/handbook/workflow-instructions/overseer/start.wf.md +66 -298
  7. data/lib/ace/overseer/atoms/start_contract.rb +2 -2
  8. data/lib/ace/overseer/cli/commands/handoff.rb +15 -3
  9. data/lib/ace/overseer/cli/commands/process_reports.rb +33 -4
  10. data/lib/ace/overseer/cli/commands/start.rb +6 -1
  11. data/lib/ace/overseer/cli/commands/status.rb +1 -1
  12. data/lib/ace/overseer/cli/commands/work_on.rb +15 -3
  13. data/lib/ace/overseer/molecules/coworker_launcher.rb +9 -3
  14. data/lib/ace/overseer/molecules/event_observation.rb +4 -0
  15. data/lib/ace/overseer/molecules/eyes_configuration.rb +26 -0
  16. data/lib/ace/overseer/molecules/eyes_evidence.rb +6 -2
  17. data/lib/ace/overseer/molecules/eyes_readiness.rb +6 -1
  18. data/lib/ace/overseer/molecules/eyes_session_evidence.rb +2 -2
  19. data/lib/ace/overseer/molecules/handoff_probe.rb +2 -1
  20. data/lib/ace/overseer/molecules/hitl_inbox.rb +2 -2
  21. data/lib/ace/overseer/molecules/local_sources.rb +25 -5
  22. data/lib/ace/overseer/molecules/manager_binding.rb +138 -0
  23. data/lib/ace/overseer/molecules/project_observations.rb +19 -43
  24. data/lib/ace/overseer/molecules/project_sessions.rb +49 -13
  25. data/lib/ace/overseer/molecules/report_completion_check.rb +3 -1
  26. data/lib/ace/overseer/molecules/session_environment.rb +3 -8
  27. data/lib/ace/overseer/molecules/worktree_provisioner.rb +2 -2
  28. data/lib/ace/overseer/organisms/handoff_orchestrator.rb +43 -1
  29. data/lib/ace/overseer/organisms/report_processor.rb +137 -17
  30. data/lib/ace/overseer/organisms/start_orchestrator.rb +28 -7
  31. data/lib/ace/overseer/organisms/status_attention.rb +27 -3
  32. data/lib/ace/overseer/organisms/status_collector.rb +22 -2
  33. data/lib/ace/overseer/organisms/work_on_orchestrator.rb +33 -7
  34. data/lib/ace/overseer/version.rb +1 -1
  35. metadata +19 -17
@@ -76,6 +76,10 @@ module Ace
76
76
  verdict = Atoms::ReportInterpretation.evaluate(event: event, managers: managers)
77
77
  next false unless verdict[:status] == :duplicate
78
78
  manager = managers.find { |task| task.id.to_s == verdict[:manager_ref] }
79
+ step = Array(manager&.subtasks).find { |candidate| candidate.id.to_s == verdict[:step_ref] } || manager
80
+ receipt = step&.metadata&.fetch("report_receipt", nil)
81
+ next false unless receipt.is_a?(Hash) && receipt["key"] == event["key"] &&
82
+ receipt["revision"] == event["revision"] && receipt["cleanup"] == "complete" && receipt["continuation"] == "complete"
79
83
  # A persisted report alone is not proof its continuation ran. Keep
80
84
  # active managers eligible after restart; their existing gates reconcile
81
85
  # the same attempt. Only completed trees suppress historical reports.
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "fileutils"
4
+ require "tempfile"
5
+
6
+ module Ace::Overseer::Molecules
7
+ # Start owns the standard session input; Pi owns observation checkpoints.
8
+ class EyesConfiguration
9
+ def self.prepare(root:, recipient:)
10
+ path = File.join(root, ".ace-local/overseer/eyes.json")
11
+ existing = File.file?(path) ? JSON.parse(File.read(path)) : {}
12
+ raise Ace::Overseer::Error, "eyes configuration must be an object" unless existing.is_a?(Hash)
13
+ config = existing.merge("scope" => "all", "recipient" => recipient)
14
+ FileUtils.mkdir_p(File.dirname(path))
15
+ Tempfile.create(["eyes-", ".json"], File.dirname(path)) do |file|
16
+ file.write(JSON.pretty_generate(config) + "\n")
17
+ file.flush
18
+ file.fsync
19
+ File.rename(file.path, path)
20
+ end
21
+ path
22
+ rescue JSON::ParserError, SystemCallError => error
23
+ raise Ace::Overseer::Error, "cannot prepare eyes configuration: #{error.message}"
24
+ end
25
+ end
26
+ end
@@ -12,7 +12,7 @@ module Ace
12
12
  BINDING = "ace-wake-eyes-binding"
13
13
  BIRTH = /\A(?:linux:[0-9a-f-]{36}:[1-9][0-9]*|darwin:[1-9][0-9]*:[0-9]{1,6})\z/
14
14
 
15
- def self.ready?(branch:, root:, process:, prompt:, required_sources:, now_ms:)
15
+ def self.ready?(branch:, root:, process:, prompt:, required_sources:, now_ms:, expected_config: nil)
16
16
  return false unless branch.is_a?(Array) && root.is_a?(String) && process.is_a?(Hash) &&
17
17
  prompt.is_a?(String) && !prompt.empty? && required_sources.is_a?(Array) &&
18
18
  !required_sources.empty? && required_sources.all? { |name| name.is_a?(String) && !name.empty? } &&
@@ -20,6 +20,10 @@ module Ace
20
20
  process["started_at"].match?(BIRTH) && now_ms.is_a?(Integer)
21
21
  eye_index = branch.rindex { |entry| entry.is_a?(Hash) && entry["type"] == "custom" && entry["customType"] == ENTRY }
22
22
  return false unless eye_index
23
+ if expected_config
24
+ actual = branch[eye_index].dig("data", "config")
25
+ return false unless actual.is_a?(Hash) && expected_config.all? { |key, value| actual[key] == value }
26
+ end
23
27
 
24
28
  identity = {"pid" => process["pid"], "startedAt" => process["started_at"]}
25
29
 
@@ -86,7 +90,7 @@ module Ace
86
90
  evidence = eye["observerEvidence"]
87
91
  return false unless evidence.is_a?(Hash) && evidence["process"] == identity
88
92
  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) &&
93
+ return false unless observed.is_a?(Integer) && observed <= now_ms && config.is_a?(Hash) &&
90
94
  config["intervalSeconds"].is_a?(Integer) && config["intervalSeconds"].between?(1, 3600) &&
91
95
  config["maxObservationsPerHour"].is_a?(Integer) && config["maxObservationsPerHour"].between?(1, 3600) &&
92
96
  config["timeoutMs"].is_a?(Integer) && config["timeoutMs"].between?(100, 120_000)
@@ -13,7 +13,12 @@ module Ace
13
13
  end
14
14
 
15
15
  def ready?(pane:, root:, prompt:, required_sources:)
16
- @local.ready?(pane: pane, root: root, prompt: prompt, required_sources: required_sources)
16
+ path = File.join(root, ".ace-local/overseer/eyes.json")
17
+ expected = JSON.parse(File.read(path))
18
+ return false unless expected.is_a?(Hash) && expected["scope"] == "all" && !expected["recipient"].to_s.empty?
19
+ @local.ready?(expected_config: expected.slice("scope", "recipient"), pane: pane, root: root, prompt: prompt, required_sources: required_sources)
20
+ rescue SystemCallError, JSON::ParserError
21
+ false
17
22
  end
18
23
  end
19
24
  end
@@ -46,7 +46,7 @@ module Ace
46
46
  @executor, @identity, @clock, @file_opener = executor, identity, clock, file_opener
47
47
  end
48
48
 
49
- def ready?(pane:, root:, prompt:, required_sources:, expected_terminal: nil)
49
+ def ready?(pane:, root:, prompt:, required_sources:, expected_terminal: nil, expected_config: nil)
50
50
  executor = @executor || Ace::Herdr::Molecules::BoundedObservation.new(
51
51
  deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5)
52
52
  before = pane_binding(executor, pane)
@@ -68,7 +68,7 @@ module Ace
68
68
  process = @identity.capture(pid)
69
69
  return false unless process && process["uid"] == stat.uid
70
70
  return false unless EyesEvidence.ready?(branch: branch, root: root, process: process,
71
- prompt: prompt, required_sources: required_sources, now_ms: @clock.call)
71
+ prompt: prompt, required_sources: required_sources, expected_config: expected_config, now_ms: @clock.call)
72
72
  # The owner or its session may change while the file is read.
73
73
  return false unless @identity.capture(pid) == process && foreground?(executor, pane, pid) && pane_binding(executor, pane) == before
74
74
  final = file.stat
@@ -43,6 +43,7 @@ module Ace
43
43
  {
44
44
  "role" => mapping["role"],
45
45
  "start_config" => mapping["start_config"],
46
+ "execution" => mapping["execution"],
46
47
  "assignment" => record["id"],
47
48
  "name" => record["name"],
48
49
  "state" => states.fetch(record["id"], "")
@@ -91,7 +92,7 @@ module Ace
91
92
  role = data["role"].to_s
92
93
  next if assignment.empty? || role.strip.empty?
93
94
 
94
- [assignment, {"role" => role,
95
+ [assignment, {"role" => role, "execution" => data["execution"],
95
96
  "start_config" => File.join(PREPARE_DIR, File.basename(path))}]
96
97
  end.to_h
97
98
  end
@@ -49,7 +49,7 @@ module Ace
49
49
  private
50
50
 
51
51
  def normalize(event)
52
- {
52
+ @observation.project(event).merge(
53
53
  "id" => event.id,
54
54
  "type" => event.type,
55
55
  "result" => event.result,
@@ -57,7 +57,7 @@ module Ace
57
57
  "assignment" => event.metadata["assignment"],
58
58
  "role" => event.metadata["role"],
59
59
  "title" => event.title
60
- }
60
+ )
61
61
  end
62
62
  end
63
63
  end
@@ -125,8 +125,9 @@ module Ace
125
125
  # executor probe reads the worktree's commissioned config and the
126
126
  # ace-assign inventory (existing run identifiers — assignment
127
127
  # ids, never pane names), the ActiveExecutorRule decides live vs
128
- # stale, and each in-flight assignment projects its own progress
129
- # row so a step report revises that assignment only.
128
+ # stale. Progress remains readable; between-step paused/running
129
+ # transitions of an identified live native executor are not wake
130
+ # reasons. Native completion/disappearance and reports remain signals.
130
131
  def coworker_events(overseer_tasks)
131
132
  result = @worktrees.list_all
132
133
  raise Error, result[:error] || "failed to list worktrees" unless result[:success]
@@ -149,12 +150,24 @@ module Ace
149
150
  end
150
151
  payload = {"path" => worktree.path, "active" => verdict[:active],
151
152
  "evidence" => verdict[:evidence], "sessions" => visible, "missing_sessions" => missing}
152
- executor = {"key" => "executor:#{identity}", "revision" => revision(payload),
153
+ live_assignments = report[:assignments].filter_map do |assignment|
154
+ id = assignment["id"]
155
+ id if visible.any? { |session| session["agent"] == "coworker-#{id}" } &&
156
+ missing.none? { |binding| binding["assignment"] == id }
157
+ end
158
+ actionable_assignments = report[:assignments].map do |assignment|
159
+ assignment.merge("state" => actionable_assignment_state(assignment, live_assignments))
160
+ end
161
+ actionable_verdict = Ace::Coworker::Atoms::ActiveExecutorRule.evaluate(
162
+ config: report[:config], assignments: actionable_assignments
163
+ )
164
+ fingerprint = payload.merge("active" => actionable_verdict[:active], "evidence" => actionable_verdict[:evidence])
165
+ executor = {"key" => "executor:#{identity}", "revision" => revision(fingerprint),
153
166
  "type" => "executor"}.merge(payload)
154
167
  assignments = report[:assignments].map do |assignment|
155
168
  {"key" => "assignment:#{assignment["id"]}",
156
- "revision" => revision({"id" => assignment["id"], "state" => assignment["state"],
157
- "progress" => assignment["progress"]}),
169
+ "revision" => revision({"id" => assignment["id"],
170
+ "state" => actionable_assignment_state(assignment, live_assignments)}),
158
171
  "type" => "assignment", "id" => assignment["id"],
159
172
  "state" => assignment["state"], "progress" => assignment["progress"]}
160
173
  end
@@ -162,6 +175,13 @@ module Ace
162
175
  end
163
176
  end
164
177
 
178
+ def actionable_assignment_state(assignment, live_assignments)
179
+ state = assignment["state"]
180
+ return "running" if state == "paused" && live_assignments.include?(assignment["id"])
181
+
182
+ state
183
+ end
184
+
165
185
  # A coordinator's durable binding also detects a session that
166
186
  # started and exited between two native observations.
167
187
  def executor_bindings(tasks)
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/task/molecules/git_ignored_path"
4
+
5
+ module Ace
6
+ module Overseer
7
+ module Molecules
8
+ # References live in ace-task; execution truth stays in coworker/Herdr.
9
+ # Call under the manager and task delegation exclusions. Creation tags are
10
+ # written by ace-task with the child, so a crash before update is recoverable.
11
+ class ManagerBinding
12
+ def initialize(task_manager:)
13
+ @tasks = task_manager
14
+ end
15
+
16
+ def validate!(manager_ref:, task:, roles:)
17
+ manager = @tasks.show(manager_ref)
18
+ raise Error, "Manager not found: #{manager_ref}" unless manager
19
+ unless Ace::Task::Molecules::GitIgnoredPath.ignored_task?(manager)
20
+ raise Error, "manager #{manager.id} must be a private Git-ignored task"
21
+ end
22
+ raise Error, "manager must be a top-level task" if manager.metadata["parent"]
23
+ if Ace::Task::Atoms::TaskValidationRules.terminal_status?(manager.status.to_s)
24
+ raise Error, "manager #{manager.id} is terminal"
25
+ end
26
+ expected = manager.metadata["project_task"].to_s
27
+ raise Error, "manager #{manager.id} belongs to task #{expected}" if !expected.empty? && expected != task.id.to_s
28
+ existing_roles = manager.metadata["roles"]
29
+ if existing_roles && existing_roles != roles
30
+ raise Error, "manager #{manager.id} has a different role sequence"
31
+ end
32
+ managers = @tasks.list(in_folder: "_overseer")
33
+ unless managers.any? { |other| other.id == manager.id }
34
+ raise Error, "manager #{manager.id} must belong to the private _overseer tree"
35
+ end
36
+ conflicts = managers.reject { |other| other.id == manager.id }.select do |other|
37
+ other.metadata["project_task"].to_s == task.id.to_s
38
+ end
39
+ raise Error, "task #{task.id} already belongs to manager #{conflicts.first.id}" unless conflicts.empty?
40
+ manager
41
+ end
42
+
43
+ def prepare(manager_ref:, task:, sequence:, worktree_path:, project_root:)
44
+ roles = sequence.map { |leg| leg.fetch("role") }
45
+ manager = validate!(manager_ref: manager_ref, task: task, roles: roles)
46
+ # Refuse conflicts before creating any child. Otherwise a malformed
47
+ # later role could leave a newly created earlier role on every retry.
48
+ matched_roles = {}
49
+ sequence.each do |leg|
50
+ role = leg.fetch("role")
51
+ children = Array(manager.subtasks)
52
+ matches = children.select { |step| step.metadata["role"] == role || Array(step.tags).include?("overseer-role:#{role}") }
53
+ raise Error, "manager #{manager.id} has ambiguous children for #{role}" if matches.size > 1
54
+ matches.each do |step|
55
+ if matched_roles.key?(step.id) && matched_roles[step.id] != role
56
+ raise Error, "manager child #{step.id} matches conflicting roles"
57
+ end
58
+ matched_roles[step.id] = role
59
+ end
60
+ assigned = children.select { |step| step.metadata["assignment"].to_s == leg.fetch("assignment") }
61
+ if assigned.any? { |step| !matches.include?(step) }
62
+ raise Error, "assignment #{leg.fetch('assignment')} belongs to a different manager role"
63
+ end
64
+ assignment = matches.first&.metadata&.fetch("assignment", nil)
65
+ if assignment && assignment != leg.fetch("assignment")
66
+ raise Error, "role #{role} already belongs to assignment #{assignment}"
67
+ end
68
+ end
69
+ if roles.include?("bootstrap-verifier") && Array(manager.subtasks).count { |step|
70
+ step.metadata["role"] == "confirm-start" || Array(step.tags).include?("overseer-role:confirm-start")
71
+ } > 1
72
+ raise Error, "manager has ambiguous confirm-start children"
73
+ end
74
+ if Array(manager.subtasks).any? { |step| matched_roles.key?(step.id) &&
75
+ (step.metadata["role"] == "confirm-start" || Array(step.tags).include?("overseer-role:confirm-start")) }
76
+ raise Error, "confirm-start child also matches an execution role"
77
+ end
78
+ update!(manager.id, set: {"project_task" => task.id.to_s,
79
+ "project_root" => project_root, "roles" => roles, "worktree" => worktree_path})
80
+ previous = nil
81
+ bindings = sequence.map do |leg|
82
+ manager = @tasks.show(manager.id)
83
+ role = leg.fetch("role")
84
+ tag = "overseer-role:#{role}"
85
+ matches = Array(manager.subtasks).select do |step|
86
+ step.metadata["role"] == role || Array(step.tags).include?(tag)
87
+ end
88
+ raise Error, "manager #{manager.id} has ambiguous children for #{role}" if matches.size > 1
89
+ step = matches.first || @tasks.create_subtask(manager.id, role, tags: [tag], status: "pending")
90
+ assignment = step.metadata["assignment"].to_s
91
+ if !assignment.empty? && assignment != leg.fetch("assignment")
92
+ raise Error, "role #{role} already belongs to assignment #{assignment}"
93
+ end
94
+ update!(step.id, set: {"role" => role, "assignment" => leg.fetch("assignment"),
95
+ "project_task" => task.id.to_s, "worktree" => worktree_path,
96
+ "dependencies" => (Array(step.dependencies) + Array(previous)).uniq})
97
+ previous = step.id.to_s
98
+ {"role" => role, "assignment" => leg.fetch("assignment"), "step" => previous}
99
+ end
100
+ if roles.include?("bootstrap-verifier")
101
+ manager = @tasks.show(manager.id)
102
+ matches = Array(manager.subtasks).select do |step|
103
+ step.metadata["role"] == "confirm-start" || Array(step.tags).include?("overseer-role:confirm-start")
104
+ end
105
+ raise Error, "manager has ambiguous confirm-start children" if matches.size > 1
106
+ step = matches.first || @tasks.create_subtask(manager.id, "confirm-start",
107
+ tags: ["overseer-role:confirm-start"], status: "pending")
108
+ update!(step.id, set: {"role" => "confirm-start",
109
+ "dependencies" => (Array(step.dependencies) + Array(previous)).uniq})
110
+ end
111
+ bindings
112
+ end
113
+
114
+ def ready!(manager_ref:, assignment:, allow_unbound: false)
115
+ manager = @tasks.show(manager_ref)
116
+ step = Array(manager.subtasks).find { |child| child.metadata["assignment"] == assignment }
117
+ raise Error, "assignment #{assignment} has no manager step" unless step || allow_unbound
118
+ [manager, step].compact.each do |task|
119
+ result = Atoms::DependencyReadiness.check(task, lookup: ->(ref) { @tasks.show(ref) })
120
+ raise Error, "delegation blocked: #{result[:reasons].join('; ')}" if result[:status] == :blocked
121
+ end
122
+ end
123
+
124
+ private def update!(ref, **options)
125
+ @tasks.update(ref, **options) || raise(Error, "task #{ref} disappeared while recording delegation; retry to reconcile")
126
+ end
127
+
128
+ def record_execution(manager_ref:, assignment:, execution:)
129
+ manager = @tasks.show(manager_ref)
130
+ matches = Array(manager.subtasks).select { |step| step.metadata["assignment"] == assignment }
131
+ raise Error, "assignment #{assignment} has no unique manager step" unless matches.size == 1
132
+ update!(matches.first.id, set: {"terminal" => execution["terminal_id"],
133
+ "executor" => execution["agent"], "start_config" => execution["config_path"]}.compact)
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -1,66 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
  require "digest"
3
- require "json"
4
- require_relative "../atoms/observation_sources"
5
3
  require "ace/lab/molecules/install_manifest_loader"
6
- require "ace/herdr/molecules/bounded_process"
7
- require "ace/support/cli/toolchain"
8
- require_relative "session_environment"
4
+ require "ace/hitl/molecules/event_observation"
9
5
 
10
6
  module Ace::Overseer::Molecules
11
- # The lab reads the same public projection as each configured project.
12
- # Tasks, reports and assignments remain in that project's existing owners.
7
+ # The lab reads only its own incoming messages from configured project HITL
8
+ # owners. It does not run the project coordinator's complete observation or
9
+ # borrow that project's interpretation of the lab's recipient alias.
13
10
  class ProjectObservations
14
- BUDGET_SECONDS = 20
15
11
  def initialize(root: Dir.pwd, env: ENV, loader: -> { Ace::Lab::Molecules::InstallManifestLoader.load },
16
- runner: Ace::Herdr::Molecules::BoundedProcess.method(:call),
17
- clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, environment: nil)
18
- @root, @env, @loader, @runner, @clock = root, env, loader, runner, clock
19
- @environment = environment || SessionEnvironment.new(env: env, manifest_loader: loader)
12
+ hitl_config: nil)
13
+ @root, @env, @loader = root, env, loader
14
+ @hitl_config = hitl_config
20
15
  end
21
16
 
22
17
  def call(owner:, scope:, limit:)
23
18
  empty = {"events" => [], "sources" => {}}
24
- return empty unless @env["ACE_TOOLCHAIN"] == "installed" &&
25
- File.exist?(Ace::Lab.install_manifest_path)
19
+ return empty unless @env["ACE_TOOLCHAIN"] == "installed" && File.exist?(Ace::Lab.install_manifest_path)
26
20
  document = @loader.call
27
21
  return empty unless document.dig("coordinator", "path") == File.realpath(@root)
22
+ config = @hitl_config || Dir.chdir(@root) { Ace::Hitl::Molecules::HitlConfigLoader.load }
23
+ address = Ace::Hitl::Molecules::HitlAddressResolver.new(config: config).resolve(owner)
28
24
  events, sources = [], {}
29
- deadline = @clock.call + BUDGET_SECONDS
30
25
  document.fetch("projects").each do |project|
31
26
  id, root = project.values_at("id", "path")
32
- source = "project:#{id}"
27
+ source = "project:#{id}:hitl"
33
28
  begin
34
29
  raise Ace::Overseer::Error, "project root is not canonical" unless root != @root && File.realpath(root) == root
35
- remaining = deadline - @clock.call
36
- raise Timeout::Error, "project observation budget exhausted" unless remaining.positive?
37
- result = @runner.call([Ace::Support::Cli::Toolchain.executable("ace-overseer"), "observe",
38
- "--owner", owner, "--scope", scope, "--limit", limit.to_s],
39
- environment: @environment.observation_environment(root: root),
40
- chdir: root, timeout_s: remaining, output_limit: 2_097_152, cleanup_group: true)
41
- raise Ace::Overseer::Error, "project observation failed" unless result.status.success? && !result.oversized
42
- value = JSON.parse(result.stdout)
43
- unless value["schema"] == "ace.hitl.observation/v1" && value["events"].is_a?(Array) &&
44
- value["settled"].is_a?(Array) && value["sources"].is_a?(Hash) &&
45
- (Ace::Overseer::Atoms::ObservationSources::REQUIRED - value["sources"].keys).empty?
46
- raise Ace::Overseer::Error, "project observation is incomplete"
30
+ projection = Dir.chdir(root) do
31
+ manager = Ace::Hitl::Organisms::HitlManager.new
32
+ Ace::Hitl::Molecules::EventObservation.new(manager: manager).call(scope: scope, limit: limit)
47
33
  end
48
- rows = (value.fetch("events") + value.fetch("settled")).map do |row|
49
- unless row.is_a?(Hash) && %w[key revision id type].all? { |key| row[key].is_a?(String) }
50
- raise Ace::Overseer::Error, "project event is incomplete"
51
- end
52
- row.merge("key" => Digest::SHA256.hexdigest("#{root}:#{row.fetch('key')}"),
34
+ projection.fetch("events").each do |row|
35
+ recipient = row["recipient"]
36
+ next unless recipient.is_a?(Hash) && address.all? { |key, value| recipient[key] == value }
37
+ events << row.merge("key" => Digest::SHA256.hexdigest("#{root}:#{row.fetch('key')}"),
53
38
  "project" => id, "project_root" => root, "owner_key" => row.fetch("key"))
54
39
  end
55
- health = value.fetch("sources").to_h do |name, state|
56
- unless name.is_a?(String) && name.match?(/\A[a-zA-Z0-9_.:-]{1,64}\z/) &&
57
- state.is_a?(Hash) && %w[ok degraded].include?(state["status"])
58
- raise Ace::Overseer::Error, "project source health is incomplete"
59
- end
60
- ["#{source}:#{name}", state]
61
- end
62
- events.concat(rows)
63
- sources.merge!(health)
64
40
  sources[source] = {"status" => "ok"}
65
41
  events << project_health(root, id, "ok")
66
42
  rescue StandardError => error
@@ -74,7 +50,7 @@ module Ace::Overseer::Molecules
74
50
  private
75
51
 
76
52
  def project_health(root, id, status, revision = status)
77
- key = Digest::SHA256.hexdigest("#{root}:project-observation")
53
+ key = Digest::SHA256.hexdigest("#{root}:project-hitl")
78
54
  {"key" => key, "id" => key, "revision" => Digest::SHA256.hexdigest(revision),
79
55
  "type" => "source-health", "status" => status, "project" => id, "project_root" => root}
80
56
  end
@@ -5,6 +5,7 @@ require "ace/herdr"
5
5
  require "digest"
6
6
  require "json"
7
7
  require_relative "project_root_resolver"
8
+ require_relative "eyes_readiness"
8
9
 
9
10
  module Ace
10
11
  module Overseer
@@ -13,12 +14,14 @@ module Ace
13
14
  # herdr. This projection has no persisted copy of either.
14
15
  class ProjectSessions
15
16
  def initialize(root: Dir.pwd, control: nil, locations: Ace::Lab::Molecules::ProjectLocations,
16
- root_resolver: ProjectRootResolver.new, executor: Ace::Herdr::Molecules::HerdrExecutor.new, env: ENV)
17
+ root_resolver: ProjectRootResolver.new, executor: Ace::Herdr::Molecules::HerdrExecutor.new,
18
+ eyes_evidence: EyesReadiness.new, env: ENV)
17
19
  @root = File.realpath(root)
18
20
  @control = control || Ace::Herdr::Organisms::ControlSurface.new
19
21
  @locations = locations
20
22
  @root_resolver = root_resolver
21
23
  @executor, @env = executor, env
24
+ @eyes_evidence = eyes_evidence
22
25
  end
23
26
 
24
27
  def configured?
@@ -54,7 +57,7 @@ module Ace
54
57
 
55
58
  tabs = @control.list_tabs
56
59
  panes = @control.list_panes
57
- failures + resolved.map do |id, root, project|
60
+ failures + resolved.flat_map do |id, root, project|
58
61
  begin
59
62
  project_row(id, root, tabs, panes)
60
63
  rescue StandardError => e
@@ -74,16 +77,16 @@ module Ace
74
77
  end
75
78
 
76
79
 
77
- def live_agent?(pane)
80
+ def live_agent(pane)
78
81
  json = @executor.agent_get(pane).parsed_json
79
82
  raise Error, "herdr returned malformed agent state" unless json.is_a?(Hash)
80
83
  agent = json["agent"]
81
84
  agent ||= json.dig("result", "agent") if json["result"].is_a?(Hash)
82
85
  agent ||= json["result"] if json["result"].is_a?(Hash)
83
86
  raise Error, "herdr returned no agent identity" unless agent.is_a?(Hash)
84
- true
87
+ agent
85
88
  rescue Ace::Herdr::AgentNotFoundError
86
- false
89
+ nil
87
90
  end
88
91
 
89
92
  def project_row(id, root, tabs, panes)
@@ -93,22 +96,55 @@ module Ace
93
96
  raise Error, "project overseer tab has no unique pane: #{label}" unless matches.length == 1
94
97
  matches
95
98
  end
96
- if owners.length > 1
97
- # Reconciliation preserves dead panels for diagnostics. Native
98
- # agent presence, not the retained tab, identifies the live owner.
99
- owners = owners.select { |owner| live_agent?(owner.fetch(:id)) }
100
- raise Error, "project overseer has ambiguous sessions: #{label}" unless owners.length == 1
99
+ # Reconciliation preserves dead panels for diagnostics. Native agent
100
+ # presence, not retained tabs, identifies the live owner.
101
+ live = owners.filter_map do |owner|
102
+ agent = live_agent(owner.fetch(:id))
103
+ [owner, agent] if agent
101
104
  end
102
- pane = owners.first
105
+ raise Error, "project overseer has ambiguous sessions: #{label}" if live.length > 1
106
+ pane, agent = live.first
103
107
  raise Error, "project overseer session did not report a working directory: #{label}" if pane && pane[:cwd].to_s.empty?
104
108
  if pane && @root_resolver.resolve(cwd: pane[:cwd]) != root
105
109
  raise Error, "project overseer session has a different root: #{label}"
106
110
  end
107
111
  state = pane ? (pane[:agent_status] || "unknown").to_s : "absent"
108
112
  payload = {"project" => id, "root" => root, "pane" => pane && pane[:id], "state" => state}
109
- {"key" => "project-session:#{Digest::SHA256.hexdigest(root)}",
110
- "revision" => Digest::SHA256.hexdigest(JSON.generate(payload)),
113
+ row = {"key" => "project-session:#{Digest::SHA256.hexdigest(root)}",
114
+ "revision" => Digest::SHA256.hexdigest(JSON.generate(payload.merge(
115
+ "state" => (%w[working idle done].include?(state) ? "available" : state)))),
111
116
  "type" => "project-session"}.merge(payload)
117
+ [row, readiness_event(id, root, pane, agent)].compact
118
+ end
119
+
120
+ # A successful first-turn proof is a stable confirmation, not a sampled
121
+ # boolean lifecycle. A concurrent transcript append can make a proof
122
+ # inconclusive; omitting this marker then does not undo eyes' existing ack.
123
+ # The recipient still rechecks start readiness before recording success.
124
+ def readiness_event(id, root, pane, agent)
125
+ return unless pane
126
+ required = Atoms::ObservationSources::REQUIRED.dup
127
+ required << Atoms::ObservationSources::PROJECT_SESSIONS if File.file?(File.join(root, ".ace/lab/config.yml"))
128
+ return unless @eyes_evidence.ready?(pane: pane.fetch(:id), root: root,
129
+ prompt: Atoms::StartContract::FIRST_TURN_PROMPT, required_sources: required)
130
+ identity = readiness_identity(agent)
131
+ current = live_agent(pane.fetch(:id))
132
+ return unless current && readiness_identity(current) == identity
133
+
134
+ payload = {"project" => id, "root" => root, "pane" => pane.fetch(:id),
135
+ "terminal" => identity[0], "session" => identity[1], "ready" => true}
136
+ key = Digest::SHA256.hexdigest(JSON.generate([root, *identity]))
137
+ {"key" => "project-ready:#{key}", "revision" => Digest::SHA256.hexdigest("ready:#{key}"),
138
+ "type" => "project-ready"}.merge(payload)
139
+ end
140
+
141
+ def readiness_identity(agent)
142
+ terminal = agent["terminal_id"]
143
+ session = agent.dig("agent_session", "value")
144
+ unless terminal.is_a?(String) && !terminal.empty? && session.is_a?(String) && !session.empty?
145
+ raise Error, "ready project agent has no terminal/session identity"
146
+ end
147
+ [terminal, session]
112
148
  end
113
149
  end
114
150
  end
@@ -25,7 +25,9 @@ module Ace
25
25
  role = step.metadata["role"].to_s
26
26
  worktree = step.metadata["worktree"].to_s
27
27
  worktree = manager.metadata["worktree"].to_s if worktree.empty?
28
- return "success requires recorded project task, role and worktree" if [task, role, worktree].any?(&:empty?)
28
+ return "private coordination task #{manager.id} is missing project_task" if task.empty?
29
+ return "private coordination step #{step.id} is missing role; record its commissioned role with ace-task update #{step.id} --set role=<role>" if role.empty?
30
+ return "private coordination step #{step.id} (or its parent) is missing worktree" if worktree.empty?
29
31
  return "report task disagrees with the allocation" unless event["task"].to_s == task
30
32
  return "confirm-start requires coordinator session evidence" if role == "confirm-start"
31
33
  return "report assignment disagrees with the allocation" unless step.metadata["assignment"].to_s == assignment
@@ -13,7 +13,7 @@ module Ace::Overseer::Molecules
13
13
  class SessionEnvironment
14
14
  BASE_KEYS = %w[HOME USER LOGNAME SHELL TERM LANG LC_ALL TZ TMPDIR PATH GEM_HOME GEM_PATH ACE_TOOLCHAIN
15
15
  HERDR_SOCKET_PATH HERDR_WORKSPACE_ID].freeze
16
- RESERVED = %w[ACE_TOOLCHAIN GEM_HOME GEM_PATH PATH PI_CODING_AGENT_DIR ACE_SESSION_CREDENTIALS_DIRECTORY BUNDLE_GEMFILE RUBYOPT RUBYLIB].freeze
16
+ RESERVED = %w[ACE_TOOLCHAIN GEM_HOME GEM_PATH PATH PI_CODING_AGENT_DIR ACE_SESSION_CREDENTIALS_DIRECTORY BUNDLE_GEMFILE RUBYOPT RUBYLIB ACE_OVERSEER_EYES_CONFIG].freeze
17
17
  MAX_BYTES = 65_536
18
18
  ENVIRONMENT_ROLES = {"reviewer" => "verifier", "bootstrap-verifier" => "verifier", "bootstrap-worker" => "worker"}.freeze
19
19
 
@@ -22,7 +22,7 @@ module Ace::Overseer::Molecules
22
22
  @env, @loader, @manifest_path = env, manifest_loader, manifest_path
23
23
  end
24
24
 
25
- def with_environment(root:, role:)
25
+ def with_environment(root:, role:, eyes_config: nil)
26
26
  environment = @env.to_h.slice(*BASE_KEYS)
27
27
  directory = nil
28
28
  handed_off = false
@@ -46,6 +46,7 @@ module Ace::Overseer::Molecules
46
46
  else
47
47
  environment.merge!(ProjectBundle.environment(root, env: @env)).compact!
48
48
  end
49
+ environment["ACE_OVERSEER_EYES_CONFIG"] = eyes_config if eyes_config
49
50
  Tempfile.create(["ace-environment-", ".json"]) do |file|
50
51
  file.chmod(0o600)
51
52
  file.write(JSON.generate(environment))
@@ -59,12 +60,6 @@ module Ace::Overseer::Molecules
59
60
  raise
60
61
  end
61
62
 
62
- # A projection needs this project's read credentials and installed CLI,
63
- # not the coordinator's token or a new model-auth directory per poll.
64
- def observation_environment(root:)
65
- configured_environment(selection_for(root, "project-overseer"))
66
- end
67
-
68
63
  def self.cleanup(directory)
69
64
  return unless directory
70
65
  # Only our exact scratch directory; never a caller's home/config root.
@@ -17,7 +17,7 @@ module Ace
17
17
  return recover_from_stale_worktree(task_ref_str, source: :switch, previous_result: switch_result)
18
18
  end
19
19
 
20
- create_result = @manager.create_task(task_ref_str)
20
+ create_result = @manager.create_task(task_ref_str, defer_setup: true)
21
21
  raise Error, create_result[:error] || "Failed to provision worktree for task #{task_ref}" unless create_result[:success]
22
22
 
23
23
  created = !create_result[:existing]
@@ -44,7 +44,7 @@ module Ace
44
44
  def recover_from_stale_worktree(task_ref, source:, previous_result:)
45
45
  @manager.prune
46
46
 
47
- retry_result = @manager.create_task(task_ref)
47
+ retry_result = @manager.create_task(task_ref, defer_setup: true)
48
48
  raise Error, retry_result[:error] || "Failed to provision worktree for task #{task_ref}" unless retry_result[:success]
49
49
 
50
50
  created = !retry_result[:existing]