Skip to content

Commit 181d73d

Browse files
authored
Merge pull request #27 from dsp-testing/fix/forge-terminal-and-query-volume
Harden Forge interrupted extraction handling
2 parents abae31d + e4718f3 commit 181d73d

6 files changed

Lines changed: 286 additions & 15 deletions

File tree

plugins/repo-dreamer/skills/repository-skill-forge/SKILL.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,19 @@ already found. Continue invoking the controller while its state is `running`;
233233
the agent must not independently declare the run blocked because remaining work
234234
is slow or numerous.
235235

236+
Before leaving extraction, updating state, or reporting any terminal outcome,
237+
require the controller to confirm that extraction is terminal:
238+
239+
```bash
240+
python3 "$SKILL_DIR/scripts/extraction-controller.py" assert-terminal \
241+
--state "$RUN_DIR/extraction-state.json"
242+
```
243+
244+
This command fails while state is `running` and identifies any issued actions
245+
that still require outcomes. A run is blocked only when this command returns
246+
`status: blocked` with the controller-recorded blocker. Never describe
247+
incomplete `running` work as blocked.
248+
236249
Generate the next bounded action batch with:
237250

238251
```bash

plugins/repo-dreamer/skills/repository-skill-forge/scripts/extraction-controller.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,36 @@ def finalize_extraction(state: dict[str, Any]) -> None:
202202
state["status"] = "partial" if state["omittedUnits"] else "complete"
203203

204204

205+
def terminal_summary(state: dict[str, Any]) -> dict[str, Any]:
206+
validate_state_invariants(state)
207+
status = str(state.get("status"))
208+
if status == "running":
209+
pending = [
210+
str(action.get("actionId"))
211+
for action in state.get("issuedActions", [])
212+
if action.get("actionId")
213+
]
214+
detail = f"; pending actions: {', '.join(pending)}" if pending else ""
215+
raise ValueError(
216+
"extraction is not terminal: status is running"
217+
f"{detail}; continue invoking next and recording every outcome"
218+
)
219+
if status not in {"complete", "partial", "blocked"}:
220+
raise ValueError(f"extraction has unsupported terminal status: {status}")
221+
summary: dict[str, Any] = {
222+
"kind": "terminal",
223+
"status": status,
224+
"terminal": True,
225+
}
226+
if state.get("coverage") is not None:
227+
summary["coverage"] = state["coverage"]
228+
if status == "partial":
229+
summary["omittedUnits"] = state["omittedUnits"]
230+
if status == "blocked":
231+
summary["blocker"] = state["blockers"][-1]
232+
return summary
233+
234+
205235
def next_actions(
206236
state: dict[str, Any],
207237
max_actions: int,
@@ -604,7 +634,6 @@ def record_success(
604634
elif action["kind"] == "files":
605635
batch["filesCursor"] = {
606636
"sessionId": last["session_id"],
607-
"turnIndex": last["turn_index"],
608637
"filePath": last["file_path"],
609638
"toolName": last["tool_name"],
610639
}
@@ -1142,6 +1171,10 @@ def main() -> None:
11421171
next_parser.add_argument("--out")
11431172
next_parser.add_argument("--parallel", action="store_true")
11441173

1174+
terminal = subparsers.add_parser("assert-terminal")
1175+
terminal.add_argument("--state", required=True)
1176+
terminal.add_argument("--out")
1177+
11451178
success = subparsers.add_parser("record-success")
11461179
success.add_argument("--state", required=True)
11471180
success.add_argument("--action", required=True)
@@ -1193,22 +1226,38 @@ def main() -> None:
11931226
)
11941227
actions = next_actions(state, max_actions)
11951228
write_json(args.state, state)
1196-
done = {"kind": "done", "status": state["status"]}
1229+
done = {
1230+
"kind": "done",
1231+
"status": state["status"],
1232+
"terminal": True,
1233+
}
11971234
if state["status"] == "blocked":
11981235
done["blocker"] = state["blockers"][-1]
11991236
if state["coverage"] is not None:
12001237
done["coverage"] = state["coverage"]
12011238
if state["status"] == "partial":
12021239
done["omittedUnits"] = state["omittedUnits"]
12031240
if args.parallel and actions:
1204-
payload = {"kind": "action-batch", "actions": actions}
1241+
payload = {
1242+
"kind": "action-batch",
1243+
"status": state["status"],
1244+
"terminal": False,
1245+
"actions": actions,
1246+
}
12051247
else:
12061248
payload = actions[0] if actions else done
12071249
if args.out:
12081250
write_json(args.out, payload)
12091251
else:
12101252
print(json.dumps(payload, indent=2))
12111253
return
1254+
if args.command == "assert-terminal":
1255+
summary = terminal_summary(state)
1256+
if args.out:
1257+
write_json(args.out, summary)
1258+
else:
1259+
print(json.dumps(summary, indent=2))
1260+
return
12121261
action = load_action(args.action, state)
12131262
if args.command == "record-success":
12141263
try:

plugins/repo-dreamer/skills/repository-skill-forge/scripts/materialize-session-query.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,45 @@ class QueryHandoffMismatch(ValueError):
2727
"""The action ID matched, but the submitted SQL differed."""
2828

2929

30-
def normalize_sql(sql: str) -> str:
31-
return " ".join(sql.split())
30+
def normalize_sql(sql: str) -> tuple[str, ...]:
31+
tokens: list[str] = []
32+
index = 0
33+
while index < len(sql):
34+
character = sql[index]
35+
if character.isspace():
36+
index += 1
37+
continue
38+
if character in {"'", '"'}:
39+
quote = character
40+
end = index + 1
41+
while end < len(sql):
42+
if sql[end] == quote:
43+
if end + 1 < len(sql) and sql[end + 1] == quote:
44+
end += 2
45+
continue
46+
end += 1
47+
break
48+
end += 1
49+
tokens.append(sql[index:end])
50+
index = end
51+
continue
52+
if character.isalnum() or character in {"_", "$"}:
53+
end = index + 1
54+
while end < len(sql) and (
55+
sql[end].isalnum() or sql[end] in {"_", "$"}
56+
):
57+
end += 1
58+
tokens.append(sql[index:end])
59+
index = end
60+
continue
61+
operator = sql[index : index + 2]
62+
if operator in {">=", "<=", "<>", "!=", "||", "::"}:
63+
tokens.append(operator)
64+
index += 2
65+
continue
66+
tokens.append(character)
67+
index += 1
68+
return tuple(tokens)
3269

3370

3471
def read_events(path: Path) -> Iterator[dict[str, Any]]:

plugins/repo-dreamer/skills/repository-skill-forge/scripts/session_queries.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -111,16 +111,22 @@ def build_files_query(
111111
after = ""
112112
if cursor:
113113
after = (
114-
"\n AND (session_id, turn_index, file_path, tool_name) > "
115-
f"('{sql_literal(str(cursor['sessionId']))}', {int(cursor['turnIndex'])}, "
116-
f"'{sql_literal(str(cursor['filePath']))}', '{sql_literal(str(cursor['toolName']))}')"
114+
"\nWHERE (session_id, file_path, tool_name) > "
115+
f"('{sql_literal(str(cursor['sessionId']))}', "
116+
f"'{sql_literal(str(cursor['filePath']))}', "
117+
f"'{sql_literal(str(cursor['toolName']))}')"
117118
)
118-
return f"""SELECT session_id, file_path, tool_name, turn_index
119-
FROM session_files
120-
WHERE first_seen_at >= TIMESTAMP '{sql_literal(start)}'
121-
AND first_seen_at < TIMESTAMP '{sql_literal(end)}'
122-
AND session_id IN ({ids}){after}
123-
ORDER BY session_id, turn_index, file_path, tool_name
119+
return f"""WITH selected_files AS (
120+
SELECT session_id, file_path, tool_name, min(turn_index) AS turn_index
121+
FROM session_files
122+
WHERE first_seen_at >= TIMESTAMP '{sql_literal(start)}'
123+
AND first_seen_at < TIMESTAMP '{sql_literal(end)}'
124+
AND session_id IN ({ids})
125+
GROUP BY session_id, file_path, tool_name
126+
)
127+
SELECT session_id, file_path, tool_name, turn_index
128+
FROM selected_files{after}
129+
ORDER BY session_id, file_path, tool_name
124130
LIMIT {limit + 1}"""
125131

126132

plugins/repo-dreamer/skills/repository-skill-forge/tests/test_extraction_controller.py

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
SCRIPTS_DIR = SKILL_DIR / "scripts"
1717
sys.path.insert(0, str(SCRIPTS_DIR))
1818

19-
from session_queries import build_discovery_query
19+
from session_queries import build_discovery_query, build_files_query
2020

2121
CONTROLLER_SPEC = importlib.util.spec_from_file_location(
2222
"extraction_controller",
@@ -133,6 +133,118 @@ def test_cli_defaults_to_twenty_five_session_batches(self) -> None:
133133
],
134134
)
135135

136+
def test_assert_terminal_rejects_running_state(self) -> None:
137+
with tempfile.TemporaryDirectory() as run_dir:
138+
state_path = Path(run_dir) / "state.json"
139+
state = controller.initialize(arguments(run_dir))
140+
action = controller.next_action(state)
141+
assert action is not None
142+
controller.write_json(str(state_path), state)
143+
144+
result = subprocess.run(
145+
[
146+
sys.executable,
147+
str(SCRIPTS_DIR / "extraction-controller.py"),
148+
"assert-terminal",
149+
"--state",
150+
str(state_path),
151+
],
152+
check=False,
153+
capture_output=True,
154+
text=True,
155+
)
156+
157+
self.assertNotEqual(0, result.returncode)
158+
self.assertIn("status is running", result.stderr)
159+
self.assertIn(action["actionId"], result.stderr)
160+
161+
def test_assert_terminal_accepts_complete_state(self) -> None:
162+
with tempfile.TemporaryDirectory() as run_dir:
163+
state_path = Path(run_dir) / "state.json"
164+
state = controller.initialize(arguments(run_dir))
165+
state["status"] = "complete"
166+
state["coverage"] = 1.0
167+
controller.write_json(str(state_path), state)
168+
169+
result = subprocess.run(
170+
[
171+
sys.executable,
172+
str(SCRIPTS_DIR / "extraction-controller.py"),
173+
"assert-terminal",
174+
"--state",
175+
str(state_path),
176+
],
177+
check=False,
178+
capture_output=True,
179+
text=True,
180+
)
181+
182+
self.assertEqual("", result.stderr)
183+
self.assertEqual(0, result.returncode)
184+
self.assertEqual(
185+
{
186+
"kind": "terminal",
187+
"status": "complete",
188+
"terminal": True,
189+
"coverage": 1.0,
190+
},
191+
json.loads(result.stdout),
192+
)
193+
194+
def test_parallel_action_manifest_is_explicitly_nonterminal(self) -> None:
195+
with tempfile.TemporaryDirectory() as run_dir:
196+
state_path = Path(run_dir) / "state.json"
197+
actions_path = Path(run_dir) / "actions.json"
198+
controller.write_json(
199+
str(state_path),
200+
controller.initialize(arguments(run_dir)),
201+
)
202+
203+
result = subprocess.run(
204+
[
205+
sys.executable,
206+
str(SCRIPTS_DIR / "extraction-controller.py"),
207+
"next",
208+
"--state",
209+
str(state_path),
210+
"--parallel",
211+
"--out",
212+
str(actions_path),
213+
],
214+
check=False,
215+
capture_output=True,
216+
text=True,
217+
)
218+
219+
self.assertEqual("", result.stderr)
220+
self.assertEqual(0, result.returncode)
221+
manifest = json.loads(actions_path.read_text(encoding="utf-8"))
222+
self.assertEqual("action-batch", manifest["kind"])
223+
self.assertEqual("running", manifest["status"])
224+
self.assertFalse(manifest["terminal"])
225+
226+
def test_file_query_deduplicates_before_pagination(self) -> None:
227+
query = build_files_query(
228+
session_ids=["session-1"],
229+
start="2026-08-01T00:00:00Z",
230+
end="2026-08-08T00:00:00Z",
231+
limit=500,
232+
cursor={
233+
"sessionId": "session-1",
234+
"filePath": "src/example.py",
235+
"toolName": "edit",
236+
},
237+
)
238+
239+
self.assertIn("min(turn_index) AS turn_index", query)
240+
self.assertIn("GROUP BY session_id, file_path, tool_name", query)
241+
self.assertIn(
242+
"WHERE (session_id, file_path, tool_name) > "
243+
"('session-1', 'src/example.py', 'edit')",
244+
query,
245+
)
246+
self.assertIn("ORDER BY session_id, file_path, tool_name", query)
247+
136248
def test_discovery_uses_ordered_keyset_query(self) -> None:
137249
query = build_discovery_query(
138250
repository="owner/repository",

plugins/repo-dreamer/skills/repository-skill-forge/tests/test_materialize_session_query.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,60 @@ def test_matches_whitespace_normalized_sql(self) -> None:
342342
materializer.result_content(events_root, sql, "discovery-1"),
343343
)
344344

345+
def test_matches_punctuation_spacing_normalized_sql(self) -> None:
346+
with tempfile.TemporaryDirectory() as temporary:
347+
events_root = Path(temporary)
348+
event_dir = events_root / "session-1"
349+
event_dir.mkdir()
350+
sql = (
351+
"SELECT session_id, tool_call_id FROM tool_requests "
352+
"WHERE session_id IN ('one', 'two') AND name = 'bash'"
353+
)
354+
submitted = (
355+
"SELECT session_id,tool_call_id FROM tool_requests "
356+
"WHERE session_id IN('one','two') AND name='bash'"
357+
)
358+
content = "Query returned 0 rows."
359+
events = [
360+
{
361+
"type": "tool.execution_start",
362+
"data": {
363+
"toolCallId": "call-1",
364+
"toolName": "session_store_sql",
365+
"arguments": {
366+
"description": "tools-batch-1",
367+
"query": submitted,
368+
},
369+
},
370+
},
371+
{
372+
"type": "tool.execution_complete",
373+
"data": {
374+
"toolCallId": "call-1",
375+
"success": True,
376+
"result": {
377+
"content": content,
378+
"detailedContent": "SQL result omitted",
379+
},
380+
},
381+
},
382+
]
383+
(event_dir / "events.jsonl").write_text(
384+
"".join(json.dumps(event) + "\n" for event in events),
385+
encoding="utf-8",
386+
)
387+
388+
self.assertEqual(
389+
content,
390+
materializer.result_content(events_root, sql, "tools-batch-1"),
391+
)
392+
393+
def test_sql_normalization_preserves_literal_contents(self) -> None:
394+
self.assertNotEqual(
395+
materializer.normalize_sql("SELECT 'one two'"),
396+
materializer.normalize_sql("SELECT 'onetwo'"),
397+
)
398+
345399
def test_ignores_matching_description_from_other_tools(self) -> None:
346400
with tempfile.TemporaryDirectory() as temporary:
347401
events_root = Path(temporary)

0 commit comments

Comments
 (0)