Skip to content

Commit fbeca5b

Browse files
jbennett-csmraible
andauthored
Fix execution skill: recognize "Completed" as a terminal success status (#52)
* Fix execution skill: recognize "Completed" as a terminal success status trigger_workflow.py --wait and monitor_execution.py were checking only for a "Succeeded" status, but live testing against the execution-results API showed a normal successful execution reports "Completed" instead — so both scripts polled to their full timeout and exited 1 on every successful run, misreporting success as failure/timeout. Add "completed" to TERMINAL_STATUSES and a new SUCCESS_STATUSES set in get_execution_results.py (the shared source of truth both other scripts import from), use it for monitor_execution.py's exit code, and document the Succeeded/Completed distinction in SKILL.md. Verified against two real executions: monitor_execution.py now reports "Completed" within seconds (previously ran to the full timeout), and trigger_workflow.py --wait no longer false-timeouts on a normal run. * Add regression tests and changelog entry for terminal-status fix - test_get_execution_results.py: guard "completed" is terminal, and add a TestSuccessStatuses class covering SUCCESS_STATUSES. - test_monitor_execution.py: regression test reproducing the reported bug (a "Completed" result must exit 0, not fall to the non-zero branch). - test_trigger_workflow.py: regression test for poll_results recognizing "Completed" as terminal (previously ran to the full --wait timeout). - CHANGELOG.md: document the fix under [Unreleased] / Fixed. * docs: reflect completed as a success status in console-verification table get_execution_results.py now treats succeeded or completed as success (SUCCESS_STATUSES); update the console-verification 'Determine success' row to match. --------- Co-authored-by: Matt Raible <matt.raible@crowdstrike.com>
1 parent 694bfdb commit fbeca5b

8 files changed

Lines changed: 91 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
1212

1313
### Fixed
1414

15+
- **`monitor_execution.py` and `trigger_workflow.py --wait` no longer misreport a successful execution as a failure/timeout.** Both polled only for a `Succeeded` terminal status, but live testing against the execution-results API showed a normal successful execution reports `Completed` — so every successful run polled to its full timeout and exited non-zero. `get_execution_results.py`'s shared `TERMINAL_STATUSES` now includes `completed`, and a new `SUCCESS_STATUSES` set (both scripts' single source of truth for the exit-code decision) treats `succeeded` and `completed` as success.
1516
- **`validate.py` now flags `WorkflowCustomVariable.<name>` references to variables that nothing declares** — a release-only failure. A reference to a custom variable that no `CreateVariable` (or `UpdateVariable` setter) declares imports and validates cleanly, then fails at release with `property "..." contains unknown variable "WorkflowCustomVariable.<name>"`. The validator now collects declared variable names and reports an undeclared reference before you deploy.
1617
- **`validate.py` no longer rejects valid action IDs that aren't 32-char hex.** The action-ID check assumed every ID was a 32-character hex string (or a `<hex>_<hex>` / `<hex>~<hex>` compound), but real catalog actions carry other shapes — a 26-character ULID joined to a hex id (custom IOC / API-integration actions), unequal compound halves (event query actions), and longer hex strings (RTR actions). Those imported fine yet were flagged as invalid locally. IDs are now treated as opaque catalog identifiers, so the check still rejects placeholders (UPPER_SNAKE tokens, punctuation, all-same-character) without blocking real IDs. Docs updated to describe action IDs as identifiers you look up rather than "32-char hex".
1718

skills/deployment/references/console-verification.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ cannot do, and uses the API for everything deterministic:
5252
| Render-test (Signal/Scheduled/SubModel) | **Browser** | No API reports "did the canvas draw" — open the editor and watch the console |
5353
| Configure an HTTP-action credential | **Browser** | Fusion has no API to create an HTTP-action credential |
5454
| Execute an On-demand workflow | **API** | `trigger_workflow.py --wait` triggers it |
55-
| Determine success | **API** | `get_execution_results.py` checks `status == succeeded` — no UI eyeballing |
55+
| Determine success | **API** | `get_execution_results.py` treats `succeeded` or `completed` as success — no UI eyeballing |
5656

5757
So a credential-gated On-demand workflow is handled in two steps: the browser
5858
configures the VirusTotal credential and publishes it, then the harness executes

skills/execution/SKILL.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ metadata:
3535
3636
This skill runs Fusion workflows that are already deployed and released, watches them to completion, retrieves their output, and helps debug failures. Writing the YAML happens in the **authoring** skill; importing and releasing happens in the **deployment** skill.
3737

38-
An execution moves through states and ends in a **terminal** state. The terminal states are `Succeeded`, `Failed`, `Canceled`, `NonRecoverable`, and `ActionRequired`. (`ActionRequired` is terminal for polling: it waits on human input and will not progress on its own.) Anything else means the execution is still running.
38+
An execution moves through states and ends in a **terminal** state. The terminal states are `Succeeded`, `Completed`, `Failed`, `Canceled`, `NonRecoverable`, and `ActionRequired`. (`ActionRequired` is terminal for polling: it waits on human input and will not progress on its own.) Anything else means the execution is still running.
39+
40+
> **`Succeeded` vs `Completed`.** Live testing against the execution-results API found that a normal successful execution reports its top-level `status` as `Completed`, not `Succeeded` — the scripts in this skill treat both as success (see `SUCCESS_STATUSES` in `get_execution_results.py`). Don't assume `Succeeded` is the only success value when reading raw API output yourself.
3941
4042
> **Running the scripts.** Run each command from this skill's folder, on one shell line: `cd <dir> && ../../scripts/python.sh scripts/<name>.py` (a sibling skill's script is `../<skill>/scripts/<name>.py`). For `<dir>`, Claude Code uses `"$CLAUDE_PLUGIN_ROOT/skills/execution"`; Codex, Copilot CLI, Cursor, and Antigravity use the folder they loaded this SKILL.md from (e.g. `~/.agents/skills/execution`). The wrapper bootstraps its own Python venv.
4143
@@ -145,7 +147,7 @@ Parameters come from `--params` (a JSON string) or interactive prompts derived f
145147
../../scripts/python.sh scripts/monitor_execution.py --execution-id <exec_id> --interval 10 --timeout 600 --json
146148
```
147149

148-
Defaults: `--interval 5`, `--timeout 300`. Prints status updates to stderr and the final result to stdout. Exits `0` only when the execution `Succeeded`; non-zero on any other terminal state or timeout, so CI can react.
150+
Defaults: `--interval 5`, `--timeout 300`. Prints status updates to stderr and the final result to stdout. Exits `0` only on a successful terminal state (`Succeeded` or `Completed`); non-zero on any other terminal state or timeout, so CI can react.
149151

150152
### get_execution_results.py
151153

skills/execution/scripts/get_execution_results.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,12 @@
3434
# Terminal statuses returned by the execution-results API. The API returns
3535
# capitalized values; callers should match case-insensitively. "ActionRequired"
3636
# is terminal for polling purposes — it waits on human input and won't progress
37-
# on its own.
38-
TERMINAL_STATUSES = {"succeeded", "failed", "canceled", "nonrecoverable", "actionrequired"}
37+
# on its own. Live testing confirmed a normal successful execution reports
38+
# "Completed" (not "Succeeded") at the top level — both are treated as success
39+
# below via SUCCESS_STATUSES; "succeeded" is kept for forward/back compat in
40+
# case another execution path ever returns it.
41+
TERMINAL_STATUSES = {"succeeded", "completed", "failed", "canceled", "nonrecoverable", "actionrequired"}
42+
SUCCESS_STATUSES = {"succeeded", "completed"}
3943

4044

4145
def fetch_results(execution_id):

skills/execution/scripts/monitor_execution.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
Monitor a CrowdStrike Fusion workflow execution until it completes.
33
44
Polls the execution-results API at a fixed interval until the execution reaches
5-
a terminal state (succeeded, failed, canceled, nonrecoverable, actionrequired)
6-
or the timeout elapses. Status updates are printed to stderr so the final
7-
result on stdout stays clean for piping.
5+
a terminal state (succeeded, completed, failed, canceled, nonrecoverable,
6+
actionrequired) or the timeout elapses. Status updates are printed to stderr so
7+
the final result on stdout stays clean for piping.
88
99
Usage:
1010
python monitor_execution.py --execution-id <exec_id>
@@ -21,7 +21,7 @@
2121
# fetch_results and TERMINAL_STATUSES live alongside this script; they own the
2222
# auth client and API-response parsing, so this script needs no direct client.
2323
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
24-
from get_execution_results import fetch_results, TERMINAL_STATUSES # pylint: disable=wrong-import-position
24+
from get_execution_results import fetch_results, TERMINAL_STATUSES, SUCCESS_STATUSES # pylint: disable=wrong-import-position
2525

2626
# Fix Windows console encoding
2727
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
@@ -98,7 +98,7 @@ def main():
9898
print(f" Output:\n{json.dumps(output, indent=4)}")
9999

100100
# Non-zero exit for non-successful terminal states so callers/CI can react.
101-
sys.exit(0 if status.lower() == "succeeded" else 1)
101+
sys.exit(0 if status.lower() in SUCCESS_STATUSES else 1)
102102

103103

104104
if __name__ == "__main__":

tests/test_get_execution_results.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,29 @@ def test_expected_terminal_statuses_present(self):
9191
"""The documented terminal states are covered."""
9292
assert {"succeeded", "failed", "canceled"} <= get_execution_results.TERMINAL_STATUSES
9393

94+
def test_completed_is_terminal(self):
95+
"""Live testing showed the API reports "Completed" (not "Succeeded")
96+
for a normal successful execution — it must be terminal or pollers
97+
run to their full timeout on every successful run."""
98+
assert "completed" in get_execution_results.TERMINAL_STATUSES
99+
100+
101+
class TestSuccessStatuses:
102+
"""Guard the success subset used to decide exit codes."""
103+
104+
def test_success_statuses_lowercase(self):
105+
"""All entries are lowercase so case-insensitive matching works."""
106+
for status in get_execution_results.SUCCESS_STATUSES:
107+
assert status == status.lower()
108+
109+
def test_success_statuses_are_terminal(self):
110+
"""Every success status must also be a terminal status."""
111+
assert get_execution_results.SUCCESS_STATUSES <= get_execution_results.TERMINAL_STATUSES
112+
113+
def test_succeeded_and_completed_are_success(self):
114+
"""Both observed success values are treated as success."""
115+
assert {"succeeded", "completed"} <= get_execution_results.SUCCESS_STATUSES
116+
94117

95118
class TestMain:
96119
"""Test the CLI entry point and exit codes."""

tests/test_monitor_execution.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,21 @@ def test_monitor_returns_terminal_result(self, monkeypatch):
2626
assert result is not None
2727
assert result["status"] == "Succeeded"
2828

29+
def test_monitor_returns_on_completed(self, monkeypatch):
30+
"""Regression: a terminal "Completed" status (the real API's success
31+
value) must also end the loop immediately, not run to the timeout."""
32+
mock_client = MagicMock()
33+
mock_client.execution_results.return_value = {
34+
"status_code": 200,
35+
"body": {"resources": [{"status": "Completed"}], "errors": []},
36+
"headers": {},
37+
}
38+
monkeypatch.setattr(get_execution_results, "get_client", lambda: mock_client)
39+
monkeypatch.setattr(monitor_execution.time, "sleep", lambda _s: None)
40+
result = monitor_execution.monitor("exec_123", interval=0.1, timeout=5)
41+
assert result is not None
42+
assert result["status"] == "Completed"
43+
2944
def test_monitor_in_progress_then_complete(self, monkeypatch):
3045
"""The loop keeps polling while non-terminal, then returns on completion."""
3146
statuses = ["Running", "Running", "Succeeded"]
@@ -119,6 +134,25 @@ def test_main_succeeded_exits_0(self, monkeypatch, capsys):
119134
assert "Succeeded" in out
120135
assert "done" in out
121136

137+
def test_main_completed_exits_0(self, monkeypatch, capsys):
138+
"""Regression: the real API's "Completed" success status must exit 0,
139+
not fall through to the non-zero branch like it did before this fix
140+
(which misreported every successful run as a failure)."""
141+
monkeypatch.setattr(
142+
"sys.argv",
143+
["monitor_execution.py", "--execution-id", "exec_123"],
144+
)
145+
monkeypatch.setattr(
146+
monitor_execution,
147+
"monitor",
148+
lambda *_a, **_k: {"status": "Completed", "output": {"done": True}},
149+
)
150+
with pytest.raises(SystemExit) as exc:
151+
monitor_execution.main()
152+
assert exc.value.code == 0
153+
out = capsys.readouterr().out
154+
assert "Completed" in out
155+
122156
def test_main_failed_exits_1(self, monkeypatch):
123157
"""A non-succeeded terminal state exits non-zero for CI."""
124158
monkeypatch.setattr(

tests/test_trigger_workflow.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,23 @@ def test_poll_succeeded_returns_result(self, monkeypatch):
398398
assert result["status"] == "Succeeded"
399399
assert result["output"] == {"key": "value"}
400400

401+
def test_poll_completed_returns_result(self, monkeypatch):
402+
"""Regression: live testing showed a normal successful execution
403+
reports "Completed", not "Succeeded" — before this fix, poll_results
404+
never recognized it as terminal and ran to the full timeout on every
405+
successful --wait run."""
406+
mock_client = MagicMock()
407+
mock_client.execution_results.return_value = {
408+
"status_code": 200,
409+
"body": {"resources": [{"status": "Completed", "output": {"key": "value"}}]},
410+
"headers": {},
411+
}
412+
monkeypatch.setattr(get_execution_results, "get_client", lambda: mock_client)
413+
monkeypatch.setattr(trigger_workflow.time, "sleep", lambda _s: None)
414+
result = trigger_workflow.poll_results("fake_id", timeout=5, interval=0.1)
415+
assert result is not None
416+
assert result["status"] == "Completed"
417+
401418
def test_poll_failed_returns_result(self, monkeypatch):
402419
"""Verify capitalized Failed status is terminal (not retried forever)."""
403420
mock_client = MagicMock()

0 commit comments

Comments
 (0)