# 常驻监听 P0+P1 修复批验证摘要(本地实现与测试,未 push、未开 PR、未合并、未部署) - 起点:`feat-resident-p0-fixes`,从 `origin/main 60997b1`(PR #275 合并头)起; 本批只改本 worktree,绝不动生产审核 worktree、闭环 worktree、主 checkout。 - 审核依据:生产审核报告 `~/workspace/codex-app-server-resident-prod-audit/verification/resident-prod-audit-summary.md` 第 4、5、7 节(动手前已重读);范围只限该报告的 P0 与 P1。 - 不做事项:控制台 UI、选择器/区域提取、其它探针 (server_status/agent_status/relay_cost)、磁盘清理、真实外网抓取 (真实抓取只用本地测试 HTTP 服务验证)、生产 PG、生产双 worker、 真实外网只读演练(仍是需用户拍板的后续窗口,见 blocker)。 - 红线:以下所有数值凡标“拟定默认值”者,均为本批实现工拟定的 默认值、供最终验收改,不是用户已拍板;摘要、代码注释与后续 PR 描述素材里一律按此口径写,不得写成用户已定。 ## 1. 拟定默认值清单(逐项标拟定,环境变量可覆盖) | 口径 | 拟定默认值 | 环境变量 | 说明 | | --- | --- | --- | --- | | 每监听每日变化任务上限 | 24 个 | `CODEX_RESIDENT_MAX_DAILY_CHANGE_TASKS` | 按 UTC 日切分;只数 `status='changed'` 且 `task_id` 非空的轮询行;超限记 `changed_capped`,游标不推进 | | 变化抖动冷却 | 300 秒 | `CODEX_RESIDENT_CHANGE_COOLDOWN_SECONDS` | 上次建任务 300 秒内的新变化记 `changed_cooldown`,游标不推进,冷却过后复查仍可报 | | resident_polls 保留期 | 90 天 | `CODEX_RESIDENT_POLL_RETENTION_DAYS` | 批量驱动每批开头 prune 一次;过期删、未过期保留;当日上限计数不受影响(保留期远大于 1 天) | | 批量总预算 | 120 秒 | `CODEX_RESIDENT_BATCH_BUDGET_SECONDS` | 单批最坏 = 预算 + 单条超时上限 60 秒;超预算的剩余监听保持 due,下批再跑 | | 代理模式 | 直连(默认) | `CODEX_RESIDENT_PROXY` | 未设/空/`direct` = 直连,不跟随进程代理;`env` = 显式跟随进程环境代理;其它值 = 显式代理 URL;两种代理模式都认进程 `no_proxy`/`NO_PROXY` | | 重定向上限 | 10 跳 | (固定值) | 超过抛 `redirect` 结构化失败 | 其它沿用旧值不变:间隔下限 60 秒、抓取超时 1–60 秒(默认 10 秒)、 1MB 截断、变化摘录 2000 字、poll detail 500 字、批量单批上限 50 条。 ## 2. P0 修复与测试证据 ### S1. DNS 级 SSRF + 摘录口径(cloud/resident.py) - 抓取前解析全部 A/AAAA(`resolve_public_addresses`):任一地址命中 私有/回环/链路本地/保留/组播/未指定即整批拒绝;IPv6 的 IPv4 映射 地址(`::ffff:127.0.0.1`)按映射后 IPv4 再判;解析失败/空结果/ 非 IP 结果按 `dns` 失败,不放行。 - 直连建连 pin 住解析结果(`_PinnedHTTPConnection` / `_PinnedHTTPSConnection`):TCP 对端就是解析时刻的 IP,不再二次 按主机名解析;https 的 SNI 与证书主机名仍是原始主机名(单测断言 `wrap_socket(server_hostname='example.com')` 且建连地址为 pin 的 IP), TLS 校验不被 pin 破坏。 - 重定向每跳重复字面校验 + 解析门 + pin(相对跳转先 urljoin); 非 200 一律抛 `HttpStatusFetchError`,错误页正文直接丢弃。 - 摘录口径(拟定):摘录只来自已通过校验的公开抓取结果;私网 抓取在抓取层直接失败(`ssrf_blocked`)、不产生摘录、不建任务、 轮询 detail 只含 `reason=ssrf_blocked; …` 异常文本,绝不含抓取正文。 创建监听时仍只做字面校验(DNS 会变,创建时放行不等于抓取时放行), 权威关口在抓取层——见代码注释。 - 残留风险(诚实写明,不假装已封口): - 代理模式下客户端解析门只是前置检查,真正建连的是代理方、由 代理方二次解析;pin 在代理模式下不生效,须靠代理自身的 egress allowlist 兜底。生产部署若用代理模式,必须同时给代理配内网拦截。 - 直连 pin 已把解析-建连 TOCTOU 关到“解析结果本身”这一层,但 “解析时刻的 IP 在之后被重新分配给内网用途”的极端情形不在 本次封口承诺内。 - 旧 urllib 默认跟随进程代理的行为已改:默认直连。部署时若宿主 出口必须走代理,须显式配 `CODEX_RESIDENT_PROXY=env` 或代理 URL, 否则公网抓取会直连失败(记失败轮询,不建任务)。 - 测试:`test_resolve_public_rejects_private_and_mixed`(私网/混合/ 映射地址/解析失败)、`test_ssrf_blocked_poll_has_no_excerpt_and_no_task`、 `test_pinned_https_uses_hostname_for_sni_not_ip`、 旧重定向测试改写(默认解析器 + getaddrinfo 补丁必拦;注入 resolver 返回公网测试 IP + socket 层重定向到本地服务时相对跳转走通)。 ### S2. 驱动与延迟:选批量总预算,不新增独立驱动进程 - 论证:独立驱动进程的运维成本(新 unit、独立 DB 连接、启停监管) 是审核第 7 节留给用户拍板的项,本批不擅自新增;批量总预算是纯 代码改动、本地可测,且 claim 门已保证未来加驱动时多驱动不重复 轮询。预算把旧最坏 `50×60=3000` 秒 bound 到“预算 120 秒 + 单条 超时上限 60 秒”,剩余到期监听保持 due、下批再跑,不丢。 - 间隔只是下限、不是 SLA:`resident_metrics` 的 `due_count` / `max_overdue_seconds` 直接度量“到期未跑”积压,进 `/metrics`; 任何对外口径仍须写“尽力而为、延迟无界”(模型任务一轮最长 1800 秒仍会拖后轮询,预算只 bound 单批,不承诺送达时限)。 - 测试:`test_batch_budget_stops_batch_and_keeps_rest_due` (单调时钟注入:50 秒/条、预算 100 秒时跑 2 条即停,剩余 3 条仍 due)。 ### G2. 成本上限与保留期 - 每日上限、抖动冷却、保留期见第 1 节清单;三者均持久化 (上限数当日轮询行、冷却用既有 `last_change_at`、保留期靠 `prune_old_polls` 的 DELETE),重启不丢计数。 - 页面每轮都变时:冷却期内只记 `changed_cooldown`、上限满后只记 `changed_capped`,游标不推进(变化不丢,冷却过后/次日仍可报), 任务数有界。 - 测试:`test_daily_cap_and_cooldown_suppress_without_losing_change` (上限 2、冷却 300 秒的加速版:建任务→冷却抑制→冷却后建任务→ 上限抑制,游标始终指向最后一次真实建任务的内容)、 `test_prune_old_polls_keeps_recent`(100 天前行删、当前行保留)。 ## 3. P1 修复与测试证据 ### S3. resident 表进 PG schema 与迁移清单 - `cloud/db.py` 的 `POSTGRES_SCHEMA` 新增 `resident_watches` / `resident_polls`(含 `failure_reason` 列)与三条索引(含部分唯一索引 `WHERE status != 'ended'`);`cloud/migrations.py` 的 `MIGRATION_TABLES` 同步新增两表(TEXT 主键,无身份列,无需序列拨动)。 - 测试:`test_migration_coverage_includes_resident_tables` (`migration_coverage()["missing_from_migration"] == ()`); 本地 PG 实测见第 5 节。 ### G1. ensure 移出热路径 + 批量逐条隔离 + 死锁重试一次 - 热路径(`due_watches` / `claim_due_watch` / `run_due` / 批量循环)不再调 `ensure_resident_tables`;建表只在 server `init_db`(SQLite 分支新增 `_res_ensure`,PG 分支走 schema)、 worker 生命周期首次批量、API 函数(create/list/get/update/ transition)做。热路径只在表确实缺失时 ensure 后重试一次。 - 批量入口逐条隔离:锁错误(PG `locked=True`、SQLite `database is locked`)回滚后重试一次,仍失败记入 `errors` 列表并继续下一条;非锁异常同样隔离;预算耗尽信号以 `watch_id=None` 的条目记入 `errors`,供 worker 记日志。 - 测试:`test_batch_isolates_item_errors_and_retries_deadlock` (死锁始终失败时只影响本条、其余照常;第一次死锁第二次成功时 errors 为空)、`test_hot_path_batch_executes_no_ddl` (计数连接断言批量内零 CREATE 语句)。 ### G3. 改目标 URL 强制走一次 baseline - `update_watch` 改 `target_url` 时 `last_content_hash` 置 NULL, 下一轮只建基线、不建任务;改间隔/提示/名字不重置游标, 已排好的 `next_poll_at` 不变(语义保留,见代码注释)。 - 测试:`test_update_target_resets_baseline_but_interval_does_not`。 ### G4. 显式代理配置 + 结构化失败原因 - 代理口径见第 1 节清单与 `resident_config()` 注释;默认直连 与环境变量的关系写在代码与本摘要里,不只靠隐式进程环境。 - 失败原因进 `resident_polls.failure_reason` 列(ensure 与 PG schema 同步补列),detail 前缀同样写 `reason=<原因>`; 原因集:`dns / proxy / timeout / http_status / ssrf_blocked / tls / redirect / network / invalid_url / unknown`。 - 测试:`test_proxy_config_defaults_direct_and_env_opt_in`、 `test_classify_fetch_errors`、`test_http_status_fetch_raises_structured_error` (单跳层真实 404 + default_fetch 状态分支)。 ### G5. resident 指标进 /metrics - `GET /metrics` 新增 `resident` 段(成员项目过滤,与任务计数 同款):`watches_by_status`、`due_count`、`max_overdue_seconds`、 近 24h `polls_last_24h / failed_last_24h / changed_last_24h / change_tasks_last_24h`、当前拟定 `config`;表缺失时 `{"available": False}`,不炸 `/metrics`。 - 测试:`test_resident_metrics_counts`(含到期积压与非成员过滤)、 `test_metrics_contains_resident_section`(HTTP 层)。 ### P2 建议项(顺手修,不改语义) - resident 四个 handler 的 502 统一为固定错误码 `resident_operation_failed`,详情只记服务端日志(`_server_log`), 不再透传可能含表名/列名的异常文本;测试 `test_resident_502_uses_fixed_error_code`(响应不含注入的细节串)。 - `_resident_get` 的 polls 异常记服务端日志(`_server_log.warning`), 响应仍保持 `polls=[] + polls_note="polls unavailable"` 的兼容形态; 测试 `test_resident_get_polls_failure_keeps_note`。 - 未做(列为后续,不硬修):改间隔立即生效的语义(仍为“暂停后 恢复才立即复查”,已写进注释与本摘要);选择器/区域提取; 控制台 UI。 ## 4. 测试结果 - 定向(最后一次纯文案修订后重跑,见时间关系说明): `python3 -m unittest cloud.tests.test_resident cloud.tests.test_resident_server cloud.tests.test_resident_website_loop cloud.tests.test_resident_p0_fixes` —— 38 项全过(旧 20 项 + 新 18 项)。 - ruff E,F(被碰文件):与改动前基线一致的 12 项,全是旧代码 (含 `cloud/worker.py` 的 F821、`cloud/server.py` 旧中文注释超长行等), 本批新增 0 项(新增的 3 项在自查中已修掉)。 - CI 同款三条 discover 全量(均在本批代码定稿后跑出): - `python3 -m unittest discover -s cloud/tests`:1569 项,OK(跳过 9 项),393 秒; - `python3 -m unittest discover -s deploy/tests`:44 项,OK,12 秒 (尾部三行中文传输日志为某测试退出时打印,不影响结果); - `python3 -m unittest discover -s tests`:105 项,OK,12 秒。 - 时间关系:定向 38 项先于全量跑出;全量三条跑完后,本摘要 填入全量数字(纯文案修订,不碰代码与测试),随后按任务要求 重跑定向——重跑结果仍为 38 项全过,与全量结论一致;摘要定稿 在定向重跑之后,不再有代码或测试改动。 ## 5. 本地 PG 16 验证单独记录(一次性库,测完已删库删脚本) - 方式:`initdb --auth=trust` 一次性库(Unix socket,psycopg2 适配层),脚本放 `/tmp`(不进 worktree),测完停库、删库、 删脚本;结论只代表本地 PG,不代表生产 PG。 - 结果(全部通过): - `init_postgres_schema` 后 `resident_watches` / `resident_polls` 齐全; - 索引在位:`idx_resident_watches_project`、 `idx_resident_watches_name`(部分唯一, `WHERE (status <> 'ended'::text)`)、`idx_resident_polls_watch`; - 二次 `init_postgres_schema` + `ensure_resident_tables` 幂等; - `migration_coverage` 无缺失;SQLite→PG 迁移 resident 两表各 1 行; - 基线→变化→queued 任务、已结束名重用全部通过; - 双连接并发:胜者 1 条 baseline 轮询、败者 0 条且 `errors` 为空、 全程无死锁异常、无整批 abort、poll 总数 1(G1 修复后败者干净返回; 旧死锁形态未再出现,但“双连接”不等于“生产双 worker”,见偏差)。 - 已清理:集群目录、socket 目录、日志与 `/tmp` 脚本均已删除, `ls /tmp/resident_pg*` 确认无残留。 ## 6. 已知偏差 - 本地 PG 结论不是生产 PG 结论;本地 HTTP 结论不是真实外网结论; 双连接结论不是生产双 worker 结论;真实外网只读抓取演练仍未做。 - 代理模式下 pin 不生效(见 S1 残留风险),须靠代理 egress allowlist。 - 旧闭环测试第 5 步轮询时刻由 `t0+400` 改为 `t0+540`:新冷却语义下 160 秒间隔的变化会被正确抑制,测试改按冷却期外复查(注释已写明), 不是为通过而删断言。 - 旧重定向测试与闭环测试的本地服务接法改为“resolver 返回公网测试 IP + socket 层重定向”:门与 pin 全程真实跑,只有传输层终点是 测试替身;门本身由默认解析器用例独立覆盖。 - `resident_config()` 每次调用读环境变量:改环境变量即生效, 但已启动进程的既有默认值不会跨进程同步(与其它环境变量配置同款)。 - 无效环境变量数值(非整数/<=0)回落默认值,不炸驱动(配置错误 按默认跑,运维侧须自行核对配置是否生效,见 /metrics 的 config 段)。 ## 7. blocker(需用户拍板,本批不擅自定) - 拟定默认值清单(第 1 节全部):上限 24、冷却 300 秒、保留期 90 天、 批量预算 120 秒、默认直连代理——供最终验收改。 - 是否新增独立驱动进程(本批选批量总预算,见 S2 论证;独立驱动 仍是可选项,需用户定运维成本)。 - SSRF 修复后的摘录口径最终版:本批拟定为“只来自已通过校验的 公开抓取结果”;是否进一步收紧为只留 hash/长度/脱敏摘录,仍由 用户定。 - 生产 PG 双 worker 实测窗口、真实外网只读抓取演练窗口、 代理模式下的代理 egress allowlist 配置(若生产用代理模式)。 - 控制台 UI 是否随修复批上(属产品范围,不计入本轮)。 ## 8. 未做事项(本批明确不做) - 未 push、未开 PR、未合并、未部署;工作区未提交(由 parent 定)。 - 未碰生产审核 worktree、闭环 worktree、主 checkout(feat-repo-picker)。 - 未做控制台 UI、选择器/区域提取、其它探针、磁盘清理、真实外网抓取。 - 未改生产默认值以外的任何行为口径;旧测试除上述两处时序/接法 调整外无删断言、无加 skip。