[object Object]

← back to Cli Printing Press

fix(cli): bypass response cache when polling async jobs (#1429)

acbb3dfe6938d4c820533d63bccfa271a30b1dce · 2026-05-15 00:13:33 -0700 · Trevin Chow

* fix(cli): bypass response cache when polling async jobs

The generated client's response cache is keyed by (path, params) with a
5-minute TTL. WaitForJob polls the same status endpoint repeatedly, so
the cached non-terminal response from the first poll was returned to
every subsequent poll for up to the TTL, locking the loop on the initial
status (Pending/Busy/Running) even after the server-side job completed.

Expose per-call GetNoCache and GetWithHeadersNoCache helpers on the
client so polling code can bypass the cache without mutating the global
c.NoCache toggle, and switch WaitForJob to use them.

Closes #1136

* fix(cli): refresh cache after no-cache GET; add async-job golden

Two follow-ups to Greptile P2 feedback on #1429:

- GetWithHeadersNoCache now writes the fresh response to cache on success.
  Previously it skipped both read and write, leaving the on-disk cache
  pinned to the first poll's non-terminal snapshot. A follow-up call
  like `renders get <id>` run shortly after `--wait` returned would
  still serve the stale Pending state for the cache TTL. Writing back
  on success makes the helper "skip stale read, refresh cache" instead
  of a full bypass, so subsequent c.Get calls see the terminal value.

- Add a dedicated generate-async-job-api golden case with a minimal
  OpenAPI fixture that triggers DetectAsyncJobs (POST returning job_id
  + sibling GET status). Without async detection, no existing golden
  exercises jobs.go emission; this locks the WaitForJob -> GetNoCache
  wiring against future template drift (indentation, surrounding
  comments) that the unit test's string-contains assertions cannot
  catch.

Files touched

Diff

commit acbb3dfe6938d4c820533d63bccfa271a30b1dce
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Fri May 15 00:13:33 2026 -0700

    fix(cli): bypass response cache when polling async jobs (#1429)
    
    * fix(cli): bypass response cache when polling async jobs
    
    The generated client's response cache is keyed by (path, params) with a
    5-minute TTL. WaitForJob polls the same status endpoint repeatedly, so
    the cached non-terminal response from the first poll was returned to
    every subsequent poll for up to the TTL, locking the loop on the initial
    status (Pending/Busy/Running) even after the server-side job completed.
    
    Expose per-call GetNoCache and GetWithHeadersNoCache helpers on the
    client so polling code can bypass the cache without mutating the global
    c.NoCache toggle, and switch WaitForJob to use them.
    
    Closes #1136
    
    * fix(cli): refresh cache after no-cache GET; add async-job golden
    
    Two follow-ups to Greptile P2 feedback on #1429:
    
    - GetWithHeadersNoCache now writes the fresh response to cache on success.
      Previously it skipped both read and write, leaving the on-disk cache
      pinned to the first poll's non-terminal snapshot. A follow-up call
      like `renders get <id>` run shortly after `--wait` returned would
      still serve the stale Pending state for the cache TTL. Writing back
      on success makes the helper "skip stale read, refresh cache" instead
      of a full bypass, so subsequent c.Get calls see the terminal value.
    
    - Add a dedicated generate-async-job-api golden case with a minimal
      OpenAPI fixture that triggers DetectAsyncJobs (POST returning job_id
      + sibling GET status). Without async detection, no existing golden
      exercises jobs.go emission; this locks the WaitForJob -> GetNoCache
      wiring against future template drift (indentation, surrounding
      comments) that the unit test's string-contains assertions cannot
      catch.
---
 internal/generator/generator_test.go               |  53 +++
 internal/generator/templates/client.go.tmpl        |  24 ++
 internal/generator/templates/jobs.go.tmpl          |   5 +-
 .../cases/generate-async-job-api/artifacts.txt     |   7 +
 .../cases/generate-async-job-api/command.txt       |   2 +
 .../async-job-api/internal/cli/jobs.go             | 357 +++++++++++++++++++++
 .../expected/generate-async-job-api/exit.txt       |   1 +
 .../expected/generate-async-job-api/stderr.txt     |   2 +
 .../expected/generate-async-job-api/stdout.txt     |   0
 .../internal/client/client.go                      |  24 ++
 .../internal/client/client.go                      |  24 ++
 .../tier-routing-golden/internal/client/client.go  |  24 ++
 testdata/golden/fixtures/async-job-api.yaml        |  56 ++++
 13 files changed, 578 insertions(+), 1 deletion(-)

diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 66072459..7cd32483 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -9287,6 +9287,59 @@ func assertNewMCPClientSkipsCache(t *testing.T, specSource string) {
 		"newMCPClient must disable the response cache so MCP-driven reads see fresh state across mutations")
 }
 
+// TestGenerateWaitForJobBypassesResponseCache proves that the polling loop
+// in WaitForJob uses GetNoCache, not Get. The response cache is keyed by
+// (path, params), so a cached non-terminal status (Pending/Busy/Running)
+// from the first poll would be returned to every subsequent poll for the
+// cache TTL, locking the loop on the initial response even after the
+// server-side job has completed. The fix exposes per-call GetNoCache /
+// GetWithHeadersNoCache helpers on the generated client so polling code
+// can bypass the cache without mutating the global c.NoCache toggle.
+func TestGenerateWaitForJobBypassesResponseCache(t *testing.T) {
+	t.Parallel()
+
+	// Build a spec that triggers async-job detection (POST returning
+	// {job_id, status} + sibling GET status endpoint) so jobs.go is
+	// emitted; minimalSpec alone has no async surface.
+	apiSpec := minimalSpec("asyncpoll")
+	apiSpec.Types = map[string]spec.TypeDef{
+		"RenderJob": {Fields: []spec.TypeField{
+			{Name: "job_id", Type: "string"},
+			{Name: "status", Type: "string"},
+		}},
+	}
+	apiSpec.Resources = map[string]spec.Resource{
+		"renders": {
+			Description: "Async render jobs",
+			Endpoints: map[string]spec.Endpoint{
+				"submit": {Method: "POST", Path: "/renders", Description: "Submit a render", Response: spec.ResponseDef{Type: "object", Item: "RenderJob"}},
+				"get":    {Method: "GET", Path: "/renders/{id}", Description: "Get a render", Response: spec.ResponseDef{Type: "object", Item: "RenderJob"}},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	clientData, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+	require.NoError(t, err)
+	clientBody := string(clientData)
+
+	assert.Contains(t, clientBody, "func (c *Client) GetNoCache(",
+		"client.go must expose GetNoCache so polling callers can bypass cache without mutating c.NoCache")
+	assert.Contains(t, clientBody, "func (c *Client) GetWithHeadersNoCache(",
+		"client.go must expose GetWithHeadersNoCache for header-bearing polling fetches")
+
+	jobsData, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "jobs.go"))
+	require.NoError(t, err)
+	jobsBody := string(jobsData)
+
+	assert.Contains(t, jobsBody, "c.GetNoCache(path, nil)",
+		"WaitForJob must poll with GetNoCache to avoid locking on the cached initial response")
+	assert.NotContains(t, jobsBody, "c.Get(path, nil)",
+		"WaitForJob must not call c.Get(path, nil); cached non-terminal status would lock the poll")
+}
+
 // TestGenerateMCPHandlerPreservesQueryPositionals proves the makeAPIHandler
 // body in generated MCP tools.go distinguishes real URL path placeholders
 // (e.g. /movie/{movieId}) from CLI positional args that map to query
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index c0c304f4..8fc592ee 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -397,6 +397,30 @@ func (c *Client) GetWithHeaders(path string, params map[string]string, headers m
 	return result, err
 }
 
+// GetNoCache issues a GET that bypasses the cache read for this call only,
+// then refreshes the cache with the fresh response on success. Use for
+// polling-until-terminal patterns where every call must reflect current
+// server state; the same (path, params) pair returning a stale
+// "in-progress" snapshot from cache would lock the poll loop on the
+// initial response. Writing-back on success means subsequent c.Get calls
+// (e.g. a follow-up `... get <id>` after WaitForJob returns) see the
+// terminal value, not the stale non-terminal snapshot left behind by the
+// first poll.
+func (c *Client) GetNoCache(path string, params map[string]string) (json.RawMessage, error) {
+	return c.GetWithHeadersNoCache(path, params, nil)
+}
+
+// GetWithHeadersNoCache is GetWithHeaders that skips the cache read but
+// still writes the fresh response on success. See GetNoCache for when to
+// prefer this over Get/GetWithHeaders.
+func (c *Client) GetWithHeadersNoCache(path string, params map[string]string, headers map[string]string) (json.RawMessage, error) {
+	result, _, err := c.do("GET", path, params, nil, headers)
+	if err == nil && !c.NoCache && !c.DryRun && c.cacheDir != "" {
+		c.writeCache(path, params, result)
+	}
+	return result, err
+}
+
 func (c *Client) ProbeGet(path string) (int, error) {
 	_, status, err := c.do("GET", path, nil, nil, nil)
 	return status, err
diff --git a/internal/generator/templates/jobs.go.tmpl b/internal/generator/templates/jobs.go.tmpl
index de80b4d0..aa3b321f 100644
--- a/internal/generator/templates/jobs.go.tmpl
+++ b/internal/generator/templates/jobs.go.tmpl
@@ -143,7 +143,10 @@ func WaitForJob(ctx context.Context, c *client.Client, statusPath string, jobID
 			return nil, err
 		}
 
-		resp, err := c.Get(path, nil)
+		// GetNoCache: the cache is keyed by (path, params), so a cached
+		// non-terminal status would lock the poll on the initial response
+		// for the cache TTL.
+		resp, err := c.GetNoCache(path, nil)
 		if err == nil {
 			var body map[string]any
 			if uerr := json.Unmarshal(resp, &body); uerr == nil {
diff --git a/testdata/golden/cases/generate-async-job-api/artifacts.txt b/testdata/golden/cases/generate-async-job-api/artifacts.txt
new file mode 100644
index 00000000..6f62bdd7
--- /dev/null
+++ b/testdata/golden/cases/generate-async-job-api/artifacts.txt
@@ -0,0 +1,7 @@
+# Locks the async-job emission contract: when DetectAsyncJobs fires
+# (POST returns a job_id-shaped response + sibling status GET), the
+# generator emits jobs.go with WaitForJob polling via GetNoCache.
+# Without async detection, none of these files are emitted; without
+# the cache-bypass fix from #1136, the WaitForJob body would call
+# c.Get instead of c.GetNoCache, locking polling on the initial response.
+async-job-api/internal/cli/jobs.go
diff --git a/testdata/golden/cases/generate-async-job-api/command.txt b/testdata/golden/cases/generate-async-job-api/command.txt
new file mode 100644
index 00000000..773b4790
--- /dev/null
+++ b/testdata/golden/cases/generate-async-job-api/command.txt
@@ -0,0 +1,2 @@
+set -euo pipefail
+"$BINARY" generate --spec testdata/golden/fixtures/async-job-api.yaml --output "$CASE_ACTUAL_DIR/async-job-api" --force --spec-url file://testdata/golden/fixtures/async-job-api.yaml --validate=false
diff --git a/testdata/golden/expected/generate-async-job-api/async-job-api/internal/cli/jobs.go b/testdata/golden/expected/generate-async-job-api/async-job-api/internal/cli/jobs.go
new file mode 100644
index 00000000..c5d9a319
--- /dev/null
+++ b/testdata/golden/expected/generate-async-job-api/async-job-api/internal/cli/jobs.go
@@ -0,0 +1,357 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"math/rand"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+	"time"
+
+	"github.com/spf13/cobra"
+
+	"async-job-pp-cli/internal/client"
+)
+
+// JobRow is one entry in the local jobs ledger. Rows are appended as NDJSON
+// to ~/.async-job-pp-cli/jobs.jsonl; the latest row for a given JobID wins
+// when listing. Pruning rewrites the file without old entries.
+type JobRow struct {
+	JobID          string    `json:"job_id"`
+	Resource       string    `json:"resource"`
+	Endpoint       string    `json:"endpoint"`
+	Status         string    `json:"status"`
+	SubmittedAt    time.Time `json:"submitted_at"`
+	UpdatedAt      time.Time `json:"updated_at"`
+	StatusResource string    `json:"status_resource,omitempty"`
+	StatusEndpoint string    `json:"status_endpoint,omitempty"`
+	Error          string    `json:"error,omitempty"`
+}
+
+func jobsFilePath() (string, error) {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return "", fmt.Errorf("resolving home dir: %w", err)
+	}
+	dir := filepath.Join(home, ".async-job-pp-cli")
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		return "", fmt.Errorf("creating state dir: %w", err)
+	}
+	return filepath.Join(dir, "jobs.jsonl"), nil
+}
+
+// RecordJob appends a JobRow to the local ledger.
+func RecordJob(row JobRow) error {
+	p, err := jobsFilePath()
+	if err != nil {
+		return err
+	}
+	if row.SubmittedAt.IsZero() {
+		row.SubmittedAt = time.Now().UTC()
+	}
+	row.UpdatedAt = time.Now().UTC()
+	f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
+	if err != nil {
+		return fmt.Errorf("opening jobs ledger: %w", err)
+	}
+	defer f.Close()
+	return json.NewEncoder(f).Encode(row)
+}
+
+func readJobRows() ([]JobRow, error) {
+	p, err := jobsFilePath()
+	if err != nil {
+		return nil, err
+	}
+	data, err := os.ReadFile(p)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil, nil
+		}
+		return nil, fmt.Errorf("reading jobs ledger: %w", err)
+	}
+	var rows []JobRow
+	for _, line := range strings.Split(string(data), "\n") {
+		line = strings.TrimSpace(line)
+		if line == "" {
+			continue
+		}
+		var r JobRow
+		if err := json.Unmarshal([]byte(line), &r); err != nil {
+			continue // skip malformed rows rather than fail the whole list
+		}
+		rows = append(rows, r)
+	}
+	return rows, nil
+}
+
+// latestByJobID returns one row per JobID (the most recent by UpdatedAt).
+func latestByJobID(rows []JobRow) []JobRow {
+	byID := map[string]JobRow{}
+	for _, r := range rows {
+		existing, ok := byID[r.JobID]
+		if !ok || r.UpdatedAt.After(existing.UpdatedAt) {
+			byID[r.JobID] = r
+		}
+	}
+	out := make([]JobRow, 0, len(byID))
+	for _, r := range byID {
+		out = append(out, r)
+	}
+	sort.Slice(out, func(i, j int) bool { return out[i].SubmittedAt.After(out[j].SubmittedAt) })
+	return out
+}
+
+// WaitOptions configures WaitForJob polling.
+type WaitOptions struct {
+	Interval time.Duration // initial poll interval; grows with backoff up to 30s
+	Timeout  time.Duration // overall wait budget; 0 means no timeout
+}
+
+// WaitForJob polls statusPath with exponential backoff + jitter until the
+// response reports a terminal status or the timeout elapses. Returns the
+// final response body as a parsed map, or an error.
+//
+// Terminal statuses recognised:
+//
+//	done, complete, completed, success, succeeded, finished,
+//	failed, errored, cancelled, canceled
+func WaitForJob(ctx context.Context, c *client.Client, statusPath string, jobID string, opts WaitOptions) (map[string]any, error) {
+	interval := opts.Interval
+	if interval <= 0 {
+		interval = 2 * time.Second
+	}
+	deadline := time.Time{}
+	if opts.Timeout > 0 {
+		deadline = time.Now().Add(opts.Timeout)
+	}
+	maxInterval := 30 * time.Second
+
+	path := strings.ReplaceAll(statusPath, "{id}", jobID)
+	path = strings.ReplaceAll(path, "{job_id}", jobID)
+
+	for {
+		if !deadline.IsZero() && time.Now().After(deadline) {
+			return nil, fmt.Errorf("wait timed out after %s (job %s)", opts.Timeout, jobID)
+		}
+		if err := ctx.Err(); err != nil {
+			return nil, err
+		}
+
+		// GetNoCache: the cache is keyed by (path, params), so a cached
+		// non-terminal status would lock the poll on the initial response
+		// for the cache TTL.
+		resp, err := c.GetNoCache(path, nil)
+		if err == nil {
+			var body map[string]any
+			if uerr := json.Unmarshal(resp, &body); uerr == nil {
+				if isJobTerminal(body) {
+					return body, nil
+				}
+			}
+		}
+
+		// Jittered sleep: interval + random up to 25%.
+		jitter := time.Duration(rand.Int63n(int64(interval)/4 + 1))
+		select {
+		case <-time.After(interval + jitter):
+		case <-ctx.Done():
+			return nil, ctx.Err()
+		}
+
+		// Exponential-ish growth with cap.
+		interval = interval * 3 / 2
+		if interval > maxInterval {
+			interval = maxInterval
+		}
+	}
+}
+
+// ExtractJobID parses response bytes and returns the string value of the
+// named field (e.g. "job_id"). Returns "" if the response is not JSON or the
+// field is missing / not a string.
+func ExtractJobID(data []byte, field string) string {
+	if len(data) == 0 || field == "" {
+		return ""
+	}
+	var body map[string]any
+	if err := json.Unmarshal(data, &body); err != nil {
+		return ""
+	}
+	if v, ok := body[field].(string); ok {
+		return v
+	}
+	// Some APIs nest the job under "data" or "job".
+	for _, wrapper := range []string{"data", "job", "result"} {
+		if inner, ok := body[wrapper].(map[string]any); ok {
+			if v, ok := inner[field].(string); ok {
+				return v
+			}
+		}
+	}
+	return ""
+}
+
+func isJobTerminal(body map[string]any) bool {
+	status, _ := body["status"].(string)
+	if status == "" {
+		return false
+	}
+	switch strings.ToLower(status) {
+	case "done", "complete", "completed", "success", "succeeded", "finished",
+		"failed", "errored", "cancelled", "canceled":
+		return true
+	}
+	return false
+}
+
+// newJobsCmd registers the `jobs` parent command and its subcommands.
+func newJobsCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "jobs",
+		Short: "List and inspect async jobs tracked by this CLI",
+		Long: `Jobs tracked when you submit an async-capable endpoint land in
+~/.async-job-pp-cli/jobs.jsonl. This command lists, inspects, and prunes them.
+
+Submit an async endpoint with --wait to block until completion; submit
+without --wait to get the job ID back immediately and track it later.`,
+		RunE: parentNoSubcommandRunE(flags),
+	}
+	cmd.AddCommand(newJobsListCmd(flags))
+	cmd.AddCommand(newJobsGetCmd(flags))
+	cmd.AddCommand(newJobsPruneCmd(flags))
+	return cmd
+}
+
+func newJobsListCmd(flags *rootFlags) *cobra.Command {
+	var limit int
+	var all bool
+	cmd := &cobra.Command{
+		Use:         "list",
+		Short:       "List recent async jobs",
+		Annotations: map[string]string{"mcp:read-only": "true"},
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			rows, err := readJobRows()
+			if err != nil {
+				return err
+			}
+			if !all {
+				rows = latestByJobID(rows)
+			}
+			if limit > 0 && limit < len(rows) {
+				rows = rows[:limit]
+			}
+			if flags.asJSON {
+				return printJSONFiltered(cmd.OutOrStdout(), rows, flags)
+			}
+			headers := []string{"JOB_ID", "RESOURCE", "STATUS", "SUBMITTED", "UPDATED"}
+			tbl := make([][]string, 0, len(rows))
+			for _, r := range rows {
+				tbl = append(tbl, []string{
+					r.JobID,
+					r.Resource + "/" + r.Endpoint,
+					r.Status,
+					r.SubmittedAt.Format(time.RFC3339),
+					r.UpdatedAt.Format(time.RFC3339),
+				})
+			}
+			return flags.printTable(cmd, headers, tbl)
+		},
+	}
+	cmd.Flags().IntVar(&limit, "limit", 50, "Maximum rows to list (0 for unlimited)")
+	cmd.Flags().BoolVar(&all, "all", false, "Show every state transition rather than the latest row per job")
+	return cmd
+}
+
+func newJobsGetCmd(flags *rootFlags) *cobra.Command {
+	return &cobra.Command{
+		Use:         "get <job-id>",
+		Short:       "Show the latest state row for a job",
+		Annotations: map[string]string{"mcp:read-only": "true"},
+		Args:        cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			rows, err := readJobRows()
+			if err != nil {
+				return err
+			}
+			rows = latestByJobID(rows)
+			for _, r := range rows {
+				if r.JobID == args[0] {
+					if flags.asJSON {
+						return printJSONFiltered(cmd.OutOrStdout(), r, flags)
+					}
+					fmt.Fprintf(cmd.OutOrStdout(), "job_id:    %s\nresource:  %s/%s\nstatus:    %s\nsubmitted: %s\nupdated:   %s\n",
+						r.JobID, r.Resource, r.Endpoint, r.Status, r.SubmittedAt.Format(time.RFC3339), r.UpdatedAt.Format(time.RFC3339))
+					if r.Error != "" {
+						fmt.Fprintf(cmd.OutOrStdout(), "error:     %s\n", r.Error)
+					}
+					return nil
+				}
+			}
+			return fmt.Errorf("job %q not found in local ledger", args[0])
+		},
+	}
+}
+
+func newJobsPruneCmd(flags *rootFlags) *cobra.Command {
+	var olderThan time.Duration
+	cmd := &cobra.Command{
+		Use:   "prune",
+		Short: "Remove job rows older than --older-than",
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			rows, err := readJobRows()
+			if err != nil {
+				return err
+			}
+			cutoff := time.Now().Add(-olderThan)
+			var kept []JobRow
+			pruned := 0
+			for _, r := range rows {
+				if r.UpdatedAt.Before(cutoff) {
+					pruned++
+					continue
+				}
+				kept = append(kept, r)
+			}
+
+			p, err := jobsFilePath()
+			if err != nil {
+				return err
+			}
+			tmp := p + ".tmp"
+			f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
+			if err != nil {
+				return fmt.Errorf("opening tmp ledger: %w", err)
+			}
+			enc := json.NewEncoder(f)
+			for _, r := range kept {
+				if err := enc.Encode(r); err != nil {
+					_ = f.Close()
+					_ = os.Remove(tmp)
+					return fmt.Errorf("writing tmp ledger: %w", err)
+				}
+			}
+			if err := f.Close(); err != nil {
+				_ = os.Remove(tmp)
+				return err
+			}
+			if err := os.Rename(tmp, p); err != nil {
+				return fmt.Errorf("replacing ledger: %w", err)
+			}
+
+			if flags.asJSON {
+				return printJSONFiltered(cmd.OutOrStdout(), map[string]any{"pruned": pruned, "kept": len(kept)}, flags)
+			}
+			fmt.Fprintf(cmd.OutOrStdout(), "pruned %d, kept %d\n", pruned, len(kept))
+			return nil
+		},
+	}
+	cmd.Flags().DurationVar(&olderThan, "older-than", 7*24*time.Hour, "Prune rows whose latest update is older than this duration")
+	return cmd
+}
diff --git a/testdata/golden/expected/generate-async-job-api/exit.txt b/testdata/golden/expected/generate-async-job-api/exit.txt
new file mode 100644
index 00000000..573541ac
--- /dev/null
+++ b/testdata/golden/expected/generate-async-job-api/exit.txt
@@ -0,0 +1 @@
+0
diff --git a/testdata/golden/expected/generate-async-job-api/stderr.txt b/testdata/golden/expected/generate-async-job-api/stderr.txt
new file mode 100644
index 00000000..9c0d74cf
--- /dev/null
+++ b/testdata/golden/expected/generate-async-job-api/stderr.txt
@@ -0,0 +1,2 @@
+warning: could not derive run_id from --research-dir; phase5 dogfood acceptance will refuse to write without it
+Generated async-job at <ARTIFACT_DIR>/generate-async-job-api/async-job-api
diff --git a/testdata/golden/expected/generate-async-job-api/stdout.txt b/testdata/golden/expected/generate-async-job-api/stdout.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
index 2ec802ce..278bf2e1 100644
--- a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
@@ -90,6 +90,30 @@ func (c *Client) GetWithHeaders(path string, params map[string]string, headers m
 	return result, err
 }
 
+// GetNoCache issues a GET that bypasses the cache read for this call only,
+// then refreshes the cache with the fresh response on success. Use for
+// polling-until-terminal patterns where every call must reflect current
+// server state; the same (path, params) pair returning a stale
+// "in-progress" snapshot from cache would lock the poll loop on the
+// initial response. Writing-back on success means subsequent c.Get calls
+// (e.g. a follow-up `... get <id>` after WaitForJob returns) see the
+// terminal value, not the stale non-terminal snapshot left behind by the
+// first poll.
+func (c *Client) GetNoCache(path string, params map[string]string) (json.RawMessage, error) {
+	return c.GetWithHeadersNoCache(path, params, nil)
+}
+
+// GetWithHeadersNoCache is GetWithHeaders that skips the cache read but
+// still writes the fresh response on success. See GetNoCache for when to
+// prefer this over Get/GetWithHeaders.
+func (c *Client) GetWithHeadersNoCache(path string, params map[string]string, headers map[string]string) (json.RawMessage, error) {
+	result, _, err := c.do("GET", path, params, nil, headers)
+	if err == nil && !c.NoCache && !c.DryRun && c.cacheDir != "" {
+		c.writeCache(path, params, result)
+	}
+	return result, err
+}
+
 func (c *Client) ProbeGet(path string) (int, error) {
 	_, status, err := c.do("GET", path, nil, nil, nil)
 	return status, err
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
index 4ef66b85..12a6aba0 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
@@ -84,6 +84,30 @@ func (c *Client) GetWithHeaders(path string, params map[string]string, headers m
 	return result, err
 }
 
+// GetNoCache issues a GET that bypasses the cache read for this call only,
+// then refreshes the cache with the fresh response on success. Use for
+// polling-until-terminal patterns where every call must reflect current
+// server state; the same (path, params) pair returning a stale
+// "in-progress" snapshot from cache would lock the poll loop on the
+// initial response. Writing-back on success means subsequent c.Get calls
+// (e.g. a follow-up `... get <id>` after WaitForJob returns) see the
+// terminal value, not the stale non-terminal snapshot left behind by the
+// first poll.
+func (c *Client) GetNoCache(path string, params map[string]string) (json.RawMessage, error) {
+	return c.GetWithHeadersNoCache(path, params, nil)
+}
+
+// GetWithHeadersNoCache is GetWithHeaders that skips the cache read but
+// still writes the fresh response on success. See GetNoCache for when to
+// prefer this over Get/GetWithHeaders.
+func (c *Client) GetWithHeadersNoCache(path string, params map[string]string, headers map[string]string) (json.RawMessage, error) {
+	result, _, err := c.do("GET", path, params, nil, headers)
+	if err == nil && !c.NoCache && !c.DryRun && c.cacheDir != "" {
+		c.writeCache(path, params, result)
+	}
+	return result, err
+}
+
 func (c *Client) ProbeGet(path string) (int, error) {
 	_, status, err := c.do("GET", path, nil, nil, nil)
 	return status, err
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
index 98425284..585769f7 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
@@ -178,6 +178,30 @@ func (c *Client) GetWithHeaders(path string, params map[string]string, headers m
 	return result, err
 }
 
+// GetNoCache issues a GET that bypasses the cache read for this call only,
+// then refreshes the cache with the fresh response on success. Use for
+// polling-until-terminal patterns where every call must reflect current
+// server state; the same (path, params) pair returning a stale
+// "in-progress" snapshot from cache would lock the poll loop on the
+// initial response. Writing-back on success means subsequent c.Get calls
+// (e.g. a follow-up `... get <id>` after WaitForJob returns) see the
+// terminal value, not the stale non-terminal snapshot left behind by the
+// first poll.
+func (c *Client) GetNoCache(path string, params map[string]string) (json.RawMessage, error) {
+	return c.GetWithHeadersNoCache(path, params, nil)
+}
+
+// GetWithHeadersNoCache is GetWithHeaders that skips the cache read but
+// still writes the fresh response on success. See GetNoCache for when to
+// prefer this over Get/GetWithHeaders.
+func (c *Client) GetWithHeadersNoCache(path string, params map[string]string, headers map[string]string) (json.RawMessage, error) {
+	result, _, err := c.do("GET", path, params, nil, headers)
+	if err == nil && !c.NoCache && !c.DryRun && c.cacheDir != "" {
+		c.writeCache(path, params, result)
+	}
+	return result, err
+}
+
 func (c *Client) ProbeGet(path string) (int, error) {
 	_, status, err := c.do("GET", path, nil, nil, nil)
 	return status, err
diff --git a/testdata/golden/fixtures/async-job-api.yaml b/testdata/golden/fixtures/async-job-api.yaml
new file mode 100644
index 00000000..cfb79190
--- /dev/null
+++ b/testdata/golden/fixtures/async-job-api.yaml
@@ -0,0 +1,56 @@
+openapi: "3.0.3"
+info:
+  title: Async Job API
+  version: "1.0.0"
+  description: |
+    Golden fixture exercising async-job detection: POST returns a job_id-shaped
+    response with a sibling GET status endpoint, triggering jobs.go emission.
+servers:
+  - url: https://api.example.com/v1
+security:
+  - ApiKeyAuth: []
+components:
+  securitySchemes:
+    ApiKeyAuth:
+      type: apiKey
+      in: header
+      name: X-API-Key
+  schemas:
+    RenderJob:
+      type: object
+      properties:
+        job_id:
+          type: string
+        status:
+          type: string
+paths:
+  /renders:
+    post:
+      tags: [renders]
+      operationId: submitRender
+      summary: Submit a render job
+      responses:
+        "202":
+          description: Accepted
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/RenderJob"
+  /renders/{id}:
+    get:
+      tags: [renders]
+      operationId: getRender
+      summary: Get a render job
+      parameters:
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: OK
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/RenderJob"

← 95f03501 fix(cli): derive doctor auth.verify_path from me-shaped endp  ·  back to Cli Printing Press  ·  fix(cli): archive merged spec for multi-spec generate runs ( 44f1ff79 →