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.
- checksums.yaml +7 -0
- data/.ace-defaults/coworker/config.yml +6 -0
- data/.ace-defaults/coworker/roles/integrator.yml +15 -0
- data/.ace-defaults/coworker/roles/reviewer.yml +15 -0
- data/.ace-defaults/coworker/roles/spec.yml +14 -0
- data/.ace-defaults/coworker/roles/worker.yml +13 -0
- data/.ace-defaults/coworker/sequences/integrator.yml +20 -0
- data/.ace-defaults/coworker/sequences/reviewer.yml +20 -0
- data/.ace-defaults/coworker/sequences/spec.yml +16 -0
- data/.ace-defaults/coworker/sequences/worker.yml +21 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-coworker.yml +19 -0
- data/CHANGELOG.md +255 -0
- data/README.md +98 -0
- data/Rakefile +13 -0
- data/docs/usage.md +564 -0
- data/exe/ace-coworker +16 -0
- data/handbook/workflow-instructions/coworker/integrator.wf.md +162 -0
- data/handbook/workflow-instructions/coworker/reviewer.wf.md +211 -0
- data/handbook/workflow-instructions/coworker/spec.wf.md +100 -0
- data/handbook/workflow-instructions/coworker/worker.wf.md +103 -0
- data/lib/ace/coworker/atoms/active_executor_rule.rb +71 -0
- data/lib/ace/coworker/atoms/brief_formatter.rb +33 -0
- data/lib/ace/coworker/atoms/config_schema.rb +121 -0
- data/lib/ace/coworker/atoms/integration_gate.rb +92 -0
- data/lib/ace/coworker/atoms/outcome_planner.rb +79 -0
- data/lib/ace/coworker/atoms/prepare_decision.rb +75 -0
- data/lib/ace/coworker/atoms/prompt_template.rb +28 -0
- data/lib/ace/coworker/atoms/report_record_formatter.rb +60 -0
- data/lib/ace/coworker/atoms/resume_brief_formatter.rb +84 -0
- data/lib/ace/coworker/atoms/resume_planner.rb +78 -0
- data/lib/ace/coworker/atoms/role_preset_schema.rb +91 -0
- data/lib/ace/coworker/atoms/round_record_schema.rb +62 -0
- data/lib/ace/coworker/atoms/round_rule.rb +105 -0
- data/lib/ace/coworker/cli/commands/commission.rb +100 -0
- data/lib/ace/coworker/cli/commands/gate.rb +91 -0
- data/lib/ace/coworker/cli/commands/prepare.rb +138 -0
- data/lib/ace/coworker/cli/commands/report.rb +79 -0
- data/lib/ace/coworker/cli/commands/resume.rb +57 -0
- data/lib/ace/coworker/cli/commands/roles.rb +60 -0
- data/lib/ace/coworker/cli/commands/rounds.rb +99 -0
- data/lib/ace/coworker/cli/commands/start.rb +39 -0
- data/lib/ace/coworker/cli/commands/status.rb +95 -0
- data/lib/ace/coworker/cli.rb +83 -0
- data/lib/ace/coworker/errors.rb +10 -0
- data/lib/ace/coworker/models/assignment_snapshot.rb +65 -0
- data/lib/ace/coworker/models/brief.rb +38 -0
- data/lib/ace/coworker/models/config.rb +54 -0
- data/lib/ace/coworker/models/outcome.rb +39 -0
- data/lib/ace/coworker/models/preparation.rb +67 -0
- data/lib/ace/coworker/models/resume_plan.rb +47 -0
- data/lib/ace/coworker/molecules/assignment_metadata.rb +57 -0
- data/lib/ace/coworker/molecules/assignment_probe.rb +57 -0
- data/lib/ace/coworker/molecules/config_loader.rb +119 -0
- data/lib/ace/coworker/molecules/git_state_probe.rb +63 -0
- data/lib/ace/coworker/molecules/hitl_reporter.rb +145 -0
- data/lib/ace/coworker/molecules/instruction_resolver.rb +82 -0
- data/lib/ace/coworker/molecules/project_root.rb +26 -0
- data/lib/ace/coworker/molecules/report_state_store.rb +53 -0
- data/lib/ace/coworker/molecules/review_round_store.rb +115 -0
- data/lib/ace/coworker/molecules/role_preset_loader.rb +95 -0
- data/lib/ace/coworker/molecules/role_sequence_composer.rb +245 -0
- data/lib/ace/coworker/molecules/step_evidence_reader.rb +60 -0
- data/lib/ace/coworker/molecules/step_report_reader.rb +39 -0
- data/lib/ace/coworker/molecules/task_probe.rb +40 -0
- data/lib/ace/coworker/molecules/task_record_writer.rb +44 -0
- data/lib/ace/coworker/molecules/worktree_executor_probe.rb +139 -0
- data/lib/ace/coworker/molecules/worktree_provision_probe.rb +137 -0
- data/lib/ace/coworker/organisms/commission_orchestrator.rb +148 -0
- data/lib/ace/coworker/organisms/prepare_orchestrator.rb +374 -0
- data/lib/ace/coworker/organisms/report_orchestrator.rb +192 -0
- data/lib/ace/coworker/organisms/resume_orchestrator.rb +91 -0
- data/lib/ace/coworker/organisms/start_config_writer.rb +127 -0
- data/lib/ace/coworker/organisms/start_orchestrator.rb +45 -0
- data/lib/ace/coworker/organisms/status_orchestrator.rb +62 -0
- data/lib/ace/coworker/version.rb +7 -0
- data/lib/ace/coworker.rb +86 -0
- 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
|