ace-coworker 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/coworker/config.yml +6 -0
  3. data/.ace-defaults/coworker/roles/integrator.yml +15 -0
  4. data/.ace-defaults/coworker/roles/reviewer.yml +15 -0
  5. data/.ace-defaults/coworker/roles/spec.yml +14 -0
  6. data/.ace-defaults/coworker/roles/worker.yml +13 -0
  7. data/.ace-defaults/coworker/sequences/integrator.yml +20 -0
  8. data/.ace-defaults/coworker/sequences/reviewer.yml +20 -0
  9. data/.ace-defaults/coworker/sequences/spec.yml +16 -0
  10. data/.ace-defaults/coworker/sequences/worker.yml +21 -0
  11. data/.ace-defaults/nav/protocols/wfi-sources/ace-coworker.yml +19 -0
  12. data/CHANGELOG.md +255 -0
  13. data/README.md +98 -0
  14. data/Rakefile +13 -0
  15. data/docs/usage.md +564 -0
  16. data/exe/ace-coworker +16 -0
  17. data/handbook/workflow-instructions/coworker/integrator.wf.md +162 -0
  18. data/handbook/workflow-instructions/coworker/reviewer.wf.md +211 -0
  19. data/handbook/workflow-instructions/coworker/spec.wf.md +100 -0
  20. data/handbook/workflow-instructions/coworker/worker.wf.md +103 -0
  21. data/lib/ace/coworker/atoms/active_executor_rule.rb +71 -0
  22. data/lib/ace/coworker/atoms/brief_formatter.rb +33 -0
  23. data/lib/ace/coworker/atoms/config_schema.rb +121 -0
  24. data/lib/ace/coworker/atoms/integration_gate.rb +92 -0
  25. data/lib/ace/coworker/atoms/outcome_planner.rb +79 -0
  26. data/lib/ace/coworker/atoms/prepare_decision.rb +75 -0
  27. data/lib/ace/coworker/atoms/prompt_template.rb +28 -0
  28. data/lib/ace/coworker/atoms/report_record_formatter.rb +60 -0
  29. data/lib/ace/coworker/atoms/resume_brief_formatter.rb +84 -0
  30. data/lib/ace/coworker/atoms/resume_planner.rb +78 -0
  31. data/lib/ace/coworker/atoms/role_preset_schema.rb +91 -0
  32. data/lib/ace/coworker/atoms/round_record_schema.rb +62 -0
  33. data/lib/ace/coworker/atoms/round_rule.rb +105 -0
  34. data/lib/ace/coworker/cli/commands/commission.rb +100 -0
  35. data/lib/ace/coworker/cli/commands/gate.rb +91 -0
  36. data/lib/ace/coworker/cli/commands/prepare.rb +138 -0
  37. data/lib/ace/coworker/cli/commands/report.rb +79 -0
  38. data/lib/ace/coworker/cli/commands/resume.rb +57 -0
  39. data/lib/ace/coworker/cli/commands/roles.rb +60 -0
  40. data/lib/ace/coworker/cli/commands/rounds.rb +99 -0
  41. data/lib/ace/coworker/cli/commands/start.rb +39 -0
  42. data/lib/ace/coworker/cli/commands/status.rb +95 -0
  43. data/lib/ace/coworker/cli.rb +83 -0
  44. data/lib/ace/coworker/errors.rb +10 -0
  45. data/lib/ace/coworker/models/assignment_snapshot.rb +65 -0
  46. data/lib/ace/coworker/models/brief.rb +38 -0
  47. data/lib/ace/coworker/models/config.rb +54 -0
  48. data/lib/ace/coworker/models/outcome.rb +39 -0
  49. data/lib/ace/coworker/models/preparation.rb +67 -0
  50. data/lib/ace/coworker/models/resume_plan.rb +47 -0
  51. data/lib/ace/coworker/molecules/assignment_metadata.rb +57 -0
  52. data/lib/ace/coworker/molecules/assignment_probe.rb +57 -0
  53. data/lib/ace/coworker/molecules/config_loader.rb +119 -0
  54. data/lib/ace/coworker/molecules/git_state_probe.rb +63 -0
  55. data/lib/ace/coworker/molecules/hitl_reporter.rb +145 -0
  56. data/lib/ace/coworker/molecules/instruction_resolver.rb +82 -0
  57. data/lib/ace/coworker/molecules/project_root.rb +26 -0
  58. data/lib/ace/coworker/molecules/report_state_store.rb +53 -0
  59. data/lib/ace/coworker/molecules/review_round_store.rb +115 -0
  60. data/lib/ace/coworker/molecules/role_preset_loader.rb +95 -0
  61. data/lib/ace/coworker/molecules/role_sequence_composer.rb +245 -0
  62. data/lib/ace/coworker/molecules/step_evidence_reader.rb +60 -0
  63. data/lib/ace/coworker/molecules/step_report_reader.rb +39 -0
  64. data/lib/ace/coworker/molecules/task_probe.rb +40 -0
  65. data/lib/ace/coworker/molecules/task_record_writer.rb +44 -0
  66. data/lib/ace/coworker/molecules/worktree_executor_probe.rb +139 -0
  67. data/lib/ace/coworker/molecules/worktree_provision_probe.rb +137 -0
  68. data/lib/ace/coworker/organisms/commission_orchestrator.rb +148 -0
  69. data/lib/ace/coworker/organisms/prepare_orchestrator.rb +374 -0
  70. data/lib/ace/coworker/organisms/report_orchestrator.rb +192 -0
  71. data/lib/ace/coworker/organisms/resume_orchestrator.rb +91 -0
  72. data/lib/ace/coworker/organisms/start_config_writer.rb +127 -0
  73. data/lib/ace/coworker/organisms/start_orchestrator.rb +45 -0
  74. data/lib/ace/coworker/organisms/status_orchestrator.rb +62 -0
  75. data/lib/ace/coworker/version.rb +7 -0
  76. data/lib/ace/coworker.rb +86 -0
  77. metadata +319 -0
@@ -0,0 +1,374 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../molecules/project_root"
4
+ require_relative "../molecules/task_probe"
5
+ require_relative "../molecules/config_loader"
6
+ require_relative "../molecules/worktree_provision_probe"
7
+ require_relative "../molecules/worktree_executor_probe"
8
+ require_relative "../molecules/role_sequence_composer"
9
+ require_relative "../molecules/hitl_reporter"
10
+ require_relative "../atoms/active_executor_rule"
11
+
12
+ module Ace
13
+ module Coworker
14
+ module Organisms
15
+ # Orchestrates `ace-coworker prepare`: the planner accepts a
16
+ # preparation commission (task + expected role sequence), finds or
17
+ # prepares exactly one proper worktree for that task, and composes
18
+ # the role-sequence assignments for the WHOLE sequence in one go
19
+ # (L4.2), so roles hand off sequentially after start without the
20
+ # overseer re-commissioning preparation per step. Re-preparation
21
+ # reuses the existing worktree and the already-composed assignments
22
+ # with zero redundant writes; an active executor or a mismatched
23
+ # worktree escalates to the overseer via ace-hitl instead of
24
+ # overwriting work. dry_run: performs the full resolution and
25
+ # conflict detection with zero effects (assignments are planned,
26
+ # not written, because the worktree does not exist yet). After a successful preparation the
27
+ # planner sends a READINESS report to the overseer via ace-hitl
28
+ # (L4.4: what was prepared — worktree, assignments, start configs
29
+ # — plus the exact first step; a report expects no reply); role
30
+ # errors and a missing overseer address are GAPS that never pass
31
+ # as ready and reach the overseer through the same conflict
32
+ # escalation path (question = a decision is needed). dry_run
33
+ # sends nothing.
34
+ class PrepareOrchestrator
35
+ DISPOSITIONS = {
36
+ created: "created", recovered: "recovered", reuse: "reused",
37
+ would_create: "would_create", would_recover: "would_recover"
38
+ }.freeze
39
+
40
+ def initialize(project_root: nil, task_probe: nil, provision_probe: nil,
41
+ executor_probe: nil, hitl_reporter: nil, config_loader: nil, config: nil,
42
+ sequence_composer: nil, start_config_writer: nil)
43
+ @config = config || Ace::Coworker.config
44
+ @project_root = project_root || Molecules::ProjectRoot.new.resolve
45
+ @task_probe = task_probe || Molecules::TaskProbe.new(project_root: @project_root)
46
+ @provision_probe = provision_probe || Molecules::WorktreeProvisionProbe.new(project_root: @project_root)
47
+ @executor_probe = executor_probe || Molecules::WorktreeExecutorProbe.new(project_root: @project_root)
48
+ @hitl_reporter = hitl_reporter || Molecules::HitlReporter.new(project_root: @project_root)
49
+ @config_loader = config_loader ||
50
+ Molecules::ConfigLoader.new(project_root: @project_root, defaults: @config)
51
+ @sequence_composer = sequence_composer
52
+ @start_config_writer = start_config_writer
53
+ end
54
+
55
+ # @param task_ref [String] task reference
56
+ # @param roles [Array<String>] expected role sequence (already
57
+ # normalized: non-blank, deduplicated, order preserved)
58
+ # @param to [String, nil] escalation recipient override
59
+ # @param config_path [String, nil] explicit planner config path
60
+ # @param dry_run [Boolean] suppress all effects
61
+ # @return [Models::Preparation]
62
+ # @raise [Error] for an unresolvable task or an operational failure
63
+ def call(task_ref:, roles:, to: nil, config_path: nil, dry_run: false)
64
+ task = @task_probe.resolve(task_ref)
65
+ raise Error, "task '#{task_ref}' not found in this worktree" if task.nil?
66
+
67
+ task_path = task.respond_to?(:path) && task.path ? task.path : task.id.to_s
68
+ task_id = task.id.to_s
69
+
70
+ decision = @provision_probe.ensure_prepared(
71
+ task_ref: task_ref, expected_task_id: task_id, dry_run: dry_run)
72
+ return conflict_result(task: task_id, task_path: task_path, roles: roles,
73
+ kind: "mismatched_worktree", detail: decision[:reason],
74
+ worktree_path: decision[:worktree_path], to: to,
75
+ config_path: config_path, dry_run: dry_run) if decision[:disposition] == :conflict_mismatch
76
+ if decision[:disposition] == :error
77
+ raise Error, "cannot prepare worktree for task '#{task_ref}': #{decision[:reason]}"
78
+ end
79
+
80
+ disposition = DISPOSITIONS.fetch(decision[:disposition])
81
+ return success(task: task_id, task_path: task_path, roles: roles,
82
+ disposition: disposition, worktree_path: decision[:worktree_path],
83
+ branch: decision[:branch], assignments: [], dry_run: dry_run) if would_disposition?(disposition)
84
+
85
+ executor = @executor_probe.probe(decision[:worktree_path])
86
+ rule = Atoms::ActiveExecutorRule.evaluate(
87
+ config: executor[:config], assignments: executor[:assignments])
88
+ if rule[:active]
89
+ return conflict_result(task: task_id, task_path: task_path, roles: roles,
90
+ kind: "active_executor", detail: active_detail(rule),
91
+ worktree_path: decision[:worktree_path], to: to,
92
+ config_path: config_path, dry_run: dry_run,
93
+ assignments: executor[:assignments], active_executor: rule[:evidence])
94
+ end
95
+
96
+ # Role errors are GAPS (L4.4): a sequence that cannot be
97
+ # composed is never reported as ready; the worktree stays
98
+ # prepared and the gap escalates to the overseer (question —
99
+ # a decision is needed), never a silent bare error.
100
+ sequence = begin
101
+ compose_sequence(task_id, roles, decision[:worktree_path], dry_run)
102
+ rescue Error => e
103
+ return conflict_result(task: task_id, task_path: task_path, roles: roles,
104
+ kind: "role_sequence", detail: e.message,
105
+ worktree_path: decision[:worktree_path], to: to,
106
+ config_path: config_path, dry_run: dry_run)
107
+ end
108
+
109
+ # Missing instruction context is a GAP too (never-ready-on-
110
+ # missing-context): the sequence composes, but a leg whose
111
+ # instruction source does not resolve would hand the executor a
112
+ # start command that fails immediately — reported with the
113
+ # concrete reason, never as a ready order.
114
+ gaps = instruction_gaps(sequence, decision[:worktree_path])
115
+ unless gaps.empty?
116
+ return conflict_result(task: task_id, task_path: task_path, roles: roles,
117
+ kind: "role_instructions",
118
+ detail: instruction_gap_detail(worktree_path: decision[:worktree_path],
119
+ branch: decision[:branch], disposition: disposition, gaps: gaps),
120
+ worktree_path: decision[:worktree_path], to: to,
121
+ config_path: config_path, dry_run: dry_run)
122
+ end
123
+
124
+ # A missing overseer address is a GAP too (L4.4): the worktree
125
+ # and the assignments ARE composed, but no start config is
126
+ # written and the commission is NOT ready — reported with the
127
+ # concrete reason, never as a ready order. (The lenient
128
+ # config-load note, if any, is appended once by conflict_result.)
129
+ leg_overseer, = escalation_recipient(to: to, config_path: config_path)
130
+ if leg_overseer.to_s.strip.empty?
131
+ return conflict_result(task: task_id, task_path: task_path, roles: roles,
132
+ kind: "missing_overseer",
133
+ detail: missing_overseer_detail(worktree_path: decision[:worktree_path],
134
+ branch: decision[:branch], disposition: disposition,
135
+ sequence: sequence),
136
+ worktree_path: decision[:worktree_path], to: to,
137
+ config_path: config_path, dry_run: dry_run)
138
+ end
139
+
140
+ start_configs = write_start_configs(task: task_id, sequence: sequence,
141
+ worktree_path: decision[:worktree_path], overseer: leg_overseer,
142
+ dry_run: dry_run)
143
+ report = dry_run ? nil : readiness_report(task: task_id, to: leg_overseer,
144
+ worktree_path: decision[:worktree_path], branch: decision[:branch],
145
+ disposition: disposition, assignments: sequence, start_configs: start_configs)
146
+ success(task: task_id, task_path: task_path, roles: roles,
147
+ disposition: disposition, worktree_path: decision[:worktree_path],
148
+ branch: decision[:branch], assignments: sequence,
149
+ start_configs: start_configs,
150
+ worktree_assignments: executor[:assignments],
151
+ report: report, dry_run: dry_run)
152
+ end
153
+
154
+ private
155
+
156
+ # Composes the whole role sequence in one go via ace-assign's plan
157
+ # storage in the prepared worktree. A missing role process is
158
+ # reported (Error) — never substituted with another role's.
159
+ # dry_run resolves the same sequence with zero writes (planned
160
+ # legs carry no assignment id).
161
+ def compose_sequence(task_id, roles, worktree_path, dry_run)
162
+ composer = @sequence_composer || Molecules::RoleSequenceComposer.new(project_root: worktree_path)
163
+ composer.call(task: task_id, roles: roles, dry_run: dry_run)
164
+ end
165
+
166
+ # Never-ready-on-missing-context (L4.4 / 8xg.t.sbz.3.2-.3.3): the
167
+ # instruction source of EVERY leg must resolve through the existing
168
+ # loader/resolver in the prepared worktree before readiness is
169
+ # declared — a start command that fails immediately is a gap,
170
+ # never a ready commission. File-path sources resolve in-process;
171
+ # protocol URIs (wfi://, prompt://, tmpl://) resolve through the
172
+ # SAME ace-nav resolver start uses, in the prepared execution
173
+ # context. A resolver that runs and fails is a gap; a missing
174
+ # ace-nav CLI (NavUnavailableError) stays a non-blocking unknown,
175
+ # consistent with lab verify's posture toward an absent checker.
176
+ # Unknown roles surface as the role_sequence gap during
177
+ # composition and are skipped here. Read-only: this check itself
178
+ # never writes.
179
+ def instruction_gaps(sequence, worktree_path)
180
+ loader = Molecules::RolePresetLoader.new(project_root: worktree_path)
181
+ resolver = Molecules::InstructionResolver.new(project_root: worktree_path)
182
+ gaps = []
183
+ sequence.each do |entry|
184
+ preset = begin
185
+ loader.fetch(entry["role"])
186
+ rescue Error
187
+ nil
188
+ end
189
+ next if preset.nil?
190
+
191
+ begin
192
+ resolver.resolve!(preset["instructions"].to_s)
193
+ rescue Molecules::InstructionResolver::NavUnavailableError
194
+ # No resolver to ask: unknown, not failed.
195
+ next
196
+ rescue Error => e
197
+ gaps << "role '#{entry['role']}': #{e.message}"
198
+ end
199
+ end
200
+ gaps
201
+ end
202
+
203
+ def instruction_gap_detail(worktree_path:, branch:, disposition:, gaps:)
204
+ detail = +"instruction source(s) do not resolve in the worktree; " \
205
+ "the commission is NOT ready - no leg config is written"
206
+ gaps.each { |gap| detail << "\n#{gap}" }
207
+ detail << "\nprepared: worktree #{worktree_path} (branch #{branch}, #{disposition})"
208
+ detail << "\ndecision needed: provide the missing instruction context " \
209
+ "(or a role preset pointing at an existing source) and re-run " \
210
+ "prepare (idempotent)"
211
+ detail
212
+ end
213
+
214
+ # The readiness report body (L4.4): what was prepared — worktree,
215
+ # per-role assignments, per-leg start configs — plus the exact
216
+ # first step, so the overseer runs the first leg without
217
+ # composing any ace-assign detail itself. A REPORT (no reply
218
+ # expected); delivery is attempted but a delivery failure never
219
+ # undoes the preparation — the event id stays in the result for
220
+ # an `ace-hitl send` retry.
221
+ def readiness_report(task:, to:, worktree_path:, branch:, disposition:,
222
+ assignments:, start_configs:)
223
+ event = @hitl_reporter.create_report(
224
+ title: "Preparation ready - #{task}/#{worktree_dir(worktree_path)}",
225
+ body_lines: readiness_lines(worktree_path: worktree_path, branch: branch,
226
+ disposition: disposition, assignments: assignments,
227
+ start_configs: start_configs),
228
+ task: task, result: "success", to: to)
229
+ if event.is_a?(Molecules::HitlReporter::Failure)
230
+ return {"kind" => "readiness", "error" => event.message}
231
+ end
232
+
233
+ {"kind" => "readiness", "event" => event,
234
+ "delivered" => @hitl_reporter.send_report(event) == true}
235
+ rescue Error => e
236
+ {"kind" => "readiness", "error" => e.message}
237
+ end
238
+
239
+ def readiness_lines(worktree_path:, branch:, disposition:, assignments:, start_configs:)
240
+ lines = ["worktree: #{worktree_path} (branch #{branch}, #{disposition})"]
241
+ assignments.each do |entry|
242
+ lines << "sequence #{entry['position']}/#{entry['of']} #{entry['role']}: " \
243
+ "#{entry['assignment']} (#{entry['disposition']}, #{entry['steps']} steps)"
244
+ end
245
+ start_configs.each do |entry|
246
+ lines << "start config #{entry['role']}: #{entry['path']} (#{entry['disposition']})"
247
+ end
248
+ first = start_configs.first
249
+ if first
250
+ lines << "first leg: ace-coworker start --config #{first['path']} in the worktree"
251
+ end
252
+ lines
253
+ end
254
+
255
+ def missing_overseer_detail(worktree_path:, branch:, disposition:, sequence:)
256
+ detail = +"no overseer address for the start configs (--to or coworker config); " \
257
+ "the commission is NOT ready - no leg config is written"
258
+ detail << "\nprepared: worktree #{worktree_path} (branch #{branch}, #{disposition})"
259
+ sequence.each do |entry|
260
+ detail << "\nprepared: assignment #{entry['role']} -> #{entry['assignment']} " \
261
+ "(#{entry['disposition']})"
262
+ end
263
+ detail << "\ndecision needed: supply the overseer address and re-run prepare " \
264
+ "with --to <address> to write the leg configs (idempotent)"
265
+ detail
266
+ end
267
+
268
+ # Writes one start config per sequence leg into the prepared
269
+ # worktree (.ace-local/coworker/prepare/<role>.yml) via the
270
+ # commission primitive; dry-run resolves them without writes.
271
+ def write_start_configs(task:, sequence:, worktree_path:, overseer:, dry_run:)
272
+ writer = @start_config_writer || Organisms::StartConfigWriter.new(worktree_path: worktree_path)
273
+ writer.call(task: task, sequence: sequence, overseer: overseer, dry_run: dry_run)
274
+ end
275
+
276
+ def would_disposition?(disposition)
277
+ %w[would_create would_recover].include?(disposition)
278
+ end
279
+
280
+ def active_detail(rule)
281
+ evidence = rule[:evidence]
282
+ if evidence["source"] == "config"
283
+ "a commissioned executor holds the worktree: assignment #{evidence['assignment']} " \
284
+ "(state #{evidence['state']})"
285
+ else
286
+ "an uncommissioned assignment is in flight: #{evidence['assignment']} " \
287
+ "(state #{evidence['state']})"
288
+ end
289
+ end
290
+
291
+ def conflict_result(task:, task_path:, roles:, kind:, detail:, worktree_path:,
292
+ to:, config_path:, dry_run:, assignments: [], active_executor: nil)
293
+ hitl_event = nil
294
+ delivered = nil
295
+ recipient, recipient_note = escalation_recipient(to: to, config_path: config_path)
296
+ detail = detail.dup
297
+
298
+ unless dry_run
299
+ if recipient.to_s.strip.empty?
300
+ detail << "\ncannot report conflict: no overseer address (--to or coworker config)"
301
+ detail << "\n#{recipient_note}" if recipient_note
302
+ else
303
+ begin
304
+ result = @hitl_reporter.create_conflict(
305
+ title: "Preparation conflict - #{task}/#{worktree_dir(worktree_path)}",
306
+ question_lines: conflict_question(kind: kind, detail: detail, worktree_path: worktree_path),
307
+ task: task, to: recipient)
308
+ if result.is_a?(Molecules::HitlReporter::Failure)
309
+ detail << "\ncannot report conflict: #{result.message}"
310
+ else
311
+ hitl_event = result
312
+ delivered = @hitl_reporter.send_report(hitl_event) == true
313
+ end
314
+ rescue Error => e
315
+ # e.g. the ace-hitl binary is missing: keep the structured
316
+ # conflict visible instead of replacing it with a bare error.
317
+ detail << "\ncannot report conflict: #{e.message}"
318
+ end
319
+ end
320
+ end
321
+
322
+ Models::Preparation.new(task: task, task_path: task_path, roles: roles,
323
+ worktree: nil, worktree_assignments: assignments,
324
+ active_executor: active_executor,
325
+ conflict: {"kind" => kind, "detail" => detail,
326
+ "hitl_event" => hitl_event, "delivered" => delivered},
327
+ dry_run: dry_run)
328
+ end
329
+
330
+ def conflict_question(kind:, detail:, worktree_path:)
331
+ [
332
+ "kind: #{kind}",
333
+ detail,
334
+ "worktree preserved at #{worktree_path} - decide whether to resume, " \
335
+ "reassign, or remove it before re-preparing"
336
+ ]
337
+ end
338
+
339
+ # Resolves the escalation recipient: the --to override, else the
340
+ # coworker config's overseer. A config that fails full validation is
341
+ # still consulted leniently (field-only read, mirroring
342
+ # WorktreeExecutorProbe#read_config) so a partial planner-side config
343
+ # can name its overseer; a config that supplies no overseer anywhere
344
+ # yields a nil address plus the load error for the conflict detail
345
+ # instead of a silent "no address".
346
+ #
347
+ # @return [Array<String, String, nil>] recipient address and an
348
+ # optional diagnostic note
349
+ def escalation_recipient(to:, config_path:)
350
+ return [to, nil] unless to.to_s.strip.empty?
351
+
352
+ cfg = @config_loader.load(path: config_path)
353
+ [cfg&.overseer, nil]
354
+ rescue Error => e
355
+ overseer = @config_loader.overseer(path: config_path).to_s
356
+ overseer.empty? ? [nil, "coworker config load failed: #{e.message}"] : [overseer, nil]
357
+ end
358
+
359
+ def worktree_dir(worktree_path)
360
+ worktree_path ? File.basename(worktree_path) : "?"
361
+ end
362
+
363
+ def success(task:, task_path:, roles:, disposition:, worktree_path:,
364
+ branch:, assignments: [], start_configs: [], worktree_assignments: [],
365
+ report: nil, dry_run:)
366
+ Models::Preparation.new(task: task, task_path: task_path, roles: roles,
367
+ worktree: {"path" => worktree_path, "branch" => branch, "disposition" => disposition},
368
+ worktree_assignments: worktree_assignments, assignments: assignments,
369
+ start_configs: start_configs, report: report, dry_run: dry_run)
370
+ end
371
+ end
372
+ end
373
+ end
374
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "pathname"
5
+
6
+ require_relative "../molecules/config_loader"
7
+ require_relative "../molecules/task_probe"
8
+ require_relative "../molecules/assignment_metadata"
9
+ require_relative "../molecules/assignment_probe"
10
+ require_relative "../molecules/step_evidence_reader"
11
+ require_relative "../molecules/hitl_reporter"
12
+ require_relative "../molecules/task_record_writer"
13
+ require_relative "../molecules/report_state_store"
14
+ require_relative "../atoms/outcome_planner"
15
+ require_relative "../atoms/report_record_formatter"
16
+
17
+ module Ace
18
+ module Coworker
19
+ module Organisms
20
+ # Orchestrates `ace-coworker report`: derive the outcome (ace-assign
21
+ # is the only truth), write the durable record into the task folder
22
+ # FIRST, keep helper state under .ace-local/coworker/, then report
23
+ # through the ace-hitl CLI. Outcome and delivery are strictly
24
+ # separated: a completed assignment with an undelivered report keeps
25
+ # the work success plus an explicit delivery error; an execution
26
+ # error records its cause and the unfinished step — never a false
27
+ # work done.
28
+ class ReportOrchestrator
29
+ ReportResult = Struct.new(:outcome, :record_path, :event_id, :delivery, :message,
30
+ :exit_code, :created_event, keyword_init: true)
31
+
32
+ def initialize(project_root: nil, config_loader: nil, task_probe: nil,
33
+ assignment_probe: nil, evidence_reader: nil, reporter: nil,
34
+ record_writer: nil, state_store: nil, config: nil)
35
+ @config = config || Ace::Coworker.config
36
+ @project_root = project_root || Molecules::ProjectRoot.new.resolve
37
+ @config_loader = config_loader || Molecules::ConfigLoader.new(project_root: @project_root, defaults: @config)
38
+ @task_probe = task_probe || Molecules::TaskProbe.new(project_root: @project_root)
39
+ @assignment_probe = assignment_probe || Molecules::AssignmentProbe.new(project_root: @project_root)
40
+ @evidence_reader = evidence_reader || Molecules::StepEvidenceReader.new(project_root: @project_root)
41
+ @reporter = reporter || Molecules::HitlReporter.new(project_root: @project_root)
42
+ @record_writer_factory = record_writer
43
+ @state_store_factory = state_store
44
+ end
45
+
46
+ # @param config_path [String, nil]
47
+ # @param assignment [String, nil] override (default: config assignment)
48
+ # @param result [String, nil] explicit --result
49
+ # @param message [String, nil] explicit cause
50
+ # @param step [String, nil] explicit --step
51
+ # @param no_send [Boolean]
52
+ # @param dry_run [Boolean]
53
+ # @return [ReportResult]
54
+ def call(config_path: nil, assignment: nil, result: nil, message: nil,
55
+ step: nil, no_send: false, dry_run: false)
56
+ cfg = @config_loader.load(path: config_path)
57
+ assignment_id = assignment.to_s.strip.empty? ? cfg.assignment : assignment.to_s.strip
58
+ # The selected assignment (CLI override or config) is the single
59
+ # identity threaded through record, evidence, and HITL message.
60
+ cfg = cfg.with(assignment: assignment_id)
61
+ @task_probe.resolve!(cfg.task)
62
+ @assignment_metadata ||= Molecules::AssignmentMetadata.new(project_root: @project_root)
63
+ @assignment_metadata.verify_task_match!(assignment_id, cfg.task)
64
+ snapshot = @assignment_probe.snapshot(assignment_id)
65
+
66
+ step_evidence = @evidence_reader.read(assignment_id, failed_step_number(snapshot))
67
+ outcome = Atoms::OutcomePlanner.plan(snapshot,
68
+ explicit_result: result, message: message,
69
+ step_evidence: step_evidence, step_override: step)
70
+
71
+ task_path = @task_probe.resolve!(cfg.task)
72
+ record_writer = @record_writer_factory || Molecules::TaskRecordWriter.new(task_path: task_path)
73
+ state_store = @state_store_factory || Molecules::ReportStateStore.new(project_root: @project_root)
74
+
75
+ return dry_run_result(outcome, cfg) if dry_run
76
+
77
+ relative_record = placeholder_record_path(cfg, assignment_id, task_path)
78
+ record_path = record_writer.write(assignment_id,
79
+ Atoms::ReportRecordFormatter.format(outcome, cfg, record_path: relative_record))
80
+
81
+ # Rerun rules: same result with an existing event => send-only
82
+ # redelivery (retrying delivery never retries the assignment).
83
+ state = state_store.read(assignment_id) || {}
84
+ event_id = nil
85
+ created_event = false
86
+ if !report_via_hitl?(cfg, outcome)
87
+ # errors.on_failure=stop: never touch HITL, not even a resend.
88
+ elsif state["result"] == outcome.result && state["event_id"] && @reporter.event_exists?(state["event_id"])
89
+ event_id = state["event_id"]
90
+ elsif report_via_hitl?(cfg, outcome)
91
+ event_id = @reporter.create(outcome, cfg)
92
+ if event_id.is_a?(Molecules::HitlReporter::Failure)
93
+ return failure_result(outcome, cfg, record_path, nil,
94
+ "outcome recorded: #{outcome.result} at #{record_path}; HITL create failed: #{event_id.message}")
95
+ end
96
+ created_event = true
97
+ # Bind the event in helper state BEFORE the send effect: an
98
+ # interrupt/crash during send must not orphan a possibly
99
+ # delivered message; a rerun reuses this ID instead of
100
+ # creating a duplicate.
101
+ state_store.write(assignment_id, {
102
+ "result" => outcome.result, "event_id" => event_id,
103
+ "record" => record_path, "delivery" => nil,
104
+ "updated_at" => Time.now.utc.iso8601
105
+ })
106
+ end
107
+
108
+ delivery = nil
109
+ if event_id && !no_send
110
+ send_result = @reporter.send_report(event_id)
111
+ if send_result == true
112
+ delivery = "delivered (event #{event_id})"
113
+ else
114
+ record_writer.write(assignment_id,
115
+ Atoms::ReportRecordFormatter.format(outcome, cfg,
116
+ event_id: event_id, delivery: "failed: #{send_result.message}",
117
+ record_path: relative_record))
118
+ return failure_result(outcome, cfg, record_path, event_id,
119
+ "outcome recorded: #{outcome.result} at #{record_path}; delivery failed: #{send_result.message}")
120
+ end
121
+ end
122
+
123
+ state_store.write(assignment_id, {
124
+ "result" => outcome.result, "event_id" => event_id,
125
+ "record" => record_path, "delivery" => delivery,
126
+ "updated_at" => Time.now.utc.iso8601
127
+ })
128
+ rewrite_record_with_delivery(record_writer, assignment_id, outcome, cfg, event_id, delivery, relative_record)
129
+
130
+ finish_result(outcome, cfg, record_path, event_id, delivery, created_event)
131
+ end
132
+
133
+ private
134
+
135
+ def report_via_hitl?(cfg, outcome)
136
+ return true if outcome.success?
137
+
138
+ # errors.on_failure=stop: record locally, skip HITL entirely.
139
+ cfg.on_failure != "stop"
140
+ end
141
+
142
+ def failed_step_number(snapshot)
143
+ failed = snapshot.steps.reverse.find { |s| s.status == "failed" } ||
144
+ snapshot.steps.find { |s| s.status == "active" } ||
145
+ snapshot.steps.find { |s| s.status == "pending" }
146
+ failed&.number
147
+ end
148
+
149
+ # The record evidence line must point at the REAL file: derive it
150
+ # from the resolved task path relative to the project root (task
151
+ # folders are <ref>-<slug>, never the bare ref).
152
+ def placeholder_record_path(cfg, assignment_id, task_path = nil)
153
+ task_path ||= @task_probe.resolve!(cfg.task)
154
+ relative = Pathname.new(task_path).relative_path_from(Pathname.new(@project_root)).to_s
155
+ File.join(relative, "coworker", "#{assignment_id}.md")
156
+ end
157
+
158
+ def rewrite_record_with_delivery(record_writer, assignment_id, outcome, cfg, event_id, delivery, relative_record)
159
+ record_writer.write(assignment_id,
160
+ Atoms::ReportRecordFormatter.format(outcome, cfg,
161
+ event_id: event_id, delivery: delivery || "not attempted",
162
+ record_path: relative_record))
163
+ end
164
+
165
+ def dry_run_result(outcome, cfg)
166
+ ReportResult.new(outcome: outcome, record_path: nil, event_id: nil,
167
+ delivery: nil, created_event: false,
168
+ message: "dry-run: outcome #{outcome.result} (nothing written)", exit_code: 0)
169
+ end
170
+
171
+ def failure_result(outcome, cfg, record_path, event_id, message)
172
+ ReportResult.new(outcome: outcome, record_path: record_path, event_id: event_id,
173
+ delivery: nil, created_event: !event_id.nil?, message: message, exit_code: 1)
174
+ end
175
+
176
+ def finish_result(outcome, cfg, record_path, event_id, delivery, created_event)
177
+ if outcome.success?
178
+ message = "outcome recorded: success at #{record_path}"
179
+ message += delivery ? "; #{delivery}" : "; delivery not attempted"
180
+ ReportResult.new(outcome: outcome, record_path: record_path, event_id: event_id,
181
+ delivery: delivery, message: message, created_event: created_event, exit_code: 0)
182
+ else
183
+ message = "outcome recorded: error at #{record_path}"
184
+ message += "; #{delivery}" if delivery
185
+ ReportResult.new(outcome: outcome, record_path: record_path, event_id: event_id,
186
+ delivery: delivery, message: message, created_event: created_event, exit_code: 1)
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../molecules/config_loader"
4
+ require_relative "../molecules/task_probe"
5
+ require_relative "../molecules/assignment_metadata"
6
+ require_relative "../molecules/assignment_probe"
7
+ require_relative "../molecules/step_evidence_reader"
8
+ require_relative "../molecules/step_report_reader"
9
+ require_relative "../molecules/git_state_probe"
10
+ require_relative "../molecules/report_state_store"
11
+ require_relative "../atoms/resume_planner"
12
+ require_relative "report_orchestrator"
13
+
14
+ module Ace
15
+ module Coworker
16
+ module Organisms
17
+ # Orchestrates `ace-coworker resume`: after an interruption, read the
18
+ # existing assignment, task, and the REAL state of the same worktree,
19
+ # and return to the remaining work. Same task/assignment/worktree —
20
+ # never a new one; local changes are never reset; resume itself never
21
+ # drives assignment steps. When the assignment is complete and only
22
+ # delivery is missing, delegates to ReportOrchestrator (same event ID
23
+ # resend). No journal/checkpoint store (ADR-034).
24
+ class ResumeOrchestrator
25
+ ResumeResult = Struct.new(:plan, :report_result, :message, :exit_code, keyword_init: true)
26
+
27
+ def initialize(project_root: nil, config_loader: nil, task_probe: nil,
28
+ assignment_probe: nil, evidence_reader: nil, report_reader: nil,
29
+ git_probe: nil, state_store: nil, report_orchestrator: nil, config: nil)
30
+ @config = config || Ace::Coworker.config
31
+ @project_root = project_root || Molecules::ProjectRoot.new.resolve
32
+ @config_loader = config_loader || Molecules::ConfigLoader.new(project_root: @project_root, defaults: @config)
33
+ @task_probe = task_probe || Molecules::TaskProbe.new(project_root: @project_root)
34
+ @assignment_metadata = Molecules::AssignmentMetadata.new(project_root: @project_root)
35
+ @assignment_probe = assignment_probe || Molecules::AssignmentProbe.new(project_root: @project_root)
36
+ @evidence_reader = evidence_reader || Molecules::StepEvidenceReader.new(project_root: @project_root)
37
+ @report_reader = report_reader || Molecules::StepReportReader.new(project_root: @project_root)
38
+ @git_probe = git_probe || Molecules::GitStateProbe.new(project_root: @project_root)
39
+ @state_store_factory = state_store
40
+ @report_orchestrator = report_orchestrator
41
+ end
42
+
43
+ # @return [ResumeResult]
44
+ def call(config_path: nil, assignment: nil, dry_run: false)
45
+ cfg = @config_loader.load(path: config_path)
46
+ assignment_id = assignment.to_s.strip.empty? ? cfg.assignment : assignment.to_s.strip
47
+ @task_probe.resolve!(cfg.task)
48
+ @assignment_metadata.verify_task_match!(assignment_id, cfg.task)
49
+ snapshot = @assignment_probe.snapshot(assignment_id)
50
+
51
+ state_store = @state_store_factory || Molecules::ReportStateStore.new(project_root: @project_root)
52
+ report_state = state_store.read(assignment_id)
53
+ git_state = @git_probe.state
54
+
55
+ in_flight_number = in_flight_step_number(snapshot)
56
+ step_cause = @evidence_reader.read(assignment_id, in_flight_number)
57
+ step_report = @report_reader.read(assignment_id, in_flight_number)
58
+
59
+ plan = Atoms::ResumePlanner.plan(snapshot: snapshot,
60
+ report_state: report_state, git_state: git_state,
61
+ step_cause: step_cause,
62
+ step_report_path: step_report && step_report["path"])
63
+
64
+ case plan.disposition
65
+ when :continue, :complete
66
+ ResumeResult.new(plan: plan,
67
+ message: plan.continue? ? "assignment in progress; resume the remaining work" : "assignment complete and delivered; nothing to resume",
68
+ exit_code: 0)
69
+ when :report
70
+ return ResumeResult.new(plan: plan,
71
+ message: "assignment complete; report delivery missing (dry-run: nothing sent)",
72
+ exit_code: 0) if dry_run
73
+
74
+ orchestrator = @report_orchestrator || ReportOrchestrator.new(project_root: @project_root, config: @config)
75
+ report_result = orchestrator.call(config_path: config_path, assignment: assignment_id)
76
+ action = report_result.created_event ? "reporting the outcome" : "redelivering the report"
77
+ ResumeResult.new(plan: plan, report_result: report_result,
78
+ message: "assignment complete; #{action}: #{report_result.message}",
79
+ exit_code: report_result.exit_code)
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def in_flight_step_number(snapshot)
86
+ Atoms::ResumePlanner.in_flight_step(snapshot)&.number
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end