agentic-devtools 0.2.332__py3-none-any.whl → 0.2.334__py3-none-any.whl
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.
- agentic_devtools/_version.py +2 -2
- agentic_devtools/adapters/__init__.py +8 -0
- agentic_devtools/adapters/idempotency_query_provider.py +64 -0
- agentic_devtools/adapters/issue_provider.py +65 -0
- agentic_devtools/adapters/jira_provider.py +140 -75
- agentic_devtools/adapters/operation_plan.py +111 -0
- agentic_devtools/adapters/orchestration_key.py +39 -15
- agentic_devtools/adapters/plan_manifest.py +437 -0
- agentic_devtools/orchestration/graph_builder.py +15 -5
- agentic_devtools/orchestration/nodes/__init__.py +31 -0
- agentic_devtools/orchestration/nodes/_helpers.py +261 -0
- agentic_devtools/orchestration/nodes/checklist_creation.py +139 -0
- agentic_devtools/orchestration/nodes/commit.py +169 -0
- agentic_devtools/orchestration/nodes/completion.py +163 -0
- agentic_devtools/orchestration/nodes/implementation.py +395 -0
- agentic_devtools/orchestration/nodes/implementation_review.py +126 -0
- agentic_devtools/orchestration/nodes/initiate.py +136 -0
- agentic_devtools/orchestration/nodes/planning.py +221 -0
- agentic_devtools/orchestration/nodes/pull_request.py +256 -0
- agentic_devtools/orchestration/nodes/retrieve.py +198 -0
- agentic_devtools/orchestration/nodes/setup.py +123 -0
- agentic_devtools/orchestration/nodes/verification.py +96 -0
- agentic_devtools/orchestration/pilot_workflow.py +53 -21
- agentic_devtools/orchestration/runner.py +62 -9
- agentic_devtools/orchestration/state_schema.py +24 -0
- {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/METADATA +1 -1
- {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/RECORD +30 -14
- {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/WHEEL +0 -0
- {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/entry_points.txt +0 -0
- {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Retrieve node: fetch issue details from Jira or GitHub.
|
|
2
|
+
|
|
3
|
+
Handles both Jira (via ``agdt-get-jira-issue``) and GitHub
|
|
4
|
+
(via ``GitHubIssuesAdapter.get_issue()``) issue retrieval paths.
|
|
5
|
+
Normalizes issue data into a common state format.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from agentic_devtools.cli.github.repo_resolution import resolve_github_repo_safe
|
|
15
|
+
from agentic_devtools.cli.jira.adf import _convert_adf_to_text
|
|
16
|
+
from agentic_devtools.orchestration.nodes._helpers import (
|
|
17
|
+
detect_issue_provider,
|
|
18
|
+
normalize_issue_key,
|
|
19
|
+
run_command,
|
|
20
|
+
utc_now,
|
|
21
|
+
)
|
|
22
|
+
from agentic_devtools.orchestration.state_schema import WorkOnIssueState
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def retrieve_node(state: WorkOnIssueState) -> dict[str, Any]:
|
|
26
|
+
"""Fetch issue details and store normalized data in state.
|
|
27
|
+
|
|
28
|
+
Dispatches to Jira or GitHub retrieval based on ``issue_provider``.
|
|
29
|
+
"""
|
|
30
|
+
issue_key = state.get("issue_key", "")
|
|
31
|
+
# Fail fast when issue_key is missing, blank, or not a string — provider
|
|
32
|
+
# detection and Jira/GitHub retrieval both require a valid key.
|
|
33
|
+
if not isinstance(issue_key, str):
|
|
34
|
+
return _error_result("issue_key is required and must be a non-empty string")
|
|
35
|
+
issue_key = issue_key.strip()
|
|
36
|
+
if not issue_key:
|
|
37
|
+
return _error_result("issue_key is required and must be a non-empty string")
|
|
38
|
+
raw_issue_provider = state.get("issue_provider")
|
|
39
|
+
issue_provider = (
|
|
40
|
+
raw_issue_provider
|
|
41
|
+
if isinstance(raw_issue_provider, str) and raw_issue_provider in {"jira", "github"}
|
|
42
|
+
else detect_issue_provider(issue_key)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if issue_provider == "jira":
|
|
46
|
+
result = _retrieve_jira(issue_key)
|
|
47
|
+
else:
|
|
48
|
+
result = _retrieve_github(issue_key)
|
|
49
|
+
|
|
50
|
+
# Propagate the (potentially derived) issue_provider back to state so downstream
|
|
51
|
+
# nodes (e.g. pull_request_node) don't default to Jira/Azure DevOps when
|
|
52
|
+
# issue_provider was absent or corrupted in the checkpoint.
|
|
53
|
+
result["issue_provider"] = issue_provider
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _retrieve_jira(issue_key: str) -> dict[str, Any]:
|
|
58
|
+
"""Retrieve issue details from Jira via agdt commands."""
|
|
59
|
+
# Set the issue key in state
|
|
60
|
+
set_result = run_command(["agdt-set", "jira.issue_key", issue_key])
|
|
61
|
+
if set_result.returncode != 0:
|
|
62
|
+
return _error_result(f"Failed to set jira.issue_key: {set_result.stderr.strip()}")
|
|
63
|
+
|
|
64
|
+
# Invoke agdt-get-jira-issue (background task)
|
|
65
|
+
get_result = run_command(["agdt-get-jira-issue"])
|
|
66
|
+
if get_result.returncode != 0:
|
|
67
|
+
return _error_result(f"Failed to invoke agdt-get-jira-issue: {get_result.stderr.strip()}")
|
|
68
|
+
|
|
69
|
+
# Wait for task completion
|
|
70
|
+
wait_result = run_command(["agdt-task-wait"], timeout=120)
|
|
71
|
+
if wait_result.returncode != 0:
|
|
72
|
+
return _error_result(f"agdt-task-wait failed: {wait_result.stderr.strip()}")
|
|
73
|
+
|
|
74
|
+
# Read the output file
|
|
75
|
+
from agentic_devtools.state import get_state_dir
|
|
76
|
+
|
|
77
|
+
state_dir = get_state_dir()
|
|
78
|
+
output_file = Path(state_dir) / "temp-get-issue-details-response.json"
|
|
79
|
+
|
|
80
|
+
if not output_file.exists():
|
|
81
|
+
return _error_result(f"Issue details output file not found: {output_file}")
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
issue_data = json.loads(output_file.read_text(encoding="utf-8"))
|
|
85
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
86
|
+
return _error_result(f"Failed to parse issue details: {exc}")
|
|
87
|
+
if not isinstance(issue_data, dict):
|
|
88
|
+
return _error_result("Failed to parse issue details: expected a JSON object.")
|
|
89
|
+
|
|
90
|
+
# Normalize issue data
|
|
91
|
+
normalized = _normalize_jira_issue(issue_data, issue_key)
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
"step": "retrieve",
|
|
95
|
+
"error": None,
|
|
96
|
+
"issue_data": normalized,
|
|
97
|
+
"issue_retrieved": True,
|
|
98
|
+
"events": [
|
|
99
|
+
{
|
|
100
|
+
"event": "retrieve_completed",
|
|
101
|
+
"timestamp": utc_now(),
|
|
102
|
+
"signals": {"provider": "jira", "issue_key": issue_key},
|
|
103
|
+
}
|
|
104
|
+
],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _retrieve_github(issue_key: str) -> dict[str, Any]:
|
|
109
|
+
"""Retrieve issue details from GitHub via adapter."""
|
|
110
|
+
try:
|
|
111
|
+
from agentic_devtools.adapters.github_adapter import GitHubIssuesAdapter
|
|
112
|
+
|
|
113
|
+
normalized_issue_key = normalize_issue_key(issue_key)
|
|
114
|
+
if not normalized_issue_key:
|
|
115
|
+
return _error_result("GitHub issue retrieval failed: issue key is empty after normalization.")
|
|
116
|
+
repo = resolve_github_repo_safe()
|
|
117
|
+
if not repo:
|
|
118
|
+
return {
|
|
119
|
+
"step": "retrieve",
|
|
120
|
+
"error": "Cannot resolve GitHub repository from state or git remote.",
|
|
121
|
+
"issue_data": {},
|
|
122
|
+
"issue_retrieved": False,
|
|
123
|
+
"events": [
|
|
124
|
+
{
|
|
125
|
+
"event": "retrieve_failed",
|
|
126
|
+
"timestamp": utc_now(),
|
|
127
|
+
"signals": {"provider": "github", "issue_key": issue_key},
|
|
128
|
+
}
|
|
129
|
+
],
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
adapter = GitHubIssuesAdapter(repo=repo)
|
|
133
|
+
issue_data = adapter.get_issue(normalized_issue_key)
|
|
134
|
+
|
|
135
|
+
normalized: dict[str, Any] = {
|
|
136
|
+
"key": normalized_issue_key,
|
|
137
|
+
"provider": "github",
|
|
138
|
+
"summary": issue_data.get("title", ""),
|
|
139
|
+
"description": issue_data.get("description", ""),
|
|
140
|
+
"status": issue_data.get("status", "open"),
|
|
141
|
+
"labels": issue_data.get("labels", []),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
"step": "retrieve",
|
|
146
|
+
"error": None,
|
|
147
|
+
"issue_data": normalized,
|
|
148
|
+
"issue_retrieved": True,
|
|
149
|
+
"events": [
|
|
150
|
+
{
|
|
151
|
+
"event": "retrieve_completed",
|
|
152
|
+
"timestamp": utc_now(),
|
|
153
|
+
"signals": {"provider": "github", "issue_key": issue_key},
|
|
154
|
+
}
|
|
155
|
+
],
|
|
156
|
+
}
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
return _error_result(f"GitHub issue retrieval failed: {exc}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _normalize_jira_issue(raw: dict[str, Any], issue_key: str) -> dict[str, Any]:
|
|
162
|
+
"""Normalize raw Jira API response to common format."""
|
|
163
|
+
raw_fields = raw.get("fields")
|
|
164
|
+
fields: dict[str, Any] = raw_fields if isinstance(raw_fields, dict) else {}
|
|
165
|
+
status_field = fields.get("status")
|
|
166
|
+
status_name = status_field.get("name", "") if isinstance(status_field, dict) else ""
|
|
167
|
+
issuetype_field = fields.get("issuetype")
|
|
168
|
+
issue_type_name = issuetype_field.get("name", "") if isinstance(issuetype_field, dict) else ""
|
|
169
|
+
parent_field = fields.get("parent")
|
|
170
|
+
parent_key = parent_field.get("key") if isinstance(parent_field, dict) else None
|
|
171
|
+
labels = fields.get("labels", [])
|
|
172
|
+
return {
|
|
173
|
+
"key": issue_key,
|
|
174
|
+
"provider": "jira",
|
|
175
|
+
"summary": _convert_adf_to_text(fields.get("summary")),
|
|
176
|
+
"description": _convert_adf_to_text(fields.get("description")),
|
|
177
|
+
"status": status_name,
|
|
178
|
+
"issue_type": issue_type_name,
|
|
179
|
+
"labels": labels if isinstance(labels, list) else [],
|
|
180
|
+
"parent_key": parent_key,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _error_result(message: str) -> dict[str, Any]:
|
|
185
|
+
"""Build an error result dict."""
|
|
186
|
+
return {
|
|
187
|
+
"step": "retrieve",
|
|
188
|
+
"error": message,
|
|
189
|
+
"issue_data": {},
|
|
190
|
+
"issue_retrieved": False,
|
|
191
|
+
"events": [
|
|
192
|
+
{
|
|
193
|
+
"event": "retrieve_failed",
|
|
194
|
+
"timestamp": utc_now(),
|
|
195
|
+
"signals": {"error": message},
|
|
196
|
+
}
|
|
197
|
+
],
|
|
198
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Setup node: validate branch context and create branch if needed.
|
|
2
|
+
|
|
3
|
+
Validates that the current git branch contains the issue key as a
|
|
4
|
+
slash-delimited segment (case-insensitive). If validation fails, creates
|
|
5
|
+
a new branch named ``feature/{issue_key}/implementation`` in the current
|
|
6
|
+
repository.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from agentic_devtools.orchestration.nodes._helpers import (
|
|
14
|
+
normalize_issue_key,
|
|
15
|
+
run_command,
|
|
16
|
+
utc_now,
|
|
17
|
+
)
|
|
18
|
+
from agentic_devtools.orchestration.state_schema import WorkOnIssueState
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _branch_targets_issue(branch_name: str, normalized_issue_key: str) -> bool:
|
|
22
|
+
"""Return whether a slash-delimited branch name targets the given issue key."""
|
|
23
|
+
normalized_segments = [segment.strip().lower() for segment in branch_name.split("/") if segment.strip()]
|
|
24
|
+
return normalized_issue_key.lower() in normalized_segments
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def setup_node(state: WorkOnIssueState) -> dict[str, Any]:
|
|
28
|
+
"""Validate worktree/branch and create if needed.
|
|
29
|
+
|
|
30
|
+
Checks that the current git branch contains the issue key as a
|
|
31
|
+
slash-delimited branch segment (case-insensitive). If not, attempts
|
|
32
|
+
to create a new branch.
|
|
33
|
+
"""
|
|
34
|
+
issue_key = state.get("issue_key", "")
|
|
35
|
+
if not isinstance(issue_key, str):
|
|
36
|
+
type_name = type(issue_key).__name__
|
|
37
|
+
return {
|
|
38
|
+
"step": "setup",
|
|
39
|
+
"error": "issue_key must be a string; cannot determine target branch",
|
|
40
|
+
"events": [
|
|
41
|
+
{
|
|
42
|
+
"event": "setup_failed",
|
|
43
|
+
"timestamp": utc_now(),
|
|
44
|
+
"signals": {"error": f"issue_key validation failed: expected string, got {type_name}"},
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
normalized_key = normalize_issue_key(issue_key)
|
|
50
|
+
|
|
51
|
+
if not normalized_key:
|
|
52
|
+
return {
|
|
53
|
+
"step": "setup",
|
|
54
|
+
"error": "issue_key is missing or empty; cannot determine target branch",
|
|
55
|
+
"events": [
|
|
56
|
+
{
|
|
57
|
+
"event": "setup_failed",
|
|
58
|
+
"timestamp": utc_now(),
|
|
59
|
+
"signals": {"error": "issue_key is missing or empty"},
|
|
60
|
+
}
|
|
61
|
+
],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Get current branch name
|
|
65
|
+
result = run_command(["git", "rev-parse", "--abbrev-ref", "HEAD"])
|
|
66
|
+
if result.returncode != 0:
|
|
67
|
+
return {
|
|
68
|
+
"step": "setup",
|
|
69
|
+
"error": f"Failed to determine current branch: {result.stderr.strip()}",
|
|
70
|
+
"events": [
|
|
71
|
+
{
|
|
72
|
+
"event": "setup_failed",
|
|
73
|
+
"timestamp": utc_now(),
|
|
74
|
+
"signals": {"error": result.stderr.strip()},
|
|
75
|
+
}
|
|
76
|
+
],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
current_branch = result.stdout.strip()
|
|
80
|
+
|
|
81
|
+
# Check if branch contains the issue key as an exact branch segment.
|
|
82
|
+
if _branch_targets_issue(current_branch, normalized_key):
|
|
83
|
+
return {
|
|
84
|
+
"step": "setup",
|
|
85
|
+
"error": None,
|
|
86
|
+
"setup_complete": True,
|
|
87
|
+
"events": [
|
|
88
|
+
{
|
|
89
|
+
"event": "setup_completed",
|
|
90
|
+
"timestamp": utc_now(),
|
|
91
|
+
"signals": {"branch": current_branch, "action": "validated"},
|
|
92
|
+
}
|
|
93
|
+
],
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# Branch doesn't contain issue key — create a new branch
|
|
97
|
+
new_branch = f"feature/{normalized_key}/implementation"
|
|
98
|
+
create_result = run_command(["git", "checkout", "-b", new_branch])
|
|
99
|
+
if create_result.returncode != 0:
|
|
100
|
+
return {
|
|
101
|
+
"step": "setup",
|
|
102
|
+
"error": f"Failed to create branch {new_branch}: {create_result.stderr.strip()}",
|
|
103
|
+
"events": [
|
|
104
|
+
{
|
|
105
|
+
"event": "setup_failed",
|
|
106
|
+
"timestamp": utc_now(),
|
|
107
|
+
"signals": {"error": create_result.stderr.strip()},
|
|
108
|
+
}
|
|
109
|
+
],
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
"step": "setup",
|
|
114
|
+
"error": None,
|
|
115
|
+
"setup_complete": True,
|
|
116
|
+
"events": [
|
|
117
|
+
{
|
|
118
|
+
"event": "setup_completed",
|
|
119
|
+
"timestamp": utc_now(),
|
|
120
|
+
"signals": {"branch": new_branch, "action": "created"},
|
|
121
|
+
}
|
|
122
|
+
],
|
|
123
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Verification node: run quality gates and track retry count.
|
|
2
|
+
|
|
3
|
+
Executes ``bash scripts/targeted-checks.sh`` and evaluates results.
|
|
4
|
+
On failure, increments retry count and returns an error. Routing back
|
|
5
|
+
to implementation (when retries remain) or to error_handler (when the
|
|
6
|
+
budget is exhausted) is handled by the graph routing functions in
|
|
7
|
+
``pilot_workflow.py``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from agentic_devtools.orchestration.nodes._helpers import run_command, utc_now
|
|
15
|
+
from agentic_devtools.orchestration.state_schema import WorkOnIssueState
|
|
16
|
+
|
|
17
|
+
# Maximum characters stored in state for verification output.
|
|
18
|
+
# Full output (which may include pytest traces and coverage reports) is
|
|
19
|
+
# truncated before persisting to avoid bloating checkpoints.
|
|
20
|
+
_MAX_STORED_OUTPUT_CHARS = 4000
|
|
21
|
+
# Prefix prepended to the stored tail when output is truncated.
|
|
22
|
+
_TRUNCATION_PREFIX = "[... truncated ...]\n"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def verification_node(state: WorkOnIssueState) -> dict[str, Any]:
|
|
26
|
+
"""Execute quality gates and evaluate pass/fail.
|
|
27
|
+
|
|
28
|
+
Runs ``bash scripts/targeted-checks.sh`` in default check mode.
|
|
29
|
+
Exit code 0 means all checks pass; exit 1-9 indicates failed check count.
|
|
30
|
+
"""
|
|
31
|
+
retry_count = _normalize_retry_count(state.get("retry_count", 0))
|
|
32
|
+
|
|
33
|
+
# Execute quality gates
|
|
34
|
+
result = run_command(
|
|
35
|
+
["bash", "scripts/targeted-checks.sh"],
|
|
36
|
+
timeout=300,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
full_output = result.stdout + result.stderr
|
|
40
|
+
# Truncate for state storage; full output may include large pytest/coverage reports.
|
|
41
|
+
# Prepend a marker so consumers can tell the output was clipped.
|
|
42
|
+
if len(full_output) > _MAX_STORED_OUTPUT_CHARS:
|
|
43
|
+
tail_chars = _MAX_STORED_OUTPUT_CHARS - len(_TRUNCATION_PREFIX)
|
|
44
|
+
stored_output = _TRUNCATION_PREFIX + full_output[-tail_chars:]
|
|
45
|
+
else:
|
|
46
|
+
stored_output = full_output
|
|
47
|
+
|
|
48
|
+
if result.returncode == 0:
|
|
49
|
+
# All checks passed
|
|
50
|
+
return {
|
|
51
|
+
"step": "verification",
|
|
52
|
+
"error": None,
|
|
53
|
+
"retry_count": retry_count,
|
|
54
|
+
"verification_output": stored_output,
|
|
55
|
+
"events": [
|
|
56
|
+
{
|
|
57
|
+
"event": "verification_passed",
|
|
58
|
+
"timestamp": utc_now(),
|
|
59
|
+
"signals": {"exit_code": 0},
|
|
60
|
+
}
|
|
61
|
+
],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Checks failed — increment retry count
|
|
65
|
+
retry_count += 1
|
|
66
|
+
error_msg = (
|
|
67
|
+
f"Quality gate failed (exit code {result.returncode}). Retry {retry_count}.\nOutput:\n{stored_output[:2000]}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
"step": "verification",
|
|
72
|
+
"error": error_msg,
|
|
73
|
+
"retry_count": retry_count,
|
|
74
|
+
"verification_output": stored_output,
|
|
75
|
+
"events": [
|
|
76
|
+
{
|
|
77
|
+
"event": "verification_failed",
|
|
78
|
+
"timestamp": utc_now(),
|
|
79
|
+
"signals": {
|
|
80
|
+
"exit_code": result.returncode,
|
|
81
|
+
"retry_count": retry_count,
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _normalize_retry_count(value: object) -> int:
|
|
89
|
+
"""Return retry_count as int, coercing non-int values to 0."""
|
|
90
|
+
if isinstance(value, bool):
|
|
91
|
+
return 0
|
|
92
|
+
if not isinstance(value, int):
|
|
93
|
+
return 0
|
|
94
|
+
if value < 0:
|
|
95
|
+
return 0
|
|
96
|
+
return value
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Routing helpers plus legacy compatibility stubs for the pilot workflow.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
``build_work_on_issue_graph`` wires real node implementations from
|
|
4
|
+
``agentic_devtools.orchestration.nodes``. The node functions in this module are
|
|
5
|
+
kept only as lightweight compatibility shims for older direct imports and
|
|
6
|
+
legacy unit tests.
|
|
5
7
|
"""
|
|
6
8
|
|
|
7
9
|
from datetime import datetime, timezone
|
|
@@ -27,8 +29,19 @@ def _normalize_retry_count(value: object) -> int:
|
|
|
27
29
|
return value
|
|
28
30
|
|
|
29
31
|
|
|
32
|
+
def _normalize_retry_budget(value: object) -> int:
|
|
33
|
+
"""Return retry_budget as int, falling back to :data:`MAX_RETRIES`."""
|
|
34
|
+
if isinstance(value, bool):
|
|
35
|
+
return MAX_RETRIES
|
|
36
|
+
if not isinstance(value, int):
|
|
37
|
+
return MAX_RETRIES
|
|
38
|
+
if value < 0:
|
|
39
|
+
return MAX_RETRIES
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
30
43
|
# ---------------------------------------------------------------------------
|
|
31
|
-
#
|
|
44
|
+
# Legacy compatibility node shims
|
|
32
45
|
# ---------------------------------------------------------------------------
|
|
33
46
|
|
|
34
47
|
|
|
@@ -199,10 +212,17 @@ def completion_node(state: WorkOnIssueState) -> dict:
|
|
|
199
212
|
|
|
200
213
|
|
|
201
214
|
def error_handler_node(state: WorkOnIssueState) -> dict:
|
|
202
|
-
"""Terminal error handler: log context and mark workflow as failed.
|
|
215
|
+
"""Terminal error handler: log context and mark workflow as failed or blocked.
|
|
216
|
+
|
|
217
|
+
Preserves ``status="blocked"`` when the incoming state already carries that
|
|
218
|
+
status (e.g., from ``planning_node``), so callers can distinguish a blocked
|
|
219
|
+
issue from a hard failure.
|
|
220
|
+
"""
|
|
221
|
+
incoming_status = state.get("status")
|
|
222
|
+
final_status = "blocked" if incoming_status == "blocked" else "failed"
|
|
203
223
|
return {
|
|
204
224
|
"step": "error_handler",
|
|
205
|
-
"status":
|
|
225
|
+
"status": final_status,
|
|
206
226
|
"error": state.get("error"),
|
|
207
227
|
"events": [
|
|
208
228
|
{
|
|
@@ -225,19 +245,15 @@ def error_handler_node(state: WorkOnIssueState) -> dict:
|
|
|
225
245
|
def route_after_initiate(state: WorkOnIssueState) -> str:
|
|
226
246
|
"""Route based on initiate node outcome signals.
|
|
227
247
|
|
|
228
|
-
Priority: error →
|
|
248
|
+
Priority: error → setup. ``initiate_node`` only performs pre-flight
|
|
249
|
+
validation; it does not fetch the issue. Setup must run before retrieval so
|
|
250
|
+
branch validation/creation is always applied, and can no-op when the branch
|
|
251
|
+
already targets the issue. ``issue_retrieved`` is deliberately NOT consulted
|
|
252
|
+
here — it is owned by ``retrieve_node`` and read by ``route_after_retrieve``.
|
|
229
253
|
"""
|
|
230
254
|
if state.get("error"):
|
|
231
255
|
return "error_handler"
|
|
232
|
-
|
|
233
|
-
return "setup"
|
|
234
|
-
if state.get("issue_retrieved"):
|
|
235
|
-
return "planning"
|
|
236
|
-
# Legacy checkpoint fallback: pre-refactor states lack signal fields.
|
|
237
|
-
# The previous default was "planning" (no error meant success).
|
|
238
|
-
if "issue_retrieved" not in state and "needs_setup" not in state:
|
|
239
|
-
return "planning"
|
|
240
|
-
return "error_handler"
|
|
256
|
+
return "setup"
|
|
241
257
|
|
|
242
258
|
|
|
243
259
|
def route_after_plan(state: WorkOnIssueState) -> str:
|
|
@@ -259,12 +275,26 @@ def route_after_plan(state: WorkOnIssueState) -> str:
|
|
|
259
275
|
|
|
260
276
|
|
|
261
277
|
def route_after_setup(state: WorkOnIssueState) -> str:
|
|
262
|
-
"""Route to ``
|
|
278
|
+
"""Route to ``retrieve`` when setup completes successfully."""
|
|
263
279
|
if state.get("error"):
|
|
264
280
|
return "error_handler"
|
|
265
281
|
if state.get("setup_complete"):
|
|
266
|
-
return "
|
|
282
|
+
return "retrieve"
|
|
267
283
|
if "setup_complete" not in state:
|
|
284
|
+
return "retrieve"
|
|
285
|
+
return "error_handler"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def route_after_retrieve(state: WorkOnIssueState) -> str:
|
|
289
|
+
"""Route to ``planning`` when issue retrieval succeeds.
|
|
290
|
+
|
|
291
|
+
Checks the ``issue_retrieved`` signal set by the retrieve node.
|
|
292
|
+
"""
|
|
293
|
+
if state.get("error"):
|
|
294
|
+
return "error_handler"
|
|
295
|
+
if state.get("issue_retrieved"):
|
|
296
|
+
return "planning"
|
|
297
|
+
if "issue_retrieved" not in state:
|
|
268
298
|
return "planning"
|
|
269
299
|
return "error_handler"
|
|
270
300
|
|
|
@@ -315,13 +345,15 @@ def route_after_implementation_review(state: WorkOnIssueState) -> str:
|
|
|
315
345
|
def route_after_verify(state: WorkOnIssueState) -> str:
|
|
316
346
|
"""Route back to ``implementation`` on retryable error, otherwise ``commit``.
|
|
317
347
|
|
|
318
|
-
Caps retry loops at
|
|
348
|
+
Caps retry loops at the policy-provided ``retry_budget`` when present,
|
|
349
|
+
otherwise falls back to :data:`MAX_RETRIES` for checkpoint compatibility.
|
|
319
350
|
Routes to ``error_handler`` when retries are exhausted.
|
|
320
351
|
"""
|
|
321
352
|
retry_count = _normalize_retry_count(state.get("retry_count", 0))
|
|
322
|
-
|
|
353
|
+
retry_budget = _normalize_retry_budget(state.get("retry_budget", MAX_RETRIES))
|
|
354
|
+
if state.get("error") and retry_count < retry_budget:
|
|
323
355
|
return "implementation"
|
|
324
|
-
if state.get("error") and retry_count >=
|
|
356
|
+
if state.get("error") and retry_count >= retry_budget:
|
|
325
357
|
return "error_handler"
|
|
326
358
|
return "commit"
|
|
327
359
|
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
"""LangGraph workflow runner for the work-on-
|
|
1
|
+
"""LangGraph workflow runner for the work-on-issue workflow.
|
|
2
2
|
|
|
3
3
|
This module provides the entry point for running the LangGraph-based
|
|
4
|
-
work-on-
|
|
4
|
+
work-on-issue workflow when ``--engine langchain`` is selected.
|
|
5
|
+
It supports both Jira issue keys (e.g., PROJECT-1234) and GitHub issue
|
|
6
|
+
numbers (e.g., #42).
|
|
5
7
|
"""
|
|
6
8
|
|
|
7
9
|
import sys
|
|
8
10
|
import uuid
|
|
11
|
+
from pathlib import Path
|
|
9
12
|
from typing import Any
|
|
10
13
|
|
|
11
14
|
|
|
@@ -17,14 +20,15 @@ def run_langchain_workflow(
|
|
|
17
20
|
resume: bool = False,
|
|
18
21
|
resume_data: dict | None = None,
|
|
19
22
|
) -> None:
|
|
20
|
-
"""Run the LangGraph-based work-on-
|
|
23
|
+
"""Run the LangGraph-based work-on-issue workflow.
|
|
21
24
|
|
|
22
25
|
This is invoked when ``--engine langchain`` (or ``--use-langchain``) is
|
|
23
26
|
provided on the CLI. It builds and invokes the compiled StateGraph with
|
|
24
|
-
real tool integrations.
|
|
27
|
+
real tool integrations. Supports both Jira issue keys (e.g., PROJECT-1234)
|
|
28
|
+
and GitHub issue numbers (e.g., #42).
|
|
25
29
|
|
|
26
30
|
Args:
|
|
27
|
-
issue_key: Jira issue key (e.g., PROJECT-1234).
|
|
31
|
+
issue_key: Jira issue key (e.g., PROJECT-1234) or GitHub issue number.
|
|
28
32
|
interactive: Whether to start the Copilot session interactively.
|
|
29
33
|
model: Copilot model to use.
|
|
30
34
|
resume: Whether to resume from an existing checkpoint.
|
|
@@ -75,6 +79,9 @@ def run_langchain_workflow(
|
|
|
75
79
|
# it even when get_checkpointer() raises before assignment.
|
|
76
80
|
checkpointer = None
|
|
77
81
|
try:
|
|
82
|
+
# Initialize infrastructure components for autonomous execution.
|
|
83
|
+
policy_state = _initialize_infrastructure(state_dir)
|
|
84
|
+
|
|
78
85
|
# Initialize checkpointer after lock acquisition so the entire execution
|
|
79
86
|
# (including checkpoint storage) is single-writer per thread scope.
|
|
80
87
|
checkpointer = get_checkpointer()
|
|
@@ -154,7 +161,10 @@ def run_langchain_workflow(
|
|
|
154
161
|
"model": model,
|
|
155
162
|
},
|
|
156
163
|
"affected_paths": [],
|
|
164
|
+
"token_usage_prompt": 0,
|
|
165
|
+
"token_usage_completion": 0,
|
|
157
166
|
}
|
|
167
|
+
initial_state.update(policy_state)
|
|
158
168
|
|
|
159
169
|
print(f"[langchain] Starting workflow for {issue_key}...")
|
|
160
170
|
try:
|
|
@@ -174,6 +184,12 @@ def run_langchain_workflow(
|
|
|
174
184
|
# True completion.
|
|
175
185
|
final_step = result.get("step", "unknown")
|
|
176
186
|
final_status = result.get("status", "unknown")
|
|
187
|
+
if final_status in {"failed", "blocked"}:
|
|
188
|
+
print(
|
|
189
|
+
f"ERROR: Workflow terminated unsuccessfully: step={final_step}, status={final_status}",
|
|
190
|
+
file=sys.stderr,
|
|
191
|
+
)
|
|
192
|
+
sys.exit(1)
|
|
177
193
|
print(f"[langchain] Workflow completed: step={final_step}, status={final_status}")
|
|
178
194
|
finally:
|
|
179
195
|
lock.release()
|
|
@@ -183,17 +199,54 @@ def run_langchain_workflow(
|
|
|
183
199
|
checkpointer.conn.close()
|
|
184
200
|
|
|
185
201
|
|
|
202
|
+
def _initialize_infrastructure(state_dir: Path | str) -> dict[str, int]:
|
|
203
|
+
"""Initialize infrastructure components for autonomous workflow execution.
|
|
204
|
+
|
|
205
|
+
Loads policy values used by the workflow (token budget and retry budget)
|
|
206
|
+
and logs them for observability. Returns state defaults that should be
|
|
207
|
+
seeded into fresh workflow executions. If policy loading fails, emits a
|
|
208
|
+
warning and continues with runtime defaults.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
state_dir: Path to the workflow state directory (str or Path).
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
A mapping of policy-backed state defaults for fresh executions.
|
|
215
|
+
"""
|
|
216
|
+
import sys
|
|
217
|
+
|
|
218
|
+
# Initialize PolicyLoader for budget enforcement
|
|
219
|
+
try:
|
|
220
|
+
from agentic_devtools.orchestration.policies.loader import PolicyLoader
|
|
221
|
+
|
|
222
|
+
loader = PolicyLoader()
|
|
223
|
+
policy = loader.load()
|
|
224
|
+
max_tokens = policy.shared.max_tokens
|
|
225
|
+
retry_budget = policy.work_on_issue.retry_budget
|
|
226
|
+
print(
|
|
227
|
+
f"[langchain] Policy loaded: state_dir={state_dir}, max_tokens={max_tokens}, retry_budget={retry_budget}",
|
|
228
|
+
file=sys.stderr,
|
|
229
|
+
)
|
|
230
|
+
return {"retry_budget": retry_budget}
|
|
231
|
+
except Exception as exc:
|
|
232
|
+
print(
|
|
233
|
+
f"[langchain] WARNING: Policy loading failed for state_dir={state_dir}, using defaults: {exc}",
|
|
234
|
+
file=sys.stderr,
|
|
235
|
+
)
|
|
236
|
+
return {}
|
|
237
|
+
|
|
238
|
+
|
|
186
239
|
def _is_workflow_paused(result: object) -> bool:
|
|
187
|
-
"""Return True if the workflow is paused
|
|
240
|
+
"""Return True if the workflow is paused and awaiting human input.
|
|
188
241
|
|
|
189
242
|
When a LangGraph workflow with a checkpointer pauses at a human-in-the-loop
|
|
190
243
|
gate node, ``invoke()`` returns the current state dict instead of raising
|
|
191
|
-
``GraphInterrupt``.
|
|
192
|
-
|
|
244
|
+
``GraphInterrupt``. This helper treats known terminal statuses as
|
|
245
|
+
non-paused and returns ``True`` only for active/in-progress states.
|
|
193
246
|
"""
|
|
194
247
|
if not isinstance(result, dict):
|
|
195
248
|
return True
|
|
196
|
-
return result.get("status")
|
|
249
|
+
return result.get("status") not in {"completed", "failed", "blocked"}
|
|
197
250
|
|
|
198
251
|
|
|
199
252
|
def _print_pause_message(issue_key: str) -> None:
|