Dayward AI
Week 1 · D6About 4 hours

Error Handling and Self-Correction: Feeding Failures Back, Backoff Retries, Loop Detection, and Cancellation

Clean up six real failure categories in one pass: tool failure, invalid arguments, gateway rate limiting, command timeout, stream disconnection, and a model spinning in circles. One principle only — feed back any error the model can fix itself, surface to the user only what it can't — and the user can always press cancel.

Today's goals 0/3

Sign in to tick these off and save your progress.

Today's Goals

  1. Split agent runtime errors into feed-back-for-self-correction, retry-with-backoff, and surface-directly, and explain the criteria
  2. Implement backoff retry with jitter, and explain why a tool failure shouldn't retry the whole turn
  3. Implement a cancellation signal that travels all the way from a keypress to the subprocess, and detect a model spinning in circles

Over five days we met several failure classes piecemeal; today we handle them together. When you finish, scroll back up and tick the three goals off.

Plain-Language Walkthrough

What to do after a mistake: handing the error back beats fixing it for them

A new hire will hit a wall on their first attempt: they edit a file and the tests go red, they type a command with a wrong flag.

Their mentor has two options: fix it for them, or paste the error verbatim with one sentence — "look, this line says the indentation does not match." The first is more efficient today, and they still cannot do it next time.

With an Agent it is more extreme: "fix it for it" is not an option at all. You can change files and commands; you cannot change what it wants to do next, which depends only on what it reads in its context. So there is exactly one way to handle an Agent's errors: turn the error into text it will read next turn. That is tool result feedback, in use since day three, and today it becomes a discipline.

The discipline has a counterintuitive corollary: a tool's failure message is not a log for humans but a behavioural specification for the model. The same failure in two wordings produces entirely different behavior:

TextText
version 1: edit_file failed: no match
version 2: that old_string cannot be found in src/calc.js; nothing was changed.
           Commonly the indentation or newlines differ, or the file already changed -
           read_file first and retry.

With the first, the model resends the identical call (assuming a transient blip). With the second, it reads the file first. The difference between those two lines is the difference between an Agent that can get itself out of trouble and one that cannot — and it lives not in the model but in the sentence you wrote. Today extends the discipline to six failure classes, and admits its limits.

Six failure classes and three handlings: feed back, retry, or surface

There are far more than six failures, but grouped by what you do about them they fall into three buckets. The lab injects each of six once, filling all three:

FailureWhat you see at this layerHandlingWhy
Tool execution failedthe tool result's ok is falsefeed backchanging a parameter or a path solves it itself
Arguments are not valid JSONthe joined fragments do not parsefeed backit wrote them badly; show it the text and it rewrites
The model is spinningsame tool, same arguments, three times runningfeed backit cannot see its own repetition; someone must say so
Command timeoutthe child process was killedfeed backthe result must carry "break the command up" for it to change strategy
Gateway rate limitinga 429retrynothing to do with the model; waiting fixes it
Stream cut mid-wayno finish event arrivesit dependsretry if nothing was emitted, surface if characters were

The criterion is worth memorizing: can the model fix this itself? If so, feed it back; if not but a different moment would work, retry; if neither, surface it.

Surfacing is the most expensive tier, because it spends the user's attention. So reserve it for two cases: a broken environment (no key, a gateway that never responds) and a hard limit being hit.

src/kernel/errors.ts
export type Handling = 'feedback' | 'retry' | 'surface'
 
/**
 * emitted is "how many deltas this attempt already emitted," the single most important
 * parameter in the taxonomy: retrying requires that this attempt left no trace the user saw.
 */
export function diagnoseStreamError(error: unknown, emitted: number): Diagnosis {
  if (error instanceof RateLimitError) {
    return { handling: 'retry', message: 'gateway rate limited', retryAfterMs: error.retryAfterMs }
  }
  if (error instanceof StreamTruncatedError) {
    // The same error, two opposite handlings, differing only in whether characters were emitted
    if (emitted === 0) return { handling: 'retry', message: 'the stream died at the start', retryAfterMs: 500 }
    return { handling: 'surface', message: 'the stream was cut mid-way; a retry would say the same passage twice' }
  }
  if (emitted === 0 && looksTransient(error)) {
    return { handling: 'retry', message: 'network blip', retryAfterMs: 500 }
  }
  return { handling: 'surface', message: describeError(error) }
}

Both versions put rate limiting and truncation — both nominally retryable — into different buckets, differing only by the emitted parameter. It is today's most overlooked criterion, and it appears twice more below.

How to word feedback: specific enough to act on, never just "it failed"

Since a failure message is a specification, write it like one. Three requirements; miss one and the model's reaction drifts:

  1. Say whether there was a side effect. "The write failed and the file is unchanged" and "the write failed" are entirely different sentences: the first says a retry is safe, the second makes it afraid to retry, or makes it write twice.
  2. Give the next action, not a root-cause analysis. "That content appears three times" is a cause; "write more context so it becomes unique" is an action.
  3. Do not leak technical detail. Stack traces, errno values and internal paths are noise, and it will try to "fix" them.

INJECT=tool_error is the experiment for this: the first file write fails, the feedback carries "the file is unchanged, retry as is," and the model resends the identical call next turn, succeeds, and the tests go green. Had the feedback said only "write failed," it would go off to read the file, check the directory, try creating it — each step reasonable, each step useless.

Feedback has limits too. The INJECT=bad_args segment is designed to make self-correction fail: the gateway truncates every argument string into invalid JSON, the model tries four different tools, breaks all four, and finally explains to the user. That demonstrates the point — self-correction is the first line, a hard limit is the last, and you need both.

Retry only the retryable: rate limits and network belong to the gateway, tool failures to the model

The first thing to settle is not "how many retries" but which layer the retry wraps. There is one correct answer: wrap the gateway call, never the whole turn.

The reason is short: retrying presumes the step is idempotent, and write tools inherently are not. Retrying a whole turn means a half-edited file gets edited again; and if the first edit actually succeeded and only the response was lost, the second hits "cannot find that original."

So the division is clear: the gateway layer swallows and retries its own errors, and tool-layer failures always go to the model. In code that is a thin wrapper:

src/kernel/retry.ts
export async function* streamWithRetry(
  provider: ChatProvider,
  req: ChatRequest,
  options: RetryOptions = {}
): AsyncIterable<StreamDelta> {
  const maxAttempts = options.maxAttempts ?? 3
 
  for (let attempt = 1; ; attempt += 1) {
    let emitted = 0
    try {
      for await (const delta of provider.stream(req)) {
        emitted += 1
        yield delta
      }
      return
    } catch (error) {
      // The user cancelled: not a failure; rethrow and let the loop wrap up
      if (req.signal?.aborted) throw error
      const diagnosis = diagnoseStreamError(error, emitted)
      if (diagnosis.handling !== 'retry' || attempt >= maxAttempts) throw error
      // If the gateway sent retry-after, use it as a floor: it knows better than our guess
      const waitMs = Math.max(backoffDelay(attempt, options), diagnosis.retryAfterMs ?? 0)
      options.onRetry?.({ attempt, waitMs, message: diagnosis.message })
      await sleep(waitMs, req.signal)
    }
  }
}

emitted appears a second time here, and it is this code's only state. It guards a visible floor: characters already printed must not be said again. The starter deliberately gets this wrong (retrying truncation too), and running INJECT=truncated prints the same sentence three times — that is not recovery, that is corruption.

One more easily missed point: a failure caused by the user cancelling is not a fault and must not be retried. Without that check you meet the maddening behavior of "I pressed cancel and it sent another request."

Backoff needs jitter: why a fixed interval turns rate limiting into an avalanche

The plainest retry interval is "wait one second." That is fine while you debug alone and causes incidents in production.

Suppose your service runs fifty sessions at once and they all hit the rate limit at one moment. If everyone waits one second, then a second later fifty requests fire simultaneously, are refused simultaneously, and wait a second simultaneously — the rate limit is not eased by retries, it is dragged by you into a periodic avalanche.

The fix adds randomness to the wait, called jitter. It does one thing: spread those requests across a window so the rhythm disappears.

src/kernel/retry.ts
/** How long to wait after failure number `attempt` (attempt starts at 1) */
export function backoffDelay(attempt: number, options: BackoffOptions = {}): number {
  const baseMs = options.baseMs ?? 400
  const capMs = options.capMs ?? 8000
  const random = options.random ?? Math.random
  const window = Math.min(capMs, baseMs * 2 ** (attempt - 1))
  // Equal jitter: half fixed, half random. Not "random between 0 and the window",
  // which can draw a few milliseconds and amount to no backoff at all
  return Math.round(window / 2 + random() * (window / 2))
}

Fix the random source at 0.5 and three waits are 300, 600 and 1200 milliseconds; with the source varying between 0 and 1, the second falls between 400 and 800; once it reaches the cap it stays at 8000. Those numbers are asserted individually in the self-test and are reproducible.

Three details that get probed:

  • Why not full jitter (random between 0 and the window)? It can draw a few milliseconds, making that retry effectively unbacked-off; equal jitter guarantees a floor.
  • There must be a cap. Exponential growth reaches minutes in four or five steps, and at that point the user should decide.
  • If the gateway sent retry-after, use it as a floor. That is the other side telling you the truth, more accurate than a guess.

As for each gateway's actual rate-limit thresholds — do not write them into code and do not memorize them. They vary by plan, by model and over time; the reliable approach reads the response headers and falls back to this scheme when there are none.

Shout "hold on a second" and the new hire must stop typing and also stop the build they just started. Do only the first and you see them look up while the machine keeps running.

An Agent's cancellation chain has four links, and missing any one produces "pressing it did nothing":

1. keypressEsc / Ctrl+C 2. AbortControllerabort() 3. fetch's signalactually close the connection 4. child processkill the whole process group loop wraps upkeeping what was received
Mermaid source
mermaidmermaid
flowchart LR
  A["1. keypress<br/>Esc / Ctrl+C"] --> B["2. AbortController<br/>abort()"]
  B --> C["3. fetch's signal<br/>actually close the connection"]
  B --> D["4. child process<br/>kill the whole process group"]
  C --> E["loop wraps up<br/>keeping what was received"]
  D --> E

Links three and four are the easiest to miss. Checking only "has the signal aborted" inside the loop makes the word "interrupted" appear instantly and feels like stopping — while that node --test still runs and that download continues. Cancellation only counts when it reaches the outermost system resource.

Link four is the dirtiest, handled once when writing the command tool on day four: spawn the child in its own process group and kill the whole group (a negative PID), sending the terminate signal first, allowing two seconds to clean up, and force-killing if it has not exited. The reason is that node --test spawns children of its own, so killing only the outer shell orphans the grandchildren, which keep running and keep holding the pipes — so your promise never settles, and that is where "killed but not stopped" comes from.

Link one has a platform detail: key events exist only in a real terminal, and pipes and CI have no keypress event at all. So the lab extracts "which keys count as cancel" into a function so that judgment can be asserted in a pipe too — Esc counts, Ctrl+C counts, a lone c does not, and Alt combinations do not.

The model is spinning: same tool, same arguments, three times running, so interrupt and change strategy

One failure class does not look like a failure: every step succeeds and the whole thing circles in place. It reads a file, gets the content, and next turn reads the same file.

It is not broken; it cannot see its own repetition. Both calls and both results are in the context, but "I already did the same thing" requires comparing across turns, while its way of working is continuing the context — and the smoothest continuation is often what it just said.

So the loop must do this, plainly: key on "tool name plus argument text," and interrupt when consecutive hits reach a threshold.

src/kernel/repeat.ts
export const REPEAT_THRESHOLD = 3
 
export class RepeatDetector {
  private lastKey = ''
  private streak = 0
 
  /** Feed one call, get how many times in a row it has occurred (including this one) */
  push(call: ToolCall): number {
    const key = `${call.name}(${call.args.trim()})`
    // Consecutive only: any other call in between resets the count
    if (key === this.lastKey) this.streak += 1
    else {
      this.lastKey = key
      this.streak = 1
    }
    return this.streak
  }
 
  tripped(call: ToolCall): boolean {
    return this.push(call) >= REPEAT_THRESHOLD
  }
}

Three rules must be read together, or you get false positives:

  • Compare the argument text, not the semantics. Arguments differing by one space count as a different call — at least it is trying something new; real spinning is character-identical repetition.
  • Consecutive only. Any other call in between resets the count: "read file, run tests, read the same file" is a normal verification rhythm.
  • Interrupt by feeding back, not by surfacing. Tell it "you have called this with identical arguments three times and the results are above; take another approach or give the conclusion," and in this lab it changes course that same turn.

The threshold is three rather than two, because two is common — re-reading a file after editing it is a good habit. Also, this detection sits before the approval gate: a spinning call should not disturb the user for a third approval.

The loop's hard limits: turns, duration and spend, all three

Day five's loop had one lonely constant: at most eight turns. It blocks one kind of runaway and not two others, so today it grows into three:

LimitWhat it blocksWhat it alone would miss
Turnsendless feedback that never gets it righta ten-minute test suite in one turn, with plenty of turns left
Durationone slow turn, or the whole thing draggingcontext growing while neither turns nor duration exceed
Spendrunaway input sizespinning forever on a call that returns in seconds

All three are needed, and when one trips it must say which — a user who sees "stopped" without a reason will only retry the same sentence.

Spend here is counted in tokens only, never converted to money: unit prices vary by gateway and model, and writing them into code binds the project to one vendor's price list; what you actually need to limit is input size, and tokens are its unit. To see money, multiply by a unit price configured as an environment variable at the deployment layer.

One implementation decision: make the three limits one accounting object rather than three scattered variables. Day ten's task checklist, day twelve's compaction and day twenty's cost statistics all read the same ledger. And one more: when a limit trips, besides telling the user, leave a record in the message array, or when the user says the next thing the model will not know why the previous turn stopped.

Source Reading

Hands-On Lab

🧪 D6 lab: a stable loop that handles six failure classes, can be cancelled at any time, and never spins forever

Code location: labs/my-coding-agent-21days/day-06-error-recovery

Today leaves five exercises, all of the "wrong code raises no error and surfaces on the bad day" kind: retrying truncation too, backoff without jitter, checking only the turn limit, loop detection not wired into the loop, and cancel recognizing only Ctrl+C. The starter passes five of eleven unmodified.

One thing first: INJECT is read once at module load, so the self-test items that switch injections all spawn a child process rather than mutating the environment in one process — the latter verifies the injection you imagine rather than the one in effect.

  1. Implement the classification function mapping the six injected failures to feed back, retry and surface; first run INJECT=truncated to see the starter's "same sentence printed three times," then fix it.
  2. Add jittered backoff retry to the gateway layer for rate limits and network errors only; with INJECT=rate_limit the terminal should print a line saying it will wait some milliseconds before attempt 2, and the task then completes.
  3. Wire cancellation from the keypress to the child process: type "run a long command, do not stop" and approve, then press Esc, and the fifteen-second command should stop within a second.
  4. Implement repeat detection and the three hard limits; with INJECT=loop the third identical call is blocked and the model then gives its conclusion.
  5. Run the self-test: MOCK=1 SELFTEST=1 pnpm start should print 11/11 passed. Delta counts, retry counts, blocked-call counts and backoff window bounds are all reproducible; only the actual milliseconds in the cancellation item vary, and there you read magnitude rather than value.

Acceptance is six ticks: the self-test prints 11/11 passed; a rate limit backs off, retries successfully and the task completes; truncation is not retried and everything received is kept; the third identical call is blocked with a "change approach" feedback; a hung command is killed on timeout with a next action in the feedback; and pressing Esc really stops a long-running command.

Interview Questions

Today's three questions test implementation judgment in error handling, not slogans like "retries need exponential backoff":

  1. How do you classify an Agent's runtime errors? Which are fed back to the model and which are reported to the user?
  2. How do you implement retry and backoff? Why add jitter? Which errors must never be retried?
  3. When a user presses cancel, how many layers must the cancellation signal cross before it really stops?

Full bilingual prompts, analyses and key points are in this course's day-six question bank. Question three exposes experience best — someone who has not implemented it stops at "set a flag," while someone who has brings up child processes and process groups unprompted.

Checklist and Tomorrow

  • I can place the six failure classes into feed back, retry and surface, and state the criterion
  • I know why rate limiting and truncation are both nominally retryable yet handled oppositely
  • I can explain why a retry may wrap only the gateway layer and not the whole turn
  • I can explain why a fixed interval turns rate limiting into a periodic avalanche, and why equal jitter beats full jitter
  • I can name the cancellation chain's four links and the symptom of missing links three and four
  • I know repeat detection's three rules: compare argument text, consecutive only, interrupt by feeding back
  • I can say what each of the three hard limits blocks, and why spend is counted in tokens only

Tomorrow is D7, "Session Persistence and Recovery: an Append-Only Event Log, resume and forking, plus a week-one review." Today it can get up after falling, but the moment the process exits the whole conversation is gone — including the facts it worked hard to establish. Tomorrow puts the conversation on disk with an append-only event log so it can restart and continue, and fork a new session from any point in the middle. The order is deliberate: clean up within-turn failures before discussing cross-process recovery, or "this turn errored" and "last time did not finish" end up tangled in the same code. Week one's seven layers stack up tomorrow, and tomorrow ends with a review.

Interview questions

  • How do you classify errors at agent runtime? Which ones go back to the model to self-correct, and which surface to the user?Agent 运行时的错误怎么分类?哪些该回灌给模型让它自纠,哪些该直接报给用户?
    Common in ChinaCommon overseasBasic#error-handling#agent-loop

    How to reason about it · think before answering

    1. This checks whether you have an actionable rule. Splitting errors into network, business, and system sounds tidy but does not help you write code, because it says nothing about handling.
    2. How to break it down: classify by handling, not by origin. The one-line rule is: can the model fix this itself? If yes, feed it back. If not, but a later attempt would succeed, retry. Only if neither holds, surface it to the user.
    3. Then place the common failures. Feedback is the biggest bucket: tool failures, arguments that are not valid JSON, command timeouts, and the model looping — all fixable in its next turn. Retry covers only rate limits and transient network errors. Surfacing is reserved for a broken environment (no key, gateway persistently down) and for hard limits firing.
    4. One detail that shows depth: the same error can land in different buckets depending on when it happened. If a stream breaks before any chunk was emitted, retrying is safe; if half a sentence is already on the user's screen, retrying prints it twice, so you must surface instead. That is why the classifier takes a count of chunks already emitted.
    5. Close with an implementation discipline: the feedback text is the model's spec for its next move, so it must state whether there were side effects and what to do next. Write failed, file unchanged, retry as-is versus write failed sends the model down two completely different paths.
    6. Likely follow-up: can feedback loop forever? Yes, so self-correction is the first line and hard limits are the last. In one injected run where arguments were always malformed, the model tried four different tools, failed all four, and the round limit ended it. Self-correction without a limit hands your control flow to luck.

    分析过程 · 先想清楚再作答

    1. 这题在看你有没有一条可执行的判据。按「网络错误 / 业务错误 / 系统错误」分类的答案听着整齐,但对写代码毫无帮助——因为它没告诉你每一类该怎么处理。
    2. 怎么拆:分类要按**处理方式**来分,不按错误来源来分。一句话的判据是:这个错误模型自己改得动吗?改得动就回灌;改不动但换个时机能好就重试;两条都不成立才向用户抬头。
    3. 然后把常见故障套进去。回灌那一档最大:工具执行失败、参数不是合法 JSON、命令超时、模型在打转,全是模型下一轮能改的。重试那一档只有网关限流与网络抖动。抬头那一档只留给环境坏了(没有密钥、网关一直不通)与硬上限触发。
    4. 有一个能显出深度的细节:**同一个错误可以落在不同的档里,取决于它发生的时机。** 流在中途断开这件事,如果一个分片都还没吐出来,重试是安全的;如果已经吐了半段话到屏幕上,重试会让用户看到同一段话说两遍——那时候必须抬头。所以分类函数的参数里要带上「这次尝试已经吐出去几个分片」。
    5. 结论还要带上一条实现纪律:回灌的文本就是模型下一步的行为规范,所以它必须写清有没有副作用、给出下一步动作。「写入失败,文件没有任何改动,请原样重试」和「写入失败」会让模型走两条完全不同的路。
    6. 可预期的追问:那回灌会不会永远转不出来?会,所以自纠是第一道、硬上限是最后一道。我实测过一段「参数一直发不对」的注入:模型连换四个工具、四次都失败,最后是靠轮数上限收场的。只有自纠没有上限,等于把无限循环交给运气。

    Key points

    • Three buckets by handling: feed back for self-correction, retry with backoff, or surface to the user
    • One rule: feed back what the model can fix, retry what a later attempt fixes, surface the rest
    • Tool failures, bad arguments, command timeouts, and looping all go back to the model; rate limits and transient network errors are retried
    • The same error splits by timing: a stream that breaks before any output is retryable, after output it must surface
    • Feedback text must state side effects and the next action; self-correction is the first line, hard limits the last

    答题要点

    • 按处理方式分三类:回灌自纠、退避重试、向用户抬头
    • 判据一句话:模型自己改得动就回灌,换个时机能好就重试,都不成立才抬头
    • 工具失败、参数非法、命令超时、模型打转都属于回灌;限流与网络抖动属于重试
    • 同一个错误按时机分档:断流在吐字前可重试,吐字后必须抬头
    • 回灌文本要写清有没有副作用与下一步动作;自纠是第一道,硬上限是最后一道
  • How would you implement retries and backoff? Why add jitter, and which errors must never be retried?重试与退避你会怎么实现?为什么要加抖动?哪些错误绝对不该重试?
    Common in ChinaCommon overseasIntermediate#retry#backoff

    How to reason about it · think before answering

    1. The signal is not the phrase exponential backoff, which everyone says. It is two things: which layer the retry wraps, and whether you can name the errors that must never be retried.
    2. Answer the layering first, the half most people get wrong: retries wrap the gateway call, not the whole turn. Retrying requires idempotence, and write tools are not idempotent. Retrying a turn means a half-edited file gets edited again, and if the first edit actually succeeded and only the response was lost, the second attempt fails with no such text. So tool failures always go back to the model and only gateway errors are retried.
    3. Then jitter, with the arithmetic out loud: if fifty sessions hit a rate limit at once and everyone waits one second, a second later fifty requests arrive together, are rejected together, and wait together. The limit is not relieved, it is turned into a periodic stampede. Jitter does one thing: it spreads those requests across a window so the rhythm disappears.
    4. The jitter shape matters too: do not sample uniformly from zero to the window, since a few milliseconds is effectively no backoff. Half fixed plus half random keeps a floor while breaking the rhythm. Cap the window, because four or five doublings reach minutes and the user should decide by then, and treat a retry-after header as a floor since it beats your guess.
    5. Then the three never-retry cases, which is what marks real experience: failures where a write already had an effect, because retrying writes twice; failures caused by user cancellation, which is not a fault at all and shows up as the agent firing another request after you cancelled; and a stream that breaks after output was already shown, because retrying repeats the same sentence. Auth and malformed-argument 4xx responses are equally pointless to retry.
    6. Likely follow-up: how many attempts? The count matters less than a cap on total wait and whether the user can interrupt mid-wait. In my implementation the backoff sleep also listens to the cancel signal, otherwise pressing cancel still waits out a full backoff.

    分析过程 · 先想清楚再作答

    1. 这题的区分度不在「指数退避」四个字——那个人人都会说。区分度在两个地方:重试包在哪一层,以及你能不能说出不该重试的那几类。
    2. 先答层次,这是最容易答错的一半:**重试包在网关调用这一层,不包整轮。** 理由是重试的前提是这一步幂等,而写工具天生不幂等。重试整轮意味着「已经改了一半的文件」会被再改一遍;如果第一次的编辑其实成功了、只是响应丢了,第二次会撞上「找不到那段原文」。所以工具失败一律走回灌,只有网关错误走重试。
    3. 再答抖动,要能算给面试官听:假设五十个会话同时撞上限流,大家都「等一秒再试」,一秒后五十个请求同时打上去、同时被拒、再同时等一秒——限流没被缓解,而是被重试拖成了一场周期性雪崩。抖动做的事只有一件:把这些请求摊到一个时间窗里,让节拍消失。
    4. 抖动的写法也有取舍:不要用「0 到窗口之间随机」,那有可能抽到几毫秒,等于没退避;用「窗口的一半确定、一半随机」既有下限又打散了节拍。另外窗口要有上限,指数涨四五次就到分钟级,那时候该让用户自己决定;网关回了 retry-after 就把它当下限,它比你的猜测准。
    5. 然后是绝对不该重试的三类,答出来才算做过:① 写操作已经产生了副作用的失败,重试会写重;② 用户按下取消导致的失败——它不是故障,重试的现象是「按了取消它却又发了一次请求」;③ 已经有内容吐到屏幕上之后的断流,重试会让同一段话说两遍。另外 4xx 里的鉴权与参数错误重试一百次也是同样的结果。
    6. 可预期的追问:重试几次合适?次数不是重点,重点是「总等待时间的上限」与「用户能不能中途打断」。我的实现里退避的等待也接了取消信号,否则按下取消还要干等一次退避。

    Key points

    • Retries wrap only the gateway call, never the whole turn, since write tools are not idempotent
    • Jitter exists to break the rhythm so rate-limited clients do not stampede in lockstep
    • Prefer equal jitter over full jitter to keep a floor, cap the window, and treat retry-after as a lower bound
    • Never retry: writes that already had an effect, failures caused by cancellation, or a break after output was shown
    • The metrics that matter are total wait cap and interruptibility, so the backoff sleep must be cancellable too

    答题要点

    • 重试只包网关调用这一层,不包整轮:写工具不幂等,重试整轮会重复副作用
    • 抖动的作用是打散节拍,避免同时被限流的一批请求变成周期性雪崩
    • 用等量抖动而不是全抖动(保住下限),窗口要有上限,retry-after 当下限
    • 绝对不重试:已产生副作用的写、用户取消引发的失败、已有输出之后的断流
    • 关键指标是总等待时间上限与可中断性,退避的等待本身也要能被取消
  • When the user presses cancel, which layers must the cancellation signal reach before things have really stopped?用户按下取消,你的取消信号要穿过哪几层才算真的停下来?
    Common in ChinaCommon overseasDeep dive#cancellation#subprocess

    How to reason about it · think before answering

    1. This is almost a binary test of hands-on experience. People who have not built it stop at set a flag and check it in the loop; people who have go straight to child processes.
    2. How to break it down: translate stopping into which resources are still held. A turn holds three: an in-flight streaming HTTP connection, a running child process, and the loop itself. Cancellation must reach the first two; the loop only cleans up.
    3. So the chain has four links: the keypress event from the terminal, an AbortController broadcasting in-process, the signal handed to fetch so the connection actually closes, and the same signal handed to the command runner so it kills the whole process group. Missing any link looks like cancel did nothing, but differently: without the third the traffic keeps flowing, without the fourth the command keeps running.
    4. The fourth link is the messy one and worth volunteering: spawn the child in its own process group and kill the group (negative PID), send a terminate signal first, allow a couple of seconds to wind down, then force kill. The command being run spawns its own children, so killing only the outer shell leaves orphans running while they still hold the pipes, which means your promise never settles. That is what cancelled but not stopped actually is.
    5. Three more details people miss: cancellation is scoped to one turn, not the session, so each turn gets a fresh controller; a failure caused by cancellation is not a fault and must not trigger a retry; and content already received must be kept in history, because what the user has seen cannot vanish.
    6. Likely follow-up: what about non-interactive environments? Pipes and CI have no keypress events, so the first link does not exist and cancellation can only come from a signal or the program itself. That is why which keys count as cancel belongs in a small pure function you can unit test, while the other three links are verified by triggering the controller directly.

    分析过程 · 先想清楚再作答

    1. 这题几乎是一道「做过没做过」的判别题。没做过的人答到「设一个标志位,循环里检查它」就停了;做过的人会立刻说到子进程。
    2. 怎么拆:把「停下来」翻译成「哪些资源还在占着」。一次 Agent 的轮次里占着资源的有三处——一个正在流的 HTTP 连接、一个正在跑的子进程、还有循环自己。取消要落到前两处上,最后一处只是收尾。
    3. 于是链路是四环:① 按键,终端的 keypress 事件;② 一个 AbortController,进程内广播;③ 把 signal 交给 fetch,连接才会真的断开;④ 把同一个 signal 交给子进程的执行器,杀掉整个进程组。缺任何一环的现象都是「按了没用」,但表现不同:缺 ③ 是流量还在跑,缺 ④ 是命令还在跑。
    4. 第四环最脏,值得主动展开:子进程要用独立进程组启动,杀的时候杀整个组(负号 PID),先发终止信号、留两秒收尾、到点还没退就强杀。原因是被调的命令自己还会拉起子进程,只杀最外层的 shell,孙子进程会变成孤儿继续跑,而且还持着管道——于是你的 Promise 永远不会完成,这就是「杀了却没停」。
    5. 还有三个容易漏的细节:取消的粒度是「这一轮」而不是整个会话,所以每轮一个新的控制器;取消导致的失败不是故障,不许触发重试;已经收到的内容要保留下来落进历史,用户看过的东西不能凭空消失。
    6. 可预期的追问:无交互环境怎么办?管道与 CI 里没有 keypress 事件,第一环不存在,取消只能来自信号或程序自己。所以「哪些键算取消」要抽成一个可单测的纯函数,后面三环则用直接触发控制器的方式来验。

    Key points

    • Translate stopping into which resources remain held: the streaming connection, the child process, and the loop
    • Four links: keypress, AbortController, the signal passed to fetch, and the signal passed to the command runner which kills the whole process group
    • Kill children via their own process group and a negative PID, terminate then force kill, or orphans holding the pipes make the call never settle
    • Cancellation is scoped to one turn, not the session, so use a fresh controller per turn, and never retry a cancellation-induced failure
    • Keep whatever was already received; non-interactive environments have no keypress link, so make the key check a testable pure function

    答题要点

    • 把「停下来」翻译成「哪些资源还占着」:流式连接、子进程、循环本身
    • 四环:按键 → AbortController → 传给 fetch 的 signal → 传给子进程执行器并杀整个进程组
    • 杀子进程要用独立进程组加负号 PID,先终止后强杀,否则孙子进程持着管道让调用永不返回
    • 取消的粒度是一轮而不是会话,每轮一个新控制器;取消引发的失败不许重试
    • 已收到的内容要保留;无交互环境没有按键这一环,把按键判定抽成纯函数来单测

Comments