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.
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Overseer
5
- VERSION = "0.26.1"
5
+ VERSION = "0.28.0"
6
6
  end
7
7
  end