Skip to content

Commit 0e80690

Browse files
fix(orchestration): Step 1 could not run on Windows at all (#303)
Merge PR #303
1 parent 70984e4 commit 0e80690

5 files changed

Lines changed: 246 additions & 23 deletions

File tree

‎CLAUDE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ Claude Workflow Engine is a 3-level LangGraph-based orchestration pipeline for a
2020
| **Status** | Active Development |
2121
| **Primary Location** | langgraph_engine/ |
2222
| **MCP Servers** | 13 servers -- all in separate repos under [techdeveloper-org](https://github.com/orgs/techdeveloper-org/repositories); 1 also keeps an in-engine copy in `src/mcp/` |
23-
| **Total Python Files** | 248 (langgraph_engine/); 490 repo-wide |
24-
| **Test Files** | 102 (93 unit, 5 integration, 3 e2e, 1 load) |
23+
| **Total Python Files** | 248 (langgraph_engine/); 491 repo-wide |
24+
| **Test Files** | 103 (94 unit, 5 integration, 3 e2e, 1 load) |
2525
| **Call Graph** | 578 classes, 3,985 methods, 4 languages (Python/Java/TS/Kotlin) |
2626

2727
---

‎README.md‎

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
[![Python](https://img.shields.io/badge/Python-3.10%2B-green)](https://python.org)
77
[![CI](https://github.com/techdeveloper-org/claude-workflow-engine/actions/workflows/ci.yml/badge.svg)](https://github.com/techdeveloper-org/claude-workflow-engine/actions/workflows/ci.yml)
88
[![License](https://img.shields.io/badge/License-MIT-yellow)](LICENSE)
9-
[![Tests](https://img.shields.io/badge/Tests-102%20files%20·%205%20integration-brightgreen)](tests/)
9+
[![Tests](https://img.shields.io/badge/Tests-103%20files%20·%205%20integration-brightgreen)](tests/)
1010
[![Discussions](https://img.shields.io/badge/Discussions-GitHub-blueviolet)](../../discussions)
1111

1212
---
@@ -106,20 +106,28 @@ yourself with `--invoked-by=`:
106106

107107
```bash
108108
# Hook Mode (default) — analysis, GitHub issue, branch
109-
python scripts/3-level-flow.py --invoked-by=implement --message "Fix the login timeout bug"
109+
python scripts/3-level-flow.py --invoked-by=implement --message="Fix the login timeout bug"
110110

111111
# Full Mode — all 9 active steps end-to-end (implements + PR + closes issue)
112112
CLAUDE_HOOK_MODE=0 python scripts/3-level-flow.py --invoked-by=run-pipeline \
113-
--message "Fix the login timeout bug"
113+
--message="Fix the login timeout bug"
114114

115115
# Template Fast-Path — Step 0 detects the template and skips Step 1 planning
116116
python scripts/3-level-flow.py --invoked-by=run-pipeline \
117-
--message "Add document Q&A feature" \
117+
--message="Add document Q&A feature" \
118118
--orchestration-template=orchestration_template.example.json
119119
```
120120

121-
Omitting `--invoked-by=` prints `[REFUSED]` and exits 0 without running anything. A
122-
misspelled command name exits 2, so a typo costs an error rather than a silent no-op.
121+
Both flags need the **equals form**. The parser matches on the `--message=` prefix, so a
122+
space-separated `--message "..."` is silently ignored: the run proceeds with an empty
123+
task, the orchestration prompt comes out empty, and Steps 2 and 3 skip while the run
124+
still reports OK.
125+
126+
`--invoked-by=` selects nothing — it only authorizes. Mode comes from
127+
`CLAUDE_HOOK_MODE`, and the per-command step ranges listed above are the plugin
128+
dispatcher's doing, not this flag's. Omitting it prints `[REFUSED]` and exits 0 without
129+
running anything; a misspelled command name exits 2, so a typo costs an error rather
130+
than a silent no-op.
123131

124132
### What happens when you run it
125133

@@ -175,7 +183,7 @@ flowchart TD
175183

176184
```mermaid
177185
flowchart TD
178-
START(["Task Input\npython scripts/3-level-flow.py --task ..."])
186+
START(["Task Input\npython scripts/3-level-flow.py --invoked-by=... --message=..."])
179187
180188
subgraph LM1["Level 0 : Pre-Flight Sanity Guard"]
181189
direction LR
@@ -335,11 +343,11 @@ flowchart LR
335343

336344
### Template Fast-Path
337345

338-
Pre-fill a JSON orchestration template once, and every subsequent run skips Step 0 entirely:
346+
Pre-fill a JSON orchestration template once, and Step 0 detects it and skips Step 1 planning:
339347

340348
```bash
341-
python scripts/3-level-flow.py \
342-
--message "add document Q&A feature" \
349+
python scripts/3-level-flow.py --invoked-by=run-pipeline \
350+
--message="add document Q&A feature" \
343351
--orchestration-template=orchestration_template.example.json
344352
# Planning time: ~0s (Step 0 detects the template and skips Step 1)
345353
```
@@ -461,8 +469,8 @@ claude-workflow-engine/ # 369 Python files total
461469
├── policies/ # non-.md policy state only (failure-kb.json) — see note below
462470
│ └── 03-execution-system/failure-prevention/failure-kb.json
463471
│
464-
├── tests/ # 102 test_*.py files (123 total Python files)
465-
│ ├── test_*.py # 93 unit tests
472+
├── tests/ # 103 test_*.py files (124 total Python files)
473+
│ ├── test_*.py # 94 unit tests
466474
│ ├── integration/ # 5 integration tests (GitHub, MCP, runtime verification)
467475
│ ├── e2e/ # 3 end-to-end scenario tests
468476
│ └── load/ # 1 concurrency / load test
@@ -593,7 +601,7 @@ Step 6: COMMENT "Implementation complete" with PR link
593601
```bash
594602
docker build -t claude-workflow-engine .
595603
docker run --env-file .env claude-workflow-engine \
596-
python scripts/3-level-flow.py --message "your task"
604+
python scripts/3-level-flow.py --invoked-by=run-pipeline --message="your task"
597605
```
598606

599607
### Docker Compose
@@ -613,18 +621,18 @@ kubectl apply -f k8s/secret.yaml -f k8s/configmap.yaml \
613621

614622
```bash
615623
ENABLE_HEALTH_SERVER=1 ENABLE_METRICS=1 LOG_FORMAT=json \
616-
python scripts/3-level-flow.py --message "your task"
624+
python scripts/3-level-flow.py --invoked-by=run-pipeline --message="your task"
617625
```
618626

619627
---
620628

621629
## Testing
622630

623-
102 test_*.py files (123 total Python files in `tests/` including conftest and `__init__` files):
631+
103 test_*.py files (124 total Python files in `tests/` including conftest and `__init__` files):
624632

625633
| Category | Files | Notes |
626634
|---|:---:|---|
627-
| Unit tests | 93 | In `tests/test_*.py` — no external dependencies required |
635+
| Unit tests | 94 | In `tests/test_*.py` — no external dependencies required |
628636
| Integration tests | 5 | `tests/integration/` — require live GitHub token or MCP servers |
629637
| E2E tests | 3 | `tests/e2e/` — require full pipeline environment |
630638
| Load tests | 1 | `tests/load/` — enabled with `RUN_LOAD_TESTS=1` |

‎langgraph_engine/sdlc_pipeline/architecture/prompt_gen_expert_caller.py‎

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,58 @@ def _load_template():
171171
)
172172

173173

174+
def _render_sample(items, total=None, limit=10):
175+
"""Render up to ``limit`` items, stating how many were left out.
176+
177+
The caller sends a sample rather than the full list, because the full lists
178+
reach a million characters on a large repository and a command line cannot
179+
carry that on Windows. The count travels with the sample so the grounding
180+
header can say "10 of 940" instead of presenting ten as if they were all --
181+
a truncation the reader cannot see is worse than no truncation, because the
182+
prompt then asserts something false about the codebase.
183+
184+
Args:
185+
items: The sample to render.
186+
total: How many existed before sampling. None means unknown, in which
187+
case the sample is assumed complete -- which is what an older caller
188+
that sends no total implies.
189+
limit: Maximum entries to render.
190+
191+
Returns:
192+
str: Comma-joined entries, suffixed with the omitted count, or "none".
193+
"""
194+
if not items:
195+
return "none"
196+
shown = list(items)[:limit]
197+
rendered = ", ".join(_entry_label(entry) for entry in shown)
198+
if total is not None and total > len(shown):
199+
return "%s (showing %d of %d)" % (rendered, len(shown), total)
200+
return rendered
201+
202+
203+
def _entry_label(entry):
204+
"""Return the human-meaningful name for one call-graph entry.
205+
206+
The analyser yields dicts -- ``fqn``, ``callers_count``,
207+
``callers_count_high_confidence``, and ``risk`` for affected methods -- not
208+
strings. The previous code joined them directly, which raises
209+
``TypeError: sequence item 0: expected str instance, dict found``. That never
210+
surfaced because the argument carrying them exceeded the Windows command-line
211+
limit and the caller died before rendering; one bug stood in front of the
212+
other. Falling back to str() rather than requiring the key, so a shape change
213+
degrades to an ugly line instead of an exception in prompt assembly.
214+
215+
Args:
216+
entry: A call-graph entry, normally a dict carrying ``fqn``.
217+
218+
Returns:
219+
str: The entry's fully-qualified name, or its string form.
220+
"""
221+
if isinstance(entry, dict):
222+
return str(entry.get("fqn") or entry.get("name") or entry)
223+
return str(entry)
224+
225+
174226
def _render_kg_routing_block(kg_routing):
175227
"""Render the KG ROUTING grounding block for the ``{kg_routing_block}``
176228
placeholder.
@@ -241,9 +293,9 @@ def _build_filled_prompt(template, args):
241293
affected_methods = call_graph.get("affected_methods", [])
242294
hot_nodes = call_graph.get("hot_nodes", [])
243295

244-
danger_zones_str = ", ".join(danger_zones) if danger_zones else "none"
245-
affected_str = ", ".join(affected_methods[:10]) if affected_methods else "none"
246-
hot_nodes_str = ", ".join(hot_nodes[:10]) if hot_nodes else "none"
296+
danger_zones_str = _render_sample(danger_zones, call_graph.get("danger_zones_total"))
297+
affected_str = _render_sample(affected_methods, call_graph.get("affected_methods_total"))
298+
hot_nodes_str = _render_sample(hot_nodes, call_graph.get("hot_nodes_total"))
247299

248300
runtime_block = json.dumps(runtime_context, indent=2, ensure_ascii=True)
249301

‎langgraph_engine/sdlc_pipeline/nodes/task_orchestration.py‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,28 @@ def step1_task_analysis_node(state: FlowState) -> Dict[str, Any]:
161161
# NFR-2 / ADR-016 replace it: unbounded unless an operator configures a
162162
# silence interval, which measures no-progress rather than duration.
163163
_pg_silence = _env_optional_seconds("STEP1_PROMPT_GEN_SILENCE")
164+
# Send a sample, not the whole graph. These lists go across a process
165+
# boundary as a command-line argument, and on this repository they serialise
166+
# to 1,031,711 characters -- 5,527 affected methods and 940 danger zones.
167+
# Windows caps a command line at 32,767, so every Step 1 run on Windows died
168+
# with WinError 206, fell back to the raw task, and left orchestration_prompt
169+
# empty; Steps 2 and 3 then skipped issue and branch creation and the whole
170+
# SDLC chain quietly did nothing while the run reported OK.
171+
#
172+
# Capping here rather than transporting it all and discarding it there.
173+
# prompt_gen_expert_caller already rendered only affected_methods[:10], so
174+
# that half of this is byte-identical to what it produced before. danger_zones
175+
# it rendered in full, so the totals travel alongside the sample and the
176+
# grounding header states how many were omitted -- a cap that hides its own
177+
# size is how a prompt starts lying about the codebase it describes.
178+
_CG_SAMPLE = 10
164179
_call_graph_json = _json.dumps(
165180
{
166181
"risk_level": call_graph_risk_level,
167-
"danger_zones": call_graph_danger_zones,
168-
"affected_methods": call_graph_affected_methods,
182+
"danger_zones": call_graph_danger_zones[:_CG_SAMPLE],
183+
"danger_zones_total": len(call_graph_danger_zones),
184+
"affected_methods": call_graph_affected_methods[:_CG_SAMPLE],
185+
"affected_methods_total": len(call_graph_affected_methods),
169186
}
170187
)
171188
_kg_routing_json = _json.dumps(kg_routing_result)
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Step 1's call-graph argument must fit in a Windows command line.
2+
3+
The payload is passed to prompt_gen_expert_caller as a command-line argument. On
4+
this repository the ungated lists serialise to 1,031,711 characters -- 5,527
5+
affected methods and 940 danger zones -- against a Windows CreateProcess limit of
6+
32,767. Every Step 1 run on Windows therefore died with WinError 206, fell back
7+
to the raw task, and left orchestration_prompt empty. Steps 2 and 3 then skipped
8+
issue and branch creation, and the run reported OK having done nothing.
9+
10+
That failure was silent for a month. It is the reason these tests assert on the
11+
SIZE of what is sent rather than only on the content: a correctness test would
12+
have passed throughout, because the values were right and merely undeliverable.
13+
14+
The receiver already truncated affected_methods to ten, so capping the sender
15+
changes nothing about the rendered prompt for that list. danger_zones it rendered
16+
in full, so the totals are sent alongside the sample and _render_sample states
17+
what was omitted -- a truncation the reader cannot see is worse than none, since
18+
the prompt then asserts something false about the codebase.
19+
"""
20+
21+
import json
22+
import sys
23+
from pathlib import Path
24+
25+
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
26+
if str(_PROJECT_ROOT) not in sys.path:
27+
sys.path.insert(0, str(_PROJECT_ROOT))
28+
29+
from langgraph_engine.sdlc_pipeline.architecture.prompt_gen_expert_caller import _render_sample # noqa: E402
30+
31+
WINDOWS_CMDLINE_LIMIT = 32767
32+
33+
# Shaped like the real thing on this repository, big enough that an uncapped
34+
# payload cannot fit. Built rather than measured so the test does not depend on
35+
# how large the repository happens to be on the day it runs.
36+
MANY_METHODS = ["langgraph_engine.module_%d.Class%d.method_%d" % (i, i, i) for i in range(5527)]
37+
MANY_ZONES = ["langgraph_engine/danger/zone_%d.py" % i for i in range(940)]
38+
39+
40+
def _build_payload(danger_zones, affected_methods, sample=10):
41+
"""Build the argument exactly as step1_task_analysis_node builds it."""
42+
return json.dumps(
43+
{
44+
"risk_level": "high",
45+
"danger_zones": danger_zones[:sample],
46+
"danger_zones_total": len(danger_zones),
47+
"affected_methods": affected_methods[:sample],
48+
"affected_methods_total": len(affected_methods),
49+
}
50+
)
51+
52+
53+
class TestThePayloadFitsInACommandLine:
54+
"""The size property the outage was caused by."""
55+
56+
def test_a_large_graph_still_fits(self):
57+
payload = _build_payload(MANY_ZONES, MANY_METHODS)
58+
assert len(payload) < WINDOWS_CMDLINE_LIMIT, "payload is %d chars, over the %d Windows limit" % (
59+
len(payload),
60+
WINDOWS_CMDLINE_LIMIT,
61+
)
62+
63+
def test_the_uncapped_payload_would_not_fit(self):
64+
"""The half that proves the cap is what makes the difference.
65+
66+
Without this, the test above passes on any repository small enough to fit
67+
anyway, and would have passed throughout the outage.
68+
"""
69+
uncapped = json.dumps({"risk_level": "high", "danger_zones": MANY_ZONES, "affected_methods": MANY_METHODS})
70+
assert len(uncapped) > WINDOWS_CMDLINE_LIMIT
71+
72+
def test_the_totals_survive_the_cap(self):
73+
"""A sample without its total cannot be reported honestly downstream."""
74+
payload = json.loads(_build_payload(MANY_ZONES, MANY_METHODS))
75+
assert payload["danger_zones_total"] == 940
76+
assert payload["affected_methods_total"] == 5527
77+
assert len(payload["danger_zones"]) == 10
78+
assert len(payload["affected_methods"]) == 10
79+
80+
81+
class TestTheRenderedSampleDeclaresWhatItOmitted:
82+
"""A cap the reader cannot see makes the prompt assert something false."""
83+
84+
def test_a_truncated_list_states_the_true_total(self):
85+
rendered = _render_sample(MANY_ZONES[:10], total=940)
86+
assert "showing 10 of 940" in rendered
87+
88+
def test_a_complete_list_carries_no_suffix(self):
89+
"""The half that fails if the suffix were unconditional."""
90+
rendered = _render_sample(["a", "b"], total=2)
91+
assert rendered == "a, b"
92+
assert "showing" not in rendered
93+
94+
def test_an_absent_total_is_treated_as_complete(self):
95+
"""An older caller sends no total; it must not be reported as truncated."""
96+
rendered = _render_sample(["a", "b"], total=None)
97+
assert rendered == "a, b"
98+
assert "showing" not in rendered
99+
100+
def test_an_empty_list_renders_as_none(self):
101+
assert _render_sample([], total=0) == "none"
102+
assert _render_sample(None, total=None) == "none"
103+
104+
105+
class TestEntriesAreDictsNotStrings:
106+
"""The second bug, which the first one hid.
107+
108+
The analyser yields dicts carrying fqn / callers_count / risk. The previous
109+
code joined them directly, which raises TypeError on a dict. It never
110+
surfaced because the argument carrying them blew the Windows command-line
111+
limit and the caller died before reaching the join -- so fixing only the size
112+
would have swapped a WinError for a TypeError.
113+
"""
114+
115+
def test_a_dict_entry_renders_its_fqn(self):
116+
entries = [{"fqn": "pkg.mod.Class.method", "callers_count": 42, "risk": "high"}]
117+
assert _render_sample(entries, total=1) == "pkg.mod.Class.method"
118+
119+
def test_the_old_join_would_have_raised_on_these(self):
120+
"""Pins why this is a separate defect rather than a style preference."""
121+
entries = [{"fqn": "pkg.mod.Class.method", "callers_count": 42}]
122+
try:
123+
", ".join(entries)
124+
except TypeError:
125+
return
126+
raise AssertionError("joining dicts must raise TypeError, or this test proves nothing")
127+
128+
def test_a_plain_string_entry_still_works(self):
129+
"""Older callers, and hot_nodes, may still send strings."""
130+
assert _render_sample(["a.b.c"], total=1) == "a.b.c"
131+
132+
def test_an_unexpected_shape_degrades_instead_of_raising(self):
133+
"""Prompt assembly must not die because an entry grew a new shape."""
134+
assert _render_sample([{"unexpected": 1}], total=1) != ""
135+
136+
137+
class TestTheNodeSendsTheCappedShape:
138+
"""The cap must live in the node, not only in this test's helper."""
139+
140+
def test_the_node_source_caps_both_lists_and_sends_totals(self):
141+
source = (_PROJECT_ROOT / "langgraph_engine" / "sdlc_pipeline" / "nodes" / "task_orchestration.py").read_text(
142+
encoding="utf-8"
143+
)
144+
assert "danger_zones_total" in source, "the node must send the danger_zones total"
145+
assert "affected_methods_total" in source, "the node must send the affected_methods total"
146+
assert "_CG_SAMPLE" in source, "the node must cap the lists before serialising"

0 commit comments

Comments
 (0)