"""云端控制面 HTTP server(首版)。 零第三方依赖,只用 http.server + sqlite3 + 标准库。 分工与边界(以实际代码为准,不夸大): - 本 server 只做控制面:项目/任务的创建、查询、取消、心跳、 stale 任务回收,以及 GitHub/Slack webhook 的验签与解析。 - 本版不直接跑模型回合。任务状态机与 host.py 对齐: queued -> running -> completed/failed/interrupted/merged/merge-failed, 其中 completed/merged 由 worker/控制面后续调用方更新(例如 worker 调 host.py 跑完回合后回写),本 server 不提供“跑模型”端点。 - 其它已就绪模块(queue 的 order_tasks/ready_tasks、sandbox 的执行器、 memory 的 MemoryStore、kb 的 KBStore、models 的 ModelRouter、 cost 的 CostStore、webhooks 的 NotificationService)本版尚未接成 HTTP 端点,留给 worker 接线时调用;本 server 只接入其中两处: AuthStore(认证/权限/审计)与 webhooks 的验签/解析函数。 - DockerExecutor 已接通(见 sandbox.py),CloudExecutor 目前仍是占位 (见 sandbox.py),本 server 不假装已接通云端执行。 导入规则:只用绝对包导入(from cloud.auth import ...)。绝不要把 cloud/ 目录加进 sys.path:标准库已有 queue 模块,会和 cloud/queue.py 撞名,导致 stdlib queue 被错误模块覆盖。 状态库:单一 SQLite 文件。AuthStore 用 users/project_members/ audit_log 三张表;本 server 用 projects/tasks 两张表,表名不冲突。 并发:ThreadingHTTPServer 多线程处理请求,每个请求内新建 SQLite 连接(server 表用新连接,AuthStore 每次请求新建一个实例),绝不在 多线程 handler 间共用一个连接。 启动: python3 -m cloud.server [--host 127.0.0.1] [--port 8765] [--db <路径>] 后端由 CLOUD_DB_BACKEND 选(sqlite 默认;postgres 已接通, 驱动缺失时启动即明确报错,见 cloud/db.py)。 测试用 create_server(db_path, host, port) 只返回 ThreadingHTTPServer 实例,不自动 serve。 """ import argparse import hashlib import json import logging import os import re import shutil import socket import sqlite3 import subprocess import sys import threading import time import uuid from collections import deque from decimal import Decimal from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse from cloud.coordinator import validate_parts as _coord_validate_parts from cloud.cost import CostStore from cloud.cost import unpriced_token_cap from cloud.gates import merge_state_decision as _merge_state_decision from cloud.gates import resolve_inside as _gate_resolve_inside from cloud.ledger import export_chain as _ledger_export from cloud.ledger import verify_chain as _ledger_verify from cloud.quota import decide as _quota_decide from cloud.quota import monthly_usage as _quota_monthly_usage from cloud.quota import quota_for_owner as _quota_for_owner from cloud.timeouts import STALE_SWEEP_SECONDS as _STALE_SWEEP from cloud.timeouts import approval_default_seconds as _approval_default from cloud.timeouts import validate_server_timeout as _validate_timeout from cloud.db import connect as _db_connect from cloud.db import create_database as _create_database from cloud.db import init_postgres_schema as _init_postgres_schema from cloud.db import PostgresIntegrityError as _PgIntegrityError from cloud.db import PostgresOperationalError as _PgOperationalError from cloud.goal_command import parse_goal_command as _parse_goal_command from cloud.peer_messages import deliver_peer_message as _deliver_peer_message from cloud.peer_messages import ensure_followups_table as _peer_ensure_followups from cloud.peer_messages import peer_message_prefix as _peer_prefix from cloud.peer_messages import row_get as _row_get from cloud.marker_tools import parse_spawn_defaults as _parse_spawn_defaults from cloud.evidence_binding import EvidenceNonceStore as _EvidenceNonceStore from cloud.evidence_binding import binding_presence as _evidence_presence from cloud.evidence_binding import validate_bound_evidence as _validate_evidence from cloud.merge_gate import protection_from_env as _merge_gate_protection from cloud.audit_wiring import pushback_block_for_task as _pushback_block from cloud.audit_loop_wiring import build_quota_check as _loop_quota_check from cloud.audit_loop_wiring import cost_threshold_from_env as _loop_cost_threshold from cloud.audit_loop_wiring import evaluate_release_gate as _release_gate_evaluate from cloud.audit_loop_wiring import process_audit_verdict as _loop_verdict from cloud.audit_loop_wiring import process_correction as _loop_correction from cloud.audit_loop_wiring import process_fix_completion as _loop_fix from cloud.audit_loop_wiring import process_rebuild_confirmation as _loop_confirm from cloud.audit_loop_wiring import state_to_dict as _loop_state_to_dict from cloud.audit_loop_wiring import load_loop_state as _loop_load_state from cloud.ledger import LedgerStore as _LedgerStore from cloud.merge_gate_hard import blocked_audit_detail as _gate_blocked_detail from cloud.merge_gate_hard import evaluate_hard_merge_gate as _merge_gate_hard from cloud.tree import DEFAULT_MAX_DEPTH as _TREE_DEFAULT_DEPTH from cloud.tree import DEFAULT_MAX_ENTRIES as _TREE_DEFAULT_ENTRIES from cloud.tree import build_tree as _build_tree from cloud.lifecycle import add_ref as _life_add_ref from cloud.lifecycle import create_snapshot as _life_snapshot from cloud.lifecycle import delete_ref as _life_delete_ref from cloud.lifecycle import latest_snapshot as _life_latest_snapshot from cloud.lifecycle import list_refs as _life_list_refs from cloud.lifecycle import list_snapshots as _life_list_snapshots from cloud.lifecycle import mark_restored as _life_mark_restored from cloud.wait_ops import build_pr_diagnostics as _wait_build_diag from cloud.wait_ops import create_handoff as _handoff_create from cloud.wait_ops import decode_wait_cursor as _wait_decode_cursor from cloud.wait_ops import encode_wait_cursor as _wait_encode_cursor from cloud.wait_ops import poll_for_first_change as _wait_poll_first from cloud.wait_ops import significant_now as _wait_significant_now from cloud.wait_ops import get_handoff as _handoff_get from cloud.wait_ops import interruptible_sleep as _wait_sleep from cloud.wait_ops import latest_handoff as _handoff_latest from cloud.wait_ops import poll_task_statuses as _wait_poll # noqa: F401 # poll_task_statuses 保留给“等全部终态”的调用方与单测; # /wait 本身走首变轮询(_wait_poll_first),见 _task_wait。 from cloud.wait_ops import report_handoff as _handoff_report from cloud.wait_ops import worktree_status as _wait_worktree_status from cloud.shared_home_mcp import sync_shared_home_mcp as _sync_shared_home_mcp from cloud.resident import create_watch as _res_create from cloud.resident import end_watch as _res_end from cloud.resident import ensure_resident_tables as _res_ensure from cloud.resident import get_watch as _res_get from cloud.resident import list_polls as _res_polls from cloud.resident import list_watches as _res_list from cloud.resident import pause_watch as _res_pause from cloud.resident import resident_metrics as _res_metrics from cloud.resident import resume_watch as _res_resume from cloud.resident import update_watch as _res_update from cloud.approval_timeout import approval_timeout_stats as _appr_timeout_stats from cloud.approval_timeout import evaluate_approval_timeout as _appr_timeout_eval from cloud.approval_timeout import recommend_approval_timeout as _appr_timeout_recommend from cloud.approvals import create_approval as _appr_create from cloud.approvals import decide_approval as _appr_decide from cloud.approvals import ensure_approvals_table as _appr_ensure from cloud.approvals import expire_overdue as _appr_expire from cloud.approvals import get_approval as _appr_get from cloud.approvals import list_approvals as _appr_list from cloud.attempt_ledger import aggregate_attempts as _aggregate_attempts from cloud.attempt_ledger import apply_to_records as _apply_attempts from cloud.attempt_ledger import read_attempt_rows as _read_attempt_rows from cloud.metrics import build_report as _build_metrics_report from cloud.metrics import compute_metrics as _compute_metrics from cloud.pr import PrSkipped, PrStepError from cloud.uploads import ( BUNDLE_TTL_SECONDS, MAX_RAW_BYTES, parse_multipart, parse_paths_field, save_bundle, upload_root as _upload_root, validate_bundle, ) from cloud.pr_exec import (enqueue_pr_job, execute_task_pr, find_open_pr_job) from cloud.workers import ( heartbeat_worker as _registry_heartbeat, list_workers as _registry_list, ) from cloud.sub2api import Sub2apiError as _Sub2apiError from cloud.sub2api import ensure_gateway_keys_table as _ensure_gateway_keys from cloud.sub2api import gateway_name as _gateway_name from cloud.sub2api import rotate_user_gateway_key as _rotate_sub2api_key from cloud.sub2api import validate_or_rotate_gateway_key as _validate_sub2api_key from cloud.push import ( dispatch_event as _push_dispatch, list_subscriptions as _push_list, ) from cloud.github_app import ( claim_fix_review, get_app_config, parse_installation_event, record_installation_repos, remove_installation, remove_installation_repos, set_fix_review_task_id, ) from cloud.webhooks import ( WebhookDeliveryStore, is_trusted_fix_review_author, parse_github_check_run, parse_github_pr_comment, parse_slack_command, verify_github_signature_any, verify_slack_signature, ) from cloud.handler_support import ( _apply_default_quota as _apply_default_quota, _auth_store as _auth_store, _default_quota_usd_micros as _default_quota_usd_micros, _ensure_followups_table as _ensure_followups_table, _ensure_quotas_table as _ensure_quotas_table, _env_int as _env_int, _environment_to_dict as _environment_to_dict, _global_admin_ids as _global_admin_ids, _task_to_dict as _task_to_dict, MAX_PROJECTS_PER_USER as MAX_PROJECTS_PER_USER, MAX_PROJECT_ID_LEN as MAX_PROJECT_ID_LEN, MAX_PROJECT_NAME_LEN as MAX_PROJECT_NAME_LEN, ) from cloud.handler_project import _validate_env_branch as _validate_env_branch # noqa: F401 # tests import it from cloud.server from cloud.handler_oauth import OAuthMixin from cloud.handler_identity import IdentityMemoryMixin from cloud.handler_project import ProjectMixin from cloud.handler_automation import AutomationMixin from cloud.handler_fullauto import FullAutoMixin # PR 事件推送失败时记日志(systemd 下进 journal);handler 的 # log_message 被重写为静默(测试输出干净),所以不用它。 _server_log = logging.getLogger("cloud.server") TERMINAL_STATUSES = frozenset( {"completed", "failed", "interrupted", "merged", "merge-failed"} ) # 线程改操作(rename/archive/unarchive/compact)只允许这三个终态: # 比 TERMINAL_STATUSES 窄——merged/merge-failed 的 thread 已随任务 # 终局,不再改;running/queued 一律 409。 THREAD_MUTATION_STATUSES = frozenset( {"completed", "failed", "interrupted"} ) # 实时链路(任务过程事件流):host 在回合进行中把 app-server # 通知摘要成条目追加写 jsonl,服务端 tail 文件经 SSE 推送。 TRACE_STREAM_MAX_SECONDS = 3600 TRACE_STREAM_POLL_SECONDS = 1.0 TRACE_DEFAULT_DIR = "/opt/codex-app-server/work/host-traces" # P1 控制面限额(建项目时校验,超限 400 并在错误里写明数值): # 单用户项目数上限、项目名长度上限、自定义项目 id 长度上限。 # 均可经环境变量调(运维侧),缺省值见下方;改动只影响新项目, # 已有超限项目不受影响(不删、不改名)。 # 协调员父任务预留:每分工至少预留的美元额度(微美元)。 # 父任务准入只查一次时,N 个分工可解锁 N 倍消费;本预留要求 # 剩余配额 >= 分工数 × 本值,否则父任务直接 402(见 # _check_coordinator_reserve)。缺省 $1/分工,可经环境变量调; # 它是准入下限估计,不是实际计费——实际花费仍按子任务 # created_by=父任务创建人记到创建人账上(worker 建子任务时 # created_by 直接取父任务创建人,成本 JOIN 据此归属)。 COORDINATOR_RESERVE_PER_PART_USD_MICROS = _env_int( "CODEX_COORDINATOR_RESERVE_PER_PART_USD_MICROS", 1000000) def _trace_file_name(task_id): """云任务 id -> 链路文件名:与 cloud.sandbox 的 _host_name 同款清洗(只保留 [A-Za-z0-9_.-],其余替 -),两边算出同一名字。""" return re.sub(r"[^A-Za-z0-9_.-]", "-", str(task_id)) def _trace_dir_for(server): """链路目录解析:server 属性(单测注入)> 环境变量 > 默认路径。""" d = getattr(server, "trace_dir", None) if d: return str(d) return os.environ.get("CODEX_TRACE_DIR") or TRACE_DEFAULT_DIR TENANTS_DEFAULT_ROOT = "/var/lib/codex-app-server/tenants" def _tenants_root_for(server): """租户根目录:server 属性(单测注入)> 环境变量 > 默认路径。""" d = getattr(server, "tenants_root", None) if d: return str(d) return os.environ.get("CODEX_TENANTS_ROOT") or TENANTS_DEFAULT_ROOT def _tenant_trace_dir(server, project_id): """项目对应的租户链路目录(租户 Docker 任务的链路文件落在这里)。 目录名清洗与 worker._tenant_safe_name 同规则;项目 id 映射 不出安全名时返回 None(只读共享目录)。 """ if not project_id: return None safe = re.sub(r"[^A-Za-z0-9_.-]", "-", str(project_id)).strip(".") if not safe or safe in (".", ".."): return None return os.path.join(_tenants_root_for(server), safe, "work", "host-traces") def _trace_dirs_for_task(server, task): """任务的链路目录列表:租户目录优先、共享目录兜底。 任务可能在不同执行器上跑过(重试换执行器),两个目录的 文件都读、按 id / (thread, id) 去重合并;通常只有一个目录 里有文件,行为与以前完全一致。 """ dirs = [] tenant = _tenant_trace_dir(server, task.get("project_id")) if tenant: dirs.append(tenant) shared = _trace_dir_for(server) if shared not in dirs: dirs.append(shared) return dirs # -- 线程 / skills / MCP 元数据操作(经 host.py CLI 透传)-------------- # 元数据操作,不跑模型回合:只调 thread/list、thread/read、 # thread/name/set、thread/archive、thread/unarchive、 # thread/compact/start、skills/list、app/list、 # mcpServerStatus/list、config/mcpServer/reload、 # mcpServer/oauth/login 这类只读或线程管理方法,不开 turn, # 因此不违反本文件开头“不直接跑模型回合”的原则。 # # host.py CLI 契约(与正在加 thread-* 子命令的另一路对齐,见 # 各 handler 的 argv 注释):子命令只往 stdout 打一个 JSON # 对象;失败时 exit 非零且 stdout 是 {"error": ...}。本模块 # 解析 whole-stdout JSON,解析失败或 host.py 缺失一律 502, # 绝不伪造成功。argv 里的子命令名与参数名是本批的约定, # host.py 侧实现时须与之一致(单测用 stub 脚本按此约定伪造)。 def _host_py_path(): """host.py 路径:环境变量 CODEX_HOST_PY 优先,否则取 cloud/server.py 所在目录的上一级 host.py(与 worker 的 --host-py 缺省规则同款)。""" env_path = os.environ.get("CODEX_HOST_PY") if env_path: return env_path here = os.path.dirname(os.path.abspath(__file__)) return os.path.join(os.path.dirname(here), "host.py") def _run_host_cli(argv, codex_home, timeout=60, model_provider=None): """调 host.py CLI 子命令,返回 (ok, result_or_error)。 ok 为 True 时 result 是 stdout 解析出的 JSON 对象;ok 为 False 时 result 是可直接放进 502 error 字段的错误文本 (CLI 自带的 error 原样透传,解析失败/缺文件/超时则给 明确的服务端错误,绝不把 stderr 或原始 key 混进响应)。 model_provider 是 host.py 顶层参数,必须放在子命令之前: app-server 按进程配置的 provider 过滤 thread/list,不带 注入时 sub2api 下建的 thread 在列表里根本看不到(见 _list_task_threads 的 provider 重试),只在解析处传, 绝不由调用方把 provider 拼进 argv(子命令解析器不认)。 """ host_py = _host_py_path() if not host_py or not os.path.isfile(host_py): return False, "host.py not found" cmd = [sys.executable, host_py] if codex_home: cmd += ["--codex-home", codex_home] if model_provider: cmd += ["--model-provider", model_provider] cmd += list(argv) try: proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) except subprocess.TimeoutExpired: return False, "host.py timed out" except OSError as exc: return False, "cannot run host.py: %s" % exc stdout = (proc.stdout or "").strip() try: parsed = json.loads(stdout) if stdout else None except ValueError: return False, "host.py output is not valid JSON" if proc.returncode != 0: err = None if isinstance(parsed, dict): err = parsed.get("error") return False, str(err) if err else ( "host.py failed with exit %d" % proc.returncode) if not isinstance(parsed, dict): return False, "host.py output is not a JSON object" if parsed.get("error"): return False, str(parsed["error"]) return True, parsed def _tenant_safe_project_name(project_id): """项目 id -> 租户目录安全名,与 worker._tenant_safe_name 同规则;映射不出安全名时返回 None。""" if not project_id: return None safe = re.sub(r"[^A-Za-z0-9_.-]", "-", str(project_id)).strip(".") if not safe or safe in (".", ".."): return None return safe def _tenant_codex_home(server, project_id): """项目对应的租户 codex-home:tenants_root/<安全项目名>/ codex-home。线程持久化在 CODEX_HOME 里,租户按项目分 home,因此线程操作必须带这个 home,不能用共享 home。""" safe = _tenant_safe_project_name(project_id) if not safe: return None return os.path.join(_tenants_root_for(server), safe, "codex-home") def _local_worker_codex_home(): """本地 worker 的共享 codex-home(兜底解析源)。 本地 worker 按 host-config-host.json 跑,codex_home 是该 配置里的共享目录,不在租户 home 里;服务端主解析先查 租户 home,空时才查这里——只读该配置文件的 codex_home 一个键,文件不存在、解析失败或键为空时回 None(调用方 跳过该候选,不因此 502)。相对路径按配置文件所在目录 解析(与 host.py 的 _resolve_path 同规则)。 """ try: cfg_path = os.path.join( os.path.dirname(_host_py_path()), "host-config-host.json") with open(cfg_path, encoding="utf-8") as f: cfg = json.load(f) home = cfg.get("codex_home") if isinstance(cfg, dict) else None if not home or not isinstance(home, str): return None home = home.strip() if not home: return None if not os.path.isabs(home): home = os.path.join(os.path.dirname(cfg_path), home) return home except Exception: return None def _resolve_shared_home_mcp(server=None): """共享 home 解析:供服务端启动时的 MCP 脚本同步用。 优先级(生产路径绝不硬编码在这里,全部来自已有解析): 1. _local_worker_codex_home():读 host-config-host.json 的 codex_home,生产即共享 home; 2. 兜底:tenants_root 的父目录 + codex-home(tenants_root 本身来自 server 属性 / CODEX_TENANTS_ROOT / 默认值)。 任一解析不出时返回 None,调用方跳过同步、不因此报错。 注意:本函数只解析路径,不做文件同步;文件清单与同步 语义只在 cloud/shared_home_mcp.py 里。 """ local = _local_worker_codex_home() if local: return local try: root = _tenants_root_for(server) except Exception: return None if not root: return None return os.path.join(os.path.dirname(root), "codex-home") def _tenant_work_dir(server, project_id): """项目对应的租户工作目录:tenants_root/<安全项目名>/work, skills/list 的 cwds 过滤用它(不传 cwd 时 app-server 会 默认用自身 cwd,那是错的 home 目录)。""" safe = _tenant_safe_project_name(project_id) if not safe: return None return os.path.join(_tenants_root_for(server), safe, "work") def _thread_worktree_for_task(server, task): """本地任务的 worktree 推导:host.py dispatch 有仓库时在 /work/host-worktrees/ 建 worktree, host 任务名 = 任务 id 的安全字符清洗(与 sandbox 的 _host_name 同规则)。无仓库任务的 thread cwd 是 worker 的 仓库路径或 host.py 当前目录,服务端表里没有、推不出来, 此时按此路径过滤会返回零个 thread,调用方拿到 404—— 这是诚实结果,不是静默错指。部署可注入 server.host_worktrees_dir(/pr 路径同款属性)或环境变量 CODEX_HOST_WORKTREES_DIR 覆盖 worktree 根。""" base = getattr(server, "host_worktrees_dir", None) if not base: base = os.environ.get("CODEX_HOST_WORKTREES_DIR") if not base: base = os.path.join(os.path.dirname(_host_py_path()), "work", "host-worktrees") safe = re.sub(r"[^A-Za-z0-9_.-]", "-", str(task["id"])) return os.path.join(base, safe) def _task_worktree_for_files(server, task): """文件端点用的任务 worktree 解析。 本地任务走 _thread_worktree_for_task;租户 Docker 任务走 tenants_root/<安全项目名>/work/host-worktrees/<安全任务名>—— worker 在租户 work 目录下按任务建子目录(生产实查 wwww 租户 下 62 个任务目录皆如此,安全名规则与 worker 侧一致)。绝不 直接 serves 共享的 /app/work 挂载点本身,只 serves 任务级子 目录,所以不存在按任务错指。cloud 任务与跨机 worktree 一律 409;两个候选路径都没有时 404(任务未启动或目录已清理)。 返回 (path, None) 或 (None, (status, msg))。 """ if task.get("executor") == "cloud": return None, (409, "cloud task worktrees are not on this host") worktree_host = task.get("worktree_host") if worktree_host and worktree_host != socket.gethostname(): return None, (409, "task worktree is on host %s;" " files must be read there" % worktree_host) safe_task = re.sub(r"[^A-Za-z0-9_.-]", "-", str(task["id"])) safe_project = _tenant_safe_project_name(task.get("project_id")) if safe_project: tenant_path = os.path.join( _tenants_root_for(server), safe_project, "work", "host-worktrees", safe_task) if os.path.isdir(tenant_path): return tenant_path, None local_path = _thread_worktree_for_task(server, task) if os.path.isdir(local_path): return local_path, None return None, (404, "no local worktree for this task") def _safe_worktree_path(worktree, rel): """worktree 内相对路径的安全解析:判定收敛到 cloud/gates.py (空串返回 worktree 本身;绝对路径、.. 逃逸、符号链接 逃逸一律返回 None;目录内的符号链接指回内部时放行, 指出去时拦住;目录违规无任何覆盖通道)。""" return _gate_resolve_inside(worktree, rel) def _extract_thread_list(result): """从 thread-list 的 CLI 结果里取 thread 列表:标准形态是 ThreadListResponse 的 data;threads 键与 result 包裹是 容忍形态(CLI 形状未定前不因键名不同就 502)。取不到 列表时返回 None(调用方按 502 处理)。""" if not isinstance(result, dict): return None for key in ("data", "threads"): value = result.get(key) if isinstance(value, list): return value inner = result.get("result") if isinstance(inner, dict): for key in ("data", "threads"): value = inner.get(key) if isinstance(value, list): return value return None def _extract_thread(result): """从 thread-read 的 CLI 结果里取 thread 对象:标准形态是 ThreadReadResponse 的 thread;结果本身就是 thread 对象 时也接受。取不到时返回 None。""" if not isinstance(result, dict): return None thread = result.get("thread") if isinstance(thread, dict): return thread if isinstance(result.get("id"), str): return result inner = result.get("result") if isinstance(inner, dict): thread = inner.get("thread") if isinstance(thread, dict): return thread if isinstance(inner.get("id"), str): return inner return None def _pick_latest_thread(threads, include_archived=False): """cwd 过滤返回多个 thread 时的取用规则:取最新一个非 archived。协议说明:Thread 对象本身没有 archived 字段, 非归档由 thread/list 的 archived 过滤保证(不传 archived 时只回非归档 thread);这里只防御性地跳过显式带 archived 真值的条目(CLI 形状若补了该字段时仍正确)。 include_archived 为真时不跳过(unarchive 解析专用的 已归档列表;实测 archived 列表里条目的 archived 字段 为 None,不跳过也能命中,但显式放行更稳)。 最新 = updatedAt 最大,缺失时退 createdAt,再缺失时按 列表原顺序取后者(同秒时后到的更新)。""" best = None best_key = None for idx, item in enumerate(threads): if not isinstance(item, dict): continue if item.get("archived") and not include_archived: continue ts = item.get("updatedAt") if ts is None: ts = item.get("createdAt") try: ts = float(ts) except (TypeError, ValueError): ts = 0 key = (ts, idx) if best_key is None or key > best_key: best = item best_key = key return best def _extract_result_list(result, keys): """从 skills/apps/mcp 列表类 CLI 结果里取列表:标准形态 是各 Response 的 data;传入的备用键是容忍形态。取不到 时返回 None。""" if not isinstance(result, dict): return None for key in keys: value = result.get(key) if isinstance(value, list): return value inner = result.get("result") if isinstance(inner, dict): for key in keys: value = inner.get(key) if isinstance(value, list): return value return None def _read_trace_entries(path): """读 host 写的 jsonl 链路文件:跳过坏行;按 id 去重,后到的 完成态覆盖先到的开始态,保首次顺序。文件不存在返回空列表。""" entries = [] index = {} try: with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: entry = json.loads(line) except ValueError: continue if not isinstance(entry, dict) or "id" not in entry: continue eid = entry["id"] if eid in index: entries[index[eid]] = entry else: index[eid] = len(entries) entries.append(entry) except OSError: return [] return entries def _read_trace_entries_multi(paths): """多文件合并读(flat trace 用):按 id 去重,后到的覆盖先到的, 保首次顺序;文件不存在视为空。""" entries = [] index = {} for path in paths: for e in _read_trace_entries(path): if e["id"] in index: entries[index[e["id"]]] = e else: index[e["id"]] = len(entries) entries.append(e) return entries AGENT_CALL_LIMIT = 1000 def _read_agent_entries(path): """读链路文件供子代理聚合:按 (thread, id) 去重,后到的完成态 覆盖先到的开始态,保首次顺序;坏行与无 id 行跳过;文件不 存在为空。同一 item id 在不同线程出现时不互相覆盖(模型内 子代理与主任务各有自己的线程)。""" entries = [] index = {} try: with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: entry = json.loads(line) except ValueError: continue if not isinstance(entry, dict) or "id" not in entry: continue key = (entry.get("thread") or "", entry["id"]) if key in index: entries[index[key]] = entry else: index[key] = len(entries) entries.append(entry) except OSError: return [] return entries def _read_agent_entries_multi(paths): """多文件合并读(agents 聚合用):按 (thread, id) 去重,后到的 覆盖先到的,保首次顺序;文件不存在视为空。""" entries = [] index = {} for path in paths: for e in _read_agent_entries(path): key = (e.get("thread") or "", e["id"]) if key in index: entries[index[key]] = e else: index[key] = len(entries) entries.append(e) return entries def _latest_ts(values): vals = [v for v in values if isinstance(v, (int, float))] return max(vals) if vals else None def _agent_status_from_task(status): return {"queued": "running", "running": "running", "completed": "completed", "merged": "completed", "failed": "failed", "merge-failed": "failed", "interrupted": "interrupted"}.get(status, "unknown") def _build_agent_chain(task, trace_dirs): """任务 → 子代理链:主任务 + 模型内子代理(同文件多线程)+ 委派子任务(父链路里的 agent-start/done 标记 + 子链路文件)。 trace_dirs 是 _trace_dirs_for_task 给的目录列表(租户优先、 共享兜底);父链路与子链路文件都在这些目录里找、合并读。 状态诚实口径:委派子任务以 agent-done 标记为准,无标记即 进行中——委派进程与父任务相互独立,父任务结束不代表子任务 停;模型内子代理无明确结束信号,父任务运行中即进行中, 否则未知。无 thread 的旧条目一律归主任务。 """ parent_paths = [os.path.join(d, _trace_file_name(task["id"]) + ".jsonl") for d in trace_dirs] entries = _read_agent_entries_multi(parent_paths) calls = [e for e in entries if e.get("kind") not in ("agent-start", "agent-done")] main_thread = None for e in calls: if e.get("thread"): main_thread = e["thread"] break root_calls = [e for e in calls if not e.get("thread") or e.get("thread") == main_thread] agents = [{ "id": "main", "name": "主任务", "kind": "main", "parent": None, "status": _agent_status_from_task(task.get("status")), "prompt": None, "trace": None, "calls": root_calls[:AGENT_CALL_LIMIT], "updated_ts": _latest_ts([e.get("ts") for e in root_calls]), }] task_running = task.get("status") in ("queued", "running") threads = {} for e in calls: t = e.get("thread") if t and t != main_thread: threads.setdefault(t, []).append(e) for t, es in threads.items(): agents.append({ "id": t, "name": "模型子代理 " + t[:12], "kind": "model", "parent": "main", "status": "running" if task_running else "unknown", "prompt": None, "trace": None, "calls": es[:AGENT_CALL_LIMIT], "updated_ts": _latest_ts([e.get("ts") for e in es]), }) starts = {} dones = {} for e in entries: if e.get("kind") == "agent-start" and e.get("name"): starts[e["name"]] = e elif e.get("kind") == "agent-done" and e.get("name"): dones[e["name"]] = e for name, s in starts.items(): trace_name = s.get("trace") or dones.get(name, {}).get("trace") child_calls = [] if trace_name: child_paths = [os.path.join(d, os.path.basename(trace_name)) for d in trace_dirs] child_calls = [e for e in _read_agent_entries_multi(child_paths) if e.get("kind") not in ("agent-start", "agent-done")] done = dones.get(name) status = "running" if done and done.get("status") in ( "completed", "failed", "interrupted"): status = done["status"] agents.append({ "id": "delegate:" + name, "name": name, "kind": "delegate", "parent": "main", "status": status, "prompt": s.get("prompt"), "trace": trace_name, "calls": child_calls[:AGENT_CALL_LIMIT], "updated_ts": _latest_ts( [e.get("ts") for e in child_calls] + [s.get("ts"), done.get("ts") if done else None]), }) return agents # 陈旧回收阈值默认值收敛到 cloud/timeouts.py(别名保留)。 DEFAULT_SWEEP_THRESHOLD_SECONDS = _STALE_SWEEP # 限流默认值:每 IP / 每 API key 每分钟 120 次;环境变量 # CLOUD_RATE_LIMIT_PER_MINUTE / CLOUD_RATE_LIMIT_WINDOW_SECONDS 可调。 # 限流默认内存滑动窗口;Postgres 下改走 DB 共享计数(多 server # 共用,见 DbRateLimiter),并发下可能多放行少量请求(非原子计数)。 DEFAULT_RATE_LIMIT_PER_MINUTE = 120 DEFAULT_RATE_LIMIT_WINDOW_SECONDS = 60 TASK_STATUS_KEYS = ( "queued", "running", "completed", "failed", "interrupted", "merged", "merge-failed", ) def _json_default(obj): """json 兜底:Postgres 的 SUM(bigint) 返回 NUMERIC,psycopg2 映成 Decimal,不处理它在 _send_json 里直接炸 TypeError、请求 线程死掉、客户端只看到连接断开(2026-10-26 项目成本按天实弹 就是这么断的)。整数值 Decimal 转 int,非整数转 float;其它 类型照旧抛 TypeError,不静默吞掉真正的类型错误。""" if isinstance(obj, Decimal): return int(obj) if obj == obj.to_integral_value() else float(obj) raise TypeError("Object of type %s is not JSON serializable" % obj.__class__.__name__) def _rate_limit_from_env(name, default): try: value = int(os.environ.get(name) or default) except (TypeError, ValueError): return default return value if value > 0 else default class RateLimiter: """内存滑动窗口限流:每 key 每窗口最多 per_minute 次。 只在 allow() 内部持一把短锁做 deque 修剪与计数,不持锁做 任何 I/O,不会把正常请求串行化。key 由调用方给(本 server 传“key:”或“ip:<客户端 IP>”,原始 API key 绝不存进限流结构里)。窗口与阈值可在实例上改(测试注入小 阈值验证 429 就靠改这两个属性)。 """ def __init__(self, per_minute=DEFAULT_RATE_LIMIT_PER_MINUTE, window_seconds=DEFAULT_RATE_LIMIT_WINDOW_SECONDS, time_fn=None): self.per_minute = per_minute self.window_seconds = window_seconds self._time_fn = time_fn or time.time self._lock = threading.Lock() self._hits = {} def allow(self, key): now = self._time_fn() window = self.window_seconds limit = self.per_minute with self._lock: queue = self._hits.get(key) if queue is None: queue = deque() self._hits[key] = queue cutoff = now - window while queue and queue[0] <= cutoff: queue.popleft() if len(queue) >= limit: return False queue.append(now) # 键数兜底:长期运行的 server 不应让 _hits 无限增长; # 超过 10000 个 key 时清掉最久未用的空队列(不影响计数)。 if len(self._hits) > 10000: for old_key in list(self._hits): if not self._hits[old_key]: del self._hits[old_key] break return True class DbRateLimiter: """DB 共享滑动窗口限流(HA 阶段 2,多 server 共用计数)。 与 RateLimiter 同接口(allow(key),per_minute/window_seconds 属性可改,测试注入小阈值验证 429 的路子不变)。Postgres 下多 server 共享同一张表,计数跨进程;SQLite 下与内存版等价(单测用)。 并发下计数与写入非原子,可能多放行少量请求——限流是 abuse 缓解,不是精确配额。表与索引懒建(workers 表同款模式), 不进 POSTGRES_SCHEMA。列名用 hit_key(key 在部分方言里是 保留字,避开它)。 结构要点(2026-10-27 死锁修复):allow() 只做 INSERT + 窗口内 COUNT,不再 DELETE——旧实现每请求在同一事务里 DELETE 旧行 + INSERT + COUNT,控制台并发加载 + 双 server 下两个事务在 rate_limit_hits 表上死锁,未捕获的异常直接断连、nginx 报 502。过期行改由后台 sweep_rate_limit_hits() 定期清(见下), 请求路径上只剩 INSERT + COUNT,不存在 DELETE↔DELETE/INSERT 的死锁形状;COUNT 本身按窗口过滤,过期行残留不影响计数 正确性,只影响表大小。 """ def __init__(self, db_path, per_minute=DEFAULT_RATE_LIMIT_PER_MINUTE, window_seconds=DEFAULT_RATE_LIMIT_WINDOW_SECONDS, time_fn=None): self.db_path = str(db_path) self.per_minute = per_minute self.window_seconds = window_seconds self._time_fn = time_fn or time.time self._ensure_lock = threading.Lock() self._ensured = False def _ensure_table(self, conn): """建表与索引:每实例只做一次(首个 allow 时),不是每请求 都跑 DDL——DDL 也会拿锁,没必要让每个请求都经过它。""" if self._ensured: return with self._ensure_lock: if self._ensured: return conn.execute( "CREATE TABLE IF NOT EXISTS rate_limit_hits(" "hit_key TEXT, ts REAL)") conn.execute( "CREATE INDEX IF NOT EXISTS rate_limit_hits_key_ts" " ON rate_limit_hits(hit_key, ts)") conn.commit() self._ensured = True def allow(self, key): now = self._time_fn() window = self.window_seconds limit = self.per_minute conn = _db_connect(self.db_path) try: self._ensure_table(conn) conn.execute( "INSERT INTO rate_limit_hits(hit_key, ts) VALUES(?, ?)", (key, now)) row = conn.execute( "SELECT COUNT(*) FROM rate_limit_hits" " WHERE hit_key = ? AND ts > ?", (key, now - window)).fetchone() conn.commit() return row[0] <= limit finally: conn.close() def _make_rate_limiter(db_path): """建限流器:Postgres 下用 DB 共享计数(多 server 共用), 否则用内存版(单机与单测)。后端只看环境变量,与其它分发一致。""" per_minute = _rate_limit_from_env( "CLOUD_RATE_LIMIT_PER_MINUTE", DEFAULT_RATE_LIMIT_PER_MINUTE) window_seconds = _rate_limit_from_env( "CLOUD_RATE_LIMIT_WINDOW_SECONDS", DEFAULT_RATE_LIMIT_WINDOW_SECONDS) if _db_backend_name() == "postgres": return DbRateLimiter(db_path, per_minute=per_minute, window_seconds=window_seconds) return RateLimiter(per_minute=per_minute, window_seconds=window_seconds) RATE_LIMIT_SWEEP_INTERVAL_SECONDS = 60 # Postgres advisory lock key:两个 server 各起一个 sweeper 时,只 # 有拿到锁的那个真正 DELETE,另一个直接跳过——清理者之间也不 # 会互相死锁。本项目其它地方不用 advisory lock,不会撞 key。 RATE_LIMIT_SWEEP_ADVISORY_KEY = 20261027 def sweep_rate_limit_hits(db_path, window_seconds, now=None): """删过期 hit 行:只由后台定期调用,不在请求路径上。 返回 True 表示本次真正执行了删除(表为空时也算),False 表示 Postgres 下没拿到 advisory 锁、另一台 server 正在清, 本次跳过。清理是纯卫生(防表无限增长):allow() 的 COUNT 本身按窗口过滤,过期行残留不影响计数,所以清理失败或 跳过都不影响限流正确性,只影响表大小。 """ now = time.time() if now is None else now conn = _db_connect(db_path) try: conn.execute( "CREATE TABLE IF NOT EXISTS rate_limit_hits(" "hit_key TEXT, ts REAL)") conn.execute( "CREATE INDEX IF NOT EXISTS rate_limit_hits_key_ts" " ON rate_limit_hits(hit_key, ts)") if _db_backend_name() == "postgres": row = conn.execute( "SELECT pg_try_advisory_xact_lock(?)", (RATE_LIMIT_SWEEP_ADVISORY_KEY,)).fetchone() if not row or not row[0]: return False conn.execute( "DELETE FROM rate_limit_hits WHERE ts <= ?", (now - window_seconds,)) conn.commit() return True finally: conn.close() def start_rate_limit_sweeper(limiter, db_path): """给 server 主进程起后台清理线程:每 60 秒清一次过期行。 只对 DbRateLimiter 起(内存版在 allow() 里自行修剪,不需要)。 线程 daemon、异常自愈(记 warning 后继续下一轮),绝不因 清理失败拖垮 server。调用方只在 main() 里调一次,测试不调。 """ if not isinstance(limiter, DbRateLimiter): return None def _loop(): while True: time.sleep(RATE_LIMIT_SWEEP_INTERVAL_SECONDS) try: sweep_rate_limit_hits(db_path, limiter.window_seconds) except Exception as exc: # noqa: BLE001 -- 清理是卫生操作 _server_log.warning("rate limit sweep failed: %s", exc) thread = threading.Thread(target=_loop, daemon=True, name="rate-limit-sweeper") thread.start() return thread def _db_backend_name(): """当前数据库后端名:环境变量,未设则 sqlite;绝不返回连接串。""" return (os.environ.get("CLOUD_DB_BACKEND") or "sqlite").strip().lower() def _deployed_commit(): """当前上线代码的 commit:deploy.py 部署时在包根写的 DEPLOYED_COMMIT。 文件不存在、读失败、内容为空时返回 None(老包、本地开发都走这支), health 绝不因此失败。commit 是公开的版本标识,不是密钥。 路径取 cloud/ 的父目录(即仓库根),不依赖当前工作目录。""" path = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "DEPLOYED_COMMIT") try: with open(path, encoding="utf-8") as f: value = f.read().strip() except Exception: return None return value or None def _worker_heartbeat_status(server=None): """读 worker 运维心跳,返回 (age_seconds_or_None, worker_ok)。 优先读 workers 注册表(多机前提):所有 worker 每轮都往 workers 表写 last_seen(见 worker._registry_heartbeat),而文件 心跳只在本机存在——多 server 下 server 读不到别机 worker 的 文件,注册表是唯一跨机可靠的心跳源。表不存在、无行、查库 失败时回落文件心跳(单机旧行为与单测保持兼容)。 文件心跳路径:测试注入优先(server.worker_heartbeat_file), 其次环境变量 CLOUD_WORKER_HEARTBEAT_FILE,未设则默认 ./worker-heartbeat.json——与 worker 的默认路径保持一致,主机 部署时两边都要指到同一个主机路径(见 deploy/README)。 阈值:server.worker_heartbeat_max_age_seconds(测试注入)或 环境变量 CLOUD_WORKER_HEARTBEAT_MAX_AGE_SECONDS,默认 180 秒。 文件不存在、读失败、JSON 非法、last_seen 非数字时一律返回 (None, False),不抛异常、不影响 health 本身的 200。 """ max_age = 180 if server is not None: override = getattr(server, "worker_heartbeat_max_age_seconds", None) if override is not None: try: max_age = float(override) except (TypeError, ValueError): max_age = 180 if max_age == 180: try: max_age = float(os.environ.get( "CLOUD_WORKER_HEARTBEAT_MAX_AGE_SECONDS") or 180) except (TypeError, ValueError): max_age = 180 db_path = getattr(server, "db_path", None) if server is not None else None if db_path: try: conn = _connect(db_path) try: row = conn.execute( "SELECT MAX(last_seen) FROM workers").fetchone() finally: conn.close() if row is not None and row[0] is not None: age = time.time() - float(row[0]) return round(age, 1), bool(age <= max_age) except Exception: pass path = None if server is not None: path = getattr(server, "worker_heartbeat_file", None) if not path: path = os.environ.get("CLOUD_WORKER_HEARTBEAT_FILE") or \ "./worker-heartbeat.json" try: with open(path, encoding="utf-8") as f: payload = json.load(f) last_seen = float(payload.get("last_seen")) except Exception: return None, False age = time.time() - last_seen return round(age, 1), bool(age <= max_age) def _connect(db_path): """每个请求新建一个连接;后端按 CLOUD_DB_BACKEND 分发。 cloud.db.connect() 本身已按环境变量分发:sqlite 时是 WAL + busy_timeout 的 SQLite 连接;postgres 时是 psycopg 适配连接(db_path 被忽略,连接参数只走 CLOUD_DB_* 环境变量,驱动缺失时明确报错,绝不回落)。因此 server 自身的表与 AuthStore / MemoryStore 等沿用 connect() 的 调用点在 PG 模式下同样走 Postgres,无需逐一改写。 """ return _db_connect(str(db_path)) def migrate_tasks_table(conn): """幂等迁移:给老库 tasks 表补新列,不丢数据。 新列:result_text、error_text、actual_model、started_at、 finished_at、child_pid、executor、pr_url、pr_number,以及 goal、verify_command、goal_max_rounds(goal 模式用)、 goal_mode(新 goal 语义:1 时 prompt 即目标,无验收命令、 不限轮次;老行 NULL 按 0 计)。 PRAGMA table_info 查现有列,缺哪个补哪个;已迁移的库再跑 一次是 no-op。worker 写执行结果、server 读任务时都依赖 这些列存在。 """ cols = {row[1] for row in conn.execute( "PRAGMA table_info(tasks)").fetchall()} new_cols = { "result_text": "TEXT", "result_text_full": "TEXT", "error_text": "TEXT", "actual_model": "TEXT", "started_at": "REAL", "finished_at": "REAL", "child_pid": "INTEGER", "executor": "TEXT", "pr_url": "TEXT", "pr_number": "INTEGER", "goal": "TEXT", "verify_command": "TEXT", "goal_max_rounds": "INTEGER", "goal_mode": "INTEGER", # HA 阶段 1:认领任务的 worker 所在主机名;/pr 据此判断 # worktree 在本机(同步跑)还是别机(排 pr 任务给该主机)。 "worktree_host": "TEXT", # 沙箱规模:认领任务的 worker_id(worker._claim 在 CAS # 认领时写);失联容器识别的 owner 依据,见 # workers.orphaned_containers。老行 NULL = 无 owner 记录。 "claimed_by_worker": "TEXT", # 任务级选项(console 选模型/强度/权限用):NULL = 跟随 # worker 配置默认值;model 不做白名单(中转目录会变, # 下拉只是建议值),effort/approval 由 _create_task 校验。 "model": "TEXT", "effort": "TEXT", "approval": "TEXT", # 派生默认(子任务模型/强度):NULL = 没有单独声明, # 派生时先取它、再取本任务的 model / effort;显式 # 传 child_model / child_effort,或建任务 prompt 里 # 写明“子任务用 X max”这类显式指令时由服务端解析 # 填入(见 marker_tools.parse_spawn_defaults)。 "child_model": "TEXT", "child_effort": "TEXT", # 计划模式(只读)与分支来源:plan=1 时 worker 用 # read-only 沙箱;forked_from 记源任务 id(非分支为 NULL)。 "plan": "INTEGER", "forked_from": "TEXT", # 环境:任务绑定的环境 id(NULL = 不指定环境,走原有 # 项目仓库路径);环境本身是项目仓库的一个命名基准检出 # (分支 + 执行器),任务 worktree 从它建,不改任务隔离。 "environment_id": "TEXT", # 续跑消息:旧任务继续会话时本轮要说的话(NULL = 没有待 # 续跑消息)。worker 认领后把 prompt 换成它跑一轮,跑完 # 清掉;任务详情里照常显示原 prompt,续跑消息只作本轮输入。 "resume_message": "TEXT", # 任务列表显示 turn 用:最近一轮的 turn_id 与 # root_turn_id(worker 写回时只在新值非空时覆盖)。 "turn_id": "TEXT", "root_turn_id": "TEXT", # 协调员:coordinator_parts 记分工 JSON(普通任务为 NULL, # worker 认领后按它建并行子任务、等齐、合流);parent_task_id # 与 coordinator_part 记在子任务行上(父任务 id 与分工名, # 续跑按分工名复用已有子任务,不重复建)。 "coordinator_parts": "TEXT", "parent_task_id": "TEXT", "coordinator_part": "TEXT", # G10:goal 断点续跑待处理标记(1 = 已调 /goal-resume、 # 待 worker 按断点续跑;worker 认领后清掉,跑完不再 # 重复触发)。与 resume_message 不同:后者是“给旧会 # 话发一条新消息、开新循环”,本标记是“从 # goal_rounds_done 的下一轮接着跑同一循环”。 "goal_resume_pending": "INTEGER DEFAULT 0", # 长任务自动恢复:paused_by_user=1 只由用户暂停/ # 取消(/pause、/cancel)写,系统中断(sweep、worker # 退出、配额除外)绝不写它;interrupted_reason 记 # 中断来源(user_pause/user_cancel/sweep/quota/ # coordinator_orphan/worker_shutdown),NULL = 老行或 # 未分类系统中断,按可自动恢复处理;auto_resume_count # 记自动续跑次数(防确定性失败无限循环的上限门); # no_time_limit=1 的任务 worker 级不限时(见 worker # _run_attempt,host 单轮 600 秒仍是单轮护栏)。 "paused_by_user": "INTEGER DEFAULT 0", "interrupted_reason": "TEXT", "auto_resume_count": "INTEGER DEFAULT 0", "no_time_limit": "INTEGER DEFAULT 0", # P0 三件:自动恢复退避与无进展熔断(见 worker # auto_resume_interrupted):resume_after 是下次允许 # 重排队的时间戳(指数退避);auto_resume_stall_count # 记连续无进展重排队次数(到 3 即停、标 # auto_resume_stalled 等人工);checkpoint_round_at_last_ # resume 记上次重排队时库中断点轮次(无进展判定的 # 基线,NULL = 从未记过);last_error_signature 记 # 最近一次终局的错误签名(permanent/transient/None, # 供排障,不作恢复判定——判定在 sandbox 终态里)。 "resume_after": "DOUBLE PRECISION DEFAULT 0", "auto_resume_stall_count": "INTEGER DEFAULT 0", "checkpoint_round_at_last_resume": "INTEGER", "last_error_signature": "TEXT", } for name, coltype in new_cols.items(): if name not in cols: conn.execute( "ALTER TABLE tasks ADD COLUMN %s %s" % (name, coltype)) def migrate_projects_table(conn): """幂等迁移:给老库 projects 表补 repo_url 列,不丢数据。 与 migrate_tasks_table 同款写法:PRAGMA table_info 查现有列 (Postgres 适配层把它翻译成 information_schema 查询,调用方 取 row[1] 即列名),缺 repo_url 时 ALTER TABLE 补列,已迁移 的库再跑一次是 no-op。SQLite 与 Postgres 两支都走它。 """ cols = {row[1] for row in conn.execute( "PRAGMA table_info(projects)").fetchall()} if "repo_url" not in cols: conn.execute("ALTER TABLE projects ADD COLUMN repo_url TEXT") if "auto_pr" not in cols: conn.execute("ALTER TABLE projects ADD COLUMN auto_pr INTEGER") def _upload_dir_for(server): """附件暂存根目录:server 属性(单测注入)> 环境变量 > 默认路径 (与 cloud.uploads.upload_root 同款解析,worker 侧也用它)。""" d = getattr(server, "upload_dir", None) if d: return str(d) return _upload_root() def _ensure_uploads_tables(conn): """幂等建附件包与任务附件表:server init_db 调它,老库第一次 跑到这里时自动补表,不丢数据。uploads 记包的归属与状态 (pending/used,任务绑定时置 used,防一包多用); task_attachments 记任务绑定的每个文件(相对路径 + 大小), worker 派发时按 bundle_id 找到暂存目录。""" conn.execute( "CREATE TABLE IF NOT EXISTS uploads(" "id TEXT PRIMARY KEY, " "created_by TEXT, " "created_at DOUBLE PRECISION, " "status TEXT)" ) conn.execute( "CREATE TABLE IF NOT EXISTS task_attachments(" "task_id TEXT, " "bundle_id TEXT, " "rel_path TEXT, " "size INTEGER, " "PRIMARY KEY(task_id, rel_path))" ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_attachments_task" " ON task_attachments(task_id)") def _attachments_map(conn, task_ids): """一批任务的附件相对路径(按路径排序):{task_id: [rel_path]}。 任务列表接口用一次查询取全量,不按任务逐条查。""" out = {tid: [] for tid in task_ids} if not task_ids: return out placeholders = ", ".join("?" * len(task_ids)) rows = conn.execute( "SELECT task_id, rel_path FROM task_attachments" " WHERE task_id IN (%s) ORDER BY rel_path" % placeholders, tuple(task_ids), ).fetchall() for row in rows: tid = row[0] if isinstance(row, tuple) else row["task_id"] rel = row[1] if isinstance(row, tuple) else row["rel_path"] if tid in out: out[tid].append(rel) return out def _task_tokens_map(conn, task_ids): """一批任务的 cost_records token 合计:{task_id: total_tokens}。 任务列表接口用一次查询取全量,不按任务逐条调成本接口 (一屏 N 个任务就是 N 个请求,会拖慢列表刷新、还容易撞 限流)。无成本记录的任务计 0(不是缺失);cost_records 表还不存在时(全新库、CostStore 未建过表)同样全计 0, 列表绝不因此失败——0 在这里就是“未记录到任何 token”, 与 _metrics 的缺表按空统计口径一致。 """ out = {tid: 0 for tid in task_ids} if not task_ids: return out placeholders = ", ".join("?" * len(task_ids)) try: rows = conn.execute( "SELECT task_id, COALESCE(SUM(total_tokens),0) AS t" " FROM cost_records WHERE task_id IN (%s)" " GROUP BY task_id" % placeholders, tuple(task_ids), ).fetchall() except (sqlite3.OperationalError, _PgOperationalError): return out for row in rows: tid = row[0] if isinstance(row, tuple) else row["task_id"] if tid in out: out[tid] = int(row[1] if isinstance(row, tuple) else row["t"] or 0) return out def _ensure_task_checkpoints_table(conn): """幂等建断点表(P0 三件其一):server init_db(PG 与 SQLite 两支)与 worker 迁移兜底共用。DOUBLE PRECISION 在两后端都可用。 """ conn.execute( "CREATE TABLE IF NOT EXISTS task_checkpoints(" "task_id TEXT, " "round_no INTEGER, " "thread_id TEXT, " "goal_status TEXT, " "state_sha TEXT, " "verify_out TEXT, " "created_at DOUBLE PRECISION, " "PRIMARY KEY(task_id, round_no))") def _ensure_task_steps_table(conn): """幂等建步骤表(P0 三件其三):timeline 接口直读此表。 id 由写入方给 uuid hex(两后端都不依赖自增语义)。 """ conn.execute( "CREATE TABLE IF NOT EXISTS task_steps(" "id TEXT PRIMARY KEY, " "task_id TEXT, " "step_no INTEGER, " "kind TEXT, " "summary TEXT, " "state_sha TEXT, " "created_at DOUBLE PRECISION)") conn.execute( "CREATE INDEX IF NOT EXISTS idx_task_steps_task" " ON task_steps(task_id, step_no)") def _followup_to_dict(row): return { "id": row["id"], "task_id": row["task_id"], "project_id": row["project_id"], "body": row["body"], "mode": row["mode"], "status": row["status"], "created_by": row["created_by"], "sender_task_id": _row_get(row, "sender_task_id"), "created_at": row["created_at"], "updated_at": row["updated_at"], } def _ensure_continue_messages_table(conn): """幂等建继续消息表:POST /continue 接受的每条用户消息都在 与状态改回 queued 的同一事务里落盘(见 _continue_task), 否则继续消息只活在 resume_message 列里、worker 跑完即清, 聊天历史永远拼不出用户那一半。老库第一次跑到这里时自动 补表,不丢数据;此前已发生的继续(audit 里 task_continued 有、表里没有)无法追回,chat-history 会如实标注缺口数, 不编造。列写法与 followups 表一致(DOUBLE PRECISION 在 SQLite 与 Postgres 下都可用)。""" conn.execute( "CREATE TABLE IF NOT EXISTS task_continue_messages(" "id TEXT PRIMARY KEY, " "task_id TEXT, " "project_id TEXT, " "body TEXT, " "created_by TEXT, " "created_at DOUBLE PRECISION)" ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_continue_messages_task" " ON task_continue_messages(task_id, created_at, id)") def _continue_message_to_dict(row): return { "id": row["id"], "task_id": row["task_id"], "project_id": row["project_id"], "body": row["body"], "created_by": row["created_by"], "created_at": row["created_at"], } def _chat_message_from_trace(entry): """链路条目 -> 聊天消息(与 React 的 mapTrace.ts 同款映射, 两边改动要同步):message 是助手回复,exec/file/tool/call 是工具气泡,plan/reasoning/agent-start/agent-done 是居中 状态提示。链路条目本身没有发送方字段,这里不编造角色, 只按 kind 分流。""" kind = entry.get("kind") or "" eid = entry.get("id") or "" if kind == "message": text = ((("[" + entry["phase"] + "] ") if entry.get("phase") else "") + (entry.get("text") or "")) return {"id": eid, "role": "assistant", "source": "trace", "kind": kind, "label": "助手", "text": text} if kind in ("exec", "file", "tool", "call"): label = {"exec": "命令", "file": "文件", "tool": "工具"}.get(kind, "调用") if kind == "exec": # 与 React traceLineText 同款:缺失字段为空,不渲染 None。 def _s(v): return "" if v is None else str(v) text = "%s(exit=%s,%sms,cwd=%s)" % ( entry.get("command") or "", _s(entry.get("exitCode")), _s(entry.get("durationMs")), entry.get("cwd") or "") elif kind == "file": text = "%s %s %s" % (entry.get("path") or "", entry.get("change") or "", entry.get("status") or "") elif kind == "tool": text = "%s/%s %s" % (entry.get("server") or "", entry.get("tool") or "", entry.get("status") or "") else: text = entry.get("name") or "" return {"id": eid, "role": "tool", "source": "trace", "kind": kind, "label": label, "text": text} if kind == "plan": return {"id": eid, "role": "status", "source": "trace", "kind": kind, "text": entry.get("text") or ""} if kind == "reasoning": return {"id": eid, "role": "status", "source": "trace", "kind": kind, "text": "思考:" + (entry.get("summary") or "")} if kind == "agent-start": return {"id": eid, "role": "status", "source": "trace", "kind": kind, "text": "子代理开始:" + (entry.get("name") or "") + (("(" + entry["prompt"] + ")") if entry.get("prompt") else "")} if kind == "agent-done": return {"id": eid, "role": "status", "source": "trace", "kind": kind, "text": "子代理结束:" + (entry.get("name") or "") + " " + (entry.get("status") or "")} return {"id": eid, "role": "status", "source": "trace", "kind": kind, "text": json.dumps(entry)[:300]} def _ensure_environments_table(conn): """幂等建环境表:server init_db 与各环境接口都可调它,老库 第一次跑到这里时自动补表,不丢数据。类型写法与 followups 表一致(DOUBLE PRECISION 在 SQLite 与 Postgres 下都可用)。 环境 = 项目仓库的一个命名基准检出:repo_url 是创建时从 项目快照来的(之后项目改 repo_url 不影响已建环境),branch 为空表示默认分支,executor 为空表示不限执行器(任务创建时 若指定执行器须与它一致,否则继承它)。状态机:creating (已记录、等 worker 准备检出)-> ready / failed;archive 后新任务不能再选它,旧任务结果不受影响。检出目录本身不 存绝对路径(worker 按 repos_dir / 租户目录推导,见 worker 的 _env_host_dir),避免 server 与 worker 机器路径不一致。 """ conn.execute( "CREATE TABLE IF NOT EXISTS environments(" "id TEXT PRIMARY KEY, " "project_id TEXT, " "name TEXT, " "branch TEXT, " "executor TEXT, " "status TEXT, " "error_text TEXT, " "repo_url TEXT, " "created_by TEXT, " "created_at DOUBLE PRECISION, " "updated_at DOUBLE PRECISION)" ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_env_project" " ON environments(project_id, status)") # 同项目未归档环境名唯一:用部分唯一索引(SQLite 与 Postgres # 都支持 WHERE 子句),归档过的名字可重用——全表 UNIQUE # 会挡住重用,与 _create_environment 的 409 规则矛盾。 conn.execute( "CREATE UNIQUE INDEX IF NOT EXISTS idx_env_name" " ON environments(project_id, name)" " WHERE status != 'archived'") def _default_executor(): """新任务未指定执行器时的默认值:docker。 空 executor(任意 worker 可认领)是多用户下的隔离缺口: 任务可能落到隔离较弱的 local worker 上。默认 docker 后, 只有显式 local/cloud 或系统创建(webhook/修复任务,executor 为 NULL)才走别的路。环境变量 CLOUD_DEFAULT_EXECUTOR 可 覆盖:设为空字符串即恢复旧行为(None,任意 worker), 供单机运维在没有 docker worker 时兜底;非法值按 docker 计。 """ raw = os.environ.get("CLOUD_DEFAULT_EXECUTOR", "docker") if raw is None: return "docker" raw = raw.strip() if raw == "": return None if raw in ("local", "docker", "cloud"): return raw return "docker" # -- 全自动执行器 HTTP 接线(cloud/full_auto.py 的薄端点层) -------- # 只做接线,不发明新护栏:开关、动作白名单、分支保护、脱敏、 # 限次修复全部复用 full_auto.py;本层只负责认证、管理员门、 # installation 验证、token 解析与审计落盘。token 只进请求头, # 绝不进命令行、文件、审计记录与 HTTP 响应。 def _backfill_default_quotas(conn): """给已有无行用户回填默认配额行(init_db 时调,幂等)。 只补“users 里有、user_quotas 里无”的用户,已有行(含 管理员显式设为 NULL 的不限额行)一律不动。返回补写行数。 失败只回滚到保存点,绝不回滚调用方的整个事务:init_db 里 migrate 的 ALTER 与它同事务,旧版 except 里整事务 rollback 会把 ALTER 一并丢掉(2026-10-31 新鲜 PG 库实测: init 后 tasks 缺 paused_by_user / interrupted_reason,合流 随后在 PG“语句失败即 abort 整个事务”的语义下连环报 InFailedSqlTransaction)。成功时也不再由本函数提交,提交 由调用方负责(init_db 结尾统一提交;旧版成功时提交等于 提前提交 init 的全部 DDL,改后 init 恢复原子性)。调用方 只有 init_db 两处(均以提交收尾),改动不影响其它路径。 保存点在 SQLite 与 PG 下都支持;保存点本身建不起来时 (如连接已处 abort 态)退化为整事务 rollback 再返回 0。 """ _ensure_quotas_table(conn) try: conn.execute("SAVEPOINT backfill_default_quotas") except Exception: try: conn.rollback() except Exception: pass return 0 try: cur = conn.execute( "INSERT OR IGNORE INTO user_quotas(user_id," " quota_usd_micros, updated_at)" " SELECT u.id, ?, ? FROM users u" " LEFT JOIN user_quotas q ON q.user_id = u.id" " WHERE q.user_id IS NULL", (_default_quota_usd_micros(), time.time())) conn.execute("RELEASE SAVEPOINT backfill_default_quotas") return cur.rowcount or 0 except Exception: try: conn.execute("ROLLBACK TO SAVEPOINT backfill_default_quotas") conn.execute("RELEASE SAVEPOINT backfill_default_quotas") except Exception: try: conn.rollback() except Exception: pass return 0 def _is_postgres_backend(): backend = os.environ.get("CLOUD_DB_BACKEND") or "sqlite" return str(backend).strip().lower() in ("postgres", "postgresql") def init_db(db_path): """创建 server 自己的 projects/tasks 表(AuthStore 的表由其自建)。 Postgres 模式:建 cloud.db.POSTGRES_SCHEMA 的全部表(其中 projects/tasks 已含 migrate_tasks_table 的 6 列),再跑一次 migrate_tasks_table——PRAGMA 翻译后在 PG 上是 no-op,老库 迁移仍走同一函数。SQLite 模式:原行为不变。 """ conn = _connect(db_path) try: if _is_postgres_backend(): _init_postgres_schema(conn) migrate_tasks_table(conn) migrate_projects_table(conn) _ensure_followups_table(conn) _ensure_continue_messages_table(conn) _ensure_environments_table(conn) _ensure_quotas_table(conn) _backfill_default_quotas(conn) _ensure_gateway_keys(conn) _ensure_uploads_tables(conn) _ensure_task_checkpoints_table(conn) _ensure_task_steps_table(conn) conn.commit() return conn.execute( "CREATE TABLE IF NOT EXISTS projects(" "id TEXT PRIMARY KEY, " "name TEXT, " "owner_id TEXT, " "created_at REAL, " "repo_url TEXT, " "auto_pr INTEGER)" ) conn.execute( "CREATE TABLE IF NOT EXISTS tasks(" "id TEXT PRIMARY KEY, " "project_id TEXT, " "prompt TEXT, " "status TEXT, " "idempotency_key TEXT, " "depends_on TEXT, " "priority INTEGER, " "created_by TEXT, " "created_at REAL, " "updated_at REAL, " "last_heartbeat REAL, " "UNIQUE(project_id, idempotency_key))" ) migrate_tasks_table(conn) migrate_projects_table(conn) _ensure_followups_table(conn) _ensure_continue_messages_table(conn) _ensure_environments_table(conn) _ensure_quotas_table(conn) _backfill_default_quotas(conn) _ensure_uploads_tables(conn) _ensure_task_checkpoints_table(conn) _ensure_task_steps_table(conn) ensure_kill_requests_table(conn) # 常驻监听表(P0 S3 的 SQLite 侧):启动时建一次, # 热路径(claim/run_due/批量)不再逐条 ensure, # 详见 cloud/resident.py ensure 的调用约定。 _res_ensure(conn) # worker 每轮 poll 都按 status 取任务(见 load_tasks 的 # WHERE status IN (...)),无索引时是全表扫描;任务表随 # 时间变大后索引是必须的。IF NOT EXISTS 保证老库幂等。 conn.execute( "CREATE INDEX IF NOT EXISTS idx_tasks_status" " ON tasks(status)") conn.commit() finally: conn.close() # 注意:此处曾有一份与 cloud.peer_messages.row_get 逐行相同的本地 # _row_get(try return row[name] except KeyError/IndexError/TypeError # -> None),它遮蔽了文件顶部的同名 import(F811 + mypy no-redef)。 # 两份实现语义完全相同,删本地定义后全部调用点解析到 import 的 # 同一份实现,行为不变;见 test_server_row_get_unified 回归测试。 def ensure_kill_requests_table(conn): """建 kill_requests 表(幂等,SQLite/PG 通用写法)。""" conn.execute( "CREATE TABLE IF NOT EXISTS kill_requests(" "id TEXT PRIMARY KEY, " "task_id TEXT, " "requested_by TEXT, " "status TEXT, " "requested_at DOUBLE PRECISION, " "done_at DOUBLE PRECISION)") def request_kill(db_path, task_id, requested_by): """写一条 kill 请求:同一任务只留一条 pending,返回 True/False。 已有 pending 时不重复写(返回 False);否则写一行 pending 并返回 True。调用方(取消/sweep)必须 best-effort 调用: 写请求失败绝不能挡住取消本身——任务状态已是 interrupted, owner 活着时的 1 秒看门狗仍会杀,收殓器只是补 owner 已死 的情形。表不存在的老库先建表(与 worker 侧同款守卫)。 """ try: conn = _connect(db_path) except Exception: return False try: ensure_kill_requests_table(conn) row = conn.execute( "SELECT id FROM kill_requests" " WHERE task_id = ? AND status = 'pending' LIMIT 1", (task_id,)).fetchone() if row is not None: return False conn.execute( "INSERT INTO kill_requests(id, task_id, requested_by," " status, requested_at, done_at)" " VALUES(?, ?, ?, 'pending', ?, NULL)", (uuid.uuid4().hex, task_id, requested_by, time.time())) conn.commit() return True except Exception: try: conn.rollback() except Exception: pass return False finally: conn.close() def sweep_stale_tasks(db_path, threshold_seconds=_STALE_SWEEP): """把 running 且心跳过期的任务改为 interrupted,返回被扫任务 id 列表。 心跳取 last_heartbeat,无心跳则用 updated_at;早于 now - threshold_seconds 即过期。审计记一条 "sweep_interrupted" (user_id 记 "system",因为回收不是某个用户的操作)。 这是系统中断:interrupted_reason='sweep'、paused_by_user=0, 长任务自动恢复可接它(用户暂停绝不走这条路)。 独立可单测,不依赖 HTTP。 """ now = time.time() cutoff = now - threshold_seconds conn = _connect(db_path) try: conn.row_factory = sqlite3.Row rows = conn.execute( "SELECT id, project_id FROM tasks WHERE status = 'running'" " AND COALESCE(last_heartbeat, updated_at) < ?", (cutoff,), ).fetchall() swept = [] for row in rows: cur = conn.execute( "UPDATE tasks SET status = 'interrupted'," " interrupted_reason = 'sweep', paused_by_user = 0," " updated_at = ?" " WHERE id = ? AND status = 'running'", (now, row["id"]), ) if cur.rowcount: swept.append(row["id"]) # 被扫任务的 owner 可能已死:光改库状态杀不掉它遗留的 # 容器,顺手写 kill 请求,活着的 worker 在收殓器里按 # 任务派生容器名在本机真杀(见 worker._reap_kill_requests)。 # 同一事务内写,状态与请求要么一起落盘、要么一起丢。 if swept: ensure_kill_requests_table(conn) for task_id in swept: exists = conn.execute( "SELECT id FROM kill_requests" " WHERE task_id = ? AND status = 'pending' LIMIT 1", (task_id,)).fetchone() if exists is None: conn.execute( "INSERT INTO kill_requests(id, task_id," " requested_by, status, requested_at, done_at)" " VALUES(?, ?, 'system', 'pending', ?, NULL)", (uuid.uuid4().hex, task_id, now)) conn.commit() stale = [(row["id"], row["project_id"]) for row in rows if row["id"] in swept] finally: conn.close() with _auth_store(db_path) as store: for task_id, project_id in stale: store.audit("system", project_id, "sweep_interrupted", task_id) return swept def _validate_hard_gate_body(body): """硬门禁请求体类型校验:畸形字段回 400,不进门禁判定。 服务端只有 hard 模式,该校验一律适用。缺失字段不是畸形, 是无证据,交门禁判 409。返回错误串,无错返回 None。 """ for field in ("ci_checks", "required_checks", "approved_exceptions"): value = body.get(field) if value is not None and not isinstance(value, list): return "%s must be a list" % field checks = body.get("ci_checks") if isinstance(checks, list): for entry in checks: if not isinstance(entry, dict) \ or not isinstance(entry.get("name"), str) \ or not isinstance(entry.get("status"), str): return "ci_checks entries must be objects " \ "with name and status" for field in ("contract_frozen", "coordinator_approved"): value = body.get(field) if value is not None and not isinstance(value, bool): return "%s must be a boolean" % field version = body.get("contract_version") if version is not None and not isinstance(version, str): return "contract_version must be a string" expected = body.get("contract_expected_version") if expected is not None and not isinstance(expected, str): return "contract_expected_version must be a string" reviews = body.get("unresolved_review_comments") if reviews is not None and not isinstance(reviews, list) \ and not (isinstance(reviews, int) and not isinstance(reviews, bool)): return "unresolved_review_comments must be a list or an integer" return None def _validate_task_goal_options(body, prompt, prompt_goal_target, plan): # 从 _create_task 平移的 goal 校验:纯函数,不碰 self/DB; # 返回 (goal_mode, goal, verify_command, goal_max_rounds, error), # error 非空时调用方按原样发 400。 # goal 模式分新旧两种语义(2026-10-27 起 console 只发新语义): # 新语义 goal_mode=1:prompt 本身就是目标,不需要专用 goal # 文本、不需要验收命令、不设轮次上限(goal_max_rounds 存 0, # worker 走 host.py goal 循环的模型自决分支);传了 # verify_command 是语义冲突,400。旧语义(verify_command # 非空)保留:goal 文本缺省用 prompt,轮次缺省 3,非法值 # 400 且不建任务;老客户端与已建的旧 goal 任务继续可用。 # /goal 命令优先于 body 里的 goal_mode=0(prompt 里的 # 命令比选项更具体),plan/验收命令冲突仍在下面 400。 goal_mode = 1 if body.get("goal_mode") in (True, 1) \ or prompt_goal_target else 0 if goal_mode: if plan: return (0, None, None, 0, "plan 模式与 goal 模式冲突(只读 vs 改文件)") if body.get("verify_command"): return (0, None, None, 0, "goal 模式不需要验收命令(达标由模型宣告)") return (1, prompt, None, 0, None) else: goal = body.get("goal") if goal is not None and not isinstance(goal, str): return (0, None, None, 0, "goal must be a string") verify_command = body.get("verify_command") if verify_command is not None and not isinstance( verify_command, str): return (0, None, None, 0, "verify_command must be a string") # 计划模式与 goal 模式也冲突:goal 循环就是要改文件 # 直到验测通过,只读沙箱下它永远做不成。 if plan and verify_command: return (0, None, None, 0, "plan 模式与 goal 模式冲突(只读 vs 改文件)") try: goal_max_rounds = int(body.get("goal_max_rounds", 3)) except (TypeError, ValueError): return (0, None, None, 0, "goal_max_rounds must be an integer") if goal_max_rounds < 1: return (0, None, None, 0, "goal_max_rounds must be >= 1") # P1:旧验收形态轮次设上限 100(与 worker/sandbox 两道门 # 一致;新 goal_mode=1 不走本分支,仍由配额兜底)。 if goal_max_rounds > 100: return (0, None, None, 0, "goal_max_rounds 超过上限 100 轮") return (0, goal, verify_command, goal_max_rounds, None) def _validate_continue_options(body, task, goal_target): """_continue_task 的选项校验块(纯函数,原样平移)。 返回 (opts, error):error 非空时调用方按原样发 400; opts 含 model / effort / child_model / child_model_present / child_effort / child_effort_present / approval / plan / goal_mode,错误文案逐字不变。 """ model = body.get("model") if model is not None: if not isinstance(model, str): return None, "model must be a string" model = model.strip() or None effort = body.get("effort") if effort is not None and effort not in ( "low", "medium", "high", "xhigh", "max"): return None, "effort must be low, medium, high, xhigh or max" # 派生默认同款:child_model 空串 = 清空(跟随本任务 # model / effort),child_effort 空串 = 清空;不传沿用原值。 child_model = None child_model_present = "child_model" in body if child_model_present: if body.get("child_model") is not None \ and not isinstance(body.get("child_model"), str): return None, "child_model must be a string" raw_child_model = body.get("child_model") child_model = raw_child_model.strip() \ if isinstance(raw_child_model, str) else None if child_model == "": child_model = None child_effort = None child_effort_present = "child_effort" in body if child_effort_present: raw_child_effort = body.get("child_effort") if raw_child_effort is None or raw_child_effort == "": child_effort = None elif raw_child_effort not in ( "low", "medium", "high", "xhigh", "max"): return None, ("child_effort must be low, medium, high," " xhigh or max") else: child_effort = raw_child_effort approval = body.get("approval") if approval is not None and approval not in ( "ask", "auto", "full"): return None, "approval must be ask, auto or full" plan = None if "plan" in body: plan = 1 if body["plan"] in (True, 1) else 0 goal_mode = None if "goal_mode" in body: goal_mode = 1 if body["goal_mode"] in (True, 1) else 0 if goal_target is not None: # /goal 命令本身就是显式 goal 请求,优先于 body 里的 # goal_mode=0(消息比选项更具体),plan 冲突仍在下面 400。 goal_mode = 1 plan_eff = plan if plan is not None else (task["plan"] or 0) goal_eff = goal_mode if goal_mode is not None \ else (task["goal_mode"] or 0) approval_eff = approval if approval is not None \ else task["approval"] if plan_eff and approval_eff == "full": return None, "plan 模式与 approval=full 冲突(只读 vs 完全访问)" if plan_eff and goal_eff: return None, "plan 模式与 goal 模式冲突" return { "model": model, "effort": effort, "child_model": child_model, "child_model_present": child_model_present, "child_effort": child_effort, "child_effort_present": child_effort_present, "approval": approval, "plan": plan, "goal_mode": goal_mode, }, None def _validate_wait_params(body): """_task_wait 的参数校验块(纯函数,原样平移)。 返回 (timeout, requested, error_payload):error_payload 非空 时调用方按原样发 400;requested 为 None 表示缺省(等全部 coordinator 子任务),其余错误文案与 count 键逐字不变。 """ timeout = body.get("timeout_seconds", 30) if isinstance(timeout, bool) or not isinstance( timeout, (int, float)): return None, None, {"error": "timeout_seconds must be a number"} if timeout < 0 or timeout > 120: return None, None, { "error": "timeout_seconds must be between 0 and 120"} requested = body.get("task_ids") if requested is not None and ( not isinstance(requested, list) or any( not isinstance(x, str) for x in requested)): return None, None, {"error": "task_ids must be a list"} if requested is not None and len(requested) == 0: return None, None, {"error": "task_ids must not be empty"} if requested is not None and len(requested) > 8: return None, None, { "error": "at most 8 threads per wait", "count": len(requested)} return timeout, requested, None def _trace_activity(entry): """_task_thread_read 的单条链路活动摘要(纯函数,原样平移)。""" if not isinstance(entry, dict): return {"kind": "unknown"} if entry.get("kind") == "tool": # tool 条目没有 text/command,旧代码只取 tool 名, # 丢了 server 与 status;拼成可读的一行。 text = "%s/%s: %s" % ( entry.get("server") or "", entry.get("tool") or "", entry.get("status") or "") else: text = (entry.get("text") or entry.get("command") or entry.get("path") or entry.get("summary") or entry.get("tool") or "") return {"kind": entry.get("kind"), "id": entry.get("id"), "text": str(text)[:300]} def _summarize_trace_entries(entries): """_task_thread_read 的链路摘要块(纯函数,原样平移)。 返回 (changed_files, commands, recent_activity):改动文件 去重取后 50、命令取后 20、最近动态取后 10,截断口径不变。 """ # 改动文件 / 命令 / 最近动态:只取链路里已有的事实, # 去重与截断在服务端做,不回整条链路原文。 changed = {} for entry in entries: if isinstance(entry, dict) and entry.get("kind") == "file" \ and entry.get("path"): changed[entry["path"]] = { "path": entry["path"], "change": entry.get("change") or "", "status": entry.get("status") or ""} changed_files = list(changed.values())[-50:] commands = [{ "command": (e.get("command") or "")[:500], "exit_code": e.get("exitCode"), "duration_ms": e.get("durationMs"), } for e in entries if isinstance(e, dict) and e.get("kind") == "exec"][-20:] recent_activity = [_trace_activity(e) for e in entries[-10:]] return changed_files, commands, recent_activity def _summarize_metrics_tasks(rows, member_ids, now): """_metrics 的任务行聚合块(纯函数,原样平移,两遍合一遍)。 返回 (by_status, durations, failures_last_24h, running_over_300s):按状态分计数(七个状态键恒存在)、 完成耗时、近 24h 失败数、跑超 300 秒的 running 数, 口径与原两遍循环逐字一致。 """ # 按状态分计数:七个状态键恒存在(缺失为 0),调用方 # 不用再猜某个状态是“零”还是“没统计”。 by_status = {key: 0 for key in TASK_STATUS_KEYS} durations = [] failures_last_24h = 0 running_over_300s = 0 for row in rows: if row["project_id"] not in member_ids: continue by_status[row["status"]] = by_status.get(row["status"], 0) + 1 if row["started_at"] and row["finished_at"]: durations.append(row["finished_at"] - row["started_at"]) if row["status"] in ("failed", "merge-failed") \ and row["finished_at"] \ and now - row["finished_at"] <= 86400: failures_last_24h += 1 if row["status"] == "running" and row["started_at"] \ and now - row["started_at"] > 300: running_over_300s += 1 return by_status, durations, failures_last_24h, running_over_300s def _parse_github_comment_with_review_fallback(payload): """GitHub 评论解析:issue_comment 直解,解不出且是 pull_request_review_comment 时把 pull_request.number 归一化 成 parser 要的 issue.number 再解一次。纯解析,无 DB、 无审计、无外部调用;_handle_github_webhook 的原样抽出。""" result = parse_github_pr_comment(payload) if result is None and payload.get("pull_request") and \ payload.get("comment"): normalized = dict(payload) normalized["issue"] = { "number": (payload["pull_request"] or {}).get("number")} result = parse_github_pr_comment(normalized) return result def _webhook_fix_review_prompt(result): """webhook /fix-review 建任务 prompt:固定模板 + 截断后的 评论摘录(上限 2000 字),不原样贴评论全文。纯构造。""" return ( "[webhook /fix-review] repo=%s pr=%s author=%s\n%s" % (result.get("repo"), result.get("pr_number"), result.get("author"), (result.get("comment") or "")[:2000])) def _validate_followup_body(body): """后续消息请求体校验(纯计算):返回 (text, mode, error), error 为 None 表示通过,否则为 (http_code, payload),调用方 按原样发响应并返回。文案与状态码逐字取自 _create_followup 原代码,不含 DB、审计与建任务。""" text = body.get("body") if not text or not isinstance(text, str) or not text.strip(): return None, None, (400, {"error": "body required"}) text = text.strip() if len(text) > 20000: return None, None, (400, {"error": "body too long"}) mode = body.get("mode", "queued") if mode not in ("queued", "direct"): return None, None, (400, { "error": "mode must be queued or direct"}) return text, mode, None def _classify_goal_followup(task, goal_target): """后续消息的 /goal 前置判定(纯计算:_create_followup 全量 用它;_deliver_to_thread 只在 goal_target 非空时用它—— 两者的 goal 前置判定逐字相同,且 queued 与终态互斥、merged 已先于终态排除,判定顺序差异不可观测;非 goal 路两者语义 不同,thread 的非 goal 路不进本函数,见调用方注释)。 返回 (action, code, payload): - ("error", code, payload):调用方按原样发响应/返回; - ("convert_queued", None, None):调用方走 queued 原地转 goal 的副作用路(建任务/审计仍在调用方); - ("revive_terminal", None, None):调用方走终态复活转 goal 的副作用路; - ("continue", None, None):落到普通 followup 插入路 (running 的 /goal 即此路,响应里再标 goal_queued)。 本函数只读 task 字典与 goal_target,绝不调 DB、审计与 建任务,调用一次无副作用重复风险。""" if goal_target is not None: if not goal_target: return "error", 400, {"error": "goal 目标不能为空"} if task.get("goal_mode") or task.get("verify_command"): return "error", 409, { "error": "主线已有 goal,/goal 不会重复创建", "status": task["status"], "goal": task.get("goal") or task.get("prompt"), } if task.get("plan"): return "error", 400, { "error": "plan 模式与 goal 模式冲突"} if task["status"] == "merged": return "error", 409, { "error": "已合并任务不能继续(worktree 已删),请分支", "status": task["status"], } if task["status"] == "queued": return "convert_queued", None, None if task["status"] in TERMINAL_STATUSES: return "revive_terminal", None, None if task["status"] not in ("queued", "running"): return "error", 409, { "error": "后续消息仅支持进行中任务", "status": task["status"], } return "continue", None, None if task["status"] not in ("queued", "running"): return "error", 409, { "error": "后续消息仅支持进行中任务", "status": task["status"], } return "continue", None, None def _seconds_to_ms(seconds): """秒 -> 毫秒整数时间戳,非法/缺失返回 None(不伪造时间戳)。 _task_chat_history 内嵌 _ms 的原样抽出,纯计算。""" try: return int(float(seconds) * 1000) except (TypeError, ValueError): return None def _chat_history_user_entries(task, cont_rows, fu_rows): """交替历史的用户侧条目(纯构造):首条 prompt、继续消息、 后续消息,各标 (0, ts_ms, dict)——用户项在同一毫秒排在链路 项之前靠排序键第一元组 0 保证。行转字典用原有 _continue_message_to_dict / _followup_to_dict。""" merged = [] if task.get("prompt"): merged.append((0, _seconds_to_ms(task.get("created_at")), { "id": "task-prompt", "role": "user", "source": "prompt", "label": "用户", "text": task["prompt"], "ts_ms": _seconds_to_ms(task.get("created_at"))})) for r in cont_rows: d = _continue_message_to_dict(r) merged.append((0, _seconds_to_ms(d["created_at"]), { "id": "continue-" + d["id"], "role": "user", "source": "continue", "label": "用户", "text": d["body"], "ts_ms": _seconds_to_ms(d["created_at"])})) for r in fu_rows: d = _followup_to_dict(r) merged.append((0, _seconds_to_ms(d["created_at"]), { "id": "followup-" + d["id"], "role": "user", "source": "followup", "label": "后续消息", "text": d["body"], "ts_ms": _seconds_to_ms(d["created_at"])})) return merged def _chat_history_trace_entries(entries, start_ms): """交替历史的链路侧条目(纯构造):跳过直接发送后续消息在 链路里留的 fu- 回声(followups 表里同一条已作用户消息返回, 否则同一条会先用户、再助手各出现一次);有 ts 的条目按 ts 排序,无 ts 的按链路顺序贴在上一条有 ts 的条目之后、输出 ts_ms 为 null,不伪造时间戳。返回 (1, sort_ms, dict) 列表。""" merged = [] last_trace_ms = start_ms for e in entries: if not isinstance(e, dict): continue if str(e.get("id") or "").startswith("fu-"): continue msg = _chat_message_from_trace(e) ts = e.get("ts") if isinstance(ts, (int, float)): last_trace_ms = int(ts) sort_ms = int(ts) else: sort_ms = last_trace_ms msg["ts_ms"] = int(ts) if isinstance(ts, (int, float)) else None merged.append((1, sort_ms, msg)) return merged def _thread_non_goal_guard(task): """_deliver_to_thread 在 /goal 判定落空后的非 goal 门禁 (纯计算):goal 任务与 merged 各 409,返回 (code, payload); 通过返回 None。注意 merged 在前置判定里已先于终态排除, 这里是普通(非 goal)路的第二次 merged 门禁,原文顺序 是 goal 判定 -> 本门禁 -> 终态 CAS 复活,调用方保持该顺序。""" if task.get("verify_command") or task.get("goal_mode"): return 409, {"error": "goal 任务暂不支持后续消息"} if task["status"] == "merged": return 409, { "error": "已合并任务不能继续(worktree 已删),请分支", "status": task["status"]} return None class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, AutomationMixin, FullAutoMixin, BaseHTTPRequestHandler): """控制面请求处理:除 /health 与 /webhooks/* 外一律要求 X-API-Key。""" protocol_version = "HTTP/1.1" # -- 通用工具 ------------------------------------------------------ def log_message(self, *args): # 测试输出保持干净 pass def _send_json(self, status, obj, extra_headers=None): body = json.dumps(obj, default=_json_default).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) for header_name, header_value in (extra_headers or {}).items(): self.send_header(header_name, header_value) self.end_headers() # 发头即算已响应:之后 wfile 写失败(客户端已断)也不应再 # 尝试回 500——响应一旦开始就不能再发第二个状态行。 self._responded = True self.wfile.write(body) def _send_html(self, status, html_body): body = html_body.encode("utf-8") self.send_response(status) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self._responded = True self.wfile.write(body) def _read_body(self): try: length = int(self.headers.get("Content-Length") or 0) except ValueError: length = 0 if length <= 0: return b"" return self.rfile.read(length) def _read_json(self): """返回 (obj, error):空 body 视为 {};非法 JSON 返回 error。""" raw = self._read_body() if not raw: return {}, None try: obj = json.loads(raw.decode("utf-8")) except (ValueError, UnicodeDecodeError): return None, "invalid json" if not isinstance(obj, dict): return None, "body must be a JSON object" return obj, None def _db(self): conn = _connect(self.server.db_path) conn.row_factory = sqlite3.Row return conn def _authenticate(self): """X-API-Key -> user_id;无 key 或 key 错返回 None。""" api_key = self.headers.get("X-API-Key") if not api_key: return None with _auth_store(self.server.db_path) as store: return store.authenticate(api_key) # -- 入口 ---------------------------------------------------------- def do_GET(self): self._dispatch_safe("GET") def do_POST(self): self._dispatch_safe("POST") def do_DELETE(self): self._dispatch_safe("DELETE") def do_PATCH(self): # PATCH 之前没有入口(BaseHTTPRequestHandler 默认回 501), # 项目补仓库是第一个 PATCH 路由;与其它方法一样走 _dispatch。 self._dispatch_safe("PATCH") def do_PUT(self): # PUT 与 PATCH 同理:此前无入口,/users/me/profile 是 # 第一个 PUT 路由;未知 PUT 路径仍由 _dispatch 回 404。 self._dispatch_safe("PUT") def _dispatch_safe(self, method): """分发兜底:_dispatch 里任何未捕获异常都回 500 JSON,而不是 让请求线程直接断连(2026-10-27 死锁事件就是这么变成前端 “HTTP 502”的:异常逃出 handle_one_request 后连接无响应, nginx 只能报 502,客户端拿不到任何可读错误)。 500 体只写固定 internal_error,不把堆栈泄漏给客户端; 堆栈走 _server_log.exception 进服务端日志。已开始响应 (SSE 流、_send_json/_send_html 已发头)时不再二次发送, 只记日志——响应一旦开始就不能再发第二个状态行。 _responded 每请求重置(同一连接 keep-alive 复用 handler 对象时不串请求)。 """ self._responded = False try: self._dispatch(method) except Exception: _server_log.exception("unhandled error in %s %s", method, self.path) if not self._responded: try: self._send_json(500, {"error": "internal_error"}) except Exception: pass def _dispatch_task_route(self, user_id, method, path, query): # 从 _dispatch 平移的 /tasks/* 子路由:语句原样平移, # 仅缩进减一格;未命中任一子路由时仍返回 404。 rest = path[len("/tasks/"):] if "/" not in rest and method == "GET": self._get_task(user_id, rest) elif rest.endswith("/cost") and method == "GET": self._task_cost(user_id, rest[: -len("/cost")]) elif rest.endswith("/trace/stream") and method == "GET": self._task_trace_stream( user_id, rest[: -len("/trace/stream")]) elif rest.endswith("/trace") and method == "GET": self._task_trace(user_id, rest[: -len("/trace")]) elif rest.endswith("/result-full") and method == "GET": self._task_result_full(user_id, rest[: -len("/result-full")]) elif rest.endswith("/agents") and method == "GET": self._task_agents(user_id, rest[: -len("/agents")]) elif rest.endswith("/cancel") and method == "POST": self._cancel_task(user_id, rest[: -len("/cancel")]) elif rest.endswith("/pause") and method == "POST": self._pause_task(user_id, rest[: -len("/pause")]) elif rest.endswith("/merge") and method == "POST": self._merge_task(user_id, rest[: -len("/merge")]) elif rest.endswith("/release") and method == "POST": self._release_task(user_id, rest[: -len("/release")]) elif rest.endswith("/audit-verdict") and method == "POST": self._audit_loop_verdict(user_id, rest[: -len("/audit-verdict")]) elif rest.endswith("/audit-fix") and method == "POST": self._audit_loop_fix(user_id, rest[: -len("/audit-fix")]) elif rest.endswith("/audit-correction") and method == "POST": self._audit_loop_correction( user_id, rest[: -len("/audit-correction")]) elif rest.endswith("/audit-rebuild-confirm") and method == "POST": self._audit_loop_confirm( user_id, rest[: -len("/audit-rebuild-confirm")]) elif rest.endswith("/audit-loop") and method == "GET": self._audit_loop_get(user_id, rest[: -len("/audit-loop")], query) elif rest.endswith("/pr") and method == "POST": self._task_pr(user_id, rest[: -len("/pr")]) elif rest.endswith("/retry") and method == "POST": self._retry_task(user_id, rest[: -len("/retry")]) elif rest.endswith("/goal-resume") and method == "POST": self._goal_resume_task(user_id, rest[: -len("/goal-resume")]) elif rest.endswith("/auto-memory-flags") and method == "POST": self._task_auto_memory_flags( user_id, rest[: -len("/auto-memory-flags")]) elif rest.endswith("/heartbeat") and method == "POST": self._heartbeat_task(user_id, rest[: -len("/heartbeat")]) elif rest.endswith("/followups") and method == "POST": self._create_followup(user_id, rest[: -len("/followups")]) elif rest.endswith("/followups") and method == "GET": self._list_followups(user_id, rest[: -len("/followups")]) elif rest.endswith("/timeline") and method == "GET": self._task_timeline(user_id, rest[: -len("/timeline")]) elif rest.endswith("/files") and method == "GET": self._task_files(user_id, rest[: -len("/files")], query) elif rest.endswith("/tree") and method == "GET": self._task_tree(user_id, rest[: -len("/tree")], query) elif rest.endswith("/browser/state") and method == "GET": self._browser_state(user_id, rest[: -len("/browser/state")]) elif rest.endswith("/browser/input") and method == "POST": self._browser_input(user_id, rest[: -len("/browser/input")], self._read_body()) elif rest.endswith("/browser/control") and method == "POST": self._browser_control(user_id, rest[: -len("/browser/control")], self._read_body()) elif rest.endswith("/browser/stream") and method == "GET": self._browser_stream(user_id, rest[: -len("/browser/stream")]) elif rest.endswith("/chat-history") and method == "GET": self._task_chat_history( user_id, rest[: -len("/chat-history")]) elif rest.endswith("/messages") and method == "POST": self._create_peer_message(user_id, rest[: -len("/messages")]) elif rest.endswith("/messages") and method == "GET": self._list_peer_messages(user_id, rest[: -len("/messages")]) elif rest.endswith("/reviews") and method == "POST": self._create_review(user_id, rest[: -len("/reviews")]) elif rest.endswith("/reviews") and method == "GET": self._list_reviews(user_id, rest[: -len("/reviews")]) elif "/reviews/" in rest and rest.endswith("/findings") \ and method == "POST": _task_part, _rev_part = rest.split("/reviews/", 1) self._update_review_findings( user_id, _task_part, _rev_part.split("/")[0]) elif rest.endswith("/threads") and method == "GET": self._task_threads(user_id, rest[: -len("/threads")]) elif rest.endswith("/thread") and method == "GET": self._task_thread(user_id, rest[: -len("/thread")], query) elif rest.endswith("/thread/rename") and method == "POST": self._task_thread_rename( user_id, rest[: -len("/thread/rename")]) elif rest.endswith("/thread/archive") and method == "POST": self._task_thread_archive( user_id, rest[: -len("/thread/archive")]) elif rest.endswith("/thread/unarchive") and method == "POST": self._task_thread_unarchive( user_id, rest[: -len("/thread/unarchive")]) elif rest.endswith("/thread/compact") and method == "POST": self._task_thread_compact( user_id, rest[: -len("/thread/compact")]) elif rest.endswith("/thread/read") and method == "GET": self._task_thread_read(user_id, rest[: -len("/thread/read")]) elif rest.endswith("/thread/message") and method == "POST": self._task_thread_message( user_id, rest[: -len("/thread/message")]) elif rest.endswith("/handoff") and method == "GET": self._task_handoff_get(user_id, rest[: -len("/handoff")]) elif rest.endswith("/handoff") and method == "POST": self._task_handoff_create(user_id, rest[: -len("/handoff")]) elif "/handoff/" in rest and rest.endswith("/report") \ and method == "POST": _task_part, _hid_part = rest.split("/handoff/", 1) self._task_handoff_report( user_id, _task_part, _hid_part[: -len("/report")]) elif rest.endswith("/worktree") and method == "GET": self._task_worktree_status(user_id, rest[: -len("/worktree")]) elif rest.endswith("/bindings") and method == "GET": self._task_bindings(user_id, rest[: -len("/bindings")]) elif rest.endswith("/refs") and method == "GET": self._task_refs_list(user_id, rest[: -len("/refs")]) elif rest.endswith("/refs") and method == "POST": self._task_refs_create(user_id, rest[: -len("/refs")]) elif "/refs/" in rest and method == "DELETE": _task_part, _ref_part = rest.split("/refs/", 1) self._task_refs_delete(user_id, _task_part, _ref_part) elif rest.endswith("/worktree/archive") and method == "POST": self._task_worktree_archive( user_id, rest[: -len("/worktree/archive")]) elif rest.endswith("/worktree/restore") and method == "POST": self._task_worktree_restore( user_id, rest[: -len("/worktree/restore")]) elif rest.endswith("/worktree/snapshots") and method == "GET": self._task_worktree_snapshots( user_id, rest[: -len("/worktree/snapshots")]) elif rest.endswith("/pr/checks") and method == "GET": self._task_pr_checks(user_id, rest[: -len("/pr/checks")]) elif rest.endswith("/sleep") and method == "POST": self._task_sleep(user_id, rest[: -len("/sleep")]) elif rest.endswith("/wait") and method == "POST": self._task_wait(user_id, rest[: -len("/wait")]) elif rest.endswith("/fork") and method == "POST": self._fork_task(user_id, rest[: -len("/fork")]) elif rest.endswith("/approvals") and method == "GET": self._list_approvals(user_id, rest[: -len("/approvals")]) elif rest.endswith("/approvals") and method == "POST": self._create_approval(user_id, rest[: -len("/approvals")]) elif rest.endswith("/approve") and method == "POST": self._decide_approval( user_id, rest[: -len("/approve")], "approved") elif rest.endswith("/deny") and method == "POST": self._decide_approval( user_id, rest[: -len("/deny")], "denied") elif rest.endswith("/continue") and method == "POST": self._continue_task(user_id, rest[: -len("/continue")]) else: self._send_json(404, {"error": "not found"}) def _dispatch(self, method): parsed = urlparse(self.path) path = parsed.path if len(path) > 1 and path.endswith("/"): path = path.rstrip("/") query = parse_qs(parsed.query) if path == "/health" and method == "GET": # 健康检查不走限流与认证;db_ok 只做一次轻量查询, # 绝不返回连接串、密码或任何凭据。 db_ok = False try: conn = _connect(self.server.db_path) try: conn.execute("SELECT 1").fetchone() db_ok = True finally: conn.close() except Exception: db_ok = False # worker 字段:worker_ok 只表示“worker 心跳新鲜”,worker # 不 ok 不代表 server 挂了,所以 health 本身仍返回 200, # 调用方看 worker_ok 字段判断,不要把 health 改成 503 # 破坏已有调用(monitor.sh 也是按字段判断,不按状态码)。 age, worker_ok = _worker_heartbeat_status(self.server) self._send_json(200, { "ok": True, "service": "cloud-control-plane", "backend": _db_backend_name(), "db_ok": db_ok, "worker_last_seen_seconds_ago": age, "worker_ok": worker_ok, # 上线证据:health 通过不再单独证明新代码, # 部署验真必须同时核对 commit 等于期望值(见 deploy.py)。 "commit": _deployed_commit(), }) return if path == "/console" and method == "GET": # 命令台 v1:只出静态壳,不走鉴权与限流(与 /health 同款 # pre-auth 待遇)。壳里没有任何数据;数据一律凭用户在 # 浏览器里填的 X-API-Key 调接口,key 只存在用户浏览器 # localStorage。页面文件与 server.py 同目录,改动随部署走。 try: here = os.path.dirname(os.path.abspath(__file__)) with open(os.path.join(here, "console.html"), "r", encoding="utf-8") as f: self._send_html(200, f.read()) except OSError: self._send_json(404, {"error": "console not found"}) return if path.startswith("/oauth/"): # OAuth 登录/回调是 pre-auth 端点(用户还没本站 API key, # 这正是 OAuth 要解决的问题),靠 state + client_secret # 认证,不走 X-API-Key;不要把它当成无认证后门。 # 绑定端点例外:绑定必须知道绑到哪个已有账号,所以在 # 处理器内验 X-API-Key(无 key 401),分发仍走这支。 bind = self._oauth_bind_route(path) if bind is not None: provider, action = bind if method == "POST" and action == "bind_initiate": self._oauth_bind_initiate(provider) elif method == "GET" and action == "bindings": self._oauth_bindings_get(provider) elif method == "DELETE" and action == "bindings": self._oauth_bindings_delete(provider) else: self._send_json(404, {"error": "not found"}) return if method == "GET" and path.endswith("/login"): self._oauth_login(path) elif method == "GET" and path.endswith("/callback"): # 真实浏览器流程:provider 重定向到这里,query 带 # code + state(不带 client_id,靠 state 记录取回)。 self._oauth_callback_get(path) elif method == "POST" and path.endswith("/callback"): self._oauth_callback(path) else: self._send_json(404, {"error": "not found"}) return if path == "/users/me/gateway/rotate" and method == "POST": self._user_gateway_rotate() return if path.startswith("/users/me/keys"): # API keys 管理:全部只操作认证用户自己的 key。 if method == "GET" and path == "/users/me/keys": self._user_keys_list() elif method == "POST" and path == "/users/me/keys": self._user_keys_create() elif method == "POST" and \ path == "/users/me/keys/revoke-others": self._user_keys_revoke_others() elif method == "DELETE" and \ path.startswith("/users/me/keys/"): key_id = path[len("/users/me/keys/"):] if key_id and "/" not in key_id: self._user_keys_revoke(key_id) else: self._send_json(404, {"error": "not found"}) else: self._send_json(404, {"error": "not found"}) return if path.startswith("/github-app/"): # GitHub App 一键安装:setup/state 与 status 须持本站 key # (在处理器内验);setup 表单页与 callback 靠 setup # state 凭证(一次性、1 小时),不走 X-API-Key(浏览器 # 回调带不了请求头,state 就是凭证)。 if method == "POST" and path == "/github-app/setup/state": self._github_app_setup_state() elif method == "GET" and path == "/github-app/setup": self._github_app_setup_page() elif method == "GET" and path == "/github-app/callback": self._github_app_callback() elif method == "GET" and path == "/github-app/status": self._github_app_status() else: self._send_json(404, {"error": "not found"}) return if path.startswith("/webhooks/"): # webhook 靠签名认证(外部服务无法持 X-API-Key),不走 API key。 if method != "POST": self._send_json(404, {"error": "not found"}) return if path == "/webhooks/github": self._handle_github_webhook() elif path == "/webhooks/slack": self._handle_slack_webhook() else: self._send_json(404, {"error": "not found"}) return # 限流在认证之前、健康/OAuth/webhook 之后:超限返回 429, # 不是 401(不要把“请求太频繁”记成“认证失败”)。 # key:有 X-API-Key 时按 key 的 sha256 计(原始 key 不存), # 无 key 时按客户端 IP 计。 limiter = getattr(self.server, "rate_limiter", None) if limiter is None: limiter = _make_rate_limiter(self.server.db_path) self.server.rate_limiter = limiter # 测试注入:server.rate_limit_per_minute / rate_limit_window_seconds # 改动后即时生效,不用重启 server。 if getattr(self.server, "rate_limit_per_minute", None) is not None: limiter.per_minute = self.server.rate_limit_per_minute if getattr(self.server, "rate_limit_window_seconds", None) is not None: limiter.window_seconds = self.server.rate_limit_window_seconds api_key_header = self.headers.get("X-API-Key") if api_key_header: limit_key = "key:" + hashlib.sha256( api_key_header.encode("utf-8")).hexdigest() else: limit_key = "ip:" + (self.client_address[0] if self.client_address else "unknown") if not limiter.allow(limit_key): # 429 限流:只加 Retry-After 头(值为限流窗口秒数, # 即 limiter.window_seconds,默认 60),不改阈值、 # 不改计数逻辑、不加白名单。客户端据此退避一个完整 # 窗口,而不是以原速率继续 hammer 把自己钉在限流里 # (Postgres 下 DbRateLimiter 先 INSERT 再 COUNT, # 被拒请求本身也计入窗口,这是 abuse 缓解的本意)。 self._send_json( 429, {"error": "rate_limited"}, extra_headers={"Retry-After": str(limiter.window_seconds)}) return user_id = self._authenticate() if user_id is None: self._send_json(401, {"error": "unauthorized"}) return if path == "/full-auto/status" and method == "GET": self._full_auto_status(user_id, query) elif path == "/full-auto/config-plan" and method == "GET": self._full_auto_config_plan(user_id, query) elif path == "/full-auto/validate-action" and method == "POST": self._full_auto_validate_action(user_id) elif path == "/full-auto/merge-gate" and method == "POST": self._full_auto_merge_gate(user_id) elif path == "/full-auto/ci-fix" and method == "POST": self._full_auto_ci_fix(user_id) elif path == "/full-auto/release-plan" and method == "POST": self._full_auto_release_plan(user_id) elif path == "/full-auto/rollback-plan" and method == "POST": self._full_auto_rollback_plan(user_id) elif path == "/full-auto/execute" and method == "POST": self._full_auto_execute(user_id) elif path == "/full-auto/audit" and method == "GET": self._full_auto_audit_list(user_id, query) elif path == "/projects" and method == "POST": self._create_project(user_id) elif path == "/projects" and method == "GET": self._list_projects(user_id) elif path == "/workers" and method == "GET": self._workers_list(user_id) elif path == "/workers/heartbeat" and method == "POST": self._workers_heartbeat(user_id) elif path == "/metrics" and method == "GET": self._metrics(user_id) elif path == "/metrics-report" and method == "GET": self._metrics_report(user_id) elif path == "/audit-scorecard" and method == "GET": self._audit_scorecard(user_id) elif path == "/models" and method == "GET": self._models_overview(user_id) elif path == "/relay/models" and method == "GET": self._relay_models(user_id) elif path == "/github/repos" and method == "GET": self._github_repos_list(user_id) elif path.startswith("/projects/"): self._dispatch_project(user_id, method, path, query) elif path == "/environments" and method == "POST": self._create_environment(user_id) elif path == "/environments" and method == "GET": self._list_environments(user_id, query) elif path.startswith("/environments/"): rest = path[len("/environments/"):] if "/" not in rest and method == "GET": self._get_environment(user_id, rest) elif rest.endswith("/archive") and method == "POST": self._archive_environment(user_id, rest[: -len("/archive")]) else: self._send_json(404, {"error": "not found"}) elif path == "/users/me/quota" and method == "GET": self._user_quota_self(user_id) elif path == "/users/me/cost" and method == "GET": self._user_cost_self(user_id, query) elif path == "/admin/cost-by-creator" and method == "GET": self._admin_cost_by_creator(user_id, query) elif path == "/admin/ops/approval-timeout" and method == "GET": self._admin_approval_timeout_ops(user_id) elif path == "/users/me/profile" and method == "GET": self._identity_profile_get(user_id) elif path == "/users/me/profile" and method in ("PUT", "POST"): self._identity_profile_upsert(user_id) elif path == "/users/me/memories" and method == "GET": self._identity_user_memories_list(user_id) elif path == "/users/me/memories" and method == "POST": self._identity_user_memory_create(user_id) elif path == "/users/me/identity-context" and method == "GET": self._identity_context(user_id, query) elif path == "/users/me/auto-memory-settings" and method == "GET": self._auto_memory_settings_get(user_id) elif path == "/users/me/auto-memory-settings" and \ method in ("PUT", "POST"): self._auto_memory_settings_upsert(user_id) elif path == "/users/me/auto-memory-log" and method == "GET": self._auto_memory_log(user_id, query) elif path == "/users/me/auto-memory/run" and method == "POST": self._auto_memory_run(user_id) elif path.startswith("/users/me/memories/"): _mid = path[len("/users/me/memories/"):] if _mid and "/" not in _mid and method == "DELETE": self._identity_user_memory_delete(user_id, _mid) elif _mid.endswith("/supersede") and method == "POST": self._identity_user_memory_supersede( user_id, _mid[: -len("/supersede")].rstrip("/")) else: self._send_json(404, {"error": "not found"}) elif path == "/tasks" and method == "POST": self._create_task(user_id) elif path == "/uploads" and method == "POST": self._create_upload(user_id) elif path == "/tasks" and method == "GET": self._list_tasks(user_id, query) elif path == "/admin/sweep" and method == "POST": self._admin_sweep(user_id) elif path == "/admin/backup" and method == "POST": self._admin_backup(user_id) elif path == "/admin/quotas" and method == "GET": self._admin_quota_get(user_id, query) elif path == "/admin/quotas" and method == "POST": self._admin_quota_set(user_id) elif path == "/admin/audit-ledger/verify" and method == "GET": self._admin_ledger_verify(user_id) elif path == "/admin/audit-ledger/export" and method == "GET": self._admin_ledger_export(user_id, query) elif path == "/admin/push/dispatch" and method == "POST": self._push_dispatch_endpoint(user_id) elif path.startswith("/tasks/"): self._dispatch_task_route(user_id, method, path, query) else: self._send_json(404, {"error": "not found"}) def _dispatch_project(self, user_id, method, path, query): rest = path[len("/projects/"):] parts = rest.split("/") project_id = parts[0] if parts else "" if not project_id: self._send_json(404, {"error": "not found"}) return if len(parts) == 1 and method == "PATCH": self._update_project(user_id, project_id) return # /projects/{id}/members:GET 列表 / POST 邀请 if len(parts) == 2 and parts[1] == "members": if method == "GET": self._project_members_list(user_id, project_id) elif method == "POST": self._project_member_invite(user_id, project_id) else: self._send_json(404, {"error": "not found"}) return # /projects/{id}/members/{user_id}:PATCH 改角色 / DELETE 删成员 if len(parts) == 3 and parts[1] == "members": if method == "PATCH": self._project_member_patch(user_id, project_id, parts[2]) elif method == "DELETE": self._project_member_delete(user_id, project_id, parts[2]) else: self._send_json(404, {"error": "not found"}) return # /projects/{id}/identity-memories (project-scope system memory) if len(parts) == 2 and parts[1] == "identity-memories": if method == "POST": err = self._check_project(user_id, project_id, "create_task") if err: self._send_json(err[0], {"error": err[1]}) return self._project_identity_memory_create(user_id, project_id) elif method == "GET": err = self._check_project(user_id, project_id, "read") if err: self._send_json(err[0], {"error": err[1]}) return self._project_identity_memories_list(project_id, query) else: self._send_json(404, {"error": "not found"}) return # /projects/{id}/identity-memories/{mid}/supersede if len(parts) == 4 and parts[1] == "identity-memories" \ and parts[3] == "supersede": if method != "POST": self._send_json(404, {"error": "not found"}) return err = self._check_project(user_id, project_id, "create_task") if err: self._send_json(err[0], {"error": err[1]}) return self._project_identity_memory_supersede( user_id, project_id, parts[2]) return # /projects/{id}/identity-memories/{mid} if len(parts) == 3 and parts[1] == "identity-memories": if method != "DELETE": self._send_json(404, {"error": "not found"}) return err = self._check_project(user_id, project_id, "create_task") if err: self._send_json(err[0], {"error": err[1]}) return self._project_identity_memory_delete( user_id, project_id, parts[2]) return # /projects/{id}/memory if len(parts) == 2 and parts[1] == "memory": if method == "POST": err = self._check_project(user_id, project_id, "create_task") if err: self._send_json(err[0], {"error": err[1]}) return self._memory_create(user_id, project_id) elif method == "GET": err = self._check_project(user_id, project_id, "read") if err: self._send_json(err[0], {"error": err[1]}) return self._memory_list(project_id, query) else: self._send_json(404, {"error": "not found"}) return # /projects/{id}/memory/{mid}/supersede if len(parts) == 4 and parts[1] == "memory" and parts[3] == "supersede": if method != "POST": self._send_json(404, {"error": "not found"}) return err = self._check_project(user_id, project_id, "create_task") if err: self._send_json(err[0], {"error": err[1]}) return self._memory_supersede(user_id, project_id, parts[2]) return # /projects/{id}/kb/index if len(parts) == 3 and parts[1] == "kb" and parts[2] == "index": if method != "POST": self._send_json(404, {"error": "not found"}) return err = self._check_project(user_id, project_id, "create_task") if err: self._send_json(err[0], {"error": err[1]}) return self._kb_index(user_id, project_id) return # /projects/{id}/kb/search if len(parts) == 3 and parts[1] == "kb" and parts[2] == "search": if method != "GET": self._send_json(404, {"error": "not found"}) return err = self._check_project(user_id, project_id, "read") if err: self._send_json(err[0], {"error": err[1]}) return self._kb_search(project_id, query) return # /projects/{id}/cost if len(parts) == 2 and parts[1] == "cost": if method != "GET": self._send_json(404, {"error": "not found"}) return err = self._check_project(user_id, project_id, "read") if err: self._send_json(err[0], {"error": err[1]}) return self._project_cost(project_id, query) return # /projects/{id}/push/subscriptions if len(parts) == 3 and parts[1] == "push" and parts[2] == "subscriptions": if method == "POST": err = self._check_project(user_id, project_id, "create_task") if err: self._send_json(err[0], {"error": err[1]}) return self._push_create_endpoint(user_id, project_id) elif method == "GET": err = self._check_project(user_id, project_id, "read") if err: self._send_json(err[0], {"error": err[1]}) return self._send_json(200, { "subscriptions": _push_list( self.server.db_path, project_id, user_id=user_id)}) else: self._send_json(404, {"error": "not found"}) return # /projects/{id}/push/subscriptions/{sid} DELETE if len(parts) == 4 and parts[1] == "push" and parts[2] == "subscriptions": self._project_push_subscription_delete( user_id, method, project_id, parts) return # /projects/{id}/automations if len(parts) == 2 and parts[1] == "automations": if method == "POST": err = self._check_project(user_id, project_id, "create_task") if err: self._send_json(err[0], {"error": err[1]}) return self._automation_create(user_id, project_id) elif method == "GET": err = self._check_project(user_id, project_id, "read") if err: self._send_json(err[0], {"error": err[1]}) return self._automation_list(project_id) else: self._send_json(404, {"error": "not found"}) return # /projects/{id}/automations/{aid}:GET 单条(含最近产出) # / PATCH 更新(automation_update) if len(parts) == 3 and parts[1] == "automations": if method == "GET": err = self._check_project(user_id, project_id, "read") if err: self._send_json(err[0], {"error": err[1]}) return self._automation_get(user_id, project_id, parts[2]) elif method == "PATCH": err = self._check_project(user_id, project_id, "create_task") if err: self._send_json(err[0], {"error": err[1]}) return self._automation_update(user_id, project_id, parts[2]) else: self._send_json(404, {"error": "not found"}) return # /projects/{id}/automations/{aid}/cancel if len(parts) == 4 and parts[1] == "automations" \ and parts[3] == "cancel": if method != "POST": self._send_json(404, {"error": "not found"}) return err = self._check_project(user_id, project_id, "create_task") if err: self._send_json(err[0], {"error": err[1]}) return self._automation_cancel(user_id, project_id, parts[2]) return # /projects/{id}/automations/{aid}/bind(GET/POST/DELETE) # / /heartbeat(POST):心跳绑定与唤醒,见 handlers。 if len(parts) == 4 and parts[1] == "automations" \ and parts[3] in ("bind", "heartbeat"): if parts[3] == "bind" and method == "GET": err = self._check_project(user_id, project_id, "read") if err: self._send_json(err[0], {"error": err[1]}) return self._automation_bind_get(user_id, project_id, parts[2]) elif parts[3] == "bind" and method == "POST": err = self._check_project(user_id, project_id, "create_task") if err: self._send_json(err[0], {"error": err[1]}) return self._automation_bind(user_id, project_id, parts[2]) elif parts[3] == "bind" and method == "DELETE": err = self._check_project(user_id, project_id, "create_task") if err: self._send_json(err[0], {"error": err[1]}) return self._automation_unbind(user_id, project_id, parts[2]) elif parts[3] == "heartbeat" and method == "POST": err = self._check_project(user_id, project_id, "create_task") if err: self._send_json(err[0], {"error": err[1]}) return self._automation_heartbeat(user_id, project_id, parts[2]) else: self._send_json(404, {"error": "not found"}) return # /projects/{id}/triage if len(parts) == 2 and parts[1] == "triage": if method != "GET": self._send_json(404, {"error": "not found"}) return err = self._check_project(user_id, project_id, "read") if err: self._send_json(err[0], {"error": err[1]}) return self._triage_inbox(project_id) return # /projects/{id}/resident-watches:常驻监听任务。 # 创建/更新/暂停/恢复/结束用 create_task,读用 read; # 状态机见 cloud/resident.py(running/paused/ended, # ended 是终态,恢复已结束的监听必须新建)。 if len(parts) == 2 and parts[1] == "resident-watches": if method == "POST": err = self._check_project(user_id, project_id, "create_task") if err: self._send_json(err[0], {"error": err[1]}) return self._resident_create(user_id, project_id) elif method == "GET": err = self._check_project(user_id, project_id, "read") if err: self._send_json(err[0], {"error": err[1]}) return self._resident_list(project_id) else: self._send_json(404, {"error": "not found"}) return if len(parts) == 3 and parts[1] == "resident-watches": if method == "GET": err = self._check_project(user_id, project_id, "read") if err: self._send_json(err[0], {"error": err[1]}) return self._resident_get(user_id, project_id, parts[2]) elif method == "PATCH": err = self._check_project(user_id, project_id, "create_task") if err: self._send_json(err[0], {"error": err[1]}) return self._resident_update(user_id, project_id, parts[2]) else: self._send_json(404, {"error": "not found"}) return if len(parts) == 4 and parts[1] == "resident-watches" \ and parts[3] in ("pause", "resume", "end"): if method != "POST": self._send_json(404, {"error": "not found"}) return err = self._check_project(user_id, project_id, "create_task") if err: self._send_json(err[0], {"error": err[1]}) return self._resident_transition(user_id, project_id, parts[2], parts[3]) return # /projects/{id}/todos GET 列表 / POST 新建;单条 # GET / PATCH / DELETE(项目 read / create_task) if len(parts) == 2 and parts[1] == "todos": if method == "GET": err = self._check_project(user_id, project_id, "read") if err: self._send_json(err[0], {"error": err[1]}) return self._todos_list(project_id, query) elif method == "POST": err = self._check_project( user_id, project_id, "create_task") if err: self._send_json(err[0], {"error": err[1]}) return self._todos_create(user_id, project_id) else: self._send_json(404, {"error": "not found"}) return if len(parts) == 3 and parts[1] == "todos": if method == "GET": err = self._check_project(user_id, project_id, "read") if err: self._send_json(err[0], {"error": err[1]}) return self._todos_get(project_id, parts[2]) elif method in ("PATCH", "DELETE"): err = self._check_project( user_id, project_id, "create_task") if err: self._send_json(err[0], {"error": err[1]}) return if method == "PATCH": self._todos_update(user_id, project_id, parts[2]) else: self._todos_delete(user_id, project_id, parts[2]) else: self._send_json(404, {"error": "not found"}) return # /projects/{id}/skills GET(项目 read) if len(parts) == 2 and parts[1] == "skills": if method != "GET": self._send_json(404, {"error": "not found"}) return err = self._check_project(user_id, project_id, "read") if err: self._send_json(err[0], {"error": err[1]}) return self._skills_list(project_id) return # /projects/{id}/apps GET(项目 read) if len(parts) == 2 and parts[1] == "apps": if method != "GET": self._send_json(404, {"error": "not found"}) return err = self._check_project(user_id, project_id, "read") if err: self._send_json(err[0], {"error": err[1]}) return self._apps_list(project_id) return # /projects/{id}/mcp-servers GET(项目 read) if len(parts) == 2 and parts[1] == "mcp-servers": if method != "GET": self._send_json(404, {"error": "not found"}) return err = self._check_project(user_id, project_id, "read") if err: self._send_json(err[0], {"error": err[1]}) return self._mcp_servers_list(project_id) return # /projects/{id}/mcp-servers/reload POST(项目 manage_project) if len(parts) == 3 and parts[1] == "mcp-servers" \ and parts[2] == "reload": if method != "POST": self._send_json(404, {"error": "not found"}) return err = self._check_project(user_id, project_id, "manage_project") if err: self._send_json(err[0], {"error": err[1]}) return self._mcp_servers_reload(user_id, project_id) return # /projects/{id}/mcp-servers/{name}/oauth-login POST # (项目 manage_project;name 为空或穿越时 404) if len(parts) == 4 and parts[1] == "mcp-servers" \ and parts[3] == "oauth-login": if method != "POST": self._send_json(404, {"error": "not found"}) return if not parts[2] or parts[2] in (".", ".."): self._send_json(404, {"error": "not found"}) return err = self._check_project(user_id, project_id, "manage_project") if err: self._send_json(err[0], {"error": err[1]}) return self._mcp_server_oauth_login(user_id, project_id, parts[2]) return self._send_json(404, {"error": "not found"}) # -- 任务 ---------------------------------------------------------- # -- 任务附件 ------------------------------------------------------ def _create_upload(self, user_id): """上传一包附件,返回 upload_id(建任务时绑定)。 权限:任何持有效 key 的用户都可上传(包本身不挂项目, 归属在建任务时校验);包不直接进任务,必须经 _create_task 绑定才生效。413 预检在读 body 之前按 Content-Length 卡, 防止超大 body 先占内存;文件级上限由 validate_bundle 卡。 """ content_type = self.headers.get("Content-Type", "") if "multipart/form-data" not in content_type: self._send_json(400, { "error": "Content-Type 必须是 multipart/form-data"}) return try: length = int(self.headers.get("Content-Length") or 0) except ValueError: length = 0 if length > MAX_RAW_BYTES: self._send_json(413, {"error": "upload too large"}) return raw = self._read_body() try: fields, files = parse_multipart(raw, content_type) paths = parse_paths_field(fields, files) except ValueError as exc: self._send_json(400, {"error": str(exc)}) return if not files: self._send_json(400, {"error": "no files uploaded"}) return if len(paths) != len(files): self._send_json(400, { "error": "附件路径与文件数量不一致"}) return err = validate_bundle(paths, [len(data) for _name, data in files]) if err: self._send_json(400, {"error": err}) return root = _upload_dir_for(self.server) os.makedirs(root, exist_ok=True) bundle_id, saved = save_bundle( root, paths, [data for _name, data in files]) conn = self._db() try: conn.execute( "INSERT INTO uploads(id, created_by, created_at, status)" " VALUES(?, ?, ?, 'pending')", (bundle_id, user_id, time.time()), ) conn.commit() finally: conn.close() self._send_json(201, { "upload_id": bundle_id, "files": [{"path": path, "size": size} for path, size in saved], }) def _create_task(self, user_id): body, err = self._read_json() if err: self._send_json(400, {"error": err}) return project_id = body.get("project_id") prompt = body.get("prompt") if not project_id or not isinstance(project_id, str): self._send_json(400, {"error": "project_id required"}) return if not prompt or not isinstance(prompt, str): self._send_json(400, {"error": "prompt required"}) return # 新建目标:prompt 里直接写 /goal 也算新建 goal,不必 # 先勾选项——例如 prompt="/goal 收xxx" 或“请开启一个 # 持久 /goal,目标是收xxx”。解析在 cloud/goal_command.py, # 与主线转换同款(讨论里提到 /goal 不算)。目标即新 # 任务的 prompt 与 goal;空目标 400,不建普通任务。 prompt_goal_target = _parse_goal_command(prompt) if prompt_goal_target is not None and not prompt_goal_target: self._send_json(400, {"error": "goal 目标不能为空"}) return if prompt_goal_target: prompt = prompt_goal_target depends_on = body.get("depends_on") or [] if isinstance(depends_on, str): depends_on = [depends_on] if not isinstance(depends_on, list): self._send_json(400, {"error": "depends_on must be a list"}) return try: priority = int(body.get("priority", 0) or 0) except (TypeError, ValueError): self._send_json(400, {"error": "priority must be an integer"}) return # executor 可选:None 表示任意 worker 可认领;指定时只能是 # local/docker/cloud,非法值 400 且不建任务。 executor = body.get("executor") if executor is not None and executor not in ( "local", "docker", "cloud"): self._send_json(400, { "error": "executor must be local, docker or cloud"}) return # 任务级选项:model 不做白名单(中转目录会变,下拉只是 # 建议值);effort 必须是 codex 的 effort 档位;approval # 是 console 三档(ask/auto/full),非法值 400 且不建任务。 # 三者都缺省(NULL)时 worker 走配置默认值,行为与过去一致。 model = body.get("model") if model is not None: if not isinstance(model, str) or not model.strip(): self._send_json(400, { "error": "model must be a non-empty string"}) return model = model.strip() effort = body.get("effort") if effort is not None and effort not in ( "low", "medium", "high", "xhigh", "max"): self._send_json(400, { "error": "effort must be low, medium, high, xhigh or max"}) return approval = body.get("approval") if approval is not None and approval not in ("ask", "auto", "full"): self._send_json(400, { "error": "approval must be ask, auto or full"}) return # 派生默认:本任务派生的子任务(marker task-create、 # 协调员分工)未显式写模型/强度时用的模型/强度。 # 显式字段优先;缺省时若 prompt 里有“子任务用 X max” # 这类显式指令,由 parse_spawn_defaults 解析填入—— # 只认带派生主语的显式指令,裸说“主线用 X”不算, # 那是本任务自己的 model,不应被误当成派生默认。 # 两者都缺省(NULL)时派生走本任务 model / effort, # 再缺省才走 worker 默认,行为与过去一致。 child_model = body.get("child_model") if child_model is not None: if not isinstance(child_model, str) or not child_model.strip(): self._send_json(400, { "error": "child_model must be a non-empty string"}) return child_model = child_model.strip() child_effort = body.get("child_effort") if child_effort is not None and child_effort not in ( "low", "medium", "high", "xhigh", "max"): self._send_json(400, { "error": "child_effort must be low, medium, high," " xhigh or max"}) return if child_model is None or child_effort is None: parsed = _parse_spawn_defaults(prompt) if child_model is None: child_model = parsed.get("model") if child_effort is None: child_effort = parsed.get("effort") # 计划模式:只读沙箱,不改任何文件;与“完全访问” # (danger-full-access)语义直接冲突,拒绝建任务。 plan = 1 if body.get("plan") in (True, 1) else 0 if plan and approval == "full": self._send_json(400, { "error": "plan 模式与 approval=full 冲突(只读 vs 完全访问)"}) return (goal_mode, goal, verify_command, goal_max_rounds, _goal_err) = _validate_task_goal_options( body, prompt, prompt_goal_target, plan) if _goal_err: self._send_json(400, {"error": _goal_err}) return # 协调员:coordinator_parts 非空时任务本身不跑模型回合, # worker 认领后按分工建并行子任务、等齐、合流;与 goal/ # plan/验收命令语义冲突(那些都是“本任务跑模型”的意思), # 同用时 400 且不建任务。 coord_parts = None if body.get("coordinator_parts") is not None: coord_parts, coord_err = _coord_validate_parts( body.get("coordinator_parts")) if coord_err: self._send_json(400, {"error": coord_err}) return if goal_mode or plan or verify_command: self._send_json(400, { "error": "协调员任务不跑模型回合,不能与" " goal/plan/验收命令同用"}) return idempotency_key = body.get("idempotency_key") if idempotency_key is not None and not isinstance(idempotency_key, str): self._send_json(400, {"error": "idempotency_key must be a string"}) return with _auth_store(self.server.db_path) as store: if not store.check_permission(user_id, project_id, "create_task"): self._send_json(403, {"error": "forbidden"}) return if self._reject_if_over_quota(user_id): return # 协调员预留(P1):父任务准入只查一次时,N 个分工会 # 解锁 N 倍消费。父任务创建时要求剩余美元配额至少覆盖 # 分工数 × 每分工预留额,否则直接 402、父任务不建; # 子任务归属 = 父任务创建人(worker 建子任务时 # created_by 直接取父任务创建人,成本 JOIN 据此归属, # 子任务认领时 worker 侧配额门再查一次)。不限额用户 # (quota 为 None)不受此预留约束。 if coord_parts and not self._check_coordinator_reserve( user_id, len(coord_parts)): return # sub2api 懒开通/健康检查(P1):建任务前先真校验网关 # key——无行则开通,死 key 则轮换重开;网关不可用时 # 502 且不建任务(不把死 key/待开通当成已开通发给 # worker)。xcmapi 网关下跳过本段。 if _gateway_name() == "sub2api": _gw_conn = self._db() try: try: _validate_sub2api_key(_gw_conn, user_id) except _Sub2apiError as exc: self._send_json(502, {"error": str(exc)}) return except Exception as exc: self._send_json(502, {"error": str(exc)}) return finally: _gw_conn.close() # 环境绑定:environment_id 非空时必须是本项目、已 ready # 的环境;creating/failed/archived 一律拒绝并把状态写进 # 报错(creating 时用户就知道该等 worker 准备好再建任务, # 而不是任务排队后才在 worker 侧失败)。执行器规则: # 环境 executor 非空且任务 executor 已指定时必须一致; # 任务 executor 未指定时继承环境 executor(认领过滤靠它, # 否则 local 任务可能被 docker worker 认领、反之亦然); # cloud 执行器没有环境检出路径,一律拒绝。 environment_id = body.get("environment_id") if environment_id is not None: if not isinstance(environment_id, str) \ or not environment_id.strip(): self._send_json(400, { "error": "environment_id must be a non-empty string"}) return environment_id = environment_id.strip() env_row = self._fetch_environment(environment_id) if env_row is None: self._send_json(404, {"error": "environment not found"}) return env = _environment_to_dict(env_row) if env["project_id"] != project_id: self._send_json(400, { "error": "environment belongs to another project"}) return if env["status"] != "ready": self._send_json(400, { "error": "environment not ready: %s" % env["status"]}) return if executor == "cloud": self._send_json(400, { "error": "environment does not support cloud executor"}) return if env["executor"] and executor \ and env["executor"] != executor: self._send_json(400, { "error": "executor does not match environment executor"}) return if executor is None and env["executor"]: executor = env["executor"] if executor is None: # 未指定且无环境继承时默认 docker:空 executor(任意 # worker 可认领)会让任务落到隔离较弱的 local worker, # 是多用户下的隔离缺口;显式 local/cloud 不受影响。 executor = _default_executor() # 附件绑定(upload_id 由 POST /uploads 发出):包必须存在、 # 归属当前用户、未使用、24 小时内、暂存目录还在;cloud # 执行器没有 worktree 可 seed,直接拒绝。校验在 INSERT # 之前做,绑定写与任务 INSERT 同一事务,失败不留半截。 bundle_files = [] upload_id = body.get("upload_id") if upload_id is not None: if not isinstance(upload_id, str) or not upload_id: self._send_json(400, { "error": "upload_id must be a non-empty string"}) return if executor == "cloud": self._send_json(400, { "error": "附件不支持 cloud 执行器"}) return conn0 = self._db() try: brow = conn0.execute( "SELECT * FROM uploads WHERE id = ?", (upload_id,), ).fetchone() finally: conn0.close() if brow is None: self._send_json(400, {"error": "upload_id 不存在"}) return if brow["created_by"] != user_id: self._send_json(403, {"error": "forbidden"}) return if brow["status"] == "used": self._send_json(409, {"error": "附件包已使用"}) return if time.time() - brow["created_at"] > BUNDLE_TTL_SECONDS: self._send_json(400, {"error": "附件包已过期"}) return bundle_dir = os.path.join( _upload_dir_for(self.server), upload_id) if not os.path.isdir(bundle_dir): self._send_json(400, {"error": "附件暂存目录缺失"}) return for dirpath, _dirnames, filenames in os.walk(bundle_dir): for fn in sorted(filenames): full = os.path.join(dirpath, fn) rel = os.path.relpath(full, bundle_dir) bundle_files.append((rel, os.path.getsize(full))) bundle_files.sort() if not bundle_files: self._send_json(400, {"error": "附件包为空"}) return now = time.time() conn = self._db() try: if idempotency_key is not None: row = conn.execute( "SELECT * FROM tasks WHERE project_id = ?" " AND idempotency_key = ?", (project_id, idempotency_key), ).fetchone() if row: self._send_json(200, _task_to_dict( row, _attachments_map( conn, [row["id"]])[row["id"]])) return task_id = uuid.uuid4().hex try: conn.execute( "INSERT INTO tasks(id, project_id, prompt, status," " idempotency_key, depends_on, priority, created_by," " created_at, updated_at, last_heartbeat, executor," " goal, verify_command, goal_max_rounds," " model, effort, approval, plan, goal_mode," " environment_id, coordinator_parts," " parent_task_id, coordinator_part," " child_model, child_effort)" " VALUES(?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, NULL, ?," " ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (task_id, project_id, prompt, idempotency_key, json.dumps(depends_on), priority, user_id, now, now, executor, goal, verify_command, goal_max_rounds, model, effort, approval, plan, goal_mode, environment_id, json.dumps(coord_parts) if coord_parts else None, None, None, child_model, child_effort), ) # 长任务不限时:API 字段 no_time_limit=1 时写标记, # worker 认领后按不限时跑(单轮 host 600 秒仍在); # 不传或非真值时保持默认 0,现有任务行为不变。 if body.get("no_time_limit") in (True, 1): conn.execute( "UPDATE tasks SET no_time_limit = 1" " WHERE id = ?", (task_id,)) for rel_path, size in bundle_files: conn.execute( "INSERT INTO task_attachments" "(task_id, bundle_id, rel_path, size)" " VALUES(?, ?, ?, ?)", (task_id, upload_id, rel_path, size), ) if upload_id is not None: conn.execute( "UPDATE uploads SET status = 'used' WHERE id = ?", (upload_id,), ) conn.commit() except (sqlite3.IntegrityError, _PgIntegrityError): # 并发下同幂等键抢建:回读已有任务,不建重复。 row = conn.execute( "SELECT * FROM tasks WHERE project_id = ?" " AND idempotency_key = ?", (project_id, idempotency_key), ).fetchone() if row: self._send_json(200, _task_to_dict( row, _attachments_map( conn, [row["id"]])[row["id"]])) return raise row = conn.execute( "SELECT * FROM tasks WHERE id = ?", (task_id,) ).fetchone() task = _task_to_dict( row, [rel_path for rel_path, _size in bundle_files]) finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, project_id, "create_task", task_id) if coord_parts: # 创建响应里写明子任务配额归属(与 worker 建子任务时 # created_by=父任务创建人一致),不是只写文档宣称。 task["quota_attribution"] = ( "coordinator subtasks are charged to the parent" " task creator") task["quota_reserved_usd_micros"] = ( len(coord_parts) * COORDINATOR_RESERVE_PER_PART_USD_MICROS) self._send_json(201, task) def _list_tasks(self, user_id, query): # GET /tasks: server-side pagination + filtering. # - No limit param: old behaviour, all rows in created_at # order (coord children and existing callers rely on it). # - limit param: limit 1..100, offset >= 0 (invalid -> 400), # order=desc (newest first, the list's display order), # status/executor exact match, q case-insensitive # substring over id + prompt (same rule the frontend # used to apply client-side). total is the filtered # count before slicing, status_counts is over all # project rows (so the data card's breakdown is not # skewed by the current filter or page). project_ids = query.get("project_id") project_id = project_ids[0] if project_ids else None if not project_id: self._send_json(400, {"error": "project_id required"}) return with _auth_store(self.server.db_path) as store: if not store.check_permission(user_id, project_id, "read"): self._send_json(403, {"error": "forbidden"}) return parent_task_id = None parent_ids = query.get("parent_task_id") if parent_ids: parent_task_id = parent_ids[0] limit = None offset = 0 if query.get("limit"): try: limit = int(query["limit"][0]) offset = int(query["offset"][0]) if query.get("offset") else 0 except (TypeError, ValueError): self._send_json(400, {"error": "limit/offset must be integers"}) return if limit < 1 or limit > 100 or offset < 0: self._send_json(400, {"error": "limit must be 1..100, offset >= 0"}) return order_desc = bool(query.get("order") and query["order"][0] == "desc") status_f = query["status"][0] if query.get("status") else None executor_f = query["executor"][0] if query.get("executor") else None q_f = query["q"][0].lower() if query.get("q") else None conn = self._db() try: if parent_task_id: rows = conn.execute( "SELECT * FROM tasks WHERE project_id = ?" " AND parent_task_id = ?" " ORDER BY created_at, id", (project_id, parent_task_id), ).fetchall() else: rows = conn.execute( "SELECT * FROM tasks WHERE project_id = ?" " ORDER BY created_at, id", (project_id,), ).fetchall() status_counts = {} for r in rows: s = r["status"] status_counts[s] = status_counts.get(s, 0) + 1 if parent_task_id: filtered = rows else: filtered = [] for r in rows: d = _task_to_dict(r, [], 0) if status_f and d["status"] != status_f: continue if executor_f and (d["executor"] or "") != executor_f: continue if q_f and q_f not in ((d["id"] or "") + " " + (d["prompt"] or "")).lower(): continue filtered.append(r) total = len(filtered) if order_desc: filtered = list(reversed(filtered)) page_rows = filtered[offset:offset + limit] if limit is not None else filtered amap = _attachments_map(conn, [r["id"] for r in page_rows]) tmap = _task_tokens_map(conn, [r["id"] for r in page_rows]) finally: conn.close() out = {"tasks": [ _task_to_dict(r, amap[r["id"]], tmap.get(r["id"], 0)) for r in page_rows], "total": total, "status_counts": status_counts} if limit is not None: out["limit"] = limit out["offset"] = offset self._send_json(200, out) def _fetch_task(self, task_id): conn = self._db() try: return conn.execute( "SELECT * FROM tasks WHERE id = ?", (task_id,) ).fetchone() finally: conn.close() def _goal_convert_queued(self, task_id, target): """把尚未认领的 queued 主线原地转成新语义 goal 任务。 CAS 只改仍是 queued 的行:worker 在本方法执行间认领 时 rowcount 为 0,调用方回 409 请重试,绝不把已在跑 的任务悄悄改模式(在跑任务的转换走 followup 排队 + worker 轮次边界转换,见 worker._convert_running_task_to_goal)。 成功返回新任务 dict,失败返回 None。""" conn = self._db() try: cur = conn.execute( "UPDATE tasks SET goal_mode = 1, goal = ?," " verify_command = NULL, goal_max_rounds = 0," " updated_at = ? WHERE id = ? AND status = 'queued'", (target, time.time(), task_id)) conn.commit() if cur.rowcount != 1: return None return _task_to_dict(conn.execute( "SELECT * FROM tasks WHERE id = ?", (task_id,) ).fetchone()) finally: conn.close() def _goal_revive_terminal(self, task_id, target): """把已终态主线复活并转成新语义 goal 任务(/continue 同款)。 CAS 要求状态在终态四态且 resume_message 为空:已有待续跑 消息时不覆盖它(那条消息会先跑,goal 请求不应把它顶掉)。 成功返回新任务 dict,失败返回 None。merged 不在此列, 调用方须先按 merged 409 拦掉。""" conn = self._db() try: cur = conn.execute( "UPDATE tasks SET status = 'queued'," " resume_message = ?, goal_mode = 1, goal = ?," " verify_command = NULL, goal_max_rounds = 0," " result_text = NULL, error_text = NULL," " actual_model = NULL, updated_at = ?" " WHERE id = ? AND status IN ('completed', 'failed'," " 'interrupted', 'merge-failed')" " AND resume_message IS NULL", (target, target, time.time(), task_id)) conn.commit() if cur.rowcount != 1: return None return _task_to_dict(conn.execute( "SELECT * FROM tasks WHERE id = ?", (task_id,) ).fetchone()) finally: conn.close() def _get_task(self, user_id, task_id): row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return conn = self._db() try: atts = _attachments_map(conn, [task_id])[task_id] # 详情与列表口径一致:单任务也一次聚合回 total_tokens, # 否则详情里的 token 栏只能显示占位。 tok = _task_tokens_map(conn, [task_id]).get(task_id, 0) finally: conn.close() task = _task_to_dict(row, atts, tok) with _auth_store(self.server.db_path) as store: if not store.check_permission(user_id, task["project_id"], "read"): self._send_json(403, {"error": "forbidden"}) return self._send_json(200, task) # -- 任务实时链路 -------------------------------------------------- def _trace_snapshot(self, task): """当前链路快照 (entries, note):worktree 在别机时读不到 文件,返回空链路 + 说明(多机诚实降级,不谎报无过程)。 租户目录与共享目录都读、合并(租户 Docker 任务的链路 文件只在租户目录里,旧版只读共享目录会误报为空)。""" worktree_host = task.get("worktree_host") if worktree_host and worktree_host != socket.gethostname(): return [], ("链路文件在 worktree 主机 %s 上,本机读不到" % worktree_host) paths = [os.path.join(d, _trace_file_name(task["id"]) + ".jsonl") for d in _trace_dirs_for_task(self.server, task)] return _read_trace_entries_multi(paths), "" def _task_result_full(self, user_id, task_id): """完整结果端点:result_text 仍是 4000 字摘要,完整版 在 result_text_full 里;无完整版时 404(老任务、goal 任务、或本轮没拿到 stdout_full 的任务),不回摘要 冒充完整。权限与 _get_task 同款 read 校验。""" row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission(user_id, task["project_id"], "read"): self._send_json(403, {"error": "forbidden"}) return full = _row_get(row, "result_text_full") if not full: self._send_json(404, {"error": "result full not available"}) return self._send_json(200, {"task_id": task_id, "status": task["status"], "result_text_full": full}) def _task_trace(self, user_id, task_id): row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission(user_id, task["project_id"], "read"): self._send_json(403, {"error": "forbidden"}) return entries, note = self._trace_snapshot(task) self._send_json(200, {"task_id": task_id, "status": task["status"], "entries": entries, "note": note}) def _task_agents(self, user_id, task_id): row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission(user_id, task["project_id"], "read"): self._send_json(403, {"error": "forbidden"}) return worktree_host = task.get("worktree_host") if worktree_host and worktree_host != socket.gethostname(): self._send_json(200, {"task_id": task_id, "status": task["status"], "agents": [], "note": ("链路文件在 worktree 主机 %s 上,本机读不到" % worktree_host)}) return agents = _build_agent_chain( task, _trace_dirs_for_task(self.server, task)) self._send_json(200, {"task_id": task_id, "status": task["status"], "agents": agents, "note": ""}) def _task_trace_stream(self, user_id, task_id): row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission(user_id, task["project_id"], "read"): self._send_json(403, {"error": "forbidden"}) return entries, note = self._trace_snapshot(task) try: self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-cache") self.send_header("Connection", "keep-alive") self.end_headers() except (BrokenPipeError, ConnectionResetError): return self._responded = True # SSE 无 Content-Length,done 后靠关连接界定流结束;注意 # send_header("Connection", "keep-alive") 会把 # close_connection 改回 False,所以必须在 end_headers 之后 # 再置 True(CPython send_header 的 Connection 分支行为)。 self.close_connection = True sent = {} def _emit(obj): try: self.wfile.write( ("data: " + json.dumps(obj, ensure_ascii=False, default=_json_default) + "\n\n").encode("utf-8")) self.wfile.flush() except (BrokenPipeError, ConnectionResetError): return False return True if not _emit({"type": "snapshot", "task_id": task_id, "status": task["status"], "entries": entries, "note": note}): return for entry in entries: sent[entry["id"]] = json.dumps(entry, sort_keys=True, ensure_ascii=False) if task["status"] in TERMINAL_STATUSES: _emit({"type": "done", "status": task["status"]}) return deadline = time.time() + TRACE_STREAM_MAX_SECONDS try: while time.time() < deadline: time.sleep(TRACE_STREAM_POLL_SECONDS) row = self._fetch_task(task_id) if row is None: _emit({"type": "done", "status": "deleted"}) return task = _task_to_dict(row) entries, _note = self._trace_snapshot(task) for entry in entries: blob = json.dumps(entry, sort_keys=True, ensure_ascii=False) if sent.get(entry["id"]) != blob: sent[entry["id"]] = blob if not _emit({"type": "entry", "entry": entry}): return if task["status"] in TERMINAL_STATUSES: _emit({"type": "done", "status": task["status"]}) return except (BrokenPipeError, ConnectionResetError): return _emit({"type": "done", "status": task["status"], "reason": "timeout"}) def _create_followup(self, user_id, task_id): """给进行中任务发后续消息:排队 / 直接发送。 语义(用户定稿):排队进队尾,不打断本轮,本轮结束 后按序每条再跑一轮;直接发送也不中断本轮——消息立即 送达(链路里可见),本轮跑完后立刻以它开下一轮,且 优先于排队中的消息。v1 只做运行时(含排队中)的任务: 已终态的任务 409;goal 任务 409(goal 循环自有轮次)。 例外:消息是 /goal 命令时(见 cloud/goal_command.py), 不走普通后续消息——queued 原地转 goal、running 排队后 由 worker 在轮次边界转 goal、已终态复活并转 goal; 已是 goal、plan 任务、merged、空目标各按 409/400 拒绝。 权限与取消同款:项目 create_task(写操作,不能只读)。 """ body, err = self._read_json() if err: self._send_json(400, {"error": err}) return row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission( user_id, task["project_id"], "create_task"): self._send_json(403, {"error": "forbidden"}) return if self._reject_if_over_quota_for_task(user_id, task): return text, mode, body_err = _validate_followup_body(body) if body_err is not None: self._send_json(body_err[0], body_err[1]) return goal_target = _parse_goal_command(text) # /goal 前置判定见 _classify_goal_followup(纯计算); # 下面只剩各判定对应的副作用路,顺序与原文一致: # error 先返回,queued 原地转 goal,终态复活转 goal, # running 落到普通 followup 插入路(不打断本轮,只排队; # worker 在本轮完成、排空到这条时把主线转成 goal,见 # worker 排空段;本轮若失败则这条留在 pending,等下次 # 成功轮次再转),响应里标明是 goal。 goal_action, goal_code, goal_payload = _classify_goal_followup( task, goal_target) if goal_action == "error": self._send_json(goal_code, goal_payload) return if goal_action == "convert_queued": new_task = self._goal_convert_queued(task_id, goal_target) if new_task is None: self._send_json(409, { "error": "任务状态已变化,请刷新后重试", "status": task["status"], }) return with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "mainline_goal_created", "%s:goal=%s" % (task_id, goal_target)) self._send_json(200, dict( new_task, delivery="goal_converted", goal=goal_target)) return if goal_action == "revive_terminal": new_task = self._goal_revive_terminal(task_id, goal_target) if new_task is None: self._send_json(409, { "error": "任务已有待续跑消息或状态已变化," "请刷新后重试", "status": task["status"], }) return with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "mainline_goal_created", "%s:goal_revived=%s" % (task_id, goal_target)) self._send_json(200, dict( new_task, delivery="goal_revived", goal=goal_target)) return if task.get("verify_command") and goal_target is None: self._send_json(409, {"error": "goal 任务暂不支持后续消息"}) return now = time.time() conn = self._db() try: _ensure_followups_table(conn) fu_id = uuid.uuid4().hex conn.execute( "INSERT INTO task_followups(id, task_id, project_id," " body, mode, status, created_by, created_at," " updated_at) VALUES(?, ?, ?, ?, ?, 'pending', ?, ?, ?)", (fu_id, task_id, task["project_id"], text, mode, user_id, now, now), ) conn.commit() fu = _followup_to_dict( conn.execute( "SELECT * FROM task_followups WHERE id = ?", (fu_id,), ).fetchone() ) finally: conn.close() if mode == "direct": self._append_direct_trace(task, fu_id, text) with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "followup_created", "%s:%s" % (task_id, fu_id)) if goal_target is not None: store.audit(user_id, task["project_id"], "mainline_goal_created", "%s:goal_queued=%s" % (task_id, goal_target)) if goal_target is not None: self._send_json(201, dict( fu, delivery="goal_queued", goal=goal_target)) return self._send_json(201, fu) def _append_direct_trace(self, task, fu_id, text): """直接发送的消息立即进链路:往 host 的 jsonl 里追加一条 message 条目(phase=followup-direct),运行中的链路流 下一轮 poll 就能看到它。只尽力而为:worktree 在别机时 本机文件不是 worker 写的那份,跳过(与 _trace_snapshot 的多机降级同规则);写失败也绝不让 POST 失败。""" try: worktree_host = task.get("worktree_host") if worktree_host and worktree_host != socket.gethostname(): return # 续写已有链路文件所在的目录:租户 Docker 任务的链路 # 文件在租户目录里,续写共享目录会把一条任务的链路 # 拆成两处;还没有任何链路文件时默认写共享目录。 d = _trace_dir_for(self.server) tenant = _tenant_trace_dir(self.server, task.get("project_id")) if tenant and os.path.exists(os.path.join( tenant, _trace_file_name(task["id"]) + ".jsonl")): d = tenant os.makedirs(d, exist_ok=True) entry = {"kind": "message", "id": "fu-" + fu_id, "ts": int(time.time() * 1000), "phase": "followup-direct", "text": text[:300]} with open(os.path.join( d, _trace_file_name(task["id"]) + ".jsonl"), "a", encoding="utf-8") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") except Exception: pass def _list_followups(self, user_id, task_id): row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission( user_id, task["project_id"], "read"): self._send_json(403, {"error": "forbidden"}) return conn = self._db() try: _ensure_followups_table(conn) rows = conn.execute( "SELECT * FROM task_followups WHERE task_id = ?" " ORDER BY created_at, id", (task_id,), ).fetchall() finally: conn.close() self._send_json(200, { "followups": [_followup_to_dict(r) for r in rows]}) def _task_timeline(self, user_id, task_id): """GET /tasks/{id}/timeline(P0 三件其三):一个任务一张 时间线。steps 直读 task_steps(claim/resume/round/final), checkpoints 直读 task_checkpoints(每轮断点快照);旧 任务(部署前跑完、无步骤记录)返回空数组,不编造。 """ row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission( user_id, task["project_id"], "read"): self._send_json(403, {"error": "forbidden"}) return conn = self._db() try: _ensure_task_checkpoints_table(conn) _ensure_task_steps_table(conn) step_rows = conn.execute( "SELECT step_no, kind, summary, state_sha, created_at" " FROM task_steps WHERE task_id = ?" " ORDER BY step_no, created_at, id", (task_id,)).fetchall() cp_rows = conn.execute( "SELECT round_no, thread_id, goal_status, state_sha," " verify_out, created_at FROM task_checkpoints" " WHERE task_id = ? ORDER BY round_no", (task_id,)).fetchall() finally: conn.close() self._send_json(200, { "task_id": task_id, "steps": [{"step_no": r[0], "kind": r[1], "summary": r[2], "state_sha": r[3], "created_at": r[4]} for r in step_rows], "checkpoints": [{"round_no": r[0], "thread_id": r[1], "goal_status": r[2], "state_sha": r[3], "verify_out": r[4], "created_at": r[5]} for r in cp_rows], }) def _task_files(self, user_id, task_id, query): """GET /tasks/{id}/files:读任务 worktree 里的文件。 解析见 _task_worktree_for_files:本地任务走本地 worktree, 租户 Docker 任务走 tenants 下的任务级子目录(只 serves host-worktrees/<任务id>,绝不 serves 共享的 /app/work 本 身);cloud 任务与跨机 worktree 一律 409,解析不到时 404。 ?path=<相对路径>:不传列 worktree 顶层(递归、最多 200 条,只含文件路径与大小,不跟符号链接);传目录列该目录 (非递归,含 is_dir);传文件读文本内容(UTF-8、≤256KB, 二进制或超大一律 409/413 明说,不截断冒充全文)。路径 解析一律走 _safe_worktree_path,防 .. 与符号链接逃逸。 读操作不限制任务状态(进行中任务读到的是中间态)。 """ task, err = self._thread_task(user_id, task_id, "read") if err is not None: self._send_json(err[0], {"error": err[1]}) return worktree, werr = _task_worktree_for_files(self.server, task) if werr is not None: self._send_json(werr[0], {"error": werr[1]}) return rel = (query.get("path") or [""])[0] target = _safe_worktree_path(worktree, rel) if target is None: self._send_json(400, {"error": "path escapes task worktree"}) return if os.path.isdir(target): entries = [] if not rel: for root, dirs, files in os.walk(target): dirs[:] = [d for d in dirs if not os.path.islink(os.path.join(root, d))] dirs.sort() for name in sorted(files): fpath = os.path.join(root, name) if os.path.islink(fpath): continue entries.append({ "path": os.path.relpath(fpath, worktree), "size": os.path.getsize(fpath), }) if len(entries) >= 200: break if len(entries) >= 200: break else: for name in sorted(os.listdir(target)): fpath = os.path.join(target, name) is_link = os.path.islink(fpath) entries.append({ "path": os.path.relpath(fpath, worktree), "size": os.path.getsize(fpath) if os.path.isfile(fpath) and not is_link else 0, "is_dir": os.path.isdir(fpath) and not is_link, }) self._send_json(200, {"task_id": task_id, "path": rel, "entries": entries}) return if not os.path.isfile(target): self._send_json(404, {"error": "no such file"}) return size = os.path.getsize(target) if size > 256 * 1024: self._send_json(413, {"error": "file too large for inline read"}) return try: with open(target, encoding="utf-8") as fh: content = fh.read() except (OSError, UnicodeDecodeError): self._send_json(409, {"error": "file is not readable UTF-8 text"}) return self._send_json(200, {"task_id": task_id, "path": rel, "size": size, "content": content}) def _task_tree(self, user_id, task_id, query): """GET /tasks/{id}/tree:结构化目录树(不读文件内容)。 与 /files 同款权限与 worktree 解析:viewer+ 可读, cloud 任务与跨机 worktree 一律 409,解析不到时 404。 ?path=<相对路径>:不传列 worktree 根;传目录列该目录。 ?max_depth:1..5,缺省 3;?max_entries:1..500,缺省 200。非法整数一律 400,不静默回落缺省。路径逃逸 400,不存在或非目录 404。条数到顶时 truncated=True, 被 max_depth 截断的目录 children=[] 且 children_truncated=True,空目录则是 children=[] 且 children_truncated=False,两者可区分。 """ task, err = self._thread_task(user_id, task_id, "read") if err is not None: self._send_json(err[0], {"error": err[1]}) return worktree, werr = _task_worktree_for_files(self.server, task) if werr is not None: self._send_json(werr[0], {"error": werr[1]}) return try: max_depth = int((query.get("max_depth") or [str(_TREE_DEFAULT_DEPTH)])[0]) max_entries = int((query.get("max_entries") or [str(_TREE_DEFAULT_ENTRIES)])[0]) except (TypeError, ValueError): self._send_json(400, {"error": "max_depth/max_entries must be integers"}) return if not 1 <= max_depth <= 5 or not 1 <= max_entries <= 500: self._send_json(400, {"error": "max_depth must be 1..5 and " "max_entries must be 1..500"}) return rel = (query.get("path") or [""])[0] try: tree = _build_tree(worktree, path=rel, max_depth=max_depth, max_entries=max_entries) except ValueError: self._send_json(400, {"error": "path escapes task worktree"}) return except FileNotFoundError: self._send_json(404, {"error": "no such path"}) return except NotADirectoryError: self._send_json(404, {"error": "path is not a directory"}) return tree = dict(tree) tree["task_id"] = task_id tree["max_depth"] = max_depth tree["max_entries"] = max_entries self._send_json(200, tree) def _browser_worktree_for(self, user_id, task_id): """Auth + worktree resolution shared by browser endpoints. Same rules as /files. Returns (worktree, None) or (None, (status, msg)). """ task, err = self._thread_task(user_id, task_id, "read") if err is not None: return None, err worktree, werr = _task_worktree_for_files(self.server, task) if werr is not None: return None, werr return worktree, None def _browser_body_dict(self, body): """Parse a raw POST body (bytes from _read_body) into a dict; None for empty/invalid JSON (handlers map it to their own 400).""" if isinstance(body, dict): return body if not body: return None try: obj = json.loads(body.decode("utf-8")) except (ValueError, UnicodeDecodeError): return None return obj if isinstance(obj, dict) else None def _browser_state(self, user_id, task_id): """GET /tasks/{id}/browser/state: fresh bridge state file.""" from cloud.playwright_remote import read_state worktree, err = self._browser_worktree_for(user_id, task_id) if err is not None: self._send_json(err[0], {"error": err[1]}) return state, serr = read_state(worktree) if serr is not None: self._send_json(serr[0], {"error": serr[1]}) return self._send_json(200, state) def _browser_input(self, user_id, task_id, body): """POST /tasks/{id}/browser/input: append to the bridge log. Holder is enforced here (409 unless the state file says "user") and again by the MCP at execution time, so a release racing an append can only drop the input, never execute it under the wrong holder. """ from cloud.playwright_remote import ( append_input, effective_holder, read_state, validate_input) worktree, err = self._browser_worktree_for(user_id, task_id) if err is not None: self._send_json(err[0], {"error": err[1]}) return state, serr = read_state(worktree) if serr is not None: self._send_json(serr[0], {"error": serr[1]}) return holder = effective_holder(worktree, state) if holder != "user": self._send_json(409, {"error": "user does not hold control " "(holder=%s); take over first" % holder}) return item, ierr = validate_input(self._browser_body_dict(body)) if ierr is not None: self._send_json(ierr[0], {"error": ierr[1]}) return try: append_input(worktree, item) except OSError as exc: self._send_json(503, {"error": "browser input unreachable: " "%s" % exc}) return self._send_json(200, {"queued": True}) def _browser_control(self, user_id, task_id, body): """POST /tasks/{id}/browser/control: write the control file. Works even before the browser starts: the MCP applies the file on its first poll, so an early takeover sticks. """ from cloud.playwright_remote import write_control worktree, err = self._browser_worktree_for(user_id, task_id) if err is not None: self._send_json(err[0], {"error": err[1]}) return parsed = self._browser_body_dict(body) holder = parsed.get("holder") if parsed else None try: holder = write_control(worktree, holder) except ValueError as exc: self._send_json(400, {"error": str(exc)}) return except OSError as exc: self._send_json(503, {"error": "browser control unreachable: " "%s" % exc}) return self._send_json(200, {"holder": holder}) def _browser_stream(self, user_id, task_id): """GET /tasks/{id}/browser/stream: SSE from bridge files. Polls the frame file every 20ms and emits a data line whenever its bytes change (plus a heartbeat with the current holder at least every 5s). The payload also carries the session url/title, so the console panel can track the header from the stream alone instead of polling the state endpoint while watching. Stale state does NOT end the stream while the task is still alive: between rounds (and across a worker restart) the MCP is gone for a while and the state file ages past the 30s staleness mark, which used to kill every watch at the first round gap. Now a stale state only switches the stream to waiting mode -- a heartbeat with waiting:true every 5s, task status checked at the same cadence -- and frames resume when the next round's MCP starts polling again. The stream ends only when the task reaches a terminal state (completed, merged, failed), the task row disappears, or the state stays stale for 10 minutes straight (abandoned watch). """ import time from cloud.playwright_remote import ( bridge_paths, effective_holder, read_state) worktree, err = self._browser_worktree_for(user_id, task_id) if err is not None: self._send_json(err[0], {"error": err[1]}) return _state, serr = read_state(worktree) if serr is not None: self._send_json(serr[0], {"error": serr[1]}) return self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-cache") self.end_headers() self._responded = True paths = bridge_paths(worktree) last_frame = None last_heartbeat = 0.0 last_holder = "" stale_since = None last_status_check = 0.0 task_status = None try: while True: state, serr = read_state(worktree) now = time.time() if serr is not None: # No live session right now (round gap, # worker restart, or a blocked drain loop). # Wait it out while the task is still alive # instead of dropping the watch. if stale_since is None: stale_since = now if now - stale_since > 600: break # abandoned watch if now - last_status_check >= 5: last_status_check = now try: row = self._fetch_task(task_id) except Exception: # noqa: BLE001 - keep waiting row = False # transient error, not deletion if row is None: break # task row gone if row is not False: task_status = row["status"] if task_status in ( "completed", "merged", "failed"): break if now - last_heartbeat >= 5: last_heartbeat = now payload = json.dumps( {"image": "", "holder": last_holder, "waiting": True, "task_status": task_status or "", "ts": int(now)}, ensure_ascii=False) self.wfile.write( ("data: %s\n\n" % payload).encode("utf-8")) self.wfile.flush() time.sleep(0.02) continue stale_since = None try: with open(paths["frame"], "rb") as fh: frame = fh.read().decode("utf-8") except (OSError, UnicodeDecodeError): frame = "" if frame != last_frame or now - last_heartbeat >= 5: last_frame = frame last_heartbeat = now last_holder = effective_holder(worktree, state) payload = json.dumps( {"image": frame, "holder": last_holder, "url": state.get("url", ""), "title": state.get("title", ""), "ts": int(now)}, ensure_ascii=False) self.wfile.write( ("data: %s\n\n" % payload).encode("utf-8")) self.wfile.flush() time.sleep(0.02) except (BrokenPipeError, ConnectionResetError, OSError): pass # An SSE stream has no Content-Length, so the client can # only tell "stream ended" from the connection closing. # HTTP/1.1 keep-alive would otherwise leave it hanging # after a terminal break (completed/failed/cap) with no # more bytes ever coming. Force the close on stream end. self.close_connection = True def _task_chat_history(self, user_id, task_id): """GET /tasks/{id}/chat-history:真正的交替历史,一次性 按时间交错拼好返回,前端不再猜顺序。 来源(全部是后端已有记录,不编造): - 首条 prompt:任务行 prompt + created_at(用户) - 继续消息:task_continue_messages(用户,部署后才入库) - 后续消息:task_followups(用户,含任务间互发) - 助手/工具/状态:链路 message/exec/file/tool/call/ plan/reasoning/agent-* 条目(trace 的 ts 是毫秒时间戳, 无 ts 的条目按链路顺序贴在上一条有 ts 的条目之后, 不给它伪造时间戳——输出里 ts_ms 为 null) 排序:按 ts_ms 升序,同一毫秒用户消息在链路条目之前 (用户消息是触发该轮的原因),其余按原顺序稳定排序。 旧任务缺口:部署前发生的继续只在 audit 里留了 task_continued(只有任务 id,没有正文),无法追回; legacy_continue_count = audit 次数 - 已入库条数,>0 时 前端必须在聊天窗上标“此前继续消息未入库,历史不完整”, 不能假装这就是全部对话。异机 worktree 时链路读不到, messages 里只有用户侧 + note,不谎报无过程。""" row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission( user_id, task["project_id"], "read"): self._send_json(403, {"error": "forbidden"}) return entries, trace_note = self._trace_snapshot(task) conn = self._db() try: _ensure_continue_messages_table(conn) _ensure_followups_table(conn) cont_rows = conn.execute( "SELECT * FROM task_continue_messages WHERE task_id = ?" " ORDER BY created_at, id", (task_id,), ).fetchall() fu_rows = conn.execute( "SELECT * FROM task_followups WHERE task_id = ?" " ORDER BY created_at, id", (task_id,), ).fetchall() legacy = None try: aud = conn.execute( "SELECT COUNT(*) FROM audit_log" " WHERE action = 'task_continued' AND detail = ?", (task_id,), ).fetchone() legacy = max(0, int(aud[0]) - len(cont_rows)) except Exception: legacy = None finally: conn.close() # 用户侧与链路侧条目构造见 _chat_history_user_entries / # _chat_history_trace_entries(纯构造);DB 读、权限、 # legacy 计数与下面的排序/响应仍在本函数。 merged = _chat_history_user_entries(task, cont_rows, fu_rows) merged += _chat_history_trace_entries( entries, _seconds_to_ms(task.get("created_at"))) # 排序键:(时间, 用户优先, 原顺序)。merged 里用户项的 # 第一元组是 0、链路项是 1;Python 排序稳定,同一 # (时间, 优先级) 内保持插入顺序。 merged.sort(key=lambda item: ( item[1] if item[1] is not None else 0, item[0])) self._send_json(200, { "task_id": task_id, "status": task["status"], "messages": [m for _p, _t, m in merged], "continue_messages_stored": len(cont_rows), "legacy_continue_count": legacy, "trace_note": trace_note, }) def _create_peer_message(self, user_id, task_id): """任务间互发消息(API 发送方):以 from_task_id 任务的名义 给目标任务发消息。投递语义与 worker 路径共用 deliver 层, 同样不中断目标任务:运行中/排队中排成后续消息,终态则 续跑复活。权限:调用方须对目标项目有 create_task、对 发送任务项目有 read(防冒名);from_task_id 必填——用户 给任务发消息走 /followups,这里只做任务对任务。""" body, err = self._read_json() if err: self._send_json(400, {"error": err}) return target_row = self._fetch_task(task_id) if target_row is None: self._send_json(404, {"error": "task not found"}) return target = _task_to_dict(target_row) with _auth_store(self.server.db_path) as store: if not store.check_permission( user_id, target["project_id"], "create_task"): self._send_json(403, {"error": "forbidden"}) return from_task_id = body.get("from_task_id") if not from_task_id or not isinstance(from_task_id, str): self._send_json(400, {"error": "from_task_id required"}) return sender_row = self._fetch_task(from_task_id) if sender_row is None: self._send_json(404, {"error": "sender task not found"}) return sender = _task_to_dict(sender_row) with _auth_store(self.server.db_path) as store: if not store.check_permission( user_id, sender["project_id"], "read"): self._send_json(403, {"error": "forbidden"}) return if self._reject_if_over_quota_for_task(user_id, target): return text = body.get("body") if not text or not isinstance(text, str) or not text.strip(): self._send_json(400, {"error": "body required"}) return text = text.strip() if len(text) > 20000: self._send_json(400, {"error": "body too long"}) return mode = body.get("mode", "queued") if mode not in ("queued", "direct"): self._send_json(400, { "error": "mode must be queued or direct"}) return conn = self._db() try: _ensure_followups_table(conn) status, detail = _deliver_peer_message( conn, from_task_id, task_id, text, mode) finally: conn.close() if status == "rejected": self._send_json(409, {"error": detail}) return if status == "delivered" and mode == "direct": # 直接发送的链路可见性与用户后续消息同款:消息立即 # 进链路,但当前轮仍跑完才开下一轮,不中断。 self._append_direct_trace( target, detail, _peer_prefix(from_task_id) + text) with _auth_store(self.server.db_path) as store: store.audit(user_id, target["project_id"], "peer_message_sent", "%s -> %s (%s)" % (from_task_id, task_id, status)) self._send_json(201, { "from_task_id": from_task_id, "to_task_id": task_id, "mode": mode, "delivery": status, "followup_id": ( detail if status == "delivered" else None)}) def _list_peer_messages(self, user_id, task_id): """列出某任务发出的互发消息(收到的消息在 /followups 里, 带 sender_task_id)。只含排成后续消息的那部分;复活投递 的那部分没有 followup 行,查审计 peer_message_sent 与目标 任务的续跑轮次。权限与 followup 列表同款:项目 read。""" row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission( user_id, task["project_id"], "read"): self._send_json(403, {"error": "forbidden"}) return conn = self._db() try: _ensure_followups_table(conn) rows = conn.execute( "SELECT * FROM task_followups WHERE sender_task_id = ?" " ORDER BY created_at, id", (task_id,), ).fetchall() finally: conn.close() self._send_json(200, { "messages": [_followup_to_dict(r) for r in rows]}) def _create_review(self, user_id, task_id): """发起评审:对某任务登记一条评审队列记录(status requested),不跑模型、不伪造 findings——执行方跑完 后经 /findings 写回。权限:项目 create_task;目标组合 非法时 400,表里不留脏行;记审计 review_requested。""" from cloud.review import build_review_target from cloud.review import create_review as _review_create body, err = self._read_json() if err: self._send_json(400, {"error": err}) return row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) err = self._check_project(user_id, task["project_id"], "create_task") if err: self._send_json(err[0], {"error": err[1]}) return target_type = body.get("target_type", "uncommittedChanges") branch = body.get("branch") sha = body.get("sha") instructions = body.get("instructions") for name, value in (("branch", branch), ("sha", sha), ("instructions", instructions)): if value is not None and not isinstance(value, str): self._send_json(400, {"error": "%s must be a string" % name}) return try: build_review_target(target_type, branch, sha, instructions) except ValueError as exc: self._send_json(400, {"error": str(exc)}) return conn = self._db() try: record = _review_create( conn, task_id, task["project_id"], target_type, user_id, branch=branch, sha=sha, instructions=instructions) finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "review_requested", "%s -> %s" % (task_id, record["id"])) self._send_json(201, record) def _list_reviews(self, user_id, task_id): """评审队列查看:列出某任务的全部评审记录(含 findings, 未写回的 findings 为 null,如实展示不编造)。权限: 项目 read。""" from cloud.review import list_reviews as _review_list row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) err = self._check_project(user_id, task["project_id"], "read") if err: self._send_json(err[0], {"error": err[1]}) return conn = self._db() try: reviews = _review_list(conn, task_id) finally: conn.close() self._send_json(200, {"reviews": reviews}) def _update_review_findings(self, user_id, task_id, review_id): """评审结果写回:执行方把 findings 记到指定记录上。 不传 status 时 findings 写回默认落 completed;显式传 failed 可标失败。权限:项目 create_task;记录不属于 该任务时 404;记审计 review_findings_updated。""" from cloud.review import get_review as _review_get from cloud.review import update_review as _review_update body, err = self._read_json() if err: self._send_json(400, {"error": err}) return row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) err = self._check_project(user_id, task["project_id"], "create_task") if err: self._send_json(err[0], {"error": err[1]}) return findings = body.get("findings") status = body.get("status") if findings is None and status is None: self._send_json(400, { "error": "findings or status required"}) return if findings is not None and not isinstance(findings, str): self._send_json(400, {"error": "findings must be a string"}) return conn = self._db() try: record = _review_get(conn, review_id) if record is None or record["task_id"] != task_id: self._send_json(404, {"error": "review not found"}) return try: updated = _review_update(conn, review_id, status=status, findings=findings) except ValueError as exc: self._send_json(400, {"error": str(exc)}) return finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "review_findings_updated", "%s -> %s" % (task_id, review_id)) self._send_json(200, updated) # -- 任务线程操作(经 host.py CLI 透传)---------------------------- # 本批限制(P2 切片 C 接线时如实保留,见 cherry-pick 说明): # Docker/异机任务线程操作一律 409(见 _thread_task_guard); # 改操作仅 completed/failed/interrupted,merged/merge-failed # 409(见 THREAD_MUTATION_STATUSES,控制台门控与此一致); # turn/steer 与 review/start 不在本批(review 端点是 P1 已有 # 功能,不由本批新增或改动)。 def _thread_task(self, user_id, task_id, action): """取任务并验项目权限:返回 (task, None),或 (None, err), err 为 (status, msg),调用方直接 _send_json。任务不存在 404 在先(与 peer 消息同款顺序),权限不足 403。""" row = self._fetch_task(task_id) if row is None: return None, (404, "task not found") task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission( user_id, task["project_id"], action): return None, (403, "forbidden") return task, None def _thread_task_guard(self, task): """线程操作的执行器门:只做 local 执行器任务。Docker 任务的 thread 在容器内、cloud 任务的 thread 不在本机, 本机租户 codex-home 里没有,查了也是错指,一律 409 明说,不回空结果假装无 thread。executor 为空(建任务 时未指定)时放行:老任务多为 NULL 且实际由 local worker 认领,卡死它们等于端点对老任务不可用;若实际 是 Docker 跑的,thread/list 会返回零个而得 404。 worktree 在别机时同样 409(与链路的跨机诚实降级同款, 但改操作绝不能在错的主机 home 上执行,所以不用 200 空结果)。返回 None 表示通过,否则为 (status, msg)。""" executor = task.get("executor") if executor == "docker": return (409, "thread ops are local-executor only;" " docker task threads live inside the container") if executor == "cloud": return (409, "thread ops are local-executor only;" " cloud task threads are not on this host") worktree_host = task.get("worktree_host") if worktree_host and worktree_host != socket.gethostname(): return (409, "task worktree is on host %s;" " thread ops must run there" % worktree_host) return None def _list_task_threads(self, task, archived=False): """对任务调 thread-list:返回 (threads, codex_home, cwd, err, provider),err 为 (status, msg) 或 None。cwd 过滤 用任务 worktree(见 _thread_worktree_for_task)。 archived 为真时传 --archived,只列已归档 thread (unarchive 解析专用;不传时只列非归档)。 候选按序穷尽,首个非空即停(thread 操作不是热路径, 多次 CLI 调用可接受): - home:租户 home(见 _tenant_codex_home)优先,本地 worker 共享 home(见 _local_worker_codex_home)兜底 ——本地派发至今仍用共享 home,旧 thread 全在那里, 只查租户 home 永远解析不到; - provider:缺省(配置 provider)优先,sub2api 次之 ——app-server 按进程 provider 过滤列表,不带注入时 sub2api 下建的 thread 在列表里不存在;未配 SUB2API_BASE_URL 时跳过次选(host.py 会 loud fail, 不能把配置缺失变成 502)。 任一候选成功返回空列表时不再继续(空就是空);全部 候选都报错(host.py 本身不可用)时回首个错误 502。""" codex_home = _tenant_codex_home(self.server, task["project_id"]) if codex_home is None: return None, None, None, ( 502, "cannot resolve tenant codex-home"), None cwd = _thread_worktree_for_task(self.server, task) homes = [codex_home] shared = _local_worker_codex_home() if shared and shared != codex_home: homes.append(shared) providers = [None] if os.environ.get("SUB2API_BASE_URL", "").strip(): providers.append("sub2api") first_error = None any_success = False list_argv = ["thread-list", "--cwd", cwd] if archived: list_argv.append("--archived") for home in homes: for provider in providers: ok, result = _run_host_cli( list_argv, home, model_provider=provider) if not ok: if first_error is None: first_error = result continue threads = _extract_thread_list(result) if threads is None: if first_error is None: first_error = ("host.py thread-list output has" " no thread list") continue any_success = True if threads: return threads, home, cwd, None, provider if not any_success and first_error is not None: return None, None, None, (502, first_error), None return [], codex_home, cwd, None, None def _resolve_task_thread(self, task, archived=False): """单线程端点的解析:thread-list 后按 _pick_latest_thread 取最新一个非 archived(archived 为真时取最新一个已归档, unarchive 专用——不传时只列非归档,刚归档的 thread 永远解析不到,unarchive 也就永远 404)。返回 (thread, codex_home, cwd, err, provider);零个 thread 时 err 为 (404, "thread not found")。provider 必须原样带给后续的 thread-read / rename / archive CLI——同一 thread 在另一 provider 进程里同样看不到,丢了它改操作会 502。""" threads, codex_home, cwd, err, provider = \ self._list_task_threads(task, archived=archived) if err is not None: return None, None, None, err, None thread = _pick_latest_thread(threads, include_archived=archived) if thread is None: return None, None, None, (404, "thread not found"), None return thread, codex_home, cwd, None, provider def _task_threads(self, user_id, task_id): """GET /tasks/{id}/threads:thread/list 原样返回列表。 读权限用项目 read;零个 thread 时返回空列表(原样), 不像单线程端点那样 404——列表端点没有“解析不到”的 概念,空就是空。""" task, err = self._thread_task(user_id, task_id, "read") if err is not None: self._send_json(err[0], {"error": err[1]}) return guard = self._thread_task_guard(task) if guard is not None: self._send_json(guard[0], {"error": guard[1]}) return threads, _home, cwd, err, _provider = self._list_task_threads(task) if err is not None: self._send_json(err[0], {"error": err[1]}) return self._send_json(200, {"task_id": task_id, "cwd": cwd, "threads": threads}) def _task_thread(self, user_id, task_id, query): """GET /tasks/{id}/thread:先 thread/list 解析出 thread, 再 thread/read。默认只要元数据(includeTurns 不传); ?include_turns=1 时才传 includeTurns。读操作不限制 任务状态;执行错误原样透传为 502。""" task, err = self._thread_task(user_id, task_id, "read") if err is not None: self._send_json(err[0], {"error": err[1]}) return guard = self._thread_task_guard(task) if guard is not None: self._send_json(guard[0], {"error": guard[1]}) return thread, codex_home, cwd, err, provider = \ self._resolve_task_thread(task) if err is not None: self._send_json(err[0], {"error": err[1]}) return argv = ["thread-read", "--thread-id", thread["id"]] include_turns = (query.get("include_turns") or ["0"])[0] if include_turns == "1": argv.append("--include-turns") ok, result = _run_host_cli(argv, codex_home, model_provider=provider) if not ok: self._send_json(502, {"error": result}) return full = _extract_thread(result) if full is None: self._send_json(502, { "error": "host.py thread-read output has no thread"}) return self._send_json(200, {"task_id": task_id, "cwd": cwd, "thread_id": thread["id"], "thread": full}) def _task_thread_mutate(self, user_id, task_id, argv, audit_action, audit_detail, timeout=60, resolve_archived=False): """线程改操作的公共流程:权限用项目 create_task;只允许 终态任务(completed/failed/interrupted),running/queued 一律 409;解析 thread 后调对应 CLI,成功记审计。 resolve_archived 只供 unarchive:已归档 thread 只能在 --archived 列表里找到,走普通解析永远 404。""" task, err = self._thread_task(user_id, task_id, "create_task") if err is not None: self._send_json(err[0], {"error": err[1]}) return guard = self._thread_task_guard(task) if guard is not None: self._send_json(guard[0], {"error": guard[1]}) return if task["status"] not in THREAD_MUTATION_STATUSES: self._send_json(409, { "error": "thread ops require a terminal task", "status": task["status"]}) return thread, codex_home, _cwd, err, provider = \ self._resolve_task_thread(task, archived=resolve_archived) if err is not None: self._send_json(err[0], {"error": err[1]}) return ok, result = _run_host_cli( list(argv) + ["--thread-id", thread["id"]], codex_home, timeout=timeout, model_provider=provider) if not ok: self._send_json(502, {"error": result}) return with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], audit_action, audit_detail % thread["id"]) self._send_json(200, {"task_id": task_id, "thread_id": thread["id"], "result": result}) def _task_thread_rename(self, user_id, task_id): """POST /tasks/{id}/thread/rename {name}:thread/name/set。 name 必填、非空、200 字内;记 thread_renamed 审计。""" body, jerr = self._read_json() if jerr: self._send_json(400, {"error": jerr}) return name = body.get("name") if not name or not isinstance(name, str) or not name.strip(): self._send_json(400, {"error": "name required"}) return name = name.strip() if len(name) > 200: self._send_json(400, {"error": "name too long"}) return self._task_thread_mutate( user_id, task_id, ["thread-rename", "--name", name], "thread_renamed", "%s renamed to " + name) def _task_thread_archive(self, user_id, task_id): """POST /tasks/{id}/thread/archive:thread/archive。 记 thread_archived 审计。""" self._task_thread_mutate(user_id, task_id, ["thread-archive"], "thread_archived", "%s archived") def _task_thread_unarchive(self, user_id, task_id): """POST /tasks/{id}/thread/unarchive:thread/unarchive。 记 thread_unarchived 审计。注意:必须走已归档解析 (resolve_archived)——刚归档的 thread 只在 --archived 列表里,普通解析永远 404,unarchive 也就 永远跑不通;这是 #103 时代的逻辑 bug,本批修。""" self._task_thread_mutate(user_id, task_id, ["thread-unarchive"], "thread_unarchived", "%s unarchived", resolve_archived=True) def _task_thread_compact(self, user_id, task_id): """POST /tasks/{id}/thread/compact:thread/compact/start。 压缩可能耗时,CLI 超时放宽到 120 秒;记 thread_compacted 审计。注意:这是线程压缩,不是开新回合,不跑模型 轮次之外的 turn(压缩本身由 app-server 内部完成)。""" self._task_thread_mutate(user_id, task_id, ["thread-compact"], "thread_compacted", "%s compacted", timeout=120) # -- 项目 skills / apps / MCP(经 host.py CLI 透传)-------------- # codex-home 按项目分租户目录,所以这批端点是项目 scope: # 读用项目 read,改用项目 manage_project(owner)。 def _project_host_home(self, project_id): """项目端点的租户 home 与工作目录:返回 (codex_home, work_dir),任一解析不出时返回 (None, None)。注意:这里 反映的是宿主租户 home,容器内 home 可能不同,skills/MCP 面看到的是宿主侧视图,不代表容器内视图。""" codex_home = _tenant_codex_home(self.server, project_id) work_dir = _tenant_work_dir(self.server, project_id) if codex_home is None or work_dir is None: return None, None return codex_home, work_dir def _skills_list(self, project_id): """GET /projects/{id}/skills:skills/list 原样返回。""" codex_home, work_dir = self._project_host_home(project_id) if codex_home is None: self._send_json(502, { "error": "cannot resolve tenant codex-home"}) return ok, result = _run_host_cli(["skills-list", "--cwd", work_dir], codex_home) if not ok: self._send_json(502, {"error": result}) return skills = _extract_result_list(result, ("data", "skills")) if skills is None: self._send_json(502, { "error": "host.py skills-list output has no list"}) return self._send_json(200, {"project_id": project_id, "skills": skills}) def _apps_list(self, project_id): """GET /projects/{id}/apps:app/list 原样返回(schema 里 是 AppsListParams/AppsListResponse;项目 scope 下不带 threadId,不做按线程的特性门控)。""" codex_home, _work = self._project_host_home(project_id) if codex_home is None: self._send_json(502, { "error": "cannot resolve tenant codex-home"}) return ok, result = _run_host_cli(["apps-list"], codex_home) if not ok: self._send_json(502, {"error": result}) return apps = _extract_result_list(result, ("data", "apps")) if apps is None: self._send_json(502, { "error": "host.py apps-list output has no list"}) return body = {"project_id": project_id, "apps": apps} cursor = result.get("nextCursor") if cursor is not None: body["nextCursor"] = cursor self._send_json(200, body) def _mcp_servers_list(self, project_id): """GET /projects/{id}/mcp-servers:mcpServerStatus/list 原样返回(schema 里是 ListMcpServerStatusParams/ Response;不带 threadId,走配置发现)。""" codex_home, _work = self._project_host_home(project_id) if codex_home is None: self._send_json(502, { "error": "cannot resolve tenant codex-home"}) return ok, result = _run_host_cli(["mcp-servers-list"], codex_home) if not ok: self._send_json(502, {"error": result}) return servers = _extract_result_list(result, ("data", "servers")) if servers is None: self._send_json(502, { "error": "host.py mcp-servers-list output has no list"}) return body = {"project_id": project_id, "servers": servers} cursor = result.get("nextCursor") if cursor is not None: body["nextCursor"] = cursor self._send_json(200, body) def _mcp_servers_reload(self, user_id, project_id): """POST /projects/{id}/mcp-servers/reload:config/mcpServer/ reload。核对结论:该方法在协议 schema 里真实存在—— v1 与 v2 的 ClientRequest 方法枚举里都有它,params 为 null,没有专用的响应定义(结果通常为空),所以 正常调、结果原样透传;只有 CLI 本身报错时才 502, 绝不伪造“不支持”也不伪造成功。记审计。""" codex_home, _work = self._project_host_home(project_id) if codex_home is None: self._send_json(502, { "error": "cannot resolve tenant codex-home"}) return ok, result = _run_host_cli(["mcp-servers-reload"], codex_home) if not ok: self._send_json(502, {"error": result}) return with _auth_store(self.server.db_path) as store: store.audit(user_id, project_id, "mcp_servers_reloaded", "mcp servers reloaded") self._send_json(200, {"project_id": project_id, "reloaded": True, "result": result}) def _mcp_server_oauth_login(self, user_id, project_id, name): """POST /projects/{id}/mcp-servers/{name}/oauth-login: mcpServer/oauth/login。协议返回(McpServerOauthLoginResponse: authorizationUrl 必填、loginId 可空)原样透传给调用方, 浏览器里完成登录的 URL/步骤由调用方展示。完成态轮询 不做:协议里完成信号是 McpServerOauthLoginCompletedNotification (服务端通知,不是请求/响应轮询),本端点只做到“发起 登录并返回授权 URL”这一步,绝不假装登录已完成。 记审计时只记服务器名与 loginId,不记授权 URL。""" body, jerr = self._read_json() if jerr: self._send_json(400, {"error": jerr}) return argv = ["mcp-server-oauth-login", "--server-name", name] scopes = body.get("scopes") if scopes is not None: if not isinstance(scopes, list) or any( not isinstance(s, str) for s in scopes): self._send_json(400, { "error": "scopes must be a list of strings"}) return if scopes: argv += ["--scopes", ",".join(scopes)] timeout_secs = body.get("timeoutSecs") if timeout_secs is not None: if not isinstance(timeout_secs, int) or timeout_secs <= 0: self._send_json(400, { "error": "timeoutSecs must be a positive integer"}) return argv += ["--timeout-secs", str(timeout_secs)] codex_home, _work = self._project_host_home(project_id) if codex_home is None: self._send_json(502, { "error": "cannot resolve tenant codex-home"}) return ok, result = _run_host_cli(argv, codex_home, timeout=120) if not ok: self._send_json(502, {"error": result}) return if not isinstance(result.get("authorizationUrl"), str): self._send_json(502, { "error": "host.py oauth-login output has no" " authorizationUrl"}) return with _auth_store(self.server.db_path) as store: store.audit(user_id, project_id, "mcp_server_oauth_login_started", "%s loginId=%s" % ( name, result.get("loginId") or "")) response = dict(result) response["project_id"] = project_id response["server_name"] = name self._send_json(200, response) # -- 线程等待与移交操作(对照 codex app 8 项) ----------------- def _task_thread_read(self, user_id, task_id): """GET /tasks/{id}/thread/read(read_thread):合成摘要, 不是 /thread 的原样转发。任务状态、结果、心跳来自任务行; 进度来自链路快照(条目数 + 最后一条);thread 元数据 尽力取(guard 挡住的 Docker / cloud / 异机任务、以及 thread-read 失败时,thread 回 null + note,仍回 200—— 摘要不能因为 thread 读不到就把任务状态也藏起来,这 正是它与 /thread(409 / 502)的区别)。 详情来自链路条目本身:kind=file 是改动文件、 kind=exec 是执行命令(含退出码)、kind=message / plan / reasoning 是最近动态;测试结果在链路里没有 单独记录(test_results 回 null + note,不编造“测试 通过”——测试通常就是命令,见 commands 的 exitCode 与 recent_activity 的 agent 消息)。阻塞判断来自 待审批(decision)与待处理后续消息(processing), 再加上“终态且可复活”时的 needs_instruction。读权限 read;审批扫荡(过期变 expired)与 /approvals 列表 同款,读摘要时一并做。""" task, err = self._thread_task(user_id, task_id, "read") if err is not None: self._send_json(err[0], {"error": err[1]}) return entries, trace_note = self._trace_snapshot(task) last = entries[-1] if entries else None last_text = "" if isinstance(last, dict): last_text = str(last.get("text") or last.get("message") or "")[:500] now = time.time() heartbeat_age = None if task.get("last_heartbeat"): heartbeat_age = now - task["last_heartbeat"] thread = None thread_id = None note = trace_note guard = self._thread_task_guard(task) if guard is not None: note = guard[1] else: resolved, _home, _cwd, rerr, provider = \ self._resolve_task_thread(task) if rerr is not None: note = rerr[1] else: thread_id = resolved.get("id") ok, result = _run_host_cli( ["thread-read", "--thread-id", thread_id], _home, model_provider=provider) if not ok: note = result else: full = _extract_thread(result) if full is None: note = ("host.py thread-read output has" " no thread") else: thread = full changed_files, commands, recent_activity = \ _summarize_trace_entries(entries) # 待审批与待处理后续消息(阻塞与“需不需要人”的来源)。 conn = self._db() try: _appr_ensure(conn) _appr_expire(conn) pending_approvals = [ {"id": a["id"], "kind": a["kind"], "summary": (a["summary"] or "")[:300]} for a in _appr_list(conn, task_id=task_id, status="pending")] _peer_ensure_followups(conn) pending_followups = [{ "id": r[0], "mode": r[1], "body": (r[2] or "")[:200]} for r in conn.execute( "SELECT id, mode, body FROM task_followups" " WHERE task_id = ? AND status = 'pending'" " ORDER BY created_at, id", (task_id,)).fetchall()] finally: conn.close() worktree_host = task.get("worktree_host") remote = worktree_host and worktree_host != socket.gethostname() agents = [] if not remote: try: agents = [{ "id": a.get("id"), "kind": a.get("kind"), "status": a.get("status")} for a in _build_agent_chain( task, _trace_dirs_for_task(self.server, task))] except Exception: agents = [] state = {"queued": "waiting", "running": "running", "completed": "completed", "merged": "completed", "failed": "failed", "merge-failed": "failed", "interrupted": "interrupted"}.get( task["status"], "unknown") needs_instruction = task["status"] in ( "completed", "failed", "interrupted") \ and not task.get("resume_message") reasons = [] if pending_approvals: reasons.append("pending approvals need a decision") if pending_followups: reasons.append("pending followups need processing") if needs_instruction: reasons.append("terminal task can be continued" " with a new instruction") if task.get("error_text"): reasons.append("task has an error_text") self._send_json(200, { "task_id": task_id, "project_id": task["project_id"], "status": task["status"], "state": state, "thread_id": thread_id, "thread": thread, "progress": { "trace_entries": len(entries), "last_trace": ({"kind": last.get("kind"), "id": last.get("id"), "text": last_text} if isinstance(last, dict) else None), "heartbeat_age_seconds": heartbeat_age, "worktree_host": worktree_host, }, "result": { "text": task.get("result_text"), "error_text": task.get("error_text"), "source": ("result_text" if task.get("result_text") else None), }, "changed_files": changed_files, "commands": commands, "recent_activity": recent_activity, "agents": agents, "test_results": None, "test_results_note": ( "no separate test-result record in the trace;" " tests usually appear as commands (see exit_code)" " and agent messages (see recent_activity)"), "pending_approvals": pending_approvals, "pending_followups": pending_followups, "attention": { "blocked": bool(pending_approvals), "needs_decision": bool(pending_approvals), "needs_processing": bool(pending_followups), "needs_instruction": needs_instruction, "reasons": reasons, }, "note": note, }) def _task_thread_message(self, user_id, task_id): """POST /tasks/{id}/thread/message(send_message_to_thread): 调用方以自己名义给另一会话的 thread 发后续指令。 授权模型:调用方须对目标项目有 create_task(项目写 权限)——“用户在对话里让我协调”本身不是 API 权限, API 只认 key + 项目权限,跨项目写一律 403,审计 thread_message_sent 留痕;这就是“向其他任务发消息 需要明确授权”在本系统的落地(当前会话的协调仍须 持写权限的 key,不能凭一句话越权)。 投递分两路:进行中任务复用 /followups 的队列 (queued / direct,direct 同样立即进链路、不中断 本轮);终态任务(completed / failed / interrupted / merge-failed)用 resume_message 复活——与 /continue 同款 CAS(状态 + resume_message 为空才改行),并 清掉旧 result/error(新一轮的指令不应顶着旧结果, 这点与 /messages 的 peer 复活不同,后者不清旧结果); 复活后 worker 认领即在同一 thread 上再跑一轮,这 正是“对失败任务发修复指令”的路径。merged 一律 409(worktree 已删,请分支);goal 任务一律 409 (goal 循环自有轮次,终态 goal 走 /continue)。""" body, jerr = self._read_json() if jerr: self._send_json(400, {"error": jerr}) return task, err = self._thread_task(user_id, task_id, "create_task") if err is not None: self._send_json(err[0], {"error": err[1]}) return if self._reject_if_over_quota_for_task(user_id, task): return text = body.get("body") if not text or not isinstance(text, str) or not text.strip(): self._send_json(400, {"error": "body required"}) return text = text.strip() if len(text) > 20000: self._send_json(400, {"error": "body too long"}) return mode = body.get("mode", "queued") if mode not in ("queued", "direct"): self._send_json(400, {"error": "mode must be queued or direct"}) return code, payload = self._deliver_to_thread( user_id, task, text, mode) self._send_json(code, payload) def _deliver_to_thread(self, user_id, task, text, mode, audit_action="thread_message_sent"): """给线程投递后续指令的共用实现(thread/message 与 自动化 heartbeat 共用同一路,不各写一套复活逻辑): goal 任务 409、merged 409、终态任务 CAS 复活(清旧 结果/旧错误)、进行中任务写 followup(direct 再追加 直接 trace)。例外:文本是 /goal 命令时(见 cloud/goal_command.py),按 /followups 的 goal 分支 处理——queued 原地转 goal、running 排队后由 worker 在轮次边界转 goal、已终态复活并转 goal。返回 (http_code, payload),不直接 _send_json;配额检查 由调用方在调本方法前做。""" task_id = task["id"] goal_target = _parse_goal_command(text) # /goal 前置判定见 _classify_goal_followup(纯计算,与 # _create_followup 共用,但只用于 goal_target 非空时: # 非 goal 路的终态与 merged 语义与 followup 不同——终态 # 走下面 CAS 复活而非 409,merged 走门禁文案而非 # “仅支持进行中任务”,故非 goal 绝不能进分类器)。 # 顺序与原文一致:goal error -> queued 原地转 goal -> # 终态复活转 goal -> running 落到普通 followup 插入路 # (响应里标 delivery=goal_queued,见本函数末尾)-> # 非 goal 门禁 -> 终态 CAS 复活。 if goal_target is not None: goal_action, goal_code, goal_payload = _classify_goal_followup( task, goal_target) if goal_action == "error": return goal_code, goal_payload if goal_action == "convert_queued": new_task = self._goal_convert_queued(task_id, goal_target) if new_task is None: return 409, { "error": "任务状态已变化,请刷新后重试", "status": task["status"]} with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], audit_action, "%s:goal_converted" % task_id) return 201, { "task_id": task_id, "delivery": "goal_converted", "followup_id": None, "status": "queued", "mode": mode, "goal": goal_target} if goal_action == "revive_terminal": new_task = self._goal_revive_terminal(task_id, goal_target) if new_task is None: return 409, { "error": "target has a pending resume message" " or status changed", "status": task["status"]} with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], audit_action, "%s:goal_revived" % task_id) return 201, { "task_id": task_id, "delivery": "goal_revived", "followup_id": None, "status": "queued", "mode": mode, "goal": goal_target} guard = _thread_non_goal_guard(task) if guard is not None: return guard if task["status"] in TERMINAL_STATUSES: # 复活路:与 /continue 同款终态集(merged 已在上 # 面排除),CAS 防“已有待续跑消息”时重复复活。 now = time.time() conn = self._db() try: cur = conn.execute( "UPDATE tasks SET status = 'queued'," " resume_message = ?, result_text = NULL," " error_text = NULL, actual_model = NULL," " updated_at = ? WHERE id = ? AND status IN" " ('completed', 'failed', 'interrupted'," " 'merge-failed') AND resume_message IS NULL", (text, now, task_id)) conn.commit() if cur.rowcount != 1: return 409, { "error": "target has a pending resume message" " or status changed", "status": task["status"]} finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], audit_action, "%s:revived" % task_id) return 201, { "task_id": task_id, "delivery": "revived", "followup_id": None, "status": "queued", "mode": mode} now = time.time() conn = self._db() try: _peer_ensure_followups(conn) fu_id = uuid.uuid4().hex conn.execute( "INSERT INTO task_followups(id, task_id, project_id," " body, mode, status, created_by, created_at," " updated_at) VALUES(?, ?, ?, ?, ?, 'pending', ?, ?, ?)", (fu_id, task_id, task["project_id"], text, mode, user_id, now, now)) conn.commit() fu = _followup_to_dict( conn.execute( "SELECT * FROM task_followups WHERE id = ?", (fu_id,)).fetchone()) finally: conn.close() if mode == "direct": self._append_direct_trace(task, fu_id, text) with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], audit_action, "%s:%s" % (task_id, fu_id)) if goal_target is not None: store.audit(user_id, task["project_id"], "mainline_goal_created", "%s:goal_queued=%s" % (task_id, goal_target)) if goal_target is not None: return 201, dict(fu, delivery="goal_queued", goal=goal_target) return 201, dict(fu, delivery="queued" if mode == "queued" else "direct") def _task_handoff_create(self, user_id, task_id): """POST /tasks/{id}/handoff:登记一次移交意图 (get_handoff_status 的写侧)。target_host 必填; 同一任务已有未终态移交时 409(见 wait_ops)。 权限 create_task;记 handoff_created 审计。""" body, jerr = self._read_json() if jerr: self._send_json(400, {"error": jerr}) return task, err = self._thread_task(user_id, task_id, "create_task") if err is not None: self._send_json(err[0], {"error": err[1]}) return conn = self._db() try: try: handoff = _handoff_create( conn, task_id, task["project_id"], body.get("target_host"), user_id, source_host=task.get("worktree_host") or socket.gethostname(), detail=body.get("detail") or "") except ValueError as exc: self._send_json(409 if "already has an open" in str(exc) else 400, {"error": str(exc)}) return finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "handoff_created", "%s -> %s" % (task_id, handoff["id"])) self._send_json(201, handoff) def _task_handoff_get(self, user_id, task_id): """GET /tasks/{id}/handoff(get_handoff_status):轮询 最新移交。无移交记录时 handoff 回 null,同时回任务 当前状态与当前主机——轮询方据此判断“还没交”还是 “已交但对方未回报”。读权限 read。""" task, err = self._thread_task(user_id, task_id, "read") if err is not None: self._send_json(err[0], {"error": err[1]}) return conn = self._db() try: handoff = _handoff_latest(conn, task_id) finally: conn.close() current_host = task.get("worktree_host") or socket.gethostname() self._send_json(200, { "task_id": task_id, "task_status": task["status"], "current_host": current_host, "handoff": handoff, "arrived": bool( handoff is not None and current_host == handoff["target_host"]), "note": ("" if handoff is not None else "no handoff recorded for this task"), }) def _task_handoff_report(self, user_id, task_id, handoff_id): """POST /tasks/{id}/handoff/{hid}/report:对方主机 回报移交进度。body:status(必填)、progress(可选 0-100)、detail(可选)。跨项目移交一律 404(不 泄露存在性);非法状态 / 进度 400;非法转移与终态 重报 409。权限 create_task(与任务心跳同款,回报 方持项目写权限);记 handoff_reported 审计。""" body, jerr = self._read_json() if jerr: self._send_json(400, {"error": jerr}) return task, err = self._thread_task(user_id, task_id, "create_task") if err is not None: self._send_json(err[0], {"error": err[1]}) return conn = self._db() try: existing = _handoff_get(conn, handoff_id) if existing is None or existing["project_id"] != \ task["project_id"] or existing["task_id"] != task_id: self._send_json(404, {"error": "handoff not found"}) return ok, result = _handoff_report( conn, handoff_id, body.get("status"), progress=body.get("progress"), detail=body.get("detail")) finally: conn.close() if not ok: if result == "handoff not found": self._send_json(404, {"error": result}) elif result.startswith("cannot move"): self._send_json(409, {"error": result}) else: self._send_json(400, {"error": result}) return if result.get("status") == "completed": # 移交完成即执行位置切换的落点:任务的 worktree_host # 改成目标主机,后续 /worktree、/pr/checks、线程解析 # 都按新主机判断;失败/进行中的上报不改主机。 conn = self._db() try: conn.execute( "UPDATE tasks SET worktree_host = ?," " updated_at = ? WHERE id = ?", (result.get("target_host"), time.time(), task_id)) conn.commit() finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "handoff_reported", "%s:%s" % (task_id, handoff_id)) self._send_json(200, result) def _worktree_summary(self, task): """worktree 摘要(bindings 与 /worktree 共用的读逻辑): remote 时 exists/is_git/branch 一律 null(本机查不到, 不谎报);本机时读目录 + .git + 分支(尽力,失败 null)。""" worktree_host = task.get("worktree_host") remote = worktree_host if ( worktree_host and worktree_host != socket.gethostname() ) else None path = _thread_worktree_for_task(self.server, task) exists = None is_git = None branch = None if remote is None: exists = os.path.isdir(path) is_git = os.path.exists(os.path.join(path, ".git")) \ if exists else False if exists and is_git: try: proc = subprocess.run( ["git", "-C", path, "branch", "--show-current"], capture_output=True, text=True, timeout=5) if proc.returncode == 0: branch = (proc.stdout or "").strip() or None except Exception: branch = None env_status = None if task.get("environment_id"): env_row = self._fetch_environment(task["environment_id"]) if env_row is not None: env_status = _environment_to_dict(env_row)["status"] status = _wait_worktree_status( task["status"], path, exists, is_git, environment_status=env_status, remote_host=remote) return dict(status, path=path, branch=branch, environment_id=task.get("environment_id"), remote_host=remote) def _task_bindings(self, user_id, task_id): """GET /tasks/{id}/bindings(任务与 Git 状态绑定 + 项目/主机上下文):把散在任务行、项目行、环境行、 worktree、PR、附件、外部引用里的一行拼成一张总表, 供跨线程协调时一次取全。只读聚合,不产生新状态; 读权限 read。local/remote 按 worktree_host 与本机 主机名判定;default_executor 是全局缺省,任务行 的 executor 为空时才生效。""" task, err = self._thread_task(user_id, task_id, "read") if err is not None: self._send_json(err[0], {"error": err[1]}) return conn = self._db() try: proj = conn.execute( "SELECT id, name, repo_url FROM projects WHERE id = ?", (task["project_id"],)).fetchone() atts = _attachments_map(conn, [task_id])[task_id] refs = _life_list_refs(conn, task_id) snaps = _life_list_snapshots(conn, task_id) finally: conn.close() env = None if task.get("environment_id"): env_row = self._fetch_environment(task["environment_id"]) if env_row is not None: env = _environment_to_dict(env_row) worktree = self._worktree_summary(task) self._send_json(200, { "task_id": task_id, "task_status": task["status"], "project": ({ "id": _row_get(proj, "id"), "name": _row_get(proj, "name"), "repo_url": _row_get(proj, "repo_url"), } if proj is not None else None), "environment": env, "host": { "worktree_host": task.get("worktree_host"), "local": worktree["remote_host"] is None, "current_host": socket.gethostname(), }, "worktree": worktree, "branch": worktree["branch"], "pull_request": ({ "url": task.get("pr_url"), "number": task.get("pr_number"), } if task.get("pr_number") else None), "executor": task.get("executor") or _default_executor(), "attachments": { "files": atts, "refs": refs, }, "worktree_snapshots": [{ "id": s["id"], "branch": s["branch"], "head_sha": s["head_sha"], "created_at": s["created_at"], "restored_at": s["restored_at"], } for s in snaps], "lineage": { "parent_task_id": task.get("parent_task_id"), "forked_from": task.get("forked_from"), "coordinator_part": task.get("coordinator_part"), }, }) def _task_refs_list(self, user_id, task_id): task, err = self._thread_task(user_id, task_id, "read") if err is not None: self._send_json(err[0], {"error": err[1]}) return conn = self._db() try: refs = _life_list_refs(conn, task_id) finally: conn.close() self._send_json(200, {"task_id": task_id, "refs": refs}) def _task_refs_create(self, user_id, task_id): """POST /tasks/{id}/refs:记一条外部引用(生成的文档 / 构建结果 / 外部服务结果)。只记引用,不存内容、不 校验链接可达;kind 白名单见 lifecycle.REF_KINDS。 权限 create_task。""" task, err = self._thread_task(user_id, task_id, "create_task") if err is not None: self._send_json(err[0], {"error": err[1]}) return body, jerr = self._read_json() if jerr: self._send_json(400, {"error": jerr}) return conn = self._db() try: try: ref = _life_add_ref( conn, task_id, task["project_id"], body.get("kind"), body.get("ref"), body.get("note"), user_id) except ValueError as exc: self._send_json(400, {"error": str(exc)}) return finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "task_ref_added", "%s:%s" % (task_id, ref["id"])) self._send_json(201, ref) def _task_refs_delete(self, user_id, task_id, ref_id): task, err = self._thread_task(user_id, task_id, "create_task") if err is not None: self._send_json(err[0], {"error": err[1]}) return conn = self._db() try: ok = _life_delete_ref(conn, task_id, ref_id) finally: conn.close() if not ok: self._send_json(404, {"error": "ref not found"}) return with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "task_ref_deleted", "%s:%s" % (task_id, ref_id)) self._send_json(200, {"task_id": task_id, "ref_id": ref_id, "deleted": True}) def _git_text(self, path, args, limit): """worktree 上尽力跑一条只读 git 命令;git 不在、 不是仓库、超时都回空串,不拖垮快照。""" try: proc = subprocess.run( ["git", "-C", path] + args, capture_output=True, text=True, timeout=10) if proc.returncode != 0: return "" return (proc.stdout or "")[:limit] except Exception: return "" def _task_worktree_archive(self, user_id, task_id): """POST /tasks/{id}/worktree/archive:给当前 worktree 拍一张快照(branch / head / status / diff / untracked 路径),行进 task_worktree_snapshots。快照不删目录、 不删分支——删目录是 merge/discard 的职责,归档只是 “先把证据存下来”。worktree 在别机时 409(本机 git 读不到对方目录);目录不存在时 409(无可拍)。 权限 create_task;记 worktree_snapshot_created 审计。""" task, err = self._thread_task(user_id, task_id, "create_task") if err is not None: self._send_json(err[0], {"error": err[1]}) return summary = self._worktree_summary(task) if summary["remote_host"] is not None: self._send_json(409, { "error": "task worktree is on host %s;" " snapshot must run there" % summary["remote_host"], "worktree_host": summary["remote_host"]}) return path = summary["path"] if not summary["exists"]: self._send_json(409, { "error": "worktree directory does not exist", "path": path}) return status_text = self._git_text( path, ["status", "--porcelain"], 4000) \ if summary["is_git"] else "" diff_text = self._git_text( path, ["diff", "HEAD"], 20000) if summary["is_git"] else "" head_sha = None if summary["is_git"]: head_sha = self._git_text( path, ["rev-parse", "HEAD"], 100).strip() or None untracked = [line[3:] for line in status_text.splitlines() if line.startswith("?? ")] conn = self._db() try: snap = _life_snapshot( conn, task_id, task["project_id"], path, summary["branch"], head_sha, status_text, diff_text, untracked) finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "worktree_snapshot_created", "%s:%s" % (task_id, snap["id"])) self._send_json(201, { "task_id": task_id, "snapshot_id": snap["id"], "branch": snap["branch"], "head_sha": snap["head_sha"], "untracked_count": len(snap["untracked"]), "status_lines": len( (snap["status_text"] or "").splitlines()), "note": ("snapshot only: untracked files are listed," " not copied; restore does not recreate a" " deleted directory"), }) def _task_worktree_snapshots(self, user_id, task_id): task, err = self._thread_task(user_id, task_id, "read") if err is not None: self._send_json(err[0], {"error": err[1]}) return conn = self._db() try: snaps = _life_list_snapshots(conn, task_id) finally: conn.close() self._send_json(200, {"task_id": task_id, "snapshots": snaps}) def _task_worktree_restore(self, user_id, task_id): """POST /tasks/{id}/worktree/restore:解归档最新快照。 目录还在时只把快照标 restored(目录本身没动过,谈 不上“还原”);目录已删时 409 并附快照(重建需要 仓库路径与分支检出,服务端没有,不编一个假恢复)。 权限 create_task。""" task, err = self._thread_task(user_id, task_id, "create_task") if err is not None: self._send_json(err[0], {"error": err[1]}) return conn = self._db() try: snap = _life_latest_snapshot(conn, task_id) finally: conn.close() if snap is None: self._send_json(409, {"error": "no snapshot to restore"}) return if not os.path.isdir(snap["worktree_path"]): self._send_json(409, { "error": "worktree directory is gone; snapshot" " cannot recreate it without the repo", "snapshot": snap}) return conn = self._db() try: snap = _life_mark_restored( conn, snap["id"], "worktree directory still present; snapshot" " unarchived only") finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "worktree_snapshot_restored", "%s:%s" % (task_id, snap["id"])) self._send_json(200, {"task_id": task_id, "snapshot": snap}) def _task_worktree_status(self, user_id, task_id): """GET /tasks/{id}/worktree(get_worktree_creation_status): 派生状态,不是异步创建任务的轮询——云端 worktree 由 worker 认领后同步建,建的过程没有可查的 job 行。 worktree 在别机时 status=remote 且 exists=null(本机 查不到,不谎报不存在);任务绑 environment 时一并回 environment_status(那才是真异步的创建过程)。读权限 read。""" task, err = self._thread_task(user_id, task_id, "read") if err is not None: self._send_json(err[0], {"error": err[1]}) return summary = self._worktree_summary(task) self._send_json(200, dict(summary, task_id=task_id)) def _task_pr_checks(self, user_id, task_id): """GET /tasks/{id}/pr/checks(read_pull_request_check_diagnostics): 只读诊断,绝不重跑检查、不批准、不合并(与 /pr 的 建 PR、/merge 的合并门是三条线)。无 pr_number 时 409(还没建 PR,查无可查);worktree 在别机时 409 (gh 必须在 worktree 所在主机跑,与 /pr 的队列路 不同——诊断是只读拉取,不排队等对方 worker 顺带跑, 避免“查一次 CI 就排一个 PR 任务”的副作用)。 取数分两层:先 `gh pr checks --json`(workflow、 link、description 即“哪个 Action 失败”与失败摘要), JSON 失败时回落制表符解析;有仓库 + head sha 且有 失败项时,再走 check-runs API 拿失败日志原文 (output.text,截 4000 字)与文件行号注释 (annotations:path / start_line / message,上限 20 条)。check-runs 一层失败时不 502 整单—— checks 本身已够回答“哪个检查失败”,日志是加分项, 此时 check_runs 回 available=False + note。读权限 read。""" task, err = self._thread_task(user_id, task_id, "read") if err is not None: self._send_json(err[0], {"error": err[1]}) return if not task.get("pr_number"): self._send_json(409, { "error": "task has no pull request yet", "pr_number": None}) return worktree_host = task.get("worktree_host") if worktree_host and worktree_host != socket.gethostname(): self._send_json(409, { "error": "task worktree is on host %s;" " pr checks must run there" % worktree_host, "worktree_host": worktree_host}) return from cloud.pr import _run as _pr_run from cloud.pr import parse_git_remote_owner_repo as _parse_repo worktree_path = _thread_worktree_for_task(self.server, task) runner = getattr(self.server, "pr_gh_runner", None) def _run(cmd): result = _pr_run(cmd, cwd=worktree_path, runner=runner) return result.returncode, result.stdout or "", \ result.stderr or "" owner_repo = None conn = self._db() try: row = conn.execute( "SELECT repo_url FROM projects WHERE id = ?", (task["project_id"],)).fetchone() repo_url = row[0] if row is not None else None finally: conn.close() if repo_url: parsed = _parse_repo(repo_url) if parsed is not None: owner_repo = "%s/%s" % (parsed[0], parsed[1]) head_sha = None if owner_repo: rc, out, _e = _run(["gh", "pr", "view", str(task["pr_number"]), "--json", "headRefOid", "--jq", ".headRefOid"]) head_sha = (out or "").strip() or None if not head_sha: rc, out, _e = _run(["git", "-C", worktree_path, "rev-parse", "HEAD"]) head_sha = (out or "").strip() or None try: diag = _wait_build_diag(_run, task["pr_number"], owner_repo=owner_repo, head_sha=head_sha) except RuntimeError as exc: self._send_json(502, {"error": str(exc)[:500]}) return pending = [c["name"] for c in diag["checks"] if c["state"] == "pending"] self._send_json(200, { "task_id": task_id, "pr_url": task.get("pr_url"), "pr_number": task["pr_number"], "head_sha": head_sha, "checks": diag["checks"], "all_pass": diag["all_pass"], "failed": diag["failed"], "pending": pending, "diagnostics": diag["diagnostics"], "check_runs": diag["check_runs"], }) def _task_sleep(self, user_id, task_id): """POST /tasks/{id}/sleep(clock.sleep 的控制面形态): 在服务端睡一段(默认 5 秒,上限 60 秒)再回任务快照, 供轮询客户端“等一会儿再继续”而不用自己计时。睡的 是调用方的等待,不是任务的执行——任务仍由 worker 跑,sleep 绝不暂停、加速或改动任务。读权限 read; seconds 非法时 400(不是静默钳制,调用方应知道 自己传错了)。""" body, jerr = self._read_json() if jerr: self._send_json(400, {"error": jerr}) return task, err = self._thread_task(user_id, task_id, "read") if err is not None: self._send_json(err[0], {"error": err[1]}) return seconds = body.get("seconds", 5) if isinstance(seconds, bool) or not isinstance( seconds, (int, float)): self._send_json(400, {"error": "seconds must be a number"}) return if seconds < 0 or seconds > 60: self._send_json(400, { "error": "seconds must be between 0 and 60"}) return slept, _interrupted = _wait_sleep(seconds) row = self._fetch_task(task_id) task = _task_to_dict(row) if row is not None else task self._send_json(200, { "task_id": task_id, "slept_seconds": slept, "status": task["status"], "result_text": task.get("result_text"), }) def _task_wait(self, user_id, task_id): """POST /tasks/{id}/wait(wait_threads):等第一个 重要变化,不是等全部终态。一次最多 8 个线程(超限 400,调用方自己选子集);body 可选 task_ids(缺省 时等该任务的全部 coordinator 子任务,超过 8 个同样 400);timeout_seconds 缺省 30、上限 120;轮询间 隔 0.5 秒。 重要变化(见 wait_ops.detect_significant_change): 终态(completed / failed / interrupted / merged / merge-failed)、待用户输入(kind=elicitation 的待 审批新增)、待审批(其它 kind 的待审批新增)、待 继续处理(待处理后续消息新增)。running / queued 流转与链路条目增加不算变化,不会把等待刷屏。 after_cursor:上一轮返回的游标;传了它,本轮只报 相对游标的新变化(已终态的线程不会再报一遍); 非法游标 400(不静默当无游标)。不传游标时,已 处重要状态的线程立即返回(不用等一轮)。 超时回 timed_out=true + 当时进度 + 新游标,不是 错误——调用方拿新游标再等一轮,或先处理进度里 已终态 / 已阻塞的线程。返回里同时保留 children (= progress 的别名)与 all_terminal,方便“等 全部完成”的调用方直接判断。 权限:锚任务 read,且每个等待任务都要 read(跨 项目任务无 read 时 403,任务不存在时 404,不 泄露存在性以外的信息);只读轮询,不改任何任务, 不创建任务、不发指令。""" body, jerr = self._read_json() if jerr: self._send_json(400, {"error": jerr}) return task, err = self._thread_task(user_id, task_id, "read") if err is not None: self._send_json(err[0], {"error": err[1]}) return timeout, requested, werr = _validate_wait_params(body) if werr is not None: self._send_json(400, werr) return conn = self._db() try: if requested is None: rows = conn.execute( "SELECT id FROM tasks WHERE parent_task_id = ?", (task_id,)).fetchall() child_ids = [r[0] if not isinstance(r, dict) and not hasattr(r, "keys") else r["id"] for r in rows] else: child_ids = list(requested) finally: conn.close() if len(child_ids) > 8: self._send_json(400, { "error": "at most 8 threads per wait;" " this task has %d children, pass task_ids" " to select a subset" % len(child_ids), "count": len(child_ids)}) return # 每个等待任务先验存在与 read 权限(顺序即 task_ids # 顺序,第一个不合规的先报错,不静默跳过)。 for cid in child_ids: _t, terr = self._thread_task(user_id, cid, "read") if terr is not None: self._send_json(terr[0], {"error": terr[1]}) return baseline = None if body.get("after_cursor") is not None: try: baseline = _wait_decode_cursor(body["after_cursor"]) except ValueError as exc: self._send_json(400, {"error": str(exc)}) return def _fetch_summaries(): conn = self._db() try: _appr_ensure(conn) _peer_ensure_followups(conn) out = {} for cid in child_ids: row = conn.execute( "SELECT * FROM tasks WHERE id = ?", (cid,)).fetchone() if row is None: # 验过存在后理论上不会走到这里;走到 # 时按“无状态”处理,不当成终态。 out[cid] = {"status": "not_found", "pending_approvals": 0, "pending_input": 0, "pending_followups": 0} continue status = _task_to_dict(row)["status"] appr_rows = conn.execute( "SELECT kind FROM task_approvals" " WHERE task_id = ? AND status = 'pending'", (cid,)).fetchall() kinds = [r[0] if not isinstance(r, dict) and not hasattr(r, "keys") else r["kind"] for r in appr_rows] followups = conn.execute( "SELECT COUNT(*) FROM task_followups" " WHERE task_id = ? AND status = 'pending'", (cid,)).fetchone()[0] out[cid] = { "status": status, "pending_approvals": sum( 1 for k in kinds if k != "elicitation"), "pending_input": sum( 1 for k in kinds if k == "elicitation"), "pending_followups": int(followups or 0), } return out finally: conn.close() changed, summaries, waited, timed_out = _wait_poll_first( _fetch_summaries, child_ids, baseline=baseline, timeout_seconds=timeout) progress = [] for cid in child_ids: summary = summaries.get(cid, {}) status = summary.get("status") progress.append({ "task_id": cid, "status": status, "terminal": status in TERMINAL_STATUSES, "pending_approvals": summary.get("pending_approvals", 0), "pending_input": summary.get("pending_input", 0), "pending_followups": summary.get("pending_followups", 0), "significant_now": _wait_significant_now(summary), }) self._send_json(200, { "task_id": task_id, "changed_task": changed, "progress": progress, "children": progress, "all_terminal": all( p["terminal"] for p in progress), "timed_out": timed_out, "waited_seconds": waited, "cursor": _wait_encode_cursor(summaries), }) def _fork_task(self, user_id, task_id): """会话分支:从已结束任务的 thread 分叉出新任务。 新任务继承源任务的 executor/模型/强度/权限/计划模式, 以及派生默认 child_model / child_effort(分支会话自己 再派生时仍用同一派生默认),不继承 goal(分支是普通 任务,首轮 prompt 就是这里传的 message)。worker 认领 后先调 host.py fork(分叉 thread 历史 + 拷 worktree 文件),再以 message 跑首轮。 只能从终态任务分支(进行中的 thread 正在变,分叉快照 不可信);message 必填——分支没有首条消息就没有方向。 """ body, err = self._read_json() if err: self._send_json(400, {"error": err}) return row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission( user_id, task["project_id"], "create_task"): self._send_json(403, {"error": "forbidden"}) return if self._reject_if_over_quota(user_id): return message = body.get("message") if not message or not isinstance(message, str) \ or not message.strip(): self._send_json(400, {"error": "message required"}) return message = message.strip() if len(message) > 20000: self._send_json(400, {"error": "message too long"}) return if task["status"] not in TERMINAL_STATUSES: self._send_json(409, { "error": "只能从已结束的任务分支", "status": task["status"], }) return now = time.time() new_id = uuid.uuid4().hex conn = self._db() try: conn.execute( "INSERT INTO tasks(id, project_id, prompt, status," " idempotency_key, depends_on, priority, created_by," " created_at, updated_at, last_heartbeat, executor," " goal, verify_command, goal_max_rounds," " model, effort, approval, plan, forked_from," " child_model, child_effort)" " VALUES(?, ?, ?, 'queued', NULL, '[]', ?, ?, ?, ?," " NULL, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?)", (new_id, task["project_id"], message, task["priority"], user_id, now, now, task["executor"] or _default_executor(), task["goal_max_rounds"] or 3, task["model"], task["effort"], task["approval"], task["plan"] or 0, task_id, task["child_model"], task["child_effort"]), ) # 分支带走附件清单(worktree 本身已含这些文件,分支 # 不再 seed;清单只作展示,bundle 删了也不影响分支)。 conn.execute( "INSERT INTO task_attachments" "(task_id, bundle_id, rel_path, size)" " SELECT ?, bundle_id, rel_path, size" " FROM task_attachments WHERE task_id = ?", (new_id, task_id), ) conn.commit() new_task = _task_to_dict( conn.execute( "SELECT * FROM tasks WHERE id = ?", (new_id,) ).fetchone(), _attachments_map(conn, [new_id])[new_id], ) finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "task_forked", "%s -> %s" % (task_id, new_id)) self._send_json(201, new_task) def _cancel_task(self, user_id, task_id): row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission( user_id, task["project_id"], "create_task"): self._send_json(403, {"error": "forbidden"}) return if task["status"] in TERMINAL_STATUSES: self._send_json(400, {"error": "task already terminal"}) return now = time.time() conn = self._db() try: # 用户取消 = 用户暂停:自动恢复绝不接它,人工 # /retry 或 /goal-resume 才能再跑(那两处会清标记)。 conn.execute( "UPDATE tasks SET status = 'interrupted'," " paused_by_user = 1, interrupted_reason = 'user_cancel'," " updated_at = ?" " WHERE id = ?", (now, task_id), ) conn.commit() task = _task_to_dict( conn.execute( "SELECT * FROM tasks WHERE id = ?", (task_id,) ).fetchone() ) # owner 活着时它自己的 1 秒看门狗会杀;owner 已死时 # 靠这条 kill 请求让别的活着的 worker 在收殓器里杀 # 遗留容器。写请求失败绝不挡取消本身(best-effort)。 try: request_kill(self.server.db_path, task_id, user_id) except Exception: pass # 取消后 worker 不会再跑它:暂存包没人 seed 了,顺手删掉 # (任务终局后 worker 侧也会删,两边删同一目录都容忍 # 不存在;删失败不挡取消本身)。 try: brow = conn.execute( "SELECT bundle_id FROM task_attachments" " WHERE task_id = ? LIMIT 1", (task_id,), ).fetchone() if brow is not None: bundle_id = brow[0] shutil.rmtree( os.path.join( _upload_dir_for(self.server), bundle_id), ignore_errors=True) except Exception: pass finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "cancel_task", task_id) self._send_json(200, task) def _pause_task(self, user_id, task_id): """用户暂停:与 /cancel 同终态、不同语义标记。 只有用户暂停才暂停:本端点把任务标 interrupted 并置 paused_by_user=1、interrupted_reason='user_pause',worker 自动恢复(auto_resume_interrupted)绝不接它;人工 /retry 或 /goal-resume 才能再跑(那两处会清标记)。 权限与 /cancel 同款(create_task),终态任务 400。 """ row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission( user_id, task["project_id"], "create_task"): self._send_json(403, {"error": "forbidden"}) return if task["status"] in TERMINAL_STATUSES: self._send_json(400, {"error": "task already terminal"}) return now = time.time() conn = self._db() try: conn.execute( "UPDATE tasks SET status = 'interrupted'," " paused_by_user = 1, interrupted_reason = 'user_pause'," " updated_at = ? WHERE id = ?", (now, task_id), ) # P0 三件其三:时间线 pause 步骤(best-effort)。 try: _ensure_task_steps_table(conn) _max = conn.execute( "SELECT MAX(step_no) FROM task_steps" " WHERE task_id = ?", (task_id,)).fetchone() conn.execute( "INSERT INTO task_steps(id, task_id, step_no, kind," " summary, state_sha, created_at)" " VALUES(?, ?, ?, 'pause', '用户暂停', NULL, ?)", (uuid.uuid4().hex, task_id, ((_max[0] or 0) + 1 if _max and _max[0] else 1), now)) except Exception: pass conn.commit() task = _task_to_dict( conn.execute( "SELECT * FROM tasks WHERE id = ?", (task_id,) ).fetchone() ) finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "pause_task", task_id) self._send_json(200, task) def _merge_task(self, user_id, task_id): """把云端任务标记为已合并:只补状态,不执行合并本身。 本接口不做 git 合并。自动 PR 的正确顺序是:任务 completed -> 调本接口改状态 -> 调 POST /tasks/{id}/pr 推分支建 PR -> 再在 GitHub 上合 PR 或本地 host.py merge。 本地 host.py merge 成功后会删分支和 worktree,此后调 PR 会跳过 no_worktree,所以 PR 必须在本地 git merge 之前做,而不是之后。规则:任务必须存在(404)、调用方 必须是项目 maintainer 以上(approve_merge,403)、任务 必须是 completed(其它状态一律 409,含已 merged 的重复 调用,避免二次合并混淆)。成功后写审计 merge_task。 合并门禁(cloud/merge_gate_hard,唯一服务端门禁,补 v0.3 技能文本检查在服务端缺失的缺口;v0.3 之前本接口只改状态, 不查目录、不查保护): 服务端只有 hard 一种模式:环境变量 CLOUD_MERGE_GATE_MODE 与请求体里的 merge_gate_mode 在服务端一律被忽略(旧值 record/enforce/off 自 A1 起不再是服务端可选项,设了也等同 hard;此前 record 下“无证据放行”、record 下“未保护 + 确认 放行并记 merge_gate_risk”、off 下“只记风险不阻断”三条 服务端路径已随删模式一并消失,不存在受控降级口)。 hard:五项全查, 缺一 409 且不改状态,并写 merge_gate_blocked 审计。五项: ① CI 全绿(ci_status=success + ci_checks 全 success + required_checks 非空且全 success);② 目录隔离(v0.3 口径, files_changed 缺失即无证据阻断,越目录只能靠 approved_exceptions 放行);③ 合同版本(contract_version 非空 + contract_frozen=true,与 contract_expected_version 给出时须相等);④ 协调员批准(coordinator_approved=true); ⑤ 无未解决评审意见(unresolved_review_comments=0 或空列表)。 另:branch_protection 必须为 protected;unprotected 即使带 merge_gate_confirm 也阻断,unknown 同样阻断(unknown 不等于 protected)。无证据不放行(fail-closed)。 门禁阻断都写 merge_gate_blocked 审计后才回 409, 这是第 3 轨自动推回的阻塞依据:推回包取响应 merge_gate 的 blocks 码,不要解析审计文本;本接口不建推回任务、不改状态机。 审计推回接线(cloud/audit_wiring,第 1 项接线缺口):五项 检查之外再与链上推回阻塞做“与”:链上任一模块仍阻塞 (merge_blocked)时,即使五项全过也 409、状态不变,并在 merge_gate.blocks 里追加 audit_pushback_blocked 码、响应 里附 audit_pushback 对象(merge_blocked / release_blocked / blocked_by="audit_pushback" / round / failed_checks)。 推回阻塞与门禁无关:显式确认在 hard 下不覆盖任何一项, 绝不覆盖推回阻塞;链读不到(audit_ledger 表 不存在)视为无推回状态,其它读链失败一律 fail-closed 409,不放行。注意:本接口没有独立的发布端点, release_blocked 随响应返回、由未来的发布端点用同一 reader 强制;deploy.py 是运维脚本,不在本接口门禁内。 本服务没有可信渠道直连 GitHub 核实 CI/保护:门禁强制的是 “调用方提交的证据齐全且全绿”,谎报证据由审计与 PR/Checks 只读核对覆盖,不在本接口的验证范围内。集成 v2 补充 (cloud/gate_verification):只读核对器本身已实现 (注入式 reader、逐项比对、mismatch 列出),但当前 合并/发布路径因请求体与任务行均无 repo/ref、且无可 信求证通道保证而接不上实时求证——见该模块头的不可 实现证明;当前真实控制仍是“证据齐全全绿 + 审计记 outcome/blocks”,审计 detail 不存证据体全文,谎报 发现靠事后比对,不是当场发现。 """ row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission( user_id, task["project_id"], "approve_merge"): self._send_json(403, {"error": "forbidden"}) return _merge_ok, _merge_reason = _merge_state_decision( task["status"]) if not _merge_ok: self._send_json(409, { "error": _merge_reason, "status": task["status"], }) return body, jerr = self._read_json() if jerr: self._send_json(400, {"error": jerr}) return if body.get("files_changed") is not None and not isinstance( body.get("files_changed"), list): self._send_json(400, {"error": "files_changed must be a list"}) return protection = body.get("branch_protection") \ or _merge_gate_protection() hard_err = _validate_hard_gate_body(body) if hard_err: self._send_json(400, {"error": hard_err}) return gate = _merge_gate_hard( files_changed=body.get("files_changed"), owned_dir=body.get("owned_dir"), approved_exceptions=body.get("approved_exceptions") or (), protection=protection, ci_status=body.get("ci_status"), ci_checks=body.get("ci_checks"), required_checks=body.get("required_checks"), contract_version=body.get("contract_version"), contract_frozen=body.get("contract_frozen", False), contract_expected_version=body.get( "contract_expected_version"), coordinator_approved=body.get("coordinator_approved"), unresolved_review_comments=body.get( "unresolved_review_comments")) # 审计推回接线:链上阻塞与门禁五项为“与”关系,且与模式 # 无关。读链失败(表不存在除外,见 audit_wiring)时 # fail-closed:不证明无阻塞就不放行。 pushback = None try: conn = self._db() try: pushback = _pushback_block(conn, task_id) finally: conn.close() except Exception: pushback = None if pushback is None: try: with _auth_store(self.server.db_path) as store: store.audit( user_id, task["project_id"], "merge_gate_blocked", "%s mode=%s blocks=audit_pushback_state_unavailable" % (task_id, gate.get("mode", "hard"))) except Exception: pass self._send_json(409, { "error": "audit pushback state unavailable", "merge_gate": gate, "audit_pushback": { "merge_blocked": True, "release_blocked": True, "blocked_by": "audit_pushback_state_unavailable", "round": None, "failed_checks": [], }, }) return gate["audit_pushback"] = pushback if pushback["merge_blocked"]: gate["allowed"] = False blocks = gate.setdefault("blocks", []) if "audit_pushback_blocked" not in blocks: blocks.append("audit_pushback_blocked") if not gate["allowed"]: with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "merge_gate_blocked", _gate_blocked_detail(task_id, gate)) self._send_json(409, { "error": "merge gate blocked", "merge_gate": gate, }) return now = time.time() conn = self._db() try: conn.execute( "UPDATE tasks SET status = 'merged', updated_at = ?" " WHERE id = ?", (now, task_id), ) conn.commit() task = _task_to_dict( conn.execute( "SELECT * FROM tasks WHERE id = ?", (task_id,) ).fetchone() ) finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "merge_task", task_id) task["merge_gate"] = gate self._send_json(200, task) # -- 项目级审计循环接线(#260 驱动的服务端执行层) -------------- # 决策一律来自 cloud/audit_loop_driver,本节只做执行:持久化 # 循环状态、经 #241/#247 写链、派生修复/重做子任务、拦合并与 # 发布。合并端点本身不改:它继续用 #247 reader + 五项硬门禁 # 做 AND,本节不给合并造第二套 reader。原任务状态在本节的 # 任何一步都不被回退:fail 只阻塞 + 派生子任务,completed # 保持 completed,直到合并端点按门禁把它标 merged。 def _loop_task_for_body(self, task, body): """把请求体里的模块/合同声明并入任务 dict,供接线层按 (module_id, contract_version) 解析循环键。任务行本身没 有 module 列,所以模块归属必须由提交方在每次调用里显 式声明(或沿用任务 id 回落);接线层绝不悄悄猜模块。""" merged = dict(task) if isinstance(body.get("module_id"), str) \ and body.get("module_id").strip(): merged["module_id"] = body["module_id"].strip() if isinstance(body.get("contract_version"), str) \ and body.get("contract_version").strip(): merged["contract_version"] = body["contract_version"].strip() return merged def _loop_quota_check(self, task, caller_id): """服务端配额 -> 驱动 quota_check:配额归属取原任务 创建人(成本归属口径与子任务一致),无创建人时取调用 人。_quota_status 抛错时闭包同样抛错,驱动 fail-closed 为 quota_blocked,绝不把查不到配额当成放行。""" quota_user = task.get("created_by") or caller_id def _check(estimate): quota, used, _month = self._quota_status(quota_user) return _loop_quota_check(quota, used)(estimate) return _check def _loop_root_task_id(self, task): """循环根任务 id:沿 forked_from 回溯到链头(最多 20 步防环)。链行必须归属根任务,否则 #247 的按任务读链 在修复子任务上写下的通过结论永远解不开父任务的合并 ——这是本节最容易踩错的归属点,单列一处 helper。""" current = task for _step in range(20): parent_id = current.get("forked_from") if not parent_id: return current["id"] parent_row = self._fetch_task(parent_id) if parent_row is None: return current["id"] current = _task_to_dict(parent_row) return current["id"] def _loop_ledger_store(self): """#241/#247 写路径的 store:LedgerStore 自带连接与 ensure_schema,每条 record_* 调用内提交。调用方用完 必须 close(各 handler 的 finally 里做)。""" return _LedgerStore(self.server.db_path) def _loop_create_subtask(self, descriptor, parent_task, caller_id): """把接线层返回的子任务描述符落成一条 queued 任务行: 同项目、prompt 写明模块/合同/失败项与完整重审标准、 created_by 取原任务创建人(成本归属)、forked_from 记 父任务。返回新任务 dict;INSERT 失败时抛错,由调用方 按 503 回执(循环已推进、描述符在响应里,调用方可按 描述符经 POST /tasks 手工补建,绝不悄悄重跑决策)。""" package = descriptor.get("pushback_package") or {} failures = package.get("failures") or [] criteria = package.get("reaudit_criteria") or [] lines = [ "Audit %s subtask for module %s@%s (parent task %s)." % (descriptor.get("kind"), descriptor.get("module_id"), descriptor.get("contract_version"), descriptor.get("parent_task_id")), "Scope: %s." % descriptor.get("scope"), "Failures: %s" % json.dumps(failures, ensure_ascii=False)[:2000], "Full re-audit criteria (all must pass, not only the " "failed items): %s" % ", ".join(str(c) for c in criteria), ] if descriptor.get("targeted_items"): lines.append("Targeted items only: %s" % json.dumps(descriptor["targeted_items"], ensure_ascii=False)[:1000]) if package.get("retained_assets"): lines.append("Retained assets (do not rebuild these): %s" % json.dumps(package["retained_assets"], ensure_ascii=False)[:2000]) prompt = "\n".join(lines) subtask_id = uuid.uuid4().hex now = time.time() conn = self._db() try: conn.execute( "INSERT INTO tasks(id, project_id, prompt, status," " created_by, created_at, updated_at, forked_from)" " VALUES(?, ?, ?, 'queued', ?, ?, ?, ?)", (subtask_id, parent_task["project_id"], prompt, parent_task.get("created_by") or caller_id, now, now, parent_task["id"])) conn.commit() row = conn.execute( "SELECT * FROM tasks WHERE id = ?", (subtask_id,) ).fetchone() return _task_to_dict(row) finally: conn.close() def _loop_run(self, user_id, task_id, body, kind): """循环端点的公共骨架:404 / approve_merge 403 / 体校验 / 接线调用 / 子任务落行。ValueError/TypeError(驱动拒绝、 映射缺失、写链预校验)回 400;其它异常(账本写失败等) 回 503 且状态未推进(接线层先写链后存状态的顺序保证 了这一点);子任务落行失败同样 503,但循环已推进的事 实在响应里明说,不谎称“未推进”。""" row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission( user_id, task["project_id"], "approve_merge"): self._send_json(403, {"error": "forbidden"}) return loop_task = self._loop_task_for_body(task, body) store = self._loop_ledger_store() loop_conn = self._db() try: if kind == "verdict": verdict = body.get("verdict") if not isinstance(verdict, dict) \ or not verdict.get("verdict"): self._send_json(400, { "error": "verdict dict with a verdict " "field is required"}) return result = _loop_verdict( loop_conn, store, loop_task, verdict, quota_check=self._loop_quota_check(task, user_id), cost_threshold=_loop_cost_threshold(), old_impl_ref=body.get("old_impl_ref"), user_id=user_id, ledger_task_id=self._loop_root_task_id(task)) elif kind == "fix": fix_result = body.get("fix_result") if not isinstance(fix_result, dict): self._send_json(400, { "error": "fix_result dict is required"}) return result = _loop_fix( loop_conn, store, loop_task, fix_result, automated=body.get("automated"), quota_check=self._loop_quota_check(task, user_id), cost_threshold=_loop_cost_threshold(), old_impl_ref=body.get("old_impl_ref"), user_id=user_id, ledger_task_id=self._loop_root_task_id(task)) elif kind == "correction": correction = body.get("correction") if not isinstance(correction, dict): self._send_json(400, { "error": "correction dict is required"}) return if isinstance(body.get("contract_version"), str): correction = dict( correction, contract_version=body["contract_version"]) result = _loop_correction( loop_conn, store, loop_task, correction, user_id=user_id, ledger_task_id=self._loop_root_task_id(task)) else: raise ValueError("unknown loop kind: %r" % (kind,)) except (ValueError, TypeError) as exc: self._send_json(400, {"error": str(exc)}) return except Exception as exc: self._send_json(503, { "error": "audit_loop_unavailable", "detail": "ledger or loop state write failed; " "loop state was not advanced: %s" % exc}) return finally: try: store.close() except Exception: pass try: loop_conn.close() except Exception: pass subtask_task = None subtask_error = None descriptor = result.get("subtask") if descriptor is not None and descriptor.get("kind") != "reaudit": try: subtask_task = self._loop_create_subtask( descriptor, task, user_id) except Exception as exc: subtask_error = str(exc) payload = { "task": task, "loop_state": _loop_state_to_dict(result["next_state"]), "action_kind": result["action_kind"], "reason": result["reason"], "merge_blocked": result["merge_blocked"], "release_blocked": result["release_blocked"], "requires_reaudit": result["requires_reaudit"], "escalation_grants_user_acceptance": result["escalation_grants_user_acceptance"], "subtask": descriptor, "subtask_task": subtask_task, "notification": result["notification"], } if "automated_checks" in result: payload["automated_checks"] = result["automated_checks"] if "correction_action" in result: payload["correction_action"] = result["correction_action"] if subtask_error is not None: payload["subtask_error"] = subtask_error payload["subtask_note"] = ( "loop state advanced and ledger written, but the " "subtask row was not created; create it from the " "subtask descriptor via POST /tasks, do not " "re-submit the verdict") self._send_json(503, payload) return self._send_json(200, payload) def _audit_loop_verdict(self, user_id, task_id): """POST /tasks/{id}/audit-verdict:提交一次独立审计或 验收结论。权限:项目 approve_merge(审计提交方须是 maintainer 以上,与合并权同级;普通 contributor 不能 凭空给循环喂结论)。体:{module_id, contract_version, verdict: {verdict, source, failures, ...}, old_impl_ref?}。signer 分离(A3):提交方本人即 signer, verdict 须另声明 executor / coordinator_approver / qa_tester 且均与提交方不同,缺字段、自审或体里 signer 与调用方不符一律 400 且不落链不推进。原任务状态不动;fail 自动派生修复子 任务并经链阻塞合并/发布。""" body, jerr = self._read_json() if jerr: self._send_json(400, {"error": jerr}) return self._loop_run(user_id, task_id, body, "verdict") def _audit_loop_fix(self, user_id, task_id): """POST /tasks/{id}/audit-fix:修复完成事件。体: {module_id, contract_version, fix_result: {reaudit_verdict: {...}}, automated?: {hard_gate: {...}, extra_checks: {...}}, old_impl_ref?}。注意“自动完整 重审”的准确含义:本端点把修复完成推进到重审(跑自动 化检查 + 要求完整重审结论),而不是自动判通过—— reaudit_verdict 仍须审计方提交,缺失或 diff_only 由 驱动以 400 拒绝;自动化检查全过也绝不等于审计通过。 本端点调用的任务应为修复子任务(其 module 与父任务 相同,体里显式声明),原任务与修复子任务的状态本端 点都不改(修复子任务的 completed 由 worker/调用方写 回,本端点只推进循环)。""" body, jerr = self._read_json() if jerr: self._send_json(400, {"error": jerr}) return self._loop_run(user_id, task_id, body, "fix") def _audit_loop_correction(self, user_id, task_id): """POST /tasks/{id}/audit-correction:审计的审计纠错。 体:{module_id, contract_version, correction: {original_verdict, corrected_verdict, reviewer, original_entry_id, ...}}。纠正为 fail 立即重拦;纠正 为 pass 不解封、只安排重审(原链行永不改动,只追加 meta-review 行)。""" body, jerr = self._read_json() if jerr: self._send_json(400, {"error": jerr}) return self._loop_run(user_id, task_id, body, "correction") def _audit_loop_confirm(self, user_id, task_id): """POST /tasks/{id}/audit-rebuild-confirm:验收触发推 倒的用户确认。体必须显式带 user_confirmed(true/false) 与 confirmed_by,且 confirmed_by 必须等于认证调用方 本人:协调员或系统没有任何路径可以代用户确认(本端 点之外没有任何代码调用确认流程;pending 状态下不建 重做任务、不耗推倒次数)。确认后才进 rebuilding,拒 绝则 escalate 且不耗预算;确认不等于 G6 验收。""" row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission( user_id, task["project_id"], "approve_merge"): self._send_json(403, {"error": "forbidden"}) return body, jerr = self._read_json() if jerr: self._send_json(400, {"error": jerr}) return if "user_confirmed" not in body \ or not isinstance(body.get("user_confirmed"), bool): self._send_json(400, { "error": "user_confirmed boolean is required; " "confirmation must be explicit"}) return if body.get("confirmed_by") != user_id: self._send_json(403, { "error": "confirmed_by must equal the authenticated " "caller; no coordinator or system may " "confirm on a user's behalf"}) return loop_task = self._loop_task_for_body(task, body) store = self._loop_ledger_store() loop_conn = self._db() try: result = _loop_confirm( loop_conn, store, loop_task, user_confirmed=body["user_confirmed"], confirmed_by=body["confirmed_by"], quota_check=self._loop_quota_check(task, user_id), cost_threshold=_loop_cost_threshold(), cost_estimate=body.get("cost_estimate"), old_impl_ref=body.get("old_impl_ref"), user_id=user_id, ledger_task_id=self._loop_root_task_id(task)) except (ValueError, TypeError) as exc: self._send_json(400, {"error": str(exc)}) return except Exception as exc: self._send_json(503, { "error": "audit_loop_unavailable", "detail": "ledger or loop state write failed; " "loop state was not advanced: %s" % exc}) return finally: try: store.close() except Exception: pass try: loop_conn.close() except Exception: pass subtask_task = None subtask_error = None descriptor = result.get("subtask") if descriptor is not None: try: subtask_task = self._loop_create_subtask( descriptor, task, user_id) except Exception as exc: subtask_error = str(exc) payload = { "task": task, "loop_state": _loop_state_to_dict(result["next_state"]), "action_kind": result["action_kind"], "reason": result["reason"], "merge_blocked": result["merge_blocked"], "release_blocked": result["release_blocked"], "subtask": descriptor, "subtask_task": subtask_task, "notification": result["notification"], } if subtask_error is not None: payload["subtask_error"] = subtask_error self._send_json(503, payload) return self._send_json(200, payload) def _audit_loop_get(self, user_id, task_id, query): """GET /tasks/{id}/audit-loop?module_id=&contract_version=: 读循环持久化状态 + 同一 #247 reader 的链阻塞视图。 权限:项目 read。读链失败(表不存在除外)回 503、不 谎称无阻塞。""" row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission( user_id, task["project_id"], "read"): self._send_json(403, {"error": "forbidden"}) return module_id = (query.get("module_id") or [None])[0] contract_version = (query.get("contract_version") or [None])[0] if not module_id or not contract_version: self._send_json(400, { "error": "module_id and contract_version query " "parameters are required"}) return conn = self._db() try: state = _loop_load_state(conn, module_id, contract_version) finally: conn.close() if state is None: self._send_json(404, {"error": "no audit loop state"}) return try: conn = self._db() try: pushback = _pushback_block(conn, task_id) finally: conn.close() except Exception: self._send_json(503, { "error": "audit_pushback_state_unavailable"}) return self._send_json(200, { "task": task, "loop_state": _loop_state_to_dict(state), "audit_pushback": pushback, }) def _release_task(self, user_id, task_id): """POST /tasks/{id}/release:发布门禁 + 发布授权(#259 契约正式建路由)。权限:只认 CODEX_GLOBAL_ADMINS 的 全局管理员写(与 verify/export 同身份来源,但 verify/ export 是只读、发布是写;项目 approve_merge 不能放行 发布)。三者 AND:① 发布时刻重读 #247 同一 reader 的 链;② 发布时刻调用方新提交的硬门禁证据重算五项(合 并时的 200 永远不作输入;硬门禁一律按 hard 算,模式环 境变量不能把发布降级);③ 任务状态须为 merged。 阻塞源区分见 release_gate:blocked_sources 列出全部, code/blocked_by 取求值顺序第一项。409 阻塞不写任何审 计(检查写链会移动链头、使锚点失效);403 被拒沿用 _audit_admin 惯例记审计。200 不改任务状态(本站没有 released 状态):200 是“发布已授权”,真正的部署动作 在平台部署通道(deploy.py)里,它部署平台本身、无任 务 id、不走服务端 HTTP 鉴权,因此不在任务门禁内—— 把它绑到任务链上会在最需要部署修复与回滚时反锁部署。 200 时写一条显式的 release_task 审计作为发布后动作记 录(检查本身是只读的,这条是检查完成后的显式动作, 不是检查的副产品;锚点须在这条之后重取)。 证据绑定(集成 v2,cloud/evidence_binding):请求体成 对带 evidence_ts / evidence_nonce 时,服务端校验新鲜 度窗口并单次消费 nonce,原样重交即 409 release_blocked_evidence_replayed,陈旧/未来时间戳即 409 release_blocked_evidence_stale;两者皆无时走旧路径 并在成功响应标注 evidence_binding=absent。绑定防原样 重放与陈旧证据,不防重盖戳(无签名);实时求证见 cloud/gate_verification(当前路径因无 repo/ref 且无 可信求证通道而接不上,见该模块头的不可实现证明)。""" row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) if not self._is_global_admin(user_id): self._audit_admin(user_id, "release_denied", task_id) self._send_json(403, {"error": "forbidden"}) return body, jerr = self._read_json() if jerr: self._send_json(400, {"error": jerr}) return if body.get("files_changed") is not None and not isinstance( body.get("files_changed"), list): self._send_json(400, {"error": "files_changed must be a list"}) return hard_err = _validate_hard_gate_body(body) if hard_err: self._send_json(400, {"error": hard_err}) return # 证据新鲜度与单次使用绑定(集成 v2,cloud/evidence_binding): # 体里成对带 evidence_ts / evidence_nonce 时才校验;两者 # 皆无走旧路径(#259 契约兼容,成功响应标注 absent); # 只带其一是格式错误 400。校验通过的 nonce 在此次提交 # 即消费(无论后续门禁放行与否),原样重交同一 nonce # 即 409 evidence_replayed;陈旧/未来时间戳即 409 # evidence_stale。409 按本接口规则不写审计;nonce # 消费写 evidence_nonces 表(与审计链无关,不移动链头)。 # 绑定防原样重放与陈旧证据,不防重盖戳(无签名证据 # 无法识破旧内容盖新戳——见 evidence_binding 模块头)。 binding = _evidence_presence(body) if binding == "partial": self._send_json(400, {"error": "evidence_ts and evidence_nonce" " must be supplied together"}) return if binding == "bound": ok, reason = _validate_evidence(body) if not ok: if reason in ("evidence_too_old", "evidence_from_future"): self._send_json(409, { "error": "release blocked", "release_gate": { "allowed": False, "http_status": 409, "code": "release_blocked_evidence_stale", "blocked_by": "evidence_binding", "blocked_sources": ["evidence_binding"], }, }) else: self._send_json(400, {"error": reason}) return _bconn = self._db() try: _store = _EvidenceNonceStore(_bconn) if not _store.consume(task_id, body["evidence_nonce"], body["evidence_ts"]): self._send_json(409, { "error": "release blocked", "release_gate": { "allowed": False, "http_status": 409, "code": "release_blocked_evidence_replayed", "blocked_by": "evidence_binding", "blocked_sources": ["evidence_binding"], }, }) return _bconn.commit() finally: _bconn.close() # 发布时刻的新证据:体里没给任何硬门禁字段时,hard_gate # 为 None -> 证据缺失 409,而不是用合并时的旧结论补。 has_evidence = any( body.get(field) is not None for field in ("files_changed", "ci_status", "ci_checks", "required_checks", "contract_version", "contract_frozen", "coordinator_approved", "unresolved_review_comments", "branch_protection")) hard_gate = None if has_evidence: protection = body.get("branch_protection") \ or _merge_gate_protection() hard_gate = _merge_gate_hard( files_changed=body.get("files_changed"), owned_dir=body.get("owned_dir"), approved_exceptions=body.get("approved_exceptions") or (), protection=protection, ci_status=body.get("ci_status"), ci_checks=body.get("ci_checks"), required_checks=body.get("required_checks"), contract_version=body.get("contract_version"), contract_frozen=body.get("contract_frozen", False), contract_expected_version=body.get( "contract_expected_version"), coordinator_approved=body.get("coordinator_approved"), unresolved_review_comments=body.get( "unresolved_review_comments")) conn = self._db() try: decision = _release_gate_evaluate( task_id, conn, task_status=task["status"], hard_gate=hard_gate) finally: conn.close() if not decision["allowed"]: # 409 不写审计:见本方法 docstring。 self._send_json(409, { "error": "release blocked", "release_gate": decision, }) return with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "release_task", task_id) task["release_gate"] = decision # 绑定出示标注(绝不谎称):bound = 本次提交带时间戳 # /nonce 且通过单次校验;absent = 未出示绑定,走 #259 # 旧路径(证据仍经硬门禁重算,但新鲜度未绑定)。 task["evidence_binding"] = binding self._send_json(200, task) # -- 运行时审批队列 ------------------------------------------------ def _list_approvals(self, user_id, task_id): """列出某任务的审批项(含已决定的,回执一并带出)。 权限:项目 read。列之前先扫荡过期:已到期的 pending 在这里变成 expired(effective decision 为 denied), 列表里看到的就是最终态,不会把已过期项继续显示为 待审批。下游异常不拦:抛给 _dispatch_safe 回 500, 绝不回一条伪造的空列表或成功。""" row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission(user_id, task["project_id"], "read"): self._send_json(403, {"error": "forbidden"}) return conn = self._db() try: _appr_ensure(conn) _appr_expire(conn) items = _appr_list(conn, task_id=task_id) finally: conn.close() self._send_json(200, {"approvals": items}) def _create_approval(self, user_id, task_id): """给某任务建一条待审批项(系统入口:未来 host hook 与 手动补录都走这里,实时 elicitation 本片未接,见 approvals 模块 docstring 的诚实边界)。 权限:项目 create_task(建审批项是写动作,与后续消息 同级)。kind 缺省 elicitation;summary 必填; timeout_seconds 只接受 60 到 86400 秒整数,缺省 3600, 超出范围回 400(校验收敛到 cloud/timeouts.py,文案 不变)。成功记审计 approval_created。""" row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission(user_id, task["project_id"], "create_task"): self._send_json(403, {"error": "forbidden"}) return body, err = self._read_json() if err: self._send_json(400, {"error": err}) return kind = body.get("kind", "elicitation") summary = body.get("summary") detail = body.get("detail") if detail is not None and not isinstance( detail, (dict, list, str)): self._send_json(400, {"error": "detail must be an object"}) return timeout = body.get("timeout_seconds", _approval_default()) try: timeout = _validate_timeout(timeout) except ValueError: self._send_json( 400, {"error": "timeout_seconds must be 60..86400"}) return conn = self._db() try: try: item = _appr_create( conn, task_id, task["project_id"], kind=kind, summary=summary, detail=detail, requested_by=body.get("requested_by"), timeout_seconds=timeout) except ValueError as exc: self._send_json(400, {"error": str(exc)}) return finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "approval_created", "%s %s" % (item["id"], item["kind"])) self._send_json(201, {"approval": item}) def _decide_approval(self, user_id, prefix, decision): """批准或拒绝一条审批项:prefix 形如 /approvals/(路由从 /approve 或 /deny 后缀剥出来的剩余部分)。 权限:项目 create_task(决定是写动作)。审批项不属于 该任务时回 404,不跨任务决定。已终态(批准、拒绝或 过期)时回 409 并带回执,原决定绝不被覆盖。成功记 审计 approval_decided,回执里有 decided_by 与 decided_at。""" parts = prefix.split("/approvals/") if len(parts) != 2 or not parts[0] or not parts[1]: self._send_json(404, {"error": "not found"}) return task_id, approval_id = parts row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission(user_id, task["project_id"], "create_task"): self._send_json(403, {"error": "forbidden"}) return conn = self._db() try: _appr_ensure(conn) _appr_expire(conn) existing = _appr_get(conn, approval_id) if existing is None or existing["task_id"] != task_id: self._send_json(404, {"error": "approval not found"}) return status, item = _appr_decide( conn, approval_id, decision, user_id) finally: conn.close() if status == "already_decided": self._send_json(409, {"error": "already decided", "approval": item}) return with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "approval_decided", "%s %s by %s" % (approval_id, decision, user_id)) self._send_json(200, {"approval": item}) def _continue_task(self, user_id, task_id): """旧任务接回来继续会话:终端任务(completed/failed/ interrupted/merge-failed)带一条新消息 + 全部选项重排队, worker 认领后在同一会话线程上再跑一轮(不是新任务、不 是分支)。新消息存在 resume_message 列,worker 跑完清掉; 原 prompt 不动,详情里仍显示原 prompt。 规则:任务必须存在(404)、调用方须有 create_task 权限 (与后续消息同级:这本质上是“给旧会话发消息”)、任务 必须是终端且非 merged(queued/running 请用后续消息, merged 的 worktree 已删、请分支,各 409)。选项与新建 任务同款校验:model(空串 = 跟随配置)、effort、approval、 plan、goal_mode,以及派生默认 child_model / child_effort (空串 = 清空);不传的选项沿用任务原值。冲突与新建 一致:plan + approval=full、plan + goal_mode 各 400。 goal_mode=1 时同时把任务转成新 goal 语义(goal=新消息、 verify_command 清空、轮次 0),worker 走 goal-resume 在旧线程上开新目标循环;goal_mode 不传时 goal 任务沿用 原语义续跑。例外:message 本身是 /goal 命令时强制 goal_mode=1,goal 与 resume_message 都取解析出的目标 而非整句(见 cloud/goal_command.py)。状态回 queued 时上一轮 result/error 清空 (与重试一致,否则排队中的任务还挂着旧结果)。 配额:全部校验通过后、写库之前按任务创建者查一次 (与重试同口径,费用记创建者名下);超限回 402,不 改状态——以前是先回 200 排队、随后 worker 认领门把 任务悄悄标 interrupted 且不推送。后续消息创建不查 配额(排队不花钱),本接口不是 followup,不要混淆。 并发下用 CAS(WHERE status IN 终端四态),没改到行 说明状态已变,返回 409 而不是假装成功。写审计 task_continued。 """ row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission( user_id, task["project_id"], "create_task"): self._send_json(403, {"error": "forbidden"}) return if self._reject_if_over_quota_for_task(user_id, task): return if task["status"] in ("queued", "running"): self._send_json(409, { "error": "进行中任务请用后续消息继续", "status": task["status"], }) return if task["status"] == "merged": self._send_json(409, { "error": "已合并任务不能继续(worktree 已删),请分支", "status": task["status"], }) return if task["status"] not in TERMINAL_STATUSES: self._send_json(409, { "error": "only terminal tasks can be continued", "status": task["status"], }) return body, err = self._read_json() if err: self._send_json(400, {"error": err}) return message = body.get("message") if not isinstance(message, str) or not message.strip(): self._send_json(400, {"error": "message 必填"}) return message = message.strip() if len(message) > 20000: self._send_json(400, {"error": "message too long"}) return goal_target = _parse_goal_command(message) if goal_target is not None and not goal_target: self._send_json(400, {"error": "goal 目标不能为空"}) return resume_text = goal_target if goal_target else message opts, cerr = _validate_continue_options(body, task, goal_target) if cerr: self._send_json(400, {"error": cerr}) return model = opts["model"] effort = opts["effort"] child_model = opts["child_model"] child_model_present = opts["child_model_present"] child_effort = opts["child_effort"] child_effort_present = opts["child_effort_present"] approval = opts["approval"] plan = opts["plan"] goal_mode = opts["goal_mode"] sets = ["status = 'queued'", "resume_message = ?", "result_text = NULL", "error_text = NULL", "actual_model = NULL", "updated_at = ?"] vals = [resume_text, time.time()] if model is not None or "model" in body: sets.append("model = ?") vals.append(model) if effort is not None: sets.append("effort = ?") vals.append(effort) if child_model_present: sets.append("child_model = ?") vals.append(child_model) if child_effort_present: sets.append("child_effort = ?") vals.append(child_effort) if approval is not None: sets.append("approval = ?") vals.append(approval) if plan is not None: sets.append("plan = ?") vals.append(plan) if goal_mode is not None: sets.append("goal_mode = ?") vals.append(goal_mode) if goal_mode == 1: sets.append("goal = ?") vals.append(resume_text) sets.append("verify_command = NULL") sets.append("goal_max_rounds = 0") vals.append(task_id) # 续跑也要花模型钱:校验全过后、写库之前按创建者查 # 配额,超限回 402 且不改状态(见本函数 docstring)。 if self._reject_if_over_quota( user_id, quota_user_id=task.get("created_by") or user_id): return conn = self._db() try: cur = conn.execute( "UPDATE tasks SET %s WHERE id = ? AND status IN" " ('completed', 'failed', 'interrupted', 'merge-failed')" % ", ".join(sets), tuple(vals), ) if cur.rowcount != 1: # 状态已变化:更新 0 行,同一事务里什么都不留—— # 尤其不能先把继续消息落盘再报 409,那会凭空多 # 一条从未被执行的“用户消息”。 conn.rollback() self._send_json(409, { "error": "任务状态已变化,请刷新后重试", "status": task["status"], }) return # 继续消息与状态改回 queued 同一事务:要么一起生效, # 要么一起不生效,绝不出现“任务已重排队、消息却丢了” # 的半截状态。created_at 用本请求的 updated_at 时间戳 # (vals[1]),与任务行的时间源一致,便于按时间交错 # 拼聊天历史。 _ensure_continue_messages_table(conn) conn.execute( "INSERT INTO task_continue_messages(id, task_id," " project_id, body, created_by, created_at)" " VALUES(?, ?, ?, ?, ?, ?)", (uuid.uuid4().hex, task_id, task["project_id"], message, user_id, vals[1]), ) conn.commit() new_row = conn.execute( "SELECT * FROM tasks WHERE id = ?", (task_id,) ).fetchone() finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "task_continued", task_id) self._send_json(200, _task_to_dict(new_row)) def _goal_resume_task(self, user_id, task_id): """G10:云端 goal 断点续跑——只接受 interrupted 的 goal 任务。 与 /continue 的区别(绝不能混):/continue 是“给旧会话 发一条新消息”,goal 任务走 goal-resume --goal-text 在旧 线程上开一个新目标循环;本端点走 host.py goal-resume (无 --goal-text)的断点语义:从 host 任务的 goal_rounds_done 的下一轮接着跑同一循环,保留原线程 与 worktree,不新建任务、不重置轮次。 拒绝口径:任务不存在 404;调用方须有 approve_merge (同重试:断点续跑要再花模型钱);状态非 interrupted 一律 409(goal-met/goal-unmet 已终局、failed 按旧规则 新建任务、running/queued 本来就在跑/等);非 goal 任务 409。配额按任务创建者查(与重试/续跑同款),超限 402 且不改状态。成功后状态回 queued 并置 goal_resume_pending=1(worker 认领后清掉并按断点跑), 上一轮 result/error/actual_model 清空,写审计 task_goal_resumed。CAS 更新,没改到行说明状态已变, 回 409 而不是假装成功。 """ row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission( user_id, task["project_id"], "approve_merge"): self._send_json(403, {"error": "forbidden"}) return if self._reject_if_over_quota( user_id, quota_user_id=task.get("created_by") or user_id): return if task["status"] != "interrupted": self._send_json(409, { "error": "only interrupted goal tasks can goal-resume", "status": task["status"], }) return if not (task.get("goal_mode") or task.get("verify_command")): self._send_json(409, { "error": "not a goal task", "status": task["status"], }) return now = time.time() conn = self._db() try: migrate_tasks_table(conn) cur = conn.execute( "UPDATE tasks SET status = 'queued'," " goal_resume_pending = 1, result_text = NULL," " error_text = NULL, actual_model = NULL," " paused_by_user = 0, interrupted_reason = NULL," " updated_at = ?" " WHERE id = ? AND status = 'interrupted'", (now, task_id), ) conn.commit() if cur.rowcount != 1: self._send_json(409, { "error": "task status changed, goal-resume aborted", "status": task["status"], }) return task = _task_to_dict( conn.execute( "SELECT * FROM tasks WHERE id = ?", (task_id,) ).fetchone() ) finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "task_goal_resumed", task_id) self._send_json(200, task) def _retry_task(self, user_id, task_id): """把终态失败任务重新排队:只改状态,不直接执行。 规则:任务必须存在(404)、调用方必须是项目 maintainer 以上(approve_merge,403——重跑要再花模型钱,不能让 只有读权限或普通贡献者随便点)、任务必须是 failed / interrupted / merge-failed(其它状态一律 409:running 本来就在跑,queued 本来就在等,completed/merged 已有 结果,重跑应是新任务而不是覆盖结果)。 配额按双人门查(P0 安全批,见 _reject_if_over_quota_for_task):触发人与原任务创建 人任一超限即拦。费用仍记在创建者名下,所以只查创建 者不够——触发人用自己的额度过门、费用记他人名下, 仍是绕限;只查触发人也不够——原创建者已超限时, 替他人重试仍会把费用记到已超限的账上。 成功后状态回 queued,上一轮的 result_text/error_text/ actual_model 清空(否则新一轮的结果会和旧结果混在一起 无法分辨);prompt、模型、优先级、依赖原样保留,依赖 已完成时重跑任务会立刻就绪。写审计 task_retried。 更新用 CAS(WHERE status IN (...)),避免与正在完成的 回合互相覆盖:若 CAS 没改到行,说明状态已变,返回 409 而不是假装成功。 """ row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission( user_id, task["project_id"], "approve_merge"): self._send_json(403, {"error": "forbidden"}) return if self._reject_if_over_quota_for_task(user_id, task): return if task["status"] not in ("failed", "interrupted", "merge-failed"): self._send_json(409, { "error": "only failed/interrupted tasks can be retried", "status": task["status"], }) return now = time.time() conn = self._db() try: cur = conn.execute( "UPDATE tasks SET status = 'queued', result_text = NULL," " error_text = NULL, actual_model = NULL," " paused_by_user = 0, interrupted_reason = NULL," " updated_at = ?" " WHERE id = ? AND status IN" " ('failed', 'interrupted', 'merge-failed')", (now, task_id), ) conn.commit() if cur.rowcount != 1: self._send_json(409, { "error": "task status changed, retry aborted", "status": task["status"], }) return task = _task_to_dict( conn.execute( "SELECT * FROM tasks WHERE id = ?", (task_id,) ).fetchone() ) finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "task_retried", task_id) self._send_json(200, task) # -- 租户配额与计费 ---------------------------------------------- # 计费口径:只算 cost_records 里 usd_micros 非空的实际成本 # 行(estimated 行 usd 为 NULL,SUM 自动跳过,不算已花 # 的钱);归属按任务创建者 created_by,不按项目——一个 # 用户在多个项目里花的钱都算进他自己的月度账单,项目维 # 度的拆分由 _project_cost 另行提供。窗口是 UTC 自然月, # 与 cost_records.day(UTC 日期)同口径。 def _quota_month(self): from datetime import datetime, timezone return datetime.now(timezone.utc).strftime("%Y-%m") def _quota_snapshot(self, user_id, month=None): """配额快照:(quota, used_usd, unpriced_actual, month)。 取数收敛到 cloud/quota.py:配额行(无行按默认值、 显式 NULL 行为不限额)与当月用量(USD 全量 + 无价 actual-only)各只有一处 SQL。CostStore 懒建表 步骤保留(全新库上 cost_records 不存在时先建表再查)。 本函数只读:无行时不在此写行(写行仍由 _apply_default_quota / _backfill_default_quota 负责)。 """ if month is None: month = self._quota_month() conn = self._db() try: _ensure_quotas_table(conn) conn.commit() _cost_store = CostStore(self.server.db_path) try: _cost_store._conn.close() except Exception: pass quota = _quota_for_owner(conn, user_id) used, unpriced = _quota_monthly_usage(conn, user_id, month) return quota, used, unpriced, month finally: conn.close() def _quota_status(self, user_id): """返回 (quota_usd_micros|None, used_usd_micros, month)。 P0 安全批后:无配额行不再等于不限额——缺行时按默认 配额处理(只在返回值里合成,不在这里写行;写行由 _apply_default_quota / _backfill_default_quota 负责)。 只有管理员显式把行写成 NULL 时,quota 才为 None (不限额)。取数见 _quota_snapshot。""" quota, used, _unpriced, month = self._quota_snapshot(user_id) return quota, used, month def _quota_unpriced_tokens(self, user_id, month=None): """当月无价模型 token 数(actual-only,见下)。 收敛说明(改动点,见 cloud/quota.py 模块头): 旧实现计全部 usd IS NULL 的行(含 estimated 估算行), 与 cost.py 的 P1 决议、CostStore 报表、worker、 自动化三处 actual-only 口径不一致;现统一为 actual-only:估算行不是真实花费,绝不触发上限。 input + output 之和;cached 已含在 input 里,不重复计。 month 缺省为当月。与 _quota_status 同款 CostStore 懒建表做法。无配额行时调用方不应以此挡人(见 _check_unpriced_token_cap 与 quota.decide 的 不限额语义)。 """ _quota, _used, unpriced, _month = self._quota_snapshot( user_id, month=month) return unpriced def _quota_payload(self, user_id, quota, used, month, unpriced_actual_tokens=None): payload = {"user_id": user_id, "month": month, "quota_usd_micros": quota, "used_usd_micros": used, "remaining_usd_micros": (quota - used) if quota is not None else None} if unpriced_actual_tokens is not None: payload["unpriced_actual_tokens"] = unpriced_actual_tokens return payload def _check_unpriced_token_cap(self, user_id): """无价 token 上限准入:超限返回 (cap, used, month),否则 None。 判定收敛到 cloud/quota.py:无配额行用户按默认配额受限; 显式 NULL(不限额)行用户不受限——与 worker 运行时门 一致(旧实现不论配额行一律拦,是三处不一致之一, 本轨随收敛统一)。USD 配额本身不计量无价模型,所 以这道是补 USD 口径的洞,不是第二套计费。""" # 取数经 _quota_status / _quota_unpriced_tokens(两者 # 内部共用 _quota_snapshot),比较经 quota.decide。 # _quota_status 在此保留为可 mock 的接缝(503 测试依赖 # 它抛异常时准入点 fail-closed),故 _check_* 不直连 # _quota_snapshot。 quota, _used, month = self._quota_status(user_id) unpriced = self._quota_unpriced_tokens(user_id, month) decision = _quota_decide(quota, _used, unpriced, unpriced_token_cap(), month) if decision is not None and decision["kind"] == "unpriced": return (decision["unpriced_token_cap"], decision["unpriced_tokens_used"], month) return None def _check_quota(self, user_id): """配额准入:放行返回 None;超限返回 (quota, used, month),调用方一律回 402——这是钱用完了,不是请求 太快,所以不用 429。 准入点:建任务 / 分支 / 重试 / 续跑 / goal-resume,均为权限校验 通过后、写库之前。P0 安全批后,后续消息与任务间 消息创建时同样查双人配额门(见 _reject_if_over_quota_for_task):排队本身不花钱, 但续跑/复活必然花钱,只靠执行时轮次边界门拦不住 “超限用户靠他人任务续命”,所以创建时先拦一道, 执行时轮次边界门仍照常生效。 fail-open / fail-closed 分层(G7):服务端准入点查 配额异常时 fail-closed——_reject_if_over_quota 回 明确的 503 quota_check_unavailable 且不改状态,绝不 因查询抖动把“不知道超没超”当成放行。system 操作人 不豁免,按普通属主走默认额度(G14 的 system 豁免与 P1 冲突,本次未合入)。 执行侧查配额异常时分层:认领门(closed)回 quota_check_unavailable、调用方标 interrupted;轮询与 轮次边界(open)回 quota_check_failed——新回合边界见 此串即拒绝,轮询经 _quota_hit 闭包过滤此串、只记 warning 不杀在跑任务,只有确认超限才掐断。 跑中掐断(与旧 docstring 不同,旧说法已过时): Local popen 与 Docker 路径的 worker 轮询每 30 秒查 一次配额,超限杀进程组/容器;同步路径没有轮询钩子, 只有认领与轮次边界检查,单轮内的超支要到轮次结束 才停;单轮本身的钱无法按 token 流式截断(用量只在 回合结束才回传),掐断粒度是轮次边界 + 长轮询中的 30 秒检查,不是 token 级。""" quota, used, month = self._quota_status(user_id) unpriced = self._quota_unpriced_tokens(user_id, month) decision = _quota_decide(quota, used, unpriced, unpriced_token_cap(), month) if decision is not None and decision["kind"] == "usd": return quota, used, month return None def _reject_if_over_quota(self, user_id, quota_user_id=None): """配额超限时回 402 并返回 True,否则 False。 quota_user_id:实际查谁的配额。建任务 / 分支时调用 方就是创建者,缺省即调用方;重试 / 续跑时费用记在 原任务创建者名下,必须传 task 的 created_by,否则 维护者替他人重试会用错人的额度(旧 bug:重试按 操作人查、费用记创建者名下)。 查询异常时 fail-closed:回 503 quota_check_unavailable 且不改状态。调用方看到 503 应重试请求本身,而不是 认为任务已建好——本函数回 True 时调用方一律直接 return,不写库。""" try: blocked = self._check_quota(quota_user_id or user_id) except Exception: self._send_json(503, {"error": "quota_check_unavailable", "detail": "quota lookup failed; " "no task was created or queued"}) return True if blocked is not None: quota, used, month = blocked self._send_json(402, {"error": "quota_exceeded", "month": month, "quota_usd_micros": quota, "used_usd_micros": used}) return True capped = self._check_unpriced_token_cap( quota_user_id or user_id) if capped is not None: cap, used, month = capped self._send_json(402, {"error": "quota_exceeded", "month": month, "unpriced_token_cap": cap, "unpriced_tokens_used": used}) return True return False def _reject_if_over_quota_for_task(self, caller_id, task): """花钱路径的双人配额门:触发人 + 原任务创建人。 任一超限即 402(USD 配额与无价 token cap 都查)。 触发人等于创建人时只查一次。调用方须在权限校验后、 写状态前调它;返回 True 表示已回 402,调用方直接 return。""" if self._reject_if_over_quota(caller_id): return True creator = (task or {}).get("created_by") if creator and creator != caller_id: if self._reject_if_over_quota(creator): return True return False def _check_coordinator_reserve(self, user_id, n_parts): """协调员预留准入:通过返回 True;被挡时已回 402 返回 False。 剩余美元配额(quota - used,quota 为 None = 不限额时放行) 必须 >= 分工数 × COORDINATOR_RESERVE_PER_PART_USD_MICROS。 这是准入下限估计,不是计费承诺:子任务实际花费仍按 created_by=父任务创建人全额记创建人账;父任务被挡时 一个子任务也不会建,不存在“第 2 个子任务才被挡”的 半截状态。""" if not n_parts or n_parts <= 1: return True quota, used, month = self._quota_status(user_id) if quota is None: return True need = n_parts * COORDINATOR_RESERVE_PER_PART_USD_MICROS if quota - used < need: self._send_json(402, { "error": "coordinator_quota_reserve_exceeded", "month": month, "quota_usd_micros": quota, "used_usd_micros": used, "coordinator_parts": n_parts, "reserved_usd_micros": need, "quota_attribution": "coordinator subtasks are charged" " to the parent task creator"}) return False return True def _audit_admin(self, user_id, action, detail): """admin 调用审计(成功与被拒都记,失败绝不挡主流程)。""" try: with _auth_store(self.server.db_path) as store: store.audit(user_id, "", action, detail) except Exception: pass def _is_global_admin(self, user_id): """真全局管理员:只认 CODEX_GLOBAL_ADMINS。 未配置时返回 False(fail-closed)。P0 安全批前“任一 项目 maintainer 即全局管理”的推导已被废掉:项目角色 只管项目内权限,不管全系统管理权。部署时运维必须先 把管理员用户 id 写进 CODEX_GLOBAL_ADMINS 再重启, 否则 admin 端点对所有人都是 403(数据不丢,改 env 重启即恢复)。""" return user_id is not None and user_id in _global_admin_ids() def _is_global_maintainer(self, user_id): """旧名兼容:等价于 _is_global_admin(推导口径已废)。""" return self._is_global_admin(user_id) def _user_exists(self, target): conn = self._db() try: return conn.execute( "SELECT 1 FROM users WHERE id = ?", (target,)).fetchone() is not None finally: conn.close() def _user_quota_self(self, user_id): quota, used, month = self._quota_status(user_id) # 合并取舍(P1 rebase 到 d254240):main 的 _quota_payload # 已带 unpriced_actual_tokens,P1 的附加字段也全留,供控制台 # 同时显示美元与无价 token 两条进度;402 时控制台据 # 响应字段区分“美元配额已满”与“无价 token 已满”。 payload = self._quota_payload( user_id, quota, used, month, self._quota_unpriced_tokens(user_id)) payload["unpriced_tokens_used"] = self._quota_unpriced_tokens( user_id, month) payload["unpriced_token_cap"] = unpriced_token_cap() self._send_json(200, payload) def _cost_by_creator_payload(self, target, month): """创建者分账双列载荷(自查与管理端共用)。 month 为 None 时不过滤月份;否则须为 YYYY-MM,否则 ValueError(调用方转 400)。配额进度与成本双列同回: quota 是当月美元配额(与 _quota_status 同口径),cost 是该创建者名下任务的成本(经 tasks.created_by JOIN)。 """ quota, used, cur_month = self._quota_status(target) # 端点默认当月(与配额窗口一致),不传 month 时成本与 # 配额同为当月;CostStore 层 month=None 仍表示全部时间。 eff_month = month if month is not None else cur_month store = CostStore(self.server.db_path) try: cost = store.dual_column_report_by_creator(target, month=eff_month) finally: try: store._conn.close() except Exception: pass return { "user_id": target, "month": eff_month, "quota_usd_micros": quota, "used_usd_micros": used, "remaining_usd_micros": (quota - used) if quota is not None else None, "unpriced_tokens_used": self._quota_unpriced_tokens( target, eff_month), "unpriced_token_cap": unpriced_token_cap(), "cost": cost, } def _user_cost_self(self, user_id, query): month = (query.get("month") or [None])[0] try: payload = self._cost_by_creator_payload(user_id, month) except ValueError: self._send_json(400, {"error": "month must be YYYY-MM"}) return self._send_json(200, payload) def _admin_cost_by_creator(self, user_id, query): if not self._is_global_admin(user_id): self._audit_admin(user_id, "admin_quota_denied", "get cost by creator") self._send_json(403, {"error": "forbidden"}) return target = (query.get("user_id") or [None])[0] if not target or not isinstance(target, str): self._send_json(400, {"error": "user_id required"}) return if not self._user_exists(target): self._send_json(404, {"error": "user not found"}) return month = (query.get("month") or [None])[0] try: payload = self._cost_by_creator_payload(target, month) except ValueError: self._send_json(400, {"error": "month must be YYYY-MM"}) return self._send_json(200, payload) def _admin_approval_timeout_ops(self, user_id): """审批超时调阈运维指标(全局管理员只读)。 先扫荡过期 pending(与审批列表同款:过期即 expired, 计入超时率),再统计 + 建议 + 记日志。返回里 recommended_seconds 是建议值,不是已生效值——默认 仍 600 秒,调阈生效须运维按建议改配置,见 cloud/approval_timeout 模块 docstring。 """ if not self._is_global_admin(user_id): self._audit_admin(user_id, "admin_quota_denied", "get approval timeout ops") self._send_json(403, {"error": "forbidden"}) return conn = self._db() try: _appr_ensure(conn) _appr_expire(conn) result = _appr_timeout_eval(conn) finally: conn.close() self._send_json(200, result) def _admin_quota_get(self, user_id, query): if not self._is_global_admin(user_id): self._audit_admin(user_id, "admin_quota_denied", "get quotas") self._send_json(403, {"error": "forbidden"}) return target = (query.get("user_id") or [None])[0] if not target or not isinstance(target, str): self._send_json(400, {"error": "user_id required"}) return if not self._user_exists(target): self._send_json(404, {"error": "user not found"}) return quota, used, month = self._quota_status(target) self._send_json(200, self._quota_payload( target, quota, used, month, self._quota_unpriced_tokens(target))) def _admin_ledger_verify(self, user_id): """GET /admin/audit-ledger/verify:全局管理员只读校验账本。 只读:成功路径不写审计(写审计会改链头,校验读数本身 就不再可复现);被拒才记 admin_ledger_denied。校验失败 仍回 200,ok=false + failures 供运维判读,不是 HTTP 错。 """ if not self._is_global_admin(user_id): self._audit_admin(user_id, "admin_ledger_denied", "verify ledger") self._send_json(403, {"error": "forbidden"}) return conn = self._db() try: result = _ledger_verify(conn) finally: conn.close() self._send_json(200, result) def _admin_ledger_export(self, user_id, query): """GET /admin/audit-ledger/export:全局管理员只读导出账本。 ?limit=N 可截断(运维取头锚点用);limit 非法回 400。 导出含头哈希,运维应把头哈希存到库外作比对锚点。 """ if not self._is_global_admin(user_id): self._audit_admin(user_id, "admin_ledger_denied", "export ledger") self._send_json(403, {"error": "forbidden"}) return limit_raw = (query.get("limit") or [None])[0] limit = None if limit_raw is not None: try: limit = int(limit_raw) except (TypeError, ValueError): limit = -1 if limit < 0: self._send_json( 400, {"error": "limit must be a non-negative integer"}) return conn = self._db() try: result = _ledger_export(conn, limit=limit) finally: conn.close() self._send_json(200, result) def _admin_quota_set(self, user_id): if not self._is_global_admin(user_id): self._audit_admin(user_id, "admin_quota_denied", "set quotas") self._send_json(403, {"error": "forbidden"}) return body, err = self._read_json() if err: self._send_json(400, {"error": err}) return target = body.get("user_id") if not target or not isinstance(target, str): self._send_json(400, {"error": "user_id required"}) return if not self._user_exists(target): self._send_json(404, {"error": "user not found"}) return quota = body.get("quota_usd_micros") if quota is not None: try: quota = int(quota) except (TypeError, ValueError): self._send_json(400, { "error": "quota_usd_micros must be an integer or null"}) return if quota < 0: self._send_json(400, { "error": "quota_usd_micros must be >= 0"}) return now = time.time() conn = self._db() try: cur = conn.execute( "UPDATE user_quotas SET quota_usd_micros = ?," " updated_at = ? WHERE user_id = ?", (quota, now, target)) if cur.rowcount != 1: conn.execute( "INSERT INTO user_quotas(user_id, quota_usd_micros," " updated_at) VALUES(?, ?, ?)", (target, quota, now)) conn.commit() finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, None, "quota_set", "%s:%s" % (target, quota)) quota, used, month = self._quota_status(target) self._send_json(200, self._quota_payload( target, quota, used, month, self._quota_unpriced_tokens(target))) # -- worker 池 ----------------------------------------------------- def _workers_list(self, user_id): """列出注册 worker:全局管理员看完整清单;其他已认证用户 只看在线/离线聚合(P1 收紧:worker_id、执行器、容量、 心跳时间等内部细节不对普通用户暴露,控制台的 Workers 区据此只显示聚合数字)。""" workers = _registry_list(self.server.db_path) if self._is_global_admin(user_id): self._send_json(200, {"workers": workers}) return online = sum(1 for w in workers if w.get("alive")) self._send_json(200, { "workers_online": online, "workers_offline": len(workers) - online, "workers_total": len(workers), }) def _workers_heartbeat(self, user_id): """worker 心跳:body 含 worker_id(必填,缺了 400)与可选 executor/capacity/running/host/pid/address。心跳本身就是 注册:worker_id 不存在时自动注册,绝不 404。running 是 worker 上报的当前占用槽数(启动中占位也算);未给时保留 旧值。host/pid/address 只存调用方显式给的值:远程 worker 的 host/pid 绝不由 server 本机推导(server 的主机名/进程 号不是该 worker 的),未给时保留旧值;host 须为字符串、 pid 须为整数、address 须为字符串,否则 400(注册表里的 寻址信息错了比没有更危险,失联判断会拿错 host/pid 当证据)。""" body, err = self._read_json() if err: self._send_json(400, {"error": err}) return worker_id = body.get("worker_id") if not worker_id or not isinstance(worker_id, str): self._send_json(400, {"error": "worker_id required"}) return executor = body.get("executor") capacity = body.get("capacity") if capacity is not None: try: capacity = int(capacity) except (TypeError, ValueError): self._send_json(400, {"error": "capacity must be an integer"}) return running = body.get("running") if running is not None: try: running = int(running) except (TypeError, ValueError): self._send_json(400, {"error": "running must be an integer"}) return if running < 0: self._send_json(400, {"error": "running must be >= 0"}) return host = body.get("host") if host is not None and not isinstance(host, str): self._send_json(400, {"error": "host must be a string"}) return pid = body.get("pid") if pid is not None: if isinstance(pid, bool) or not isinstance(pid, int): self._send_json(400, {"error": "pid must be an integer"}) return if pid <= 0: self._send_json(400, {"error": "pid must be > 0"}) return address = body.get("address") if address is not None and not isinstance(address, str): self._send_json(400, {"error": "address must be a string"}) return try: worker = _registry_heartbeat( self.server.db_path, worker_id, executor=executor, capacity=capacity, running=running, host=host, pid=pid, address=address) except ValueError as exc: self._send_json(400, {"error": str(exc)}) return worker["alive"] = True self._send_json(200, worker) # -- 自动 PR(默认关闭,显式触发)---------------------------------- def _notify_pr_event(self, project_id, event_type, payload): """PR 事件推送:best-effort,失败只记日志,绝不破坏 PR 响应。 review-requested = PR 建成功(PR 在等评审); merge-failed = 已 merged 任务的落盘(push/建 PR)失败, 任务状态保持 merged、可重调 /pr——事件说的是落盘失败, 不是状态变 merge-failed(云端 merge 端点只改状态, 没有真合并可失败)。 """ try: result = _push_dispatch( self.server.db_path, event_type, project_id, payload) except Exception as exc: # noqa: BLE001 -- 通知失败绝不炸 PR _server_log.warning("pr notify %s failed: %s", event_type, exc) return if result.get("errors"): _server_log.warning("pr notify %s errors: %s", event_type, result["errors"]) def _task_pr(self, user_id, task_id): """给已 merged 的任务建 PR(见 cloud/pr.py 的跳过规则)。 权限:项目 maintainer 以上(approve_merge,403);任务 不存在 404;非 merged 状态 409;跳过时返回 200 + pr_skipped 与 reason,任务状态保持 merged 不变;push/pr 创建失败时返回 502 并写清哪一步失败,任务同样保持 merged。 成功时把 pr_url/pr_number 写回任务并记审计 pr_created。 推送:成功时发 review-requested,落盘失败时发 merge-failed (均为 best-effort,不影响 PR 响应;跳过时不发)。 HA 阶段 1:任务的 worktree_host 与本机不同时,不直接跑, 而是排 pr 任务给该主机(返回 200 + pr_queued,同一任务 未完成的任务去重),由该主机上的 local worker 执行; worktree 在本机(或未记录)时同步跑,与旧行为一致。 App-token:项目 repo_url 能解析出 github.com 仓库名且 库里有该仓库的 App 安装记录时,现换 installation token 走 App 路径(响应里 auth_via=app),否则走原来的 gh + PAT 路径(auth_via=pat);换 token 任一步失败都回落 PAT,不因此报错。token 只在本次请求内存里出现。 """ row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission( user_id, task["project_id"], "approve_merge"): self._send_json(403, {"error": "forbidden"}) return if task["status"] != "merged": self._send_json(409, { "error": "only merged tasks can create a PR", "status": task["status"], }) return # G13:/pr 不再走 system 豁免。请求用户即费用归属人, # 建任务前先过配额门(超限 402、查询异常 503,均不改 # 状态、不排队);这是有意收紧的口径,见 PR 描述—— # PR 本身不跑模型回合,纯 git/gh 操作几乎不花钱,合 # 并前若认为“已完成工作的 PR 不应被配额挡住”,可把 # 这一门改回豁免而不动归属列(created_by 照常记录)。 if self._reject_if_over_quota(user_id): return # HA 阶段 1:worktree 在别机时不直接跑,排 pr 任务给该主机; # 本机(或 worktree_host 未记录的老任务)走同步路。 worktree_host = task.get("worktree_host") if worktree_host and worktree_host != socket.gethostname(): conn = self._db() try: existing = find_open_pr_job(conn, task_id) if existing is not None: job_id = existing["id"] else: job_id = enqueue_pr_job( conn, task_id, task["project_id"], worktree_host, created_by=user_id) finally: conn.close() self._send_json(200, { "task_id": task_id, "status": task["status"], "pr_queued": True, "pr_job_id": job_id, "worktree_host": worktree_host, }) return try: result = execute_task_pr( self.server.db_path, task_id, user_id, worktrees_root=getattr( self.server, "host_worktrees_dir", None), git_runner=getattr(self.server, "pr_git_runner", None), gh_runner=getattr(self.server, "pr_gh_runner", None), gh_which=getattr(self.server, "pr_gh_which", None), app_token_client=getattr( self.server, "pr_app_token_client", None), github_api=getattr(self.server, "pr_github_api", None)) except PrSkipped as exc: self._send_json(200, { "task_id": task_id, "status": task["status"], "pr_skipped": True, "reason": exc.reason, }) return except PrStepError as exc: # merge-failed 事件已在 execute_task_pr 内发出,这里只映射响应。 self._send_json(502, { "error": exc.message, "step": exc.step, "task_id": task_id, "status": task["status"], }) return conn = self._db() try: task = _task_to_dict( conn.execute( "SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()) finally: conn.close() self._send_json(200, dict(task, pr_url=result["pr_url"], pr_number=result["pr_number"], branch=result["branch"], auth_via=result["auth_via"])) def _heartbeat_task(self, user_id, task_id): row = self._fetch_task(task_id) if row is None: self._send_json(404, {"error": "task not found"}) return task = _task_to_dict(row) with _auth_store(self.server.db_path) as store: if not store.check_permission( user_id, task["project_id"], "create_task"): self._send_json(403, {"error": "forbidden"}) return now = time.time() new_status = "running" if task["status"] == "queued" else task["status"] conn = self._db() try: conn.execute( "UPDATE tasks SET status = ?, last_heartbeat = ?," " updated_at = ? WHERE id = ?", (new_status, now, now, task_id), ) conn.commit() task = _task_to_dict( conn.execute( "SELECT * FROM tasks WHERE id = ?", (task_id,) ).fetchone() ) finally: conn.close() self._send_json(200, task) # -- 管理 ---------------------------------------------------------- def _admin_sweep(self, user_id): """POST /admin/sweep:把超阈值的陈旧 running 任务标 interrupted。 权限:只认 CODEX_GLOBAL_ADMINS(见 _is_global_admin), 未配置时一律 403。threshold_seconds 下限钳到 60 秒: 0 会把所有 running 任务一口气扫掉,只能是管理员的 显式紧急操作才需要那么小的值,而 60 秒以下的“陈旧” 本就没有意义。扫完记 admin_sweep 审计,被拒也记审计。 """ if not self._is_global_admin(user_id): self._audit_admin(user_id, "admin_sweep_denied", "sweep") self._send_json(403, {"error": "forbidden"}) return body, err = self._read_json() if err: self._send_json(400, {"error": err}) return threshold = body.get( "threshold_seconds", DEFAULT_SWEEP_THRESHOLD_SECONDS) try: threshold = int(threshold) except (TypeError, ValueError): self._send_json(400, {"error": "threshold_seconds must be an integer"}) return threshold = max(60, threshold) swept = sweep_stale_tasks(self.server.db_path, threshold) with _auth_store(self.server.db_path) as store: store.audit(user_id, "", "admin_sweep", "threshold=%d swept=%d" % (threshold, len(swept))) self._send_json(200, {"swept": swept}) def _admin_backup(self, user_id): """POST /admin/backup:备份当前后端数据库,返回路径与规模信息。 权限:只认 CODEX_GLOBAL_ADMINS(见 _is_global_admin), 未配置时一律 403;非运维调用方根本到不了这里,绝对 路径只回给全局管理员。out_dir 限白名单目录(P1 收紧): 白名单根来自 CLOUD_BACKUP_ROOT(逗号分隔多根),未设 则 CLOUD_BACKUP_DIR,未设则固定根 cloud-backups;请求 的 out_dir 经实路径解析后必须等于某根或在其下,含 .. 逃逸、指向 /、/etc、/opt 等系统目录的一律 400(白名单 外的绝对路径即使存在也不放行)。备份文件落盘后 chmod 0600。成功与被拒都记审计(调用人由审计的 user_id 列 记,detail 含目标目录与大小,不记库内容)。 """ if not self._is_global_admin(user_id): self._audit_admin(user_id, "admin_backup_denied", "backup") self._send_json(403, {"error": "forbidden"}) return body, err = self._read_json() if err: self._send_json(400, {"error": err}) return requested = body.get("out_dir") if requested is not None and ( not isinstance(requested, str) or not requested): self._send_json(400, {"error": "out_dir must be a string"}) return roots_raw = os.environ.get("CLOUD_BACKUP_ROOT", "") if roots_raw.strip(): roots = [os.path.realpath(p.strip()) for p in roots_raw.split(",") if p.strip()] else: roots = [os.path.realpath( os.environ.get("CLOUD_BACKUP_DIR") or "cloud-backups")] if requested: candidate = os.path.realpath(requested) else: candidate = roots[0] allowed = any(candidate == root or candidate.startswith(root + os.sep) for root in roots) if not allowed: self._audit_admin( user_id, "admin_backup_denied", "out_dir outside backup roots: %s" % requested) self._send_json(400, { "error": "out_dir outside allowed backup roots"}) return out_dir = candidate backend = _db_backend_name() try: if backend == "postgres": from cloud.backup import backup_postgres as _backup_pg dest = _backup_pg(out_dir, keep=3) else: from cloud.backup import backup_db as _backup_sqlite dest = _backup_sqlite(self.server.db_path, out_dir, keep=3) try: os.chmod(dest, 0o600) except OSError: pass size_bytes = os.path.getsize(dest) conn = self._db() try: task_count = conn.execute( "SELECT COUNT(*) FROM tasks").fetchone()[0] finally: conn.close() except Exception as exc: self._send_json(500, {"error": "backup failed: %s" % (exc,)}) return self._audit_admin( user_id, "admin_backup", "caller=%s backend=%s out_dir=%s path=%s size=%d tasks=%d" % (user_id, backend, out_dir, dest, size_bytes, task_count)) self._send_json(200, { "backend": backend, "path": dest, "size_bytes": size_bytes, "task_count": task_count, }) def _metrics(self, user_id): """可观测性:任务计数、成功率、平均耗时、fallback、成本合计。 权限:任意项目成员即可读(不要求 owner),但只统计调用者 是成员的项目——不是全库数字,避免跨项目信息泄露。 成本真数与估算分开:cost_records 的 source 列区分。 """ conn = self._db() try: member_ids = { row["project_id"] for row in conn.execute( "SELECT project_id FROM project_members WHERE user_id = ?", (user_id,), ).fetchall() } rows = conn.execute( "SELECT project_id, status, started_at, finished_at" " FROM tasks" ).fetchall() # 新增可观测字段(只加键,旧键语义不动):队列深度、 # 近 24h 失败数、跑超 300 秒的 running 任务数。 now = time.time() (by_status, durations, failures_last_24h, running_over_300s) = _summarize_metrics_tasks( rows, member_ids, now) total = sum(by_status.values()) done = by_status.get("completed", 0) + by_status.get("merged", 0) failed = by_status.get("failed", 0) + by_status.get("merge-failed", 0) finished = done + failed try: cost_rows = conn.execute( "SELECT project_id, model, source, total_tokens," " usd_micros, fallback_count FROM cost_records" ).fetchall() except (sqlite3.OperationalError, _PgOperationalError): cost_rows = [] actual_tokens = estimated_tokens = 0 actual_usd = estimated_usd = 0 fallback_total = 0 by_model = {} unpriced_tokens = 0 for row in cost_rows: if row["project_id"] not in member_ids: continue if row["source"] == "actual": actual_tokens += row["total_tokens"] or 0 actual_usd += row["usd_micros"] or 0 else: estimated_tokens += row["total_tokens"] or 0 estimated_usd += row["usd_micros"] or 0 fallback_total += row["fallback_count"] or 0 model = row["model"] or "unknown" entry = by_model.setdefault( model, {"model": model, "total_tokens": 0, "usd_micros": 0, "priced": False}) entry["total_tokens"] += row["total_tokens"] or 0 if row["usd_micros"] is not None: entry["usd_micros"] += row["usd_micros"] entry["priced"] = True else: unpriced_tokens += row["total_tokens"] or 0 all_tokens = actual_tokens + estimated_tokens # 常驻监听口径(P1 G5):只统计调用者是成员的项目, # 与任务计数同款成员过滤;表缺失时 resident_metrics # 回 available=False,不炸 /metrics。 try: resident_stats = _res_metrics(conn, member_ids, now) except Exception: # noqa: BLE001 - 观测端点不因 resident 失败 resident_stats = {"available": False} # G7 运维指标:审批超时调阈(成员项目口径,只加键)。 # 统计失败时置 None(不是零),绝不把“算不出来”写成 # “超时率为零”;未扫荡的过期 pending 不计入,见 # approval_timeout 模块 docstring。这里不用 evaluate #(它写日志,/metrics 高频轮询会刷屏),只算不记。 try: _at_stats = _appr_timeout_stats( conn, project_ids=sorted(member_ids)) approval_timeout = _appr_timeout_recommend( _at_stats["expired_count"], _at_stats["decided_count"]) approval_timeout.update(_at_stats) except Exception: approval_timeout = None finally: conn.close() try: workers = _registry_list(self.server.db_path) except Exception: # noqa: BLE001 - 观测端点不因 worker 表失败 workers = [] workers_online = sum(1 for w in workers if w.get("alive")) worker_ages = [now - w["last_seen"] for w in workers if w.get("last_seen")] self._send_json(200, { "total_tasks": total, "by_status": by_status, "success_rate": (done / finished) if finished else None, "avg_duration_seconds": ( sum(durations) / len(durations)) if durations else None, "fallback_total": fallback_total, "cost": { "actual_tokens": actual_tokens, "estimated_tokens": estimated_tokens, "actual_usd_micros": actual_usd, "estimated_usd_micros": estimated_usd, "unpriced_tokens": unpriced_tokens, "unpriced_share": ( unpriced_tokens / all_tokens) if all_tokens else None, "by_model": sorted(by_model.values(), key=lambda e: -e["total_tokens"]), }, "resident": resident_stats, "queue_depth": { "queued": by_status.get("queued", 0), "running": by_status.get("running", 0), }, "approval_timeout": approval_timeout, "failures_last_24h": failures_last_24h, "running_over_300s": running_over_300s, "workers": { "online": workers_online, "offline": len(workers) - workers_online, "total": len(workers), "max_last_seen_age_seconds": ( max(worker_ages) if worker_ages else None), }, }) def _metrics_report(self, user_id): """指标闭环报告:首过绿灯率 / 缺陷率 / 返工率 / 每任务成本。 权限与 /metrics 同款:只统计调用者是成员的项目。 台账(audit gap 3,见 ``cloud/attempt_ledger.py``):任务的 attempts/defects 来自 ``task_attempts`` 台账的聚合,不再 恒为 None;台账里只有中断行的任务、以及台账里根本没有 行的老任务,仍传 None——未知率回 None(未知,不报 0), 老任务绝不被算成首过或无缺陷。台账表尚不存在(老库、 或生产 PG 未手动执行 ``PG_DDL``)时与无台账行同等处理, 不因此让端点报错;金任务集未经人工审定,报告恒为 pending_human_approval、decision_ready False。 成本走 #212 双列口径:有价合计与无价 actual token 分开, 总额在成本不完整时为 None,不把无价折成 0。 """ conn = self._db() try: member_ids = { row["project_id"] for row in conn.execute( "SELECT project_id FROM project_members WHERE user_id = ?", (user_id,), ).fetchall() } rows = conn.execute( "SELECT id, project_id, status FROM tasks").fetchall() records = [{"id": row["id"], "status": row["status"], "attempts": None, "defects": None} for row in rows if row["project_id"] in member_ids] try: aggregates = _aggregate_attempts( _read_attempt_rows(conn)) except (sqlite3.OperationalError, _PgOperationalError): # No ledger table yet (old DB, or production PG # before the manual PG_DDL step): same as no rows. aggregates = {} records = _apply_attempts(records, aggregates) try: cost_rows = conn.execute( "SELECT task_id, project_id, source, total_tokens," " usd_micros FROM cost_records").fetchall() except (sqlite3.OperationalError, _PgOperationalError): cost_rows = [] costs = {} for row in cost_rows: if row["project_id"] not in member_ids: continue entry = costs.setdefault(row["task_id"], { "priced_usd_micros": 0, "unpriced_actual_tokens": 0, "has_rows": True}) if row["source"] == "actual" \ and row["usd_micros"] is None: entry["unpriced_actual_tokens"] += \ row["total_tokens"] or 0 elif row["usd_micros"] is not None: entry["priced_usd_micros"] += row["usd_micros"] finally: conn.close() metrics = _compute_metrics(records, costs) self._send_json(200, _build_metrics_report(metrics)) def _audit_scorecard(self, user_id): """只读 7 维审计评分卡报告端点。 权限:与 /metrics 同款,任意项目成员即可读(不要求 owner), 但本端点不统计任何项目数据、不写库、不写账本、不改链头、 不触发推回或重审——它只返回评分卡模块的一张历史示例卡。 该示例卡的证据是 2026-10-31 的快照(见 ``example_scorecard_as_of_2026_10_31``),此后未重新 实测,响应里以 ``evidence_as_of`` / ``not_current_system_score`` 明示;调用方绝不能把它读 成当前系统得分。调用方要拿到真实分数,必须显式把结构化 证据传给 cloud/audit_scorecard.py 的 build_scorecard(), 端点本身绝不从测试文件、摘要文档、PR 描述里拼凑分数。 阈值仍为测试默认值(``test_default_unapproved``),未拍板。 响应同时带 ``code`` / ``production`` / ``product`` 三个子对象 (三张表拆分,见 cloud/audit_scorecard.py;示例证据下三表 均为 not_pass,产品表因无真实产品轮证据而恒 not_pass)。 """ try: from cloud.audit_scorecard import ( example_scorecard_as_of_2026_10_31) report = example_scorecard_as_of_2026_10_31() except ImportError: # pragma: no cover - 导入失败时仍要诚实返回 report = { "dimensions": [], "average_score": None, "average_is_not_pass_criterion": True, "overall": "not_pass", "overall_pass": False, "missing_items": ["audit_scorecard_module_unavailable"], "untested_items": [], "thresholds_source": "test_default_unapproved", "evidence_as_of": "2026-10-31", "evidence_kind": "historical_example_snapshot", "not_current_system_score": True, # 三张表兜底形状:模块不可用时三表均为 not_pass。 "code": {"dimensions": [], "average_score": None, "average_is_not_pass_criterion": True, "overall": "not_pass", "overall_pass": False, "missing_items": ["audit_scorecard_module_unavailable"], "untested_items": []}, "production": {"dimensions": [], "average_score": None, "average_is_not_pass_criterion": True, "overall": "not_pass", "overall_pass": False, "missing_items": ["audit_scorecard_module_unavailable"], "untested_items": [], "user_side_items": [], "user_side_pending": [], "user_side_blocked": False}, "product": {"dimensions": [], "average_score": None, "average_is_not_pass_criterion": True, "overall": "not_pass", "overall_pass": False, "missing_items": ["audit_scorecard_module_unavailable"], "untested_items": [], "product_gate_passed": False}, } self._send_json(200, report) # -- API keys 管理 --------------------------------------------------- def _user_keys_list(self): """列出自己的 key:primary(id "legacy")+ 附加 key。 只返回 id / label / 后 4 位提示 / 创建 / 最近使用 / 撤销 时间,绝不返回明文与哈希;只看得到自己的(认证用户)。 """ user_id = self._authenticate() if user_id is None: self._send_json(401, {"error": "unauthorized"}) return with _auth_store(self.server.db_path) as auth: keys = auth.list_api_keys(user_id) self._send_json(200, {"keys": keys}) def _user_keys_create(self): """给自己签发一个附加 key:明文只在本次响应里出现一次。 body 可含 label(字符串,缺省 "api-key");新 key 立刻 可用,与 primary 及已有附加 key 互不影响。 """ user_id = self._authenticate() if user_id is None: self._send_json(401, {"error": "unauthorized"}) return body, err = self._read_json() if err: body = {} label = (body or {}).get("label") if not isinstance(label, str) or not label: label = "api-key" with _auth_store(self.server.db_path) as auth: key_id, api_key_plain = auth.create_api_key(user_id, label) # 审计只记 key_id 与 label,绝不记明文与哈希。 auth.audit(user_id, "", "api_key_created", "key_id=%s label=%s" % (key_id, label)) self._send_json(201, {"id": key_id, "label": label, "hint": api_key_plain[-4:], "api_key": api_key_plain}) def _user_keys_revoke(self, key_id): """撤销自己的一个 key:附加 key 记 revoked_at,primary (id "legacy")清哈希;无此 key 或已撤销返回 404。 撤销自己的最后一个 key 后须重新登录拿新 key(登录总能 签发,不会把自己锁死在账号外)。 """ user_id = self._authenticate() if user_id is None: self._send_json(401, {"error": "unauthorized"}) return with _auth_store(self.server.db_path) as auth: ok = auth.revoke_api_key(user_id, key_id) if ok: auth.audit(user_id, "", "api_key_revoked", "key_id=%s" % key_id) if not ok: self._send_json(404, {"error": "key not found"}) return self._send_json(200, {"revoked": True, "id": key_id}) def _user_keys_revoke_others(self): """撤销除本次请求 key 之外的全部自己的 key(附加 + primary), 返回撤销数。用于“这台设备之外的 key 全作废”;本次请求 的 key 本身不受影响。""" user_id = self._authenticate() if user_id is None: self._send_json(401, {"error": "unauthorized"}) return api_key = self.headers.get("X-API-Key") or "" keep_hash = hashlib.sha256(api_key.encode()).hexdigest() with _auth_store(self.server.db_path) as auth: count = auth.revoke_other_api_keys(user_id, keep_hash) # 只记撤销数,不记任何 key 明文、哈希或保留 key 的身份。 auth.audit(user_id, "", "api_key_revoke_others", "revoked_count=%d" % count) self._send_json(200, {"revoked_count": count}) def _user_gateway_rotate(self): """POST /users/me/gateway/rotate:仅限本人重新开通/轮换 网关 key。非 sub2api 网关时 400;网关不可用时 502 且 旧行不动(rotate 内部先删旧行再重开,只在重开失败时 旧 key 已丢——调用方须知:轮换是显式动作,失败后须 重试本端点重新开通)。审计只记 sub2api_user_id 与 key_id,绝不记 key 明文;响应不含 key。""" user_id = self._authenticate() if user_id is None: self._send_json(401, {"error": "unauthorized"}) return if _gateway_name() != "sub2api": self._send_json(400, {"error": "gateway is not sub2api"}) return conn = self._db() try: try: status, _key, sub_id, key_id = _rotate_sub2api_key( conn, user_id) except _Sub2apiError as exc: self._send_json(502, {"error": str(exc)}) return except Exception as exc: self._send_json(502, {"error": str(exc)}) return finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, "", "gateway_key_rotated", "sub2api_user_id=%s key_id=%s" % (sub_id, key_id)) self._send_json(200, {"rotated": True, "provisioning": status, "sub2api_user_id": sub_id}) # -- 常驻监听任务(见 cloud/resident.py)------------------- # 权限口径与自动化同款:创建/更新/暂停/恢复/结束用 # create_task,读用 read;状态变更记审计。创建不走配额门 # (轮询不花模型额度),配额只在变化建任务时由驱动过, # 耗尽时转暂停而非拒绝创建。 def _resident_create(self, user_id, project_id): body, err = self._read_json() if err: self._send_json(400, {"error": err}) return interval = body.get("interval_seconds", 3600) if interval is None: interval = 3600 conn = self._db() try: try: watch = _res_create( conn, project_id, body.get("name"), body.get("target_url"), body.get("prompt"), user_id, interval, body.get("fetch_timeout_seconds")) except ValueError as exc: self._send_json(400, {"error": str(exc)}) return except Exception as exc: # P2:502 不再透传异常文本(可能含表名/列名等 # DB 细节),统一固定错误码,详情只记服务端日志。 _server_log.warning("resident create failed: %s", exc) self._send_json(502, {"error": "resident_operation_failed"}) return finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, project_id, "resident_watch_created", watch["id"]) self._send_json(201, watch) def _resident_list(self, project_id): conn = self._db() try: try: watches = _res_list(conn, project_id) except Exception as exc: _server_log.warning("resident list failed: %s", exc) self._send_json(502, {"error": "resident_operation_failed"}) return conn.commit() finally: conn.close() self._send_json(200, { "project_id": project_id, "resident_watches": watches, "due_count": sum(1 for w in watches if w["due"])}) def _resident_get(self, user_id, project_id, watch_id): conn = self._db() try: watch = _res_get(conn, watch_id) polls = [] polls_note = "" if watch is not None: try: polls = _res_polls(conn, watch_id) except Exception as exc: # P2:polls 异常不再静默吞成空串,至少记 # 服务端日志;响应仍保持 polls=[] + note # 的兼容形态(排障信号在日志里,不在响应里丢)。 _server_log.warning( "resident polls unavailable for %s: %s", watch_id, exc) polls_note = "polls unavailable" finally: conn.close() if watch is None or watch["project_id"] != project_id: self._send_json(404, {"error": "resident watch not found"}) return self._send_json(200, dict(watch, polls=polls, polls_note=polls_note)) def _resident_update(self, user_id, project_id, watch_id): body, jerr = self._read_json() if jerr: self._send_json(400, {"error": jerr}) return conn = self._db() try: try: ok, watch = _res_update(conn, watch_id, body) except ValueError as exc: self._send_json(400, {"error": str(exc)}) return except Exception as exc: _server_log.warning("resident update failed: %s", exc) self._send_json(502, {"error": "resident_operation_failed"}) return finally: conn.close() if watch is None or watch["project_id"] != project_id: self._send_json(404, {"error": "resident watch not found"}) return if not ok: self._send_json(409, { "error": "ended resident watch cannot be updated;" " create a new watch instead"}) return with _auth_store(self.server.db_path) as store: store.audit(user_id, project_id, "resident_watch_updated", watch_id) self._send_json(200, watch) def _resident_transition(self, user_id, project_id, watch_id, action): fn = {"pause": _res_pause, "resume": _res_resume, "end": _res_end}[action] conn = self._db() try: try: ok, watch = fn(conn, watch_id) except Exception as exc: _server_log.warning( "resident %s failed: %s", action, exc) self._send_json(502, {"error": "resident_operation_failed"}) return finally: conn.close() if watch is None or watch["project_id"] != project_id: self._send_json(404, {"error": "resident watch not found"}) return if not ok: if watch["status"] == "ended": self._send_json(409, { "error": "ended resident watch is terminal;" " create a new watch instead"}) else: self._send_json(409, { "error": "resident watch in status %s cannot %s" % (watch["status"], action)}) return audit_action = {"pause": "resident_watch_paused", "resume": "resident_watch_resumed", "end": "resident_watch_ended"}[action] with _auth_store(self.server.db_path) as store: store.audit(user_id, project_id, audit_action, watch_id) self._send_json(200, watch) def _handle_github_webhook(self): """GitHub webhook:验签 -> delivery 去重 -> 解析 -> 执行建任务。 顺序是故意的:secret 未配 503、签名无效 401(两者都在 去重之前,避免未验签的重复送达污染去重表);同一 X-GitHub-Delivery 第二次送达直接回 200 duplicate,不 重复建任务、不重复记审计。验签接受两个 secret:App webhook secret(库里,App 装好后)与 env 里 GITHUB_WEBHOOK_SECRET(手动 webhook,过渡期共存;App 装好、手动 webhook 删掉后自然不再被命中)。 /fix-review 解析成功后在控制面建一个 queued 任务(prompt 是固定模板 + 截断后的评论摘录,不是评论原文原样), 由 worker 认领执行——webhook 本身不跑模型。任务归属: 按项目名 == repo full_name 匹配项目,created_by 归属 项目 owner 并走 owner 的配额门,executor 强制 docker; 没有匹配项目时不凭空建项目、不建任务,返回 task_id 为 None 并说明原因(worker 的 repo 仍是配置里的 repo, 不是任意 GitHub 仓库的自动克隆,见 README)。作者 不可信时同样不建任务(见 is_trusted_fix_review_author)。check_run / check_suite 只做通知解析, 返回解析结果而不建任务、不改云端任务状态(CI 状态回填 由 pr-checks 主动拉取)。installation / installation_repositories 事件记安装记录(created/added 增行、removed 减行、deleted 清安装),不建任务。手动 webhook 与 App webhook 共存时同一评论 会送两遍(delivery_id 不同):/fix-review 另有内容去重 (repo + PR + 评论 id),只建一次任务。 """ raw = self._read_body() app_secret = None conn = self._db() try: config = get_app_config(conn) if config is not None: app_secret = config.get("webhook_secret") finally: conn.close() secrets = [s for s in (app_secret, os.environ.get("GITHUB_WEBHOOK_SECRET")) if s] if not secrets: self._send_json(503, {"error": "github webhook secret not configured"}) return signature = self.headers.get("X-Hub-Signature-256") if not verify_github_signature_any(secrets, raw, signature): self._send_json(401, {"error": "unauthorized"}) return try: payload = json.loads(raw.decode("utf-8")) if raw else {} except (ValueError, UnicodeDecodeError): self._send_json(400, {"error": "invalid json"}) return if not isinstance(payload, dict): self._send_json(400, {"error": "body must be a JSON object"}) return event = self.headers.get("X-GitHub-Event") delivery_id = self.headers.get("X-GitHub-Delivery") repository = payload.get("repository") or {} repo_name = repository.get("full_name") store = WebhookDeliveryStore(self.server.db_path) try: if not store.claim(delivery_id, event=event, repo=repo_name): self._send_json(200, { "duplicate": True, "delivery_id": delivery_id, }) return finally: try: store.close() except Exception: pass # installation / installation_repositories 事件:记安装记录, # 不建任务。后者是安装后增减仓库的同步事件(added 增行、 # removed 减行),与 installation 事件共用同一解析与动作 # 分发;new_permissions_accepted 无需改行。 if event in ("installation", "installation_repositories"): inst = parse_installation_event(payload) if inst is None: self._send_json(200, {"result": None}) return conn = self._db() try: action = inst["action"] if action == "created": record_installation_repos( conn, inst["installation_id"], inst["account_login"], inst["repos"]) elif action == "added": record_installation_repos( conn, inst["installation_id"], inst["account_login"], inst["repos"]) elif action == "removed": remove_installation_repos( conn, inst["installation_id"], inst["repos"]) elif action == "deleted": remove_installation(conn, inst["installation_id"]) # new_permissions_accepted:权限已接受,无需改行。 finally: conn.close() with _auth_store(self.server.db_path) as auth: auth.audit("system", "", "github_app_installation", "%s:%s" % (inst["installation_id"], inst["action"])) self._send_json(200, { "result": "installation_recorded", "installation_id": inst["installation_id"], "action": inst["action"], "repos": inst["repos"], }) return # check_run / check_suite:只解析,不建任务、不改状态。 check = parse_github_check_run(payload) if check is not None: self._send_json(200, check) return # issue_comment 与 pull_request_review_comment:后者把 # pull_request.number 归一化成 parser 要的 issue.number # (见 _parse_github_comment_with_review_fallback)。 result = _parse_github_comment_with_review_fallback(payload) if result is None: self._send_json(200, {"result": None}) return # /fix-review 执行:建 queued 任务(不跑模型,worker 认领)。 # P0 安全批后:先验作者、再查配额、最后才建任务。 # 作者校验用 GitHub 事件自带的 author_association 与 # PR/issue 作者比对(见 is_trusted_fix_review_author): # 不可信一律不建任务、不占去重位,fail-closed。 conn = self._db() try: project_row = None if repo_name: project_row = conn.execute( "SELECT id, owner_id FROM projects WHERE name = ?", (repo_name,), ).fetchone() if project_row is None: self._send_json(200, dict( result, task_id=None, project_id=None, error="no matching project for repo")) return project_id = project_row[0] project_owner = project_row[1] if not is_trusted_fix_review_author(payload): with _auth_store(self.server.db_path) as auth: auth.audit("system", project_id, "webhook_fix_review_rejected", "untrusted author %s" % (result.get("author"),)) self._send_json(200, dict( result, task_id=None, project_id=project_id, error="untrusted fix-review author")) return if not project_owner: self._send_json(200, dict( result, task_id=None, project_id=project_id, error="project has no owner for quota attribution")) return if self._reject_if_over_quota(project_owner): with _auth_store(self.server.db_path) as auth: auth.audit("system", project_id, "webhook_fix_review_quota_blocked", project_owner) return # 内容去重:手动 webhook 与 App webhook 共存时同一评论 # 送两遍(delivery_id 不同),只建一次任务;重复送达 # 回读当初的 task_id,不重复记审计。 comment_id = (payload.get("comment") or {}).get("id") is_new, existing_task_id = claim_fix_review( conn, repo_name, result.get("pr_number"), comment_id) if not is_new: self._send_json(200, dict( result, task_id=existing_task_id, project_id=project_id, duplicate=True)) return task_id = uuid.uuid4().hex now = time.time() # prompt 不再原样贴评论全文:固定模板 + 截断后的 # 评论摘录(上限 2000 字),防超长评论撑爆 prompt # 与指令注入面无限放大(见 _webhook_fix_review_prompt)。 prompt = _webhook_fix_review_prompt(result) try: conn.execute( "INSERT INTO tasks(id, project_id, prompt, status," " idempotency_key, depends_on, priority, created_by," " created_at, updated_at, last_heartbeat, executor)" " VALUES(?, ?, ?, 'queued', ?, '[]', 0, ?," " ?, ?, NULL, 'docker')", (task_id, project_id, prompt, "webhook:%s" % delivery_id if delivery_id else None, project_owner, now, now), ) conn.commit() except (sqlite3.IntegrityError, _PgIntegrityError): # 并发/去重兜底:同 delivery 已建过任务时回读它, # 不建重复任务、不重复记审计。 row = conn.execute( "SELECT id FROM tasks WHERE project_id = ?" " AND idempotency_key = ?", (project_id, "webhook:%s" % delivery_id if delivery_id else None), ).fetchone() if row: self._send_json(200, dict( result, task_id=row[0], project_id=project_id, duplicate=True)) return raise set_fix_review_task_id( conn, repo_name, result.get("pr_number"), comment_id, task_id) finally: conn.close() with _auth_store(self.server.db_path) as auth: auth.audit("system", project_id, "webhook_fix_review", task_id) self._send_json(200, dict( result, task_id=task_id, project_id=project_id)) def _handle_slack_webhook(self): raw = self._read_body() secret = os.environ.get("SLACK_SIGNING_SECRET") if not secret: self._send_json(503, {"error": "slack webhook secret not configured"}) return signature = self.headers.get("X-Slack-Signature") timestamp = self.headers.get("X-Slack-Request-Timestamp") if not verify_slack_signature(secret, timestamp, raw, signature): self._send_json(401, {"error": "unauthorized"}) return # 只解析不执行任务:Slack 斜杠命令的执行由控制面后续决定。 content_type = self.headers.get("Content-Type") or "" text = "" if "application/x-www-form-urlencoded" in content_type: parsed = parse_qs(raw.decode("utf-8", "replace")) values = parsed.get("text") text = values[0] if values else "" elif raw: try: payload = json.loads(raw.decode("utf-8")) if isinstance(payload, dict): text = payload.get("text", "") or "" else: text = raw.decode("utf-8", "replace") except (ValueError, UnicodeDecodeError): text = raw.decode("utf-8", "replace") self._send_json(200, parse_slack_command(text)) def create_server(db_path, host="127.0.0.1", port=8765, backend=None): """建 server 实例(不自动 serve,供测试与 __main__ 共用)。 backend 为 None 时读环境变量 CLOUD_DB_BACKEND,未设则默认 sqlite(旧行为不变)。选 postgres 时在建表之前就调用工厂: 驱动缺失则初始化即抛明确错误(文案含 psycopg),启动即失败, 绝不在运行时中途才炸、也绝不静默回落到 SQLite;驱动已装 且能连上服务器时 Postgres 即为可用后端(适配层见 cloud/db.py,部署步骤见 cloud/README.md)。 """ db_path = str(db_path) # 后端校验必须在 init_db 之前:postgres 模式不能先建 SQLite 表 # 再失败,那会假装 sqlite 已可用。 _create_database(backend=backend, db_path=db_path) init_db(db_path) # 确保 AuthStore 的三张表也存在,后续请求才能直接查 project_members。 with _auth_store(db_path): pass server = ThreadingHTTPServer((host, port), CloudControlPlaneHandler) server.db_path = db_path server.daemon_threads = True # 限流器:Postgres 下 DB 共享计数(多 server 共用),否则内存 # 滑动窗口;默认值与环境变量覆盖见 _make_rate_limiter;测试可 # 直接改 server.rate_limit_per_minute / window_seconds。 server.rate_limiter = _make_rate_limiter(db_path) server.rate_limit_per_minute = None server.rate_limit_window_seconds = None # OAuth 回调注入点:生产为 None(走真实换 token + 真实 userinfo), # 测试可注入 fake http_client / userinfo_client;绝不注入真实 secret。 server.oauth_http_client = None server.oauth_userinfo_client = None # 自动 PR 注入点:生产为 None(走真实 git/gh),测试可注入 # fake git/gh runner 与 worktree 根;绝不注入真实凭证。 server.pr_git_runner = None server.pr_gh_runner = None server.pr_gh_which = None server.host_worktrees_dir = None # 全自动执行器注入点:生产为 None(走 urllib + 真实 # installation token 解析);测试可注入 fake http_client # 与 token 解析器;绝不注入真实 token、真实 secret。 server.full_auto_http_client = None server.full_auto_token_resolver = None return server def main(argv=None): parser = argparse.ArgumentParser(description="cloud control plane") parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8765) parser.add_argument("--db", default="cloud-control-plane.db") parser.add_argument("--db-backend", default=None, help="sqlite(默认)或 postgres;未给则读 " "CLOUD_DB_BACKEND 环境变量") args = parser.parse_args(argv) server = create_server(args.db, args.host, args.port, backend=args.db_backend) # 共享 home MCP 脚本同步:只在 main() 启动时做,不在 # create_server() 里做——create_server 是单测入口,单测若 # 在此同步会按 host-config-host.json 写向生产共享路径。 # systemd 两个控制面都跑 python3 -m cloud.server,走的 # 都是 main(),每次部署重启都会同步一次;同步失败只记 # warning,绝不阻塞启动(sync 本身也不抛异常,外层再 # 兜一层防解析等意外)。Docker 任务不受影响:容器内 # 该共享路径是租户 home 的挂载,宿主补齐不进容器。 try: _shared_home = _resolve_shared_home_mcp(server) if _shared_home: _sync_result = _sync_shared_home_mcp(_shared_home) if _sync_result.get("copied") or _sync_result.get("refreshed"): _server_log.info( "shared home MCP synced: copied=%s refreshed=%s", _sync_result.get("copied"), _sync_result.get("refreshed")) if _sync_result.get("error"): _server_log.warning( "shared home MCP sync failed (non-fatal): %s", _sync_result.get("error")) except Exception as exc: _server_log.warning( "shared home MCP sync failed (non-fatal): %s", exc) start_rate_limit_sweeper(server.rate_limiter, args.db) print( "cloud-control-plane listening on %s:%d db=%s" % (args.host, server.server_address[1], args.db), flush=True, ) try: server.serve_forever() except KeyboardInterrupt: pass if __name__ == "__main__": main()