Async job orchestration
Skill verivus-oss/llm-cli-gateway/.agents/skills/async-job-orchestration
Manage long-running async LLM jobs across Claude, Codex, Gemini, Grok, Mistral, Devin, and Cursor. Use for parallel or non-blocking execution. Covers backend-qualified durability, cache-aware `promptParts`, `cache-state://` MCP resources, and the Claude `cache_ttl_expiring_soon` warning.From its SKILL.md
npx -y skills add verivus-oss/llm-cli-gateway --skill async-job-orchestrationAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 13 stars13 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
32.5 KB, as published. Nobody here has run it
Async Job Orchestration
Async execution is available for Claude, Codex, Gemini, Grok, Mistral, Devin,
and Cursor when the gateway registers async tools. SQLite and PostgreSQL stores
preserve jobs across gateway restarts; acknowledged memory storage lasts only
for the process lifetime; persistence.backend = "none" registers no async or
job tools. See Durability & Dedup.
Mistral Vibe: the gateway always emits
--agent <mode>and defaults toaccept-editsfor programmatic callers. Mistral usesapprovalStrategy:"legacy":mcp_managedis rejected before launch andapprovalPolicyhas no effect. Current Vibe defaults session logging on;doctor --jsonflags an explicit[session_logging] enabled = falsebefore session-continuity use.
Dispatch Defaults
Apply these on every dispatch unless the caller has explicitly overridden a rule in the current turn:
- Omit
model: let the gateway use its configured default per CLI. Nominating a model risks deprecated IDs (o3,o3-pro,gpt-4o, …) and capability mismatches. - Managed approval is Claude-only. Use
approvalStrategy:"mcp_managed"only withclaude_requestorclaude_request_async. The gateway makes a request-scoped--strict-mcp-configfrom only provisioned gateway-owned local MCP definitions; dynamicnpx, ambientPATH, and Codex-config overrides are excluded. UseapprovalStrategy:"legacy"for Codex, Gemini, Grok, Mistral, Devin, and Cursor. They rejectmcp_managedbefore launch because their ambient MCP configuration cannot be isolated.approvalPolicyis valid only for Claude managed requests and has no effect with legacy. - No caller-imposed wallclock timeout; poll every 60 s by default: see Polling Strategy below. When the user requests a 90-second progress cadence, do not poll earlier. Do not cancel jobs for taking too long; cancel only on explicit instruction or hard failure.
idleTimeoutMsis separate: a no-output safeguard for incremental providers, and a total-runtime bound for terminal-burst ones (see Idle Timeout and Runtime Cap). - Complete mandatory reviews without a cap. Dispatch review work only through
the current local stdio gateway MCP surface. Require the terminal JSON verdict
APPROVED_UNCONDITIONALLY,CHANGES_REQUIRED, orBLOCKED_EXTERNAL. OnCHANGES_REQUIRED, a qualified result, incomplete evidence, or malformed output, fix or rebut findings with evidence and re-review the same required reviewer. Do not set a review round, turn, token, price, budget, or wallclock limit. A failed or unavailable required reviewer must be retained asBLOCKED_EXTERNALwith its exact error, never treated as approved. Repair and retry it until it returnsAPPROVED_UNCONDITIONALLY, or stop only for explicit user cancellation or a terminal external blocker.
Explicit user-authorized full-access review jobs
The normal examples in this skill retain safe defaults. When the user explicitly
requires full provider permissions and native MCP access for a review, use the
complete multi-llm-review full-access protocol. Build the exact target
checkout and launch node dist/index.js --transport=stdio from it; never send
that review to a globally installed or stale gateway process.
Use the provider-native mapping from that protocol on every new async job. Do not add tool/MCP allowlists, deny lists, or user caps, and do not assume a previous job or resume retained its full-access posture. Preserve the provider's ambient native MCP configuration and ask the reviewer to use its available MCP tools when useful. A missing required native MCP capability is a finding or concrete blocker, not a reason to fabricate an allowlist.
Each job prompt must carry the verification report as a corrective-program
specification, the exact base and diff or exhaustive changed-file list,
product-relevant untracked files, and persistent evidence locations. Require
independent inspection of code, docs, tests, and commands. When the user asks
for 90-second progress checks, wait non-blockingly and do not call
llm_job_status earlier than 90 seconds after the prior check. Retrieve enough
raw output to preserve the terminal evidence; never set maxChars merely to
shorten a mandatory review result.
Auto-Async Deferral
Sync tools auto-defer when execution exceeds sync deadline. No manual sync/async choice needed.
Flow
- Call a sync tool (
claude_request,codex_request,gemini_request,grok_request,mistral_request,devin_request, orcursor_request) - Gateway starts CLI as background job, polls internally
- Job completes within deadline (default 45s) → result returned directly
- Deadline exceeded → deferred response with
jobIdfor polling
Deferred Response
{
"status": "deferred",
"jobId": "uuid",
"cli": "claude",
"correlationId": "...",
"message": "Execution exceeded sync deadline (45000ms). Poll with llm_job_status, collect with llm_job_result.",
"sessionId": "...",
"pollWith": "llm_job_status",
"collectWith": "llm_job_result",
"cancelWith": "llm_job_cancel"
}
Handling
- Parse response as JSON
- If
status==="deferred"→ extractjobId - Poll
llm_job_status({jobId})untiljob.statusis terminal (completed,failed,canceled, ororphaned) - Collect
llm_job_result({jobId})
Non-deferred responses: process as normal. SQLite and PostgreSQL results are retained for the configured period (30 days by default), so they can be fetched after a restart. Memory-backed results disappear when the gateway exits.
Configuration
SYNC_DEADLINE_MS=45000 # Default: 45s (under 60s MCP client cap)
SYNC_DEADLINE_MS=0 # Disable auto-deferral (pure sync)
SYNC_DEADLINE_MS=20000 # Shorter deadline
When to Use Explicit Async
Use *_request_async when:
- Fire-and-forget (start job, work on other tasks, check later)
- Launching multiple parallel jobs (need all job IDs upfront)
- Avoiding any sync wait (even 45s deadline)
Core Tools
| Tool | Purpose |
|---|---|
claude_request_async | Start async Claude job |
codex_request_async | Start async Codex job |
gemini_request_async | Start async Gemini job |
grok_request_async | Start async Grok (xAI) job |
mistral_request_async | Start async Mistral Vibe job |
devin_request_async | Start async Devin job |
cursor_request_async | Start async Cursor Agent job |
llm_job_status | Poll job status (in-memory + durable store fallback) |
llm_job_watch | Wait briefly for new privacy-safe normalized progress |
llm_job_result | Retrieve job output (in-memory + durable store fallback) |
llm_job_cancel | Cancel running job |
llm_process_health | Inspect in-memory job/process health |
llm_request_result | Read back any persisted request by correlationId |
Single Job
Start
claude_request_async({prompt:"Analyze src/ for type safety...",workingDir:"<repo>",approvalStrategy:"mcp_managed",optimizePrompt:true})
Response:
{
"success": true,
"job": { "id": "job-abc123", "cli": "claude", "status": "running", "startedAt": "..." },
"sessionId": "...",
"approval": null,
"mcpServers": { "requested": [] }
}
- Gemini async responses also include
resumable:true|false; only user-provided GeminisessionIdvalues are resumable - Gateway-generated Gemini
gw-*IDs are bookkeeping IDs and are rejected if passed back assessionId
Poll
llm_job_status({jobId:"job-abc123"})
Statuses: queued | running | completed | failed | canceled |
orphaned. The last four are terminal except queued and running.
Pass afterProgressSeq to receive only newer normalized progress and
progressLimit to request up to 64 events in forward sequence order. Preserve
progress.nextAfterSeq as the next cursor. progress.highWaterSeq and its
compatibility alias lastSeq report the highest emitted sequence;
progress.hasMore reports retained events after the page. Capability is
structured, activity_only, or lifecycle_only; messages never include raw
reasoning, provider-supplied tool names, tool arguments, paths, provider IDs, or
provider output text. Tool-start activity uses the fixed message
Using a provider tool. Claude stream-JSON, Codex JSONL, and Grok streaming-JSON
can report structured phases.
Codex validation/review calls without JSONL report activity_only; HTTP/API
jobs report lifecycle_only; other process modes report activity only.
Use llm_job_watch({jobId,afterProgressSeq,waitMs}) to wait up to 30 seconds for
new events. MCP progress notifications are emitted only when that active request
carries a progress token. A watch call does not relax a user-required 90-second
orchestration check cadence.
Retrieve
llm_job_result({jobId:"job-abc123",maxChars:200000})
maxChars: 1,000 to 2,000,000 (default 200,000), applied independently to stdout and stderr. The default offsets return the head page of each captured stream, not the tail.stdoutTruncated/stderrTruncatedindicate that more captured characters exist after that page.stdoutTotalChars/stderrTotalCharsstate the full captured lengths.- Default display output is for ordinary inspection only. It can be parsed, reconstructed, or compressed, so it rejects non-zero stream offsets.
Retrieve a complete stale result
For a durable result fetched after a wrapper timeout or gateway restart, use
rawOutput:true to retrieve a deterministic head-to-tail page sequence. Start
both stream offsets at zero, then keep each offset independent:
page = llm_job_result({
jobId:"job-abc123",
rawOutput:true,
maxChars:200000,
stdoutOffsetChars:0,
stderrOffsetChars:0
})
// Append page.result.stdout and page.result.stderr to their matching streams.
// On the next call, use each non-null *NextOffsetChars value.
// If one stream is already complete while the other still has pages, pass that
// completed stream's *TotalChars as its offset. Do not omit it: omission resets
// the request offset to zero and returns its head page again.
next = llm_job_result({
jobId:"job-abc123",
rawOutput:true,
maxChars:200000,
stdoutOffsetChars: page.result.stdoutNextOffsetChars ?? page.result.stdoutTotalChars,
stderrOffsetChars: page.result.stderrNextOffsetChars ?? page.result.stderrTotalChars
})
Stop when both stdoutNextOffsetChars and stderrNextOffsetChars are null.
On the local stdio surface, raw pages concatenate to the captured stdout and
stderr streams. Remote callers use the same offset protocol but receive
provider-session-ID-redacted, sanitized pages, not byte-for-byte provider
output.
Read a request back instead of re-running it
llm_request_result({correlationId}) returns any persisted request, sync or
async, from the flight recorder. Reach for it whenever a response was lost:
a wrapper timed out, a client disconnected, or the transcript was truncated.
Re-issuing the request instead burns the provider call again and produces a
different answer.
llm_request_result({correlationId:"corr-abc123"})
Prefer it over llm_job_result when you want the provider's complete final
response. For Codex in particular, llm_job_result surfaces codexDisplayText,
which can be a truncated interim line rather than the finished answer, while the
flight recorder holds the whole thing. Every request carries a correlationId
in its response envelope, including the deferral payload shown above, so capture
it before you need it.
Cancel
llm_job_cancel({jobId:"job-abc123"})
Sends SIGTERM, then SIGKILL after 5s.
Idle Timeout and Runtime Cap
The default is derived from each provider's registry outputDiscipline.streaming
value, and it means two different things.
For incremental providers the default is a genuine idle timeout: the process is killed if it produces no stdout/stderr for the duration, which detects a stuck process.
For terminal-burst providers it is not an idle timeout at all. Those
providers emit zero bytes until they exit, so an idle timer would kill healthy
work; they instead get a total-runtime bound. The classification describes each
provider's default invocation: cursor with outputFormat: "stream-json" does
stream, and there the same value behaves as a real idle window.
| CLI | Discipline | Default | Meaning |
|---|---|---|---|
| Claude | incremental | 600,000ms | Idle. stream-json mode only. text/json produce no output until done (would false-positive) |
| Codex | incremental | 600,000ms | Idle. Streams stderr progress: works all modes |
| Grok | incremental | 600,000ms | Idle. Streams stdout: works all modes |
| Gemini | terminal-burst | 3,600,000ms | Total runtime, not idle. Emits nothing until exit |
| Mistral Vibe | terminal-burst | 3,600,000ms | Total runtime, not idle. Emits nothing until exit |
| Devin | terminal-burst | 3,600,000ms | Total runtime, not idle. Emits nothing until exit |
| Cursor Agent | terminal-burst | 3,600,000ms | Total runtime, not idle, under the default text invocation. With outputFormat: "stream-json" Cursor streams, so the timer is a genuine idle window |
The bound on terminal-burst providers is kept rather than removed because the stall checker only warns and never kills, so nothing else would bound a hung process.
Override: idleTimeoutMs:int (30,000 to 3,600,000)
When the idle timeout fires, or the total-runtime bound on a terminal-burst provider: exit code 125 (non-transient, no retry).
Parallel Jobs
Start all, then collect:
claude_request_async({prompt:"Review architecture in <repo>. Return terminal JSON verdict APPROVED_UNCONDITIONALLY, CHANGES_REQUIRED, or BLOCKED_EXTERNAL with inspected evidence.",workingDir:"<repo>",approvalStrategy:"legacy",correlationId:"review-arch"})
codex_request_async({prompt:"Check <repo> for bugs. Return terminal JSON verdict APPROVED_UNCONDITIONALLY, CHANGES_REQUIRED, or BLOCKED_EXTERNAL with inspected evidence.",workingDir:"<repo>",sandboxMode:"read-only",approvalStrategy:"legacy",correlationId:"review-impl"})
gemini_request_async({prompt:"Security audit <repo>. Return terminal JSON verdict APPROVED_UNCONDITIONALLY, CHANGES_REQUIRED, or BLOCKED_EXTERNAL with inspected evidence.",workingDir:"<repo>",approvalStrategy:"legacy",correlationId:"review-sec"})
grok_request_async({prompt:"Independently review <repo>. Return terminal JSON verdict APPROVED_UNCONDITIONALLY, CHANGES_REQUIRED, or BLOCKED_EXTERNAL with inspected evidence.",workingDir:"<repo>",approvalStrategy:"legacy",correlationId:"review-grok"})
mistral_request_async({prompt:"Independently review <repo>. Return terminal JSON verdict APPROVED_UNCONDITIONALLY, CHANGES_REQUIRED, or BLOCKED_EXTERNAL with inspected evidence.",workingDir:"<repo>",approvalStrategy:"legacy",correlationId:"review-mistral"})
devin_request_async({prompt:"Independently review <repo>. Return terminal JSON verdict APPROVED_UNCONDITIONALLY, CHANGES_REQUIRED, or BLOCKED_EXTERNAL with inspected evidence.",workingDir:"<repo>",approvalStrategy:"legacy",correlationId:"review-devin"})
cursor_request_async({prompt:"Independently review <repo>. Return terminal JSON verdict APPROVED_UNCONDITIONALLY, CHANGES_REQUIRED, or BLOCKED_EXTERNAL with inspected evidence.",workingDir:"<repo>",approvalStrategy:"legacy",correlationId:"review-cursor"})
Poll each with llm_job_status every 60s by default, or every 90s when the user
explicitly requires that cadence. Retrieve with llm_job_result when terminal.
Before a review fan-out, verify the target checkout. workingDir selects the
local checkout for all seven providers. For Gemini, includeDirs is an extra
read path and not a cwd selector, so workingDir is the scoping input. For
Cursor, workspace is its own native selector (a saved-workspace name, a
.code-workspace file, or a directory); an absolute workspace path that
disagrees with workingDir is rejected rather than silently ranked.
Never let a reviewer silently inspect an unrelated configured default workspace. An unscoped local child runs in a fresh neutral temporary directory, not the gateway process repository.
Polling Strategy
- Poll
llm_job_statusevery 60 seconds by default. When the user requires a 90-second cadence, do not check earlier than 90 seconds after the prior status call. - No caller-imposed wallclock timeout: good reviews take minutes to tens of minutes
- Do not cancel jobs for "taking too long"; cancel only on explicit user instruction or hard failure (process dead, non-transient error such as exit 125/126)
idleTimeoutMscarries two meanings and neither needs tightening for normal reviews. For incremental providers (claude, codex, grok) it is a 10-minute no-output safeguard. For terminal-burst providers (gemini, mistral, devin, and cursor under its default text output) it is a one-hour total-runtime bound, because those emit nothing until exit and an idle timer would kill healthy work. Devin is included; it has no separate exemption. Cursor withoutputFormat: "stream-json"does stream, so there the same value behaves as a real idle window. See Idle Timeout and Runtime Cap.- When using
ScheduleWakeupor sleep loops, use the requested cadence. The default is 60 s; a user-required 90 s cadence wins. The 5-minute prompt-cache window also favors intervals ≤ 270 s or ≥ 20 min.
Wait-between-polls mechanism (orchestrator-specific)
Between llm_job_status calls, use a non-blocking wait. Standalone sleep commands are blocked in some orchestrators (e.g. the Claude Code harness rejects Bash({command: "sleep 60"}) as a standalone call).
- Claude Code harness: use
Bashwithrun_in_background: truefor a one-shot wait:
TheBash({command: "sleep 60 && echo done", run_in_background: true}) // Returns a task ID. Harness emits a completion notification when the 60s elapses. // On notification, call llm_job_status.Monitortool is for streaming progress (one event per stdout line), not for one-shot waits. Do not chain multiple short sleeps to work around the standalone-sleep block; the harness detects and blocks that too. ScheduleWakeup(if available in your orchestrator): schedule a wakeup withdelaySeconds: 60and a prompt that resumes the polling loop. The runtime fires you back on schedule.- Codex CLI / other orchestrators: use the orchestrator's native non-blocking wait primitive (e.g. async/await on a timer). Avoid synchronous blocking sleeps that freeze the agent loop.
Treat the wait as "yield control for ~60 s, then poll once," not "block the shell for 60 s."
Error Handling
| Status | Exit Code | Meaning | Action |
|---|---|---|---|
completed | 0 | Success | Retrieve result |
failed | 124 | CLI timeout | Check stderr |
failed | 125 | Idle timeout, or the total-runtime bound on a terminal-burst provider | Increase idleTimeoutMs or check CLI |
failed | 126 | Bounded I/O failure | Inspect error; reduce input/output scope |
failed | non-zero | CLI error | Check stderr |
failed | null | Process error | Check job.error |
canceled | any | Canceled | Result still retrievable |
Only exitCode===0 → completed. All non-zero → failed. Results retrievable for ALL terminal states.
Exit codes 125/126 are non-transient; the retry engine skips them. An
argv-bound prompt that exceeds the platform-safe UTF-8 byte ceiling returns
errorCategory:"input_too_large" and is never truncated. Codex new and resume
prompts use stdin and do not consume the single-argv prompt allowance.
codex_fork_session remains argv-bound and still applies the size rejection
ahead of its own availability check, so an oversized prompt returns
input_too_large there rather than the unavailability message. The tool itself
cannot run from the gateway: codex fork requires a controlling terminal. To
continue a Codex conversation, call codex_request with a real Codex session
UUID or resumeLatest:true. Adjust the scope or choose a verified stdin, ACP,
or HTTP transport. All other
caller-controlled argv values are admitted in their final encoded form before
spawn, including serialized JSON and joined lists. The resolved command line
also has a conservative platform-specific aggregate byte budget and a
2,048-element cap. That byte budget excludes environment bytes but reserves
headroom for them. Windows preflight assumes the smaller npm .cmd/.bat
wrapper limit until resolution proves a native executable, and handler-added
native session flags are admitted before workspace, session, provider-artifact
handoff, or durable-job effects on non-Kit requests. Claude Kit projects its
eventual argv before compiled-context artifact materialization or durable
Kit-session allocation. Native E2BIG remains a redacted fallback. The
gateway never silently truncates input. An embedded NUL byte in command or argv
returns non-retryable invalid_input before spawn. Public results, long-lived
job memory, durable args, and async flight rows use a fixed invalid-argv marker,
while the optional duplicate durable payload is suppressed. None retains Node's
value-echoing native error or the rejected vector.
For stdin-backed jobs, a clean provider exit completes only after the entire
payload write callback succeeds. Closed or pending delivery becomes a fixed,
non-sensitive failure; timeout, cancellation, and provider nonzero exits keep
their normal precedence.
Job Lifecycle
- SQLite/PostgreSQL: state transitions are durable.
llm_job_statusandllm_job_resultread the store after an in-memory eviction or restart. Normalized progress is persisted with lifecycle state, refreshed across gateway instances, and fenced so stale owners cannot overwrite an orphaned terminal projection. - Memory: async registration requires
[persistence].acknowledgeEphemeral = true; results vanish on gateway exit. - None: async request and job tools are not registered.
- Configure retention and dedup with
[persistence].retentionDaysand[persistence].dedupWindowMs. The oldLLM_GATEWAY_JOB_RETENTION_DAYSandLLM_GATEWAY_DEDUP_WINDOW_MSoverrides remain deprecated compatibility paths. - Jobs still running when the gateway stopped can become
orphaned; captured partial output remains readable when the store is durable. - The default combined stdout/stderr cap is 50 MiB, controlled by
[limits].max_job_output_bytes; overflow terminates the process with exit 126. - The completed in-memory cache has its own configured TTL. It is not the durable-retention policy.
Durability & Dedup
The gateway is a durable result collection layer. Two behaviors directly address the "agent polls, times out, re-issues, the whole CLI run starts over" failure mode:
Auto-dedup
Identical *_request / *_request_async calls within the dedup window (default 1 hour, LLM_GATEWAY_DEDUP_WINDOW_MS in ms) short-circuit onto the existing running or completed job. You get back the same jobId instead of spawning a duplicate run.
- Set
LLM_GATEWAY_DEDUP_WINDOW_MS=0to disable dedup gateway-wide. - Pass
forceRefresh: trueon a single call to bypass dedup for that request:
codex_request_async({prompt:"...",workingDir:"<repo>",sandboxMode:"workspace-write",approvalStrategy:"legacy",forceRefresh:true})
Use forceRefresh when you genuinely need a fresh CLI run (e.g., file contents changed since the last dispatch, retry after manual fix). For normal "I crashed and restarted, let me re-issue" recovery, omit forceRefresh; dedup is exactly what you want.
Durable retrieval
- Polling timeouts no longer destroy results. If your wrapper agent gives up at 5 minutes,
llm_job_status({jobId})andllm_job_result({jobId})still return the completed result hours/days later. - Gateway restarts no longer destroy results. Persisted rows survive process restarts.
- An
orphanedjob wasrunningwhen the gateway last stopped; partial output is still readable. Treat it as a non-recoverable terminal state for the active CLI invocation (re-dispatch withforceRefresh:trueif you need fresh work).
Recovery pattern
// 1. Wrapper agent died after dispatching; you have no jobId in memory.
// 2. Re-issue the identical *_request_async call. The gateway dedups onto
// the existing in-flight or completed job and returns its jobId. Dedup is
// cwd-aware, so the scope must match the original call too, not just the
// prompt and correlationId.
result = codex_request_async({prompt:"<same prompt as before>",workingDir:"<same workingDir as before>",sandboxMode:"workspace-write",approvalStrategy:"legacy",correlationId:"<same correlationId>"})
// result.job.id is the original job
// 3. Poll/fetch as normal; works whether the job is running, completed, or completed days ago.
Cache-Aware Prompts (promptParts)
Claude, Codex, Gemini, Grok, and Mistral async tools accept structured
promptParts instead of a flat prompt. Devin and Cursor accept only prompt.
The two fields are mutually exclusive where promptParts exists: supplying both
returns provide exactly one of \prompt` or `promptParts`; supplying neither returns one of `prompt` or `promptParts` is required`. Keep a canonical flat
brief when fanning out to Devin or Cursor so every required reviewer receives
the same substantive evidence packet.
codex_request_async({
promptParts: {
system: "<long stable system instruction>",
tools: "<long stable tool description>",
context: "<long stable spec or file dump>",
task: "Implement X per the above."
},
sandboxMode: "workspace-write",
workingDir: "<repo>",
approvalStrategy: "legacy",
correlationId: "impl-r1"
})
The gateway concatenates in canonical order system → tools → context → task.
For providers that accept promptParts, that preserves stable-prefix hashing
across re-issues. It does not prove equal provider-side cache behavior or a hit.
For parallel async fan-out, only the five providers that accept promptParts
can share the structured stable prefix. Devin and Cursor receive the canonical
flat packet instead. cache-state://prefix/{hash} exposes gateway flight
recorder aggregates; it is observability, not proof that a provider hit its
cache or that providers shared equivalent cache behavior.
Cache observability resources
Three MCP resources expose cache effectiveness from the flight recorder (tokens / hashes / aggregates only, no prompt or response text):
cache-state://global: last-24h aggregate hit rate, total hits, estimated savings, per-CLI breakdowncache-state://session/{sessionId}: per-session aggregates includingttlRemainingMsfor Claudecache-state://prefix/{hash}: per-stable-prefix-hash aggregates with CLI × model breakdown
session_get({sessionId}) also projects a compact cacheState block when the session has prior requests (omitted entirely for fresh sessions).
TTL warning on Claude async jobs (opt-in)
With [cache_awareness] warn_on_ttl_expiry = true in ~/.llm-cli-gateway/config.toml, both claude_request and claude_request_async responses include a structured warning when the resumed session's prior lastRequestAt is within 30 s of Anthropic's cache TTL (default 5 min; 1 h when [cache_awareness] anthropic_ttl_seconds = 3600):
{
"warnings": [
{
"code": "cache_ttl_expiring_soon",
"ttlRemainingMs": 12000,
"message": "Anthropic cache breakpoint for session <id> expires in 12000ms (< 30000ms). Subsequent requests may miss the cache."
}
]
}
For long-running async loops on a Claude session, treat the warning as a hint to dispatch the next turn promptly (or accept the upcoming cache miss). The warning is gated on the config flag and appears only for Claude sessions with prior cache writes.
Tips
- Use
correlationIdon every job for log tracing - Async jobs do NOT support
optimizeResponse; optimize after retrieval - Sessions work with async only when the provider-specific request surface and returned native handle permit it. Gateway session bookkeeping is not itself a provider-native resume handle.
- When durable async jobs are enabled, sync tools can auto-defer at the 45 s deadline: check for
status:"deferred"in sync responses, then poll every 60 s by default or at a user-required 90-second cadence. Withpersistence.backend = "none", async and job tools are absent and sync requests run to completion. SYNC_DEADLINE_MS=0disables auto-deferral- For Gemini, check
resumable: onlytruefor a user-providedsessionId - Raise
idleTimeoutMsfor tasks with long silent periods on an incremental provider. On a terminal-burst provider in its default mode silence is normal for the whole run, so raising it raises the total-runtime bound rather than an idle window - Review jobs: every required healthy reviewer must return an evidence-backed
APPROVED_UNCONDITIONALLY. Do not downgrade, skip, vote around, or impose a budget/round/turn/wallclock cap. A provider failure is incomplete review work, recorded asBLOCKED_EXTERNAL, not an approval. - If jobs fail because a CLI is missing or stale, check
cli_versionsand runcli_upgradeas a dry run before any real upgrade. Grok self-updates viagrok update;cli_upgraderoutes that for you. - Don't burn re-runs after a polling timeout: re-issue the same call; auto-dedup snaps you back onto the live job. Reserve
forceRefresh:truefor cases where the underlying inputs actually changed. - Durable results outlive in-memory caches only on SQLite/PostgreSQL. Do not hold polling open merely to avoid losing a durable result; memory-backed results still disappear on a process exit.
- When Personal Agent Config Kit is enabled, only Claude/Codex/Mistral Kit
requests are admitted, and they require healthy SQLite/PostgreSQL durable admission.
Cross-provider validation and least-cost routing are unavailable in Kit mode.
The normal
workingDirtargeting rule does not apply to a Claude or Mistral Kit request: both reject caller-suppliedworkingDirbefore context compilation. Inspect a candidate folder withexplain_effective_config, then target that provider through an already configured registeredworkspacealias or the configured default workspace. It never inherits the gateway process cwd.