Compare commits
11
Commits
a1a50f1e81
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37ba6876bf | ||
|
|
2fcde375a4 | ||
|
|
698398dafb | ||
|
|
adc2a02f6c | ||
|
|
6a70775ad9 | ||
|
|
6d009af48f | ||
|
|
da027f8628 | ||
|
|
1eefe17474 | ||
|
|
f76a566aa8 | ||
|
|
ce98aaa448 | ||
|
|
6ba822daa2 |
No files matched your search
@@ -10,7 +10,9 @@ jobs:
|
||||
run: |
|
||||
rm -rf src
|
||||
git clone https://gitea.tose.top/admin/codex-app-server.git src
|
||||
cd src && git rev-parse HEAD
|
||||
cd src && git fetch origin "${{ gitea.ref }}" && git checkout FETCH_HEAD
|
||||
echo "pushed ref: ${{ gitea.ref }}"
|
||||
git rev-parse HEAD && git log --oneline -1
|
||||
- name: host tests
|
||||
run: cd src && python3 -m unittest discover -s tests -v
|
||||
- name: cloud tests
|
||||
|
||||
@@ -72,8 +72,9 @@ agent 循环 + 沙箱 + 工具 + MCP + skills + 线程持久化
|
||||
agent 循环、沙箱、工具执行、上下文压缩、线程存储,全部是官方
|
||||
codex 二进制里的,不是我复刻的简化版。
|
||||
|
||||
实测版本:`codex-cli 0.164.0-alpha.9`,协议定义就是从它
|
||||
导出的,见 `protocol/`。
|
||||
实测版本:`codex-cli 0.166.0`,协议定义就是从它
|
||||
导出的,见 `protocol/`(2026-11-05 由 0.166.0 二进制重导,见
|
||||
`docs/upstream-166-alignment.md`)。
|
||||
|
||||
## 目录
|
||||
|
||||
|
||||
@@ -129,7 +129,6 @@ honour that today, and this track says so instead of claiming it:
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
+235
-52
@@ -67,10 +67,50 @@ Semantics (pinned by tests, not left to each caller):
|
||||
|
||||
Persistence notes:
|
||||
|
||||
- One table, one definition. ``task_attempts`` is defined ONCE
|
||||
below (``TASK_ATTEMPTS_DDL``, aliased as ``SQLITE_DDL`` /
|
||||
``PG_DDL``); ``cloud.attempts`` (the resume-history writer) and
|
||||
``cloud.db.POSTGRES_SCHEMA`` (the fresh-Postgres schema) both
|
||||
import this same string instead of carrying their own copy.
|
||||
The two historical shapes -- main's resume DDL (``kind`` /
|
||||
``interrupted_reason`` / ``checkpoint_round`` / ``result_text``
|
||||
/ ``error_text``) and gap 3's ledger DDL (``outcome`` /
|
||||
``is_defect`` / ``defect_category`` / ``corrects_attempt_no``)
|
||||
-- are superseded by this unified definition, which carries
|
||||
both column groups. The shared columns (``task_id``,
|
||||
``attempt_no``, ``created_at``) had identical meaning and type
|
||||
on both sides, so there is no semantic conflict to arbitrate:
|
||||
the union of the column sets IS the unified schema, with one
|
||||
deliberate relaxation (see below).
|
||||
- Row kinds in one table. A row is a ledger row iff ``outcome``
|
||||
is non-NULL (written by this module, outcome validated in
|
||||
Python before the insert); a row with ``outcome`` NULL is a
|
||||
resume-history row (written by ``cloud.attempts``). The two
|
||||
writers insert disjoint column groups and read by column name,
|
||||
so they share the table without overwriting each other's
|
||||
fields. ``aggregate_attempts`` counts ONLY ledger rows: a
|
||||
resume row (outcome NULL) is skipped, never counted as a
|
||||
defect-free first pass -- otherwise a task that was merely
|
||||
auto-resumed would silently gain a counted attempt.
|
||||
- The one deliberate relaxation: ``outcome`` is nullable in the
|
||||
unified DDL even though the ledger writer always supplies it.
|
||||
A ``NOT NULL`` on ``outcome`` would make the resume writer's
|
||||
insert (which has no outcome to report) fail on a fresh table,
|
||||
so the constraint lives in Python validation here, not in the
|
||||
DDL. ``is_defect`` keeps ``NOT NULL DEFAULT 0`` so a resume
|
||||
row that omits it still reads as "not a defect judgement".
|
||||
- SQLite: ``ensure_task_attempts_table`` runs
|
||||
``CREATE TABLE IF NOT EXISTS`` -- idempotent, safe to call on
|
||||
every write, and a no-op on a database that already has the
|
||||
table (including one that already has rows).
|
||||
``CREATE TABLE IF NOT EXISTS`` with the unified DDL, then
|
||||
migrates a pre-existing legacy table (old resume shape, gap-3
|
||||
ledger shape, or the rehearsal-era 12-column shape) by adding
|
||||
whichever unified columns it lacks. Added columns are always
|
||||
nullable or defaulted, so existing rows are never blocked;
|
||||
SQLite ``ALTER TABLE`` cannot tighten an existing column's
|
||||
nullability, so a migrated table keeps its looser column
|
||||
types -- the column SET and the data are unified, the
|
||||
``NOT NULL`` spelling on fresh tables is not retrofitted.
|
||||
This is a migration to the single canonical definition, not a
|
||||
second definition assembled at runtime.
|
||||
- Postgres: the production table is NOT created by
|
||||
``init_postgres_schema`` (deliberately: that schema is covered by
|
||||
the migrations test asserting ``MIGRATION_TABLES`` equals the
|
||||
@@ -87,7 +127,18 @@ Persistence notes:
|
||||
``test_attempt_ledger.py``). Until that manual step runs, the
|
||||
endpoint treats a missing table exactly like "no ledger rows":
|
||||
unknown rates, not an error -- the same fail-soft shape already
|
||||
used for missing ``cost_records``.
|
||||
used for missing ``cost_records``. The Postgres upgrade path
|
||||
for a legacy-shaped table was verified against a real
|
||||
Postgres 16.15 instance on 2026-11-06 (fresh ``PG_DDL``,
|
||||
three legacy shapes migrated in place, both writers, PK,
|
||||
idempotent ensure; see
|
||||
``verification/codex-task-attempts-pg-proof-20261106.md``).
|
||||
One gap-3-only step relaxes a gap-3 table's ``outcome``
|
||||
from NOT NULL with ``ALTER COLUMN outcome DROP NOT NULL``
|
||||
(see ``ensure_task_attempts_table``); the statement works
|
||||
on Postgres and on SQLite 3.53.0+, and is swallowed by
|
||||
``try/except`` on older SQLite, where the column stays
|
||||
NOT NULL.
|
||||
- The read path (``GET /metrics-report``) never creates the table:
|
||||
a read-only endpoint must not mutate schema. Table creation
|
||||
happens only on the write path, inside ``record_attempt`` /
|
||||
@@ -104,18 +155,15 @@ Persistence notes:
|
||||
unknown until the hook is deliberately enabled. The worker hook
|
||||
always records ``is_defect=False``: it never judges a defect,
|
||||
so enabling it can never turn a transient retry into a defect.
|
||||
- Schema evolution note (pre-production, this track):
|
||||
``corrects_attempt_no`` was added to the DDL after audit gap 3
|
||||
but BEFORE either DDL reached production (gap 3 is still an
|
||||
OPEN, unmerged PR), so no production table needs migration:
|
||||
fresh tables get the column from the DDL below. A database
|
||||
created from gap 3's DDL alone (temporary/test databases only)
|
||||
gains the column with one statement --
|
||||
``ALTER TABLE task_attempts ADD COLUMN corrects_attempt_no
|
||||
INTEGER`` -- which is also the documented step if gap 3's DDL
|
||||
was ever run on production Postgres before this track lands.
|
||||
The read path uses ``SELECT *`` deliberately so a table without
|
||||
the column still reads (its rows aggregate as plain attempts).
|
||||
- Schema evolution note (unified schema, this batch):
|
||||
the old resume DDL, the gap-3 ledger DDL, and the rehearsal
|
||||
12-column shape are all legacy shapes of the SAME table.
|
||||
``ensure_task_attempts_table`` migrates any of them to the
|
||||
unified column set in place (add missing columns only, never
|
||||
drop or rewrite rows); the read path uses ``SELECT *``
|
||||
deliberately so rows of any shape still read, and
|
||||
``aggregate_attempts`` treats a row without an outcome as a
|
||||
resume row, not as a ledger attempt (see above).
|
||||
|
||||
This module imports no ``cloud.*`` module and opens no database
|
||||
connection itself: callers pass a connection in, so the layer rules
|
||||
@@ -130,49 +178,174 @@ TABLE = "task_attempts"
|
||||
|
||||
OUTCOMES = frozenset({"success", "failure", "interrupted"})
|
||||
|
||||
# One DDL serves both backends: every type and the composite key are
|
||||
# valid in SQLite and in Postgres, and there is no AUTOINCREMENT or
|
||||
# identity column for either backend's sequence handling to chase.
|
||||
# ``corrects_attempt_no`` (NULL = a real attempt row; non-NULL = a
|
||||
# correction row for that attempt_no, never counted as an attempt)
|
||||
# was added by the lifecycle-hook track; it SUPERSEDES gap 3's DDL
|
||||
# for any production manual step (gap 3 never merged, so no
|
||||
# production table exists yet -- see the module docstring).
|
||||
SQLITE_DDL = (
|
||||
# THE single canonical definition of task_attempts (unified
|
||||
# schema, FLAG-1 decision 2026-11-05: "unified schema", rejecting
|
||||
# both the rehearsal-era runtime column-union workaround and a
|
||||
# ledger rename). One DDL serves both backends: every type and
|
||||
# the composite key are valid in SQLite and in Postgres, and
|
||||
# there is no AUTOINCREMENT or identity column for either
|
||||
# backend's sequence handling to chase.
|
||||
#
|
||||
# Column groups, in order (each group's internal order preserves
|
||||
# its historical DDL order):
|
||||
# - shared: task_id, attempt_no (composite PK), created_at last;
|
||||
# - ledger (gap 3 + lifecycle hook): outcome, is_defect,
|
||||
# defect_category, corrects_attempt_no (NULL = a real attempt
|
||||
# row; non-NULL = a correction row for that attempt_no, never
|
||||
# counted as an attempt);
|
||||
# - resume (main v6): kind, interrupted_reason,
|
||||
# checkpoint_round, result_text, error_text.
|
||||
# ``outcome`` is deliberately nullable (see the module docstring:
|
||||
# the resume writer has no outcome to report, so a NOT NULL here
|
||||
# would break its insert; the ledger writer validates outcome in
|
||||
# Python instead). ``cloud.attempts.CREATE_TABLE_SQL`` and the
|
||||
# ``task_attempts`` entry of ``cloud.db.POSTGRES_SCHEMA`` are
|
||||
# this same string object, imported -- never a second copy.
|
||||
TASK_ATTEMPTS_DDL = (
|
||||
"CREATE TABLE IF NOT EXISTS task_attempts("
|
||||
"task_id TEXT NOT NULL, "
|
||||
"attempt_no INTEGER NOT NULL, "
|
||||
"outcome TEXT NOT NULL, "
|
||||
"outcome TEXT, "
|
||||
"is_defect INTEGER NOT NULL DEFAULT 0, "
|
||||
"defect_category TEXT, "
|
||||
"created_at DOUBLE PRECISION, "
|
||||
"corrects_attempt_no INTEGER, "
|
||||
"kind TEXT, "
|
||||
"interrupted_reason TEXT, "
|
||||
"checkpoint_round INTEGER, "
|
||||
"result_text TEXT, "
|
||||
"error_text TEXT, "
|
||||
"created_at DOUBLE PRECISION, "
|
||||
"PRIMARY KEY (task_id, attempt_no))"
|
||||
)
|
||||
|
||||
SQLITE_DDL = TASK_ATTEMPTS_DDL
|
||||
|
||||
# Production Postgres: run this verbatim by hand and verify the
|
||||
# table exists before expecting real rates from a Postgres-backed
|
||||
# endpoint. It is intentionally NOT part of POSTGRES_SCHEMA (see
|
||||
# the module docstring: migrations coverage must not widen silently).
|
||||
# This is the NEW DDL (with ``corrects_attempt_no``): if gap 3's
|
||||
# older DDL was ever run first, follow it with
|
||||
# ``ALTER TABLE task_attempts ADD COLUMN corrects_attempt_no
|
||||
# INTEGER`` and verify before expecting corrections to work.
|
||||
PG_DDL = SQLITE_DDL
|
||||
# For a legacy-shaped table already on Postgres, add whichever
|
||||
# unified columns are missing (see UNIFIED_COLUMN_TYPES) and
|
||||
# relax a NOT NULL outcome if the legacy shape declared one
|
||||
# (see ensure_task_attempts_table); that Postgres upgrade path
|
||||
# was verified against a real Postgres 16.15 instance on
|
||||
# 2026-11-06 -- see verification/
|
||||
# codex-task-attempts-pg-proof-20261106.md.
|
||||
PG_DDL = TASK_ATTEMPTS_DDL
|
||||
|
||||
# One-line upgrade for a table created from gap 3's DDL (no
|
||||
# ``corrects_attempt_no`` column yet). Valid in both backends.
|
||||
# Unified column set, in DDL order. The migration in
|
||||
# ``ensure_task_attempts_table`` adds exactly these columns when
|
||||
# missing; the ALTER types below are the migration spellings
|
||||
# (nullable/defaulted, so existing rows are never blocked --
|
||||
# SQLite ALTER cannot add a NOT NULL column without a default
|
||||
# to a table that already has rows, and cannot tighten an
|
||||
# existing column at all).
|
||||
UNIFIED_COLUMNS = (
|
||||
"task_id", "attempt_no", "outcome", "is_defect",
|
||||
"defect_category", "corrects_attempt_no", "kind",
|
||||
"interrupted_reason", "checkpoint_round", "result_text",
|
||||
"error_text", "created_at",
|
||||
)
|
||||
|
||||
UNIFIED_COLUMN_TYPES = {
|
||||
"task_id": "TEXT",
|
||||
"attempt_no": "INTEGER",
|
||||
"outcome": "TEXT",
|
||||
"is_defect": "INTEGER DEFAULT 0",
|
||||
"defect_category": "TEXT",
|
||||
"corrects_attempt_no": "INTEGER",
|
||||
"kind": "TEXT",
|
||||
"interrupted_reason": "TEXT",
|
||||
"checkpoint_round": "INTEGER",
|
||||
"result_text": "TEXT",
|
||||
"error_text": "TEXT",
|
||||
"created_at": "DOUBLE PRECISION",
|
||||
}
|
||||
|
||||
# Legacy one-line upgrade for a table created from gap 3's DDL
|
||||
# (no ``corrects_attempt_no`` column yet). Retained for
|
||||
# backward compatibility with existing callers/tests; it is a
|
||||
# single step of the general migration above, not a second
|
||||
# schema definition. Valid in both backends.
|
||||
OLD_TABLE_ALTER = (
|
||||
"ALTER TABLE task_attempts "
|
||||
"ADD COLUMN corrects_attempt_no INTEGER"
|
||||
)
|
||||
|
||||
|
||||
def _existing_columns(conn, table):
|
||||
"""已有列集合(automations.py 同款口径:SQLite 走 PRAGMA,
|
||||
其它后端异常时回空集、后续 ALTER 逐条 try/except 兜底)。"""
|
||||
try:
|
||||
return {row[1] for row in conn.execute(
|
||||
"PRAGMA table_info(%s)" % table).fetchall()}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _column_notnull(conn, table):
|
||||
"""列名 -> 是否 NOT NULL(PRAGMA table_info 的 row[3];
|
||||
SQLite 与 PG 适配层含义一致:1 = NOT NULL,0 = 可空;
|
||||
探针失败时回空 dict,调用方按“无需放宽”处理)。"""
|
||||
try:
|
||||
return {row[1]: bool(row[3]) for row in conn.execute(
|
||||
"PRAGMA table_info(%s)" % table).fetchall()}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def ensure_task_attempts_table(conn):
|
||||
"""Create the ledger table if absent. Idempotent; does not
|
||||
touch existing rows. The caller owns the commit (``record_attempt``
|
||||
commits; a bare ensure inside a larger transaction does not)."""
|
||||
conn.execute(SQLITE_DDL)
|
||||
"""Create the unified table if absent; migrate a legacy
|
||||
table to it if present. Idempotent; does not touch existing
|
||||
rows. The caller owns the commit (``record_attempt``
|
||||
commits; a bare ensure inside a larger transaction does not).
|
||||
|
||||
Unified schema (FLAG-1, user decision 2026-11-05): there is
|
||||
exactly one ``task_attempts`` definition
|
||||
(``TASK_ATTEMPTS_DDL``). A fresh database gets it verbatim
|
||||
from ``CREATE TABLE IF NOT EXISTS``. A database whose table
|
||||
was built from any legacy shape -- the old resume DDL, the
|
||||
gap-3 ledger DDL, or the rehearsal-era 12-column shape --
|
||||
is migrated in place by adding whichever unified columns it
|
||||
lacks (``UNIFIED_COLUMN_TYPES``); added columns are always
|
||||
nullable or defaulted, so existing rows are never blocked,
|
||||
and no row is dropped or rewritten. A gap-3 table's
|
||||
``outcome`` was created NOT NULL; ensure also relaxes it to
|
||||
nullable (``ALTER COLUMN outcome DROP NOT NULL`` -- works
|
||||
on Postgres and on SQLite 3.53.0+, swallowed on older
|
||||
SQLite), because the resume writer inserts outcome NULL --
|
||||
without the relax every resume write on such a table fails.
|
||||
This replaces the
|
||||
rehearsal-era runtime column-completion workaround: the
|
||||
target here is the single canonical definition, and
|
||||
``cloud.attempts`` / ``cloud.db`` share that same string
|
||||
instead of each carrying a DDL of their own.
|
||||
"""
|
||||
conn.execute(TASK_ATTEMPTS_DDL)
|
||||
existing = _existing_columns(conn, "task_attempts")
|
||||
for col in UNIFIED_COLUMNS:
|
||||
if col not in existing:
|
||||
try:
|
||||
conn.execute(
|
||||
"ALTER TABLE task_attempts ADD COLUMN %s %s"
|
||||
% (col, UNIFIED_COLUMN_TYPES[col]))
|
||||
except Exception:
|
||||
pass
|
||||
# gap-3 形态的 outcome 是 NOT NULL,而统一 DDL 要求它可空
|
||||
# (恢复写路不报 outcome,NOT NULL 会使它在 PG 上每次写
|
||||
# 行都报 NotNullViolation)。只在探针报 NOT NULL 时放宽:
|
||||
# ALTER COLUMN outcome DROP NOT NULL 在 Postgres 上生效;
|
||||
# SQLite 3.53.0 起也支持该语法,在此之前不合法、被
|
||||
# try/except 吞掉,gap-3 表仍保留 NOT NULL(恢复写路
|
||||
# 会被 INSERT OR IGNORE 静默丢弃——版本相关边界,见
|
||||
# test_column_notnull_probe_sees_gap3_outcome)。
|
||||
if _column_notnull(conn, "task_attempts").get("outcome"):
|
||||
try:
|
||||
conn.execute(
|
||||
"ALTER TABLE task_attempts "
|
||||
"ALTER COLUMN outcome DROP NOT NULL")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def record_attempt(conn, task_id, outcome, is_defect=False,
|
||||
@@ -231,11 +404,11 @@ def record_correction(conn, task_id, attempt_no, is_defect,
|
||||
correction row, a target whose outcome is ``interrupted``
|
||||
(interrupted rows are never defects, so there is no defect
|
||||
judgement to correct), and a defect category without a defect
|
||||
flag all raise ``ValueError`` and write nothing. On a table
|
||||
created from gap 3's DDL (no ``corrects_attempt_no`` column),
|
||||
the INSERT raises the backend's OperationalError loudly --
|
||||
run ``OLD_TABLE_ALTER`` first (see the module docstring);
|
||||
a correction must never silently degrade into a plain row.
|
||||
flag all raise ``ValueError`` and write nothing. A table
|
||||
created from a legacy DDL is migrated by ``ensure`` first
|
||||
(above), so the ``corrects_attempt_no`` column is present
|
||||
before the INSERT runs; a correction must never silently
|
||||
degrade into a plain row.
|
||||
Returns the correction row's own ``attempt_no``.
|
||||
"""
|
||||
if not task_id:
|
||||
@@ -284,12 +457,13 @@ def read_attempt_rows(conn):
|
||||
OperationalError when the table does not exist yet; the
|
||||
endpoint caller treats that as "no ledger", not as a failure.
|
||||
|
||||
``SELECT *`` is deliberate: a table created from gap 3's DDL
|
||||
has no ``corrects_attempt_no`` column, and naming that column
|
||||
here would turn those readable rows into an endpoint error.
|
||||
``SELECT *`` is deliberate: a legacy-shaped table may lack
|
||||
columns the unified DDL has, and naming a missing column
|
||||
here would turn readable rows into an endpoint error.
|
||||
With ``*`` the rows still read, and ``aggregate_attempts``
|
||||
treats a missing ``corrects_attempt_no`` as a plain attempt
|
||||
row (see ``_field_optional``).
|
||||
row (see ``_field_optional``) and a missing/NULL ``outcome``
|
||||
as a resume row, not a ledger attempt.
|
||||
"""
|
||||
return conn.execute(
|
||||
"SELECT * FROM task_attempts "
|
||||
@@ -323,10 +497,14 @@ def aggregate_attempts(rows):
|
||||
|
||||
Interrupted rows are excluded from both counts, and a task
|
||||
with only interrupted rows is omitted entirely (no counted
|
||||
attempt => its rates stay unknown, never 0). Correction rows
|
||||
(``corrects_attempt_no`` set) are excluded from the attempt
|
||||
count and instead OVERWRITE the defect judgement of their
|
||||
target attempt, in attempt order, last-wins: a task's
|
||||
attempt => its rates stay unknown, never 0). Resume rows
|
||||
(``outcome`` NULL -- written by ``cloud.attempts``, which
|
||||
records an interrupted attempt's history, not a ledger
|
||||
outcome) are likewise excluded: they are not ledger attempts
|
||||
and must never inflate a task's counted attempts. Correction
|
||||
rows (``corrects_attempt_no`` set) are excluded from the
|
||||
attempt count and instead OVERWRITE the defect judgement of
|
||||
their target attempt, in attempt order, last-wins: a task's
|
||||
``defects`` is the number of its counted attempts whose
|
||||
judgement AFTER corrections is a defect, and its
|
||||
``defect_category`` is the category of its FIRST defect
|
||||
@@ -351,7 +529,12 @@ def aggregate_attempts(rows):
|
||||
bool(_field(row, "is_defect")),
|
||||
_field(row, "defect_category")]
|
||||
continue
|
||||
if _field(row, "outcome") == "interrupted":
|
||||
outcome = _field_optional(row, "outcome")
|
||||
if outcome is None:
|
||||
# Resume-history row (cloud.attempts): no ledger
|
||||
# outcome, so not a counted ledger attempt.
|
||||
continue
|
||||
if outcome == "interrupted":
|
||||
continue
|
||||
entries = judged.setdefault(task_id, {})
|
||||
entries[_field(row, "attempt_no")] = [
|
||||
|
||||
+18
-14
@@ -31,7 +31,13 @@ host.py、不改 codex 线程恢复:
|
||||
result_text / error_text 各截断 2000 字。记账与回 queued
|
||||
在同一事务里,记账失败不单独炸恢复循环(外层已兜底),
|
||||
但同一 (task_id, attempt_no) 重复记时 INSERT OR IGNORE
|
||||
保留第一次,不覆盖。
|
||||
保留第一次,不覆盖。表定义是统一 schema(FLAG-1,
|
||||
2026-11-05):本模块不自带一份 DDL,CREATE_TABLE_SQL
|
||||
与建表/迁移均取自 ``cloud.attempt_ledger`` 的
|
||||
``TASK_ATTEMPTS_DDL`` / ``ensure_task_attempts_table``,
|
||||
与台账写路径共用同一定义;本模块写的行 outcome 为
|
||||
NULL(恢复历史行,非台账计数行,见 attempt_ledger 对
|
||||
aggregate 的口径)。
|
||||
② 重跑前言:非 goal 平台全新重排的任务在认领时,worker
|
||||
在内存副本的 prompt 前贴一段上次尝试说明(原 prompt
|
||||
不落库,与项目/待办/记忆注入同款只改内存副本),内容
|
||||
@@ -63,25 +69,23 @@ host.py、不改 codex 线程恢复:
|
||||
|
||||
import time
|
||||
|
||||
from cloud.attempt_ledger import TASK_ATTEMPTS_DDL
|
||||
from cloud.attempt_ledger import ensure_task_attempts_table as _ensure_unified
|
||||
|
||||
ATTEMPT_TEXT_CHARS = 2000
|
||||
PREAMBLE_TEXT_CHARS = 1000
|
||||
|
||||
CREATE_TABLE_SQL = (
|
||||
"CREATE TABLE IF NOT EXISTS task_attempts("
|
||||
"task_id TEXT, "
|
||||
"attempt_no INTEGER, "
|
||||
"kind TEXT, "
|
||||
"interrupted_reason TEXT, "
|
||||
"checkpoint_round INTEGER, "
|
||||
"result_text TEXT, "
|
||||
"error_text TEXT, "
|
||||
"created_at DOUBLE PRECISION, "
|
||||
"PRIMARY KEY(task_id, attempt_no))")
|
||||
# 统一 schema:与台账共用同一定义,绝不在此另写一份 DDL
|
||||
# (历史教训:两份 DDL 同名异构,CREATE TABLE IF NOT EXISTS
|
||||
# 使后建的一方成空操作、写行报缺列)。别名保留,供旧调用
|
||||
# 方按名取定义;建表与老表迁移一律走台账的 ensure。
|
||||
CREATE_TABLE_SQL = TASK_ATTEMPTS_DDL
|
||||
|
||||
|
||||
def ensure_attempts_table(conn):
|
||||
"""幂等建尝试账本表(server/worker 两支共用同一 DDL)。"""
|
||||
conn.execute(CREATE_TABLE_SQL)
|
||||
"""幂等建尝试账本表(server/worker 两支共用同一 DDL):
|
||||
新库建统一表,老库迁移到统一列集(见 attempt_ledger)。"""
|
||||
_ensure_unified(conn)
|
||||
|
||||
|
||||
def _trunc(text, limit):
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
build_scorecard(..., baseline_head=None) 的 baseline_head 是机器字段,
|
||||
只能由调用方显式传入当前 main 头;本模块绝不在生产代码里探测
|
||||
版本控制状态、绝不读版本控制目录、绝不编造一个头。
|
||||
接受任意字符串、原样记录,不做任何格式校验(不要求形如
|
||||
commit hash);本模块不验证该值是否真实、可信,可信 main 头
|
||||
的来源仍是调用方责任。
|
||||
不传入(或传入空串)时 baseline_head 为 None,并在 missing_items
|
||||
里记 baseline_head_missing,不把缺失读成“与当前一致”。
|
||||
check_baseline_stale(scorecard, current_main_head) 是唯一的漂移判定:
|
||||
@@ -916,6 +919,16 @@ def check_baseline_stale(scorecard, current_main_head):
|
||||
参数全部由调用方传入:scorecard 是 build_scorecard 的输出 dict,
|
||||
current_main_head 是调用方当前确认的 main 头。本函数不在生产
|
||||
代码里探测版本控制状态,也不读版本控制目录。
|
||||
调用方契约(gapfill2 H4,只写文档、不改行为):
|
||||
current_main_head 接受任意字符串、原样比对,不做任何格式
|
||||
校验(不要求形如 commit hash,不校验长度、字符集、前缀);
|
||||
本函数不验证该值是否真实、可信、是否确为 main 头——可信
|
||||
main 头的来源仍是调用方责任。空串先归一化为 None,与未
|
||||
传入同等处理。注意端点层与函数层的区别:评分卡端点在缺失
|
||||
或空串时根本不调用本函数(不做判定、不附加判定字段);
|
||||
调用方若直接调本函数且 current_main_head 缺失,本函数按
|
||||
失败关闭返回 stale=True(见下三种情形之二),那是函数层
|
||||
面的判定结果,不是端点行为。
|
||||
|
||||
返回 dict(调用方凭 stale / usable_as_pass_basis 判断,绝不能
|
||||
只看旧卡的 overall / overall_pass):
|
||||
@@ -1068,7 +1081,8 @@ def build_scorecard(evidence, thresholds=None, rubric_version=None,
|
||||
(user_side_items 可选占位),product=build_product_scorecard
|
||||
(无真实产品轮证据时恒为 not_pass)。新增键不改变旧字段的值。
|
||||
另新增 rubric_version(见 cloud/scorecard_rubric.py)、
|
||||
baseline_head(机器字段,见 check_baseline_stale)、
|
||||
baseline_head(机器字段,见 check_baseline_stale;接受任意
|
||||
字符串原样记录、不做格式校验,空串按未传入处理)、
|
||||
evidence_ledger_head 与 evidence_row_ids(见
|
||||
compute_evidence_ledger)三个机制字段;无证据行时头为 None。
|
||||
"""
|
||||
|
||||
+12
-13
@@ -65,6 +65,12 @@ import time
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
|
||||
# 统一 schema:task_attempts 的 PG 新库定义与台账定义是同一
|
||||
# 字符串(cloud.attempt_ledger.TASK_ATTEMPTS_DDL),不在此另
|
||||
# 写一份。attempt_ledger 只依赖标准库 time,与本模块无循环
|
||||
# 导入;层规则上它也不属于 orchestration 模块集合。
|
||||
from cloud.attempt_ledger import TASK_ATTEMPTS_DDL as _TASK_ATTEMPTS_DDL
|
||||
|
||||
# 与 server.py 旧 _connect 的 timeout=30s 保持一致的连接级等待;
|
||||
# PRAGMA busy_timeout 是语句级等待,两者不冲突,后者以毫秒为单位
|
||||
# 在每个连接上单独设置(不随库文件持久化,见 connect 的注释)。
|
||||
@@ -727,7 +733,7 @@ class PgCursor:
|
||||
info_cur.close()
|
||||
self._fake_rows = [
|
||||
PgRow((i, r[1], r[2],
|
||||
0 if r[3] == "NO" else 1, r[4], r[5]),
|
||||
1 if r[3] == "NO" else 0, r[4], r[5]),
|
||||
("cid", "name", "type", "notnull",
|
||||
"dflt_value", "pk"))
|
||||
for i, r in enumerate(raw)]
|
||||
@@ -876,18 +882,11 @@ POSTGRES_SCHEMA = (
|
||||
"created_at DOUBLE PRECISION)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_task_steps_task"
|
||||
" ON task_steps(task_id, step_no)",
|
||||
# 尝试账本(v6,见 cloud/attempts.py):(task_id, attempt_no)
|
||||
# 唯一;PG 新库一次建齐,老库走 server/worker ensure。
|
||||
"CREATE TABLE IF NOT EXISTS task_attempts("
|
||||
"task_id TEXT, "
|
||||
"attempt_no INTEGER, "
|
||||
"kind TEXT, "
|
||||
"interrupted_reason TEXT, "
|
||||
"checkpoint_round INTEGER, "
|
||||
"result_text TEXT, "
|
||||
"error_text TEXT, "
|
||||
"created_at DOUBLE PRECISION, "
|
||||
"PRIMARY KEY(task_id, attempt_no))",
|
||||
# 尝试账本(统一 schema,见 cloud/attempt_ledger.py):
|
||||
# (task_id, attempt_no) 唯一;PG 新库一次建齐,老库走
|
||||
# server/worker ensure 迁移到同一列集。此处是导入的统
|
||||
# 一定义本身,不是第二份 DDL。
|
||||
_TASK_ATTEMPTS_DDL,
|
||||
# 审计循环状态(audit loop):task_id 单行主键;PG 新库一次
|
||||
# 建齐,老库走 server/worker ensure(见 cloud/audit_loop.py)。
|
||||
"CREATE TABLE IF NOT EXISTS task_audit_loop_state("
|
||||
|
||||
+24
-16
@@ -21,16 +21,19 @@
|
||||
报告:表齐但钩子关 ≠ 就绪,钩子开但表不齐 ≠ 就绪。
|
||||
3. DDL 清单不另造:清单直接引用 attempt_ledger.PG_DDL
|
||||
与 attempt_hook.PG_MARKER_DDL 两个常量(本模块不
|
||||
复制 DDL 文本,复制就会漂移)。另含 gap 3 旧表升级句
|
||||
attempt_ledger.OLD_TABLE_ALTER(仅当旧 DDL 建过表时
|
||||
需要;gap 3 未合并,生产尚无旧表,属防御项)。
|
||||
复制 DDL 文本,复制就会漂移)。老形态表升级走
|
||||
attempt_ledger.ensure_task_attempts_table 的统一列补齐
|
||||
(仅当老形态建过表时需要;属防御项,真实 PG 16.15
|
||||
升级路径已于 2026-11-06 实证,见 verification/
|
||||
codex-task-attempts-pg-proof-20261106.md)。
|
||||
4. 校验函数 verify_ledger_ready(conn):只读探针——查两
|
||||
张表的存在与必需列,不建表、不写行(读路径绝不改
|
||||
schema,与 metrics-report 端点的只读纪律一致)。
|
||||
5. 生产 PG 手动 DDL 仍是运维侧动作:本轨无生产 PG、无
|
||||
本地 PG,PG 证据与此前各轨同款——适配器级(? 占位符
|
||||
经 cloud.db 翻译、DDL 仅用 PG 合法类型、无自增列),
|
||||
绝不写成“生产 PG 已执行”。运维执行清单见
|
||||
5. 生产 PG 手动 DDL 仍是运维侧动作:升级路径本身已在
|
||||
真实 PG 16.15 上实证(见 verification/
|
||||
codex-task-attempts-pg-proof-20261106.md),但“生产 PG
|
||||
已执行”仍须运维在生产库上跑过才算,绝不写成已执行。
|
||||
运维执行清单见
|
||||
LEDGER_DDL_CHECKLIST(顺序执行 + 每步验证命令)。
|
||||
|
||||
零第三方依赖,只用标准库。conn 约定与 attempt_ledger
|
||||
@@ -42,11 +45,14 @@ from cloud.attempt_hook import PG_MARKER_DDL as _PG_MARKER_DDL
|
||||
from cloud.attempt_ledger import OLD_TABLE_ALTER as _OLD_TABLE_ALTER
|
||||
from cloud.attempt_ledger import PG_DDL as _LEDGER_PG_DDL
|
||||
|
||||
# 必需列:缺任一列即视为表不齐(旧 DDL 建的表缺
|
||||
# corrects_attempt_no 时走 OLD_TABLE_ALTER 补列)。
|
||||
# 必需列:统一 schema 的全部 12 列;缺任一列即视为表
|
||||
# 不齐(老形态建的表由 ensure_task_attempts_table 补齐
|
||||
# 缺失列后复核,不再只补 corrects_attempt_no 一列)。
|
||||
LEDGER_REQUIRED_COLUMNS = (
|
||||
"task_id", "attempt_no", "outcome", "is_defect",
|
||||
"defect_category", "created_at", "corrects_attempt_no",
|
||||
"defect_category", "corrects_attempt_no", "kind",
|
||||
"interrupted_reason", "checkpoint_round", "result_text",
|
||||
"error_text", "created_at",
|
||||
)
|
||||
MARKER_REQUIRED_COLUMNS = ("task_id", "origin", "queued_at")
|
||||
|
||||
@@ -72,12 +78,14 @@ LEDGER_DDL_CHECKLIST = (
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"action": "仅当表由 gap 3 旧 DDL 建过时:执行"
|
||||
" attempt_ledger.OLD_TABLE_ALTER 补"
|
||||
" corrects_attempt_no 列(gap 3 未合并,"
|
||||
"生产正常路径跳过此步)",
|
||||
"ddl_source": "cloud.attempt_ledger.OLD_TABLE_ALTER",
|
||||
"verify": "task_attempts 含 corrects_attempt_no 列",
|
||||
"action": "仅当表已由老形态(旧恢复 DDL、gap 3 台账 DDL"
|
||||
" 或演练形态)建过时:经 attempt_ledger."
|
||||
"ensure_task_attempts_table 补齐缺失的统一列"
|
||||
"(SQLite 与真实 PG 16.15 均已实测,见 verification/"
|
||||
"codex-task-attempts-pg-proof-20261106.md;生产 PG"
|
||||
" 上执行仍是运维侧动作)",
|
||||
"ddl_source": "cloud.attempt_ledger.TASK_ATTEMPTS_DDL",
|
||||
"verify": "task_attempts 含统一 schema 全部 12 列",
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
行字段:
|
||||
- rubric_version:评分口径版本(调用方传入的字符串,如 "v1";
|
||||
本模块不规定口径内容、不替用户拍板阈值)。
|
||||
- baseline_head:冻结时调用方传入的 main 头(字符串,非空)。
|
||||
- baseline_head:冻结时调用方传入的 main 头(字符串,非空;
|
||||
接受任意字符串原样存、不做格式校验,本模块不验证其真实性,
|
||||
可信来源仍是调用方责任)。
|
||||
- frozen_at:冻结时间戳(秒,调用方不传时取当前时间)。
|
||||
- scorecard_snapshot:build_scorecard 输出的完整 dict,以 JSON
|
||||
文本存入,读回时还原为 dict。
|
||||
|
||||
+10
-1
@@ -61,7 +61,6 @@ from cloud.agent_def import parse_agent_definition as _parse_agent_definition
|
||||
from cloud.agent_def import resolve_registry_agent as _resolve_agent
|
||||
from cloud.attempts import ensure_attempts_table as _ensure_attempts
|
||||
from cloud.audit_loop import ensure_audit_loop_table as _ensure_audit_loop
|
||||
from cloud.auth import AuthStore
|
||||
# 演练注:删 _parse_tools_col/_parse_tool_approvals_col(随 helper
|
||||
# 移 handler_support)、_MEMBER_ROLES/_coord_parse_parts(用点已随
|
||||
# split 移走,server.py 内无其余引用,ruff F401 计)。
|
||||
@@ -9194,6 +9193,16 @@ pid 须为整数、address 须为字符串,否则 400(注册表里的
|
||||
参数也永远返回 200 + 报告,只是多一个判定字段。
|
||||
生产代码绝不读 git、不起 subprocess 查版本:main 头
|
||||
只能由调用方传入,端点本身不探测版本控制状态。
|
||||
调用方契约(gapfill2 H4,只写文档、不改行为):
|
||||
current_main_head 由调用方提供,接受任意字符串、原样
|
||||
使用,不做任何格式校验(不要求形如 commit hash,不
|
||||
校验长度、字符集、前缀);端点不验证该值是否真实、
|
||||
是否可信、是否确为 main 头——可信 main 头的来源仍是
|
||||
调用方责任。缺失(未传该查询参数)与空串一律按未传入
|
||||
处理:端点不调用判定、不附加 baseline_staleness 字段,
|
||||
响应与以前完全一致。传入非空值后,端点永远返回 200 +
|
||||
报告、只附加判定字段:判定为 stale 也不挡查询,绝不改
|
||||
写报告的 overall / overall_pass。
|
||||
|
||||
权限:与 /metrics 同款,任意项目成员即可读(不要求 owner),
|
||||
但本端点不统计任何项目数据、不写库、不写账本、不改链头、
|
||||
|
||||
@@ -149,6 +149,19 @@ def _table_exists(db_path, table):
|
||||
return row[0] == 1
|
||||
|
||||
|
||||
def _task_attempts_row_count(db_path):
|
||||
"""task_attempts 行数(表不存在时计 0;COUNT(*) 与列集无关,
|
||||
旧 DDL 表与并集表均可查)。"""
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
if not _table_exists(db_path, "task_attempts"):
|
||||
return 0
|
||||
return conn.execute(
|
||||
"SELECT COUNT(*) FROM task_attempts").fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _completed_runner(cmd):
|
||||
"""Fake CLI: dispatch/run always succeed, host.py-style JSON."""
|
||||
if "dispatch" in cmd:
|
||||
@@ -466,7 +479,11 @@ class TestWorkerHook(unittest.TestCase):
|
||||
self.assertFalse(worker.attempt_ledger_hook)
|
||||
result = worker.run_one()
|
||||
self.assertEqual(result["status"], "completed")
|
||||
self.assertFalse(_table_exists(self.db_path, "task_attempts"))
|
||||
# 演练注:main 侧 init_db 已先建同名旧 DDL 的
|
||||
# task_attempts 表(见 attempt_ledger 并集表说明),
|
||||
# “表不存在”在合入后不再是可观测口径;保留的意图是
|
||||
# “关时不写行”,故此处断言零行而非表不存在。
|
||||
self.assertEqual(_task_attempts_row_count(self.db_path), 0)
|
||||
self.assertFalse(
|
||||
_table_exists(self.db_path, "task_attempt_origin_markers"))
|
||||
|
||||
@@ -559,7 +576,9 @@ class TestWorkerHook(unittest.TestCase):
|
||||
worker = self._worker(attempt_ledger_hook=True, env={})
|
||||
result = worker.run_one()
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertFalse(_table_exists(self.db_path, "task_attempts"))
|
||||
# 演练注:同上,门失败不调钩子、故零行;表本身由
|
||||
# main 侧 init_db 预建,不再断言表不存在。
|
||||
self.assertEqual(_task_attempts_row_count(self.db_path), 0)
|
||||
|
||||
def test_hook_on_cancelled_dispatch_writes_interrupted_row(self):
|
||||
# Established pattern (test_worker
|
||||
|
||||
@@ -288,7 +288,7 @@ class CoordGateLookupFailureTestCase(unittest.TestCase):
|
||||
if self._old is None:
|
||||
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
||||
else:
|
||||
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
|
||||
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_direct_lookup_raises_instead_of_empty_set(self):
|
||||
|
||||
@@ -245,7 +245,7 @@ class PersistedWhitespaceModelTestCase(unittest.TestCase):
|
||||
if self._old is None:
|
||||
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
||||
else:
|
||||
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
|
||||
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _policy(self):
|
||||
@@ -308,7 +308,7 @@ class ExplicitNonSub2apiGateTestCase(unittest.TestCase):
|
||||
if self._old is None:
|
||||
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
||||
else:
|
||||
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
|
||||
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _policy(self, mode, capabilities):
|
||||
@@ -446,7 +446,7 @@ class CoordGateAttributionTestCase(unittest.TestCase):
|
||||
if self._old is None:
|
||||
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
||||
else:
|
||||
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
|
||||
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _merge(self, child_error, audit_rows=()):
|
||||
@@ -555,7 +555,7 @@ class GateResultsCheckedTestCase(unittest.TestCase):
|
||||
if self._old is None:
|
||||
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
||||
else:
|
||||
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
|
||||
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_direct_call_checked_subset_and_legacy(self):
|
||||
|
||||
@@ -197,7 +197,7 @@ class _RunOneCase(unittest.TestCase):
|
||||
if self._old is None:
|
||||
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
||||
else:
|
||||
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
|
||||
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
|
||||
self.tmp.cleanup()
|
||||
|
||||
|
||||
@@ -272,7 +272,7 @@ class I1MarkerTestCase(unittest.TestCase):
|
||||
if self._old is None:
|
||||
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
||||
else:
|
||||
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
|
||||
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _merge(self, child_error, audit_rows=()):
|
||||
@@ -343,7 +343,7 @@ class _WireCase(unittest.TestCase):
|
||||
if self._old is None:
|
||||
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
||||
else:
|
||||
os.environ[CODEX_PUSH_ALLOW_PRIVATE_TARGETS] = self._old
|
||||
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _worker(self, **kwargs):
|
||||
|
||||
@@ -43,10 +43,25 @@ from pathlib import Path
|
||||
_PATTERN = re.compile(r"INSERT\s+INTO\s+audit_log", re.IGNORECASE)
|
||||
# Fixture files that deliberately insert audit_log rows into
|
||||
# throwaway test databases (see module docstring).
|
||||
# 演练注:合入时 main 侧 9 个审计测试文件同样只往临时库插
|
||||
# audit_log 行(TemporaryDirectory/control.db,逐文件核过),
|
||||
# 与已列 3 个属同类 fixture,按名补入——生产侧口径不变,
|
||||
# 仍只允许 cloud/auth.py。若用户要更严口径(测试也一律走
|
||||
# AuthStore.audit),那是另起一批改 9 个文件的活,本演练
|
||||
# 不悄悄替用户定(见演练报告 FLAG)。
|
||||
_FIXTURE_FILES = {
|
||||
"cloud/tests/test_tamper_ledger.py",
|
||||
"cloud/tests/test_chat_history.py",
|
||||
"cloud/tests/test_automation_run_due.py",
|
||||
"cloud/tests/test_95_code.py",
|
||||
"cloud/tests/test_audit_batch4.py",
|
||||
"cloud/tests/test_audit_batch5.py",
|
||||
"cloud/tests/test_audit_batch6.py",
|
||||
"cloud/tests/test_audit_fixes4.py",
|
||||
"cloud/tests/test_audit_fixes5.py",
|
||||
"cloud/tests/test_audit_loop_coord.py",
|
||||
"cloud/tests/test_audit_loop_coord_fix.py",
|
||||
"cloud/tests/test_routing_shadow.py",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""gapfill2 H3:_merge_task 成功审计段里 rollback 自身的 except: pass。
|
||||
|
||||
上一批唯一未覆盖分支:G7 新段里 ``conn.rollback()`` 本身抛错时的
|
||||
防御分支(基底行号 server.py:6615-6616)。rollback 从不失败,故
|
||||
全量覆盖率里该分支一直缺失。本文件只用注入式测试覆盖它,绝不
|
||||
改动 rollback 语义、绝不把该分支改成可观测行为:
|
||||
|
||||
- 注入一个 rollback 会抛错的连接代理(除 rollback 外全部透传
|
||||
真实连接),同时让审计失败(沿用 G7 的 RuntimeError 注入),
|
||||
使执行流必然走进 ``except Exception: pass``;
|
||||
- 断言:rollback 的异常被吞掉(原审计错误原样上抛,不被掩盖)、
|
||||
任务状态仍是 completed、审计 0 行、原样重试后成功 200。
|
||||
|
||||
真实模型 0 轮,不联网;不跑真实 HTTP(handler 直调 +
|
||||
真实 AuthStore/DB,审计行直接查库验证)。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import cloud.server as server_mod # noqa: E402
|
||||
from cloud.auth import AuthStore # noqa: E402
|
||||
from cloud.server import CloudControlPlaneHandler, init_db # noqa: E402
|
||||
|
||||
NO_BLOCK = {"merge_blocked": False, "release_blocked": False,
|
||||
"blocked_by": None, "round": None, "failed_checks": []}
|
||||
|
||||
HARD_BODY = {
|
||||
"files_changed": ["apps/web/a.py"],
|
||||
"owned_dir": "apps/web",
|
||||
"branch_protection": "protected",
|
||||
"ci_status": "success",
|
||||
"ci_checks": [{"name": "ci", "status": "success"}],
|
||||
"required_checks": ["ci"],
|
||||
"contract_version": "v2",
|
||||
"contract_frozen": True,
|
||||
"coordinator_approved": True,
|
||||
"unresolved_review_comments": 0,
|
||||
}
|
||||
|
||||
|
||||
class _RollbackFailingConn:
|
||||
"""代理真实连接:除 rollback 外全部透传,rollback 计数后抛错。
|
||||
|
||||
execute / commit / close / row_factory 等一律委托给真实连接,
|
||||
只有 rollback 被替换为“计数 + 抛错”,精确模拟“rollback 自身
|
||||
失败”这一防御分支的前提,不改动其它任何连接语义。
|
||||
"""
|
||||
|
||||
def __init__(self, real, calls):
|
||||
self._real = real
|
||||
self._calls = calls
|
||||
|
||||
def rollback(self):
|
||||
self._calls["n"] += 1
|
||||
raise RuntimeError("rollback down")
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._real, name)
|
||||
|
||||
|
||||
class RollbackFailureSwallowedTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
init_db(self.db_path)
|
||||
self.auth = AuthStore(self.db_path)
|
||||
self.owner_id, _key = self.auth.create_user("owner")
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO projects(id, name, owner_id, created_at,"
|
||||
" repo_url, auto_pr) VALUES(?, ?, ?, ?, ?, 0)",
|
||||
("p1", "P", self.owner_id, time.time(), None))
|
||||
conn.execute(
|
||||
"INSERT INTO tasks(id, project_id, prompt, status,"
|
||||
" created_by, created_at, updated_at)"
|
||||
" VALUES(?, ?, ?, ?, ?, ?, ?)",
|
||||
("t1", "p1", "do work", "completed", self.owner_id,
|
||||
time.time(), time.time()))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
self.auth.create_project("p1", self.owner_id)
|
||||
|
||||
def tearDown(self):
|
||||
try:
|
||||
self.auth.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _handler(self):
|
||||
h = CloudControlPlaneHandler.__new__(CloudControlPlaneHandler)
|
||||
h.server = SimpleNamespace(db_path=self.db_path)
|
||||
sent = []
|
||||
h._send_json = lambda status, obj, extra_headers=None: sent.append(
|
||||
(status, obj))
|
||||
h._read_json = lambda: (dict(HARD_BODY), None)
|
||||
h.sent = sent
|
||||
return h
|
||||
|
||||
def _merge(self, h):
|
||||
with mock.patch.object(server_mod, "_pushback_block",
|
||||
return_value=dict(NO_BLOCK)):
|
||||
h._merge_task(self.owner_id, "t1")
|
||||
|
||||
def _status(self):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
return conn.execute(
|
||||
"SELECT status FROM tasks WHERE id = 't1'").fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _merge_audits(self):
|
||||
return [a for a in self.auth.list_audit("p1")
|
||||
if a["action"] == "merge_task"]
|
||||
|
||||
def test_rollback_failure_is_swallowed(self):
|
||||
# 审计失败 + rollback 自身失败:rollback 的异常必须被吞掉,
|
||||
# 原审计错误原样上抛(不被 rollback 错误掩盖),状态回滚
|
||||
# 语义不变(completed、审计 0 行),原样重试后成功。
|
||||
h = self._handler()
|
||||
real_db = CloudControlPlaneHandler._db
|
||||
calls = {"n": 0}
|
||||
|
||||
def failing_db(handler_self):
|
||||
return _RollbackFailingConn(real_db(handler_self), calls)
|
||||
|
||||
with mock.patch.object(CloudControlPlaneHandler, "_db",
|
||||
failing_db):
|
||||
with mock.patch.object(
|
||||
server_mod, "_audit_on_conn",
|
||||
side_effect=RuntimeError("audit down")):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
self._merge(h)
|
||||
self.assertEqual(str(ctx.exception), "audit down")
|
||||
self.assertEqual(calls["n"], 1)
|
||||
self.assertEqual(h.sent, [])
|
||||
self.assertEqual(self._status(), "completed")
|
||||
self.assertEqual(self._merge_audits(), [])
|
||||
# 原样重试(审计恢复、连接恢复后)必须成功:状态仍是
|
||||
# completed,不是卡在 merged 导致重复合并 409。
|
||||
h2 = self._handler()
|
||||
self._merge(h2)
|
||||
self.assertEqual(h2.sent[-1][0], 200)
|
||||
self.assertEqual(self._status(), "merged")
|
||||
self.assertEqual(len(self._merge_audits()), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""gapfill2 H4:G5 调用方契约的文档钉死(只改文档、不改行为)。
|
||||
|
||||
本文件断言的全部是 docstring 文本,不是行为:上一批残留
|
||||
“可信 main 头的来源仍是调用方责任”在本批补进 scorecard
|
||||
相关模块与端点的 docstring 后,必须写清四点——
|
||||
current_main_head 由调用方提供、接受什么形式的值、缺失与
|
||||
空串按未传入处理且不做 stale 判定、传入后永远 200 且不改写
|
||||
overall;且绝不新增任何校验或拒绝行为(行为断言见既有
|
||||
test_gapfill_g5_baseline_snapshot,本文件不重复测行为)。
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import cloud.audit_scorecard as scorecard_mod # noqa: E402
|
||||
import cloud.scorecard_baseline_store as store_mod # noqa: E402
|
||||
from cloud.server import CloudControlPlaneHandler # noqa: E402
|
||||
|
||||
|
||||
def _flat(doc):
|
||||
"""docstring 空白归一化:去掉全部空白后按语义短语查,
|
||||
不依赖换行位置(中文短语常被换行切开)。"""
|
||||
return "".join((doc or "").split())
|
||||
|
||||
|
||||
class CallerContractDocsTestCase(unittest.TestCase):
|
||||
def test_endpoint_docstring_states_caller_contract(self):
|
||||
doc = _flat(CloudControlPlaneHandler._audit_scorecard.__doc__)
|
||||
self.assertIn("由调用方提供", doc)
|
||||
self.assertIn("接受任意字符串", doc)
|
||||
self.assertIn("不做任何格式校验", doc)
|
||||
self.assertIn("不要求形如commithash", doc)
|
||||
self.assertIn("缺失", doc)
|
||||
self.assertIn("空串一律按未传入处理", doc)
|
||||
self.assertIn("不附加baseline_staleness字段", doc)
|
||||
self.assertIn("永远返回200", doc)
|
||||
self.assertIn("绝不改写报告的overall/overall_pass", doc)
|
||||
self.assertIn("可信main头的来源仍是调用方责任", doc)
|
||||
|
||||
def test_check_baseline_stale_docstring_states_contract(self):
|
||||
doc = _flat(scorecard_mod.check_baseline_stale.__doc__)
|
||||
self.assertIn("接受任意字符串", doc)
|
||||
self.assertIn("不做任何格式校验", doc)
|
||||
self.assertIn("空串先归一化为None", doc)
|
||||
self.assertIn("根本不调用本函数", doc)
|
||||
self.assertIn("可信", doc)
|
||||
|
||||
def test_module_and_store_docstrings_state_contract(self):
|
||||
self.assertIn("接受任意字符串",
|
||||
_flat(scorecard_mod.__doc__))
|
||||
self.assertIn("不做格式校验", _flat(store_mod.__doc__))
|
||||
self.assertIn("可信来源仍是调用方责任",
|
||||
_flat(store_mod.__doc__))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,200 @@
|
||||
"""gapfill2 H2:全仓低于 90% 文件的扫尾(生产代码 0 改动)。
|
||||
|
||||
基线(本批自己跑的 CI 同款覆盖率,基底 5d0651e)低于 90% 的
|
||||
文件共 37 个。本文件补其中三个缺口最小、可纯函数 / 注入式
|
||||
真实驱动的文件,缺口大的文件(resident、sandbox、ledger、
|
||||
playwright_browser 等,缺口均在 50 行以上且多需 Docker / 真实
|
||||
外部服务)不在本批硬凑,见总结残留清单:
|
||||
|
||||
- cloud/approval_timeout.py:57 语句、缺失 8 行(41-42、46、
|
||||
48、62-63、65、109)→ 本文件全覆盖。
|
||||
- cloud/merge_gate_hard.py:89 语句、缺失 12 行(103、108、
|
||||
142-143、147、157-158、201-202、212-215)→ 本文件全覆盖。
|
||||
- cloud/worker_sweep.py:60 语句、缺失 8 行(54、82-83、
|
||||
117-118、131-132、174)→ 本文件覆盖除 174(``__main__``
|
||||
守卫行,import 无法覆盖)外的 7 行。
|
||||
|
||||
真实模型 0 轮,不联网;approval_timeout 与 merge_gate_hard
|
||||
均为纯函数直调,worker_sweep 用假 AuthStore / 假 reap 注入。
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import cloud.worker_sweep as sweep_mod # noqa: E402
|
||||
from cloud.approval_timeout import ( # noqa: E402
|
||||
approval_timeout_stats,
|
||||
recommend_approval_timeout,
|
||||
timeout_rate,
|
||||
)
|
||||
from cloud.merge_gate_hard import evaluate_hard_merge_gate # noqa: E402
|
||||
|
||||
|
||||
def _gate(**overrides):
|
||||
body = {
|
||||
"files_changed": ["apps/web/a.py"],
|
||||
"owned_dir": "apps/web",
|
||||
"protection": "protected",
|
||||
"ci_status": "success",
|
||||
"ci_checks": [{"name": "ci", "status": "success"}],
|
||||
"required_checks": ["ci"],
|
||||
"contract_version": "v2",
|
||||
"contract_frozen": True,
|
||||
"coordinator_approved": True,
|
||||
"unresolved_review_comments": 0,
|
||||
}
|
||||
body.update(overrides)
|
||||
return evaluate_hard_merge_gate(**body)
|
||||
|
||||
|
||||
class ApprovalTimeoutBranchTestCase(unittest.TestCase):
|
||||
def test_timeout_rate_non_numeric_none(self):
|
||||
# 41-42:计数无法转 int 时回 None,不是 0%。
|
||||
self.assertIsNone(timeout_rate("abc", 10))
|
||||
self.assertIsNone(timeout_rate(1, "xyz"))
|
||||
|
||||
def test_timeout_rate_clamps(self):
|
||||
# 46:expired 为负时按 0 计;48:expired 超过 decided
|
||||
# 时按 decided 计(比率封顶 1.0)。
|
||||
self.assertEqual(timeout_rate(-3, 10), 0.0)
|
||||
self.assertEqual(timeout_rate(15, 10), 1.0)
|
||||
|
||||
def test_recommend_current_fallbacks(self):
|
||||
# 62-63:current 非法时按默认值 600 计;65:current
|
||||
# <= 0 时同样按默认值计。
|
||||
self.assertEqual(
|
||||
recommend_approval_timeout(0, 10,
|
||||
current_seconds="bad")
|
||||
["current_seconds"], 600)
|
||||
self.assertEqual(
|
||||
recommend_approval_timeout(0, 10, current_seconds=0)
|
||||
["current_seconds"], 600)
|
||||
self.assertEqual(
|
||||
recommend_approval_timeout(0, 10, current_seconds=-5)
|
||||
["current_seconds"], 600)
|
||||
|
||||
def test_stats_empty_project_list(self):
|
||||
# 109:project_ids 为空列表时直接回零统计,不拼 IN 查询。
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
self.assertEqual(
|
||||
approval_timeout_stats(conn, []),
|
||||
{"decided_count": 0, "expired_count": 0,
|
||||
"pending_count": 0})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class MergeGateHardBranchTestCase(unittest.TestCase):
|
||||
def test_ci_check_entry_not_dict(self):
|
||||
# 103 + 147:ci_checks 条目不是 dict 时索引为 None、
|
||||
# 记 ci_check_failed 块。
|
||||
result = _gate(ci_checks=["not-a-dict"])
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertIn("ci_check_failed", result["blocks"])
|
||||
|
||||
def test_ci_check_entry_bad_name_status(self):
|
||||
# 108 + 147:条目 name 为空时索引为 None、记
|
||||
# ci_check_failed 块。
|
||||
result = _gate(ci_checks=[{"name": "", "status": "success"}])
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertIn("ci_check_failed", result["blocks"])
|
||||
|
||||
def test_ci_checks_not_list(self):
|
||||
# 142-143:ci_checks 非列表时记 ci_evidence_missing。
|
||||
result = _gate(ci_checks="ci")
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertIn("ci_evidence_missing", result["blocks"])
|
||||
|
||||
def test_required_checks_not_list(self):
|
||||
# 157-158:required_checks 非列表时记 ci_evidence_missing。
|
||||
result = _gate(required_checks="ci")
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertIn("ci_evidence_missing", result["blocks"])
|
||||
|
||||
def test_reviews_bool_is_missing_evidence(self):
|
||||
# 201-202:评审意见为 bool 时不是“0 条”,而是无证据。
|
||||
result = _gate(unresolved_review_comments=True)
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertIn("review_evidence_missing", result["blocks"])
|
||||
|
||||
def test_reviews_negative_and_other_type(self):
|
||||
# 212:负数评审意见记 review_evidence_missing;
|
||||
# 214-215:其它类型(字符串)同样记无证据。
|
||||
result = _gate(unresolved_review_comments=-1)
|
||||
self.assertIn("review_evidence_missing", result["blocks"])
|
||||
result = _gate(unresolved_review_comments="many")
|
||||
self.assertIn("review_evidence_missing", result["blocks"])
|
||||
|
||||
|
||||
class WorkerSweepBranchTestCase(unittest.TestCase):
|
||||
def _fake_store_cls(self, user_id, audit_raises=False,
|
||||
close_raises=False):
|
||||
class _FakeConn:
|
||||
def close(self):
|
||||
if close_raises:
|
||||
raise RuntimeError("close failed")
|
||||
|
||||
class _FakeStore:
|
||||
def __init__(self, db_path):
|
||||
self.conn = _FakeConn()
|
||||
|
||||
def authenticate(self, api_key):
|
||||
return user_id if api_key else None
|
||||
|
||||
def audit(self, *args, **kwargs):
|
||||
if audit_raises:
|
||||
raise RuntimeError("audit down")
|
||||
|
||||
return _FakeStore
|
||||
|
||||
def test_global_admin_ids_explicit(self):
|
||||
# 54:显式传入 admin_ids 时去空白、去空项。
|
||||
self.assertEqual(
|
||||
sweep_mod.global_admin_ids([" a ", "", "b", " a "]),
|
||||
{"a", "b"})
|
||||
|
||||
def test_denied_audit_failure_swallowed(self):
|
||||
# 82-83:非管理员被拒时,拒绝审计本身抛错也被吞掉,
|
||||
# PermissionError 照常抛出,绝不调 reap。
|
||||
fake = self._fake_store_cls("u1", audit_raises=True)
|
||||
with mock.patch.object(sweep_mod, "AuthStore", fake):
|
||||
with self.assertRaises(PermissionError):
|
||||
sweep_mod.sweep_stale_workers_admin(
|
||||
"/tmp/x.db", "k", admin_ids=["admin1"])
|
||||
|
||||
def test_sweep_audit_failure_marks_summary_false(self):
|
||||
# 117-118:汇总审计抛错时 audit_summary 置 False,
|
||||
# 调用仍正常返回(reap 结果不受影响)。
|
||||
fake = self._fake_store_cls("admin1", audit_raises=True)
|
||||
with mock.patch.object(sweep_mod, "AuthStore", fake):
|
||||
with mock.patch.object(
|
||||
sweep_mod, "reap_stale_workers",
|
||||
return_value=[]):
|
||||
summary = sweep_mod.sweep_stale_workers_admin(
|
||||
"/tmp/x.db", "k", admin_ids=["admin1"])
|
||||
self.assertFalse(summary["audit_summary"])
|
||||
self.assertEqual(summary["killed"], [])
|
||||
|
||||
def test_store_close_failure_swallowed(self):
|
||||
# 131-132:finally 里 store.conn.close() 抛错时被吞掉,
|
||||
# 汇总照常返回。
|
||||
fake = self._fake_store_cls("admin1", close_raises=True)
|
||||
with mock.patch.object(sweep_mod, "AuthStore", fake):
|
||||
with mock.patch.object(
|
||||
sweep_mod, "reap_stale_workers",
|
||||
return_value=[]):
|
||||
summary = sweep_mod.sweep_stale_workers_admin(
|
||||
"/tmp/x.db", "k", admin_ids=["admin1"])
|
||||
self.assertTrue(summary["audit_summary"])
|
||||
self.assertEqual(summary["user_id"], "admin1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,610 @@
|
||||
"""gapfill2 H1:worker.py 覆盖缺口补测试(生产代码 0 改动)。
|
||||
|
||||
基线(本批自己跑的 CI 同款覆盖率,基底 5d0651e):
|
||||
cloud/worker.py 3493 语句、277 缺失、92%。本文件只加真实断言
|
||||
测试,瞄准基线缺失行里可真实驱动的分支;每组测试头上注明
|
||||
它覆盖的基底行号。需要 Docker / 协调员 / run_one 主循环等重
|
||||
型 harness 的缺失行(run_one、_run_attempt、_docker_task_context、
|
||||
run_loop、run_all 等)不在本文件硬凑,见总结残留清单。
|
||||
|
||||
真实模型 0 轮,不联网;不启动真实 Docker / 子进程,凡需注入
|
||||
处均用假连接、假 runner、假 proc 或方法补丁,且断言均为行为
|
||||
断言(返回值、落盘行、调用记录),不是“跑过即过”。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import cloud.worker as worker_mod # noqa: E402
|
||||
from cloud.server import init_db # noqa: E402
|
||||
from cloud.worker import DockerExecutor, Worker # noqa: E402
|
||||
|
||||
|
||||
def _worker(db_path, **attrs):
|
||||
w = Worker.__new__(Worker)
|
||||
w.db_path = db_path
|
||||
w.tenant_isolation = False
|
||||
w.executor = SimpleNamespace()
|
||||
w.logger = logging.getLogger("test_gapfill2_worker")
|
||||
w._cancel_requested = set()
|
||||
w._running = {}
|
||||
w._running_lock = threading.Lock()
|
||||
w._loop_stop = False
|
||||
w.quota_poll_interval_seconds = 30
|
||||
w.process_factory = None
|
||||
w.repos_runner = None
|
||||
for key, value in attrs.items():
|
||||
setattr(w, key, value)
|
||||
return w
|
||||
|
||||
|
||||
def _insert_task(db_path, task_id="T1", status="running", goal="old goal",
|
||||
goal_version=0, applied=0):
|
||||
now = time.time()
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO tasks(id, project_id, prompt, status,"
|
||||
" idempotency_key, depends_on, priority, created_by,"
|
||||
" created_at, updated_at, last_heartbeat, goal, goal_mode,"
|
||||
" verify_command, goal_max_rounds, goal_version,"
|
||||
" applied_goal_version) VALUES(?, 'p1', ?, ?, NULL, '[]',"
|
||||
" 0, 'tester', ?, ?, NULL, ?, 1, NULL, 0, ?, ?)",
|
||||
(task_id, goal, status, now, now, goal, goal_version,
|
||||
applied))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _task_row(db_path, task_id="T1"):
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.row_factory = sqlite3.Row
|
||||
return dict(conn.execute(
|
||||
"SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone())
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _write_state(state_path, tasks):
|
||||
with open(state_path, "w", encoding="utf-8") as fh:
|
||||
json.dump({"tasks": tasks}, fh)
|
||||
|
||||
|
||||
class GoalSignalBranchTestCase(unittest.TestCase):
|
||||
"""goal 信号写侧 / 镜像分支(基底缺失 1294 / 1300 / 1306 /
|
||||
1313-1314 / 1339-1340 / 1381-1382 / 1397-1398)。"""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "test.db")
|
||||
init_db(self.db_path)
|
||||
self.state_path = os.path.join(self.tmp.name, "host-state.json")
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _signal_worker(self):
|
||||
w = _worker(self.db_path)
|
||||
w._host_state_path_for_task = lambda task: self.state_path
|
||||
return w
|
||||
|
||||
def test_bad_goal_rejected(self):
|
||||
# 1294:db_goal 为空或非字符串时返回 False,不写信号。
|
||||
w = self._signal_worker()
|
||||
task = {"id": "T1", "project_id": "p1"}
|
||||
self.assertFalse(w._write_goal_signal_if_newer(task, None, 2))
|
||||
self.assertFalse(w._write_goal_signal_if_newer(task, "", 2))
|
||||
self.assertFalse(w._write_goal_signal_if_newer(task, 123, 2))
|
||||
self.assertFalse(os.path.exists(
|
||||
os.path.join(self.tmp.name, "goal-updates")))
|
||||
|
||||
def test_no_state_path_returns_false(self):
|
||||
# 1300:解析不出 state 路径时返回 False,不猜路径。
|
||||
w = _worker(self.db_path)
|
||||
w._host_state_path_for_task = lambda task: None
|
||||
self.assertFalse(w._write_goal_signal_if_newer(
|
||||
{"id": "T1", "project_id": "p1"}, "new goal", 2))
|
||||
|
||||
def test_entry_not_dict_returns_false(self):
|
||||
# 1306:state 里本任务条目不是 dict 时返回 False。
|
||||
_write_state(self.state_path, {"T1": "not-a-dict"})
|
||||
w = self._signal_worker()
|
||||
self.assertFalse(w._write_goal_signal_if_newer(
|
||||
{"id": "T1", "project_id": "p1"}, "new goal", 2))
|
||||
|
||||
def test_corrupt_state_swallowed(self):
|
||||
# 1313-1314:state 文件损坏(json 解析失败)时异常被吞掉,
|
||||
# 返回 False,绝不炸掉调用方。
|
||||
with open(self.state_path, "w", encoding="utf-8") as fh:
|
||||
fh.write("{not json")
|
||||
w = self._signal_worker()
|
||||
self.assertFalse(w._write_goal_signal_if_newer(
|
||||
{"id": "T1", "project_id": "p1"}, "new goal", 2))
|
||||
|
||||
def test_write_signal_success_control(self):
|
||||
# 对照项(非缺失行):版本大于已应用版本时写信号成功,
|
||||
# 证明上面几组失败测试的 harness 本身能走通成功路径。
|
||||
_write_state(self.state_path, {
|
||||
"T1": {"applied_goal_version": 0, "goal_rounds_done": 1}})
|
||||
w = self._signal_worker()
|
||||
self.assertTrue(w._write_goal_signal_if_newer(
|
||||
{"id": "T1", "project_id": "p1"}, "new goal", 2))
|
||||
signal_dir = os.path.join(self.tmp.name, "goal-updates")
|
||||
self.assertTrue(os.path.isdir(signal_dir))
|
||||
self.assertEqual(len(os.listdir(signal_dir)), 1)
|
||||
|
||||
def test_sync_resume_missing_id_swallowed(self):
|
||||
# 1339-1340:续跑认领钩子读库时任务缺 id(KeyError)时
|
||||
# 异常被吞掉,返回 False,续跑本身不受影响。
|
||||
w = _worker(self.db_path)
|
||||
self.assertFalse(w._sync_resume_goal_signal({}))
|
||||
|
||||
def test_mirror_bad_applied_ignored(self):
|
||||
# 1381-1382:state 里 applied_goal_version 不是数字时,
|
||||
# 转换失败置 None、跳过回写,不改任务行、不炸镜像。
|
||||
_insert_task(self.db_path, applied=0)
|
||||
_write_state(self.state_path, {"T1": {
|
||||
"goal_rounds_done": 3, "status": "goal-running",
|
||||
"applied_goal_version": "not-a-number",
|
||||
"thread_id": "th-1"}})
|
||||
w = self._signal_worker()
|
||||
self.assertEqual(
|
||||
w._mirror_checkpoint_once({"id": "T1", "project_id": "p1"}), 3)
|
||||
self.assertEqual(_task_row(self.db_path)["applied_goal_version"],
|
||||
0)
|
||||
|
||||
def test_mirror_db_read_failure_skips_signal_only(self):
|
||||
# 1397-1398:镜像内读库(goal/goal_version)失败时只跳过
|
||||
# 反向信号,检查点镜像本身继续、返回轮次,不炸、不回 -1。
|
||||
_insert_task(self.db_path, applied=0)
|
||||
_write_state(self.state_path, {"T1": {
|
||||
"goal_rounds_done": 2, "status": "goal-running",
|
||||
"applied_goal_version": 0, "thread_id": "th-1"}})
|
||||
w = self._signal_worker()
|
||||
real_connect = worker_mod._db_connect
|
||||
|
||||
class _FailingGoalReadConn:
|
||||
def __init__(self, real):
|
||||
self._real = real
|
||||
|
||||
def execute(self, sql, params=()):
|
||||
if "SELECT goal, goal_version" in sql:
|
||||
raise sqlite3.OperationalError(
|
||||
"injected goal read failure")
|
||||
return self._real.execute(sql, params)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._real, name)
|
||||
|
||||
def failing_connect(db_path):
|
||||
return _FailingGoalReadConn(real_connect(db_path))
|
||||
|
||||
with mock.patch.object(worker_mod, "_db_connect",
|
||||
failing_connect):
|
||||
self.assertEqual(
|
||||
w._mirror_checkpoint_once(
|
||||
{"id": "T1", "project_id": "p1"}), 2)
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT round_no FROM task_checkpoints"
|
||||
" WHERE task_id = 'T1'").fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
self.assertEqual(row, (2,))
|
||||
|
||||
|
||||
class TenantEnvBranchTestCase(unittest.TestCase):
|
||||
"""租户目录 / 环境分支(基底缺失 3062 / 3091 / 3121-3122 /
|
||||
3148-3149 / 3156-3157 / 3181-3182 / 3196)。"""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _tenant_worker(self, **extra):
|
||||
w = _worker(os.path.join(self.tmp.name, "test.db"))
|
||||
w.tenant_isolation = True
|
||||
w.tenants_root = os.path.join(self.tmp.name, "tenants")
|
||||
w.tenant_template_home = os.path.join(self.tmp.name, "tmpl-home")
|
||||
w.tenant_template_repo = os.path.join(self.tmp.name, "tmpl-repo")
|
||||
w.seed_skills = False
|
||||
w.websearch_mcp = False
|
||||
w.imagegen_mcp = False
|
||||
w.playwright_mcp = False
|
||||
for key, value in extra.items():
|
||||
setattr(w, key, value)
|
||||
return w
|
||||
|
||||
def test_tenant_dirs_missing_test_repo_template(self):
|
||||
# 3062:test-repo 为空目录、模板不存在时直接抛错,
|
||||
# 不静默播种一个空 test-repo。
|
||||
os.makedirs(self._tenant_worker().tenant_template_home)
|
||||
with open(os.path.join(self.tmp.name, "tmpl-home",
|
||||
"config.toml"), "w") as fh:
|
||||
fh.write("[x]\n")
|
||||
w = self._tenant_worker()
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
w._tenant_dirs("p1")
|
||||
self.assertIn("tmpl-repo", str(ctx.exception))
|
||||
|
||||
def test_tenant_mounts_includes_repo_checkout(self):
|
||||
# 3091:repo_checkout 目录已存在时,挂载列表里追加
|
||||
# repos/<安全项目名> 挂载;不存在时不硬挂载。
|
||||
tmpl_home = os.path.join(self.tmp.name, "tmpl-home")
|
||||
tmpl_repo = os.path.join(self.tmp.name, "tmpl-repo")
|
||||
os.makedirs(tmpl_home)
|
||||
os.makedirs(tmpl_repo)
|
||||
with open(os.path.join(tmpl_home, "config.toml"), "w") as fh:
|
||||
fh.write("[x]\n")
|
||||
with open(os.path.join(tmpl_repo, "sample.py"), "w") as fh:
|
||||
fh.write("# sample\n")
|
||||
w = self._tenant_worker()
|
||||
dirs = w._tenant_dirs("p1")
|
||||
os.makedirs(dirs["repo_checkout"], exist_ok=True)
|
||||
mounts = w._tenant_mounts("p1")
|
||||
self.assertIn(
|
||||
"%s:/var/lib/codex-app-server/repos/p1:rw"
|
||||
% dirs["repo_checkout"], mounts)
|
||||
|
||||
def test_env_host_dir_tenant_docker(self):
|
||||
# 3121-3122:租户 + Docker 时环境宿主目录走
|
||||
# tenants_root/<项目>/envs/<env_id>。
|
||||
w = self._tenant_worker()
|
||||
w.executor = DockerExecutor.__new__(DockerExecutor)
|
||||
result = w._env_host_dir({"project_id": "p1", "id": "env1"})
|
||||
self.assertTrue(result.startswith(w.tenants_root))
|
||||
self.assertTrue(result.endswith(
|
||||
os.path.join("envs", "env1")))
|
||||
|
||||
def test_fetch_environment_db_failure_none(self):
|
||||
# 3148-3149:库连接失败时返回 None,不炸 worker。
|
||||
w = _worker(os.path.join(self.tmp.name, "no-such-dir",
|
||||
"test.db"))
|
||||
self.assertIsNone(w._fetch_environment("e1"))
|
||||
|
||||
def test_fetch_environment_narrow_schema_none_fields(self):
|
||||
# 3156-3157:老库 environments 表缺可选列时,_get 兜底
|
||||
# 回 None,不炸整行;必备列仍正常读出。
|
||||
db_path = os.path.join(self.tmp.name, "test.db")
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"CREATE TABLE environments(id TEXT, project_id TEXT,"
|
||||
" name TEXT, status TEXT)")
|
||||
conn.execute(
|
||||
"INSERT INTO environments VALUES('e1', 'p1', 'E',"
|
||||
" 'ready')")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
w = _worker(db_path)
|
||||
env = w._fetch_environment("e1")
|
||||
self.assertEqual(env["id"], "e1")
|
||||
self.assertEqual(env["project_id"], "p1")
|
||||
self.assertEqual(env["status"], "ready")
|
||||
self.assertIsNone(env["branch"])
|
||||
self.assertIsNone(env["repo_url"])
|
||||
|
||||
def test_fetch_environment_missing_required_column_none(self):
|
||||
# 3170-3171:environments 行缺必备列(project_id)时,
|
||||
# 外层兜底回 None,不炸 worker。
|
||||
db_path = os.path.join(self.tmp.name, "test.db")
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute("CREATE TABLE environments(id TEXT)")
|
||||
conn.execute("INSERT INTO environments VALUES('e1')")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
w = _worker(db_path)
|
||||
self.assertIsNone(w._fetch_environment("e1"))
|
||||
|
||||
def test_run_git_typeerror_fallback(self):
|
||||
# 3181-3182:repos_runner 单参调用抛 TypeError 时,
|
||||
# 回落到 (cmd, timeout=None) 双参调用。
|
||||
calls = []
|
||||
_unset = object()
|
||||
|
||||
def runner(*args, timeout=_unset):
|
||||
calls.append((args, timeout))
|
||||
if timeout is _unset:
|
||||
raise TypeError("single-arg not supported")
|
||||
return SimpleNamespace(returncode=0, stdout="ok",
|
||||
stderr="")
|
||||
|
||||
w = _worker(os.path.join(self.tmp.name, "test.db"))
|
||||
w.repos_runner = runner
|
||||
result = w._run_git(["git", "status"])
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertEqual(result.stdout, "ok")
|
||||
self.assertEqual(len(calls), 2)
|
||||
self.assertIs(calls[1][1], None)
|
||||
|
||||
def test_prepare_environment_no_repo_url(self):
|
||||
# 3196:环境无 repo_url 时直接返回错误文本,不建目录。
|
||||
w = _worker(os.path.join(self.tmp.name, "test.db"))
|
||||
self.assertEqual(w._prepare_environment({}),
|
||||
"environment has no repo_url")
|
||||
|
||||
|
||||
class DbFailureBranchTestCase(unittest.TestCase):
|
||||
"""查库失败即回落的分支(基底缺失 3333-3334 / 3339-3340 /
|
||||
3583-3584 / 3604-3605 / 3620-3621 / 3650-3651 / 3665-3666 /
|
||||
3694-3695 / 5343-5344 / 5798-5799)。"""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "test.db")
|
||||
init_db(self.db_path)
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _bad_db_worker(self):
|
||||
return _worker(os.path.join(self.tmp.name, "no-such-dir",
|
||||
"test.db"))
|
||||
|
||||
def test_project_repo_url_db_failure_none(self):
|
||||
# 3333-3334:projects 表查库失败时回 None,不炸派发。
|
||||
self.assertIsNone(self._bad_db_worker()._project_repo_url("p1"))
|
||||
|
||||
def test_project_repo_url_row_missing_key_none(self):
|
||||
# 3339-3340:行里缺 repo_url 键(非 Row 行)时回 None。
|
||||
class _Cursor:
|
||||
def fetchone(self):
|
||||
return {}
|
||||
|
||||
class _Conn:
|
||||
def execute(self, sql, params=()):
|
||||
return _Cursor()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
w = _worker(self.db_path)
|
||||
with mock.patch.object(worker_mod, "_db_connect",
|
||||
lambda path: _Conn()):
|
||||
self.assertIsNone(w._project_repo_url("p1"))
|
||||
|
||||
def test_claim_followup_db_connect_failure_none(self):
|
||||
# 3583-3584:followup 认领时库连接失败回 None。
|
||||
self.assertIsNone(
|
||||
self._bad_db_worker()._claim_next_followup("t1"))
|
||||
|
||||
def test_claim_followup_missing_table_none(self):
|
||||
# 3604-3605:task_followups 表不存在时回 None,不炸 run_one。
|
||||
fresh = os.path.join(self.tmp.name, "fresh.db")
|
||||
sqlite3.connect(fresh).close()
|
||||
w = _worker(fresh)
|
||||
self.assertIsNone(w._claim_next_followup("t1"))
|
||||
|
||||
def test_finish_followup_db_failure_silent(self):
|
||||
# 3620-3621:结束 followup 时库失败静默,不抛。
|
||||
self._bad_db_worker()._finish_followup("f1", "done")
|
||||
|
||||
def test_convert_to_goal_db_failure_false(self):
|
||||
# 3650-3651:转 goal 时库失败回 False,不悄悄改状态。
|
||||
self.assertFalse(
|
||||
self._bad_db_worker()._convert_running_task_to_goal(
|
||||
"t1", "new goal"))
|
||||
|
||||
def test_restore_followup_db_failure_silent(self):
|
||||
# 3665-3666:放回 followup 时库失败静默,不抛。
|
||||
self._bad_db_worker()._restore_followup("f1")
|
||||
|
||||
def test_queue_self_followup_db_failure_false(self):
|
||||
# 3694-3695:排自有 followup 时库失败回 False。
|
||||
w = self._bad_db_worker()
|
||||
self.assertFalse(w._queue_self_followup(
|
||||
{"id": "t1", "project_id": "p1"}, "body"))
|
||||
|
||||
def test_coord_child_rows_missing_key_skipped(self):
|
||||
# 5343-5344:子任务行缺列(非 Row 行)时跳过该行,
|
||||
# 不炸等齐判断。
|
||||
class _Cursor:
|
||||
def fetchone(self):
|
||||
return {"id": "c1"}
|
||||
|
||||
class _Conn:
|
||||
def execute(self, sql, params=()):
|
||||
return _Cursor()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
w = _worker(self.db_path)
|
||||
with mock.patch.object(worker_mod, "_db_connect",
|
||||
lambda path: _Conn()):
|
||||
self.assertEqual(w._coord_child_rows(["c1"]), {})
|
||||
|
||||
def test_record_ledger_attempt_outbox_failure_swallowed(self):
|
||||
# 5798-5799:台账写失败后 outbox 追加本身也失败时,
|
||||
# 仍只吞掉、返回 None,不影响任务状态与返回值。
|
||||
w = self._bad_db_worker()
|
||||
w.attempt_ledger_hook = True
|
||||
import cloud.ledger_outbox as outbox_mod # noqa: E402
|
||||
with mock.patch.object(
|
||||
outbox_mod, "append_failure",
|
||||
side_effect=RuntimeError("outbox down")):
|
||||
self.assertIsNone(
|
||||
w._record_ledger_attempt("t1", "fail", True))
|
||||
|
||||
|
||||
class ProcessWatchdogBranchTestCase(unittest.TestCase):
|
||||
"""进程 / 看门狗分支(基底缺失 2505-2506 / 2892-2893 /
|
||||
3853-3854 / 3874-3875 / 3881-3882)。"""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "test.db")
|
||||
init_db(self.db_path)
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_docker_cancel_watchdog_db_interrupted(self):
|
||||
# 2505-2506:看门狗查库发现任务已 interrupted 时杀容器。
|
||||
killed = []
|
||||
w = _worker(self.db_path)
|
||||
w.executor = SimpleNamespace(
|
||||
kill_container=lambda name: killed.append(name))
|
||||
w.get_task = lambda task_id: {"status": "interrupted"}
|
||||
stop_event = threading.Event()
|
||||
w._docker_cancel_watchdog("t1", "c1", stop_event)
|
||||
self.assertEqual(killed, ["c1"])
|
||||
|
||||
def test_execute_via_process_kill_failure_swallowed(self):
|
||||
# 2892-2893:communicate 超时后 proc.kill() 本身抛错时
|
||||
# 被吞掉,第二次 communicate 的结果仍正常返回 completed。
|
||||
calls = {"kill": 0}
|
||||
|
||||
class _FakeProc:
|
||||
pid = 999
|
||||
returncode = 0
|
||||
|
||||
def poll(self):
|
||||
return 0
|
||||
|
||||
def communicate(self, timeout=None):
|
||||
if timeout is not None:
|
||||
raise RuntimeError("communicate timeout")
|
||||
return ("out", "err")
|
||||
|
||||
def kill(self):
|
||||
calls["kill"] += 1
|
||||
raise RuntimeError("kill failed")
|
||||
|
||||
w = _worker(self.db_path)
|
||||
w.process_factory = lambda task: _FakeProc()
|
||||
with mock.patch.object(Worker, "_ensure_gateway",
|
||||
return_value=(None, None)):
|
||||
result = w._execute_via_process({"id": "t1"})
|
||||
self.assertEqual(calls["kill"], 1)
|
||||
self.assertEqual(result["status"], "completed")
|
||||
self.assertEqual(result["stdout"], "out")
|
||||
self.assertEqual(result["stderr"], "err")
|
||||
self.assertEqual(result["returncode"], 0)
|
||||
|
||||
def test_marker_auth_close_failure_swallowed(self):
|
||||
# 3853-3854:权限查完后 store.conn.close() 抛错时被吞掉,
|
||||
# 权限结论(True)不受影响。
|
||||
class _FakeConn:
|
||||
def close(self):
|
||||
raise RuntimeError("close failed")
|
||||
|
||||
class _FakeStore:
|
||||
def __init__(self, db_path):
|
||||
self.conn = _FakeConn()
|
||||
|
||||
def check_permission(self, creator, project_id, action):
|
||||
return True
|
||||
|
||||
w = _worker(self.db_path)
|
||||
with mock.patch.object(worker_mod, "AuthStore", _FakeStore):
|
||||
self.assertEqual(
|
||||
w._marker_auth({"created_by": "u1",
|
||||
"project_id": "p1"}, "read"),
|
||||
(True, ""))
|
||||
|
||||
def test_task_files_worktree_remote_host(self):
|
||||
# 3874-3875:worktree 在别机时直接拒绝,不指错目录。
|
||||
w = _worker(self.db_path)
|
||||
path, reason = w._task_files_worktree(
|
||||
{"executor": "local", "worktree_host": "other-host-xyz",
|
||||
"id": "t1"})
|
||||
self.assertIsNone(path)
|
||||
self.assertIn("other-host-xyz", reason)
|
||||
|
||||
def test_task_files_worktree_bad_project_falls_through(self):
|
||||
# 3881-3882:project_id 缺失致安全名解析失败时,
|
||||
# safe_project 置 None、走本地 worktree 分支,不炸。
|
||||
w = _worker(self.db_path)
|
||||
w.host_py_path = os.path.join(self.tmp.name, "host.py")
|
||||
path, reason = w._task_files_worktree(
|
||||
{"executor": "local", "project_id": None, "id": "t1"})
|
||||
self.assertIsNone(path)
|
||||
self.assertIn("没有本地 worktree", reason)
|
||||
|
||||
|
||||
class PollWaitBranchTestCase(unittest.TestCase):
|
||||
"""轮询 / 等待分支(基底缺失 3711-3712 / 3720-3721 /
|
||||
3728-3729 / 3747 / 3768-3769 / 3774-3775 / 3804-3805 / 3825)。"""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "test.db")
|
||||
init_db(self.db_path)
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _poll(self, timeout=10):
|
||||
return {"task_id": "T2", "interval_seconds": 1,
|
||||
"timeout_seconds": timeout, "chars": 100}
|
||||
|
||||
def test_poll_cancelled_before_snapshot(self):
|
||||
# 3711-3712 + 3747:父任务已取消时不等快照、直接回
|
||||
# “读取失败”,不挂起。
|
||||
w = _worker(self.db_path)
|
||||
w._cancel_requested = {"P1"}
|
||||
result = w._poll_task_snapshot(
|
||||
{"id": "P1", "project_id": "p1"}, self._poll())
|
||||
self.assertEqual(result, "轮询任务 T2:读取失败。")
|
||||
|
||||
def test_poll_db_failure_returns_missing(self):
|
||||
# 3720-3721 + 3728-3729:心跳与快照查库都失败时,
|
||||
# 异常被吞掉、回“不存在”,不炸等待循环。
|
||||
w = _worker(os.path.join(self.tmp.name, "no-such-dir",
|
||||
"test.db"))
|
||||
w.get_task = lambda task_id: None
|
||||
result = w._poll_task_snapshot(
|
||||
{"id": "P1", "project_id": "p1"}, self._poll())
|
||||
self.assertEqual(result, "轮询任务 T2:不存在(同项目内也查不到)。")
|
||||
|
||||
def test_wait_cancelled(self):
|
||||
# 3768-3769 + 3825 + 3831:父任务已取消时等待中止,
|
||||
# 未读到的目标记“读取失败”,末尾附取消说明。
|
||||
w = _worker(self.db_path)
|
||||
w._cancel_requested = {"P1"}
|
||||
lines = w._wait_task_targets(
|
||||
{"id": "P1", "project_id": "p1"},
|
||||
{"task_ids": ["T2"], "interval_seconds": 1,
|
||||
"timeout_seconds": 10, "chars": 100})
|
||||
self.assertEqual(lines[0], "[等待 1 个任务]")
|
||||
self.assertIn("等待任务 T2:读取失败。", lines)
|
||||
self.assertIn("说明:等待期间父任务被取消,等待已中止。", lines)
|
||||
|
||||
def test_wait_db_failure_silent(self):
|
||||
# 3774-3775 + 3804-3805:心跳与批量快照查库都失败时,
|
||||
# 异常被吞掉、超时 0 立即收尾,不炸、不挂起。
|
||||
w = _worker(os.path.join(self.tmp.name, "no-such-dir",
|
||||
"test.db"))
|
||||
w.get_task = lambda task_id: None
|
||||
lines = w._wait_task_targets(
|
||||
{"id": "P1", "project_id": "p1"},
|
||||
{"task_ids": ["T2"], "interval_seconds": 1,
|
||||
"timeout_seconds": 0, "chars": 100})
|
||||
self.assertEqual(lines[0], "[等待 1 个任务]")
|
||||
self.assertIn("等待任务 T2:读取失败。", lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -504,6 +504,33 @@ class HostLoopTestCase(unittest.TestCase):
|
||||
self.assertIn("new goal", prompts[0])
|
||||
self.assertIn("旧目标", prompts[0])
|
||||
|
||||
def test_first_turn_both_pending_note_before_notice(self):
|
||||
# FLAG-3 定序(v2 演练决定):首轮断点备注与改 goal 通知
|
||||
# 同 pending 时,模型可见顺序为备注在前、通知在后。
|
||||
# 注意代码里两次 prepend 的执行顺序与可见顺序相反
|
||||
# (先贴通知、再贴备注),本测试钉住的是可见顺序,
|
||||
# 而非执行顺序。
|
||||
task = self._task()
|
||||
self._write_signal(1, "new goal")
|
||||
prompts = []
|
||||
script = [("completed", "目标已达成", None)]
|
||||
fake = _fake_turn_script(prompts, script)
|
||||
note = ("[审计推回包(第 1 轮)]\n"
|
||||
"- [execution_failed] 证据:boom detail")
|
||||
with mock.patch.object(host_mod, "run_task_turn", fake):
|
||||
result = host_mod.run_goal_loop(
|
||||
SimpleNamespace(), self.state, self.state_path, task,
|
||||
"old goal", None, 0, None, None, None, cfg=None,
|
||||
resume_note=note)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["rounds_done"], 1)
|
||||
self.assertEqual(len(prompts), 1)
|
||||
first = prompts[0]
|
||||
self.assertTrue(first.startswith(note))
|
||||
self.assertLess(first.index(note), first.index("目标已更新"))
|
||||
self.assertIn("new goal", first)
|
||||
self.assertIn("作废", first)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -144,7 +144,11 @@ class TestReexports(unittest.TestCase):
|
||||
|
||||
|
||||
class TestMethodAccounting(unittest.TestCase):
|
||||
def test_total_244_no_duplicates(self):
|
||||
# 演练注:合入后总数 244 -> 247、server.py 自有 150 -> 153,
|
||||
# 增量全是 main 侧方法(_routing_shadow、_resolve_create_
|
||||
# agent_definition、_validate_create_audit_loop),源线方法
|
||||
# 零丢失、mixin 侧零增减;此为源线 34b9a34 同款 re-pin。
|
||||
def test_total_247_no_duplicates(self):
|
||||
seen = {}
|
||||
dups = []
|
||||
classes = [CloudControlPlaneHandler]
|
||||
@@ -159,9 +163,9 @@ class TestMethodAccounting(unittest.TestCase):
|
||||
dups.append((seen[name], cls.__name__, name))
|
||||
seen[name] = cls.__name__
|
||||
self.assertEqual(dups, [])
|
||||
self.assertEqual(total, 244)
|
||||
self.assertEqual(total, 247)
|
||||
self.assertEqual(len(_own_methods(CloudControlPlaneHandler)),
|
||||
244 - 94)
|
||||
247 - 94)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -48,11 +48,14 @@ EXPECTED_METHODS = {
|
||||
("server.py", "CloudControlPlaneHandler._dispatch_task_route"),
|
||||
("server.py", "CloudControlPlaneHandler._dispatch"),
|
||||
("server.py", "CloudControlPlaneHandler._dispatch_project"),
|
||||
# 演练注:main 侧 _routing_shadow(233 行)为第 18 个长方法;
|
||||
# 另 2 个 main 侧新增方法均 <=100 行,不进清单。
|
||||
("server.py", "CloudControlPlaneHandler._routing_shadow"),
|
||||
}
|
||||
|
||||
|
||||
class LongFuncInventoryTestCase(unittest.TestCase):
|
||||
def test_seventeen_long_methods_including_full_auto_execute(self):
|
||||
def test_eighteen_long_methods_including_full_auto_execute(self):
|
||||
cloud_dir = Path(__file__).resolve().parents[1]
|
||||
methods = set()
|
||||
module_funcs = set()
|
||||
@@ -75,7 +78,7 @@ class LongFuncInventoryTestCase(unittest.TestCase):
|
||||
lines = node.end_lineno - node.lineno + 1
|
||||
if lines > 100:
|
||||
module_funcs.add((path.name, node.name))
|
||||
self.assertEqual(len(EXPECTED_METHODS), 17)
|
||||
self.assertEqual(len(EXPECTED_METHODS), 18)
|
||||
self.assertIn(
|
||||
("handler_fullauto.py", "FullAutoMixin._full_auto_execute"),
|
||||
EXPECTED_METHODS)
|
||||
|
||||
@@ -250,6 +250,25 @@ class AdapterFakeDriverTestCase(unittest.TestCase):
|
||||
self.assertIn("%s", fake.conn.cursor_obj.executed[0][0])
|
||||
self.assertNotIn("?", fake.conn.cursor_obj.executed[0][0])
|
||||
|
||||
def test_pragma_info_notnull_flag_matches_sqlite(self):
|
||||
# PG 实证(2026-11-06)抓到的真 bug:适配层把
|
||||
# information_schema.is_nullable 翻反了(NO -> 0),
|
||||
# 与 sqlite PRAGMA table_info 的 notnull 语义相反。
|
||||
# 此前无调用方读 row[3],故只潜伏未炸;ensure 的
|
||||
# outcome 放宽依赖该标志为真,此处钉住。
|
||||
rows = [(1, "task_id", "text", "NO", None, 1),
|
||||
(2, "outcome", "text", "YES", None, 0)]
|
||||
fake = _FakeDriver(_FakeCursor(rows=rows))
|
||||
db = _make_db(fake)
|
||||
conn = db.connect()
|
||||
info = conn.execute(
|
||||
"PRAGMA table_info(task_attempts)").fetchall()
|
||||
by_name = {row[1]: row for row in info}
|
||||
self.assertEqual(by_name["task_id"][3], 1)
|
||||
self.assertEqual(by_name["outcome"][3], 0)
|
||||
self.assertEqual([row[1] for row in info],
|
||||
["task_id", "outcome"])
|
||||
|
||||
def test_unique_violation_maps_to_integrity_error(self):
|
||||
import sqlite3
|
||||
fake = _FakeDriver(_FakeCursor(fail_sequence=[_FakeUnique("dup")]))
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Unified task_attempts schema (FLAG-1, user decision 2026-11-05).
|
||||
|
||||
Pins the single-definition rule and the three-shape migration:
|
||||
|
||||
- one DDL: attempt_ledger.TASK_ATTEMPTS_DDL is the same string
|
||||
used by cloud.attempts.CREATE_TABLE_SQL and by the
|
||||
task_attempts entry of cloud.db.POSTGRES_SCHEMA;
|
||||
- fresh database: ensure builds exactly the 12 unified columns;
|
||||
- old resume shape, gap-3 ledger shape, and the rehearsal-era
|
||||
12-column shape all migrate in place with rows intact, and
|
||||
both writers (resume + ledger) work on the migrated table;
|
||||
- aggregation counts ledger rows only: a resume row (outcome
|
||||
NULL) never becomes a counted, defect-free attempt.
|
||||
|
||||
SQLite only, run live. The Postgres upgrade path is NOT proven
|
||||
here (adapter-level DDL text check only, same as the other
|
||||
ledger tests); the batch summary states that explicitly.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(
|
||||
os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from cloud.attempt_ledger import ( # noqa: E402
|
||||
PG_DDL,
|
||||
SQLITE_DDL,
|
||||
TASK_ATTEMPTS_DDL,
|
||||
UNIFIED_COLUMNS,
|
||||
_column_notnull,
|
||||
aggregate_attempts,
|
||||
ensure_task_attempts_table,
|
||||
read_attempt_rows,
|
||||
record_attempt as ledger_record,
|
||||
record_correction as ledger_correct,
|
||||
)
|
||||
from cloud.attempts import ( # noqa: E402
|
||||
CREATE_TABLE_SQL,
|
||||
ensure_attempts_table,
|
||||
latest_attempt,
|
||||
record_attempt as resume_record,
|
||||
)
|
||||
from cloud.db import POSTGRES_SCHEMA # noqa: E402
|
||||
from cloud.db import _translate_sql # noqa: E402
|
||||
|
||||
OLD_DDL = (
|
||||
"CREATE TABLE task_attempts("
|
||||
"task_id TEXT, attempt_no INTEGER, kind TEXT, "
|
||||
"interrupted_reason TEXT, checkpoint_round INTEGER, "
|
||||
"result_text TEXT, error_text TEXT, "
|
||||
"created_at DOUBLE PRECISION, "
|
||||
"PRIMARY KEY(task_id, attempt_no))")
|
||||
|
||||
GAP3_DDL = (
|
||||
"CREATE TABLE task_attempts("
|
||||
"task_id TEXT NOT NULL, attempt_no INTEGER NOT NULL, "
|
||||
"outcome TEXT NOT NULL, is_defect INTEGER NOT NULL DEFAULT 0, "
|
||||
"defect_category TEXT, created_at DOUBLE PRECISION, "
|
||||
"PRIMARY KEY (task_id, attempt_no))")
|
||||
|
||||
# Rehearsal-era shape: same 12 column names as unified, but the
|
||||
# looser rehearsal types (outcome nullable, is_defect without
|
||||
# NOT NULL, PK columns without NOT NULL spelling).
|
||||
REHEARSAL_DDL = (
|
||||
"CREATE TABLE task_attempts("
|
||||
"task_id TEXT, attempt_no INTEGER, outcome TEXT, "
|
||||
"is_defect INTEGER DEFAULT 0, defect_category TEXT, "
|
||||
"corrects_attempt_no INTEGER, kind TEXT, "
|
||||
"interrupted_reason TEXT, checkpoint_round INTEGER, "
|
||||
"result_text TEXT, error_text TEXT, "
|
||||
"created_at DOUBLE PRECISION, "
|
||||
"PRIMARY KEY(task_id, attempt_no))")
|
||||
|
||||
|
||||
def _columns(conn):
|
||||
return [row[1] for row in conn.execute(
|
||||
"PRAGMA table_info(task_attempts)").fetchall()]
|
||||
|
||||
|
||||
def _row_by_key(conn, task_id, attempt_no):
|
||||
row = conn.execute(
|
||||
"SELECT * FROM task_attempts WHERE task_id = ? "
|
||||
"AND attempt_no = ?", (task_id, attempt_no)).fetchone()
|
||||
cols = [d[0] for d in conn.execute(
|
||||
"SELECT * FROM task_attempts LIMIT 0").description]
|
||||
return dict(zip(cols, row)) if row is not None else None
|
||||
|
||||
|
||||
class TestSingleDefinition(unittest.TestCase):
|
||||
def test_one_ddl_string_everywhere(self):
|
||||
self.assertEqual(SQLITE_DDL, TASK_ATTEMPTS_DDL)
|
||||
self.assertEqual(PG_DDL, TASK_ATTEMPTS_DDL)
|
||||
self.assertEqual(CREATE_TABLE_SQL, TASK_ATTEMPTS_DDL)
|
||||
schema_statements = [s for s in POSTGRES_SCHEMA
|
||||
if "task_attempts" in s]
|
||||
self.assertEqual(len(schema_statements), 1)
|
||||
self.assertEqual(schema_statements[0], TASK_ATTEMPTS_DDL)
|
||||
|
||||
def test_unified_column_set(self):
|
||||
self.assertEqual(tuple(UNIFIED_COLUMNS), (
|
||||
"task_id", "attempt_no", "outcome", "is_defect",
|
||||
"defect_category", "corrects_attempt_no", "kind",
|
||||
"interrupted_reason", "checkpoint_round", "result_text",
|
||||
"error_text", "created_at"))
|
||||
|
||||
def test_pg_ddl_text_is_static_only(self):
|
||||
translated, _kind = _translate_sql(PG_DDL)
|
||||
for col in UNIFIED_COLUMNS:
|
||||
self.assertIn(col, translated)
|
||||
self.assertIn("PRIMARY KEY (task_id, attempt_no)", translated)
|
||||
self.assertNotIn("AUTOINCREMENT", translated)
|
||||
self.assertNotIn("GENERATED", translated)
|
||||
|
||||
|
||||
class TestFreshAndMigrations(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.conn = sqlite3.connect(":memory:")
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
|
||||
def tearDown(self):
|
||||
self.conn.close()
|
||||
|
||||
def test_fresh_build_has_exact_unified_columns(self):
|
||||
ensure_task_attempts_table(self.conn)
|
||||
self.assertEqual(_columns(self.conn), list(UNIFIED_COLUMNS))
|
||||
# Idempotent, rows preserved.
|
||||
ledger_record(self.conn, "t-1", "success")
|
||||
ensure_task_attempts_table(self.conn)
|
||||
ensure_attempts_table(self.conn)
|
||||
self.assertEqual(_columns(self.conn), list(UNIFIED_COLUMNS))
|
||||
self.assertEqual(self.conn.execute(
|
||||
"SELECT COUNT(*) FROM task_attempts").fetchone()[0], 1)
|
||||
|
||||
def test_column_notnull_probe(self):
|
||||
# PG 实证(2026-11-06):ensure 的 outcome 放宽依赖
|
||||
# PRAGMA row[3] 的 notnull 语义;SQLite 侧钉住探针本身。
|
||||
ensure_task_attempts_table(self.conn)
|
||||
flags = _column_notnull(self.conn, "task_attempts")
|
||||
self.assertFalse(flags["outcome"])
|
||||
self.assertTrue(flags["task_id"])
|
||||
self.assertTrue(flags["attempt_no"])
|
||||
self.assertTrue(flags["is_defect"])
|
||||
|
||||
def test_column_notnull_probe_sees_gap3_outcome(self):
|
||||
# gap-3 形态的 outcome 是 NOT NULL;探针必须报 True,
|
||||
# 否则 PG 上的放宽不会触发,恢复写路仍会炸。
|
||||
self.conn.execute(GAP3_DDL)
|
||||
flags = _column_notnull(self.conn, "task_attempts")
|
||||
self.assertTrue(flags["outcome"])
|
||||
# 迁移本身不抛错、列集仍齐;真正的不变量是 ensure
|
||||
# 之后恢复写路与实际的 schema 状态一致,而不是
|
||||
# outcome 的 NOT NULL 位:outcome 放宽了(PG 上
|
||||
# ALTER 生效;SQLite 3.53.0 起也支持 ALTER COLUMN
|
||||
# DROP NOT NULL),resume 风格写行必须能写入并读回
|
||||
# outcome 为 NULL 的行;outcome 仍 NOT NULL 时(更
|
||||
# 早的 SQLite:ALTER 不合法、被 try/except 吞掉),
|
||||
# 该行会被 INSERT OR IGNORE 静默丢弃——版本相关
|
||||
# 边界,不是迁移失败。
|
||||
ensure_task_attempts_table(self.conn)
|
||||
self.assertEqual(set(_columns(self.conn)),
|
||||
set(UNIFIED_COLUMNS))
|
||||
relaxed = not _column_notnull(
|
||||
self.conn, "task_attempts")["outcome"]
|
||||
resume_record(self.conn, "t-gap", 2, "platform_fresh",
|
||||
"worker_shutdown", -1, "second", None)
|
||||
self.conn.commit()
|
||||
row = _row_by_key(self.conn, "t-gap", 2)
|
||||
if relaxed:
|
||||
self.assertIsNotNone(row)
|
||||
self.assertIsNone(row["outcome"])
|
||||
latest = latest_attempt(self.conn, "t-gap")
|
||||
self.assertEqual(latest["attempt_no"], 2)
|
||||
else:
|
||||
self.assertIsNone(row)
|
||||
|
||||
def test_old_shape_migrates_with_rows_intact(self):
|
||||
self.conn.execute(OLD_DDL)
|
||||
self.conn.execute(
|
||||
"INSERT INTO task_attempts(task_id, attempt_no, kind, "
|
||||
"interrupted_reason, checkpoint_round, result_text, "
|
||||
"error_text, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
("t-old", 1, "platform_fresh", "worker_shutdown", -1,
|
||||
"partial output", "killed", 1.0))
|
||||
self.conn.commit()
|
||||
before = _row_by_key(self.conn, "t-old", 1)
|
||||
ensure_task_attempts_table(self.conn)
|
||||
# Migrated tables keep ALTER-append order, not fresh DDL
|
||||
# order; every read path is by column name, so the set
|
||||
# is the invariant, not the order (fresh tables pin order).
|
||||
self.assertEqual(set(_columns(self.conn)),
|
||||
set(UNIFIED_COLUMNS))
|
||||
after = _row_by_key(self.conn, "t-old", 1)
|
||||
for col in ("task_id", "attempt_no", "kind",
|
||||
"interrupted_reason", "checkpoint_round",
|
||||
"result_text", "error_text", "created_at"):
|
||||
self.assertEqual(after[col], before[col], col)
|
||||
self.assertIsNone(after["outcome"])
|
||||
# Both writers now work on the migrated table.
|
||||
resume_record(self.conn, "t-old", 2, "platform_fresh",
|
||||
"worker_shutdown", -1, "second", None)
|
||||
ledger_record(self.conn, "t-led", "failure", is_defect=True,
|
||||
defect_category="contract")
|
||||
self.conn.commit()
|
||||
latest = latest_attempt(self.conn, "t-old")
|
||||
self.assertEqual(latest["attempt_no"], 2)
|
||||
self.assertEqual(latest["kind"], "platform_fresh")
|
||||
# Aggregation counts the ledger row only, never the
|
||||
# resume rows (outcome NULL).
|
||||
agg = aggregate_attempts(read_attempt_rows(self.conn))
|
||||
self.assertEqual(agg, {"t-led": {
|
||||
"attempts": 1, "defects": 1,
|
||||
"defect_category": "contract"}})
|
||||
|
||||
def test_gap3_shape_migrates_and_corrections_work(self):
|
||||
self.conn.execute(GAP3_DDL)
|
||||
self.conn.execute(
|
||||
"INSERT INTO task_attempts(task_id, attempt_no, outcome, "
|
||||
"is_defect, defect_category, created_at) "
|
||||
"VALUES('t-gap', 1, 'success', 0, NULL, 1.0)")
|
||||
self.conn.commit()
|
||||
ensure_task_attempts_table(self.conn)
|
||||
self.assertEqual(set(_columns(self.conn)),
|
||||
set(UNIFIED_COLUMNS))
|
||||
row = _row_by_key(self.conn, "t-gap", 1)
|
||||
self.assertEqual(row["outcome"], "success")
|
||||
ledger_correct(self.conn, "t-gap", 1, is_defect=True,
|
||||
defect_category="contract")
|
||||
agg = aggregate_attempts(read_attempt_rows(self.conn))
|
||||
self.assertEqual(agg["t-gap"]["defects"], 1)
|
||||
|
||||
def test_rehearsal_shape_migrates_with_rows_intact(self):
|
||||
self.conn.execute(REHEARSAL_DDL)
|
||||
self.conn.execute(
|
||||
"INSERT INTO task_attempts(task_id, attempt_no, outcome, "
|
||||
"is_defect, defect_category, corrects_attempt_no, kind, "
|
||||
"interrupted_reason, checkpoint_round, result_text, "
|
||||
"error_text, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, "
|
||||
"?, ?, ?, ?)",
|
||||
("t-reh", 1, "failure", 1, "contract", None,
|
||||
"platform_fresh", "worker_shutdown", -1, "out", "err",
|
||||
2.0))
|
||||
self.conn.commit()
|
||||
before = _row_by_key(self.conn, "t-reh", 1)
|
||||
ensure_task_attempts_table(self.conn)
|
||||
self.assertEqual(_columns(self.conn), list(UNIFIED_COLUMNS))
|
||||
after = _row_by_key(self.conn, "t-reh", 1)
|
||||
self.assertEqual(after, before)
|
||||
# Both writers still work after the migration.
|
||||
resume_record(self.conn, "t-reh", 2, "goal_breakpoint",
|
||||
"paused", 3, "r", "e")
|
||||
ledger_record(self.conn, "t-reh2", "success")
|
||||
agg = aggregate_attempts(read_attempt_rows(self.conn))
|
||||
self.assertEqual(agg["t-reh"]["defects"], 1)
|
||||
self.assertEqual(agg["t-reh2"]["attempts"], 1)
|
||||
|
||||
def test_resume_rows_never_count_in_aggregation(self):
|
||||
# Pure aggregation: a NULL-outcome row is a resume row.
|
||||
agg = aggregate_attempts([
|
||||
{"task_id": "t-1", "attempt_no": 1, "outcome": None,
|
||||
"is_defect": 0, "defect_category": None,
|
||||
"corrects_attempt_no": None},
|
||||
{"task_id": "t-1", "attempt_no": 2, "outcome": "success",
|
||||
"is_defect": 0, "defect_category": None,
|
||||
"corrects_attempt_no": None},
|
||||
])
|
||||
self.assertEqual(agg["t-1"]["attempts"], 1)
|
||||
agg_only_resume = aggregate_attempts([
|
||||
{"task_id": "t-2", "attempt_no": 1, "outcome": None,
|
||||
"is_defect": 0, "defect_category": None,
|
||||
"corrects_attempt_no": None},
|
||||
])
|
||||
self.assertNotIn("t-2", agg_only_resume)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -223,9 +223,14 @@ class LedgerOnboardingGapTestCase(unittest.TestCase):
|
||||
finally:
|
||||
conn.close()
|
||||
self.assertFalse(out["tables_ready"])
|
||||
# Unified schema: the required set is all 12 columns, so a
|
||||
# gap-3-shaped table is missing corrects_attempt_no AND
|
||||
# the five resume columns, not just the one column.
|
||||
self.assertEqual(
|
||||
out["missing"],
|
||||
["task_attempts missing columns: corrects_attempt_no"])
|
||||
["task_attempts missing columns: corrects_attempt_no, "
|
||||
"kind, interrupted_reason, checkpoint_round, "
|
||||
"result_text, error_text"])
|
||||
|
||||
def test_verify_marker_missing_column(self):
|
||||
conn = self._sqlite(
|
||||
@@ -237,9 +242,15 @@ class LedgerOnboardingGapTestCase(unittest.TestCase):
|
||||
finally:
|
||||
conn.close()
|
||||
self.assertFalse(out["tables_ready"])
|
||||
# Unified schema: the gap-3-shaped ledger table is also
|
||||
# missing the five resume columns, reported before the
|
||||
# marker table's own missing column.
|
||||
self.assertEqual(
|
||||
out["missing"],
|
||||
["task_attempt_origin_markers missing columns: queued_at"])
|
||||
["task_attempts missing columns: kind, "
|
||||
"interrupted_reason, checkpoint_round, result_text, "
|
||||
"error_text",
|
||||
"task_attempt_origin_markers missing columns: queued_at"])
|
||||
|
||||
|
||||
class LedgerOutboxGapTestCase(unittest.TestCase):
|
||||
|
||||
@@ -374,6 +374,18 @@ class GatewayTestCase(_Base):
|
||||
# 2564-2565
|
||||
class _BadEnv:
|
||||
def get(self, *a, **k):
|
||||
# 演练注:main 侧 Worker.__init__ 新增路由策略
|
||||
# 环境读取(CLOUD_ROUTING_POLICY_FILE/JSON,
|
||||
# 未包在 try 内);假 env 若全键抛错,构造即
|
||||
# 炸、测不到 key_available 本身。故仅对路由
|
||||
# 策略键回 None(= 未配策略),其余键仍抛错,
|
||||
# key_available 的失败回 False 断言意图不变。
|
||||
# 另:__init__ 两处裸 env.get 是否应包 try,
|
||||
# 属 main 侧跟进项,本演练不改生产 __init__。
|
||||
key = a[0] if a else k.get("key")
|
||||
if key in ("CLOUD_ROUTING_POLICY_FILE",
|
||||
"CLOUD_ROUTING_POLICY_JSON"):
|
||||
return None
|
||||
raise RuntimeError("env down")
|
||||
|
||||
w = Worker(self.db_path, "/fake/host.py", env=_BadEnv())
|
||||
|
||||
@@ -36,6 +36,12 @@ daemon_auto_start = false
|
||||
|
||||
# Skills / 插件:放 ~/.codex/skills 或用 `codex plugin` 管理,
|
||||
# app-server 会在 thread 里按需加载,不用在代码里硬编码。
|
||||
# 想停用某个技能又不想删文件时,用 [[skills.config]](只写一条
|
||||
# 选择器:path 匹配单个 SKILL.md,name 匹配同名全部技能;多条
|
||||
# 匹配时最后一条胜出):
|
||||
# [[skills.config]]
|
||||
# name = "software-engineering"
|
||||
# enabled = false
|
||||
|
||||
# 项目指令:每个仓库根放 AGENTS.md,thread 启动时自动合并读取,
|
||||
# 依次是 ~/.codex/AGENTS.md、仓库根、当前子目录,越往下优先级越高。
|
||||
@@ -0,0 +1,130 @@
|
||||
# 上游对齐:openai/codex 稳定版 0.166.0(核查日 2026-11-05)
|
||||
|
||||
结论先说:**已用 0.166.0 官方二进制重导 schema 并验证通过;本批只改
|
||||
宿主侧 pin、文档、schema 与映射测试,不动任何生产二进制。**
|
||||
|
||||
生产二进制更换(/usr/local/bin/codex、172、170、Docker 镜像内
|
||||
二进制)是部署动作,需要用户另外点头 + 窗口,不在本批;本批的
|
||||
验证边界是本地 scratch 验证(见下文“验证”),不是生产换装实证。
|
||||
|
||||
## 基线与上游位置
|
||||
|
||||
- 本项目此前锚定:`codex-cli 0.164.0-alpha.9`(2026-10-25 实测,
|
||||
`protocol/` 的 schema 就是从它用
|
||||
`codex app-server generate-json-schema` 导出的;见
|
||||
`docs/upstream-164-alignment.md`)。
|
||||
- 上游稳定版:`0.165.0`(2026-10-27)、`0.166.0`(2026-10-29,
|
||||
当前最新稳定版)。本批一次跨两个稳定版锚定到 0.166.0,不
|
||||
在 0.165.0 停留(0.165.0 的内容已全含在 0.166.0 里)。
|
||||
- 上游 alpha 线(不作为目标):0.167.0-alpha.5 与 0.168.0-alpha.1
|
||||
(均 2026-11-03),无 0.167/0.168 稳定版。按 164 文档立过的
|
||||
原则只记录、不跟进;alpha 跟踪办法见批总结,不进本文档。
|
||||
- 上游来源:https://github.com/openai/codex/releases(npm 官方包
|
||||
`@openai/codex@0.166.0` + 平台包 `@openai/codex@0.166.0-linux-x64`,
|
||||
npm integrity sha512 已验)。
|
||||
|
||||
## 稳定版 0.166.0 改动 → 本项目状态
|
||||
|
||||
| 上游改动 | 对宿主层的影响 | 结论 |
|
||||
|---|---|---|
|
||||
| app-server 线程启动/关闭竞态修复、已完成 runtime 及时释放(#57787、#57788) | 宿主 host.py 是长循环、一个 app-server 进程托管多 thread,直接受益 | 升级收益,无需改代码;重测 thread 生命周期一项(已做,见验证) |
|
||||
| resume/fork 时保留已确认的 approval 与权限设置(#57227) | 宿主 resume/fork 流程不再被重置坑绊 | 升级收益,无需改代码 |
|
||||
| 独立 executor 随规范 CLI 与 app-server 发布包一起发(#57837) | 实查:不是新文件,是二进制内 `exec-server` 子命令;三件套文件形态不变,宿主只驱动 `app-server` | 不影响安装形态,无需改代码 |
|
||||
| GPT-6.1 Terra 进入 bundled、Vertex AI、Bedrock 三个目录(#57293);Vertex AI provider(0.165.0 #56265) | 宿主模型走中转路由,引擎版本不卡 Terra 可用性;内置列表回退路径 0.165+ 才含 Terra | 不相关,无需改代码 |
|
||||
| `codex queue --resume`(0.165.0 #57232) | 宿主任务队列自建(host.py 循环 + worker_sweep admin CLI),不依赖上游 queue | 不相关,功能等价但实现无关 |
|
||||
| `codex doctor --network-only`(#57606) | 运维诊断工具,不是宿主运行路径 | 观察项,不接 |
|
||||
| 键盘 transcript 选择、Markdown 渲染一致化、安全拦截说明 | 都是 TUI 交互面,app-server 协议与宿主层不依赖它们 | 不相关,无需改代码 |
|
||||
| memory v2 对新 profile 默认开启 + 外部记忆导入(0.165.0) | 宿主 auto_memory 为自建租户记忆,未对齐上游 memory v2 格式/导入 | 不对齐,见对齐表 A10;本批不动 |
|
||||
| 实验性 attached thread forks(0.165.0 #56351) | 只在 `--experimental` schema 里出现(`ThreadForkParams.attachToSource`),默认 schema 无;且 attached fork 在普通 thread/list 里不可见,与宿主“fork 即新建 worktree 的可见线程”模型冲突 | 不接,评估见批总结;本批不写代码 |
|
||||
| 构建基线 Node.js 24.22.1、Rust 1.95.0 | 与本机 Node 24.22.1 一致;宿主是 Python,不编译 Rust | 不相关 |
|
||||
|
||||
## schema 重导结果(0.166.0 二进制实导,非树对比)
|
||||
|
||||
导出方式:`codex app-server generate-json-schema --out <dir>`,
|
||||
原样取其中两 bundle 替换 `protocol/`(v1 + v2,与旧形态一致,
|
||||
不引入 per-type 散文件)。
|
||||
|
||||
| 对比项 | alpha.9 时代 bundle | 0.166.0 重导 bundle | 结论 |
|
||||
|---|---|---|---|
|
||||
| v1 bundle 定义数 | 83 | 83 | 仅 ClientRequest 变(新增 feedback/save 一个方法) |
|
||||
| v2 bundle 定义数 | 665 | 677 | +12 定义、0 删除、16 个定义有改动 |
|
||||
| v2 新增定义 | — | AccountSession、AccountSessionWorkspace(Kind)、ApplicationStorageRequirements、FeedbackSaveParams/Response、ModelProviderError、OtelExporter、OtelHttpProtocol、OtelRequirements、OtelTlsConfig、OtelToolResultLogConfig | 均为新能力/新遥测,无 host 用项 |
|
||||
| v2 改动定义 | — | Account、AdditionalContextEntry、ApplicationRequirements、ClientRequest、CodexErrorInfo、ConfigRequirements、HookMetadata、HookRunSummary、HooksListParams、NetworkRequirements、ProcessExitedNotification、ReviewDelivery、ReviewStartParams、ReviewStartResponse、ThreadItem、ThreadUsage | 除 ReviewDelivery 外全是新增可选字段或描述改动(见下) |
|
||||
| 方法集 | ClientRequest 105 | ClientRequest 106(+feedback/save) | 0 删除;ServerRequest 10→10、通知 85→85 全不变 |
|
||||
| host 方法 params 引用 | — | 21 个 host 方法的 params 引用全部未变 | 零漂移 |
|
||||
|
||||
唯一语义变化:`ReviewDelivery` 枚举从 `["inline", "detached"]`
|
||||
变为 `["inline"]`(detached 已移除),`ReviewStartParams.delivery`
|
||||
与 `ReviewStartResponse.reviewThreadId` 的描述同步改写。宿主早已
|
||||
不发 detached(`host.py start_review` 注释“不发 detached(已废弃)”、
|
||||
`cloud/review.py` 同款说明),故无影响;其余改动如
|
||||
`ThreadItem` 新增 `completedAtMs`/`startedAtMs`(可空)、
|
||||
`ThreadUsage` 新增 `nativeUsageUsdMicros`(可空)、
|
||||
`CodexErrorInfo` 新增 `modelNotFound`/`policyCapacityUnavailable`
|
||||
均为向后兼容加法。
|
||||
|
||||
方法数勘误:2026-11-05 对齐表写“17 个方法/通知”并不准确。
|
||||
实查 host.py 触及 21 个方法字符串:18 个发出请求(含
|
||||
initialize 握手)+ 3 个入站通知;对齐表列出的 18 项漏了
|
||||
initialize、app/list、mcpServerStatus/list 三个。
|
||||
`tests/test_host_protocol_mapping.py` 以实查 21 项为准钉死。
|
||||
|
||||
## 三件套核对(0.166.0 linux-x64 平台包实查)
|
||||
|
||||
- `bin/codex`(319,346,888 字节,`codex-cli 0.166.0`)、
|
||||
`bin/codex-code-mode-host`(77,997,328 字节)、
|
||||
`codex-resources/bwrap`(529,776 字节)三件齐全,成套。
|
||||
- 包内另有 voice bundle(`codex-voice-host` 及 gstreamer 库,
|
||||
语音为刻意不做项,不理会)与 `codex-path/rg`,均不影响三件套。
|
||||
- 0.166.0 发布包不改变安装形态:宿主的 `bin/codex` 优先、
|
||||
`CODEX_BIN` 环境变量、`PATH` 回退三档逻辑(`host.py
|
||||
default_codex_bin`、`start.sh`)无需改动。
|
||||
|
||||
## 验证(本地 scratch 验证,无模型调用)
|
||||
|
||||
- 沙箱 smoke:scratch 三件套布局下 `codex sandbox echo`
|
||||
成功返回,exit 0(bwrap 配对工作)。
|
||||
- app-server smoke:仓库自带 `client.py --list-threads` 对
|
||||
scratch 0.166.0 二进制跑通:initialize 成功(codexHome 为
|
||||
scratch 目录、platform=linux)、thread/list 返回空数据,exit 0。
|
||||
- 技能发现复核:0.166.0 下 `<codex-home>/skills/`(我方播种
|
||||
位置)与 `$HOME/.agents/skills/`(文档 USER 位置)放的测试
|
||||
技能均被 `skills/list` 发现(scope=user、enabled=true),
|
||||
故播种机制无需改动;系统技能(skill-creator 等 5 个)照常出现。
|
||||
- 方法映射重测:`tests/test_host_protocol_mapping.py`(21 项
|
||||
钉死)+ 完整三套件(数见批总结)。
|
||||
|
||||
## 本批实际改动(只改宿主侧,不动官方二进制)
|
||||
|
||||
1. `protocol/`:两 bundle 替换为 0.166.0 二进制原样导出
|
||||
(749,825 + 646,070 字节)。
|
||||
2. `README.md`:实测版本 pin 从 `0.164.0-alpha.9` 改为
|
||||
`0.166.0`(验证通过后才改,见批总结;不是提前改 pin 充数)。
|
||||
3. `start.sh`:三件套注释版本号同步为 0.166.0(仅注释)。
|
||||
4. `config.example.toml`:补 `[[skills.config]]` 禁用语义的
|
||||
注释示例(仅注释,不改任何生效配置)。
|
||||
5. `tests/test_host_protocol_mapping.py`:新增,21 项钉死。
|
||||
6. 本文件:作为 0.166.0 的对齐记录,后续每次上游稳定版发布时
|
||||
按同一张表复核。
|
||||
|
||||
## 明确不做的事(诚实边界)
|
||||
|
||||
- **不换任何生产/服务器二进制。** `/usr/local/bin/codex`、
|
||||
172、170、Docker 镜像内二进制一律不动;本批二进制只活在
|
||||
scratch 目录,批后清理。
|
||||
- **不追 alpha。** 0.167/0.168 alpha 只记录、不采用。
|
||||
- **不改播种机制。** 0.166.0 下 `<codex-home>/skills/` 仍被
|
||||
发现,无需改 `skill_seed.py`。
|
||||
- **不接 attached forks。** 实验性、仅 `--experimental`
|
||||
schema、与宿主可观测模型冲突;重估触发条件见批总结。
|
||||
- **不改阈值、不改 #238 auto_apply、不改 #247 语义、不改
|
||||
v1.1 契约、不改 SKILL.md 第五项。**
|
||||
- **不合并、不部署、不推送。** 本批留在隔离 worktree 的
|
||||
`codex-opensource-align2` 分支上;live 仍是 aba06d9,不碰。
|
||||
|
||||
## 下一步(需要用户定)
|
||||
|
||||
1. 是否把生产二进制从 alpha.9 换到 0.166.0(主机部署动作,
|
||||
需窗口 + 重跑 resume / goal / 成本实弹)?那一步与控制面
|
||||
部署是两件分开的决策,不可悄悄混进一次部署。
|
||||
2. gapfill2 线是否合并(B1)仍待用户;本批不替用户定。
|
||||
@@ -2915,21 +2915,25 @@ def run_goal_loop(server, state, state_path, task, goal_text, verify,
|
||||
f"目标:{goal_text}\n"
|
||||
f"验收命令:{verify_text}\n"
|
||||
f"上一轮验收输出:\n{last_verify}")
|
||||
# FLAG-3 定序(v2 演练决定):首轮两者同 pending 时,
|
||||
# 模型可见顺序为断点备注在前、改 goal 通知在后、再是
|
||||
# 本轮任务提示。注意两次 prepend 的执行顺序与可见顺序
|
||||
# 相反:先贴通知、再贴备注,最终才是备注在前。v1 注释
|
||||
# 声称“先见备注”但代码实际产生通知在前,v2 在此修正,
|
||||
# 并由 test_goal_live_update 的共 pending 测试钉住。
|
||||
# 影响面仅首轮共 pending 时;之后轮次备注块是 no-op,
|
||||
# 通知块行为不变。
|
||||
if pending_goal_notice is not None:
|
||||
# 本轮提示显式写出旧 goal → 新 goal;通知只用一次,
|
||||
# 下一轮提示是重新构造的,不重复贴。
|
||||
prompt = pending_goal_notice + "\n\n" + prompt
|
||||
pending_goal_notice = None
|
||||
# N-12:断点备注只贴第一轮 prompt(见 _first_turn 注释),
|
||||
# 绝不写回 goal_text——task["goal"] 在循环开始时已按原
|
||||
# goal_text 落盘,备注只是本次续跑的展示前缀。
|
||||
if _first_turn and resume_note:
|
||||
prompt = "%s\n\n%s" % (resume_note, prompt)
|
||||
_first_turn = False
|
||||
# 演练注 FLAG:与上同锚点——运行中改 goal 的旧→新通知
|
||||
# 与 N-12 断点备注同属 prompt 前缀;两段独立 flag,
|
||||
# 并集保留(备注在前、改 goal 通知在后);若首轮两者
|
||||
# 同 pending,模型先见备注后见改 goal 通知,顺序待定。
|
||||
if pending_goal_notice is not None:
|
||||
# 本轮提示显式写出旧 goal → 新 goal;通知只用一次,
|
||||
# 下一轮提示是重新构造的,不重复贴。
|
||||
prompt = pending_goal_notice + "\n\n" + prompt
|
||||
pending_goal_notice = None
|
||||
try:
|
||||
result = run_task_turn(server, state, task, prompt,
|
||||
model, effort, sandbox, timeout=timeout,
|
||||
|
||||
@@ -2394,6 +2394,30 @@
|
||||
"title": "Feedback/uploadRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"feedback/save"
|
||||
],
|
||||
"title": "Feedback/saveRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/FeedbackSaveParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Feedback/saveRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Execute a standalone command (argv vector) under the server's sandbox.",
|
||||
"properties": {
|
||||
@@ -6781,6 +6805,13 @@
|
||||
],
|
||||
"title": "ChatgptAccountType",
|
||||
"type": "string"
|
||||
},
|
||||
"workspaceName": {
|
||||
"description": "Display name of the selected ChatGPT workspace, when available.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -6810,6 +6841,23 @@
|
||||
],
|
||||
"title": "AmazonBedrockAccount",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Google Cloud Vertex AI with externally managed credentials.",
|
||||
"properties": {
|
||||
"type": {
|
||||
"enum": [
|
||||
"gcp"
|
||||
],
|
||||
"title": "GcpAccountType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"title": "GcpAccount",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -6871,6 +6919,103 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"AccountSession": {
|
||||
"properties": {
|
||||
"displayName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"email": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"imageUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"isActive": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"lastUsedAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"selectedWorkspaceAccountId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sessionId": {
|
||||
"type": "string"
|
||||
},
|
||||
"userId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"workspaces": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/AccountSessionWorkspace"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"isActive",
|
||||
"lastUsedAt",
|
||||
"sessionId",
|
||||
"workspaces"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AccountSessionWorkspace": {
|
||||
"properties": {
|
||||
"accountId": {
|
||||
"type": "string"
|
||||
},
|
||||
"imageUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"kind": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/AccountSessionWorkspaceKind"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accountId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AccountSessionWorkspaceKind": {
|
||||
"enum": [
|
||||
"personal",
|
||||
"workspace"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"AccountTokenUsageDailyBucket": {
|
||||
"properties": {
|
||||
"startDate": {
|
||||
@@ -6993,6 +7138,13 @@
|
||||
"kind": {
|
||||
"$ref": "#/definitions/v2/AdditionalContextKind"
|
||||
},
|
||||
"name": {
|
||||
"description": "Optional name for untrusted context, used to label its tool output.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -7756,6 +7908,42 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"storage": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/ApplicationStorageRequirements"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ApplicationStorageRequirements": {
|
||||
"description": "Managed storage settings for new Desktop workspaces, not filesystem access restrictions.",
|
||||
"properties": {
|
||||
"defaultProjectParent": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/LegacyAppPathString"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"projectlessWorkspaceRoot": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/LegacyAppPathString"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -8502,6 +8690,8 @@
|
||||
"rateLimitExceeded",
|
||||
"flexUnavailable",
|
||||
"serverOverloaded",
|
||||
"modelNotFound",
|
||||
"policyCapacityUnavailable",
|
||||
"cyberPolicy",
|
||||
"misalignmentPolicyViolation",
|
||||
"tooManyDenials",
|
||||
@@ -10216,6 +10406,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"otel": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/OtelRequirements"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sqliteHome": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -11807,6 +12007,85 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FeedbackSaveParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"classification": {
|
||||
"type": "string"
|
||||
},
|
||||
"extraLogFiles": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"includeLogs": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"savePath": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/LegacyAppPathString"
|
||||
}
|
||||
],
|
||||
"description": "Absolute, previously unused ZIP path on the app-server host. The caller owns this file, including after a disconnected RPC."
|
||||
},
|
||||
"tags": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"threadId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"classification",
|
||||
"savePath"
|
||||
],
|
||||
"title": "FeedbackSaveParams",
|
||||
"type": "object"
|
||||
},
|
||||
"FeedbackSaveResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"promptHash": {
|
||||
"description": "Same prompt hash as feedback/upload; null when prompt metadata is absent.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"savedPath": {
|
||||
"$ref": "#/definitions/v2/LegacyAppPathString"
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"savedPath",
|
||||
"threadId"
|
||||
],
|
||||
"title": "FeedbackSaveResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"FeedbackUploadParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
@@ -13455,6 +13734,13 @@
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"environmentId": {
|
||||
"description": "The owning execution environment, if this hook came from its requirements.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"eventName": {
|
||||
"$ref": "#/definitions/v2/HookEventName"
|
||||
},
|
||||
@@ -13622,7 +13908,7 @@
|
||||
"default": "unknown"
|
||||
},
|
||||
"sourcePath": {
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
"$ref": "#/definitions/v2/LegacyAppPathString"
|
||||
},
|
||||
"startedAt": {
|
||||
"format": "int64",
|
||||
@@ -13743,11 +14029,18 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"cwds": {
|
||||
"description": "When empty, defaults to the current session working directory.",
|
||||
"description": "Working directories to inspect when no thread is specified. An empty list uses the session cwd.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"threadId": {
|
||||
"description": "List a loaded thread's hooks, including its selected environments. Omit `cwds` in this mode.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "HooksListParams",
|
||||
@@ -15818,6 +16111,81 @@
|
||||
"title": "ModelProviderCapabilitiesReadResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ModelProviderError": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Structured JSON-RPC error data. Unknown errors must retain the client's normal fallback.",
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"currentModelProvider": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelProvider": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"modelProviderMismatch"
|
||||
],
|
||||
"title": "ModelProviderMismatchv2::ModelProviderErrorType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"currentModelProvider",
|
||||
"modelProvider",
|
||||
"type"
|
||||
],
|
||||
"title": "ModelProviderMismatchv2::ModelProviderError",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"modelProvider": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"modelProviderNotConfigured"
|
||||
],
|
||||
"title": "ModelProviderNotConfiguredv2::ModelProviderErrorType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"modelProvider",
|
||||
"type"
|
||||
],
|
||||
"title": "ModelProviderNotConfiguredv2::ModelProviderError",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"modelProvider": {
|
||||
"type": "string"
|
||||
},
|
||||
"requiredModelProvider": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"modelProviderPolicyConflict"
|
||||
],
|
||||
"title": "ModelProviderPolicyConflictv2::ModelProviderErrorType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"modelProvider",
|
||||
"requiredModelProvider",
|
||||
"type"
|
||||
],
|
||||
"title": "ModelProviderPolicyConflictv2::ModelProviderError",
|
||||
"type": "object"
|
||||
}
|
||||
],
|
||||
"title": "ModelProviderError"
|
||||
},
|
||||
"ModelRerouteReason": {
|
||||
"enum": [
|
||||
"highRiskCyberActivity"
|
||||
@@ -16116,7 +16484,7 @@
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/v2/NetworkDomainPermission"
|
||||
},
|
||||
"description": "Canonical network permission map for `experimental_network`.",
|
||||
"description": "Canonical managed network permission map.",
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
@@ -16143,6 +16511,16 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"reviewDomains": {
|
||||
"description": "Host patterns routed to network approval before ordinary allow rules.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"socksPort": {
|
||||
"format": "uint16",
|
||||
"minimum": 0.0,
|
||||
@@ -16155,7 +16533,7 @@
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/v2/NetworkUnixSocketPermission"
|
||||
},
|
||||
"description": "Canonical unix socket permission map for `experimental_network`.",
|
||||
"description": "Canonical managed unix socket permission map.",
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
@@ -16229,6 +16607,232 @@
|
||||
],
|
||||
"title": "Nullable_GetAccountTokenUsageParams"
|
||||
},
|
||||
"OtelExporter": {
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"none",
|
||||
"statsig"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"otlp-http": {
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"headers": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Header names are retained; values are redacted by the server.",
|
||||
"type": "object"
|
||||
},
|
||||
"protocol": {
|
||||
"$ref": "#/definitions/v2/OtelHttpProtocol"
|
||||
},
|
||||
"tls": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/OtelTlsConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"endpoint",
|
||||
"headers",
|
||||
"protocol"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"otlp-http"
|
||||
],
|
||||
"title": "OtlpHttpOtelExporter",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"otlp-grpc": {
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"headers": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Header names are retained; values are redacted by the server.",
|
||||
"type": "object"
|
||||
},
|
||||
"tls": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/OtelTlsConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"endpoint",
|
||||
"headers"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"otlp-grpc"
|
||||
],
|
||||
"title": "OtlpGrpcOtelExporter",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"OtelHttpProtocol": {
|
||||
"enum": [
|
||||
"binary",
|
||||
"json"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"OtelRequirements": {
|
||||
"properties": {
|
||||
"environment": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"exporter": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/OtelExporter"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"log_agent_responses": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"log_guardian_assessments": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"log_user_prompt": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"metrics_exporter": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/OtelExporter"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"span_attributes": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"tool_result": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/OtelToolResultLogConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trace_exporter": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/OtelExporter"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tracestate": {
|
||||
"additionalProperties": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"OtelTlsConfig": {
|
||||
"properties": {
|
||||
"ca-certificate": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"client-certificate": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"client-private-key": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"OtelToolResultLogConfig": {
|
||||
"properties": {
|
||||
"max_bytes": {
|
||||
"format": "uint",
|
||||
"minimum": 0.0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"max_bytes"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"OverriddenMetadata": {
|
||||
"properties": {
|
||||
"effectiveValue": true,
|
||||
@@ -17824,6 +18428,13 @@
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
},
|
||||
"outputDrainTimedOut": {
|
||||
"description": "Whether output collection hit its post-exit deadline before seeing EOF or reaching the requested byte cap on either stream. Older servers omit this field.",
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processHandle": {
|
||||
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
|
||||
"type": "string"
|
||||
@@ -19801,8 +20412,7 @@
|
||||
},
|
||||
"ReviewDelivery": {
|
||||
"enum": [
|
||||
"inline",
|
||||
"detached"
|
||||
"inline"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
@@ -19819,7 +20429,7 @@
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Where to run the review: inline (default) on the current thread or detached on a new thread (returned in `reviewThreadId`). Detached delivery is deprecated and emits `deprecationNotice`. Use `thread/start` followed by an inline review for a separate review thread."
|
||||
"description": "Reviews run inline on the supplied thread. Omit this field or pass `inline`. To review on a separate thread, start a thread first and use its id."
|
||||
},
|
||||
"target": {
|
||||
"$ref": "#/definitions/v2/ReviewTarget"
|
||||
@@ -19839,7 +20449,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"reviewThreadId": {
|
||||
"description": "Identifies the thread where the review runs.\n\nFor inline reviews, this is the original thread id. For detached reviews, this is the id of the new review thread.",
|
||||
"description": "The thread id supplied to `review/start`.",
|
||||
"type": "string"
|
||||
},
|
||||
"turn": {
|
||||
@@ -22135,6 +22745,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadItem": {
|
||||
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
@@ -22917,6 +23528,14 @@
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"generationId": {
|
||||
"default": null,
|
||||
"description": "Image generation identifier for the generated image, when provided by the image backend.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -23037,7 +23656,28 @@
|
||||
"title": "ContextCompactionThreadItem",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
],
|
||||
"properties": {
|
||||
"completedAtMs": {
|
||||
"default": null,
|
||||
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"startedAtMs": {
|
||||
"default": null,
|
||||
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadItemEntry": {
|
||||
"properties": {
|
||||
@@ -25689,6 +26329,14 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"nativeUsageUsdMicros": {
|
||||
"description": "Native dollar usage, independent of credit estimates. None means unknown, not zero.",
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -40,6 +40,13 @@
|
||||
],
|
||||
"title": "ChatgptAccountType",
|
||||
"type": "string"
|
||||
},
|
||||
"workspaceName": {
|
||||
"description": "Display name of the selected ChatGPT workspace, when available.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -69,6 +76,23 @@
|
||||
],
|
||||
"title": "AmazonBedrockAccount",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Google Cloud Vertex AI with externally managed credentials.",
|
||||
"properties": {
|
||||
"type": {
|
||||
"enum": [
|
||||
"gcp"
|
||||
],
|
||||
"title": "GcpAccountType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"title": "GcpAccount",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -130,6 +154,103 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"AccountSession": {
|
||||
"properties": {
|
||||
"displayName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"email": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"imageUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"isActive": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"lastUsedAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"selectedWorkspaceAccountId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sessionId": {
|
||||
"type": "string"
|
||||
},
|
||||
"userId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"workspaces": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/AccountSessionWorkspace"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"isActive",
|
||||
"lastUsedAt",
|
||||
"sessionId",
|
||||
"workspaces"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AccountSessionWorkspace": {
|
||||
"properties": {
|
||||
"accountId": {
|
||||
"type": "string"
|
||||
},
|
||||
"imageUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"kind": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AccountSessionWorkspaceKind"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accountId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AccountSessionWorkspaceKind": {
|
||||
"enum": [
|
||||
"personal",
|
||||
"workspace"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"AccountTokenUsageDailyBucket": {
|
||||
"properties": {
|
||||
"startDate": {
|
||||
@@ -252,6 +373,13 @@
|
||||
"kind": {
|
||||
"$ref": "#/definitions/AdditionalContextKind"
|
||||
},
|
||||
"name": {
|
||||
"description": "Optional name for untrusted context, used to label its tool output.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -1015,6 +1143,42 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"storage": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ApplicationStorageRequirements"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ApplicationStorageRequirements": {
|
||||
"description": "Managed storage settings for new Desktop workspaces, not filesystem access restrictions.",
|
||||
"properties": {
|
||||
"defaultProjectParent": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/LegacyAppPathString"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"projectlessWorkspaceRoot": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/LegacyAppPathString"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -3967,6 +4131,30 @@
|
||||
"title": "Feedback/uploadRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"feedback/save"
|
||||
],
|
||||
"title": "Feedback/saveRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/FeedbackSaveParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Feedback/saveRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Execute a standalone command (argv vector) under the server's sandbox.",
|
||||
"properties": {
|
||||
@@ -4318,6 +4506,8 @@
|
||||
"rateLimitExceeded",
|
||||
"flexUnavailable",
|
||||
"serverOverloaded",
|
||||
"modelNotFound",
|
||||
"policyCapacityUnavailable",
|
||||
"cyberPolicy",
|
||||
"misalignmentPolicyViolation",
|
||||
"tooManyDenials",
|
||||
@@ -6032,6 +6222,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"otel": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/OtelRequirements"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sqliteHome": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -7623,6 +7823,85 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FeedbackSaveParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"classification": {
|
||||
"type": "string"
|
||||
},
|
||||
"extraLogFiles": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"includeLogs": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"savePath": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/LegacyAppPathString"
|
||||
}
|
||||
],
|
||||
"description": "Absolute, previously unused ZIP path on the app-server host. The caller owns this file, including after a disconnected RPC."
|
||||
},
|
||||
"tags": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"threadId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"classification",
|
||||
"savePath"
|
||||
],
|
||||
"title": "FeedbackSaveParams",
|
||||
"type": "object"
|
||||
},
|
||||
"FeedbackSaveResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"promptHash": {
|
||||
"description": "Same prompt hash as feedback/upload; null when prompt metadata is absent.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"savedPath": {
|
||||
"$ref": "#/definitions/LegacyAppPathString"
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"savedPath",
|
||||
"threadId"
|
||||
],
|
||||
"title": "FeedbackSaveResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"FeedbackUploadParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
@@ -9382,6 +9661,13 @@
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"environmentId": {
|
||||
"description": "The owning execution environment, if this hook came from its requirements.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"eventName": {
|
||||
"$ref": "#/definitions/HookEventName"
|
||||
},
|
||||
@@ -9549,7 +9835,7 @@
|
||||
"default": "unknown"
|
||||
},
|
||||
"sourcePath": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
"$ref": "#/definitions/LegacyAppPathString"
|
||||
},
|
||||
"startedAt": {
|
||||
"format": "int64",
|
||||
@@ -9670,11 +9956,18 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"cwds": {
|
||||
"description": "When empty, defaults to the current session working directory.",
|
||||
"description": "Working directories to inspect when no thread is specified. An empty list uses the session cwd.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"threadId": {
|
||||
"description": "List a loaded thread's hooks, including its selected environments. Omit `cwds` in this mode.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "HooksListParams",
|
||||
@@ -11810,6 +12103,81 @@
|
||||
"title": "ModelProviderCapabilitiesReadResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ModelProviderError": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Structured JSON-RPC error data. Unknown errors must retain the client's normal fallback.",
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"currentModelProvider": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelProvider": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"modelProviderMismatch"
|
||||
],
|
||||
"title": "ModelProviderMismatchv2::ModelProviderErrorType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"currentModelProvider",
|
||||
"modelProvider",
|
||||
"type"
|
||||
],
|
||||
"title": "ModelProviderMismatchv2::ModelProviderError",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"modelProvider": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"modelProviderNotConfigured"
|
||||
],
|
||||
"title": "ModelProviderNotConfiguredv2::ModelProviderErrorType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"modelProvider",
|
||||
"type"
|
||||
],
|
||||
"title": "ModelProviderNotConfiguredv2::ModelProviderError",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"modelProvider": {
|
||||
"type": "string"
|
||||
},
|
||||
"requiredModelProvider": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"modelProviderPolicyConflict"
|
||||
],
|
||||
"title": "ModelProviderPolicyConflictv2::ModelProviderErrorType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"modelProvider",
|
||||
"requiredModelProvider",
|
||||
"type"
|
||||
],
|
||||
"title": "ModelProviderPolicyConflictv2::ModelProviderError",
|
||||
"type": "object"
|
||||
}
|
||||
],
|
||||
"title": "ModelProviderError"
|
||||
},
|
||||
"ModelRerouteReason": {
|
||||
"enum": [
|
||||
"highRiskCyberActivity"
|
||||
@@ -12108,7 +12476,7 @@
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/NetworkDomainPermission"
|
||||
},
|
||||
"description": "Canonical network permission map for `experimental_network`.",
|
||||
"description": "Canonical managed network permission map.",
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
@@ -12135,6 +12503,16 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"reviewDomains": {
|
||||
"description": "Host patterns routed to network approval before ordinary allow rules.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"socksPort": {
|
||||
"format": "uint16",
|
||||
"minimum": 0.0,
|
||||
@@ -12147,7 +12525,7 @@
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/NetworkUnixSocketPermission"
|
||||
},
|
||||
"description": "Canonical unix socket permission map for `experimental_network`.",
|
||||
"description": "Canonical managed unix socket permission map.",
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
@@ -12221,6 +12599,232 @@
|
||||
],
|
||||
"title": "Nullable_GetAccountTokenUsageParams"
|
||||
},
|
||||
"OtelExporter": {
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"none",
|
||||
"statsig"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"otlp-http": {
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"headers": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Header names are retained; values are redacted by the server.",
|
||||
"type": "object"
|
||||
},
|
||||
"protocol": {
|
||||
"$ref": "#/definitions/OtelHttpProtocol"
|
||||
},
|
||||
"tls": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/OtelTlsConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"endpoint",
|
||||
"headers",
|
||||
"protocol"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"otlp-http"
|
||||
],
|
||||
"title": "OtlpHttpOtelExporter",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"otlp-grpc": {
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"headers": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Header names are retained; values are redacted by the server.",
|
||||
"type": "object"
|
||||
},
|
||||
"tls": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/OtelTlsConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"endpoint",
|
||||
"headers"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"otlp-grpc"
|
||||
],
|
||||
"title": "OtlpGrpcOtelExporter",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"OtelHttpProtocol": {
|
||||
"enum": [
|
||||
"binary",
|
||||
"json"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"OtelRequirements": {
|
||||
"properties": {
|
||||
"environment": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"exporter": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/OtelExporter"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"log_agent_responses": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"log_guardian_assessments": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"log_user_prompt": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"metrics_exporter": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/OtelExporter"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"span_attributes": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"tool_result": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/OtelToolResultLogConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trace_exporter": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/OtelExporter"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tracestate": {
|
||||
"additionalProperties": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"OtelTlsConfig": {
|
||||
"properties": {
|
||||
"ca-certificate": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"client-certificate": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"client-private-key": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"OtelToolResultLogConfig": {
|
||||
"properties": {
|
||||
"max_bytes": {
|
||||
"format": "uint",
|
||||
"minimum": 0.0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"max_bytes"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"OverriddenMetadata": {
|
||||
"properties": {
|
||||
"effectiveValue": true,
|
||||
@@ -13816,6 +14420,13 @@
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
},
|
||||
"outputDrainTimedOut": {
|
||||
"description": "Whether output collection hit its post-exit deadline before seeing EOF or reaching the requested byte cap on either stream. Older servers omit this field.",
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processHandle": {
|
||||
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
|
||||
"type": "string"
|
||||
@@ -15793,8 +16404,7 @@
|
||||
},
|
||||
"ReviewDelivery": {
|
||||
"enum": [
|
||||
"inline",
|
||||
"detached"
|
||||
"inline"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
@@ -15811,7 +16421,7 @@
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Where to run the review: inline (default) on the current thread or detached on a new thread (returned in `reviewThreadId`). Detached delivery is deprecated and emits `deprecationNotice`. Use `thread/start` followed by an inline review for a separate review thread."
|
||||
"description": "Reviews run inline on the supplied thread. Omit this field or pass `inline`. To review on a separate thread, start a thread first and use its id."
|
||||
},
|
||||
"target": {
|
||||
"$ref": "#/definitions/ReviewTarget"
|
||||
@@ -15831,7 +16441,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"reviewThreadId": {
|
||||
"description": "Identifies the thread where the review runs.\n\nFor inline reviews, this is the original thread id. For detached reviews, this is the id of the new review thread.",
|
||||
"description": "The thread id supplied to `review/start`.",
|
||||
"type": "string"
|
||||
},
|
||||
"turn": {
|
||||
@@ -19849,6 +20459,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadItem": {
|
||||
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
@@ -20631,6 +21242,14 @@
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"generationId": {
|
||||
"default": null,
|
||||
"description": "Image generation identifier for the generated image, when provided by the image backend.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -20751,7 +21370,28 @@
|
||||
"title": "ContextCompactionThreadItem",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
],
|
||||
"properties": {
|
||||
"completedAtMs": {
|
||||
"default": null,
|
||||
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"startedAtMs": {
|
||||
"default": null,
|
||||
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadItemEntry": {
|
||||
"properties": {
|
||||
@@ -23403,6 +24043,14 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"nativeUsageUsdMicros": {
|
||||
"description": "Native dollar usage, independent of credit estimates. None means unknown, not zero.",
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ cd "$(dirname "$0")"
|
||||
|
||||
# 本机 VM 修复:系统里的 codex 是裸二进制,旁边没有
|
||||
# codex-code-mode-host 和 codex-resources/bwrap,工具会 fail closed。
|
||||
# 本项目 bin/ 里是配对好的官方 0.164.0-alpha.9 三件套
|
||||
# 本项目 bin/ 里是配对好的官方 0.166.0 三件套
|
||||
# (codex + codex-code-mode-host + codex-resources/bwrap),
|
||||
# 优先用它;Mac 上用 npm 正常安装的 codex 自带这三件套,
|
||||
# 不需要 bin/,直接用 PATH 里的 codex 即可。
|
||||
|
||||
@@ -104,7 +104,11 @@ class StubServer:
|
||||
self.created = []
|
||||
|
||||
def create_thread(self, cwd, model=None, sandbox=None,
|
||||
approval_policy=None):
|
||||
approval_policy=None, instructions=None):
|
||||
# 演练注:main 侧 dispatch 传 instructions(developer
|
||||
# 指令,真实 Server.create_thread 已接受);源线 stub
|
||||
# 未跟进接口致 2 项 TypeError,测试桩同步接口,生产
|
||||
# 代码与断言均未动。
|
||||
self.created.append(cwd)
|
||||
return "th-1"
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
"""host.py 方法 ↔ protocol/ schema 映射钉死测试(引擎升级 0.166.0 批)。
|
||||
|
||||
钉死两件事:
|
||||
1. host.py 实际触及的方法集合(发出的请求 + 处理的入站通知)必须
|
||||
等于下表;host.py 新增方法时本测试先红,逼升级者同步更新映射,
|
||||
而不是悄悄带一个 schema 里没有的方法上线。
|
||||
2. 下表每个方法在 protocol/ 两 bundle 里存在,且 params 引用与
|
||||
0.166.0 重导结果一致;上游删方法或改 params 引用时本测试先红。
|
||||
|
||||
方法数勘误:2026-11-05 对齐表写“17 个方法/通知”并不准确——
|
||||
实查 host.py 触及 21 个方法字符串:18 个发出请求(含 initialize
|
||||
握手)+ 3 个入站通知;对齐表列出的 18 项漏了 initialize、
|
||||
app/list、mcpServerStatus/list 三个。本测试以实查 21 项为准。
|
||||
其中 17 个应用请求(不含 initialize)与对齐表原意一致,只是
|
||||
对齐表把其中 app/list、mcpServerStatus/list 两项漏列了。
|
||||
|
||||
本测试只读 host.py 文本与 protocol/ 两 bundle,不 import host,
|
||||
不启动任何 app-server 进程,不做模型调用。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# method -> v2 ClientRequest 的 params 引用;None 表示该请求的
|
||||
# params 在 schema 里是 {"type": "null"}(config/mcpServer/reload)。
|
||||
EXPECTED_REQUESTS = {
|
||||
"initialize": "InitializeParams",
|
||||
"thread/start": "ThreadStartParams",
|
||||
"thread/resume": "ThreadResumeParams",
|
||||
"thread/fork": "ThreadForkParams",
|
||||
"thread/list": "ThreadListParams",
|
||||
"thread/read": "ThreadReadParams",
|
||||
"thread/name/set": "ThreadSetNameParams",
|
||||
"thread/archive": "ThreadArchiveParams",
|
||||
"thread/unarchive": "ThreadUnarchiveParams",
|
||||
"thread/compact/start": "ThreadCompactStartParams",
|
||||
"turn/start": "TurnStartParams",
|
||||
"turn/interrupt": "TurnInterruptParams",
|
||||
"review/start": "ReviewStartParams",
|
||||
"skills/list": "SkillsListParams",
|
||||
"app/list": "AppsListParams",
|
||||
"mcpServerStatus/list": "ListMcpServerStatusParams",
|
||||
"mcpServer/oauth/login": "McpServerOauthLoginParams",
|
||||
"config/mcpServer/reload": None,
|
||||
}
|
||||
|
||||
# method -> (bundle, container, params 引用)。turn/completed 与
|
||||
# thread/tokenUsage/updated 是服务端发来的通知(v2 ServerNotification);
|
||||
# mcpServer/elicitation/request 是服务端发来的请求,活在 v1 bundle
|
||||
# 的 ServerRequest 里(v2 bundle 没有 ServerRequest 定义)。
|
||||
EXPECTED_INBOUND = {
|
||||
"turn/completed": ("v2", "ServerNotification", "TurnCompletedNotification"),
|
||||
"thread/tokenUsage/updated": (
|
||||
"v2", "ServerNotification", "ThreadTokenUsageUpdatedNotification"),
|
||||
"mcpServer/elicitation/request": (
|
||||
"v1", "ServerRequest", "McpServerElicitationRequestParams"),
|
||||
}
|
||||
|
||||
|
||||
def _load_bundle(name):
|
||||
path = os.path.join(ROOT, "protocol", name)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _method_map(bundle, container):
|
||||
out = {}
|
||||
for entry in bundle["definitions"].get(container, {}).get("oneOf", []):
|
||||
props = entry.get("properties", {})
|
||||
method = (props.get("method", {}).get("enum") or [None])[0]
|
||||
if not method:
|
||||
continue
|
||||
params = props.get("params", {})
|
||||
ref = params.get("$ref", "")
|
||||
out[method] = ref.split("/")[-1] if ref else params
|
||||
return out
|
||||
|
||||
|
||||
class TestHostProtocolMapping(unittest.TestCase):
|
||||
def test_host_method_set_matches_pin(self):
|
||||
with open(os.path.join(ROOT, "host.py"), encoding="utf-8") as f:
|
||||
src = f.read()
|
||||
requests = set(re.findall(r'\.request\(\s*"([^"]+)"', src))
|
||||
inbound = set(
|
||||
re.findall(r'\.get\("method"\)\s*[=!]=\s*"([^"]+)"', src))
|
||||
self.assertEqual(requests, set(EXPECTED_REQUESTS),
|
||||
"host.py 发出的请求集合变了,先更新映射表再合")
|
||||
self.assertEqual(inbound, set(EXPECTED_INBOUND),
|
||||
"host.py 处理的入站通知集合变了,先更新映射表再合")
|
||||
|
||||
def test_requests_in_v2_schema(self):
|
||||
bundle = _load_bundle("codex_app_server_protocol.v2.schemas.json")
|
||||
methods = _method_map(bundle, "ClientRequest")
|
||||
for method, ref in EXPECTED_REQUESTS.items():
|
||||
self.assertIn(method, methods, f"v2 schema 缺少 host 请求 {method}")
|
||||
if ref is None:
|
||||
self.assertEqual(methods[method], {"type": "null"},
|
||||
f"{method} 的 params 应为 null 类型")
|
||||
else:
|
||||
self.assertEqual(methods[method], ref,
|
||||
f"{method} 的 params 引用变了")
|
||||
|
||||
def test_inbound_in_schema(self):
|
||||
bundles = {
|
||||
"v1": _load_bundle("codex_app_server_protocol.schemas.json"),
|
||||
"v2": _load_bundle("codex_app_server_protocol.v2.schemas.json"),
|
||||
}
|
||||
for method, (bundle_name, container, ref) in EXPECTED_INBOUND.items():
|
||||
methods = _method_map(bundles[bundle_name], container)
|
||||
self.assertIn(method, methods,
|
||||
f"{bundle_name} schema {container} 缺少 {method}")
|
||||
self.assertEqual(methods[method], ref,
|
||||
f"{method} 的 params 引用变了")
|
||||
|
||||
def test_bundles_parse(self):
|
||||
for name in ("codex_app_server_protocol.schemas.json",
|
||||
"codex_app_server_protocol.v2.schemas.json"):
|
||||
bundle = _load_bundle(name)
|
||||
self.assertIn("$schema", bundle)
|
||||
self.assertIn("definitions", bundle)
|
||||
self.assertGreater(len(bundle["definitions"]), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user