Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ quay outbox complete <outbox_item_id> --claim-id <id>
quay outbox fail <outbox_item_id> --claim-id <id> --error <message>
# shared side-effect outbox
quay task get <task_id> | task list # read commands (deterministic JSON)
quay task increase-budget <task_id> --by <n> --reason <text>
# operator recovery for burned retry budget
quay submit-brief | escalate-human | record-human-reply | cancel
quay artifact get <task_id> <kind> # raw bytes to stdout
```
Expand Down
3 changes: 2 additions & 1 deletion docs/quay-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -928,7 +928,7 @@ CREATE TABLE events (
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES tasks(task_id),
attempt_id INTEGER REFERENCES attempts(attempt_id),
event_type TEXT NOT NULL, -- spawned / pr_opened / ci_failed / ci_passed / merged / closed / changes_requested / conflict / blocker_ingested / malformed_signal_ingested / no_progress / crashed / spawn_failed / worker_auth_invalid / stale_detected / stale_killed / wall_clock_exceeded / wall_clock_killed / slack_reply_ingested / claimed / claim_expired / orchestrator_loop_parked / non_budget_loop_parked / brief_submitted / human_escalated / budget_exhausted / cancelled / worktree_error / tick_error
event_type TEXT NOT NULL, -- spawned / pr_opened / ci_failed / ci_passed / merged / closed / changes_requested / conflict / blocker_ingested / malformed_signal_ingested / no_progress / crashed / spawn_failed / worker_auth_invalid / stale_detected / stale_killed / wall_clock_exceeded / wall_clock_killed / slack_reply_ingested / claimed / claim_expired / orchestrator_loop_parked / non_budget_loop_parked / brief_submitted / human_escalated / budget_exhausted / task_budget_adjusted / cancelled / worktree_error / tick_error
from_state TEXT,
to_state TEXT,
payload_artifact_id INTEGER REFERENCES artifacts(artifact_id),
Expand Down Expand Up @@ -1095,6 +1095,7 @@ No `--json` flag exists or is needed; JSON is the only output format. The pull-l
| `quay task claim <task_id>` | Atomically transition `awaiting-next-brief` → `claimed-by-orchestrator` and **mint a fresh `claim_id`** (UUID v4). Predicate: `state = 'awaiting-next-brief' AND cancel_requested_at IS NULL`. Returns `{"task_id": "...", "claim_id": "<uuid>"}` on stdout. The orchestrator MUST store `claim_id` and pass it back as `--claim-id <claim_id>` on every subsequent claim-scoped write. Errors if the task is not in `awaiting-next-brief` or has been cancelled. | Orchestrator |
| `quay task release-claim <task_id> --claim-id <claim_id>` | Ownership-fenced release: predicate is `state IN ('claimed-by-orchestrator', 'waiting_human') AND claim_id = ?`. On match: clears `claim_id` and transitions to `awaiting-next-brief`. On mismatch: errors with `claim_lost` (the prior claim was timed out and re-claimed by someone else; the caller must abandon and re-claim). Releasing a task already in `awaiting-next-brief` is an idempotent no-op success (no claim_id check needed for that case). | Orchestrator |
| `quay task retarget <task_id> --repo <target_repo> [--base-branch <branch>] --yes` | Clone an existing non-terminal task into another repo. The clone is a fresh `queued` task that reuses the source task's stable objective, ticket snapshot, tags, author metadata, agent/model overrides, screenshot settings, worker execution mode, external ref, Slack thread ref, and retry budget. It gets a new task id, branch, worktree, attempt #1, and task artifacts exactly like `enqueue`, with `tasks.retargeted_from_task_id` pointing at the source. The source task transitions to `cancelled` through a `retargeted` audit event; that event's `event_data` records the target repo, target task id, branch, worktree, and base branch override. If retarget crashes after the clone and cancel intent are durable but before the source terminal transition, the next tick's cancel finalizer recovers the source as `cancelled` and emits the source-side `retargeted` event data from the linked clone. `--yes` is required because the source task is mutated and a running worker session is killed before source worktree/branch cleanup. | Operator or orchestrator |
| `quay task increase-budget <task_id> (--by <n>\|--set <n>) --reason <text> [--force]` | Raise an existing task's copied `retry_budget` for operator recovery when control-plane or substrate failures consumed retry budget without useful worker progress. Requires a human-readable reason, records a `task_budget_adjusted` audit event, and recomputes `budget_exhausted` from `attempts_consumed >= retry_budget`. By default it is limited to parked/recovery states (`awaiting-next-brief`, `claimed-by-orchestrator`, `waiting_human`, `non_budget_loop`, `orchestrator_loop`, `worktree_error`); live non-terminal states require `--force`; terminal tasks are refused. | Operator |
| `quay submit-brief <task_id> --claim-id <claim_id> --brief-file <path> --reason <blocker_resolved\|advice_answered>` | Submit a new brief. **Requires `--claim-id` matching the live `tasks.claim_id`** (ownership fence) and `cancel_requested_at IS NULL`. On `claim_id` mismatch: errors with `claim_lost`. On `cancel_requested_at IS NOT NULL`: errors with `cancelled`. On wrong state otherwise: `wrong_state`. On success: persists the brief as an artifact, transitions the task to `queued`, clears `claim_id`, resets `claim_expirations_consecutive` to 0. Tick promotes `queued → running` on its next cycle when capacity allows; budget is consumed at that promotion (unless reason is `advice_answered`). Errors with `budget_exhausted` if `task.budget_exhausted = true` and reason is `blocker_resolved`; orchestrator must use `escalate-human` or `cancel` instead. **Note:** there is no `--reason initial`; the initial brief is supplied at `enqueue` and `attempts.reason = 'initial'` is set internally by Quay on the first spawn. | Orchestrator |
| `quay escalate-human <task_id> --claim-id <claim_id> --question-file <path> [--thread-ref <channel:ts>]` | Record a human question and transition to `waiting_human`. **Requires `--claim-id` matching the live `tasks.claim_id`** (ownership fence) and `cancel_requested_at IS NULL`; same error taxonomy as `submit-brief`. **The CLI does NOT post to Slack** — it persists the `slack_escalation_post` artifact (with `escalation_seq`, `escalation_nonce`, `content_hash` set) and preserves the claim so the orchestrator owns routing, posting, waiting, and reply handling. `--thread-ref` is optional metadata; missing thread refs are allowed so deployments can use fallback routing outside Quay. When provided, it follows the same storage contract as enqueue: canonical `CHANNEL:THREAD_TS` is stored as-is, legacy `slack:CHANNEL:THREAD_TS` is normalized, and malformed refs fail before the task row is updated. Does not consume budget. | Orchestrator |
| `quay record-human-reply <task_id> --claim-id <claim_id> --reply-file <path> [--thread-ref <channel:ts>] [--message-ts <ts>] [--author <name>]` | Persist a human answer as a `slack_reply` artifact and transition `waiting_human` → `claimed-by-orchestrator` under the same ownership fence. The orchestrator then calls `submit-brief --reason advice_answered`, which completes the original handoff without a second Quay-created handoff round-trip. | Orchestrator |
Expand Down
13 changes: 13 additions & 0 deletions docs/user/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ quay task release-claim <task_id> --claim-id <claim_id>
quay task retarget <task_id> --repo <target_repo> [--base-branch <branch>] --yes
quay task resnapshot <task_id> --reason <text>
quay task recreate-worktree <task_id> --yes [--force]
quay task increase-budget <task_id> (--by <n>|--set <n>) --reason <text> [--force]
```

`task claim` only succeeds for `awaiting-next-brief` tasks.
Expand All @@ -446,6 +447,18 @@ branch name. The repo install command runs after recreation, and Quay records a
confirming no worker is live; it allows recreation when the path already exists
or an active attempt is still recorded.

`task increase-budget` raises an existing task's copied `retry_budget` after an
operator has confirmed that control-plane or substrate failures consumed retry
budget without useful worker progress. It requires a human-readable `--reason`,
records a `task_budget_adjusted` audit event, and recomputes
`budget_exhausted` from `attempts_consumed >= retry_budget`. Use `--by <n>` to
add attempts or `--set <n>` to set a higher absolute budget. By default the
command is limited to parked/recovery states such as `awaiting-next-brief`,
`waiting_human`, `claimed-by-orchestrator`, `non_budget_loop`,
`orchestrator_loop`, and `worktree_error`; use `--force` only after confirming a
live non-terminal task should receive more retry budget. Terminal tasks cannot
be adjusted.

`task list`, `task get`, and `task events` accept legacy `task_id` values and
return the same task/run rows as before. JSON output now also includes
run-aware compatibility fields:
Expand Down
7 changes: 7 additions & 0 deletions docs/user/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,17 @@ is rejected in this state.
Use one of:

```bash
quay task increase-budget <task_id> --by 2 --reason "repaired missing worktree; previous retries did not spawn a worker"
quay escalate-human <task_id> --claim-id <claim_id> --question-file ./question.md
quay cancel <task_id>
```

Use `task increase-budget` when an operator has fixed a control-plane or
substrate issue that consumed retry budget without useful worker progress, for
example a deleted worktree that caused repeated spawn/no-progress failures. The
command records the reason in a `task_budget_adjusted` audit event and clears
`budget_exhausted` only when the raised budget is above `attempts_consumed`.

After asking a human, use `record-human-reply` to persist the answer, then
`submit-brief --reason advice_answered`; that reason is allowed because it does
not consume retry budget.
Expand Down
83 changes: 83 additions & 0 deletions packages/cli/src/cli/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ import {
recreate_task_worktree,
type RecreateWorktreeDeps,
} from "../core/recreate_worktree.ts";
import {
adjust_task_budget,
type AdjustTaskBudgetDeps,
} from "../core/task_budget.ts";
import {
claim_task,
release_claim,
Expand Down Expand Up @@ -744,6 +748,9 @@ async function handleTask(
case "recreate-worktree":
if (wantsHelp(rest)) return printHelp(io, ["task", "recreate-worktree"]);
return await handleTaskRecreateWorktree(rest, deps, io);
case "increase-budget":
if (wantsHelp(rest)) return printHelp(io, ["task", "increase-budget"]);
return await handleTaskIncreaseBudget(rest, deps, io);
default:
// A typo'd subcommand benefits from the noun's usage block as much as
// a missing one — surface it on stderr alongside the structured envelope.
Expand All @@ -756,6 +763,82 @@ async function handleTask(
}
}

async function handleTaskIncreaseBudget(
argv: string[],
deps: CliDeps,
io: CliIO,
): Promise<DispatchResult> {
const validation = validateFlags(argv, {
boolean: ["--force"],
valued: ["--by", "--set", "--reason"],
});
if (!validation.ok) {
return writeError(io, "usage_error", validation.message, validation.details);
}
const taskId = positional(argv);
if (!taskId) {
return writeError(
io,
"usage_error",
"task increase-budget requires <task_id>",
);
}
const reason = readFlag(argv, "--reason");
if (reason === null) {
return writeError(
io,
"usage_error",
"task increase-budget requires --reason <text>",
);
}
const byRaw = readFlag(argv, "--by");
const setRaw = readFlag(argv, "--set");
if (
(byRaw === null && setRaw === null) ||
(byRaw !== null && setRaw !== null)
) {
return writeError(
io,
"usage_error",
"task increase-budget requires exactly one of --by or --set",
);
}
const by = byRaw === null
? undefined
: parsePositiveIntArg(byRaw, "task increase-budget", "by");
if (by !== undefined && !by.ok) {
return writeError(io, "usage_error", by.message);
}
const set = setRaw === null
? undefined
: parsePositiveIntArg(setRaw, "task increase-budget", "set");
if (set !== undefined && !set.ok) {
return writeError(io, "usage_error", set.message);
}
const budgetDeps: AdjustTaskBudgetDeps = {
db: deps.db,
clock: deps.clock,
supervisorLock: deps.supervisorLock,
};
const input: {
taskId: string;
by?: number;
set?: number;
reason: string;
force: boolean;
} = {
taskId,
reason,
force: argv.includes("--force"),
};
if (by !== undefined) input.by = by.value;
if (set !== undefined) input.set = set.value;
return emitServiceResult(
await adjust_task_budget(budgetDeps, input),
io,
);
}

async function handleTaskRecreateWorktree(
argv: string[],
deps: CliDeps,
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const COMMANDS: Record<string, CommandSpec> = {
"task retarget",
"task resnapshot",
"task recreate-worktree",
"task increase-budget",
],
},
"task list": {
Expand Down Expand Up @@ -122,6 +123,21 @@ const COMMANDS: Record<string, CommandSpec> = {
{ flag: "--force", desc: "Allow recreation when the path exists or an active attempt is recorded." },
],
},
"task increase-budget": {
path: "task increase-budget",
synopsis:
"quay task increase-budget <task_id> (--by <n>|--set <n>) --reason <text> [--force]",
summary:
"Raise a task's retry budget and recompute its budget_exhausted flag.",
details:
"Use this for operator recovery when control-plane or substrate failures consumed retry budget without useful worker progress. By default it is limited to parked/recovery states; --force is required for live non-terminal states. Terminal tasks cannot be adjusted.",
flags: [
{ flag: "--by <n>", desc: "Increase retry_budget by a positive integer." },
{ flag: "--set <n>", desc: "Set retry_budget to a higher positive integer." },
{ flag: "--reason <text>", desc: "Required human-readable audit reason." },
{ flag: "--force", desc: "Allow adjustment for live non-terminal states." },
],
},
tick: {
path: "tick",
synopsis: "quay tick",
Expand Down
Loading