Skip to content

Add resumable batch cancel and signal with a private manifest (cli#60) - #137

Merged
rmcdaniel merged 1 commit into
mainfrom
agent/cli/60-batch-manifests
Nov 12, 2026
Merged

rmcdaniel merged 1 commit into
mainfrom
agent/cli/60-batch-manifests

Conversation

@rmcdaniel

Copy link
Copy Markdown
Member

Part of #60. Implements the plan as new Rust-only commands. It is purely additive: no 2.x command changes behaviour. Targets 2.6.0; no version or release files are touched.

Commands

  • dw batch:cancel --query=… --manifest=FILE [--type] [--status=running] [--limit=100 (≤1000)] [--reason] [--rate=10] [--yes] [--json]
  • dw batch:signal <signal-name> --query=… --manifest=FILE [-i/--input-file/--input-encoding] [filters] [--rate] [--yes] [--json]
  • dw batch:show FILE [--json]: reads the manifest only and makes no network requests.
  • dw batch:resume FILE [--rate] [--yes] [--json]

Selection reuses workflow:cancel --all-matching: each target is pinned to the listed run (#59), with the repeated-token and empty-page guards. Confirmation works the same way (stdin line, -n, --yes; --yes is required with JSON).

Manifest (durable-workflow.cli.batch-manifest/v1)

A private JSONL file: created with create_new, mode 0600, and it never stores the token.

  • Line 1, the header: manifest_id (128-bit hex), created_at, cli_version, server, namespace, operation, parameters (the reason, or the signal name and the input exactly as sent), selection (query, filters, limit, snapshot note), and targets[] (index, workflow_id, pinned run_id, command_key = batch-<manifest_id>-<index>).
  • Events: dispatching before each request, then outcome with state, http_status, reason, message and retry_after. Every line is fsync'd.
  • The full selection is durable before the first mutation. A final line cut short by a crash is ignored and removed on resume, and an flock keeps a second process off the same manifest.

States and rules

State From Resume
acknowledged 2xx never resent
skipped 404, 410, 409 (closed), no run ID never resent
refused other 4xx, request-ID conflict never resent
pressure 429, or 503 with Retry-After retried in place and on resume with the same key, at most 3 attempts per target; Retry-After honoured up to 60 s
unknown lost response, 5xx, malformed success, or dispatching without an outcome never resent; listed with inspection steps
pending not sent yet, or 401/403 (not admitted) sent

A run stops at the first unknown outcome, auth refusal or exhausted pressure, so the remaining targets stay pending. Resume refuses a different Server or namespace, never relists, and reuses the recorded keys and signal input. Exit 0 only when every target is acknowledged or skipped.

Catalog and parity

  • parity/catalog-additions.json adds the four commands, marked rust_only. update-catalog.sh merges them into their namespace, and the regenerated catalog.json shows only insertions, so the 2.x part is byte-identical.
  • The harness announces the Rust-only commands, skips their --help (there's no 2.x help to compare), and removes only those lines or entries from candidate listings and completions before comparing. The rest of list, list raw, list short json, the ANSI and no-ANSI listings, and the bare dw listing are still compared in full. New harness unit tests cover the strip and the skip.
  • Full parity: 919/947 match, 28 intentional, 0 unexpected, the same numbers as main. Harness tests: 24 pass. test-release-rust-workflow.py: OK.

Tests (Rust 1.86; fmt and clippy clean; 292 pass)

New: rust/tests/batch.rs, 11 tests:

  • manifest durable before the first POST, no token, mode 0600, events in order
  • an existing manifest refused before any request
  • unknown outcome stops the batch; cold resume sends only the rest and never resends the unknown target (exactly one POST)
  • pressure retried with the same key, bounded at 3, signal input reused byte for byte on resume
  • 401 leaves the target pending, and it is resent after resume
  • a process killed mid-request: show reports unknown; resume records it as interrupted and never resends it
  • changed visibility between pages keeps the first run
  • resume refuses another namespace or Server without sending anything
  • batch:show makes zero requests
  • bounds and JSON confirmation checked before anything is written
  • declining the prompt writes nothing

Plus unit tests for the classification and the manifest's UTC and state handling.

Refactors (no behaviour change, parity unchanged): workflow_control's selection, filters and confirmation are shared; message_path is shared; the client gains workflow_post_observed, which returns Retry-After.

Runbook: docs/batch-operations.md (start, inspect, resume, the states, how to resolve unknown, and the manifest format), linked from rust/README.md.

Gaps against #60's acceptance (why this is "Part of")

  • Updates: there is no batch:update. The plan covered cancel and signal; update needs the update-diagnostics contract and is follow-up work.
  • Deadlines: each request keeps the SDK timeout, and pressure waits are capped, but there's no overall batch --deadline.
  • Published artifacts: interruption and cold-resume tests run against the loopback fixture. No live published-Server batch case exists yet; the live parity harness compares 2.x commands, and these have no 2.x reference.
  • Retained admission (server#934): unknown stays unresolved by design until the Server advertises retained exact-key admission.

🤖 Generated with Claude Code

New Rust-only commands, purely additive (no 2.x command changes):

- batch:cancel / batch:signal select workflows with a visibility query
  (reusing workflow:cancel --all-matching's selection: pinned runs,
  repeated-token and empty-page guards), write the whole selection to a
  new private manifest (create_new, 0600, never the token) before any
  mutation, then send one request per target, recording a dispatching
  event before and an outcome after each, each fsync'd.
- States: acknowledged, skipped (404/409/410, or no run ID), refused
  (permanent 4xx, request-ID conflict), pressure (429, 503+Retry-After;
  retried in place with the same key, Retry-After honoured up to 60 s,
  at most 3 attempts per target), unknown (lost response, 5xx, or
  dispatching without an outcome), pending (not sent, or 401/403). A run
  stops at the first unknown outcome, auth refusal or exhausted pressure.
- batch:resume refuses another Server or namespace, never relists, sends
  only pending/pressure targets with their original keys and recorded
  signal input, and never resends unknown targets. An flock prevents two
  processes running one manifest; a half-written final line is dropped.
- batch:show reads the manifest offline.
- Manifest schema durable-workflow.cli.batch-manifest/v1 (JSONL header
  plus events). Runbook in docs/batch-operations.md.

Catalog: parity/catalog-additions.json adds the commands (rust_only) via
update-catalog.sh. The harness announces them, skips their --help, and
strips only them from candidate listings, so listings still compare.

Shared pieces moved out of workflow_control/workflow_message (selection,
filters, confirmation, message paths) instead of being copied; the client
gains workflow_post_observed to read Retry-After.

Part of #60

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel rmcdaniel added the agent:cli Work by the cli repo agent label Nov 12, 2026
@sonarqubecloud

Copy link
Copy Markdown

@rmcdaniel
rmcdaniel marked this pull request as ready for review November 12, 2026 02:17
@rmcdaniel
rmcdaniel merged commit 9b583a4 into main Nov 12, 2026
27 checks passed
@rmcdaniel
rmcdaniel deleted the agent/cli/60-batch-manifests branch November 12, 2026 02:56
rsguimaraes-pe pushed a commit to rsguimaraes-pe/cli that referenced this pull request Nov 13, 2026
Finishes cli#60 on top of durable-workflow#137.

- batch:update <update-name>: one request per target with the target's
  command key as request_id, --wait checked against the Server's request
  contract before selection, and the input recorded once and resent
  unchanged on resume. An accepted or completed update is acknowledged;
  one the workflow rejected or failed is refused; anything that may have
  been admitted without a definite answer stays unknown and is never
  resent.
- --deadline on batch:cancel/signal/update/resume (seconds or 90s, 5m,
  1h30m; at most 7 days). No request starts after it, or with under 0.5 s
  left; unsent targets stay pending; a Retry-After past it leaves the
  target in pressure; an in-flight request's timeout is capped to the time
  left, and a capped timeout after send is unknown.
- Every early stop (deadline, unknown, pressure, authentication) is
  recorded as a target-less {"event":"stopped"} manifest line, shown by
  batch:show and listed under "stops" in JSON.
- parity/live_batch.py runs the batch commands against the published PHP
  2.11.8, native 3.5.0 and native 3.0.5 Servers in the rust-live-parity job:
  cancel 5/5 with show and resume agreeing, signal 3/3, and a deadline stop
  partway followed by a resume that completes 6/6.

Tests: 6 new batch integration tests and an update classification unit
test; 299 Rust tests pass. Parity 919/947, 28 intentional, 0 unexpected;
live 84/84.

Closes durable-workflow#60

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent:cli Work by the cli repo agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant