@guanlin4924/devflow-pi 0.1.39 → 0.1.40

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.
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "json"
4
5
  require_relative "gate_result"
5
6
  require_relative "gate_contract"
6
7
  require_relative "checker_registry"
8
+ require_relative "parallel_run"
7
9
 
8
10
  module GateRuntime
9
11
  WORKFLOW_STATUSES = %w[in_progress waiting_checkpoint completed blocked failed cancelled remediation].freeze
@@ -16,7 +18,7 @@ module GateRuntime
16
18
  contract
17
19
  end
18
20
 
19
- def evaluate(contract:, workflow_state:, current_revision:, expected_revision:, track: nil, source: "harness", evidence: {}, root: nil, check_outputs: false, transition_kind: nil, checkpoint_authorized: false)
21
+ def evaluate(contract:, workflow_state:, current_revision:, expected_revision:, track: nil, branch_id: nil, attempt: nil, branch_revision: nil, source: "harness", evidence: {}, root: nil, check_outputs: false, transition_kind: nil, checkpoint_authorized: false)
20
22
  validate_contract!(contract)
21
23
  gate_id = contract.fetch("gate_id")
22
24
  step_id = contract.fetch("step_id")
@@ -25,7 +27,22 @@ module GateRuntime
25
27
  unless WORKFLOW_STATUSES.include?(workflow_state["status"].to_s)
26
28
  return GateResult.block(**base, code: "WORKFLOW_STATUS_INVALID", message: "workflow status is not recognized")
27
29
  end
28
- unless %w[confirm_checkpoint enter_failure_branch exit_failure_branch].include?(transition_kind)
30
+ if workflow_state["parallel_run_ref"]
31
+ branch_error = validate_parallel_branch(
32
+ contract: contract,
33
+ workflow_state: workflow_state,
34
+ current_revision: current_revision,
35
+ track: track,
36
+ branch_id: branch_id,
37
+ attempt: attempt,
38
+ branch_revision: branch_revision,
39
+ evidence: evidence,
40
+ root: root,
41
+ transition_kind: transition_kind,
42
+ )
43
+ return GateResult.block(**base, **branch_error) if branch_error
44
+ end
45
+ unless %w[confirm_checkpoint enter_failure_branch exit_failure_branch parallel_join].include?(transition_kind)
29
46
  allowed_tracks = Array(contract.dig("topology", "allowed_tracks"))
30
47
  if track && !allowed_tracks.empty? && !allowed_tracks.include?(track)
31
48
  return GateResult.block(**base, code: "TRACK_SCOPE_VIOLATION", message: "current track is not allowed by GateContract", recovery: { "kind" => "registered_reentry" })
@@ -60,6 +77,58 @@ module GateRuntime
60
77
  GateResult.block(gate_id: contract.is_a?(Hash) ? contract["gate_id"] : "unknown", step_id: contract.is_a?(Hash) ? contract["step_id"] : "unknown", code: "GATE_CONTRACT_INVALID", message: e.message)
61
78
  end
62
79
 
80
+ def validate_parallel_branch(contract:, workflow_state:, current_revision:, track:, branch_id:, attempt:, branch_revision:, evidence:, root:, transition_kind:)
81
+ return { code: "PARALLEL_CONTEXT_MISSING", message: "parallel workflow requires root" } unless root
82
+
83
+ record = ParallelRun.load(root, workflow_state.fetch("current_feature"))
84
+ return { code: "PARALLEL_RUN_NOT_FOUND", message: "parallel_run_ref does not resolve to a run record" } unless record
85
+ return { code: "PARALLEL_RUN_REF_MISMATCH", message: "STATE parallel_run_ref does not match run record" } unless record["run_id"].to_s == workflow_state["parallel_run_ref"].to_s
86
+ bound_revision = Integer(record.fetch("bound_state_revision"))
87
+ return { code: "PARALLEL_STATE_REVISION_MISMATCH", message: "parallel run is bound to a different STATE revision", details: { expected_state_revision: bound_revision, actual_state_revision: current_revision } } unless bound_revision == current_revision.to_i
88
+
89
+ if transition_kind.to_s == "parallel_join"
90
+ branches = Array(record["branches"])
91
+ return { code: "PARALLEL_JOIN_INCOMPLETE", message: "parallel join requires every branch to succeed", details: { incomplete: branches.reject { |item| item["status"].to_s == "succeeded" }.map { |item| item["branch_id"] } } } unless branches.any? && branches.all? { |item| item["status"].to_s == "succeeded" }
92
+ return nil
93
+ end
94
+ return { code: "PARALLEL_CONTEXT_MISSING", message: "parallel workflow requires branch_id" } unless branch_id
95
+
96
+ branch = Array(record["branches"]).find { |item| item["branch_id"].to_s == branch_id.to_s }
97
+ return { code: "PARALLEL_BRANCH_NOT_FOUND", message: "branch is not registered in the parallel run", details: { branch_id: branch_id } } unless branch
98
+ return { code: "PARALLEL_STEP_MISMATCH", message: "branch is bound to a different workflow step", details: { branch_step: branch["workflow_step"], gate_step: contract["step_id"] } } unless branch["workflow_step"].to_s == contract["step_id"].to_s
99
+ expected_track = branch["track"].to_s.sub(/\Atrack_/, "").upcase
100
+ return { code: "PARALLEL_TRACK_MISMATCH", message: "branch track is not the requested track", details: { branch_track: expected_track, requested_track: track } } unless track.to_s.empty? || expected_track.empty? || expected_track == track.to_s
101
+ return { code: "PARALLEL_BRANCH_TERMINAL", message: "terminal branch cannot pass a gate; retry must create a new attempt" } if ParallelRun::TERMINAL_BRANCH_STATUSES.include?(branch["status"].to_s) && transition_kind.to_s != "parallel_branch_retry"
102
+ return { code: "PARALLEL_ATTEMPT_MISMATCH", message: "branch attempt does not match the current run record" } if attempt && Integer(branch.fetch("attempt", 0)) != attempt.to_i
103
+ return { code: "PARALLEL_BRANCH_REVISION_MISMATCH", message: "branch revision does not match the current run record" } if branch_revision && Integer(branch.fetch("branch_revision", 0)) != branch_revision.to_i
104
+
105
+ predecessor_ids = Array(contract.dig("topology", "allowed_predecessors"))
106
+ completed = Array(workflow_state["completed_steps"]).map(&:to_s)
107
+ completed.concat(Array(record["branches"]).select { |item| item["status"].to_s == "succeeded" }.map { |item| item["workflow_step"].to_s })
108
+ missing_predecessors = predecessor_ids.map(&:to_s).reject { |step| completed.include?(step) }
109
+ return { code: "PARALLEL_PREDECESSOR_INCOMPLETE", message: "parallel branch predecessor is incomplete", details: { missing: missing_predecessors } } unless missing_predecessors.empty?
110
+
111
+ if transition_kind.to_s == "parallel_branch_complete"
112
+ required = %w[branch_id attempt branch_revision]
113
+ missing = required.reject { |field| evidence.key?(field) }
114
+ return { code: "PARALLEL_EVIDENCE_MISSING", message: "branch completion requires independent evidence binding", details: { missing: missing } } unless missing.empty?
115
+ return { code: "PARALLEL_EVIDENCE_MISMATCH", message: "branch evidence does not match current branch", details: { branch_id: branch_id } } unless evidence["branch_id"].to_s == branch_id.to_s && evidence["attempt"].to_i == branch.fetch("attempt", 0).to_i && evidence["branch_revision"].to_i == branch.fetch("branch_revision", 0).to_i
116
+ evidence_path = evidence["evidence_path"].to_s
117
+ return { code: "PARALLEL_EVIDENCE_MISSING", message: "Core machine evidence file is required" } if evidence_path.empty? || !File.file?(File.expand_path(evidence_path, root))
118
+ begin
119
+ pack = JSON.parse(File.read(File.expand_path(evidence_path, root)))
120
+ binding = pack.dig("evidence", "parallel_binding")
121
+ expected = { "run_id" => record["run_id"], "branch_id" => branch["branch_id"], "attempt" => branch.fetch("attempt", 0), "branch_revision" => branch.fetch("branch_revision", 0), "state_revision" => current_revision.to_i }
122
+ return { code: "PARALLEL_EVIDENCE_MISMATCH", message: "machine evidence binding does not match current branch", details: { expected: expected, actual: binding } } unless binding.is_a?(Hash) && expected.all? { |key, value| binding[key].to_s == value.to_s }
123
+ rescue JSON::ParserError, Errno::ENOENT
124
+ return { code: "PARALLEL_EVIDENCE_MISMATCH", message: "machine evidence file is not valid JSON" }
125
+ end
126
+ end
127
+ nil
128
+ rescue KeyError, ArgumentError, TypeError
129
+ { code: "PARALLEL_RECORD_INVALID", message: "parallel run or branch record is invalid" }
130
+ end
131
+
63
132
  def run_checker(contract:, workflow_state:, current_revision:, expected_revision:, checker:, **kwargs)
64
133
  boundary = evaluate(contract: contract, workflow_state: workflow_state, current_revision: current_revision, expected_revision: expected_revision, **kwargs)
65
134
  return boundary unless boundary["verdict"] == "allow"
@@ -191,6 +191,9 @@ def active_parallel_window(workflow, state, root, step_by_id, completed_ids, pen
191
191
  step = step_by_id[step_id]
192
192
  pending_ids.include?(step_id) && step && dependencies_satisfied?(step, completed_ids)
193
193
  end
194
+ # A single runnable branch is an intentional serial fallback (for example
195
+ # when active_track limits the window to one track), not a missing window.
196
+ # Only a genuinely multi-branch runnable set enters the parallel scheduler.
194
197
  next unless runnable_ids.length > 1
195
198
 
196
199
  policy = window["execution_policy"] || {}
@@ -12,13 +12,17 @@ require_relative "control_io"
12
12
  require_relative "requirement_graph"
13
13
  require_relative "checker_registry"
14
14
 
15
- options = { feature: nil, step: nil, contract: nil, write: false, json: false }
15
+ options = { feature: nil, step: nil, contract: nil, write: false, json: false, branch_id: nil, attempt: nil, branch_revision: nil, lock_held: false }
16
16
  OptionParser.new do |o|
17
17
  o.on("--feature NAME") { |v| options[:feature] = v }
18
18
  o.on("--step ID") { |v| options[:step] = v }
19
19
  o.on("--contract PATH") { |v| options[:contract] = v }
20
20
  o.on("--write") { options[:write] = true }
21
21
  o.on("--json") { options[:json] = true }
22
+ o.on("--parallel-branch-id ID") { |v| options[:branch_id] = v }
23
+ o.on("--parallel-attempt N", Integer) { |v| options[:attempt] = v }
24
+ o.on("--parallel-branch-revision N", Integer) { |v| options[:branch_revision] = v }
25
+ o.on("--lock-held") { options[:lock_held] = true }
22
26
  end.parse!
23
27
  abort "feature, step and --write are required" unless options[:feature] && options[:step] && options[:write]
24
28
  abort "invalid feature" unless RequirementGraph.valid_feature?(options[:feature])
@@ -41,7 +45,7 @@ def file_evidence(root, relative)
41
45
  { "path" => relative, "exists" => true, "sha256" => Digest::SHA256.file(absolute).hexdigest, "bytes" => File.size(absolute) }
42
46
  end
43
47
 
44
- def independent_evidence(root, feature, step, state, workflow, graph, files, checker_results)
48
+ def independent_evidence(root, feature, step, state, workflow, graph, files, checker_results, binding = nil, inputs = [])
45
49
  source_nodes = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "source" }
46
50
  authority = source_nodes.map do |node|
47
51
  evidence = node["evidence"].is_a?(Hash) ? node["evidence"] : {}
@@ -54,7 +58,9 @@ def independent_evidence(root, feature, step, state, workflow, graph, files, che
54
58
  # The harness may publish an append-only event log beside the machine pack.
55
59
  # Absence is represented as unavailable, never as false/zero, so the pack
56
60
  # cannot manufacture proof that an engine did not invoke AI.
57
- event_path = File.join(".planning", "evidence", feature, "#{step}.engine-events.jsonl")
61
+ binding_attempt = binding && binding["attempt"]
62
+ event_name = binding_attempt.nil? ? "#{step}.engine-events.jsonl" : "#{step}.attempt-#{binding_attempt}.engine-events.jsonl"
63
+ event_path = File.join(".planning", "evidence", feature, event_name)
58
64
  event_absolute, = DevflowControlIO.resolve_control_path(root, event_path, :artifact)
59
65
  events = if File.file?(event_absolute)
60
66
  File.foreach(event_absolute).first(10_000).each_with_object([]) do |line, parsed|
@@ -72,10 +78,15 @@ def independent_evidence(root, feature, step, state, workflow, graph, files, che
72
78
  nil
73
79
  end
74
80
  ai_events = events ? events.select { |event| %w[ai_runtime_initialized ai_invocation].include?(event["event"]) } : []
81
+ dispatched_events = events ? events.select { |event| event["event"] == "engine_dispatched" } : []
82
+ ai_measurement_observed = !ai_events.empty?
75
83
 
76
84
  {
77
85
  "collector" => "core.machine_evidence/v1",
78
86
  "state" => { "revision" => state["revision"], "step" => step },
87
+ "parallel_binding" => binding,
88
+ "input_fingerprints" => inputs.select { |item| item["exists"] }.map { |item| item["sha256"] },
89
+ "output_fingerprints" => files.select { |item| item["exists"] }.map { |item| item["sha256"] },
79
90
  "source_authority" => { "status" => authority.empty? ? "unavailable" : "observed", "sources" => authority },
80
91
  "candidate_binding" => {
81
92
  "status" => "observed",
@@ -90,8 +101,13 @@ def independent_evidence(root, feature, step, state, workflow, graph, files, che
90
101
  "engine_evidence" => {
91
102
  "collector" => events ? "harness.engine-events/v1" : "unavailable",
92
103
  "event_log" => event_path,
93
- "ai_runtime_initialized" => events ? ai_events.any? { |event| event["event"] == "ai_runtime_initialized" } : nil,
94
- "ai_invocation_count" => events ? ai_events.count { |event| event["event"] == "ai_invocation" } : nil,
104
+ # engine_dispatched proves that the harness dispatched work, but it does
105
+ # not prove how many model calls the child session made. Keep unknown as
106
+ # nil instead of manufacturing false/zero values for branch evidence.
107
+ "ai_runtime_initialized" => ai_measurement_observed ? ai_events.any? { |event| event["event"] == "ai_runtime_initialized" } : nil,
108
+ "ai_invocation_count" => ai_measurement_observed ? ai_events.count { |event| event["event"] == "ai_invocation" } : nil,
109
+ "engine_dispatched_count" => events ? dispatched_events.length : nil,
110
+ "ai_capable_engine_dispatched" => events ? dispatched_events.any? { |event| event["ai_capable"] == true } : nil,
95
111
  "evidence_status" => events ? "observed" : "unavailable"
96
112
  }
97
113
  }
@@ -156,10 +172,21 @@ rescue JSON::ParserError => error
156
172
  end
157
173
 
158
174
  root = Dir.pwd
159
- lock = DevflowControlIO.acquire_project_lock(root)
175
+ lock = options[:lock_held] ? nil : DevflowControlIO.acquire_project_lock(root)
160
176
  begin
161
177
  state = state_front_matter(root)
162
- unless state["current_feature"] == options[:feature] && state["current_step"] == options[:step]
178
+ binding = nil
179
+ branch = nil
180
+ if options[:branch_id]
181
+ require_relative "parallel_run"
182
+ record = ParallelRun.load(root, options[:feature])
183
+ branch = Array(record && record["branches"]).find { |item| item["branch_id"].to_s == options[:branch_id].to_s }
184
+ raise "parallel branch not found" unless record && branch
185
+ raise "parallel branch attempt mismatch" unless options[:attempt].nil? || Integer(branch.fetch("attempt", 0)) == options[:attempt]
186
+ raise "parallel branch revision mismatch" unless options[:branch_revision].nil? || Integer(branch.fetch("branch_revision", 0)) == options[:branch_revision]
187
+ binding = { "run_id" => record["run_id"], "branch_id" => branch["branch_id"], "attempt" => branch["attempt"], "branch_revision" => branch["branch_revision"], "state_revision" => state["revision"] }
188
+ end
189
+ unless state["current_feature"] == options[:feature] && (state["current_step"] == options[:step] || branch)
163
190
  result = { "ok" => false, "reason" => "state_step_mismatch", "state_feature" => state["current_feature"], "state_step" => state["current_step"] }
164
191
  puts JSON.pretty_generate(result)
165
192
  exit 1
@@ -181,6 +208,7 @@ begin
181
208
  { "path" => relative, "required" => true, "exists" => exists, "bytes" => exists ? File.size(absolute) : 0,
182
209
  "sha256" => exists ? Digest::SHA256.file(absolute).hexdigest : nil }
183
210
  end.compact
211
+ input_files = Array(step["inputs"]).map { |input| file_evidence(root, interpolate(input.to_s, state)) }
184
212
 
185
213
  graph, = RequirementGraph.load_graph(root, options[:feature])
186
214
  checkers = RequirementGraph.checker_results(graph)
@@ -206,13 +234,17 @@ begin
206
234
  contract_hash = Digest::SHA256.file(contract_path).hexdigest
207
235
  end
208
236
  node_counts = Array(graph["nodes"]).group_by { |node| node["kind"] }.transform_values(&:length)
209
- independent = independent_evidence(root, options[:feature], options[:step], state, workflow, graph, files, checker_results)
237
+ independent = independent_evidence(root, options[:feature], options[:step], state, workflow, graph, files, checker_results, binding, input_files)
210
238
  engine_evidence_complete = independent.dig("engine_evidence", "evidence_status") == "observed"
211
239
  evidence_complete = engine_evidence_gate != "enforced" || engine_evidence_complete
212
240
  pack = {
213
241
  "schema_version" => 2, "origin" => "core_machine", "feature" => options[:feature], "step" => options[:step],
214
242
  "state_revision" => state["revision"], "graph_revision" => graph["revision"], "workflow_fingerprint" => Digest::SHA256.hexdigest(workflow_raw),
215
- "contract_hash" => contract_hash, "files" => files, "checkers" => checker_results, "evidence" => independent,
243
+ "run_id" => binding && binding["run_id"], "branch_id" => binding && binding["branch_id"],
244
+ "attempt" => binding && binding["attempt"], "branch_revision" => binding && binding["branch_revision"],
245
+ "input_fingerprints" => input_files.select { |item| item["exists"] }.map { |item| item["sha256"] },
246
+ "output_fingerprints" => files.select { |item| item["exists"] }.map { |item| item["sha256"] },
247
+ "contract_hash" => contract_hash, "files" => files, "input_files" => input_files, "checkers" => checker_results, "evidence" => independent,
216
248
  "counters" => { "required_file_count" => files.length, "existing_file_count" => files.count { |item| item["exists"] }, "nodes_by_kind" => node_counts,
217
249
  "stale_node_count" => Array(graph["stale"]).length, "stale_view_count" => Hash(graph["views"]).count { |_name, view| view["needs_recheck"] } },
218
250
  "view_fingerprints" => Hash(graph["views"]).transform_values { |view| view.select { |key, _value| %w[fingerprint graph_revision state needs_recheck stale_fingerprint].include?(key) } },
@@ -220,12 +252,13 @@ begin
220
252
  "evidence_complete" => evidence_complete,
221
253
  "gate_mode" => gate.empty? ? "observe" : gate
222
254
  }.compact
223
- destination = File.join(root, ".planning/evidence", options[:feature], "#{options[:step]}.machine.json")
255
+ suffix = binding ? ".attempt-#{binding["attempt"]}.branch-#{Digest::SHA256.hexdigest(binding["branch_id"].to_s)[0, 12]}" : ""
256
+ destination = File.join(root, ".planning/evidence", options[:feature], "#{options[:step]}#{suffix}.machine.json")
224
257
  DevflowControlIO.atomic_write(destination, JSON.pretty_generate(pack) + "\n")
225
- result = { "ok" => true, "path" => destination.sub("#{root}/", ""), "complete" => pack["complete"], "state_revision" => pack["state_revision"], "graph_revision" => pack["graph_revision"], "checkers" => checker_results }
258
+ result = { "ok" => true, "path" => destination.sub("#{root}/", ""), "complete" => pack["complete"], "state_revision" => pack["state_revision"], "graph_revision" => pack["graph_revision"], "checkers" => checker_results, "parallel_binding" => binding }
226
259
  rescue StandardError => error
227
260
  result = { "ok" => false, "reason" => "machine_evidence_failed", "message" => error.message }
228
261
  ensure
229
- DevflowControlIO.release_lock(lock)
262
+ DevflowControlIO.release_lock(lock) if lock
230
263
  end
231
264
  puts JSON.pretty_generate(result)
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "date"
5
+ require "digest"
6
+ require "fileutils"
7
+ require "json"
8
+ require "optparse"
9
+ require "securerandom"
10
+ require "time"
11
+ require "yaml"
12
+ require_relative "control_io"
13
+
14
+ # Durable scheduler state for one workflow parallel window. This file owns
15
+ # dispatch state only; artifacts/index.yaml owns artifact content state and
16
+ # STATE.md owns the workflow snapshot/current pointer.
17
+ module ParallelRun
18
+ SCHEMA_VERSION = 1
19
+ TERMINAL_BRANCH_STATUSES = %w[succeeded failed aborted].freeze
20
+
21
+ module_function
22
+
23
+ def safe_feature(feature)
24
+ value = feature.to_s
25
+ raise ArgumentError, "feature is required" if value.empty?
26
+ raise ArgumentError, "feature contains path separators" if value.include?("/") || value.include?("\\") || value == "." || value == ".."
27
+
28
+ value
29
+ end
30
+
31
+ def path(root, feature)
32
+ File.join(root, ".planning", "parallel", safe_feature(feature), "branches.yaml")
33
+ end
34
+
35
+ def load(root, feature)
36
+ target = path(root, feature)
37
+ return nil unless File.file?(target)
38
+
39
+ value = YAML.safe_load(File.read(target), permitted_classes: [Date, Time], aliases: false)
40
+ raise ArgumentError, "parallel run record must be a mapping" unless value.is_a?(Hash)
41
+ value
42
+ end
43
+
44
+ def write(root, feature, record)
45
+ raise ArgumentError, "parallel run record must be a mapping" unless record.is_a?(Hash)
46
+
47
+ DevflowControlIO.atomic_write(path(root, feature), record.to_yaml)
48
+ record
49
+ end
50
+
51
+ def state_front_matter(markdown)
52
+ match = markdown.match(/\A---\n(.*?)\n---\n/m)
53
+ match && match[1]
54
+ end
55
+
56
+ def attach_state_ref(root, state_path, run_id, expected_revision)
57
+ absolute, = DevflowControlIO.resolve_control_path(root, state_path, :state)
58
+ original = File.read(absolute)
59
+ front = state_front_matter(original)
60
+ raise ArgumentError, "STATE.md has no YAML front matter" unless front
61
+
62
+ state = YAML.safe_load(front, permitted_classes: [], aliases: false)
63
+ raise ArgumentError, "STATE.md must contain a mapping" unless state.is_a?(Hash)
64
+ actual = Integer(state.fetch("revision", 0))
65
+ raise ArgumentError, "STATE revision conflict" unless actual == Integer(expected_revision)
66
+ raise ArgumentError, "STATE already references a parallel run" if state["parallel_run_ref"]
67
+
68
+ state["parallel_run_ref"] = run_id
69
+ state["revision"] = actual + 1
70
+ body = original.sub(/\A---\n.*?\n---\n/m, "---\n#{state.to_yaml.sub(/\A---\n/, "")}---\n")
71
+ DevflowControlIO.atomic_write(absolute, body)
72
+ state
73
+ end
74
+
75
+ def prepare(root:, feature:, workflow_window:, state_revision:, branches:, task_ids: {}, state_path: ".planning/STATE.md")
76
+ lock = DevflowControlIO.acquire_project_lock(root)
77
+ begin
78
+ raise ArgumentError, "parallel run already exists" if load(root, feature)
79
+
80
+ run_id = "PR-#{SecureRandom.uuid}"
81
+ branch_records = Array(branches).map do |branch|
82
+ value = branch.is_a?(Hash) ? branch : { "branch_id" => branch }
83
+ workflow_step = value["workflow_step"].to_s
84
+ track = value["track"].to_s
85
+ raise ArgumentError, "workflow_step is required" if workflow_step.empty?
86
+ raise ArgumentError, "track is required" if track.empty?
87
+ branch_id = value["branch_id"].to_s
88
+ branch_id = "#{run_id}-#{track.sub(/\Atrack_/, "")}-#{workflow_step}" if branch_id.empty?
89
+
90
+ value.merge(
91
+ "branch_id" => branch_id,
92
+ "workflow_step" => workflow_step,
93
+ "track" => track,
94
+ "attempt" => 0,
95
+ "branch_revision" => 0,
96
+ "status" => "pending",
97
+ "task_id" => task_ids[branch_id],
98
+ "previous_branch_id" => nil,
99
+ "history" => [],
100
+ ).compact
101
+ end
102
+ record = {
103
+ "schema_version" => SCHEMA_VERSION,
104
+ "run_id" => run_id,
105
+ "workflow_window" => workflow_window,
106
+ "bound_state_revision" => Integer(state_revision),
107
+ "run_revision" => 0,
108
+ "status" => "pending",
109
+ "created_at" => Time.now.utc.iso8601,
110
+ "branches" => branch_records,
111
+ }
112
+ write(root, feature, record)
113
+ updated_state = attach_state_ref(root, state_path, run_id, state_revision)
114
+ record["bound_state_revision"] = Integer(updated_state.fetch("revision"))
115
+ record["branches"].each { |branch| branch["expected_state_revision"] = record["bound_state_revision"] }
116
+ write(root, feature, record)
117
+ { "ok" => true, "run" => record }
118
+ ensure
119
+ DevflowControlIO.release_lock(lock)
120
+ end
121
+ end
122
+
123
+ def update_branch(root:, feature:, branch_id:, expected_revision:, status:, task_id: nil, evidence_path: nil, output_fingerprint: nil, error: nil, lock: nil)
124
+ owned_lock = lock.nil?
125
+ lock ||= DevflowControlIO.acquire_project_lock(root)
126
+ begin
127
+ record = load(root, feature) || raise(ArgumentError, "parallel run not found")
128
+ actual = Integer(record.fetch("run_revision", 0))
129
+ raise ArgumentError, "parallel run revision conflict" unless actual == Integer(expected_revision)
130
+
131
+ branch = Array(record["branches"]).find { |item| item["branch_id"].to_s == branch_id.to_s }
132
+ raise ArgumentError, "parallel branch not found: #{branch_id}" unless branch
133
+ raise ArgumentError, "invalid branch status: #{status}" unless (TERMINAL_BRANCH_STATUSES + %w[pending running]).include?(status)
134
+ raise ArgumentError, "terminal branch must retry before update" if TERMINAL_BRANCH_STATUSES.include?(branch["status"])
135
+
136
+ branch["status"] = status
137
+ branch["branch_revision"] = Integer(branch.fetch("branch_revision", 0)) + 1
138
+ branch["task_id"] = task_id if task_id
139
+ branch["evidence_path"] = evidence_path if evidence_path
140
+ branch["output_fingerprint"] = output_fingerprint if output_fingerprint
141
+ branch["error"] = error if error
142
+ record["run_revision"] = actual + 1
143
+ record["status"] = "failed" if status == "failed"
144
+ record["status"] = "running" if status == "running" && record["status"] == "pending"
145
+ record["status"] = "completed" if Array(record["branches"]).all? { |item| item["status"] == "succeeded" }
146
+ record["updated_at"] = Time.now.utc.iso8601
147
+ write(root, feature, record)
148
+ { "ok" => true, "run" => record }
149
+ ensure
150
+ DevflowControlIO.release_lock(lock) if owned_lock
151
+ end
152
+ end
153
+
154
+ def retry_branch(root:, feature:, branch_id:, expected_revision:, lock: nil)
155
+ owned_lock = lock.nil?
156
+ lock ||= DevflowControlIO.acquire_project_lock(root)
157
+ begin
158
+ record = load(root, feature) || raise(ArgumentError, "parallel run not found")
159
+ raise ArgumentError, "parallel run revision conflict" unless Integer(record.fetch("run_revision", 0)) == Integer(expected_revision)
160
+ branch = Array(record["branches"]).find { |item| item["branch_id"].to_s == branch_id.to_s }
161
+ raise ArgumentError, "parallel branch not found: #{branch_id}" unless branch
162
+ raise ArgumentError, "only failed/aborted branch can retry" unless %w[failed aborted].include?(branch["status"])
163
+
164
+ previous_id = branch["branch_id"]
165
+ branch["history"] ||= []
166
+ branch["history"] << branch.slice("branch_id", "attempt", "branch_revision", "status", "task_id", "evidence_path")
167
+ branch["attempt"] = Integer(branch.fetch("attempt", 0)) + 1
168
+ branch["branch_revision"] = 0
169
+ branch["previous_branch_id"] = previous_id
170
+ branch["status"] = "pending"
171
+ branch.delete("error")
172
+ record["status"] = "running"
173
+ record["run_revision"] = Integer(record.fetch("run_revision", 0)) + 1
174
+ record["updated_at"] = Time.now.utc.iso8601
175
+ write(root, feature, record)
176
+ { "ok" => true, "run" => record }
177
+ ensure
178
+ DevflowControlIO.release_lock(lock) if owned_lock
179
+ end
180
+ end
181
+ end
182
+
183
+ if $PROGRAM_NAME == __FILE__
184
+ begin
185
+ options = { action: nil, feature: nil, state_revision: nil, run_revision: nil, branch_id: nil, status: nil, workflow_window: nil, branches: [], state_path: ".planning/STATE.md", json: false }
186
+ OptionParser.new do |opts|
187
+ opts.on("--action ACTION", %w[inspect prepare update retry]) { |value| options[:action] = value }
188
+ opts.on("--feature FEATURE") { |value| options[:feature] = value }
189
+ opts.on("--state-revision REV", Integer) { |value| options[:state_revision] = value }
190
+ opts.on("--run-revision REV", Integer) { |value| options[:run_revision] = value }
191
+ opts.on("--branch-id ID") { |value| options[:branch_id] = value }
192
+ opts.on("--status STATUS") { |value| options[:status] = value }
193
+ opts.on("--workflow-window NAME") { |value| options[:workflow_window] = value }
194
+ opts.on("--state PATH") { |value| options[:state_path] = value }
195
+ opts.on("--branch STEP:TRACK", "Register a branch as workflow_step:track") do |value|
196
+ workflow_step, track = value.split(":", 2)
197
+ raise ArgumentError, "--branch must be WORKFLOW_STEP:TRACK" if workflow_step.to_s.empty? || track.to_s.empty?
198
+ options[:branches] << { "workflow_step" => workflow_step, "track" => track }
199
+ end
200
+ opts.on("--json") { options[:json] = true }
201
+ end.parse!
202
+
203
+ raise ArgumentError, "--feature is required" unless options[:feature]
204
+ result = case options[:action]
205
+ when "inspect"
206
+ record = ParallelRun.load(Dir.pwd, options[:feature])
207
+ record ? { "ok" => true, "run" => record } : { "ok" => false, "reason" => "parallel_run_not_found" }
208
+ when "prepare"
209
+ ParallelRun.prepare(root: Dir.pwd, feature: options[:feature], workflow_window: options[:workflow_window], state_revision: options[:state_revision], branches: options[:branches], state_path: options[:state_path])
210
+ when "update"
211
+ ParallelRun.update_branch(root: Dir.pwd, feature: options[:feature], branch_id: options[:branch_id], expected_revision: options[:run_revision], status: options[:status])
212
+ when "retry"
213
+ ParallelRun.retry_branch(root: Dir.pwd, feature: options[:feature], branch_id: options[:branch_id], expected_revision: options[:run_revision])
214
+ else
215
+ raise ArgumentError, "--action must be inspect, prepare, update, or retry"
216
+ end
217
+ puts(options[:json] ? JSON.generate(result) : YAML.dump(result))
218
+ rescue StandardError => e
219
+ warn e.message
220
+ exit 1
221
+ end
222
+ end
@@ -12,6 +12,7 @@ require_relative "architecture_feasibility"
12
12
  require_relative "checker_registry"
13
13
  require_relative "requirement_coverage"
14
14
  require_relative "workflow_gate_contract"
15
+ require_relative "parallel_run"
15
16
 
16
17
  module WorkflowStateValidator
17
18
  REQUIRED_STATE_FIELDS = %w[
@@ -80,7 +81,11 @@ module WorkflowStateValidator
80
81
  tracked_ids = completed_ids + pending_ids
81
82
 
82
83
  validate_step_tracking(issues, state, step_ids, completed_ids, pending_ids, tracked_ids)
83
- validate_track_scope(issues, state, completed_ids)
84
+ if state["parallel_run_ref"]
85
+ validate_parallel_run(issues, root, state, workflow, step_by_id)
86
+ else
87
+ validate_track_scope(issues, state, completed_ids)
88
+ end
84
89
  validate_dependencies(issues, step_by_id, completed_ids)
85
90
  validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status, workflow)
86
91
  validate_waiting_checkpoint(issues, root, state, step_by_id, artifact_by_path, strict_artifact_status)
@@ -269,6 +274,76 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
269
274
  end
270
275
  end
271
276
 
277
+ def validate_parallel_run(issues, root, state, workflow, step_by_id)
278
+ feature = state["current_feature"].to_s
279
+ run = ParallelRun.load(root, feature)
280
+ unless run
281
+ issues << issue("error", "parallel_run_missing", "parallel_run_ref does not resolve to a run record", feature: feature)
282
+ return
283
+ end
284
+ if run["run_id"].to_s != state["parallel_run_ref"].to_s
285
+ issues << issue("error", "parallel_run_ref_mismatch", "parallel_run_ref does not match the run record", reference: state["parallel_run_ref"], run_id: run["run_id"])
286
+ end
287
+ begin
288
+ bound_revision = Integer(run.fetch("bound_state_revision"))
289
+ issues << issue("error", "parallel_state_revision_mismatch", "parallel run is bound to a different STATE revision", state_revision: state["revision"], bound_state_revision: bound_revision) unless bound_revision == Integer(state.fetch("revision"))
290
+ rescue ArgumentError, TypeError, KeyError
291
+ issues << issue("error", "parallel_run_revision_invalid", "parallel run bound_state_revision is invalid")
292
+ end
293
+
294
+ window = Array(workflow["parallel_windows"]).find { |candidate| candidate.is_a?(Hash) && candidate["id"].to_s == run["workflow_window"].to_s }
295
+ unless window
296
+ issues << issue("error", "parallel_window_not_found", "parallel run references an unknown workflow window", window: run["workflow_window"])
297
+ return
298
+ end
299
+ join_step = window["join_step"].to_s
300
+ if state["current_step"].to_s != join_step
301
+ issues << issue("error", "parallel_current_step_invalid", "parallel state current_step must remain the window join step", current_step: state["current_step"], join_step: join_step)
302
+ end
303
+
304
+ branch_steps = window["branches"].is_a?(Hash) ? window["branches"].each_with_object({}) do |(track, steps), result|
305
+ Array(steps).each { |step| result[step.to_s] = track.to_s }
306
+ end : {}
307
+ seen_ids = {}
308
+ Array(run["branches"]).each do |branch|
309
+ unless branch.is_a?(Hash)
310
+ issues << issue("error", "parallel_branch_invalid", "parallel run branch must be a mapping")
311
+ next
312
+ end
313
+ branch_id = branch["branch_id"].to_s
314
+ step_id = branch["workflow_step"].to_s
315
+ track = branch["track"].to_s
316
+ issues << issue("error", "parallel_branch_id_missing", "parallel branch_id is required") if branch_id.empty?
317
+ issues << issue("error", "parallel_branch_duplicate_id", "parallel branch_id is duplicated", branch_id: branch_id) if seen_ids[branch_id]
318
+ seen_ids[branch_id] = true
319
+ issues << issue("error", "parallel_branch_step_missing", "parallel branch workflow_step is required", branch_id: branch_id) if step_id.empty?
320
+ issues << issue("error", "parallel_branch_step_unknown", "parallel branch step is not in the window", branch_id: branch_id, step: step_id) unless branch_steps.key?(step_id)
321
+ issues << issue("error", "parallel_branch_track_mismatch", "parallel branch track does not match the workflow window", branch_id: branch_id, track: track, expected_track: branch_steps[step_id]) if branch_steps[step_id] && track.sub(/\Atrack_/, "").upcase != branch_steps[step_id].sub(/\Atrack_/, "").upcase
322
+ begin
323
+ attempt = Integer(branch.fetch("attempt"))
324
+ branch_revision = Integer(branch.fetch("branch_revision"))
325
+ issues << issue("error", "parallel_branch_attempt_invalid", "parallel branch attempt must be non-negative", branch_id: branch_id) if attempt.negative?
326
+ issues << issue("error", "parallel_branch_revision_invalid", "parallel branch revision must be non-negative", branch_id: branch_id) if branch_revision.negative?
327
+ rescue ArgumentError, TypeError, KeyError
328
+ issues << issue("error", "parallel_branch_version_invalid", "parallel branch attempt and branch_revision are required integers", branch_id: branch_id)
329
+ end
330
+ expected_state_revision = branch["expected_state_revision"]
331
+ if expected_state_revision && expected_state_revision.to_i != state["revision"].to_i
332
+ issues << issue("error", "parallel_branch_state_revision_mismatch", "parallel branch is bound to a different STATE revision", branch_id: branch_id)
333
+ end
334
+ if step_id && state["completed_steps"].to_a.map(&:to_s).include?(step_id)
335
+ issues << issue("error", "parallel_step_completed_before_join", "parallel branch step must not enter completed_steps before join", branch_id: branch_id, step: step_id)
336
+ end
337
+ end
338
+ declared_steps = branch_steps.keys
339
+ actual_steps = Array(run["branches"]).select { |branch| branch.is_a?(Hash) }.map { |branch| branch["workflow_step"].to_s }
340
+ duplicate_steps = actual_steps.each_with_object(Hash.new(0)) { |step, counts| counts[step] += 1 }.select { |_, count| count > 1 }.keys
341
+ duplicate_steps.each { |step| issues << issue("error", "parallel_branch_step_duplicated", "parallel run contains duplicate workflow_step records", step: step) }
342
+ (declared_steps - actual_steps).each { |step| issues << issue("error", "parallel_branch_record_missing", "workflow branch step has no persisted branch record", step: step) }
343
+ rescue ArgumentError, TypeError, Psych::Exception => error
344
+ issues << issue("error", "parallel_run_invalid", "parallel run record is invalid", detail: error.message)
345
+ end
346
+
272
347
  def validate_checker_registry(issues, workflow)
273
348
  declared = workflow.dig("governance", "step_checkers")
274
349
  return unless declared.is_a?(Hash)