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