Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/agent-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,10 @@ cs scans logs SCAN_ID
```

Without an ID, `scans show` selects the latest completed scan, while `scans logs`
selects the latest scan of any status. An interrupted Deep Scan still marked
selects the latest scan of any status. An interrupted Standard or Deep Scan still marked
`running` can use `cs scans resume SCAN_ID` with its original checkout, output,
session, and state directory. Completed, failed, and canceled scans need a new
attempt with `cs scans rerun SCAN_ID`. A rerun uses the current checkout and may
need the original custom prompt files supplied again. See
[resume requirements](../sdk/typescript/docs/cli.md#resuming-an-interrupted-deep-scan)
[resume requirements](../sdk/typescript/docs/cli.md#resuming-an-interrupted-scan)
and [runtime troubleshooting](../sdk/typescript/docs/cli.md#troubleshooting).
1 change: 1 addition & 0 deletions plugins/codex-security/scripts/workbench_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ def add_command(name: str, *required: str) -> argparse.ArgumentParser:
set_scan_thread = subparsers.add_parser("set-scan-thread")
set_scan_thread.add_argument("--scan-id", required=True)
set_scan_thread.add_argument("--thread-id", required=True)
set_scan_thread.add_argument("--time-budget-started-at")

set_scan_cost_limit = add_command("set-scan-cost-limit", "--scan-id")
set_scan_cost_limit.add_argument("--max-cost-usd", required=True, type=float)
Expand Down
19 changes: 11 additions & 8 deletions plugins/codex-security/scripts/workbench_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -1580,12 +1580,7 @@ def register_cli_scan(
*,
before_archive: Callable[[], None] | None = None,
) -> dict[str, Any]:
"""Register a CLI scan and its selected target before execution begins.

Record the launch recipe and target identity with the new scan. When reusing
an output directory, coordinate archiving with registration so previous results
can still be located. Return the scan ID, contract, and target metadata.
"""
"""Register the scan recipe and target identity, archiving any previous output atomically."""
# A selected plugin may replace __main__ while delegating to this workbench.
before_output_change = before_archive or getattr(
sys, "_codex_security_before_output_change", None
Expand Down Expand Up @@ -1793,6 +1788,11 @@ def acknowledge_output_change() -> None:
"contract": scan_contract(scan),
"lifecycle": json.loads(scan["lifecycle_json"]),
**({"phase": scan["phase"]} if mode == "standard" else {}),
**(
{"canSaveTimeBudgetStart": True}
if "maxTimeMinutes" in recipe and mode in {"standard", "diff"}
else {}
),
"scanDir": str(scan_dir),
"scanId": scan_id,
"scopeFileCount": scope_file_count,
Expand All @@ -1805,8 +1805,11 @@ def set_scan_thread(connection: sqlite3.Connection, args: argparse.Namespace) ->
scan = require_scan(connection, args.scan_id)
with connection:
connection.execute(
"UPDATE scans SET continuation_thread_id = ?, updated_at = ? WHERE id = ?",
(args.thread_id, now(), scan["id"]),
"""UPDATE scans SET continuation_thread_id = ?1, updated_at = ?2,
recipe_json = CASE WHEN ?3 IS NULL THEN recipe_json
ELSE json_insert(recipe_json, '$.timeBudgetStartedAt', ?3) END
WHERE id = ?4""",
(args.thread_id, now(), getattr(args, "time_budget_started_at", None), scan["id"]),
)
return {"scanId": scan["id"], "threadId": args.thread_id}

Expand Down
4 changes: 2 additions & 2 deletions plugins/codex-security/scripts/workbench_scan_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,8 @@ def cli_scan_resume(
) -> dict[str, Any]:

scan = scan_with_progress(connection, scan["id"])
if scan["mode"] != "deep" or scan["recipe_json"] is None:
raise SystemExit("Resume requires a Deep Scan with a saved CLI launch recipe.")
if scan["mode"] not in {"standard", "deep"} or scan["recipe_json"] is None:
raise SystemExit("Resume requires a Standard or Deep Scan with a saved CLI launch recipe.")
if scan["status"] != "running" or scan["canceled_at"] is not None:
raise SystemExit(
"Resume requires a running scan; completed, failed, and canceled scans cannot resume."
Expand Down
2 changes: 1 addition & 1 deletion plugins/codex-security/scripts/workbench_scan_start.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ class WindowsResumeTarget:
def prepare_cli_scan_target(
target: Path, diff_target: dict[str, str] | None, mode: str, launch_kind: str
) -> tuple[tuple[str, str | None, int | str, int | str], WindowsResumeTarget | None]:
if os.name == "nt" and mode == "deep" and launch_kind == "scan":
if os.name == "nt" and mode in {"standard", "deep"} and launch_kind == "scan":
from windows_scan_local_files import pinned_resume_checkout

with pinned_resume_checkout(target) as native:
Expand Down
24 changes: 24 additions & 0 deletions plugins/codex-security/scripts/workbench_scan_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,12 @@ def collect_scan_usage(
started_at=started_at,
completed_at=stopped_at,
completed_turn_id=completed_turn_id if session.thread_id == thread_id else None,
resume_owner=(
scan["mode"] == "standard"
and "recipe_json" in scan.keys()
and scan["recipe_json"] is not None
and session.thread_id == scan["continuation_thread_id"]
),
is_scan_owner=(
scan["mode"] == "deep"
and session.thread_id == scan["deep_scan_owner_thread_id"]
Expand Down Expand Up @@ -926,6 +932,7 @@ def _read_rollout_usage(
completed_at: datetime | None,
completed_turn_id: str | None = None,
is_scan_owner: bool = False,
resume_owner: bool = False,
) -> RolloutUsage:
result = RolloutUsage(_empty_token_usage(), set(), None, {})
total = result.tokens
Expand All @@ -947,6 +954,7 @@ def _read_rollout_usage(
started_turns: set[str] = set()
open_turns: set[str] = set()
closed_turns: set[str] = set()
failed_turns: set[str] = set()
measured_turns: set[str] = set()

def past_scan_end(timestamp: datetime, turn_id: object) -> bool:
Expand Down Expand Up @@ -1077,6 +1085,8 @@ def past_scan_end(timestamp: datetime, turn_id: object) -> bool:
if timestamp >= started_at:
if terminal_turn_id in started_turns:
closed_turns.add(terminal_turn_id)
if payload.get("type") == "task_complete" and payload.get("error"):
failed_turns.add(terminal_turn_id)
if payload.get("type") == "turn_aborted":
# Later successful work does not recover interrupted request usage.
warnings.add("turn_interrupted")
Expand Down Expand Up @@ -1143,6 +1153,20 @@ def past_scan_end(timestamp: datetime, turn_id: object) -> bool:
warnings.add("token_record_incomplete")
result.tokens_updated_at = max(result.tokens_updated_at or timestamp, timestamp)
_add_token_usage(response_usage, usage)
if (
resume_owner
and receipt_turn_id == current_turn_id
and current_turn_id in started_turns
and (open_turns - {current_turn_id} or failed_turns)
):
# Matching thread totals account for turns left open or failed before resume.
cumulative = _usage_counts(payload.get("thread_token_usage"))
if cumulative is not None and all(
cumulative[key] == total[key] + response_usage[key]
for key in TOKEN_FIELDS.values()
):
open_turns.intersection_update({current_turn_id})
closed_turns.difference_update(failed_turns - {current_turn_id})
continue
if event.get("type") != "event_msg" or payload.get("type") != "token_count":
continue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -532,8 +532,10 @@ def test_resume_verification_failure_rolls_back_the_created_handle(

with closing(workbench_db.connect()) as connection:
old_id = None
previous_identity = None
if archive:
old_id = register("standard", connection=connection)["scanId"]
previous_identity = windows_scan_local_files.deep_resume_output_identity(output)
connection.execute("UPDATE scans SET status = 'failed' WHERE id = ?", (old_id,))
connection.commit()
previous_file.write_bytes(b"original result")
Expand All @@ -560,13 +562,14 @@ def competing_reader(handle, path, **options):
with pytest.raises(OSError) as failure:
register(archive=archive, connection=connection)
assert (getattr(failure.value, "winerror", None) or failure.value.errno) == 32
assert not retained.exists()
assert not list(output.parent.glob(f"{output.name}.previous-*"))
rows = connection.execute("SELECT id, scan_dir FROM scans").fetchall()
if archive:
assert windows_scan_local_files.deep_resume_output_identity(output) == previous_identity
assert [tuple(row) for row in rows] == [(old_id, str(output))]
assert previous_file.read_bytes() == b"original result"
else:
assert not retained.exists()
assert rows == []
assert list(output.iterdir()) == []
assert register(archive=archive, connection=connection)["scanDir"] == str(output)
Expand Down
52 changes: 52 additions & 0 deletions plugins/codex-security/tests/test_workbench_scan_usage_resume.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Account for interrupted and resumed turns of a Standard scan."""

from datetime import datetime
from pathlib import Path

import pytest
from test_workbench_scan_usage import (
_event,
_measure_rollout,
_response_event,
_rollout,
_token_event,
)
from workbench_test_support import usage_counts as _counts


@pytest.mark.parametrize("cumulative_input", [30, 31, None])
def test_resumed_owner_reconciles_crash_and_failed_reconnect_only_with_matching_thread_usage(
workbench_api, tmp_path: Path, cumulative_input: int | None
) -> None:
at = datetime.fromisoformat("2026-01-01T00:00:00+00:00")
thread_id = "scan-parent"
resumed = _response_event(at, thread_id, "second", 10, 2, turn_id="resumed")
if cumulative_input is None:
del resumed["payload"]["thread_token_usage"]
else:
resumed["payload"]["thread_token_usage"] = _token_event(at, cumulative_input, 5)["payload"][
"info"
]["total_token_usage"]
events = [
_event(at, "event_msg", {"type": "task_started", "turn_id": "crashed"}),
_response_event(at, thread_id, "first", 20, 3, turn_id="crashed"),
_event(at, "event_msg", {"type": "task_started", "turn_id": "reconnect"}),
_event(
at,
"event_msg",
{
"type": "task_complete",
"turn_id": "reconnect",
"error": {"message": "Connection failed"},
},
),
_event(at, "event_msg", {"type": "task_started", "turn_id": "resumed"}),
resumed,
_event(at, "event_msg", {"type": "task_complete", "turn_id": "resumed"}),
]
rollout = _rollout(tmp_path, thread_id, events)
usage = _measure_rollout(workbench_api["scan_usage"], rollout, at, at, resume_owner=True)
assert usage.tokens == _counts(30, 0, 5)
assert usage.warnings == (
set() if cumulative_input == 30 else {"token_record_missing", "turn_in_progress"}
)
25 changes: 16 additions & 9 deletions sdk/typescript/docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -724,8 +724,8 @@ Choose the next step from the recorded reason:
start a new scan with the intended scope, including for plugin scans without
a recipe. Retained findings can still be reviewed, but absence of findings
from a stopped run is inconclusive.
- **A Deep Scan was interrupted and is still recorded as `running`:** it may
support [resume](#resuming-an-interrupted-deep-scan) with the original checkout,
- **A Standard or Deep Scan was interrupted and is still recorded as `running`:** it may
support [resume](#resuming-an-interrupted-scan) with the original checkout,
artifacts, and session. Completed, failed, and canceled scans cannot resume.

For example, after identifying an omitted API directory:
Expand Down Expand Up @@ -956,7 +956,7 @@ behavior are unchanged.

The SDK setting is `maxTimeMinutes`; project files use `scan.max_time_minutes`.
Each launched scan or rerun gets its own allowance after setup, and reconnects
do not reset it. Standard scans still do not support resume. For batch and
do not reset it. [Resumed Standard scans](#resuming-an-interrupted-scan) keep the original deadline. For batch and
component scans, the project setting applies separately to each scan attempt,
not to planning, result matching, or the total campaign.

Expand Down Expand Up @@ -2135,7 +2135,7 @@ in the workbench, without changing sealed report timestamps.
| `scans list [REPOSITORY]` | List scans; `--scan-root DIR` filters artifact roots. |
| `scans show [SCAN_ID]` | Show a scan; defaults to latest completed. |
| `scans logs [SCAN_ID]` | Show session events; defaults to latest, including active. |
| `scans resume SCAN_ID` | Continue an interrupted Deep Scan. |
| `scans resume SCAN_ID` | Continue an interrupted Standard or Deep Scan. |
| `scans rerun [SCAN_ID]` | Rerun a saved recipe in its checkout. |
| `scans match --all` | Match completed scans in this repository generation, including linked worktrees. |
| `scans compare [BEFORE] [AFTER]` | Compare scans; defaults to latest two completed. |
Expand Down Expand Up @@ -2231,15 +2231,15 @@ work on all supported Node.js versions, including logs, feedback attachments,
usage accounting, and saved-session discovery for resume. A bundled decoder
reads concatenated frames and reports truncated streams as collection errors.

### Resuming an interrupted Deep Scan
### Resuming an interrupted scan

```bash
codex-security scans list --scan-root /path/to/security-scans
codex-security scans resume SCAN_ID
```

The terminal list shows the full scan ID and labels the last activity of a
`running` scan after 24 hours without an update. For a Deep Scan with a saved
`running` scan after 24 hours without an update. For a Standard or Deep Scan with a saved
launch recipe and its original session, it also shows the resume command to use after interruption.

The scan must still be `running`, with its original checkout, output directory,
Expand All @@ -2251,15 +2251,22 @@ output. It lets the workbench verify the original output even before the first
checkpoint. If an older scan stopped with an empty output, start a new scan with
`codex-security scans rerun SCAN_ID`.

New Windows Deep Scans also record the checkout's native volume and file ID
New Windows Standard and Deep Scans also record the checkout's native volume and file ID
when the filesystem supplies them. Validation and remediation verify the same
checkout identity. An older scan can resume across Python
versions when its saved checkout identity can be verified. If the workbench
cannot verify it, resume with the Python runtime that started the scan or use
`codex-security scans rerun SCAN_ID` to start a new scan.

Resume retains scan ID, completed workers, artifacts, accumulated cost, and saved
settings/instructions. Knowledge-bearing scans save their extracted documents in
Resume keeps the scan ID, saved settings and instructions, completed workers, artifacts,
and accumulated cost. A Standard scan continues in its original Codex session with
the same `--max-cost` limit and `--max-time` deadline. Only one CLI or SDK call
can run it at a time. If the connection fails after the session is saved, the CLI
prints the resume command. Work since the last checkpoint may need to be repeated.
Use `scans rerun` for canceled or failed scans, including authentication failures.
For custom validation, rerun with the original validation instructions.

Knowledge-bearing scans save their extracted documents in
the private `.scan-knowledge.json` continuation file beside the scan artifacts.
Resume verifies and uses that snapshot even if the original files changed or were
deleted. Preserve it with the scan directory; canonical reports do not embed its
Expand Down
Loading