@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.
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +125 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +194 -5
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb +94 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +71 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +45 -12
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +222 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +76 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +94 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +5 -0
- package/src/constants.ts +1 -1
- package/src/runtime.ts +8 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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)
|