ace-overseer 0.26.1 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +138 -0
- data/docs/usage.md +225 -1
- data/handbook/workflow-instructions/overseer/bootstrap-delegation.wf.md +88 -0
- data/handbook/workflow-instructions/overseer/cold-cycle-acceptance.wf.md +95 -0
- data/handbook/workflow-instructions/overseer/lab-start.wf.md +82 -0
- data/handbook/workflow-instructions/overseer/start.wf.md +79 -0
- data/lib/ace/overseer/atoms/start_contract.rb +128 -0
- data/lib/ace/overseer/cli/commands/start.rb +94 -0
- data/lib/ace/overseer/cli.rb +2 -0
- data/lib/ace/overseer/molecules/event_observation.rb +83 -5
- data/lib/ace/overseer/molecules/local_sources.rb +141 -0
- data/lib/ace/overseer/molecules/project_bundle.rb +119 -0
- data/lib/ace/overseer/molecules/project_root_resolver.rb +66 -0
- data/lib/ace/overseer/molecules/remote_sources.rb +139 -0
- data/lib/ace/overseer/organisms/start_orchestrator.rb +666 -0
- data/lib/ace/overseer/version.rb +1 -1
- data/lib/ace/overseer.rb +25 -0
- metadata +17 -12
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atoms/start_contract"
|
|
4
|
+
require_relative "../molecules/project_root_resolver"
|
|
5
|
+
require_relative "../molecules/project_bundle"
|
|
6
|
+
require "ace/herdr"
|
|
7
|
+
require "ace/support/cli/toolchain"
|
|
8
|
+
require "json"
|
|
9
|
+
|
|
10
|
+
module Ace
|
|
11
|
+
module Overseer
|
|
12
|
+
module Organisms
|
|
13
|
+
# `ace-overseer start` boots the Lab in the calling Herdr terminal,
|
|
14
|
+
# or starts/reuses configured project sessions. Missing start config
|
|
15
|
+
# uses the packaged bootstrap profile so the coordinator can configure
|
|
16
|
+
# itself. Explicit malformed configuration remains a blocked start.
|
|
17
|
+
#
|
|
18
|
+
# Session identity is Herdr's: the tab/pane label
|
|
19
|
+
# `overseer-<project>` derives from the canonical repository root
|
|
20
|
+
# (subdirectory, symlink and worktree of one repository are the
|
|
21
|
+
# SAME owner, so a worktree never spawns a second whole-project
|
|
22
|
+
# overseer). A live owner pane is REUSED; a live pane with a
|
|
23
|
+
# conflicting cwd is a conflict (never taken over); a dead pane is
|
|
24
|
+
# replaced with a new identity reported in the result. The agent
|
|
25
|
+
# boots with the packaged guide as standing instructions — the
|
|
26
|
+
# overseer coordinates and delegates, it never substitutes for a
|
|
27
|
+
# missing coworker.
|
|
28
|
+
class StartOrchestrator
|
|
29
|
+
AGENT_NAME = "ace-overseer"
|
|
30
|
+
START_TIMEOUT_MS = 60_000
|
|
31
|
+
# Bounds the --wait activity gate on the first-turn prompt: the
|
|
32
|
+
# turn must BEGIN within this window (herdr enforces its own
|
|
33
|
+
# 5s activity observation inside the wait).
|
|
34
|
+
FIRST_TURN_TIMEOUT_MS = 120_000
|
|
35
|
+
# Workspace display names are separate from session identities.
|
|
36
|
+
# `overseer` belongs to the Lab; project spaces use topology ids.
|
|
37
|
+
LAB_SPACE_TITLE = "overseer"
|
|
38
|
+
# `herdr agent start` success only means the interactive agent is
|
|
39
|
+
# READY for input — the session begins its first turn when this
|
|
40
|
+
# prompt arrives, exactly as the standing instructions' "First
|
|
41
|
+
# turn after start" section describes (state read, /eyes arming).
|
|
42
|
+
FIRST_TURN_PROMPT = <<~PROMPT.chomp
|
|
43
|
+
Begin your first turn now. Follow the "First turn after start"
|
|
44
|
+
section of your standing instructions: read your private
|
|
45
|
+
_overseer tasks and the project state from its owners, continue
|
|
46
|
+
existing commissioned work before choosing anything new. Arm
|
|
47
|
+
standing observation with the ace_eyes tool, action start and
|
|
48
|
+
configPath naming your eyes configuration; verify its returned
|
|
49
|
+
observation state. Remain idle when there is nothing to do.
|
|
50
|
+
PROMPT
|
|
51
|
+
|
|
52
|
+
attr_reader :cwd
|
|
53
|
+
|
|
54
|
+
# @param config [Hash, nil] resolved overseer config (defaults
|
|
55
|
+
# to Ace::Overseer.config)
|
|
56
|
+
# @param executor [HerdrExecutor, nil] herdr seam
|
|
57
|
+
# @param root_resolver [ProjectRootResolver, nil]
|
|
58
|
+
# @param nav_runner [Proc, nil] (argv, cwd) runner seam for
|
|
59
|
+
# resolving wfi:// instructions through ace-nav
|
|
60
|
+
# @param workspace [String, nil] explicit herdr workspace id for
|
|
61
|
+
# the session; used verbatim by boot_session
|
|
62
|
+
# @param workspace_factory [Proc, nil] label -> workspace id
|
|
63
|
+
# seam; the lab scope passes one so each project boots in its
|
|
64
|
+
# OWN space instead of the caller's ambient workspace. Called
|
|
65
|
+
# lazily, only when a session actually boots.
|
|
66
|
+
# @param cwd [String] invocation directory
|
|
67
|
+
def initialize(config: nil, executor: nil, root_resolver: nil, nav_runner: nil,
|
|
68
|
+
workspace: nil, workspace_factory: nil, cwd: Dir.pwd, env: ENV, scope: nil, project_runner: nil)
|
|
69
|
+
@config = config || Ace::Overseer.config
|
|
70
|
+
@executor = executor || Ace::Herdr::Molecules::HerdrExecutor.new
|
|
71
|
+
@control_surface = Ace::Herdr::Organisms::ControlSurface.new(executor: @executor)
|
|
72
|
+
@root_resolver = root_resolver || Molecules::ProjectRootResolver.new
|
|
73
|
+
@nav_runner = nav_runner || method(:run_nav)
|
|
74
|
+
@workspace = workspace
|
|
75
|
+
@workspace_factory = workspace_factory
|
|
76
|
+
@cwd = cwd
|
|
77
|
+
@env = env
|
|
78
|
+
@scope = scope
|
|
79
|
+
@project_runner = project_runner || Molecules::ProjectBundle.new
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @param dry_run [Boolean] report the plan without creating a
|
|
83
|
+
# session, syncing, or invoking any model
|
|
84
|
+
# @return [Hash] {status: started|reused|blocked|dry_run, scope,
|
|
85
|
+
# root, label, workspace, pane, agent, profile, instructions,
|
|
86
|
+
# replaced_pane, message}; scope lab additionally carries
|
|
87
|
+
# {projects: [{id, path, status, ...}], project_count, ready}
|
|
88
|
+
def call(dry_run: false)
|
|
89
|
+
plan = Atoms::StartContract.normalize(Atoms::StartContract.bootstrap(@config, scope: @scope || "lab"))
|
|
90
|
+
if @scope && plan["scope"] != @scope
|
|
91
|
+
raise Ace::Overseer::Error, "configured start scope '#{plan["scope"]}' conflicts with requested '#{@scope}'; nested lab scopes are refused"
|
|
92
|
+
end
|
|
93
|
+
return lab_start(plan, dry_run) if plan["scope"] == "lab"
|
|
94
|
+
|
|
95
|
+
root = @root_resolver.resolve(cwd: @cwd)
|
|
96
|
+
label = Atoms::StartContract.session_label(root)
|
|
97
|
+
instructions_path = resolve_instructions(plan["instructions"], root)
|
|
98
|
+
|
|
99
|
+
report = base_report(plan, root, label, instructions_path)
|
|
100
|
+
if dry_run
|
|
101
|
+
return report.merge("status" => "dry_run",
|
|
102
|
+
"message" => "dry-run: session would start with this plan; nothing was created")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
owner = owner_pane(label)
|
|
106
|
+
if owner
|
|
107
|
+
conflict = owner_conflict(owner, root)
|
|
108
|
+
if conflict
|
|
109
|
+
return report.merge("status" => "blocked", "pane" => owner["id"], "message" => conflict)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
state = agent_state(owner["id"])
|
|
113
|
+
if state
|
|
114
|
+
# a live agent is a PROCESS, not necessarily a completed
|
|
115
|
+
# start — the missing first-turn initialization is
|
|
116
|
+
# repaired through the existing session (see
|
|
117
|
+
# repair_reused_initialization)
|
|
118
|
+
warning = repair_reused_initialization(owner["id"], state)
|
|
119
|
+
return report.merge("status" => "reused", "pane" => owner["id"],
|
|
120
|
+
"workspace" => owner["workspace_id"] || owner["workspace"],
|
|
121
|
+
"prompt_failed" => !warning.nil?,
|
|
122
|
+
"message" => warning ||
|
|
123
|
+
"existing project overseer session reused; no second session created")
|
|
124
|
+
end
|
|
125
|
+
# a dead owner is replaced after observing the real state —
|
|
126
|
+
# the new identity is reported, the old pane is left to the
|
|
127
|
+
# operator's pane hygiene
|
|
128
|
+
report["replaced_pane"] = owner["id"]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# resolved only once a session actually boots, so a merely
|
|
132
|
+
# reused lab project never creates an empty space
|
|
133
|
+
workspace = boot_workspace(label)
|
|
134
|
+
report["workspace"] = workspace
|
|
135
|
+
|
|
136
|
+
pane = boot_session(workspace, label, root, plan, instructions_path)
|
|
137
|
+
prompt_warning = first_prompt(pane)
|
|
138
|
+
report.merge("status" => "started", "pane" => pane,
|
|
139
|
+
"prompt_failed" => !prompt_warning.nil?,
|
|
140
|
+
"message" => prompt_warning ||
|
|
141
|
+
"project overseer session started; the agent reads its " \
|
|
142
|
+
"private tasks and project state and stays idle when there is no work")
|
|
143
|
+
rescue Ace::Overseer::Error => e
|
|
144
|
+
blocked(e.message)
|
|
145
|
+
rescue Ace::Herdr::ExecutorError => e
|
|
146
|
+
# herdr failures (pane list, agent probe, session boot) surface
|
|
147
|
+
# as a blocked start report naming herdr — never an unhandled
|
|
148
|
+
# exception out of `ace-overseer start`
|
|
149
|
+
blocked("herdr failed while preparing the overseer session: #{e.message}")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Bare start is just the foreground harness entrypoint. It does
|
|
153
|
+
# not discover a terminal, inspect agents or analyze topology.
|
|
154
|
+
def bootstrap(dry_run: false)
|
|
155
|
+
plan = Atoms::StartContract.normalize(@config)
|
|
156
|
+
unless plan["profile"]["harness"] == "pi"
|
|
157
|
+
raise Ace::Overseer::Error, "foreground start requires the configured pi harness"
|
|
158
|
+
end
|
|
159
|
+
root = File.realpath(@cwd)
|
|
160
|
+
source = plan["instructions"]
|
|
161
|
+
guide = if %w[wfi://overseer/lab-start wfi://overseer/start].include?(source)
|
|
162
|
+
filename = source.end_with?("lab-start") ? "lab-start.wf.md" : "start.wf.md"
|
|
163
|
+
File.join(Ace::Overseer.gem_root, "handbook/workflow-instructions/overseer", filename)
|
|
164
|
+
else
|
|
165
|
+
resolve_instructions(source, root)
|
|
166
|
+
end
|
|
167
|
+
raise Ace::Overseer::Error, "standing instructions file is missing: #{guide}" unless File.file?(guide)
|
|
168
|
+
command = ["pi", "--model", plan["profile"]["model"]]
|
|
169
|
+
thinking = plan["profile"]["thinking"]
|
|
170
|
+
command += ["--thinking", thinking] if thinking
|
|
171
|
+
command += ["--append-system-prompt", guide, FIRST_TURN_PROMPT]
|
|
172
|
+
{"status" => dry_run ? "dry_run" : "foreground", "scope" => plan["scope"],
|
|
173
|
+
"root" => root, "profile" => plan["profile_name"],
|
|
174
|
+
"instructions" => {"source" => source, "path" => guide},
|
|
175
|
+
"command" => command, "message" => "launch the coordinator in the current terminal"}
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Explicit tool available to the running Lab agent. This operation
|
|
179
|
+
# manages configured project children, never the coordinator itself.
|
|
180
|
+
def reconcile(dry_run: false)
|
|
181
|
+
plan = Atoms::StartContract.normalize(@config)
|
|
182
|
+
raise Ace::Overseer::Error, "project reconciliation requires lab scope" unless plan["scope"] == "lab"
|
|
183
|
+
|
|
184
|
+
lab_start(plan, dry_run, reconcile: true)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
# --- lab scope (task 8xs.t.pu0.a) -----------------------------------
|
|
190
|
+
# The lab root is its own coordination directory with its own
|
|
191
|
+
# _overseer. The project list and canonical locations come from
|
|
192
|
+
# the existing ace-lab topology configuration (topology.projects
|
|
193
|
+
# id/path) — never from the sanitized public projection and
|
|
194
|
+
# never derived from an id/label/repo name. The lab space hosts
|
|
195
|
+
# ONLY the lab overseer session; EACH project boots in its OWN
|
|
196
|
+
# herdr space (created/reused by the project's session label),
|
|
197
|
+
# so project sessions are isolated from each other and from the
|
|
198
|
+
# lab overseer. Each project starts through the SAME operation
|
|
199
|
+
# as a standalone start (its own cascade, its own session
|
|
200
|
+
# identity); an unhealthy project is reported per-project while
|
|
201
|
+
# healthy projects continue; a repeated start repairs only what
|
|
202
|
+
# is missing and never restarts healthy sessions or closes
|
|
203
|
+
# manual panes.
|
|
204
|
+
def lab_start(plan, dry_run, reconcile: false)
|
|
205
|
+
lab_root = File.realpath(@cwd)
|
|
206
|
+
base = {"status" => nil, "scope" => "lab", "root" => lab_root,
|
|
207
|
+
"label" => Atoms::StartContract.session_label(lab_root),
|
|
208
|
+
"workspace" => nil, "pane" => nil, "agent" => AGENT_NAME,
|
|
209
|
+
"profile" => plan["profile_name"],
|
|
210
|
+
"instructions" => {"source" => plan["instructions"], "path" => nil},
|
|
211
|
+
"replaced_pane" => nil, "projects" => [], "project_count" => 0,
|
|
212
|
+
"ready" => nil, "message" => nil}
|
|
213
|
+
base["instructions"]["path"] = resolve_instructions(plan["instructions"], lab_root) unless reconcile
|
|
214
|
+
|
|
215
|
+
projects = begin
|
|
216
|
+
Ace::Lab::Molecules::ProjectLocations.read(lab_root)
|
|
217
|
+
rescue Ace::Lab::InvalidConfigurationError => e
|
|
218
|
+
return base.merge("status" => "blocked",
|
|
219
|
+
"message" => "lab topology could not be read: #{e.message}")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
if projects.any? { |project|
|
|
223
|
+
project["path"].to_s.empty? == false &&
|
|
224
|
+
File.directory?(project["path"]) && File.realpath(project["path"]) == lab_root
|
|
225
|
+
}
|
|
226
|
+
return base.merge("status" => "blocked",
|
|
227
|
+
"message" => "the lab root '#{lab_root}' is itself declared as a project; " \
|
|
228
|
+
"conflicting lab/project roles are refused")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
if dry_run
|
|
232
|
+
return base.merge("status" => "dry_run", "project_count" => projects.size,
|
|
233
|
+
"projects" => projects.map { |project|
|
|
234
|
+
{"id" => project["id"], "path" => project["path"],
|
|
235
|
+
"status" => "would_start"}
|
|
236
|
+
},
|
|
237
|
+
"message" => "dry-run: the lab space with the lab overseer session and " \
|
|
238
|
+
"#{projects.size} project space(s)/overseer(s) would start; nothing was created")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
if reconcile
|
|
242
|
+
base["coordinator_managed"] = false
|
|
243
|
+
session = {}
|
|
244
|
+
lab_ready = true
|
|
245
|
+
else
|
|
246
|
+
workspace = lab_workspace
|
|
247
|
+
base["workspace"] = workspace
|
|
248
|
+
|
|
249
|
+
# The lab's OWN overseer session (the wfi://overseer/lab-start
|
|
250
|
+
# guide assumes it): booted or reused in the lab space before
|
|
251
|
+
# any project, so the lab-start instructions reach an agent.
|
|
252
|
+
session = boot_lab_session(workspace, plan, base["instructions"]["path"], lab_root)
|
|
253
|
+
if session["status"] == "blocked"
|
|
254
|
+
return base.merge("status" => "blocked", "pane" => session["pane"],
|
|
255
|
+
"message" => session["message"])
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
base["pane"] = session["pane"]
|
|
259
|
+
base["replaced_pane"] = session["replaced_pane"]
|
|
260
|
+
base["prompt_failed"] = !session["prompt_failed"].nil? && session["prompt_failed"]
|
|
261
|
+
|
|
262
|
+
# Readiness REQUIRES a confirmed first turn (review R7): a
|
|
263
|
+
# started/reused session whose first-turn delivery failed or
|
|
264
|
+
# stays unconfirmed is NOT ready, whatever its process state.
|
|
265
|
+
lab_ready = !session["prompt_failed"]
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# An empty configured list is a valid idle lab: the lab
|
|
269
|
+
# overseer itself still runs (and must be initialized).
|
|
270
|
+
if projects.empty?
|
|
271
|
+
message = reconcile ? "no project children configured" : "no projects configured; the lab overseer is idle"
|
|
272
|
+
message += "; #{session["message"]}" if session["message"]
|
|
273
|
+
return base.merge("status" => lab_ready ? "ready" : "partial",
|
|
274
|
+
"ready" => lab_ready, "project_count" => 0, "message" => message)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
results = projects.map { |project| start_project(project, lab_root) }
|
|
278
|
+
healthy = ->(result) { %w[started reused].include?(result["status"]) && !result["prompt_failed"] }
|
|
279
|
+
ready = lab_ready && results.all?(&healthy)
|
|
280
|
+
summary = "#{results.count(&healthy)}/#{results.size} project overseers ready"
|
|
281
|
+
summary += "; lab overseer: #{session["message"]}" if session["message"]
|
|
282
|
+
base.merge("status" => ready ? "ready" : "partial", "ready" => ready,
|
|
283
|
+
"projects" => results, "project_count" => results.size,
|
|
284
|
+
"message" => summary)
|
|
285
|
+
rescue Ace::Overseer::Error, Ace::Herdr::ExecutorError => e
|
|
286
|
+
# A lab-level failure keeps the lab context (scope/root/label)
|
|
287
|
+
# so callers can tell it from a per-project blocked result.
|
|
288
|
+
base.merge("status" => "blocked", "message" => "lab start failed: #{e.message}")
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# The lab's own herdr space, created once and reused by title.
|
|
292
|
+
# The title is namespaced (see LAB_SPACE_TITLE) so it can never
|
|
293
|
+
# collide with a session/space title derived from a project
|
|
294
|
+
# directory name (e.g. a project rooted at a directory literally
|
|
295
|
+
# named "lab" owns "overseer-lab").
|
|
296
|
+
def lab_workspace
|
|
297
|
+
find_or_create_workspace(LAB_SPACE_TITLE)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# A herdr space by title, created when absent: the lab space
|
|
301
|
+
# ("overseer-lab") and each project's OWN space (titled by the
|
|
302
|
+
# project's session label, so a repeated lab start converges on
|
|
303
|
+
# the same space). herdr spaces are flat (tmux-session
|
|
304
|
+
# analogue, no parent/child nesting) — the lab context owns
|
|
305
|
+
# them by creating/reusing them from this one start flow.
|
|
306
|
+
def find_or_create_workspace(title)
|
|
307
|
+
found = find_workspace(title)
|
|
308
|
+
return found if found
|
|
309
|
+
|
|
310
|
+
created = @executor.workspace_create(label: title).parsed_json
|
|
311
|
+
dig_workspace(created)
|
|
312
|
+
rescue Ace::Herdr::ExecutorUnavailableError => e
|
|
313
|
+
raise Ace::Overseer::Error, "herdr is unavailable: #{e.message}"
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# An existing herdr space by title, or nil — never creates. A
|
|
317
|
+
# titled entry without an id is REUSED (its title is the
|
|
318
|
+
# workspace reference), never treated as absent: creating a
|
|
319
|
+
# second space with the same title would duplicate the space.
|
|
320
|
+
def find_workspace(title)
|
|
321
|
+
found = @control_surface.list_workspaces.find { |entry| entry[:title] == title }
|
|
322
|
+
return nil unless found
|
|
323
|
+
|
|
324
|
+
found[:id] || found[:title]
|
|
325
|
+
rescue Ace::Herdr::ExecutorUnavailableError => e
|
|
326
|
+
raise Ace::Overseer::Error, "herdr is unavailable: #{e.message}"
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# The lab's own session through the SAME identity rules as a
|
|
330
|
+
# project session (label from the lab root, cwd at the lab
|
|
331
|
+
# root, live owner reused, dead owner replaced). The lab root
|
|
332
|
+
# is a coordination directory, not a repository, so a live
|
|
333
|
+
# owner is matched by its cwd directly — never through
|
|
334
|
+
# project-root resolution.
|
|
335
|
+
def boot_lab_session(workspace, plan, instructions_path, lab_root)
|
|
336
|
+
label = Atoms::StartContract.session_label(lab_root)
|
|
337
|
+
owner = owner_pane(label)
|
|
338
|
+
replaced = nil
|
|
339
|
+
if owner
|
|
340
|
+
owner_cwd = owner["cwd"].to_s
|
|
341
|
+
matching = !owner_cwd.empty? &&
|
|
342
|
+
begin
|
|
343
|
+
File.realpath(owner_cwd) == lab_root
|
|
344
|
+
rescue
|
|
345
|
+
false
|
|
346
|
+
end
|
|
347
|
+
unless matching
|
|
348
|
+
return {"status" => "blocked", "pane" => nil, "replaced_pane" => nil,
|
|
349
|
+
"message" => "owner pane '#{owner["id"]}' works in '#{owner_cwd}', which is " \
|
|
350
|
+
"not the lab root '#{lab_root}'; refusing to take over a foreign session"}
|
|
351
|
+
end
|
|
352
|
+
state = agent_state(owner["id"])
|
|
353
|
+
if state
|
|
354
|
+
warning = repair_reused_initialization(owner["id"], state)
|
|
355
|
+
return {"status" => "reused", "pane" => owner["id"], "replaced_pane" => nil,
|
|
356
|
+
"prompt_failed" => !warning.nil?, "message" => warning}
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
replaced = owner["id"]
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
pane = boot_session(workspace, label, lab_root, plan, instructions_path)
|
|
363
|
+
prompt_warning = first_prompt(pane)
|
|
364
|
+
{"status" => "started", "pane" => pane, "replaced_pane" => replaced,
|
|
365
|
+
"prompt_failed" => !prompt_warning.nil?, "message" => prompt_warning}
|
|
366
|
+
rescue Ace::Overseer::Error => e
|
|
367
|
+
{"status" => "blocked", "pane" => nil, "replaced_pane" => nil, "message" => e.message}
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# One project through the SAME operation as a standalone start:
|
|
371
|
+
# its own cascade (config_at), its own canonical root and session
|
|
372
|
+
# identity. A missing path or unreadable directory is THAT
|
|
373
|
+
# project's failure — the others continue.
|
|
374
|
+
def start_project(project, lab_root)
|
|
375
|
+
id = project["id"]
|
|
376
|
+
path = project["path"]
|
|
377
|
+
if path.nil? || path.to_s.strip.empty?
|
|
378
|
+
return {"id" => id, "path" => nil, "status" => "blocked",
|
|
379
|
+
"message" => "project '#{id}' declares no path in the lab topology"}
|
|
380
|
+
end
|
|
381
|
+
unless File.directory?(path)
|
|
382
|
+
return {"id" => id, "path" => path, "status" => "blocked",
|
|
383
|
+
"message" => "project '#{id}' directory '#{path}' does not exist"}
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
if id == LAB_SPACE_TITLE
|
|
387
|
+
return {"id" => id, "path" => path, "status" => "blocked",
|
|
388
|
+
"message" => "project id '#{id}' is reserved for the lab workspace"}
|
|
389
|
+
end
|
|
390
|
+
root = @root_resolver.resolve(cwd: path)
|
|
391
|
+
label = Atoms::StartContract.session_label(root)
|
|
392
|
+
owner = owner_pane(label)
|
|
393
|
+
workspace = if owner && agent_state(owner["id"])
|
|
394
|
+
reference = owner["workspace"] || owner["workspace_id"]
|
|
395
|
+
if reference.to_s.strip.empty?
|
|
396
|
+
raise Ace::Overseer::Error,
|
|
397
|
+
"live project owner '#{owner["id"]}' reports no workspace; " \
|
|
398
|
+
"refusing ambient fallback or a separate project workspace"
|
|
399
|
+
end
|
|
400
|
+
reference
|
|
401
|
+
else
|
|
402
|
+
find_or_create_workspace(id)
|
|
403
|
+
end
|
|
404
|
+
report = @project_runner.call(root: root, workspace: workspace, env: @env)
|
|
405
|
+
|
|
406
|
+
report.merge("id" => id, "path" => path)
|
|
407
|
+
rescue Ace::Overseer::Error, Ace::Herdr::ExecutorError => e
|
|
408
|
+
{"id" => id, "path" => path, "status" => "blocked", "message" => e.message}
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def base_report(plan, root, label, instructions_path)
|
|
412
|
+
{"status" => nil, "scope" => plan["scope"], "root" => root, "label" => label,
|
|
413
|
+
"workspace" => nil, "pane" => nil, "agent" => AGENT_NAME,
|
|
414
|
+
"profile" => plan["profile_name"], "instructions" =>
|
|
415
|
+
{"source" => plan["instructions"], "path" => instructions_path},
|
|
416
|
+
"replaced_pane" => nil, "prompt_failed" => false, "message" => nil}
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def blocked(message)
|
|
420
|
+
{"status" => "blocked", "scope" => nil, "root" => nil, "label" => nil,
|
|
421
|
+
"workspace" => nil, "pane" => nil, "agent" => AGENT_NAME, "profile" => nil,
|
|
422
|
+
"instructions" => nil, "replaced_pane" => nil, "prompt_failed" => false,
|
|
423
|
+
"message" => message}
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# wfi:// (and sibling protocol) sources resolve through the
|
|
427
|
+
# project's own ace-nav; an absolute path is used verbatim. The
|
|
428
|
+
# resolved file is the harness's standing-instruction source.
|
|
429
|
+
# The executable comes from the repo toolchain policy
|
|
430
|
+
# (Ace::Support::Cli::Toolchain): auto mode prefers the
|
|
431
|
+
# project's bin/ binstub and falls back to PATH; installed mode
|
|
432
|
+
# (ACE_TOOLCHAIN=installed) ALWAYS uses the installed gem
|
|
433
|
+
# launcher — a checkout inside the project is data, never the
|
|
434
|
+
# runtime source.
|
|
435
|
+
def resolve_instructions(source, root)
|
|
436
|
+
return source unless source.include?("://")
|
|
437
|
+
|
|
438
|
+
binary = begin
|
|
439
|
+
Ace::Support::Cli::Toolchain.executable("ace-nav", project_root: root,
|
|
440
|
+
prefer_project: !Process.uid.zero?)
|
|
441
|
+
rescue Ace::Support::Cli::Toolchain::Error => e
|
|
442
|
+
raise Ace::Overseer::Error,
|
|
443
|
+
"cannot resolve the ace-nav executable for the start instructions: #{e.message}"
|
|
444
|
+
end
|
|
445
|
+
result = @nav_runner.call([binary, "resolve", source], root)
|
|
446
|
+
path = (result["status"] == "ok") ? result["out"].to_s.lines.first.to_s.strip : nil
|
|
447
|
+
if path.nil? || path.empty?
|
|
448
|
+
raise Ace::Overseer::Error,
|
|
449
|
+
"start instructions '#{source}' do not resolve through ace-nav " \
|
|
450
|
+
"for project '#{File.basename(root)}'"
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
path
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# The herdr space a NEW session boots in: the explicit id seam,
|
|
457
|
+
# the lab-managed factory (each project's OWN space, titled by
|
|
458
|
+
# its session label), or the caller's ambient resolution.
|
|
459
|
+
def boot_workspace(label)
|
|
460
|
+
return @workspace if @workspace
|
|
461
|
+
return @workspace_factory.call(label) if @workspace_factory
|
|
462
|
+
|
|
463
|
+
resolve_workspace
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# Same workspace policy as ControlSurface: the caller's herdr
|
|
467
|
+
# workspace when invoked from a pane, otherwise the current
|
|
468
|
+
# pane projection; an environment without herdr is a blocked
|
|
469
|
+
# start, never a guessed session.
|
|
470
|
+
def resolve_workspace
|
|
471
|
+
env = ENV["HERDR_WORKSPACE_ID"].to_s
|
|
472
|
+
return env unless env.empty?
|
|
473
|
+
|
|
474
|
+
json = @executor.pane_current.parsed_json
|
|
475
|
+
workspace = dig_workspace(json)
|
|
476
|
+
unless workspace
|
|
477
|
+
raise Ace::Overseer::Error,
|
|
478
|
+
"cannot resolve the herdr workspace; run ace-overseer start " \
|
|
479
|
+
"inside a herdr pane or start herdr first"
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
workspace
|
|
483
|
+
rescue Ace::Herdr::ExecutorUnavailableError => e
|
|
484
|
+
raise Ace::Overseer::Error, "herdr is unavailable: #{e.message}"
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# The live pane owning this project's overseer label.
|
|
488
|
+
def owner_pane(label)
|
|
489
|
+
# boot_session names the TAB, not the terminal title. Pi and shell
|
|
490
|
+
# applications may change terminal titles at any time.
|
|
491
|
+
tabs = @control_surface.list_tabs.select { |entry| entry[:title] == label }
|
|
492
|
+
return nil if tabs.empty?
|
|
493
|
+
|
|
494
|
+
panes = @control_surface.list_panes
|
|
495
|
+
owners = tabs.map do |tab|
|
|
496
|
+
matches = panes.select { |pane| pane[:tab] == tab[:id] }
|
|
497
|
+
if tab[:id].to_s.empty? || matches.length != 1 || matches.first[:id].to_s.empty?
|
|
498
|
+
raise Ace::Overseer::Error,
|
|
499
|
+
"overseer tab '#{label}' has no unique owner pane; refusing an ambiguous session"
|
|
500
|
+
end
|
|
501
|
+
matches.first
|
|
502
|
+
end
|
|
503
|
+
if owners.length > 1
|
|
504
|
+
# A replaced dead owner may retain its tab. Only a single live
|
|
505
|
+
# agent identifies an unambiguous current owner; never pick the
|
|
506
|
+
# first of multiple live or dead candidates.
|
|
507
|
+
live = owners.select { |pane| !agent_state(pane[:id]).nil? }
|
|
508
|
+
unless live.length == 1
|
|
509
|
+
raise Ace::Overseer::Error,
|
|
510
|
+
"multiple overseer tabs '#{label}' have no unique live owner; refusing an ambiguous session"
|
|
511
|
+
end
|
|
512
|
+
owners = live
|
|
513
|
+
end
|
|
514
|
+
owners.first.transform_keys(&:to_s)
|
|
515
|
+
rescue Ace::Herdr::ExecutorUnavailableError => e
|
|
516
|
+
raise Ace::Overseer::Error, "herdr is unavailable: #{e.message}"
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# A live owner whose cwd belongs to a DIFFERENT canonical root
|
|
520
|
+
# is a conflict — it is never silently taken over. Returns the
|
|
521
|
+
# conflict message or nil when the owner matches the root.
|
|
522
|
+
def owner_conflict(owner, root)
|
|
523
|
+
owner_cwd = owner["cwd"].to_s
|
|
524
|
+
if owner_cwd.empty?
|
|
525
|
+
return "owner pane '#{owner["id"]}' reports no working directory; " \
|
|
526
|
+
"verify the session manually before starting"
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
owner_root = @root_resolver.resolve(cwd: owner_cwd)
|
|
530
|
+
if owner_root != root
|
|
531
|
+
return "owner pane '#{owner["id"]}' works in '#{owner_cwd}', which " \
|
|
532
|
+
"resolves to '#{owner_root}', not the project root '#{root}'; " \
|
|
533
|
+
"refusing to take over a foreign session"
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
nil
|
|
537
|
+
rescue Ace::Overseer::Error
|
|
538
|
+
"owner pane '#{owner["id"]}' works in '#{owner_cwd}', which does not " \
|
|
539
|
+
"resolve to the project root '#{root}'; refusing to take over a " \
|
|
540
|
+
"foreign session"
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# Live agent classification from the existing herdr session
|
|
544
|
+
# state: returns the agent_status string ("idle", "working",
|
|
545
|
+
# "blocked", "done", "unknown", or "" when the payload carries
|
|
546
|
+
# no recognizable status), or nil when NO agent lives in the
|
|
547
|
+
# pane (AgentNotFoundError). This is the "live process" half of
|
|
548
|
+
# the live-vs-completed-start distinction; delivery
|
|
549
|
+
# confirmation comes from first_prompt's activity gate.
|
|
550
|
+
def agent_state(pane)
|
|
551
|
+
json = @executor.agent_get(pane).parsed_json
|
|
552
|
+
return nil unless json.is_a?(Hash)
|
|
553
|
+
|
|
554
|
+
agent = json["agent"]
|
|
555
|
+
agent = json["result"]["agent"] if agent.nil? && json["result"].is_a?(Hash)
|
|
556
|
+
agent = json["result"] if agent.nil? && json["result"].is_a?(Hash)
|
|
557
|
+
return "" unless agent.is_a?(Hash)
|
|
558
|
+
|
|
559
|
+
agent["agent_status"].to_s
|
|
560
|
+
rescue Ace::Herdr::AgentNotFoundError
|
|
561
|
+
nil
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# Boots the session: one herdr tab labeled with the owner
|
|
565
|
+
# identity, cwd at the project root, then the harness agent with
|
|
566
|
+
# the configured profile and the packaged guide as standing
|
|
567
|
+
# instructions (passthrough args). Ends at the started agent —
|
|
568
|
+
# the first prompt is a SEPARATE step (first_prompt) so a
|
|
569
|
+
# delivery failure never reports a live agent as not started.
|
|
570
|
+
def boot_session(workspace, label, root, plan, instructions_path)
|
|
571
|
+
tab = @executor.tab_create(workspace_id: workspace, label: label, cwd: root)
|
|
572
|
+
.parsed_json
|
|
573
|
+
pane = dig_pane(tab)
|
|
574
|
+
unless pane
|
|
575
|
+
raise Ace::Overseer::Error,
|
|
576
|
+
"herdr created tab '#{label}' but reported no root pane"
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
@executor.pane_run(pane, Molecules::ProjectBundle.shell_setup(root, env: @env))
|
|
580
|
+
args = ["--model", plan["profile"]["model"]]
|
|
581
|
+
if plan["profile"]["thinking"]
|
|
582
|
+
args += ["--thinking", plan["profile"]["thinking"]]
|
|
583
|
+
end
|
|
584
|
+
args += ["--append-system-prompt", instructions_path]
|
|
585
|
+
|
|
586
|
+
@executor.agent_start(name: AGENT_NAME, kind: plan["profile"]["harness"],
|
|
587
|
+
pane: pane, timeout_ms: START_TIMEOUT_MS, args: args)
|
|
588
|
+
pane
|
|
589
|
+
rescue Ace::Herdr::CommandError => e
|
|
590
|
+
raise Ace::Overseer::Error, "herdr failed to start the overseer session: #{e.message}"
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# `agent start` success only means the interactive agent is
|
|
594
|
+
# ready for input; the first prompt begins the session's first
|
|
595
|
+
# turn (state read, /eyes arming). Delivery is CONFIRMED through
|
|
596
|
+
# herdr's activity gate (`agent prompt --wait`): success means
|
|
597
|
+
# herdr observed working/blocked activity, so the turn actually
|
|
598
|
+
# began — a stall, timeout or pre-send failure is an uncertain
|
|
599
|
+
# outcome, returned as a visible warning instead of a readiness
|
|
600
|
+
# claim. Never raised: the agent itself is live and idle.
|
|
601
|
+
def first_prompt(pane)
|
|
602
|
+
@executor.agent_prompt(pane: pane, text: FIRST_TURN_PROMPT,
|
|
603
|
+
wait: true, timeout_ms: FIRST_TURN_TIMEOUT_MS)
|
|
604
|
+
nil
|
|
605
|
+
rescue Ace::Herdr::ExecutorError => e
|
|
606
|
+
"the agent is started and idle, but its first turn could not be " \
|
|
607
|
+
"confirmed delivered (#{e.message}); re-run start to repair the " \
|
|
608
|
+
"missing initialization"
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# A REUSED live agent proves a process, not a completed start.
|
|
612
|
+
# Classification through the existing herdr session state (no
|
|
613
|
+
# parallel store): working/blocked = mid-conversation, the first
|
|
614
|
+
# turn is already underway — never interrupted; idle/done =
|
|
615
|
+
# ready for input — the first-turn prompt is (re-)delivered
|
|
616
|
+
# through the existing session with the activity gate, which
|
|
617
|
+
# repairs a missing initialization without restarting anything
|
|
618
|
+
# (a healthy agent simply reconciles once and settles); any
|
|
619
|
+
# other state is visibly unconfirmed, never a readiness claim.
|
|
620
|
+
def repair_reused_initialization(pane, state)
|
|
621
|
+
case state
|
|
622
|
+
when "working", "blocked"
|
|
623
|
+
nil
|
|
624
|
+
when "idle", "done"
|
|
625
|
+
first_prompt(pane)
|
|
626
|
+
else
|
|
627
|
+
"the reused agent state is '#{state}'; its first turn could not " \
|
|
628
|
+
"be verified — inspect the session before relying on it"
|
|
629
|
+
end
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def dig_pane(json)
|
|
633
|
+
return nil unless json.is_a?(Hash)
|
|
634
|
+
|
|
635
|
+
result = json["result"]
|
|
636
|
+
if result.is_a?(Hash)
|
|
637
|
+
root = result["root_pane"]
|
|
638
|
+
return root["pane_id"] || root["id"] if root.is_a?(Hash)
|
|
639
|
+
end
|
|
640
|
+
json["pane_id"] || json["pane"] || json["id"]
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# Exactly the canonical workspace keys ace-herdr itself digs
|
|
644
|
+
# (workspace_id/workspaceId, as in HerdrExecutor consumers like
|
|
645
|
+
# Dispatcher/ControlSurface) — no broader key guessing.
|
|
646
|
+
def dig_workspace(json)
|
|
647
|
+
result = json.is_a?(Hash) ? json["result"] : nil
|
|
648
|
+
source = result.is_a?(Hash) ? result : json
|
|
649
|
+
return nil unless source.is_a?(Hash)
|
|
650
|
+
|
|
651
|
+
source["workspace_id"] || source["workspaceId"]
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def run_nav(argv, cwd)
|
|
655
|
+
out, err, status = Open3.capture3(*argv, chdir: cwd)
|
|
656
|
+
{"status" => status.success? ? "ok" : (status.exitstatus || "signal"),
|
|
657
|
+
"out" => out, "err" => err}
|
|
658
|
+
rescue Errno::ENOENT
|
|
659
|
+
{"status" => "enoent", "out" => "", "err" => ""}
|
|
660
|
+
rescue SystemCallError, IOError => e
|
|
661
|
+
{"status" => "exec_failed", "out" => "", "err" => e.class.name}
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
end
|