Skip to content

Commit ffc9f15

Browse files
author
SqlRush
committed
Retry daemon remote polls
1 parent 20929a2 commit ffc9f15

4 files changed

Lines changed: 67 additions & 10 deletions

File tree

cmd/claude/main.go

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,11 +1240,7 @@ func fetchDaemonRemoteEvents(ctx context.Context, registration remotepkg.Registr
12401240
if ws.Error == "" || pollURL == "" {
12411241
return fetch
12421242
}
1243-
poll := remotepkg.FetchPollEvents(ctx, remotepkg.PollOptions{
1244-
PollURL: pollURL,
1245-
Cursor: cursor,
1246-
AuthToken: authToken,
1247-
})
1243+
poll := remotepkg.FetchPollEvents(ctx, daemonRemotePollOptions(pollURL, cursor, authToken))
12481244
return daemonRemoteFetch{
12491245
Transport: "poll",
12501246
StatusCode: poll.StatusCode,
@@ -1255,11 +1251,7 @@ func fetchDaemonRemoteEvents(ctx context.Context, registration remotepkg.Registr
12551251
FallbackError: ws.Error,
12561252
}
12571253
}
1258-
poll := remotepkg.FetchPollEvents(ctx, remotepkg.PollOptions{
1259-
PollURL: pollURL,
1260-
Cursor: cursor,
1261-
AuthToken: authToken,
1262-
})
1254+
poll := remotepkg.FetchPollEvents(ctx, daemonRemotePollOptions(pollURL, cursor, authToken))
12631255
return daemonRemoteFetch{
12641256
Transport: "poll",
12651257
StatusCode: poll.StatusCode,
@@ -1270,6 +1262,17 @@ func fetchDaemonRemoteEvents(ctx context.Context, registration remotepkg.Registr
12701262
}
12711263
}
12721264

1265+
func daemonRemotePollOptions(pollURL string, cursor string, authToken string) remotepkg.PollOptions {
1266+
return remotepkg.PollOptions{
1267+
PollURL: pollURL,
1268+
Cursor: cursor,
1269+
AuthToken: authToken,
1270+
RetryAttempts: 1,
1271+
RetryInitialDelay: 100 * time.Millisecond,
1272+
RetryMaxDelay: 500 * time.Millisecond,
1273+
}
1274+
}
1275+
12731276
type daemonRemoteDeliveryResult struct {
12741277
Delivered int
12751278
Duplicates int

cmd/claude/main_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,57 @@ func TestRunDaemonRemotePollInjectsRemoteTriggers(t *testing.T) {
404404
}
405405
}
406406

407+
func TestRunDaemonRemotePollRetriesTransientPoll(t *testing.T) {
408+
dir := t.TempDir()
409+
transcriptPath := filepath.Join(dir, "session.jsonl")
410+
sessionID := contracts.ID("sess_daemon_remote_poll_retry")
411+
calls := 0
412+
var auths []string
413+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
414+
calls++
415+
auths = append(auths, r.Header.Get("Authorization"))
416+
if calls == 1 {
417+
w.Header().Set("Retry-After", "0")
418+
http.Error(w, "try again", http.StatusServiceUnavailable)
419+
return
420+
}
421+
w.Header().Set("content-type", "application/json")
422+
_, _ = w.Write([]byte(`{"next_cursor":"cursor-retry","events":[]}`))
423+
}))
424+
defer server.Close()
425+
if err := remotepkg.WriteRegistrationState(remotepkg.SessionRegistrationPath(transcriptPath, sessionID), remotepkg.RegistrationState{
426+
SessionID: sessionID,
427+
RuntimeState: remotepkg.RegistrationRegistered,
428+
PollURL: server.URL + "/poll",
429+
RemoteSessionID: "remote-session",
430+
}); err != nil {
431+
t.Fatal(err)
432+
}
433+
runner := conversation.Runner{
434+
SessionID: sessionID,
435+
SessionPath: transcriptPath,
436+
WorkingDirectory: dir,
437+
MCP: &conversation.MCPConfig{UserSettings: contracts.Settings{
438+
Remote: &contracts.RemoteSetting{AuthToken: "poll-token"},
439+
}},
440+
}
441+
442+
result := runDaemonRemotePoll(context.Background(), runner, time.Unix(200, 0).UTC())
443+
if result.StructuredContent["runtime_state"] != remotepkg.PumpRunning || result.StructuredContent["status_code"] != http.StatusOK || result.StructuredContent["attempt_count"] != 2 || result.StructuredContent["last_cursor"] != "cursor-retry" || result.StructuredContent["event_count"] != 0 || result.StructuredContent["error_count"] != 0 {
444+
t.Fatalf("retry poll = %#v", result.StructuredContent)
445+
}
446+
if calls != 2 || len(auths) != 2 || auths[0] != "Bearer poll-token" || auths[1] != "Bearer poll-token" {
447+
t.Fatalf("calls/auths = %d %#v", calls, auths)
448+
}
449+
pump, err := remotepkg.LoadPumpState(remotepkg.SessionPumpPath(transcriptPath, sessionID))
450+
if err != nil {
451+
t.Fatal(err)
452+
}
453+
if pump.StatusCode != http.StatusOK || pump.AttemptCount != 2 || pump.LastCursor != "cursor-retry" || pump.ErrorCount != 0 {
454+
t.Fatalf("pump = %#v", pump)
455+
}
456+
}
457+
407458
func TestRunDaemonRemotePollSkipsExpiredLease(t *testing.T) {
408459
dir := t.TempDir()
409460
transcriptPath := filepath.Join(dir, "session.jsonl")

docs/cc-100-roadmap.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ M10 补充:remote ack 和 lease renew 的 transient retry 现在会优先遵
135135

136136
M10 补充:remote poll fetch 现在也支持 transient retry,遇到 transport error、408、429 或 5xx 时可按 PollOptions 重试,并同样优先遵守服务端 `Retry-After` header;PollResult 暴露 `attempt_count` 便于 daemon/pump 审计。
137137

138+
M10 补充:daemon remote poll 与 WebSocket fallback poll 现在实际启用 transient retry(一次短退避,优先遵守 `Retry-After`),并把 poll `attempt_count` 写入 structured result、`remote-pump.json``/status show remote`
139+
138140
M10 补充:remote pump state 现在持久化 `attempt_count``/status show remote` 会显示 `attempts N`,让 poll/WebSocket pump 的重试行为能被 CLI 状态页审计。
139141

140142
M10 补充:remote WebSocket upgrade 失败后的重连退避现在会读取服务端 `Retry-After` header(秒数或 HTTP-date),Fetch/Stream 两条 WebSocket 路径都会优先遵守云端限流退避,再回退到本地指数退避。

docs/claude-code-go-rewrite-plan.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,7 @@ test/parity/ # golden tests against TS/official behavior
758758
- 本轮补充:remote poll/WebSocket 事件现在会解析 `ack_url`/`ackUrl`/`acknowledge_url`/`receipt_url``lease_id`/`lease_expires_at``ack`/`lease` nested object 元数据;daemon 会在 delivered/duplicate/failed 后对注册 poll/websocket 同源的 ack URL 做 best-effort POST,带 Bearer auth 和 event/status/sent_count/duplicate/error payload,并对 transport error、408/429/5xx 做一次短退避重试;pump state、structured result 和 `/status show remote` 会记录 ack event/sent/error 与 lease event 计数;非同源 ack URL 会被拒绝且脱敏;已过期 lease 会被跳过投递并 ack `expired`,同时记录 `lease_expired_count`。更深的服务端协议协商仍未完成。
759759
- 本轮补充:remote ack 和 lease renew 的 transient retry 现在会优先遵守服务端 `Retry-After` header(秒数或 HTTP-date),再回退到本地指数退避,并继续受最大退避上限约束,减少云端 429/503 限流时的协议偏差。
760760
- 本轮补充:remote poll fetch 现在也支持 transient retry,遇到 transport error、408、429 或 5xx 时可按 PollOptions 重试,并同样优先遵守服务端 `Retry-After` header;PollResult 暴露 `attempt_count` 便于 daemon/pump 审计。
761+
- 本轮补充:daemon remote poll 与 WebSocket fallback poll 现在实际启用 transient retry(一次短退避,优先遵守 `Retry-After`),并把 poll `attempt_count` 写入 structured result、`remote-pump.json``/status show remote`
761762
- 本轮补充:remote pump state 现在持久化 `attempt_count``/status show remote` 会显示 `attempts N`,让 poll/WebSocket pump 的重试行为能被 CLI 状态页审计。
762763
- 本轮补充:remote WebSocket upgrade 失败后的重连退避现在会读取服务端 `Retry-After` header(秒数或 HTTP-date),Fetch/Stream 两条 WebSocket 路径都会优先遵守云端限流退避,再回退到本地指数退避。
763764
- 本轮补充:remote WebSocket `FetchWebSocketEvents`/`StreamWebSocketEvents` result 现在暴露 `status_code``attempt_count`;成功握手记录 101,upgrade 失败记录服务端 HTTP status,daemon tick/常驻 stream 会把这些字段写入 `remote-pump.json`、structured result 和 `/status show remote` 审计面。

0 commit comments

Comments
 (0)