From 9239028ca05fdf4f44c443c56aca9907462bc8c0 Mon Sep 17 00:00:00 2001 From: chuixue <2960494764@qq.com> Date: Mon, 10 Aug 2026 10:55:28 +0800 Subject: [PATCH 01/21] docs: define agentseek api runtime migration --- ...-agentseek-api-runtime-migration-design.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-10-agentseek-api-runtime-migration-design.md diff --git a/docs/superpowers/specs/2026-08-10-agentseek-api-runtime-migration-design.md b/docs/superpowers/specs/2026-08-10-agentseek-api-runtime-migration-design.md new file mode 100644 index 00000000..bd2c70a1 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-agentseek-api-runtime-migration-design.md @@ -0,0 +1,56 @@ +# AgentSeek API Runtime Migration Design + +## Goal + +将 AgentSeek 及其模板的本地 backend 运行时统一迁移到 `uv run agentseek-api dev`,保留现有 lifecycle 配置、工作目录、环境变量、端口、HTTP 检查和子进程监督行为。 + +## Current implementation findings + +- AgentSeek 的 `agentseek dev` 入口最终进入 `src/agentseek/cli/lifecycle/core.py` 的 `dev()`。 +- backend 命令来自生成项目的 `.agentseek/lifecycle.toml` 的 `[processes.backend].command`,不是 AgentSeek CLI 中硬编码的 `langgraph dev`。 +- AgentSeek 的 dry-run、info、doctor 和 live doctor 均由 lifecycle spec 驱动;进程启动、等待、终止由 lifecycle process-group 实现。 +- AgentSeek API 的 `dev` 命令在 `src/agentseek_api/cli.py` 中自行组装并运行 uvicorn,支持 `--host`、`--port`、`--config`、`--env-file`,reload 通过 `--no-reload` 控制。 +- AgentSeek API 可读取 `agentseek.json` 和 `langgraph.json`,并设置 `AGENTSEEK_GRAPHS` 给运行时。 + +## Architecture + +模板 lifecycle spec 是唯一的项目启动协议。每个 backend process 声明 `uv run agentseek-api dev` 及模板需要的参数;AgentSeek CLI 只负责解析、检查、启动、监督和停止声明的进程,不实现第二套 runtime。 + +模板继续使用兼容的 graph 配置结构,优先保留 `langgraph.json`;只有在 AgentSeek API 语义确实要求时才迁移为 `agentseek.json`。模板依赖显式加入 `agentseek-api`,并同步 lockfile。 + +## Component changes + +### AgentSeek + +- 增强 lifecycle backend/runtime 描述,使 `info`(包括 JSON 输出)显示 `agentseek-api`,且不泄露环境变量值。 +- 让 doctor 静态检查确认 `agentseek-api` 可执行文件、graph 配置、必要路径和环境变量。 +- 保持现有 cwd、env file、host/port、reload 参数、frontend/backend 关系以及进程失败和停止处理。 +- 增加 dry-run、info、doctor、live doctor、端口/进程失败场景的回归测试。 + +### Templates + +- 扫描所有模板和模板生成文件,替换 backend 启动命令及当前文档中的旧启动方式。 +- 为每个实际运行 backend 的模板加入 `agentseek-api` 依赖并更新 lockfile。 +- 验证 graph 配置、环境样例、frontend backend URL 和文档的一致性。 +- 先验证一个最小模板,再批量处理其余模板。 + +### AgentSeek API + +- 先使用现有 CLI 和配置加载能力验证最小模板。 +- 只有当问题被证明属于 runtime 能力缺口时,才修改 CLI、配置加载、HTTP API 或集成测试;不得增加调用 `langgraph dev` 的兼容层。 + +## Error handling + +- 缺少 `agentseek-api`、graph 配置、依赖路径或必需环境变量时,在启动前由 doctor/启动前检查给出明确修复提示。 +- backend 进程启动失败、提前退出或端口冲突时保留现有进程监督错误路径,并明确指出 backend 命令和 URL。 +- `doctor --live` 只通过 lifecycle 配置中的 HTTP endpoint 检查服务,不通过进程名判断。 + +## Verification + +按顺序验证:AgentSeek CLI 单元测试;最小模板生成、info、doctor、dry-run 和真实启动;必要时 AgentSeek API CLI/配置/API 测试;全部模板扫描和逐模板生成检查;三个仓库最终搜索不得把 `langgraph dev` 或 `uv run langgraph dev` 作为当前启动方式。 + +## Scope decisions + +- LangGraph Python 库和 SDK 只要仍被 graph 或 frontend 使用则保留。 +- `langgraph.json` 作为 graph 配置文件不因名称本身删除;它与 runtime 启动命令分开处理。 +- 文档中的历史迁移说明若保留,必须明确标为旧版本行为,不能作为当前操作方式。 From bc596ad3d6422e9e798752c88d6f8509f194ccdc Mon Sep 17 00:00:00 2001 From: chuixue <2960494764@qq.com> Date: Mon, 10 Aug 2026 10:55:56 +0800 Subject: [PATCH 02/21] docs: plan agentseek api runtime migration --- ...6-08-10-agentseek-api-runtime-migration.md | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-10-agentseek-api-runtime-migration.md diff --git a/docs/superpowers/plans/2026-08-10-agentseek-api-runtime-migration.md b/docs/superpowers/plans/2026-08-10-agentseek-api-runtime-migration.md new file mode 100644 index 00000000..0778ad99 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-agentseek-api-runtime-migration.md @@ -0,0 +1,83 @@ +# AgentSeek API Runtime Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every AgentSeek-generated backend start through `uv run agentseek-api dev`, with verified CLI, template, and runtime behavior. + +**Architecture:** Keep AgentSeek lifecycle TOML as the orchestration contract. Templates declare the AgentSeek API process and health URL; AgentSeek manages checks, env, cwd, subprocess supervision, and frontend ordering. AgentSeek API remains the runtime and reads `agentseek.json`, `langgraph.json`, or explicit `AGENTSEEK_GRAPHS` without invoking LangGraph CLI. + +**Tech Stack:** Python, Typer, pytest, TOML lifecycle specs, Cookiecutter templates, uv lockfiles, FastAPI/Uvicorn. + +## Global Constraints + +- Runtime startup must use `uv run agentseek-api dev`. +- Current user modifications must be preserved. +- LangGraph Python libraries/config compatibility may remain; LangGraph CLI startup may not. +- Secrets remain placeholders only in `.env.example` files. +- Verify before claiming completion. + +--- + +### Task 1: Establish AgentSeek lifecycle runtime contract + +**Files:** +- Modify: `agentseek/src/agentseek/cli/lifecycle/core.py` +- Modify: `agentseek/src/agentseek/cli/lifecycle/spec.py` and related lifecycle JSON rendering code identified by tests +- Test: `agentseek/tests/cli_commands/test_lifecycle.py` + +- [ ] **Step 1: Add failing tests** for backend runtime/tool checks, human info, JSON info, dry-run command, and live doctor using the same configured URL. +- [ ] **Step 2: Run focused lifecycle tests** with `uv run pytest tests/cli_commands/test_lifecycle.py -q` and confirm the new assertions fail. +- [ ] **Step 3: Implement the smallest lifecycle changes** so `agentseek-api` is checked as an executable, backend runtime is rendered without secrets, and the declared command remains the only process command. +- [ ] **Step 4: Add regression coverage** for missing executable, missing graph config, port collision/startup failure, and subprocess cleanup while preserving existing user edits. +- [ ] **Step 5: Run focused AgentSeek tests** and inspect the diff. + +### Task 2: Validate and, only if needed, fix AgentSeek API CLI/config behavior + +**Files:** +- Inspect/modify: `agentseek-api/src/agentseek_api/cli.py` +- Inspect/modify: `agentseek-api/src/agentseek_api/settings.py` and config loader modules only if required +- Test: `agentseek-api/tests/unit/test_cli.py`, `agentseek-api/tests/unit/test_graph_manifest.py`, relevant integration tests + +- [ ] **Step 1: Run existing CLI/config tests** covering `dev`, `--config`, env files, graph loading, host, port, and reload. +- [ ] **Step 2: Run the CLI help and a minimal temporary graph project** with `uv run agentseek-api dev --no-browser --no-reload`, custom host/port, and explicit config. +- [ ] **Step 3: If a required behavior fails, add a focused failing test** before changing implementation. +- [ ] **Step 4: Implement only confirmed runtime gaps**, ensuring the process starts uvicorn directly and never shells out to `langgraph dev`. +- [ ] **Step 5: Run the focused runtime test set and record supported flag mapping (`--reload` versus `--no-reload`). + +### Task 3: Migrate and verify the smallest template + +**Files:** +- Modify: `agentseek-templates/templates/langchain/markdown-messages/{{cookiecutter.project_slug}}/.agentseek/lifecycle.toml` +- Modify: `agentseek-templates/templates/langchain/markdown-messages/{{cookiecutter.project_slug}}/pyproject.toml` +- Modify: generated/template `langgraph.json`, `.env.example`, README, tests as present +- Regenerate: the template's `uv.lock` using uv + +- [ ] **Step 1: Inspect the existing user edits and add tests** asserting the generated backend command, dependency, config, URL, and docs. +- [ ] **Step 2: Generate a project from the minimal template** in an isolated temporary directory and run `uv sync`/lock regeneration. +- [ ] **Step 3: Run `agentseek info`, `agentseek doctor`, and `agentseek dev --dry-run`; confirm output contains `agentseek-api` and no LangGraph CLI command. +- [ ] **Step 4: Start the backend, run `agentseek doctor --live`, and exercise health, assistant, thread/run, and streaming endpoints as supported by the template. +- [ ] **Step 5: Fix only template or runtime defects exposed by this validation, then rerun the complete minimal-template flow. + +### Task 4: Batch-migrate all AgentSeek templates + +**Files:** +- Modify every affected file under `agentseek-templates/templates/`, including lifecycle TOML, `pyproject.toml`, lockfiles, graph config, env examples, scripts, tests, and current README docs +- Modify: template registry/catalog files identified by the scan + +- [ ] **Step 1: Produce a classified scan** excluding generated `node_modules` noise and list every occurrence of `langgraph dev`, `uv run langgraph dev`, `langgraph-cli`, and `langgraph_api`. +- [ ] **Step 2: Update each backend lifecycle command** to the supported `agentseek-api dev` flags, retaining port/host/cwd/env/reload semantics. +- [ ] **Step 3: Add a consistent `agentseek-api` dependency** to every backend template that starts a local backend, preserving required LangGraph/LangChain packages. +- [ ] **Step 4: Regenerate each affected lockfile** with uv and verify Python/version compatibility. +- [ ] **Step 5: Update env examples, graph configs, frontend backend URLs, scripts, tests, and current docs. +- [ ] **Step 6: Generate each migrated template and run its static lifecycle checks and dry-run; group only templates that share an identical validated contract. + +### Task 5: End-to-end and cross-repository verification + +**Files:** +- Modify only tests/docs needed for failures discovered in Tasks 1–4. + +- [ ] **Step 1: Run AgentSeek CLI coverage** for `dev --dry-run`, `info`, `info --json`, `doctor`, and `doctor --live`, including missing dependency, port conflict, health failure, and child-process failure cases. +- [ ] **Step 2: Run AgentSeek API unit/integration coverage** for CLI startup, config precedence, graph loading, health, threads/runs, streaming, auth/MCP/store/A2A/CORS paths used by migrated templates. +- [ ] **Step 3: Run generated-template smoke tests** covering create, info, doctor, dry-run, startup, live doctor, graph call, streaming, thread/run, frontend URL, and reload where feasible. +- [ ] **Step 4: Run a final classified repository scan** and ensure no current startup path contains `langgraph dev`, `uv run langgraph dev`, or a requirement on `langgraph-cli`. +- [ ] **Step 5: Review all three git diffs and status**, preserve unrelated user files, and report tests, template count/list, runtime changes, remaining LangGraph dependencies, and unresolved issues. From 72f32aec9b7bc03152c4a27728f9cf0497655785 Mon Sep 17 00:00:00 2001 From: chuixue <2960494764@qq.com> Date: Mon, 10 Aug 2026 19:19:28 +0800 Subject: [PATCH 03/21] fix: pass template env to dev child processes --- .gitignore | 5 +++ docs/guides/create-template.md | 5 +-- docs/reference/lifecycle-spec.md | 10 +++--- pyproject.toml | 1 + src/agentseek/cli/lifecycle/authored.py | 1 + src/agentseek/cli/lifecycle/core.py | 26 ++++++++++++-- .../references/agentseek-lifecycle.md | 4 +-- tests/cli_commands/test_lifecycle.py | 35 +++++++++++++++++++ uv.lock | 1 + 9 files changed, 77 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 2ee2640e..3262b356 100644 --- a/.gitignore +++ b/.gitignore @@ -169,6 +169,11 @@ docs/hub.zh.md /.superpowers/ /specs/plans/ +# Local rendered templates and runtime artifacts +/my_*/ +/.cookiecutter-replay/ +/catalog-lock-sync-analysis.md + # mypy .mypy_cache/ .dmypy.json diff --git a/docs/guides/create-template.md b/docs/guides/create-template.md index f40d6eb1..f08d9e03 100644 --- a/docs/guides/create-template.md +++ b/docs/guides/create-template.md @@ -97,8 +97,9 @@ adapters. Add provider-specific keys only when the selected SDK requires them. Document how runtime code maps aliases and which value wins. Declare the same required names under `[env.*]` in the lifecycle file. AgentSeek -uses those declarations for readiness checks; it does not inject `.env` into -child processes. +uses those declarations for readiness checks. During `agentseek dev`, the +project `.env` is also passed to long-running child processes, with exported +shell variables taking precedence. ## 5. Define The Lifecycle diff --git a/docs/reference/lifecycle-spec.md b/docs/reference/lifecycle-spec.md index af3d19b0..d7f52dee 100644 --- a/docs/reference/lifecycle-spec.md +++ b/docs/reference/lifecycle-spec.md @@ -91,7 +91,7 @@ command = ["npm", "install", "--prefix", "frontend"] | Section | Purpose | | --- | --- | -| `env_file` | Optional project-local env file used only for declared environment checks. It is not injected into child processes. | +| `env_file` | Optional project-local env file used for declared checks and `agentseek dev` child processes. Shell variables take precedence. | | `tools` | Required executables used by the project. | | `paths` | Required local files or directories. | | `env.` | Environment variables AgentSeek should check. Defaults are lower priority than `env_file` and shell variables. | @@ -112,10 +112,10 @@ AgentSeek checks environment requirements from lifecycle defaults, the optional lifecycle default < env_file < shell environment ``` -Only keys declared under `[env.]` and their aliases are read from -`env_file`. Templates do not need to declare every runtime variable a project -may use. AgentSeek does not pass the env file or lifecycle defaults to child -processes. +Only keys declared under `[env.]` and their aliases are used for +readiness checks. During `agentseek dev`, values from the project `env_file` +are passed to long-running child processes, with the current shell environment +applied last. Lifecycle defaults are not injected into child processes. ## Lifecycle v1 first-phase scope diff --git a/pyproject.toml b/pyproject.toml index ef14def4..0cb3fbd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "httpx>=0.28", "pydantic>=2.0", "pydantic-settings>=2.0.0", + "python-dotenv>=1.0", "typer>=0.12", ] diff --git a/src/agentseek/cli/lifecycle/authored.py b/src/agentseek/cli/lifecycle/authored.py index b8b02096..03cc553f 100644 --- a/src/agentseek/cli/lifecycle/authored.py +++ b/src/agentseek/cli/lifecycle/authored.py @@ -69,6 +69,7 @@ class _EnvRequirementV2(EnvRequirement): class ServiceV1(SpecModel): url: str + tech: str | None = None class ProcessV1(SpecModel): diff --git a/src/agentseek/cli/lifecycle/core.py b/src/agentseek/cli/lifecycle/core.py index 0f594443..2c800fe0 100644 --- a/src/agentseek/cli/lifecycle/core.py +++ b/src/agentseek/cli/lifecycle/core.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextlib +import os import shlex import shutil import signal @@ -19,6 +20,7 @@ import typer from duty import Collection from duty._internal.collection import Duty +from dotenv import dotenv_values from pydantic import Field, create_model from pydantic_settings import BaseSettings, SettingsConfigDict @@ -227,7 +229,8 @@ def print_info(project: LifecycleProject, *, verbose: bool) -> None: print("Entrypoints") print(" Dev: agentseek dev") for name, service in spec.services.items(): - print(f" {_display_name(name)}: {service.url}") + runtime = f" (runtime: {service.tech})" if service.tech else "" + print(f" {_display_name(name)}: {service.url}{runtime}") print() print("Environment") if spec.env_file: @@ -270,7 +273,8 @@ def dev(project: LifecycleProject, *, dry_run: bool) -> None: for name, process in project.spec.processes.items(): print(f" {_display_name(name)}: {_render_command(process.command)}") for name, service in project.spec.services.items(): - print(f" {_display_name(name)}: {service.url}") + runtime = f" (runtime: {service.tech})" if service.tech else "" + print(f" {_display_name(name)}: {service.url}{runtime}") if dry_run: return @@ -503,6 +507,23 @@ def _render_command(command: Sequence[str]) -> str: return " ".join(shlex.quote(part) for part in command) +def _process_environment(project: LifecycleProject) -> dict[str, str]: + """Build a child environment from the project dotenv file and shell. + + The shell is intentionally applied last so explicit exported values keep + precedence over project-local defaults and secrets. + """ + + env_file = _env_file_path(project) + environment = ( + {key: value for key, value in dotenv_values(env_file).items() if value is not None} + if env_file is not None + else {} + ) + environment.update(os.environ) + return environment + + def _spawn_process(process: ProcessV1 | ProcessV2, *, project: LifecycleProject) -> ManagedProcess: executable = shutil.which(process.command[0]) if executable is None: @@ -519,6 +540,7 @@ def _spawn_process(process: ProcessV1 | ProcessV2, *, project: LifecycleProject) popen( command, cwd=str(cwd), + env=_process_environment(project), **spawn_kwargs(), ), ) diff --git a/src/skills/agentseek-lifecycle/references/agentseek-lifecycle.md b/src/skills/agentseek-lifecycle/references/agentseek-lifecycle.md index e935b5ca..f6b9799c 100644 --- a/src/skills/agentseek-lifecycle/references/agentseek-lifecycle.md +++ b/src/skills/agentseek-lifecycle/references/agentseek-lifecycle.md @@ -24,13 +24,13 @@ Projects may expose additional spec tasks. Run them through `agentseek task`. - Declare tools under `[tools]` with a `required` list. - Declare file and directory prerequisites under `[paths]` with a `required` list. - Declare only environment variables AgentSeek should check under `[env.]`. Defaults are lower priority than `env_file` and shell variables. -- Use top-level `env_file` only when AgentSeek should read a project-local env file for declared env checks. AgentSeek does not inject that file into child processes. +- Use top-level `env_file` when AgentSeek should read a project-local env file for checks and development processes. During `agentseek dev`, values from this file are passed to child processes; explicitly exported shell variables take precedence. - Put public service URLs under `[services.]`. - Put long-running process commands under `[processes.]`. Do not declare process-level environment overrides. - Put task commands under `[tasks.]`. Task `cwd` values are project-relative and must exist before the task starts. Version 1 deliberately does not support optional tool/path checks, TCP checks, -process env overrides, multiple env files, env file injection, or env interpolation. +process env overrides, multiple env files, or env interpolation. ## Command Semantics diff --git a/tests/cli_commands/test_lifecycle.py b/tests/cli_commands/test_lifecycle.py index c351c2fe..bca3c6c9 100644 --- a/tests/cli_commands/test_lifecycle.py +++ b/tests/cli_commands/test_lifecycle.py @@ -50,6 +50,7 @@ def _write_lifecycle_spec(root: Path) -> None: [services.app] url = "http://127.0.0.1:5173" +tech = "agentseek-api" [services.seekdb] url = "mysql://127.0.0.1:2884/phoenix" @@ -400,6 +401,7 @@ def test_dev_dry_run_dispatches_lifecycle_spec(tmp_path: Path, monkeypatch) -> N assert "Startup plan" in result.stdout assert "Web: python -m http.server 5173" in result.stdout assert "App: http://127.0.0.1:5173" in result.stdout + assert "App: http://127.0.0.1:5173 (runtime: agentseek-api)" in result.stdout assert "seekdb: mysql://127.0.0.1:2884/phoenix" in result.stdout assert "Seekdb:" not in result.stdout @@ -547,6 +549,39 @@ def fake_call(command: object, *, cwd: object, **kwargs: Any) -> int: assert captured_child_environ["BUB_OPENAI_API_KEY"] == "shell-key" assert "EXTRA_DOTENV" not in captured_child_environ assert "AGENTSEEK_SECRET" not in captured_child_environ + + +def test_dev_child_process_inherits_env_file_with_shell_precedence(tmp_path: Path, monkeypatch) -> None: + _write_lifecycle_spec(tmp_path) + (tmp_path / ".env").write_text( + "SEEKDB_URL=mysql+aiomysql://dotenv.example/test\nDOTENV_ONLY=from-dotenv\nOVERRIDE=from-dotenv\n", + encoding="utf-8", + ) + captured_child_environ: dict[str, str] | None = None + + class FakeProcess: + def poll(self) -> int | None: + return None + + def fake_popen(command: object, *, cwd: object, env: dict[str, str], **kwargs: object) -> FakeProcess: + nonlocal captured_child_environ + del command, cwd, kwargs + captured_child_environ = env + return FakeProcess() + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("OVERRIDE", "from-shell") + monkeypatch.setattr(lifecycle_core.shutil, "which", lambda _tool: sys.executable) + monkeypatch.setattr(lifecycle_core.subprocess, "Popen", fake_popen) + monkeypatch.setattr(lifecycle_core, "manage", lambda process: process) + + project = lifecycle_core.discover_lifecycle_project(tmp_path) + lifecycle_core._spawn_process(project.spec.processes["web"], project=project) + + assert captured_child_environ is not None + assert captured_child_environ["SEEKDB_URL"] == "mysql+aiomysql://dotenv.example/test" + assert captured_child_environ["DOTENV_ONLY"] == "from-dotenv" + assert captured_child_environ["OVERRIDE"] == "from-shell" assert "BUB_SECRET" not in captured_child_environ diff --git a/uv.lock b/uv.lock index e67d4b8e..60c54431 100644 --- a/uv.lock +++ b/uv.lock @@ -50,6 +50,7 @@ dependencies = [ { name = "logfire" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "python-dotenv" }, { name = "typer" }, ] From c8fce3c0016b893e36011a53ab11a8b98a8e989f Mon Sep 17 00:00:00 2001 From: chuixue <2960494764@qq.com> Date: Mon, 10 Aug 2026 19:27:03 +0800 Subject: [PATCH 04/21] chore: remove internal migration notes --- ...6-08-10-agentseek-api-runtime-migration.md | 83 ------------------- ...-agentseek-api-runtime-migration-design.md | 56 ------------- 2 files changed, 139 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-10-agentseek-api-runtime-migration.md delete mode 100644 docs/superpowers/specs/2026-08-10-agentseek-api-runtime-migration-design.md diff --git a/docs/superpowers/plans/2026-08-10-agentseek-api-runtime-migration.md b/docs/superpowers/plans/2026-08-10-agentseek-api-runtime-migration.md deleted file mode 100644 index 0778ad99..00000000 --- a/docs/superpowers/plans/2026-08-10-agentseek-api-runtime-migration.md +++ /dev/null @@ -1,83 +0,0 @@ -# AgentSeek API Runtime Migration Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make every AgentSeek-generated backend start through `uv run agentseek-api dev`, with verified CLI, template, and runtime behavior. - -**Architecture:** Keep AgentSeek lifecycle TOML as the orchestration contract. Templates declare the AgentSeek API process and health URL; AgentSeek manages checks, env, cwd, subprocess supervision, and frontend ordering. AgentSeek API remains the runtime and reads `agentseek.json`, `langgraph.json`, or explicit `AGENTSEEK_GRAPHS` without invoking LangGraph CLI. - -**Tech Stack:** Python, Typer, pytest, TOML lifecycle specs, Cookiecutter templates, uv lockfiles, FastAPI/Uvicorn. - -## Global Constraints - -- Runtime startup must use `uv run agentseek-api dev`. -- Current user modifications must be preserved. -- LangGraph Python libraries/config compatibility may remain; LangGraph CLI startup may not. -- Secrets remain placeholders only in `.env.example` files. -- Verify before claiming completion. - ---- - -### Task 1: Establish AgentSeek lifecycle runtime contract - -**Files:** -- Modify: `agentseek/src/agentseek/cli/lifecycle/core.py` -- Modify: `agentseek/src/agentseek/cli/lifecycle/spec.py` and related lifecycle JSON rendering code identified by tests -- Test: `agentseek/tests/cli_commands/test_lifecycle.py` - -- [ ] **Step 1: Add failing tests** for backend runtime/tool checks, human info, JSON info, dry-run command, and live doctor using the same configured URL. -- [ ] **Step 2: Run focused lifecycle tests** with `uv run pytest tests/cli_commands/test_lifecycle.py -q` and confirm the new assertions fail. -- [ ] **Step 3: Implement the smallest lifecycle changes** so `agentseek-api` is checked as an executable, backend runtime is rendered without secrets, and the declared command remains the only process command. -- [ ] **Step 4: Add regression coverage** for missing executable, missing graph config, port collision/startup failure, and subprocess cleanup while preserving existing user edits. -- [ ] **Step 5: Run focused AgentSeek tests** and inspect the diff. - -### Task 2: Validate and, only if needed, fix AgentSeek API CLI/config behavior - -**Files:** -- Inspect/modify: `agentseek-api/src/agentseek_api/cli.py` -- Inspect/modify: `agentseek-api/src/agentseek_api/settings.py` and config loader modules only if required -- Test: `agentseek-api/tests/unit/test_cli.py`, `agentseek-api/tests/unit/test_graph_manifest.py`, relevant integration tests - -- [ ] **Step 1: Run existing CLI/config tests** covering `dev`, `--config`, env files, graph loading, host, port, and reload. -- [ ] **Step 2: Run the CLI help and a minimal temporary graph project** with `uv run agentseek-api dev --no-browser --no-reload`, custom host/port, and explicit config. -- [ ] **Step 3: If a required behavior fails, add a focused failing test** before changing implementation. -- [ ] **Step 4: Implement only confirmed runtime gaps**, ensuring the process starts uvicorn directly and never shells out to `langgraph dev`. -- [ ] **Step 5: Run the focused runtime test set and record supported flag mapping (`--reload` versus `--no-reload`). - -### Task 3: Migrate and verify the smallest template - -**Files:** -- Modify: `agentseek-templates/templates/langchain/markdown-messages/{{cookiecutter.project_slug}}/.agentseek/lifecycle.toml` -- Modify: `agentseek-templates/templates/langchain/markdown-messages/{{cookiecutter.project_slug}}/pyproject.toml` -- Modify: generated/template `langgraph.json`, `.env.example`, README, tests as present -- Regenerate: the template's `uv.lock` using uv - -- [ ] **Step 1: Inspect the existing user edits and add tests** asserting the generated backend command, dependency, config, URL, and docs. -- [ ] **Step 2: Generate a project from the minimal template** in an isolated temporary directory and run `uv sync`/lock regeneration. -- [ ] **Step 3: Run `agentseek info`, `agentseek doctor`, and `agentseek dev --dry-run`; confirm output contains `agentseek-api` and no LangGraph CLI command. -- [ ] **Step 4: Start the backend, run `agentseek doctor --live`, and exercise health, assistant, thread/run, and streaming endpoints as supported by the template. -- [ ] **Step 5: Fix only template or runtime defects exposed by this validation, then rerun the complete minimal-template flow. - -### Task 4: Batch-migrate all AgentSeek templates - -**Files:** -- Modify every affected file under `agentseek-templates/templates/`, including lifecycle TOML, `pyproject.toml`, lockfiles, graph config, env examples, scripts, tests, and current README docs -- Modify: template registry/catalog files identified by the scan - -- [ ] **Step 1: Produce a classified scan** excluding generated `node_modules` noise and list every occurrence of `langgraph dev`, `uv run langgraph dev`, `langgraph-cli`, and `langgraph_api`. -- [ ] **Step 2: Update each backend lifecycle command** to the supported `agentseek-api dev` flags, retaining port/host/cwd/env/reload semantics. -- [ ] **Step 3: Add a consistent `agentseek-api` dependency** to every backend template that starts a local backend, preserving required LangGraph/LangChain packages. -- [ ] **Step 4: Regenerate each affected lockfile** with uv and verify Python/version compatibility. -- [ ] **Step 5: Update env examples, graph configs, frontend backend URLs, scripts, tests, and current docs. -- [ ] **Step 6: Generate each migrated template and run its static lifecycle checks and dry-run; group only templates that share an identical validated contract. - -### Task 5: End-to-end and cross-repository verification - -**Files:** -- Modify only tests/docs needed for failures discovered in Tasks 1–4. - -- [ ] **Step 1: Run AgentSeek CLI coverage** for `dev --dry-run`, `info`, `info --json`, `doctor`, and `doctor --live`, including missing dependency, port conflict, health failure, and child-process failure cases. -- [ ] **Step 2: Run AgentSeek API unit/integration coverage** for CLI startup, config precedence, graph loading, health, threads/runs, streaming, auth/MCP/store/A2A/CORS paths used by migrated templates. -- [ ] **Step 3: Run generated-template smoke tests** covering create, info, doctor, dry-run, startup, live doctor, graph call, streaming, thread/run, frontend URL, and reload where feasible. -- [ ] **Step 4: Run a final classified repository scan** and ensure no current startup path contains `langgraph dev`, `uv run langgraph dev`, or a requirement on `langgraph-cli`. -- [ ] **Step 5: Review all three git diffs and status**, preserve unrelated user files, and report tests, template count/list, runtime changes, remaining LangGraph dependencies, and unresolved issues. diff --git a/docs/superpowers/specs/2026-08-10-agentseek-api-runtime-migration-design.md b/docs/superpowers/specs/2026-08-10-agentseek-api-runtime-migration-design.md deleted file mode 100644 index bd2c70a1..00000000 --- a/docs/superpowers/specs/2026-08-10-agentseek-api-runtime-migration-design.md +++ /dev/null @@ -1,56 +0,0 @@ -# AgentSeek API Runtime Migration Design - -## Goal - -将 AgentSeek 及其模板的本地 backend 运行时统一迁移到 `uv run agentseek-api dev`,保留现有 lifecycle 配置、工作目录、环境变量、端口、HTTP 检查和子进程监督行为。 - -## Current implementation findings - -- AgentSeek 的 `agentseek dev` 入口最终进入 `src/agentseek/cli/lifecycle/core.py` 的 `dev()`。 -- backend 命令来自生成项目的 `.agentseek/lifecycle.toml` 的 `[processes.backend].command`,不是 AgentSeek CLI 中硬编码的 `langgraph dev`。 -- AgentSeek 的 dry-run、info、doctor 和 live doctor 均由 lifecycle spec 驱动;进程启动、等待、终止由 lifecycle process-group 实现。 -- AgentSeek API 的 `dev` 命令在 `src/agentseek_api/cli.py` 中自行组装并运行 uvicorn,支持 `--host`、`--port`、`--config`、`--env-file`,reload 通过 `--no-reload` 控制。 -- AgentSeek API 可读取 `agentseek.json` 和 `langgraph.json`,并设置 `AGENTSEEK_GRAPHS` 给运行时。 - -## Architecture - -模板 lifecycle spec 是唯一的项目启动协议。每个 backend process 声明 `uv run agentseek-api dev` 及模板需要的参数;AgentSeek CLI 只负责解析、检查、启动、监督和停止声明的进程,不实现第二套 runtime。 - -模板继续使用兼容的 graph 配置结构,优先保留 `langgraph.json`;只有在 AgentSeek API 语义确实要求时才迁移为 `agentseek.json`。模板依赖显式加入 `agentseek-api`,并同步 lockfile。 - -## Component changes - -### AgentSeek - -- 增强 lifecycle backend/runtime 描述,使 `info`(包括 JSON 输出)显示 `agentseek-api`,且不泄露环境变量值。 -- 让 doctor 静态检查确认 `agentseek-api` 可执行文件、graph 配置、必要路径和环境变量。 -- 保持现有 cwd、env file、host/port、reload 参数、frontend/backend 关系以及进程失败和停止处理。 -- 增加 dry-run、info、doctor、live doctor、端口/进程失败场景的回归测试。 - -### Templates - -- 扫描所有模板和模板生成文件,替换 backend 启动命令及当前文档中的旧启动方式。 -- 为每个实际运行 backend 的模板加入 `agentseek-api` 依赖并更新 lockfile。 -- 验证 graph 配置、环境样例、frontend backend URL 和文档的一致性。 -- 先验证一个最小模板,再批量处理其余模板。 - -### AgentSeek API - -- 先使用现有 CLI 和配置加载能力验证最小模板。 -- 只有当问题被证明属于 runtime 能力缺口时,才修改 CLI、配置加载、HTTP API 或集成测试;不得增加调用 `langgraph dev` 的兼容层。 - -## Error handling - -- 缺少 `agentseek-api`、graph 配置、依赖路径或必需环境变量时,在启动前由 doctor/启动前检查给出明确修复提示。 -- backend 进程启动失败、提前退出或端口冲突时保留现有进程监督错误路径,并明确指出 backend 命令和 URL。 -- `doctor --live` 只通过 lifecycle 配置中的 HTTP endpoint 检查服务,不通过进程名判断。 - -## Verification - -按顺序验证:AgentSeek CLI 单元测试;最小模板生成、info、doctor、dry-run 和真实启动;必要时 AgentSeek API CLI/配置/API 测试;全部模板扫描和逐模板生成检查;三个仓库最终搜索不得把 `langgraph dev` 或 `uv run langgraph dev` 作为当前启动方式。 - -## Scope decisions - -- LangGraph Python 库和 SDK 只要仍被 graph 或 frontend 使用则保留。 -- `langgraph.json` 作为 graph 配置文件不因名称本身删除;它与 runtime 启动命令分开处理。 -- 文档中的历史迁移说明若保留,必须明确标为旧版本行为,不能作为当前操作方式。 From cb910393d348029a16af8e0882132dce9cbca7a6 Mon Sep 17 00:00:00 2001 From: chuixue <2960494764@qq.com> Date: Mon, 10 Aug 2026 19:40:41 +0800 Subject: [PATCH 05/21] fix: avoid dotenv dependency in lifecycle runtime --- pyproject.toml | 1 - src/agentseek/cli/lifecycle/core.py | 26 ++++++++++++++++++++------ uv.lock | 1 - 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0cb3fbd1..ef14def4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,6 @@ dependencies = [ "httpx>=0.28", "pydantic>=2.0", "pydantic-settings>=2.0.0", - "python-dotenv>=1.0", "typer>=0.12", ] diff --git a/src/agentseek/cli/lifecycle/core.py b/src/agentseek/cli/lifecycle/core.py index 2c800fe0..1a41f5a3 100644 --- a/src/agentseek/cli/lifecycle/core.py +++ b/src/agentseek/cli/lifecycle/core.py @@ -20,7 +20,6 @@ import typer from duty import Collection from duty._internal.collection import Duty -from dotenv import dotenv_values from pydantic import Field, create_model from pydantic_settings import BaseSettings, SettingsConfigDict @@ -515,15 +514,30 @@ def _process_environment(project: LifecycleProject) -> dict[str, str]: """ env_file = _env_file_path(project) - environment = ( - {key: value for key, value in dotenv_values(env_file).items() if value is not None} - if env_file is not None - else {} - ) + environment = _read_env_file(env_file) if env_file is not None else {} environment.update(os.environ) return environment +def _read_env_file(path: Path) -> dict[str, str]: + """Read the simple KEY=VALUE dotenv form used by generated templates.""" + values: dict[str, str] = {} + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[7:].lstrip() + key, separator, value = line.partition("=") + if not separator or not key.isidentifier(): + continue + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1] + values[key] = value + return values + + def _spawn_process(process: ProcessV1 | ProcessV2, *, project: LifecycleProject) -> ManagedProcess: executable = shutil.which(process.command[0]) if executable is None: diff --git a/uv.lock b/uv.lock index 60c54431..e67d4b8e 100644 --- a/uv.lock +++ b/uv.lock @@ -50,7 +50,6 @@ dependencies = [ { name = "logfire" }, { name = "pydantic" }, { name = "pydantic-settings" }, - { name = "python-dotenv" }, { name = "typer" }, ] From 55d5bda284170430e618e407e2698bf9de1fc21d Mon Sep 17 00:00:00 2001 From: chuixue <2960494764@qq.com> Date: Mon, 10 Aug 2026 19:49:05 +0800 Subject: [PATCH 06/21] test: cover AgentSeek API lifecycle commands --- tests/cli_commands/test_lifecycle.py | 45 ++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/cli_commands/test_lifecycle.py b/tests/cli_commands/test_lifecycle.py index bca3c6c9..baef7832 100644 --- a/tests/cli_commands/test_lifecycle.py +++ b/tests/cli_commands/test_lifecycle.py @@ -217,6 +217,17 @@ def test_info_lists_lifecycle_tasks_and_task_discovery_hint(tmp_path: Path, monk assert "agentseek task --list" in result.stdout +def test_info_describes_agentseek_api_runtime(tmp_path: Path, monkeypatch) -> None: + _write_lifecycle_spec(tmp_path) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(build_command_app(), ["info"]) + + assert result.exit_code == 0, result.stdout + result.stderr + assert "App: http://127.0.0.1:5173 (runtime: agentseek-api)" in result.stdout + assert "langgraph dev" not in result.stdout + + def test_doctor_dispatches_lifecycle_spec(tmp_path: Path, monkeypatch) -> None: _write_lifecycle_spec(tmp_path) _write_project_inputs(tmp_path) @@ -298,6 +309,29 @@ def __init__(self, status_code: int) -> None: assert "ok app: http://127.0.0.1:5173 is reachable." in result.stdout +def test_doctor_live_reports_migrated_service_health(tmp_path: Path, monkeypatch) -> None: + _write_lifecycle_spec(tmp_path) + _write_project_inputs(tmp_path) + monkeypatch.chdir(tmp_path) + + class FakeResponse: + status_code = 204 + + requested: list[str] = [] + + def get(url: str, *, timeout: float) -> FakeResponse: + del timeout + requested.append(url) + return FakeResponse() + + monkeypatch.setattr(lifecycle_core.httpx, "get", get) + result = CliRunner().invoke(build_command_app(), ["doctor", "--live"]) + + assert result.exit_code == 0, result.stdout + result.stderr + assert requested == ["http://127.0.0.1:5173"] + assert "ok app: http://127.0.0.1:5173 is reachable." in result.stdout + + @pytest.mark.parametrize( "error", [ValueError("invalid timeout"), OverflowError("timestamp out of range")], @@ -406,6 +440,17 @@ def test_dev_dry_run_dispatches_lifecycle_spec(tmp_path: Path, monkeypatch) -> N assert "Seekdb:" not in result.stdout +def test_dev_dry_run_uses_agentseek_api_as_backend(tmp_path: Path, monkeypatch) -> None: + _write_lifecycle_spec(tmp_path) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(build_command_app(), ["dev", "--dry-run"]) + + assert result.exit_code == 0, result.stdout + result.stderr + assert "runtime: agentseek-api" in result.stdout + assert "langgraph dev" not in result.stdout + + def test_dev_skip_check_still_enforces_required_inputs(tmp_path: Path, monkeypatch) -> None: _write_lifecycle_spec(tmp_path) monkeypatch.chdir(tmp_path) From 08403159b203f76263fd7e272799f03ef7b4f1a6 Mon Sep 17 00:00:00 2001 From: chuixue <2960494764@qq.com> Date: Tue, 11 Aug 2026 20:35:27 +0800 Subject: [PATCH 07/21] fix: align lifecycle child environment semantics --- docs/get-started/index.md | 3 +- docs/get-started/index.zh.md | 4 +-- docs/guides/choose-template.md | 2 +- docs/guides/choose-template.zh.md | 2 +- docs/guides/create-template.zh.md | 3 +- docs/reference/lifecycle-spec.zh.md | 8 ++--- docs/reference/template-authoring-contract.md | 6 ++-- .../template-authoring-contract.zh.md | 3 +- src/agentseek/cli/lifecycle/core.py | 26 ++++------------ src/agentseek/cli/lifecycle/discovery.py | 1 - src/agentseek/cli/lifecycle/normalize.py | 2 +- tests/cli_commands/test_lifecycle.py | 30 +++++++++++++++++-- tests/cli_commands/test_lifecycle_json.py | 3 +- 13 files changed, 55 insertions(+), 38 deletions(-) diff --git a/docs/get-started/index.md b/docs/get-started/index.md index 933ed663..4688cf0e 100644 --- a/docs/get-started/index.md +++ b/docs/get-started/index.md @@ -45,7 +45,8 @@ Set the model and provider credentials required by the selected template in `.env` or the environment used to run AgentSeek. `.env` is used by AgentSeek only for lifecycle environment checks declared by -the template. It is not automatically passed to child processes. +the template. During `agentseek dev`, it is passed to long-running child +processes; exported shell variables take precedence. ## Check and run diff --git a/docs/get-started/index.zh.md b/docs/get-started/index.zh.md index 58e4d162..08324e20 100644 --- a/docs/get-started/index.zh.md +++ b/docs/get-started/index.zh.md @@ -41,8 +41,8 @@ agentseek task frontend 在 `.env` 或运行 AgentSeek 的环境里,设置所选模板需要的模型和 provider 凭证。 -AgentSeek 只把 `.env` 用作模板声明的生命周期环境检查来源。 -它不会把 `.env` 自动传给子进程。 +AgentSeek 使用 `.env` 检查模板声明的生命周期环境需求。在 `agentseek dev` +期间,它也会传给长运行子进程;显式导出的 shell 变量优先。 ## 检查并运行 diff --git a/docs/guides/choose-template.md b/docs/guides/choose-template.md index 559cc0bd..c87a78e2 100644 --- a/docs/guides/choose-template.md +++ b/docs/guides/choose-template.md @@ -36,7 +36,7 @@ generated app code is Bub, DeepAgents, LangChain, or LangGraph shaped. | Run a DeepAgents content workflow | `deepagents/content-builder` | It includes brand memory, skills, subagents, image generation, and streamed UI. | | Build a sandbox-backed coding agent | `deepagents/sandbox` | It uses `create_deep_agent(...)` with Daytona by default and keeps the charged LangSmith Sandbox as an alternative. | | Build a LangChain AG-UI app | `langchain/default` | It keeps the LangChain `create_agent(...)` shape and binds it through AgentSeek. | -| Start with a pure LangGraph-style chat UI | `langchain/markdown-messages` | It uses `langgraph dev`, `@langchain/react`, and markdown message rendering. | +| Start with a pure LangGraph-style chat UI | `langchain/markdown-messages` | It uses `agentseek-api dev`, `@langchain/react`, and markdown message rendering. | | Build RAG over OceanBase seekdb | `langchain/agentic-rag` | It includes an agentic retrieval tool, ingest command, frontend, and OceanBase seekdb setup. | | Learn and inspect hybrid retrieval behavior | `langchain/agentic-rag-hybrid` | It includes image ingestion, vector/sparse/full-text/metadata modes, a guided starter pack, visual compare UI, and optional Phoenix traces. | | Connect to a remote LangGraph service | `langchain/cli-remote` | It bridges a remote LangGraph agent through `LangGraphClientRunnable`. | diff --git a/docs/guides/choose-template.zh.md b/docs/guides/choose-template.zh.md index cf788454..2019d87e 100644 --- a/docs/guides/choose-template.zh.md +++ b/docs/guides/choose-template.zh.md @@ -36,7 +36,7 @@ DeepAgents、LangChain 或 LangGraph 的形态。 | 运行 DeepAgents 内容工作流 | `deepagents/content-builder` | 它包含品牌记忆、skills、subagents、图像生成和 streamed UI。 | | 构建 sandbox coding agent | `deepagents/sandbox` | 它使用 `create_deep_agent(...)`,默认接入 Daytona,并保留收费的 LangSmith Sandbox 作为备选。 | | 构建 LangChain AG-UI 应用 | `langchain/default` | 它保留 LangChain `create_agent(...)` 形态,并通过 AgentSeek 接入。 | -| 从纯 LangGraph 风格 chat UI 开始 | `langchain/markdown-messages` | 它使用 `langgraph dev`、`@langchain/react` 和 Markdown 消息渲染。 | +| 从纯 LangGraph 风格 chat UI 开始 | `langchain/markdown-messages` | 它使用 `agentseek-api dev`、`@langchain/react` 和 Markdown 消息渲染。 | | 基于 OceanBase seekdb 构建 RAG | `langchain/agentic-rag` | 它包含 agentic retrieval tool、ingest command、frontend 和 OceanBase seekdb 设置。 | | 学习并观察混合检索效果 | `langchain/agentic-rag-hybrid` | 它包含图片导入、向量/稀疏/全文/元数据模式、内置 starter pack、可视化对比 UI 和可选 Phoenix traces。 | | 连接远程 LangGraph 服务 | `langchain/cli-remote` | 它通过 `LangGraphClientRunnable` 桥接远程 LangGraph agent。 | diff --git a/docs/guides/create-template.zh.md b/docs/guides/create-template.zh.md index 86fdad2c..c9bfed36 100644 --- a/docs/guides/create-template.zh.md +++ b/docs/guides/create-template.zh.md @@ -88,7 +88,8 @@ AGENTSEEK_API_BASE= 应用在多个原生 provider adapter 之间切换时,增加 `AGENTSEEK_MODEL_PROVIDER`。只有所选 SDK 确实要求时,才增加 provider 专属密钥。文档必须说明运行时代码如何映射别名,以及冲突时谁优先。 -在 lifecycle 文件的 `[env.*]` 中声明同一组必需名称。AgentSeek 用这些声明检查就绪状态,不会把 `.env` 注入子进程。 +在 lifecycle 文件的 `[env.*]` 中声明同一组必需名称。AgentSeek 用这些声明检查 +就绪状态;`agentseek dev` 会把项目 `.env` 传给长运行子进程,shell 变量优先。 ## 5. 定义生命周期 diff --git a/docs/reference/lifecycle-spec.zh.md b/docs/reference/lifecycle-spec.zh.md index d904d47a..8eeb436a 100644 --- a/docs/reference/lifecycle-spec.zh.md +++ b/docs/reference/lifecycle-spec.zh.md @@ -91,7 +91,7 @@ command = ["npm", "install", "--prefix", "frontend"] | 段落 | 作用 | | --- | --- | -| `env_file` | 可选项目本地 env 文件,只用于声明的环境检查。它不会注入子进程。 | +| `env_file` | 可选项目本地 env 文件,用于声明的环境检查和 `agentseek dev` 子进程。shell 变量优先。 | | `tools` | 项目需要的可执行文件。 | | `paths` | 必需的本地文件或目录。 | | `env.` | AgentSeek 应检查的环境变量。默认值优先级低于 `env_file` 和 shell 变量。 | @@ -111,9 +111,9 @@ AgentSeek 从生命周期默认值、可选 `env_file` 和当前进程环境检 lifecycle default < env_file < shell environment ``` -只有 `[env.]` 下声明的 key 及其 aliases 会从 `env_file` 读取。 -模板不需要声明项目可能使用的每一个运行时变量。AgentSeek 不会把 env 文件或 -生命周期默认值传给子进程。 +只有 `[env.]` 下声明的 key 及其 aliases 会从 `env_file` 读取以检查就绪。 +模板不需要声明项目可能使用的每一个运行时变量。`agentseek dev` 会把项目 env +文件传给长运行子进程,当前 shell 环境最后应用;生命周期默认值不会注入子进程。 ## 生命周期 v1 第一阶段范围 diff --git a/docs/reference/template-authoring-contract.md b/docs/reference/template-authoring-contract.md index 2d42d0e8..63f20f3c 100644 --- a/docs/reference/template-authoring-contract.md +++ b/docs/reference/template-authoring-contract.md @@ -88,8 +88,10 @@ lifecycle default < env_file < shell environment ``` Lifecycle defaults and `.env` values validate readiness. AgentSeek does not -inject them into child processes. Process commands must load their runtime -environment themselves. +inject lifecycle defaults into child processes. The project `env_file` is +passed to long-running `agentseek dev` child processes, and shell variables +take precedence. Process commands may load any additional runtime +configuration themselves. ## Task Names diff --git a/docs/reference/template-authoring-contract.zh.md b/docs/reference/template-authoring-contract.zh.md index 3ce80f1a..57132905 100644 --- a/docs/reference/template-authoring-contract.zh.md +++ b/docs/reference/template-authoring-contract.zh.md @@ -79,7 +79,8 @@ sources: lifecycle default < env_file < shell environment ``` -生命周期默认值和 `.env` 只用于检查就绪状态。AgentSeek 不会把它们注入子进程,process command 必须自行加载运行环境。 +生命周期默认值只用于检查就绪状态,不会注入子进程。`agentseek dev` 会把项目 +`.env` 传给长运行子进程,且 shell 变量优先;process command 仍可自行加载额外运行配置。 ## Task 命名 diff --git a/src/agentseek/cli/lifecycle/core.py b/src/agentseek/cli/lifecycle/core.py index 1a41f5a3..262267aa 100644 --- a/src/agentseek/cli/lifecycle/core.py +++ b/src/agentseek/cli/lifecycle/core.py @@ -22,6 +22,7 @@ from duty._internal.collection import Duty from pydantic import Field, create_model from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings.sources.providers.dotenv import dotenv_values from agentseek.cli.lifecycle.errors import ( LifecycleNotFoundError, @@ -514,30 +515,15 @@ def _process_environment(project: LifecycleProject) -> dict[str, str]: """ env_file = _env_file_path(project) - environment = _read_env_file(env_file) if env_file is not None else {} + environment = ( + {key: value for key, value in dotenv_values(env_file).items() if value is not None} + if env_file is not None + else {} + ) environment.update(os.environ) return environment -def _read_env_file(path: Path) -> dict[str, str]: - """Read the simple KEY=VALUE dotenv form used by generated templates.""" - values: dict[str, str] = {} - for raw_line in path.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - if line.startswith("export "): - line = line[7:].lstrip() - key, separator, value = line.partition("=") - if not separator or not key.isidentifier(): - continue - value = value.strip() - if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: - value = value[1:-1] - values[key] = value - return values - - def _spawn_process(process: ProcessV1 | ProcessV2, *, project: LifecycleProject) -> ManagedProcess: executable = shutil.which(process.command[0]) if executable is None: diff --git a/src/agentseek/cli/lifecycle/discovery.py b/src/agentseek/cli/lifecycle/discovery.py index 44ac251e..1bdeb170 100644 --- a/src/agentseek/cli/lifecycle/discovery.py +++ b/src/agentseek/cli/lifecycle/discovery.py @@ -671,7 +671,6 @@ def _v1_postconditions_hold(project: NormalizedLifecycleProject) -> bool: or service.kind is not None or service.display is not None or service.primary is not None - or service.tech is not None or service.providers or service.check_ids or service.links diff --git a/src/agentseek/cli/lifecycle/normalize.py b/src/agentseek/cli/lifecycle/normalize.py index e453690c..3753cf5f 100644 --- a/src/agentseek/cli/lifecycle/normalize.py +++ b/src/agentseek/cli/lifecycle/normalize.py @@ -141,7 +141,7 @@ def _v1_services_and_checks( kind=None, display=None, primary=None, - tech=None, + tech=service.tech, ) ) check_targets: dict[str, str | None] = {} diff --git a/tests/cli_commands/test_lifecycle.py b/tests/cli_commands/test_lifecycle.py index baef7832..15abe2ad 100644 --- a/tests/cli_commands/test_lifecycle.py +++ b/tests/cli_commands/test_lifecycle.py @@ -599,7 +599,7 @@ def fake_call(command: object, *, cwd: object, **kwargs: Any) -> int: def test_dev_child_process_inherits_env_file_with_shell_precedence(tmp_path: Path, monkeypatch) -> None: _write_lifecycle_spec(tmp_path) (tmp_path / ".env").write_text( - "SEEKDB_URL=mysql+aiomysql://dotenv.example/test\nDOTENV_ONLY=from-dotenv\nOVERRIDE=from-dotenv\n", + 'SEEKDB_URL=mysql+aiomysql://dotenv.example/test\nDOTENV_ONLY="from dotenv # value\\nnext"\nOVERRIDE=from-dotenv # comment\nexport EXPORTED=value\n', encoding="utf-8", ) captured_child_environ: dict[str, str] | None = None @@ -625,11 +625,37 @@ def fake_popen(command: object, *, cwd: object, env: dict[str, str], **kwargs: o assert captured_child_environ is not None assert captured_child_environ["SEEKDB_URL"] == "mysql+aiomysql://dotenv.example/test" - assert captured_child_environ["DOTENV_ONLY"] == "from-dotenv" + assert captured_child_environ["DOTENV_ONLY"] == "from dotenv # value\nnext" + assert captured_child_environ["EXPORTED"] == "value" assert captured_child_environ["OVERRIDE"] == "from-shell" assert "BUB_SECRET" not in captured_child_environ +def test_dev_child_process_applies_dotenv_values_to_a_real_process(tmp_path: Path, monkeypatch) -> None: + _write_lifecycle_spec(tmp_path) + (tmp_path / ".env").write_text( + 'CHILD_VALUE="from dotenv # value\\nnext"\n', + encoding="utf-8", + ) + output = tmp_path / "child-value.txt" + monkeypatch.chdir(tmp_path) + project = lifecycle_core.discover_lifecycle_project(tmp_path) + process = project.spec.processes["web"].model_copy( + update={ + "command": ( + sys.executable, + "-c", + "from pathlib import Path; import os; Path('child-value.txt').write_text(os.environ['CHILD_VALUE'])", + ) + } + ) + + child = lifecycle_core._spawn_process(process, project=project) + + assert child.wait(timeout=5) == 0 + assert output.read_text(encoding="utf-8") == "from dotenv # value\nnext" + + @pytest.mark.parametrize("command", (["info"], ["doctor"])) def test_v2_operational_path_env_file_symlink_swap_rejects_before_file_access( tmp_path: Path, diff --git a/tests/cli_commands/test_lifecycle_json.py b/tests/cli_commands/test_lifecycle_json.py index fb0ea1a6..a16ce175 100644 --- a/tests/cli_commands/test_lifecycle_json.py +++ b/tests/cli_commands/test_lifecycle_json.py @@ -101,6 +101,7 @@ def _write_representative_v1_project(root: Path) -> None: [services.api] url = "http://user:password@127.0.0.1:8000/private" +tech = "agentseek-api" [processes.app] command = ["python", "PROCESS_SECRET_MUST_NOT_APPEAR"] @@ -172,7 +173,7 @@ def test_info_json_emits_exact_representative_v1_contract(tmp_path: Path, monkey '{"project":{"template":null,"name":"Legacy Project","description":null,"guide":null},' '"metadata_complete":false,"environment":[],"services":' '[{"id":"api","name":null,"description":null,"url":null,"kind":null,"display":null,' - '"primary":null,"tech":null,"providers":[],"check_ids":[],"links":[]}],' + '"primary":null,"tech":"agentseek-api","providers":[],"check_ids":[],"links":[]}],' '"checks":[{"id":"probe","service_id":null,"type":"http","target":null,"state":"not_run"}],' '"tasks":[{"id":"setup","description":null,"starts":[],"stops":[]}],"actions":[],"warnings":' '[{"code":"lifecycle_v1_metadata_incomplete","message":"Lifecycle v1 metadata is incomplete.",' From 8eee57077ea190f60727798e0216069caed46474 Mon Sep 17 00:00:00 2001 From: chuixue <2960494764@qq.com> Date: Wed, 12 Aug 2026 12:02:36 +0800 Subject: [PATCH 08/21] fix: harden lifecycle environment compatibility --- .github/workflows/main.yml | 21 +++++++++++++++++++++ docs/get-started/index.md | 3 ++- docs/get-started/index.zh.md | 2 +- docs/guides/choose-template.md | 2 +- docs/guides/choose-template.zh.md | 2 +- docs/reference/lifecycle-spec.md | 3 +++ docs/reference/lifecycle-spec.zh.md | 3 +++ pyproject.toml | 1 + src/agentseek/cli/lifecycle/core.py | 4 ++-- tests/cli_commands/test_lifecycle.py | 26 ++++++++++++++++++++++++++ tests/test_docs_lifecycle.py | 12 ++++++++++++ uv.lock | 2 ++ 12 files changed, 75 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index df69b1fa..1646b00a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,6 +25,27 @@ jobs: - name: Run lint and type check run: make lint typecheck + minimum-supported-cli: + name: Minimum supported CLI import + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up the environment + uses: ./.github/actions/setup-python-env + with: + python-version: "3.13" + + - name: Run CLI with the declared dependency floor + run: | + set -euo pipefail + export UV_CACHE_DIR="$(mktemp -d)/uv-cache" + uv run --python 3.13 --isolated --no-project \ + --with-editable . \ + --with pydantic-settings==2.0.0 \ + agentseek --help + cross-platform-tests-and-type-check: runs-on: ${{ matrix.os }} strategy: diff --git a/docs/get-started/index.md b/docs/get-started/index.md index 4688cf0e..baaa150e 100644 --- a/docs/get-started/index.md +++ b/docs/get-started/index.md @@ -46,7 +46,8 @@ Set the model and provider credentials required by the selected template in `.env` is used by AgentSeek only for lifecycle environment checks declared by the template. During `agentseek dev`, it is passed to long-running child -processes; exported shell variables take precedence. +processes; non-empty exported shell variables take precedence, while an empty +exported value is treated as unset. ## Check and run diff --git a/docs/get-started/index.zh.md b/docs/get-started/index.zh.md index 08324e20..e1c9e22e 100644 --- a/docs/get-started/index.zh.md +++ b/docs/get-started/index.zh.md @@ -42,7 +42,7 @@ agentseek task frontend 在 `.env` 或运行 AgentSeek 的环境里,设置所选模板需要的模型和 provider 凭证。 AgentSeek 使用 `.env` 检查模板声明的生命周期环境需求。在 `agentseek dev` -期间,它也会传给长运行子进程;显式导出的 shell 变量优先。 +期间,它也会传给长运行子进程;非空的显式 shell 变量优先,空值视为未设置。 ## 检查并运行 diff --git a/docs/guides/choose-template.md b/docs/guides/choose-template.md index c87a78e2..559cc0bd 100644 --- a/docs/guides/choose-template.md +++ b/docs/guides/choose-template.md @@ -36,7 +36,7 @@ generated app code is Bub, DeepAgents, LangChain, or LangGraph shaped. | Run a DeepAgents content workflow | `deepagents/content-builder` | It includes brand memory, skills, subagents, image generation, and streamed UI. | | Build a sandbox-backed coding agent | `deepagents/sandbox` | It uses `create_deep_agent(...)` with Daytona by default and keeps the charged LangSmith Sandbox as an alternative. | | Build a LangChain AG-UI app | `langchain/default` | It keeps the LangChain `create_agent(...)` shape and binds it through AgentSeek. | -| Start with a pure LangGraph-style chat UI | `langchain/markdown-messages` | It uses `agentseek-api dev`, `@langchain/react`, and markdown message rendering. | +| Start with a pure LangGraph-style chat UI | `langchain/markdown-messages` | It uses `langgraph dev`, `@langchain/react`, and markdown message rendering. | | Build RAG over OceanBase seekdb | `langchain/agentic-rag` | It includes an agentic retrieval tool, ingest command, frontend, and OceanBase seekdb setup. | | Learn and inspect hybrid retrieval behavior | `langchain/agentic-rag-hybrid` | It includes image ingestion, vector/sparse/full-text/metadata modes, a guided starter pack, visual compare UI, and optional Phoenix traces. | | Connect to a remote LangGraph service | `langchain/cli-remote` | It bridges a remote LangGraph agent through `LangGraphClientRunnable`. | diff --git a/docs/guides/choose-template.zh.md b/docs/guides/choose-template.zh.md index 2019d87e..cf788454 100644 --- a/docs/guides/choose-template.zh.md +++ b/docs/guides/choose-template.zh.md @@ -36,7 +36,7 @@ DeepAgents、LangChain 或 LangGraph 的形态。 | 运行 DeepAgents 内容工作流 | `deepagents/content-builder` | 它包含品牌记忆、skills、subagents、图像生成和 streamed UI。 | | 构建 sandbox coding agent | `deepagents/sandbox` | 它使用 `create_deep_agent(...)`,默认接入 Daytona,并保留收费的 LangSmith Sandbox 作为备选。 | | 构建 LangChain AG-UI 应用 | `langchain/default` | 它保留 LangChain `create_agent(...)` 形态,并通过 AgentSeek 接入。 | -| 从纯 LangGraph 风格 chat UI 开始 | `langchain/markdown-messages` | 它使用 `agentseek-api dev`、`@langchain/react` 和 Markdown 消息渲染。 | +| 从纯 LangGraph 风格 chat UI 开始 | `langchain/markdown-messages` | 它使用 `langgraph dev`、`@langchain/react` 和 Markdown 消息渲染。 | | 基于 OceanBase seekdb 构建 RAG | `langchain/agentic-rag` | 它包含 agentic retrieval tool、ingest command、frontend 和 OceanBase seekdb 设置。 | | 学习并观察混合检索效果 | `langchain/agentic-rag-hybrid` | 它包含图片导入、向量/稀疏/全文/元数据模式、内置 starter pack、可视化对比 UI 和可选 Phoenix traces。 | | 连接远程 LangGraph 服务 | `langchain/cli-remote` | 它通过 `LangGraphClientRunnable` 桥接远程 LangGraph agent。 | diff --git a/docs/reference/lifecycle-spec.md b/docs/reference/lifecycle-spec.md index d7f52dee..265224bf 100644 --- a/docs/reference/lifecycle-spec.md +++ b/docs/reference/lifecycle-spec.md @@ -112,6 +112,9 @@ AgentSeek checks environment requirements from lifecycle defaults, the optional lifecycle default < env_file < shell environment ``` +An empty exported shell value is treated as unset, so the next non-empty +source is used consistently by readiness checks and spawned child processes. + Only keys declared under `[env.]` and their aliases are used for readiness checks. During `agentseek dev`, values from the project `env_file` are passed to long-running child processes, with the current shell environment diff --git a/docs/reference/lifecycle-spec.zh.md b/docs/reference/lifecycle-spec.zh.md index 8eeb436a..5eabfebb 100644 --- a/docs/reference/lifecycle-spec.zh.md +++ b/docs/reference/lifecycle-spec.zh.md @@ -111,6 +111,9 @@ AgentSeek 从生命周期默认值、可选 `env_file` 和当前进程环境检 lifecycle default < env_file < shell environment ``` +显式导出的空 shell 值视为未设置,因此就绪检查和启动的子进程都会一致地 +使用下一个非空来源。 + 只有 `[env.]` 下声明的 key 及其 aliases 会从 `env_file` 读取以检查就绪。 模板不需要声明项目可能使用的每一个运行时变量。`agentseek dev` 会把项目 env 文件传给长运行子进程,当前 shell 环境最后应用;生命周期默认值不会注入子进程。 diff --git a/pyproject.toml b/pyproject.toml index ef14def4..3758f11a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ classifiers = [ dependencies = [ "bub==0.3.9", "cookiecutter>=2.5", + "python-dotenv>=1.0", "duty>=1.9", "filelock>=3.20.3", "jinja2>=3.1", diff --git a/src/agentseek/cli/lifecycle/core.py b/src/agentseek/cli/lifecycle/core.py index 262267aa..329a26c7 100644 --- a/src/agentseek/cli/lifecycle/core.py +++ b/src/agentseek/cli/lifecycle/core.py @@ -18,11 +18,11 @@ import httpx import typer +from dotenv import dotenv_values from duty import Collection from duty._internal.collection import Duty from pydantic import Field, create_model from pydantic_settings import BaseSettings, SettingsConfigDict -from pydantic_settings.sources.providers.dotenv import dotenv_values from agentseek.cli.lifecycle.errors import ( LifecycleNotFoundError, @@ -520,7 +520,7 @@ def _process_environment(project: LifecycleProject) -> dict[str, str]: if env_file is not None else {} ) - environment.update(os.environ) + environment.update({key: value for key, value in os.environ.items() if value}) return environment diff --git a/tests/cli_commands/test_lifecycle.py b/tests/cli_commands/test_lifecycle.py index 15abe2ad..cb73c43a 100644 --- a/tests/cli_commands/test_lifecycle.py +++ b/tests/cli_commands/test_lifecycle.py @@ -656,6 +656,32 @@ def test_dev_child_process_applies_dotenv_values_to_a_real_process(tmp_path: Pat assert output.read_text(encoding="utf-8") == "from dotenv # value\nnext" +def test_empty_shell_value_falls_back_to_dotenv_for_readiness_and_spawned_child(tmp_path: Path, monkeypatch) -> None: + _write_v2_lifecycle_spec(tmp_path, env_file=".env") + (tmp_path / ".env").write_text("API_KEY=from-dotenv\n", encoding="utf-8") + output = tmp_path / "child-api-key.txt" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("API_KEY", "") + + project = lifecycle_core.discover_lifecycle_project(tmp_path) + process = project.spec.processes["web"].model_copy( + update={ + "command": ( + sys.executable, + "-c", + "from pathlib import Path; import os; " + "Path('child-api-key.txt').write_text(os.environ['API_KEY'], encoding='utf-8')", + ) + } + ) + + assert lifecycle_core._env_requirement_source(project, "API_KEY", project.spec.env["API_KEY"]) == ".env" + child = lifecycle_core._spawn_process(process, project=project) + + assert child.wait(timeout=5) == 0 + assert output.read_text(encoding="utf-8") == "from-dotenv" + + @pytest.mark.parametrize("command", (["info"], ["doctor"])) def test_v2_operational_path_env_file_symlink_swap_rejects_before_file_access( tmp_path: Path, diff --git a/tests/test_docs_lifecycle.py b/tests/test_docs_lifecycle.py index 9403f10f..870c57dc 100644 --- a/tests/test_docs_lifecycle.py +++ b/tests/test_docs_lifecycle.py @@ -382,3 +382,15 @@ def test_lifecycle_references_describe_authored_v2_loading(reference: Path) -> N "`agentseek-ai/agentseek-templates`" in row and "`version = 2`" in row for row in table_rows ) assert has_v2_catalog_row, reference + + +@pytest.mark.parametrize( + "guide", + (ROOT / "docs" / "guides" / "choose-template.md", ROOT / "docs" / "guides" / "choose-template.zh.md"), +) +def test_choose_template_guides_match_locked_catalog_runtime(guide: Path) -> None: + """The guides must describe the lifecycle command shipped by the locked catalog.""" + text = guide.read_text(encoding="utf-8") + + assert "langgraph dev" in text, guide + assert "agentseek-api dev" not in text, guide diff --git a/uv.lock b/uv.lock index e67d4b8e..3ec7be51 100644 --- a/uv.lock +++ b/uv.lock @@ -50,6 +50,7 @@ dependencies = [ { name = "logfire" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "python-dotenv" }, { name = "typer" }, ] @@ -92,6 +93,7 @@ requires-dist = [ { name = "logfire", specifier = ">=4.33.0" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, + { name = "python-dotenv", specifier = ">=1.0" }, { name = "typer", specifier = ">=0.12" }, ] From 85e7e383fe74f96b46a3a17ee5c3dd6ee8235b90 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sun, 16 Aug 2026 23:55:27 +0800 Subject: [PATCH 09/21] refactor: add immutable lifecycle environment snapshot --- pyproject.toml | 2 +- src/agentseek/cli/lifecycle/dotenv_adapter.py | 54 ++++++ src/agentseek/cli/lifecycle/environment.py | 80 +++++++++ .../test_lifecycle_environment.py | 161 ++++++++++++++++++ uv.lock | 2 +- 5 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 src/agentseek/cli/lifecycle/dotenv_adapter.py create mode 100644 src/agentseek/cli/lifecycle/environment.py create mode 100644 tests/cli_commands/test_lifecycle_environment.py diff --git a/pyproject.toml b/pyproject.toml index 3758f11a..b0c4c928 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ dependencies = [ "bub==0.3.9", "cookiecutter>=2.5", - "python-dotenv>=1.0", + "python-dotenv>=1.0,<1.3", "duty>=1.9", "filelock>=3.20.3", "jinja2>=3.1", diff --git a/src/agentseek/cli/lifecycle/dotenv_adapter.py b/src/agentseek/cli/lifecycle/dotenv_adapter.py new file mode 100644 index 00000000..d1305001 --- /dev/null +++ b/src/agentseek/cli/lifecycle/dotenv_adapter.py @@ -0,0 +1,54 @@ +"""Strict lifecycle adapter around the bounded python-dotenv implementation APIs.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path + +from dotenv.parser import parse_stream +from dotenv.variables import parse_variables + + +class LifecycleDotenvError(ValueError): + def __init__(self, path: Path, message: str, *, line: int | None = None) -> None: + self.path = path + self.line = line + location = f" at line {line}" if line is not None else "" + super().__init__(f"Lifecycle env file '{path}' {message}{location}.") + + +def parse_lifecycle_dotenv( + path: Path, + *, + ambient: Mapping[str, str], +) -> dict[str, str | None]: + try: + with path.open(encoding="utf-8") as stream: + bindings = list(parse_stream(stream)) + except FileNotFoundError as exc: + raise LifecycleDotenvError(path, "does not exist") from exc + except UnicodeDecodeError as exc: + raise LifecycleDotenvError(path, "is not valid UTF-8") from exc + except OSError as exc: + reason = exc.strerror or type(exc).__name__ + raise LifecycleDotenvError(path, f"could not be read: {reason}") from exc + + malformed = next((binding for binding in bindings if binding.error), None) + if malformed is not None: + raise LifecycleDotenvError( + path, + "has malformed dotenv syntax", + line=malformed.original.line, + ) + + context: dict[str, str | None] = dict(ambient) + values: dict[str, str | None] = {} + for binding in bindings: + if binding.key is None: + continue + value = ( + None if binding.value is None else "".join(atom.resolve(context) for atom in parse_variables(binding.value)) + ) + values[binding.key] = value + context[binding.key] = value + return values diff --git a/src/agentseek/cli/lifecycle/environment.py b/src/agentseek/cli/lifecycle/environment.py new file mode 100644 index 00000000..00107958 --- /dev/null +++ b/src/agentseek/cli/lifecycle/environment.py @@ -0,0 +1,80 @@ +"""Immutable environment boundary for AgentSeek-managed lifecycle processes.""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass, field +from enum import StrEnum +from pathlib import Path +from types import MappingProxyType + +from agentseek.cli.lifecycle.dotenv_adapter import ( + LifecycleDotenvError, + parse_lifecycle_dotenv, +) + +_MISMATCHED_SNAPSHOT_KEYS_ERROR = "Snapshot values and origins must contain the same keys." + + +class EnvironmentOrigin(StrEnum): + """Value-free provenance retained by the lifecycle owner.""" + + ENV_FILE = "env_file" + LAUNCH_ENVIRONMENT = "launch_environment" + + +@dataclass(frozen=True) +class LifecycleEnvironmentSnapshot: + """Resolved child values that cannot be changed after construction.""" + + values: Mapping[str, str] = field(repr=False) + origins: Mapping[str, EnvironmentOrigin] + + def __post_init__(self) -> None: + values = dict(self.values) + origins = dict(self.origins) + if values.keys() != origins.keys(): + raise ValueError(_MISMATCHED_SNAPSHOT_KEYS_ERROR) + object.__setattr__(self, "values", MappingProxyType(values)) + object.__setattr__(self, "origins", MappingProxyType(origins)) + + def as_subprocess_env(self) -> dict[str, str]: + """Return an isolated mutable mapping accepted by subprocess APIs.""" + + return dict(self.values) + + +def resolve_lifecycle_environment( + *, + env_file: Path | None, + launch_environment: Mapping[str, str] | None = None, +) -> LifecycleEnvironmentSnapshot: + """Resolve dotenv plus the non-empty launch overlay exactly once.""" + + captured_launch = dict(os.environ if launch_environment is None else launch_environment) + file_values = parse_lifecycle_dotenv(env_file, ambient=captured_launch) if env_file is not None else {} + values: dict[str, str] = {} + origins: dict[str, EnvironmentOrigin] = {} + + for key, value in file_values.items(): + if value is None: + continue + values[key] = value + origins[key] = EnvironmentOrigin.ENV_FILE + + for key, value in captured_launch.items(): + if value == "": + continue + values[key] = value + origins[key] = EnvironmentOrigin.LAUNCH_ENVIRONMENT + + return LifecycleEnvironmentSnapshot(values=values, origins=origins) + + +__all__ = [ + "EnvironmentOrigin", + "LifecycleDotenvError", + "LifecycleEnvironmentSnapshot", + "resolve_lifecycle_environment", +] diff --git a/tests/cli_commands/test_lifecycle_environment.py b/tests/cli_commands/test_lifecycle_environment.py new file mode 100644 index 00000000..5ed169af --- /dev/null +++ b/tests/cli_commands/test_lifecycle_environment.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +from typing import cast + +import pytest + +import agentseek.cli.lifecycle.environment as environment_module +from agentseek.cli.lifecycle.environment import ( + EnvironmentOrigin, + LifecycleDotenvError, + LifecycleEnvironmentSnapshot, + resolve_lifecycle_environment, +) + + +def test_snapshot_applies_only_nonempty_launch_values_over_dotenv(tmp_path, monkeypatch) -> None: + env_file = tmp_path / ".env" + env_file.write_text( + "OVERRIDE=from-dotenv\nEMPTY_FALLBACK=from-dotenv\nDOTENV_ONLY=dotenv\n", + encoding="utf-8", + ) + monkeypatch.setenv("OVERRIDE", "from-shell") + monkeypatch.setenv("EMPTY_FALLBACK", "") + monkeypatch.setenv("EMPTY_LAUNCH_ONLY", "") + + snapshot = resolve_lifecycle_environment(env_file=env_file) + + assert snapshot.values["OVERRIDE"] == "from-shell" + assert snapshot.origins["OVERRIDE"] is EnvironmentOrigin.LAUNCH_ENVIRONMENT + assert snapshot.values["EMPTY_FALLBACK"] == "from-dotenv" + assert snapshot.origins["EMPTY_FALLBACK"] is EnvironmentOrigin.ENV_FILE + assert snapshot.values["DOTENV_ONLY"] == "dotenv" + assert "EMPTY_LAUNCH_ONLY" not in snapshot.values + + +def test_snapshot_preserves_dotenv_empty_and_omits_valueless_binding(tmp_path, monkeypatch) -> None: + env_file = tmp_path / ".env" + env_file.write_text("EXPLICIT_EMPTY=\nVALUELESS\n", encoding="utf-8") + monkeypatch.delenv("EXPLICIT_EMPTY", raising=False) + monkeypatch.delenv("VALUELESS", raising=False) + + snapshot = resolve_lifecycle_environment(env_file=env_file) + + assert "EXPLICIT_EMPTY" in snapshot.values + assert snapshot.values["EXPLICIT_EMPTY"] == "" + assert snapshot.origins["EXPLICIT_EMPTY"] is EnvironmentOrigin.ENV_FILE + assert "VALUELESS" not in snapshot.values + assert "VALUELESS" not in snapshot.origins + + +def test_snapshot_uses_file_local_physical_order_and_parses_once(tmp_path, monkeypatch) -> None: + env_file = tmp_path / ".env" + env_file.write_text("BASE=file\nDEPENDENT=${BASE}/v1\n", encoding="utf-8") + monkeypatch.setenv("BASE", "shell") + calls: list[object] = [] + real_parse = environment_module.parse_lifecycle_dotenv + + def counting_parse(path, *, ambient): + calls.append(path) + return real_parse(path, ambient=ambient) + + monkeypatch.setattr(environment_module, "parse_lifecycle_dotenv", counting_parse) + + snapshot = resolve_lifecycle_environment(env_file=env_file) + + assert calls == [env_file] + assert snapshot.values["BASE"] == "shell" + assert snapshot.values["DEPENDENT"] == "file/v1" + assert snapshot.origins["DEPENDENT"] is EnvironmentOrigin.ENV_FILE + + +def test_snapshot_is_immutable_and_returns_defensive_process_copies() -> None: + source_values = {"KEY": "original"} + source_origins = {"KEY": EnvironmentOrigin.ENV_FILE} + snapshot = LifecycleEnvironmentSnapshot( + values=source_values, + origins=source_origins, + ) + source_values["KEY"] = "source-mutated" + source_origins["KEY"] = EnvironmentOrigin.LAUNCH_ENVIRONMENT + + with pytest.raises(TypeError): + cast("dict[str, str]", snapshot.values)["KEY"] = "mutated" + with pytest.raises(TypeError): + cast("dict[str, EnvironmentOrigin]", snapshot.origins)["KEY"] = EnvironmentOrigin.LAUNCH_ENVIRONMENT + + child_environment = snapshot.as_subprocess_env() + child_environment["KEY"] = "child-only" + + assert snapshot.values["KEY"] == "original" + assert snapshot.origins["KEY"] is EnvironmentOrigin.ENV_FILE + assert snapshot.as_subprocess_env()["KEY"] == "original" + + +def test_snapshot_repr_never_contains_resolved_values() -> None: + snapshot = LifecycleEnvironmentSnapshot( + values={"API_KEY": "secret-sentinel-7f3a"}, + origins={"API_KEY": EnvironmentOrigin.LAUNCH_ENVIRONMENT}, + ) + + rendered = repr(snapshot) + + assert "secret-sentinel-7f3a" not in rendered + assert "API_KEY" in rendered + assert "launch_environment" in rendered + + +def test_snapshot_rejects_mismatched_value_and_origin_keys() -> None: + with pytest.raises(ValueError, match="same keys"): + LifecycleEnvironmentSnapshot( + values={"VALUE_ONLY": "secret-sentinel"}, + origins={"ORIGIN_ONLY": EnvironmentOrigin.ENV_FILE}, + ) + + +def test_resolver_uses_captured_launch_mapping_when_live_environment_changes( + tmp_path, + monkeypatch, +) -> None: + env_file = tmp_path / ".env" + env_file.write_text("DEPENDENT=${BASE}/v1\n", encoding="utf-8") + monkeypatch.setenv("BASE", "captured") + real_parse = environment_module.parse_lifecycle_dotenv + + def mutate_after_capture(path, *, ambient): + monkeypatch.setenv("BASE", "changed-after-capture") + return real_parse(path, ambient=ambient) + + monkeypatch.setattr(environment_module, "parse_lifecycle_dotenv", mutate_after_capture) + + snapshot = resolve_lifecycle_environment(env_file=env_file) + + assert snapshot.values["BASE"] == "captured" + assert snapshot.values["DEPENDENT"] == "captured/v1" + + +@pytest.mark.parametrize("contents", ['BROKEN "value"\n', 'UNTERMINATED="value\n']) +def test_resolver_rejects_malformed_dotenv_without_partial_snapshot( + tmp_path, + contents, +) -> None: + env_file = tmp_path / ".env" + env_file.write_text("SECRET=must-not-leak\n" + contents + "AFTER=value\n", encoding="utf-8") + + with pytest.raises(LifecycleDotenvError) as raised: + resolve_lifecycle_environment(env_file=env_file, launch_environment={}) + + assert raised.value.line == 2 + assert "must-not-leak" not in str(raised.value) + + +def test_resolver_rejects_missing_and_invalid_utf8_sources(tmp_path) -> None: + with pytest.raises(LifecycleDotenvError, match="does not exist"): + resolve_lifecycle_environment( + env_file=tmp_path / "missing.env", + launch_environment={}, + ) + invalid = tmp_path / "invalid.env" + invalid.write_bytes(b"TOKEN=\xff\n") + with pytest.raises(LifecycleDotenvError, match="not valid UTF-8"): + resolve_lifecycle_environment(env_file=invalid, launch_environment={}) diff --git a/uv.lock b/uv.lock index 3ec7be51..957d8818 100644 --- a/uv.lock +++ b/uv.lock @@ -93,7 +93,7 @@ requires-dist = [ { name = "logfire", specifier = ">=4.33.0" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, - { name = "python-dotenv", specifier = ">=1.0" }, + { name = "python-dotenv", specifier = ">=1.0,<1.3" }, { name = "typer", specifier = ">=0.12" }, ] From 75413b99c85fe7187ba79f20650497a8b3d38150 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Mon, 17 Aug 2026 00:11:51 +0800 Subject: [PATCH 10/21] fix: reuse one environment snapshot for lifecycle dev --- src/agentseek/cli/commands/dev.py | 22 +- src/agentseek/cli/lifecycle/__init__.py | 10 + src/agentseek/cli/lifecycle/core.py | 165 ++++++++--- tests/cli_commands/test_dev_supervision.py | 6 +- tests/cli_commands/test_lifecycle.py | 328 ++++++++++++++++++--- 5 files changed, 449 insertions(+), 82 deletions(-) diff --git a/src/agentseek/cli/commands/dev.py b/src/agentseek/cli/commands/dev.py index e7a2700a..1d8ff3d1 100644 --- a/src/agentseek/cli/commands/dev.py +++ b/src/agentseek/cli/commands/dev.py @@ -6,7 +6,13 @@ import typer -from agentseek.cli.lifecycle import load_lifecycle_project, run_lifecycle_task +from agentseek.cli.lifecycle import ( + LifecycleDotenvError, + load_lifecycle_project, + resolve_project_environment, + run_lifecycle_task, +) +from agentseek.cli.lifecycle.errors import exit_project_error app = typer.Typer( name="dev", @@ -29,9 +35,17 @@ def dev( ) -> None: """Run the local app defined by the lifecycle spec.""" project = load_lifecycle_project() - if not skip_check and not dry_run: - run_lifecycle_task(project, "doctor", strict=True) - run_lifecycle_task(project, "dev", dry_run=dry_run) + if dry_run: + run_lifecycle_task(project, "dev", dry_run=True) + return + + try: + environment = resolve_project_environment(project) + except LifecycleDotenvError as exc: + exit_project_error("Invalid lifecycle environment.", str(exc)) + if not skip_check: + run_lifecycle_task(project, "doctor", strict=True, environment=environment) + run_lifecycle_task(project, "dev", dry_run=False, environment=environment) __all__ = ["app"] diff --git a/src/agentseek/cli/lifecycle/__init__.py b/src/agentseek/cli/lifecycle/__init__.py index e6e6e3f5..1f2a1545 100644 --- a/src/agentseek/cli/lifecycle/__init__.py +++ b/src/agentseek/cli/lifecycle/__init__.py @@ -4,10 +4,16 @@ LifecycleProject, lifecycle_spec_exists, load_lifecycle_project, + resolve_project_environment, run_lifecycle_task, run_task_cli, ) from agentseek.cli.lifecycle.discovery import NormalizationWarning, NormalizedLifecycleProject +from agentseek.cli.lifecycle.environment import ( + EnvironmentOrigin, + LifecycleDotenvError, + LifecycleEnvironmentSnapshot, +) from agentseek.cli.lifecycle.normalize import normalize_lifecycle from agentseek.cli.lifecycle.spec import ( LIFECYCLE_SPEC_FILE, @@ -23,12 +29,16 @@ "SUPPORTED_LIFECYCLE_VERSION", "SUPPORTED_LIFECYCLE_VERSIONS", "AuthoredLifecycleSpec", + "EnvironmentOrigin", + "LifecycleDotenvError", + "LifecycleEnvironmentSnapshot", "LifecycleProject", "NormalizationWarning", "NormalizedLifecycleProject", "lifecycle_spec_exists", "load_lifecycle_project", "normalize_lifecycle", + "resolve_project_environment", "run_lifecycle_task", "run_task_cli", ] diff --git a/src/agentseek/cli/lifecycle/core.py b/src/agentseek/cli/lifecycle/core.py index 329a26c7..8503fe7c 100644 --- a/src/agentseek/cli/lifecycle/core.py +++ b/src/agentseek/cli/lifecycle/core.py @@ -3,7 +3,6 @@ from __future__ import annotations import contextlib -import os import shlex import shutil import signal @@ -18,12 +17,17 @@ import httpx import typer -from dotenv import dotenv_values from duty import Collection from duty._internal.collection import Duty from pydantic import Field, create_model from pydantic_settings import BaseSettings, SettingsConfigDict +from agentseek.cli.lifecycle.environment import ( + EnvironmentOrigin, + LifecycleDotenvError, + LifecycleEnvironmentSnapshot, + resolve_lifecycle_environment, +) from agentseek.cli.lifecycle.errors import ( LifecycleNotFoundError, LifecycleTomlError, @@ -123,6 +127,8 @@ def run_lifecycle_task(project: LifecycleProject, name: str, **kwargs: object) - ) try: task.run(**kwargs) + except LifecycleDotenvError as exc: + exit_project_error("Invalid lifecycle environment.", str(exc)) except _UnsafeOperationalPathError as exc: exit_project_error( f"Invalid lifecycle {exc.field} path.", @@ -168,21 +174,34 @@ def _lifecycle_collection(project: LifecycleProject) -> Collection: Duty( name="info", description="Print project summary.", - function=lambda _ctx, verbose=False: print_info(project, verbose=verbose), + function=lambda _ctx, verbose=False, environment=None: print_info( + project, + verbose=verbose, + environment=environment, + ), ) ) collection.add( Duty( name="doctor", description="Check local project readiness.", - function=lambda _ctx, live=False, strict=False: doctor(project, live=live, strict=strict), + function=lambda _ctx, live=False, strict=False, environment=None: doctor( + project, + live=live, + strict=strict, + environment=environment, + ), ) ) collection.add( Duty( name="dev", description="Run local development.", - function=lambda _ctx, dry_run=False: dev(project, dry_run=dry_run), + function=lambda _ctx, dry_run=False, environment=None: dev( + project, + dry_run=dry_run, + environment=environment, + ), ) ) return collection @@ -217,7 +236,12 @@ def _display_name(name: str) -> str: return name.title() -def print_info(project: LifecycleProject, *, verbose: bool) -> None: +def print_info( + project: LifecycleProject, + *, + verbose: bool, + environment: LifecycleEnvironmentSnapshot | None = None, +) -> None: """Print a project summary derived from the lifecycle spec.""" spec = project.spec print("Project") @@ -238,7 +262,12 @@ def print_info(project: LifecycleProject, *, verbose: bool) -> None: present = env_file.is_file() print(f" Env file: {spec.env_file} ({'present' if present else 'missing'})") for name, requirement in spec.env.items(): - source = _env_requirement_source(project, name, requirement) + source = _env_requirement_source( + project, + name, + requirement, + environment=environment, + ) print(f" {name}: {f'set ({source})' if source else 'missing'}") print() if spec.tasks: @@ -255,9 +284,15 @@ def print_info(project: LifecycleProject, *, verbose: bool) -> None: _print_verbose_info(project) -def doctor(project: LifecycleProject, *, live: bool, strict: bool) -> None: +def doctor( + project: LifecycleProject, + *, + live: bool, + strict: bool, + environment: LifecycleEnvironmentSnapshot | None = None, +) -> None: """Run local readiness checks derived from the lifecycle spec.""" - results = _static_checks(project) + results = _static_checks(project, environment=environment) if live: results.extend(_live_checks(project)) _print_checks(results) @@ -267,7 +302,12 @@ def doctor(project: LifecycleProject, *, live: bool, strict: bool) -> None: raise SystemExit(1) -def dev(project: LifecycleProject, *, dry_run: bool) -> None: +def dev( + project: LifecycleProject, + *, + dry_run: bool, + environment: LifecycleEnvironmentSnapshot | None = None, +) -> None: """Start local development processes declared in the lifecycle spec.""" print("Startup plan") for name, process in project.spec.processes.items(): @@ -278,13 +318,14 @@ def dev(project: LifecycleProject, *, dry_run: bool) -> None: if dry_run: return - _ensure_required_inputs(project) + environment = environment if environment is not None else resolve_project_environment(project) + _ensure_required_inputs(project, environment=environment) for name, process in project.spec.processes.items(): _operational_path(project, process.cwd, allow_dot=True, field=f"processes.{name}.cwd") processes: list[ManagedProcess] = [] with _supervise_processes(processes): for process in project.spec.processes.values(): - processes.append(_spawn_process(process, project=project)) + processes.append(_spawn_process(process, project=project, environment=environment)) _wait_for_processes(processes) @@ -296,14 +337,18 @@ def _discover_spec(root: Path) -> tuple[Path, Path] | None: return None -def _static_checks(project: LifecycleProject) -> list[CheckResult]: +def _static_checks( + project: LifecycleProject, + *, + environment: LifecycleEnvironmentSnapshot | None = None, +) -> list[CheckResult]: checks = [ _check("ok" if project.path.is_file() else "fail", project.path.name, "Lifecycle spec is present."), ] checks.extend(_tool_checks(project.spec.required_tools)) checks.extend(_path_checks(project)) checks.extend(_env_file_checks(project)) - checks.extend(_env_checks(project)) + checks.extend(_env_checks(project, environment=environment)) checks.extend(_process_cwd_checks(project)) return checks @@ -354,10 +399,22 @@ def _env_file_checks(project: LifecycleProject) -> list[CheckResult]: ] -def _env_checks(project: LifecycleProject) -> list[CheckResult]: +def _env_checks( + project: LifecycleProject, + *, + environment: LifecycleEnvironmentSnapshot | None = None, +) -> list[CheckResult]: results: list[CheckResult] = [] for name, requirement in project.spec.env.items(): - configured = _env_requirement_source(project, name, requirement) is not None + configured = ( + _env_requirement_source( + project, + name, + requirement, + environment=environment, + ) + is not None + ) if not requirement.required and not configured: continue status = "ok" if configured else ("fail" if requirement.required else "ok") @@ -415,19 +472,41 @@ def _check_target(check: CheckV1 | CheckV2) -> bool: return 200 <= response.status_code < 400 -def _ensure_required_inputs(project: LifecycleProject) -> None: - failing = [item for item in _static_checks(project) if item.status == "fail"] +def _ensure_required_inputs( + project: LifecycleProject, + *, + environment: LifecycleEnvironmentSnapshot, +) -> None: + failing = [item for item in _static_checks(project, environment=environment) if item.status == "fail"] if failing: _print_checks(failing) exit_project_error("Project is not ready to run.", "Fix failing checks or use `agentseek doctor` for details.") -def _env_requirement_source(project: LifecycleProject, name: str, requirement: EnvRequirement) -> str | None: - environment = _env_settings_values(project, env_file=None, defaults=False) - if environment.get(name): +def _env_requirement_source( + project: LifecycleProject, + name: str, + requirement: EnvRequirement, + *, + environment: LifecycleEnvironmentSnapshot | None = None, +) -> str | None: + if environment is not None: + for key in requirement.keys(name): + if not environment.values.get(key): + continue + origin = environment.origins[key] + if origin is EnvironmentOrigin.LAUNCH_ENVIRONMENT: + return "environment" + return project.spec.env_file or "env_file" + if requirement.default: + return "default" + return None + + launch_values = _env_settings_values(project, env_file=None, defaults=False) + if launch_values.get(name): return "environment" - env_file = _env_settings_values(project, env_file=_env_file_path(project), defaults=False) - if env_file.get(name): + dotenv_values = _env_settings_values(project, env_file=_env_file_path(project), defaults=False) + if dotenv_values.get(name): return project.spec.env_file or "env_file" if requirement.default: return "default" @@ -469,6 +548,19 @@ def _env_file_path(project: LifecycleProject) -> Path | None: return _operational_path(project, project.spec.env_file, allow_dot=False, field="env_file") +def resolve_project_environment(project: LifecycleProject) -> LifecycleEnvironmentSnapshot: + """Resolve the one environment snapshot owned by this lifecycle invocation.""" + + try: + env_file = _env_file_path(project) + except _UnsafeOperationalPathError as exc: + exit_project_error( + f"Invalid lifecycle {exc.field} path.", + f"Update {exc.field} in {LIFECYCLE_SPEC_FILE}.", + ) + return resolve_lifecycle_environment(env_file=env_file) + + def _resolve_operational_path(project: LifecycleProject, value: str, *, allow_dot: bool) -> Path: """Resolve a runtime lifecycle path while preserving v1 joins.""" if isinstance(project.spec, LifecycleSpecV2): @@ -507,24 +599,12 @@ def _render_command(command: Sequence[str]) -> str: return " ".join(shlex.quote(part) for part in command) -def _process_environment(project: LifecycleProject) -> dict[str, str]: - """Build a child environment from the project dotenv file and shell. - - The shell is intentionally applied last so explicit exported values keep - precedence over project-local defaults and secrets. - """ - - env_file = _env_file_path(project) - environment = ( - {key: value for key, value in dotenv_values(env_file).items() if value is not None} - if env_file is not None - else {} - ) - environment.update({key: value for key, value in os.environ.items() if value}) - return environment - - -def _spawn_process(process: ProcessV1 | ProcessV2, *, project: LifecycleProject) -> ManagedProcess: +def _spawn_process( + process: ProcessV1 | ProcessV2, + *, + project: LifecycleProject, + environment: LifecycleEnvironmentSnapshot, +) -> ManagedProcess: executable = shutil.which(process.command[0]) if executable is None: exit_project_error( @@ -540,7 +620,7 @@ def _spawn_process(process: ProcessV1 | ProcessV2, *, project: LifecycleProject) popen( command, cwd=str(cwd), - env=_process_environment(project), + env=environment.as_subprocess_env(), **spawn_kwargs(), ), ) @@ -644,6 +724,7 @@ def _print_task_help(project: LifecycleProject) -> None: "discover_lifecycle_project", "lifecycle_spec_exists", "load_lifecycle_project", + "resolve_project_environment", "run_lifecycle_task", "run_task_cli", ] diff --git a/tests/cli_commands/test_dev_supervision.py b/tests/cli_commands/test_dev_supervision.py index be688004..b0347b11 100644 --- a/tests/cli_commands/test_dev_supervision.py +++ b/tests/cli_commands/test_dev_supervision.py @@ -21,6 +21,7 @@ import agentseek.cli.lifecycle.core as lifecycle_core import agentseek.cli.lifecycle.process_group as process_group +from agentseek.cli.lifecycle.environment import LifecycleEnvironmentSnapshot from agentseek.cli.lifecycle.process_group import ManagedProcess, manage, spawn_kwargs, terminate from tests.cli_commands.helpers import build_command_app @@ -267,12 +268,13 @@ def spawn_then_interrupt(*_args: object, **_kwargs: object) -> ManagedProcess: return started raise KeyboardInterrupt - monkeypatch.setattr(lifecycle_core, "_ensure_required_inputs", lambda _project: None) + environment = LifecycleEnvironmentSnapshot(values={}, origins={}) + monkeypatch.setattr(lifecycle_core, "_ensure_required_inputs", lambda _project, *, environment: None) monkeypatch.setattr(lifecycle_core, "_operational_path", lambda *_args, **_kwargs: tmp_path) monkeypatch.setattr(lifecycle_core, "_spawn_process", spawn_then_interrupt) try: with pytest.raises(KeyboardInterrupt): - lifecycle_core.dev(project, dry_run=False) + lifecycle_core.dev(project, dry_run=False, environment=environment) _assert_tree_stopped(started, child_pid) finally: terminate(started, grace_seconds=0.0) diff --git a/tests/cli_commands/test_lifecycle.py b/tests/cli_commands/test_lifecycle.py index cb73c43a..372d8ddc 100644 --- a/tests/cli_commands/test_lifecycle.py +++ b/tests/cli_commands/test_lifecycle.py @@ -8,11 +8,14 @@ import tomllib from pathlib import Path from typing import Any, cast +from unittest.mock import Mock import pytest from typer.testing import CliRunner import agentseek.cli.lifecycle.core as lifecycle_core +import agentseek.cli.lifecycle.environment as lifecycle_environment +from agentseek.cli.lifecycle.environment import EnvironmentOrigin, LifecycleEnvironmentSnapshot from tests.cli_commands.helpers import build_command_app pytestmark = pytest.mark.usefixtures("create_symlink") @@ -217,6 +220,16 @@ def test_info_lists_lifecycle_tasks_and_task_discovery_hint(tmp_path: Path, monk assert "agentseek task --list" in result.stdout +def test_info_succeeds_before_configured_optional_dotenv_exists(tmp_path: Path, monkeypatch) -> None: + _write_v2_lifecycle_spec(tmp_path, env_file="missing.env") + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(build_command_app(), ["info"]) + + assert result.exit_code == 0, result.stdout + result.stderr + assert "Env file: missing.env (missing)" in result.stdout + + def test_info_describes_agentseek_api_runtime(tmp_path: Path, monkeypatch) -> None: _write_lifecycle_spec(tmp_path) monkeypatch.chdir(tmp_path) @@ -287,6 +300,8 @@ def test_doctor_reports_missing_required_inputs(tmp_path: Path, monkeypatch) -> assert "fail .env: .env is missing." in result.stdout assert "fail BUB_API_KEY: BUB_API_KEY or BUB_OPENAI_API_KEY is not configured." in result.stdout assert "fail frontend/node_modules: frontend/node_modules is missing." in result.stdout + assert "Invalid lifecycle environment" not in result.stderr + assert "Traceback" not in result.stdout + result.stderr def test_doctor_live_accepts_2xx_and_3xx_statuses(tmp_path: Path, monkeypatch) -> None: @@ -453,13 +468,14 @@ def test_dev_dry_run_uses_agentseek_api_as_backend(tmp_path: Path, monkeypatch) def test_dev_skip_check_still_enforces_required_inputs(tmp_path: Path, monkeypatch) -> None: _write_lifecycle_spec(tmp_path) + (tmp_path / ".env").write_text("", encoding="utf-8") monkeypatch.chdir(tmp_path) result = CliRunner().invoke(build_command_app(), ["dev", "--skip-check"]) assert result.exit_code == 2 assert "Project is not ready to run." in result.stderr - assert "fail .env: .env is missing." in result.stdout + assert "fail .env" not in result.stdout assert "fail BUB_API_KEY: BUB_API_KEY or BUB_OPENAI_API_KEY is not configured." in result.stdout @@ -574,6 +590,7 @@ def test_task_child_process_does_not_inherit_env_file(tmp_path: Path, monkeypatc def fake_call(command: object, *, cwd: object, **kwargs: Any) -> int: nonlocal captured_child_environ del command, cwd + assert "env" not in kwargs captured_child_environ = dict(kwargs.get("env", os.environ)) return 0 @@ -596,6 +613,239 @@ def fake_call(command: object, *, cwd: object, **kwargs: Any) -> int: assert "AGENTSEEK_SECRET" not in captured_child_environ +def test_dev_resolves_once_for_readiness_and_every_child(tmp_path: Path, monkeypatch) -> None: + _write_v2_lifecycle_spec(tmp_path, env_file=".env") + spec_path = tmp_path / ".agentseek" / "lifecycle.toml" + spec_path.write_text( + spec_path.read_text(encoding="utf-8") + + f""" +[processes.worker] +command = [{_toml_string(sys.executable)}, "-c", "print('worker')"] +cwd = "." +""", + encoding="utf-8", + ) + env_file = tmp_path / ".env" + env_file.write_text("API_KEY=initial\nSNAPSHOT_SENTINEL=initial-dependent\n", encoding="utf-8") + parse_calls: list[Path] = [] + child_environments: list[dict[str, str]] = [] + snapshots: list[LifecycleEnvironmentSnapshot] = [] + real_parse = lifecycle_environment.parse_lifecycle_dotenv + real_ensure = lifecycle_core._ensure_required_inputs + real_spawn = lifecycle_core._spawn_process + real_static_checks = lifecycle_core._static_checks + + def counting_parse(path: Path, *, ambient): + parse_calls.append(path) + return real_parse(path, ambient=ambient) + + def ensure_then_change(project, *, environment) -> None: + snapshots.append(environment) + real_ensure(project, environment=environment) + env_file.write_text("API_KEY=changed-after-readiness\nSNAPSHOT_SENTINEL=changed\n", encoding="utf-8") + + def static_checks_with_identity(project, *, environment): + snapshots.append(environment) + return real_static_checks(project, environment=environment) + + def spawn_with_identity(process, *, project, environment): + snapshots.append(environment) + return real_spawn(process, project=project, environment=environment) + + class FinishedProcess: + def poll(self) -> int: + return 0 + + def capture_popen(command, *, cwd, env, **kwargs): + del command, cwd, kwargs + child_environments.append(dict(env)) + if len(child_environments) == 1: + env_file.write_text("API_KEY=changed-between-children\n", encoding="utf-8") + return FinishedProcess() + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("API_KEY", raising=False) + monkeypatch.delenv("SNAPSHOT_SENTINEL", raising=False) + monkeypatch.setattr(lifecycle_environment, "parse_lifecycle_dotenv", counting_parse) + monkeypatch.setattr(lifecycle_core, "_ensure_required_inputs", ensure_then_change) + monkeypatch.setattr(lifecycle_core, "_spawn_process", spawn_with_identity) + monkeypatch.setattr(lifecycle_core, "_static_checks", static_checks_with_identity) + monkeypatch.setattr(lifecycle_core.subprocess, "Popen", capture_popen) + monkeypatch.setattr(lifecycle_core, "manage", lambda process: process) + monkeypatch.setattr(lifecycle_core, "_terminate", lambda process: None) + + result = CliRunner().invoke(build_command_app(), ["dev"]) + + assert result.exit_code == 0, result.stdout + result.stderr + assert parse_calls == [env_file] + assert len(snapshots) == 5 + assert all(snapshot is snapshots[0] for snapshot in snapshots) + assert len(child_environments) == 2 + assert [env["API_KEY"] for env in child_environments] == ["initial", "initial"] + assert [env["SNAPSHOT_SENTINEL"] for env in child_environments] == [ + "initial-dependent", + "initial-dependent", + ] + + +def test_dotenv_explicit_empty_remains_present_in_dev_child(tmp_path: Path, monkeypatch) -> None: + _write_v2_lifecycle_spec(tmp_path, env_file=".env") + (tmp_path / ".env").write_text("API_KEY=configured\nEXPLICIT_EMPTY=\n", encoding="utf-8") + captured: list[dict[str, str]] = [] + + class FinishedProcess: + def poll(self) -> int: + return 0 + + def capture_popen(command, *, cwd, env, **kwargs): + del command, cwd, kwargs + captured.append(dict(env)) + return FinishedProcess() + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("EXPLICIT_EMPTY", raising=False) + monkeypatch.setattr(lifecycle_core.subprocess, "Popen", capture_popen) + monkeypatch.setattr(lifecycle_core, "manage", lambda process: process) + monkeypatch.setattr(lifecycle_core, "_terminate", lambda process: None) + + result = CliRunner().invoke(build_command_app(), ["dev", "--skip-check"]) + + assert result.exit_code == 0, result.stdout + result.stderr + assert captured[0]["EXPLICIT_EMPTY"] == "" + + +def test_lifecycle_default_is_readiness_only_and_absent_from_dev_child(tmp_path: Path, monkeypatch) -> None: + _write_v2_lifecycle_spec(tmp_path) + spec_path = tmp_path / ".agentseek" / "lifecycle.toml" + spec_path.write_text( + spec_path.read_text(encoding="utf-8").replace( + "[env.API_KEY]\nrequired = true", + '[env.API_KEY]\nrequired = true\ndefault = "readiness-default"', + ), + encoding="utf-8", + ) + captured: list[dict[str, str]] = [] + + class FinishedProcess: + def poll(self) -> int: + return 0 + + def capture_popen(command, *, cwd, env, **kwargs): + del command, cwd, kwargs + captured.append(dict(env)) + return FinishedProcess() + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("API_KEY", raising=False) + monkeypatch.setattr(lifecycle_core.subprocess, "Popen", capture_popen) + monkeypatch.setattr(lifecycle_core, "manage", lambda process: process) + monkeypatch.setattr(lifecycle_core, "_terminate", lambda process: None) + + result = CliRunner().invoke(build_command_app(), ["dev"]) + + assert result.exit_code == 0, result.stdout + result.stderr + assert "API_KEY is configured" in result.stdout + assert "API_KEY" not in captured[0] + + +def test_dev_dry_run_does_not_resolve_environment(tmp_path: Path, monkeypatch) -> None: + _write_v2_lifecycle_spec(tmp_path, env_file=".env") + monkeypatch.chdir(tmp_path) + + def fail_resolve(project): + del project + raise AssertionError("dry-run resolved environment") # noqa: TRY003 + + monkeypatch.setattr(lifecycle_core, "resolve_project_environment", fail_resolve) + monkeypatch.setattr("agentseek.cli.commands.dev.resolve_project_environment", fail_resolve) + + result = CliRunner().invoke(build_command_app(), ["dev", "--dry-run"]) + + assert result.exit_code == 0, result.stdout + result.stderr + assert "Startup plan" in result.stdout + + +@pytest.mark.parametrize( + ("contents", "binary"), + [ + ('SECRET=must-not-leak\nBROKEN "value"\nAFTER=value\n', False), + ('SECRET=must-not-leak\nUNTERMINATED="value\n', False), + (b"SECRET=must-not-leak\nTOKEN=\xff\n", True), + ], +) +def test_invalid_lifecycle_dotenv_exits_2_before_any_child( + tmp_path: Path, + monkeypatch, + contents, + binary: bool, +) -> None: + _write_v2_lifecycle_spec(tmp_path, env_file=".env") + env_file = tmp_path / ".env" + if binary: + env_file.write_bytes(contents) + else: + env_file.write_text(contents, encoding="utf-8") + popen_calls: list[object] = [] + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + lifecycle_core.subprocess, + "Popen", + lambda *args, **kwargs: popen_calls.append((args, kwargs)), + ) + + result = CliRunner().invoke(build_command_app(), ["dev", "--skip-check"]) + rendered = result.stdout + result.stderr + + assert result.exit_code == 2 + assert popen_calls == [] + assert "must-not-leak" not in rendered + assert "Traceback" not in rendered + + +def test_missing_lifecycle_dotenv_exits_2_before_any_child(tmp_path: Path, monkeypatch) -> None: + _write_v2_lifecycle_spec(tmp_path, env_file="missing.env") + monkeypatch.chdir(tmp_path) + popen = Mock(side_effect=AssertionError("child started")) + monkeypatch.setattr(lifecycle_core.subprocess, "Popen", popen) + + result = CliRunner().invoke(build_command_app(), ["dev", "--skip-check"]) + rendered = result.stdout + result.stderr + + assert result.exit_code == 2 + assert popen.call_count == 0 + assert "missing.env" in rendered + assert "Traceback" not in rendered + + +def test_spawn_process_uses_direct_argv_and_snapshot_without_shell(tmp_path: Path, monkeypatch) -> None: + _write_v2_lifecycle_spec(tmp_path) + project = lifecycle_core.discover_lifecycle_project(tmp_path) + process = project.spec.processes["web"].model_copy( + update={"command": ("python tool", "--label", "value with spaces")} + ) + snapshot = LifecycleEnvironmentSnapshot( + values={"UNICODE_VALUE": "值"}, + origins={"UNICODE_VALUE": EnvironmentOrigin.ENV_FILE}, + ) + captured: dict[str, object] = {} + + def capture_popen(command, *, cwd, env, **kwargs): + captured.update(command=command, cwd=cwd, env=env, kwargs=kwargs) + return cast("subprocess.Popen[bytes]", object()) + + monkeypatch.setattr(lifecycle_core.shutil, "which", lambda _executable: "/tools/python tool") + monkeypatch.setattr(lifecycle_core, "spawn_kwargs", lambda: {"creationflags": 512}) + monkeypatch.setattr(lifecycle_core.subprocess, "Popen", capture_popen) + monkeypatch.setattr(lifecycle_core, "manage", lambda child: child) + + lifecycle_core._spawn_process(process, project=project, environment=snapshot) + + assert captured["command"] == ("/tools/python tool", "--label", "value with spaces") + assert captured["env"] == {"UNICODE_VALUE": "值"} + assert cast("dict[str, object]", captured["kwargs"])["creationflags"] == 512 + assert "shell" not in cast("dict[str, object]", captured["kwargs"]) + + def test_dev_child_process_inherits_env_file_with_shell_precedence(tmp_path: Path, monkeypatch) -> None: _write_lifecycle_spec(tmp_path) (tmp_path / ".env").write_text( @@ -621,7 +871,12 @@ def fake_popen(command: object, *, cwd: object, env: dict[str, str], **kwargs: o monkeypatch.setattr(lifecycle_core, "manage", lambda process: process) project = lifecycle_core.discover_lifecycle_project(tmp_path) - lifecycle_core._spawn_process(project.spec.processes["web"], project=project) + environment = lifecycle_core.resolve_project_environment(project) + lifecycle_core._spawn_process( + project.spec.processes["web"], + project=project, + environment=environment, + ) assert captured_child_environ is not None assert captured_child_environ["SEEKDB_URL"] == "mysql+aiomysql://dotenv.example/test" @@ -649,8 +904,9 @@ def test_dev_child_process_applies_dotenv_values_to_a_real_process(tmp_path: Pat ) } ) + environment = lifecycle_core.resolve_project_environment(project) - child = lifecycle_core._spawn_process(process, project=project) + child = lifecycle_core._spawn_process(process, project=project, environment=environment) assert child.wait(timeout=5) == 0 assert output.read_text(encoding="utf-8") == "from dotenv # value\nnext" @@ -660,25 +916,26 @@ def test_empty_shell_value_falls_back_to_dotenv_for_readiness_and_spawned_child( _write_v2_lifecycle_spec(tmp_path, env_file=".env") (tmp_path / ".env").write_text("API_KEY=from-dotenv\n", encoding="utf-8") output = tmp_path / "child-api-key.txt" + child_script = ( + "from pathlib import Path; import os; " + "Path('child-api-key.txt').write_text(os.environ['API_KEY'], encoding='utf-8')" + ) + spec_path = tmp_path / ".agentseek" / "lifecycle.toml" + original_command = f'command = [{_toml_string(sys.executable)}, "-c", "print(\'unreachable\')"]' + replacement_command = f'command = [{_toml_string(sys.executable)}, "-c", {_toml_string(child_script)}]' + lifecycle_text = spec_path.read_text(encoding="utf-8") + assert original_command in lifecycle_text + spec_path.write_text( + lifecycle_text.replace(original_command, replacement_command), + encoding="utf-8", + ) monkeypatch.chdir(tmp_path) monkeypatch.setenv("API_KEY", "") - project = lifecycle_core.discover_lifecycle_project(tmp_path) - process = project.spec.processes["web"].model_copy( - update={ - "command": ( - sys.executable, - "-c", - "from pathlib import Path; import os; " - "Path('child-api-key.txt').write_text(os.environ['API_KEY'], encoding='utf-8')", - ) - } - ) - - assert lifecycle_core._env_requirement_source(project, "API_KEY", project.spec.env["API_KEY"]) == ".env" - child = lifecycle_core._spawn_process(process, project=project) + result = CliRunner().invoke(build_command_app(), ["dev"]) - assert child.wait(timeout=5) == 0 + assert result.exit_code == 0, result.stdout + result.stderr + assert "API_KEY is configured" in result.stdout assert output.read_text(encoding="utf-8") == "from-dotenv" @@ -706,7 +963,7 @@ def test_v2_operational_path_env_file_symlink_swap_rejects_before_file_access( assert escaped not in accessed -def test_v2_operational_path_dev_env_settings_symlink_swap_rejects_before_reader( +def test_v2_operational_path_dev_env_snapshot_symlink_swap_rejects_before_reader( tmp_path: Path, monkeypatch, ) -> None: @@ -714,26 +971,25 @@ def test_v2_operational_path_dev_env_settings_symlink_swap_rejects_before_reader env_dir = tmp_path / "settings" env_dir.mkdir() (env_dir / ".env").write_text("API_KEY=inside\n", encoding="utf-8") - outside = tmp_path.parent / f"{tmp_path.name}-outside-env-settings" + outside = tmp_path.parent / f"{tmp_path.name}-outside-env-snapshot" outside.mkdir() escaped = outside / ".env" escaped.write_text("API_KEY=outside\n", encoding="utf-8") _swap_after_lifecycle_load(monkeypatch, env_dir, outside) read_paths: list[Path | None] = [] - def record_settings(project, *, env_file: Path | None, defaults: bool) -> dict[str, str]: - del project, defaults - read_paths.append(env_file) + def record_dotenv_read(path: Path | None, *, ambient): + del ambient + read_paths.append(path) return {} - monkeypatch.setattr(lifecycle_core, "_env_file_checks", lambda project: []) - monkeypatch.setattr(lifecycle_core, "_env_settings_values", record_settings) + monkeypatch.setattr(lifecycle_environment, "parse_lifecycle_dotenv", record_dotenv_read) monkeypatch.chdir(tmp_path) result = CliRunner().invoke(build_command_app(), ["dev", "--skip-check"]) _assert_confined_rejection(result, escaped, "env_file") - assert read_paths == [None] + assert read_paths == [] @pytest.mark.parametrize("command", (["doctor"], ["dev", "--skip-check"])) @@ -806,8 +1062,8 @@ def test_v2_operational_path_process_cwd_symlink_swap_after_readiness_rejects_be outside.mkdir() original = lifecycle_core._ensure_required_inputs - def ensure_then_swap(project) -> None: - original(project) + def ensure_then_swap(project, *, environment) -> None: + original(project, environment=environment) _swap_with_outside_symlink(runtime, outside) popen_called = False @@ -916,9 +1172,9 @@ def test_v2_operational_path_preflights_all_process_cwds_before_starting_childre sentinel_ran = False calls: list[object] = [] - def ensure_then_swap(project) -> None: + def ensure_then_swap(project, *, environment) -> None: nonlocal sentinel_ran - original(project) + original(project, environment=environment) sentinel_ran = True _swap_with_outside_symlink(second, outside) @@ -1037,12 +1293,16 @@ def fake_call(command: object, *, cwd: Path, **kwargs: object) -> int: monkeypatch.delenv("BUB_MODEL", raising=False) assert required[0].status == "ok" assert env_file == outside_env - assert lifecycle_core._env_requirement_source(project, "BUB_MODEL", project.spec.env["BUB_MODEL"]) == str( - outside_env - ) + environment = lifecycle_core.resolve_project_environment(project) + assert lifecycle_core._env_requirement_source( + project, + "BUB_MODEL", + project.spec.env["BUB_MODEL"], + environment=environment, + ) == str(outside_env) assert lifecycle_core._resolve_operational_path(project, process.cwd, allow_dot=True) == tmp_path / process.cwd assert lifecycle_core._run_command(task.command, project=project, cwd=task.cwd) == 0 - lifecycle_core._spawn_process(process, project=project) + lifecycle_core._spawn_process(process, project=project, environment=environment) assert seen == [tmp_path / "frontend", tmp_path / f"../{outside.name}"] From 12f777fdcd6a459c24405f8b5e610c7699426ba0 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Mon, 17 Aug 2026 00:21:50 +0800 Subject: [PATCH 11/21] test: prove lifecycle boundary against published api --- .github/workflows/main.yml | 26 ++++ .../check_agentseek_api_lifecycle_contract.py | 139 ++++++++++++++++++ src/agentseek/cli/lifecycle/__init__.py | 2 + src/agentseek/cli/lifecycle/compatibility.py | 5 + tests/test_github_workflows.py | 12 ++ 5 files changed, 184 insertions(+) create mode 100644 scripts/check_agentseek_api_lifecycle_contract.py create mode 100644 src/agentseek/cli/lifecycle/compatibility.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1646b00a..e989c789 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -44,6 +44,7 @@ jobs: uv run --python 3.13 --isolated --no-project \ --with-editable . \ --with pydantic-settings==2.0.0 \ + --with python-dotenv==1.0.0 \ agentseek --help cross-platform-tests-and-type-check: @@ -95,6 +96,31 @@ jobs: - name: Check typing run: make typecheck + agentseek-api-lifecycle-contract: + name: Published agentseek-api lifecycle contract + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up the environment + uses: ./.github/actions/setup-python-env + with: + python-version: "3.12" + + - name: Verify the exact published API floor through agentseek dev + run: | + set -euo pipefail + api_version="$(uv run --frozen python -c 'from agentseek.cli.lifecycle.compatibility import MINIMUM_AGENTSEEK_API_VERSION; print(MINIMUM_AGENTSEEK_API_VERSION)')" + test "${api_version}" = "0.2.2" + export PYTHONPATH= + export UV_CACHE_DIR="${RUNNER_TEMP}/agentseek-api-contract-cache" + uv run --python 3.12 --isolated --no-project \ + --with-editable . \ + --with "agentseek-api==${api_version}" \ + python scripts/check_agentseek_api_lifecycle_contract.py + legacy-template-compatibility: if: ${{ github.event_name == 'push' || startsWith(github.head_ref, 'release/') }} runs-on: ubuntu-latest diff --git a/scripts/check_agentseek_api_lifecycle_contract.py b/scripts/check_agentseek_api_lifecycle_contract.py new file mode 100644 index 00000000..4623853e --- /dev/null +++ b/scripts/check_agentseek_api_lifecycle_contract.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +from importlib.metadata import version +from pathlib import Path + +from agentseek.cli.lifecycle.compatibility import MINIMUM_AGENTSEEK_API_VERSION + + +def _toml_string(value: str | Path) -> str: + return json.dumps(str(value), ensure_ascii=False) + + +def _write_api_capture_helper(root: Path, output: Path) -> Path: + helper = root / "capture_api_environment.py" + changed_dotenv = ( + "DIRECT_SENTINEL=changed-after-snapshot\n" + "DEPENDENT_SENTINEL=changed-after-snapshot\n" + "EXPLICIT_EMPTY=changed-after-snapshot\n" + "CHILD_ONLY=added-after-snapshot\n" + ) + helper.write_text( + "\n".join([ + "from __future__ import annotations", + "import json", + "from importlib.metadata import version", + "from pathlib import Path", + "from agentseek_api.cli import main", + f"OUTPUT = Path({_toml_string(output)})", + f"ENV_FILE = Path({_toml_string(root / '.env')})", + "def capture(command, *, env, cwd=None):", + " OUTPUT.write_text(json.dumps({", + " 'api_version': version('agentseek-api'),", + " 'direct': env['DIRECT_SENTINEL'],", + " 'dependent': env['DEPENDENT_SENTINEL'],", + " 'explicit_empty_present': 'EXPLICIT_EMPTY' in env,", + " 'explicit_empty': env['EXPLICIT_EMPTY'],", + " 'child_only': env['CHILD_ONLY'],", + " 'graphs': env['AGENTSEEK_GRAPHS'],", + " }, sort_keys=True), encoding='utf-8')", + " return 0", + f"ENV_FILE.write_text({_toml_string(changed_dotenv)}, encoding='utf-8')", + "raise SystemExit(main(['dev', '--config', 'langgraph.json', '--no-reload', '--no-browser'], runner=capture, cwd=Path.cwd()))", + ]) + + "\n", + encoding="utf-8", + ) + return helper + + +def main() -> int: + actual_api_version = version("agentseek-api") + if actual_api_version != MINIMUM_AGENTSEEK_API_VERSION: + message = f"expected agentseek-api {MINIMUM_AGENTSEEK_API_VERSION}, got {actual_api_version}" + raise AssertionError(message) + + with tempfile.TemporaryDirectory(prefix="agentseek-api-lifecycle-contract-") as raw_root: + root = Path(raw_root) + lifecycle_dir = root / ".agentseek" + lifecycle_dir.mkdir() + output = root / "observed.json" + helper = _write_api_capture_helper(root, output) + + (root / ".env").write_text( + "DIRECT_SENTINEL=from-dotenv\nDEPENDENT_SENTINEL=${DIRECT_SENTINEL}:resolved-in-file\nEXPLICIT_EMPTY=\n", + encoding="utf-8", + ) + (root / "unused.py").write_text("graph = object()\n", encoding="utf-8") + (root / "langgraph.json").write_text( + json.dumps({ + "dependencies": [], + "graphs": {"contract": "./unused.py:graph"}, + "env": ".env", + }), + encoding="utf-8", + ) + (lifecycle_dir / "lifecycle.toml").write_text( + "\n".join([ + "version = 2", + 'template = "contract/agentseek-api"', + 'name = "Published API contract"', + 'env_file = ".env"', + "", + "[env.DIRECT_SENTINEL]", + "required = true", + "", + "[processes.api]", + f"command = [{_toml_string(sys.executable)}, {_toml_string(helper)}]", + 'cwd = "."', + ]) + + "\n", + encoding="utf-8", + ) + + launch_environment = dict(os.environ) + launch_environment["DIRECT_SENTINEL"] = "from-shell" + launch_environment.pop("DEPENDENT_SENTINEL", None) + launch_environment.pop("EXPLICIT_EMPTY", None) + launch_environment.pop("CHILD_ONLY", None) + launch_environment.pop("PYTHONPATH", None) + + completed = subprocess.run( + [sys.executable, "-m", "agentseek", "dev"], + cwd=root, + env=launch_environment, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + if completed.returncode != 0: + message = f"agentseek dev failed ({completed.returncode})\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}" + raise AssertionError(message) + + if not output.is_file(): + message = "published API capture runner did not execute" + raise AssertionError(message) + observed = json.loads(output.read_text(encoding="utf-8")) + expected = { + "api_version": MINIMUM_AGENTSEEK_API_VERSION, + "child_only": "added-after-snapshot", + "dependent": "from-dotenv:resolved-in-file", + "direct": "from-shell", + "explicit_empty": "", + "explicit_empty_present": True, + "graphs": str((root / "langgraph.json").resolve()), + } + if observed != expected: + message = "published API capture did not preserve the lifecycle environment contract" + raise AssertionError(message) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/agentseek/cli/lifecycle/__init__.py b/src/agentseek/cli/lifecycle/__init__.py index 1f2a1545..0082e64b 100644 --- a/src/agentseek/cli/lifecycle/__init__.py +++ b/src/agentseek/cli/lifecycle/__init__.py @@ -1,5 +1,6 @@ """Lifecycle public API.""" +from agentseek.cli.lifecycle.compatibility import MINIMUM_AGENTSEEK_API_VERSION from agentseek.cli.lifecycle.core import ( LifecycleProject, lifecycle_spec_exists, @@ -24,6 +25,7 @@ ) __all__ = [ + "MINIMUM_AGENTSEEK_API_VERSION", "LIFECYCLE_SPEC_FILE", "REQUIRED_COMMANDS", "SUPPORTED_LIFECYCLE_VERSION", diff --git a/src/agentseek/cli/lifecycle/compatibility.py b/src/agentseek/cli/lifecycle/compatibility.py new file mode 100644 index 00000000..03eabb45 --- /dev/null +++ b/src/agentseek/cli/lifecycle/compatibility.py @@ -0,0 +1,5 @@ +"""Versioned compatibility boundaries for optional lifecycle runtimes.""" + +MINIMUM_AGENTSEEK_API_VERSION = "0.2.2" + +__all__ = ["MINIMUM_AGENTSEEK_API_VERSION"] diff --git a/tests/test_github_workflows.py b/tests/test_github_workflows.py index 6ccb824c..73b500d6 100644 --- a/tests/test_github_workflows.py +++ b/tests/test_github_workflows.py @@ -5,6 +5,18 @@ from pathlib import Path +def test_published_api_lifecycle_contract_uses_declared_floors() -> None: + workflow = Path(__file__).resolve().parents[1] / ".github" / "workflows" / "main.yml" + text = workflow.read_text(encoding="utf-8") + + assert "--with python-dotenv==1.0.0" in text + assert "agentseek-api-lifecycle-contract:" in text + assert "scripts/check_agentseek_api_lifecycle_contract.py" in text + assert 'test "${api_version}" = "0.2.2"' in text + assert '--with "agentseek-api==${api_version}"' in text + assert "export PYTHONPATH=" in text + + def test_phoenix_smoke_verifies_multiple_trace_markers() -> None: """The Phoenix smoke job must prove more than one persisted trace.""" workflow = Path(__file__).resolve().parents[1] / ".github" / "workflows" / "main.yml" From 51a638b7e9789eae02629c9b5d8470bcda54fbe0 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Mon, 17 Aug 2026 00:33:03 +0800 Subject: [PATCH 12/21] test: reap lifecycle contract timeout process --- .../check_agentseek_api_lifecycle_contract.py | 41 ++++++-- .../test_agentseek_api_lifecycle_contract.py | 97 +++++++++++++++++++ tests/test_github_workflows.py | 31 ++++-- 3 files changed, 157 insertions(+), 12 deletions(-) create mode 100644 tests/test_agentseek_api_lifecycle_contract.py diff --git a/scripts/check_agentseek_api_lifecycle_contract.py b/scripts/check_agentseek_api_lifecycle_contract.py index 4623853e..b85449e5 100644 --- a/scripts/check_agentseek_api_lifecycle_contract.py +++ b/scripts/check_agentseek_api_lifecycle_contract.py @@ -5,6 +5,7 @@ import subprocess import sys import tempfile +from contextlib import suppress from importlib.metadata import version from pathlib import Path @@ -52,6 +53,38 @@ def _write_api_capture_helper(root: Path, output: Path) -> Path: return helper +def _run_agentseek( + command: list[str], + *, + cwd: Path, + env: dict[str, str], + timeout_seconds: float = 30, + graceful_shutdown_timeout_seconds: float = 5, +) -> subprocess.CompletedProcess[str]: + process = subprocess.Popen( # noqa: S603 - command is constructed by this contract script + command, + cwd=cwd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + stdout, stderr = process.communicate(timeout=timeout_seconds) + except subprocess.TimeoutExpired: + with suppress(ProcessLookupError): + process.terminate() + try: + process.communicate(timeout=graceful_shutdown_timeout_seconds) + except subprocess.TimeoutExpired: + with suppress(ProcessLookupError): + process.kill() + process.communicate() + message = "agentseek dev exceeded the lifecycle-contract timeout" + raise TimeoutError(message) from None + return subprocess.CompletedProcess(command, process.returncode, stdout, stderr) + + def main() -> int: actual_api_version = version("agentseek-api") if actual_api_version != MINIMUM_AGENTSEEK_API_VERSION: @@ -103,17 +136,13 @@ def main() -> int: launch_environment.pop("CHILD_ONLY", None) launch_environment.pop("PYTHONPATH", None) - completed = subprocess.run( + completed = _run_agentseek( [sys.executable, "-m", "agentseek", "dev"], cwd=root, env=launch_environment, - check=False, - capture_output=True, - text=True, - timeout=30, ) if completed.returncode != 0: - message = f"agentseek dev failed ({completed.returncode})\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}" + message = "agentseek dev failed" raise AssertionError(message) if not output.is_file(): diff --git a/tests/test_agentseek_api_lifecycle_contract.py b/tests/test_agentseek_api_lifecycle_contract.py new file mode 100644 index 00000000..3b007ba9 --- /dev/null +++ b/tests/test_agentseek_api_lifecycle_contract.py @@ -0,0 +1,97 @@ +"""Regression coverage for the published agentseek-api lifecycle contract script.""" + +from __future__ import annotations + +import importlib.util +import os +import signal +import sys +import time +from pathlib import Path +from types import ModuleType + +import pytest + +_PARENT_WITH_REAPED_CHILD = """ +import pathlib +import signal +import subprocess +import sys +import time + +child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) +pathlib.Path(sys.argv[1]).write_text(str(child.pid), encoding="utf-8") + +def stop(*_args): + child.terminate() + child.wait(timeout=5) + raise SystemExit(0) + +signal.signal(signal.SIGTERM, stop) +while True: + time.sleep(0.05) +""" + + +def _load_contract_script() -> ModuleType: + script = Path(__file__).resolve().parents[1] / "scripts" / "check_agentseek_api_lifecycle_contract.py" + spec = importlib.util.spec_from_file_location("agentseek_api_lifecycle_contract", script) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _wait_until(predicate, *, timeout: float = 5.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.05) + return predicate() + + +def _process_is_running(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _force_stop(pid: int) -> None: + if not _process_is_running(pid): + return + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + return + + +@pytest.mark.skipif(os.name == "nt", reason="the contract's graceful SIGTERM path is POSIX-specific") +def test_timeout_reaps_real_descendant_after_graceful_parent_shutdown(tmp_path: Path) -> None: + contract = _load_contract_script() + marker = tmp_path / "child.pid" + parent = tmp_path / "parent.py" + parent.write_text(_PARENT_WITH_REAPED_CHILD, encoding="utf-8") + child_pid: int | None = None + + try: + with pytest.raises(TimeoutError): + contract._run_agentseek( + [sys.executable, str(parent), str(marker)], + cwd=tmp_path, + env=dict(os.environ), + timeout_seconds=1.0, + graceful_shutdown_timeout_seconds=5.0, + ) + + assert marker.is_file(), "parent process did not publish its descendant PID" + child_pid = int(marker.read_text(encoding="utf-8")) + assert _wait_until(lambda: not _process_is_running(child_pid)), "descendant survived timeout cleanup" + finally: + if child_pid is not None: + _force_stop(child_pid) diff --git a/tests/test_github_workflows.py b/tests/test_github_workflows.py index 73b500d6..cbe56f1f 100644 --- a/tests/test_github_workflows.py +++ b/tests/test_github_workflows.py @@ -2,19 +2,38 @@ from __future__ import annotations +import re from pathlib import Path +def _job_block(workflow: str, job_name: str) -> str: + match = re.search( + rf"^ {re.escape(job_name)}:\n.*?(?=^ [a-zA-Z0-9_-]+:|\Z)", + workflow, + flags=re.MULTILINE | re.DOTALL, + ) + assert match, f"workflow does not define job {job_name!r}" + return match.group() + + def test_published_api_lifecycle_contract_uses_declared_floors() -> None: workflow = Path(__file__).resolve().parents[1] / ".github" / "workflows" / "main.yml" text = workflow.read_text(encoding="utf-8") - assert "--with python-dotenv==1.0.0" in text - assert "agentseek-api-lifecycle-contract:" in text - assert "scripts/check_agentseek_api_lifecycle_contract.py" in text - assert 'test "${api_version}" = "0.2.2"' in text - assert '--with "agentseek-api==${api_version}"' in text - assert "export PYTHONPATH=" in text + minimum_supported_cli = _job_block(text, "minimum-supported-cli") + assert "uv run --python 3.13 --isolated --no-project" in minimum_supported_cli + assert "--with-editable ." in minimum_supported_cli + assert "--with pydantic-settings==2.0.0" in minimum_supported_cli + assert "--with python-dotenv==1.0.0" in minimum_supported_cli + assert "agentseek --help" in minimum_supported_cli + + api_contract = _job_block(text, "agentseek-api-lifecycle-contract") + assert "uv run --python 3.12 --isolated --no-project" in api_contract + assert "--with-editable ." in api_contract + assert 'test "${api_version}" = "0.2.2"' in api_contract + assert "export PYTHONPATH=" in api_contract + assert '--with "agentseek-api==${api_version}"' in api_contract + assert "scripts/check_agentseek_api_lifecycle_contract.py" in api_contract def test_phoenix_smoke_verifies_multiple_trace_markers() -> None: From 93ec3af68d77bb54fccc2027257077b2c6e9cd6b Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Mon, 17 Aug 2026 00:40:51 +0800 Subject: [PATCH 13/21] test: bound lifecycle timeout fallback --- .../check_agentseek_api_lifecycle_contract.py | 115 +++++++++++++++--- .../test_agentseek_api_lifecycle_contract.py | 71 +++++++---- 2 files changed, 147 insertions(+), 39 deletions(-) diff --git a/scripts/check_agentseek_api_lifecycle_contract.py b/scripts/check_agentseek_api_lifecycle_contract.py index b85449e5..b2da8d67 100644 --- a/scripts/check_agentseek_api_lifecycle_contract.py +++ b/scripts/check_agentseek_api_lifecycle_contract.py @@ -2,21 +2,29 @@ import json import os +import signal import subprocess import sys import tempfile +import time from contextlib import suppress from importlib.metadata import version from pathlib import Path from agentseek.cli.lifecycle.compatibility import MINIMUM_AGENTSEEK_API_VERSION +_AGENTSEEK_TIMEOUT_SECONDS = 30.0 +_AGENTSEEK_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS = 15.0 +_HELPER_PROCESS_GROUP_GRACE_SECONDS = 1.0 +_FALLBACK_REAP_TIMEOUT_SECONDS = 5.0 +_PROCESS_GROUP_POLL_SECONDS = 0.05 + def _toml_string(value: str | Path) -> str: return json.dumps(str(value), ensure_ascii=False) -def _write_api_capture_helper(root: Path, output: Path) -> Path: +def _write_api_capture_helper(root: Path, output: Path, process_marker: Path) -> Path: helper = root / "capture_api_environment.py" changed_dotenv = ( "DIRECT_SENTINEL=changed-after-snapshot\n" @@ -28,11 +36,14 @@ def _write_api_capture_helper(root: Path, output: Path) -> Path: "\n".join([ "from __future__ import annotations", "import json", + "import os", "from importlib.metadata import version", "from pathlib import Path", "from agentseek_api.cli import main", f"OUTPUT = Path({_toml_string(output)})", f"ENV_FILE = Path({_toml_string(root / '.env')})", + f"PROCESS_MARKER = Path({_toml_string(process_marker)})", + "PROCESS_MARKER.write_text(json.dumps({'pid': os.getpid(), 'pgid': os.getpgid(0)}), encoding='utf-8')", "def capture(command, *, env, cwd=None):", " OUTPUT.write_text(json.dumps({", " 'api_version': version('agentseek-api'),", @@ -53,36 +64,104 @@ def _write_api_capture_helper(root: Path, output: Path) -> Path: return helper +def _tracked_posix_process_group(process_marker: Path | None) -> int | None: + if os.name == "nt" or process_marker is None: + return None + try: + observed = json.loads(process_marker.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + pid = observed.get("pid") if isinstance(observed, dict) else None + pgid = observed.get("pgid") if isinstance(observed, dict) else None + if type(pid) is not int or type(pgid) is not int or pid <= 0 or pid != pgid: + return None + try: + if os.getpgid(pid) != pgid: + return None + except (ProcessLookupError, PermissionError): + return None + return pgid + + +def _process_group_exists(pgid: int) -> bool: + try: + os.killpg(pgid, 0) + except (ProcessLookupError, PermissionError): + return False + return True + + +def _terminate_tracked_posix_process_group( + process_marker: Path | None, + *, + grace_seconds: float, + reap_timeout_seconds: float, +) -> None: + pgid = _tracked_posix_process_group(process_marker) + if pgid is None: + return + with suppress(ProcessLookupError): + os.killpg(pgid, signal.SIGTERM) + deadline = time.monotonic() + grace_seconds + while _process_group_exists(pgid) and time.monotonic() < deadline: + time.sleep(_PROCESS_GROUP_POLL_SECONDS) + if not _process_group_exists(pgid): + return + with suppress(ProcessLookupError): + os.killpg(pgid, signal.SIGKILL) + deadline = time.monotonic() + reap_timeout_seconds + while _process_group_exists(pgid) and time.monotonic() < deadline: + time.sleep(_PROCESS_GROUP_POLL_SECONDS) + + +def _kill_and_reap_agentseek(process: subprocess.Popen[bytes], *, timeout_seconds: float) -> None: + with suppress(ProcessLookupError): + process.kill() + try: + process.wait(timeout=timeout_seconds) + except subprocess.TimeoutExpired: + message = "agentseek dev fallback could not reap its parent" + raise TimeoutError(message) from None + + def _run_agentseek( command: list[str], *, cwd: Path, env: dict[str, str], - timeout_seconds: float = 30, - graceful_shutdown_timeout_seconds: float = 5, -) -> subprocess.CompletedProcess[str]: + timeout_seconds: float = _AGENTSEEK_TIMEOUT_SECONDS, + graceful_shutdown_timeout_seconds: float = _AGENTSEEK_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS, + helper_process_marker: Path | None = None, + helper_process_group_grace_seconds: float = _HELPER_PROCESS_GROUP_GRACE_SECONDS, + fallback_reap_timeout_seconds: float = _FALLBACK_REAP_TIMEOUT_SECONDS, +) -> int: process = subprocess.Popen( # noqa: S603 - command is constructed by this contract script command, cwd=cwd, env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) try: - stdout, stderr = process.communicate(timeout=timeout_seconds) + return process.wait(timeout=timeout_seconds) except subprocess.TimeoutExpired: + if os.name == "nt": + _kill_and_reap_agentseek(process, timeout_seconds=fallback_reap_timeout_seconds) + message = "agentseek lifecycle timeout fallback requires POSIX process-group support" + raise RuntimeError(message) from None with suppress(ProcessLookupError): - process.terminate() + process.send_signal(signal.SIGTERM) try: - process.communicate(timeout=graceful_shutdown_timeout_seconds) + process.wait(timeout=graceful_shutdown_timeout_seconds) except subprocess.TimeoutExpired: - with suppress(ProcessLookupError): - process.kill() - process.communicate() + _terminate_tracked_posix_process_group( + helper_process_marker, + grace_seconds=helper_process_group_grace_seconds, + reap_timeout_seconds=fallback_reap_timeout_seconds, + ) + _kill_and_reap_agentseek(process, timeout_seconds=fallback_reap_timeout_seconds) message = "agentseek dev exceeded the lifecycle-contract timeout" raise TimeoutError(message) from None - return subprocess.CompletedProcess(command, process.returncode, stdout, stderr) def main() -> int: @@ -96,7 +175,8 @@ def main() -> int: lifecycle_dir = root / ".agentseek" lifecycle_dir.mkdir() output = root / "observed.json" - helper = _write_api_capture_helper(root, output) + helper_process_marker = root / ".agentseek-api-helper-process.json" + helper = _write_api_capture_helper(root, output, helper_process_marker) (root / ".env").write_text( "DIRECT_SENTINEL=from-dotenv\nDEPENDENT_SENTINEL=${DIRECT_SENTINEL}:resolved-in-file\nEXPLICIT_EMPTY=\n", @@ -136,12 +216,13 @@ def main() -> int: launch_environment.pop("CHILD_ONLY", None) launch_environment.pop("PYTHONPATH", None) - completed = _run_agentseek( + returncode = _run_agentseek( [sys.executable, "-m", "agentseek", "dev"], cwd=root, env=launch_environment, + helper_process_marker=helper_process_marker, ) - if completed.returncode != 0: + if returncode != 0: message = "agentseek dev failed" raise AssertionError(message) diff --git a/tests/test_agentseek_api_lifecycle_contract.py b/tests/test_agentseek_api_lifecycle_contract.py index 3b007ba9..79b3d89a 100644 --- a/tests/test_agentseek_api_lifecycle_contract.py +++ b/tests/test_agentseek_api_lifecycle_contract.py @@ -3,6 +3,7 @@ from __future__ import annotations import importlib.util +import json import os import signal import sys @@ -12,22 +13,20 @@ import pytest -_PARENT_WITH_REAPED_CHILD = """ +_BLOCKED_SEPARATE_SESSION_HELPER = """ +import json +import os import pathlib import signal -import subprocess import sys import time -child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) -pathlib.Path(sys.argv[1]).write_text(str(child.pid), encoding="utf-8") - -def stop(*_args): - child.terminate() - child.wait(timeout=5) - raise SystemExit(0) - -signal.signal(signal.SIGTERM, stop) +pathlib.Path(sys.argv[1]).write_text(json.dumps({ + "pid": os.getpid(), + "pgid": os.getpgid(0), + "parent_pid": os.getppid(), +}), encoding="utf-8") +signal.signal(signal.SIGTERM, lambda *_args: None) while True: time.sleep(0.05) """ @@ -71,27 +70,55 @@ def _force_stop(pid: int) -> None: return -@pytest.mark.skipif(os.name == "nt", reason="the contract's graceful SIGTERM path is POSIX-specific") -def test_timeout_reaps_real_descendant_after_graceful_parent_shutdown(tmp_path: Path) -> None: +@pytest.mark.skipif(os.name == "nt", reason="the contract timeout fallback requires POSIX process groups") +def test_timeout_fallback_reaps_actual_agentseek_parent_and_separate_session_helper(tmp_path: Path) -> None: contract = _load_contract_script() - marker = tmp_path / "child.pid" - parent = tmp_path / "parent.py" - parent.write_text(_PARENT_WITH_REAPED_CHILD, encoding="utf-8") + marker = tmp_path / "helper-process.json" + lifecycle_dir = tmp_path / ".agentseek" + lifecycle_dir.mkdir() + helper = tmp_path / "blocked_helper.py" + helper.write_text(_BLOCKED_SEPARATE_SESSION_HELPER, encoding="utf-8") + (lifecycle_dir / "lifecycle.toml").write_text( + "\n".join([ + "version = 2", + 'template = "contract/timeout"', + 'name = "Timeout fallback contract"', + "", + "[processes.api]", + f"command = {json.dumps([sys.executable, str(helper), str(marker)])}", + 'cwd = "."', + ]) + + "\n", + encoding="utf-8", + ) child_pid: int | None = None + parent_pid: int | None = None + started = time.monotonic() try: with pytest.raises(TimeoutError): contract._run_agentseek( - [sys.executable, str(parent), str(marker)], + [sys.executable, "-m", "agentseek", "dev", "--skip-check"], cwd=tmp_path, env=dict(os.environ), - timeout_seconds=1.0, - graceful_shutdown_timeout_seconds=5.0, + timeout_seconds=2.0, + graceful_shutdown_timeout_seconds=0.2, + helper_process_marker=marker, + helper_process_group_grace_seconds=0.1, + fallback_reap_timeout_seconds=1.0, ) - assert marker.is_file(), "parent process did not publish its descendant PID" - child_pid = int(marker.read_text(encoding="utf-8")) - assert _wait_until(lambda: not _process_is_running(child_pid)), "descendant survived timeout cleanup" + elapsed = time.monotonic() - started + assert elapsed < 5.0, "fallback cleanup exceeded its bounded timeout" + assert marker.is_file(), "helper process did not publish its private marker" + observed = json.loads(marker.read_text(encoding="utf-8")) + child_pid = observed["pid"] + parent_pid = observed["parent_pid"] + assert observed["pgid"] == child_pid, "AgentSeek did not start the helper in a separate session" + assert _wait_until(lambda: not _process_is_running(parent_pid)), "AgentSeek parent survived timeout fallback" + assert _wait_until(lambda: not _process_is_running(child_pid)), "helper survived timeout fallback" finally: if child_pid is not None: _force_stop(child_pid) + if parent_pid is not None: + _force_stop(parent_pid) From 86f7fdc9bda220c5b883369b2ced06697bdeb737 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Mon, 17 Aug 2026 00:45:42 +0800 Subject: [PATCH 14/21] test: gate lifecycle contract to posix --- scripts/check_agentseek_api_lifecycle_contract.py | 7 +++++++ tests/test_agentseek_api_lifecycle_contract.py | 12 +++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/scripts/check_agentseek_api_lifecycle_contract.py b/scripts/check_agentseek_api_lifecycle_contract.py index b2da8d67..2d800529 100644 --- a/scripts/check_agentseek_api_lifecycle_contract.py +++ b/scripts/check_agentseek_api_lifecycle_contract.py @@ -18,12 +18,18 @@ _HELPER_PROCESS_GROUP_GRACE_SECONDS = 1.0 _FALLBACK_REAP_TIMEOUT_SECONDS = 5.0 _PROCESS_GROUP_POLL_SECONDS = 0.05 +_POSIX_ONLY_CONTRACT_DIAGNOSTIC = "published agentseek-api lifecycle contract requires POSIX process-group support" def _toml_string(value: str | Path) -> str: return json.dumps(str(value), ensure_ascii=False) +def _require_posix_contract_platform() -> None: + if os.name != "posix": + raise RuntimeError(_POSIX_ONLY_CONTRACT_DIAGNOSTIC) + + def _write_api_capture_helper(root: Path, output: Path, process_marker: Path) -> Path: helper = root / "capture_api_environment.py" changed_dotenv = ( @@ -165,6 +171,7 @@ def _run_agentseek( def main() -> int: + _require_posix_contract_platform() actual_api_version = version("agentseek-api") if actual_api_version != MINIMUM_AGENTSEEK_API_VERSION: message = f"expected agentseek-api {MINIMUM_AGENTSEEK_API_VERSION}, got {actual_api_version}" diff --git a/tests/test_agentseek_api_lifecycle_contract.py b/tests/test_agentseek_api_lifecycle_contract.py index 79b3d89a..75126b37 100644 --- a/tests/test_agentseek_api_lifecycle_contract.py +++ b/tests/test_agentseek_api_lifecycle_contract.py @@ -9,7 +9,7 @@ import sys import time from pathlib import Path -from types import ModuleType +from types import ModuleType, SimpleNamespace import pytest @@ -70,6 +70,16 @@ def _force_stop(pid: int) -> None: return +def test_contract_main_rejects_windows_before_helper_generation(monkeypatch: pytest.MonkeyPatch) -> None: + contract = _load_contract_script() + monkeypatch.setattr(contract, "os", SimpleNamespace(name="nt")) + + with pytest.raises(RuntimeError) as result: + contract.main() + + assert str(result.value) == "published agentseek-api lifecycle contract requires POSIX process-group support" + + @pytest.mark.skipif(os.name == "nt", reason="the contract timeout fallback requires POSIX process groups") def test_timeout_fallback_reaps_actual_agentseek_parent_and_separate_session_helper(tmp_path: Path) -> None: contract = _load_contract_script() From d213f58f1bddd841b31ab1f0705f9c40b7ab090b Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Mon, 17 Aug 2026 00:50:51 +0800 Subject: [PATCH 15/21] docs: define lifecycle environment ownership contract --- docs/get-started/index.md | 9 ++-- docs/get-started/index.zh.md | 5 ++- docs/guides/create-template.md | 9 ++++ docs/guides/create-template.zh.md | 7 +++ docs/reference/lifecycle-spec.md | 41 ++++++++++++----- docs/reference/lifecycle-spec.zh.md | 30 +++++++++---- docs/reference/template-authoring-contract.md | 19 +++++--- .../template-authoring-contract.zh.md | 12 +++-- .../references/agentseek-lifecycle.md | 11 ++++- tests/test_docs_lifecycle.py | 44 +++++++++++++++++++ 10 files changed, 149 insertions(+), 38 deletions(-) diff --git a/docs/get-started/index.md b/docs/get-started/index.md index baaa150e..620c271f 100644 --- a/docs/get-started/index.md +++ b/docs/get-started/index.md @@ -44,10 +44,11 @@ agentseek task frontend Set the model and provider credentials required by the selected template in `.env` or the environment used to run AgentSeek. -`.env` is used by AgentSeek only for lifecycle environment checks declared by -the template. During `agentseek dev`, it is passed to long-running child -processes; non-empty exported shell variables take precedence, while an empty -exported value is treated as unset. +For `agentseek dev`, AgentSeek reads the project `env_file` once, overlays +non-empty launch variables once, and reuses that immutable snapshot for +readiness and every long-running process. Lifecycle defaults are checks only. +One-shot `agentseek task` commands keep their normal launch environment and do +not inherit `env_file`. ## Check and run diff --git a/docs/get-started/index.zh.md b/docs/get-started/index.zh.md index e1c9e22e..06f25050 100644 --- a/docs/get-started/index.zh.md +++ b/docs/get-started/index.zh.md @@ -41,8 +41,9 @@ agentseek task frontend 在 `.env` 或运行 AgentSeek 的环境里,设置所选模板需要的模型和 provider 凭证。 -AgentSeek 使用 `.env` 检查模板声明的生命周期环境需求。在 `agentseek dev` -期间,它也会传给长运行子进程;非空的显式 shell 变量优先,空值视为未设置。 +对于 `agentseek dev`,AgentSeek 只读取一次项目 `env_file`,只覆盖一次非空启动变量, +并将同一个 immutable snapshot 复用于 readiness 和每个长运行进程。生命周期默认值仅用于 +检查。一次性的 `agentseek task` 命令保留其正常启动环境,不继承 `env_file`。 ## 检查并运行 diff --git a/docs/guides/create-template.md b/docs/guides/create-template.md index f08d9e03..d4f1be6f 100644 --- a/docs/guides/create-template.md +++ b/docs/guides/create-template.md @@ -150,6 +150,15 @@ Use `sync` for Python or backend dependencies and `frontend` for a separate frontend dependency tree. Put all long-running local processes under `[processes.*]` so `agentseek dev` owns the documented development stack. +### Released API contract + +Templates that run agentseek-api require `agentseek-api >= 0.2.2` and pin one +exact published version in the generated dependency file. Lifecycle process +commands use direct argv. Shell wrappers, duplicated dotenv loading, and +editable or local API checkouts do not satisfy the release contract. The exact +version pin and catalog digest are delivered in the later template/catalog +stage, not by AgentSeek core. + Servers bind to loopback by default. If remote development is supported, add documented host overrides. A browser frontend must derive the backend host from the browser location or accept an explicit public API URL. diff --git a/docs/guides/create-template.zh.md b/docs/guides/create-template.zh.md index c9bfed36..2651453d 100644 --- a/docs/guides/create-template.zh.md +++ b/docs/guides/create-template.zh.md @@ -137,6 +137,13 @@ command = ["uv", "sync"] Python 或 backend 依赖统一使用 `sync`,独立 frontend 依赖树使用 `frontend`。所有长时间运行的本地进程都放在 `[processes.*]` 下,让 `agentseek dev` 管理文档中的完整开发环境。 +### 已发布 API 契约 + +运行 agentseek-api 的模板需要 `agentseek-api >= 0.2.2`,并在生成的依赖文件中 pin 一个 +exact published version。生命周期 process command 使用 direct argv。shell wrapper、重复 +dotenv 加载,以及 editable 或本地 API checkout 都不满足发布契约。精确版本 pin 与 catalog +digest 在后续 template/catalog 阶段交付,不由 AgentSeek core 提供。 + Server 默认绑定 loopback。支持远程开发时,增加并说明 host override。浏览器 frontend 必须根据浏览器地址推导 backend host,或接受显式 public API URL。 ## 6. 编写两层 README diff --git a/docs/reference/lifecycle-spec.md b/docs/reference/lifecycle-spec.md index 265224bf..ae7f0ed2 100644 --- a/docs/reference/lifecycle-spec.md +++ b/docs/reference/lifecycle-spec.md @@ -6,6 +6,8 @@ runs: no verified_on: 2026-07-28 sources: - src/agentseek/cli/lifecycle/spec.py + - src/agentseek/cli/lifecycle/environment.py + - src/agentseek/cli/lifecycle/compatibility.py - src/agentseek/cli/lifecycle/core.py - src/agentseek/cli/lifecycle/authored.py - src/agentseek/cli/lifecycle/normalize.py @@ -91,7 +93,7 @@ command = ["npm", "install", "--prefix", "frontend"] | Section | Purpose | | --- | --- | -| `env_file` | Optional project-local env file used for declared checks and `agentseek dev` child processes. Shell variables take precedence. | +| `env_file` | Optional project-local dotenv file resolved once by `agentseek dev` for declared checks and long-running child processes. | | `tools` | Required executables used by the project. | | `paths` | Required local files or directories. | | `env.` | Environment variables AgentSeek should check. Defaults are lower priority than `env_file` and shell variables. | @@ -105,27 +107,42 @@ than `0` and no greater than `300`; `attempts` is a positive integer. ## Environment Checks -AgentSeek checks environment requirements from lifecycle defaults, the optional -`env_file`, and the current process environment: +AgentSeek resolves one immutable snapshot per non-dry-run `agentseek dev` +invocation. The snapshot is created from the project `env_file` and non-empty +launch environment values: ```text -lifecycle default < env_file < shell environment +lifecycle env_file < non-empty launch environment ``` -An empty exported shell value is treated as unset, so the next non-empty -source is used consistently by readiness checks and spawned child processes. - -Only keys declared under `[env.]` and their aliases are used for -readiness checks. During `agentseek dev`, values from the project `env_file` -are passed to long-running child processes, with the current shell environment -applied last. Lifecycle defaults are not injected into child processes. +In a lifecycle dotenv, `KEY=` is a present empty assignment, while bare `KEY` +assigns nothing. An empty raw launch value is omitted before the snapshot is +created, so a dotenv value can fill it. Once a key is present in the snapshot, +including as `""`, it is final at the child boundary. + +Readiness, the internal preflight, and every long-running child consume the +same snapshot. Lifecycle defaults may satisfy readiness but never enter the +snapshot. Only declared `[env.]` keys and aliases participate in +readiness checks. The child receives only final values: AgentSeek does not send +source paths, provenance, or instructions to repeat resolution. A child may +fill absent keys from its own lower sources but may not replace inherited +present keys. `agentseek task` does not inherit lifecycle `env_file`; its +behavior is unchanged. + +Lifecycle processes using the API completion contract require +`agentseek-api >= 0.2.2`. `agentseek dev --dry-run` prints the plan without +reading the lifecycle dotenv. A missing, undecodable, or malformed dotenv +creates no partial snapshot, starts no child, and returns `exit 2` with a +value-free diagnostic; bare `KEY` remains valid syntax. ## Lifecycle v1 first-phase scope Version 1 supports required tools, required paths, project environment requirements, HTTP live checks, long-running processes, and one-shot tasks. It does not support optional tool/path checks, TCP checks, process env -overrides, multiple env files, or env interpolation. +overrides, or multiple env files. It adds no lifecycle-schema interpolation +mode: a configured `env_file` uses the supported python-dotenv file-local +interpolation semantics. ## Lifecycle v2 authored fields diff --git a/docs/reference/lifecycle-spec.zh.md b/docs/reference/lifecycle-spec.zh.md index 5eabfebb..1a1ee37e 100644 --- a/docs/reference/lifecycle-spec.zh.md +++ b/docs/reference/lifecycle-spec.zh.md @@ -6,6 +6,8 @@ runs: no verified_on: 2026-07-28 sources: - src/agentseek/cli/lifecycle/spec.py + - src/agentseek/cli/lifecycle/environment.py + - src/agentseek/cli/lifecycle/compatibility.py - src/agentseek/cli/lifecycle/core.py - src/agentseek/cli/lifecycle/authored.py - src/agentseek/cli/lifecycle/normalize.py @@ -91,7 +93,7 @@ command = ["npm", "install", "--prefix", "frontend"] | 段落 | 作用 | | --- | --- | -| `env_file` | 可选项目本地 env 文件,用于声明的环境检查和 `agentseek dev` 子进程。shell 变量优先。 | +| `env_file` | 可选的项目本地 dotenv 文件,由 `agentseek dev` 解析一次,用于声明的环境检查和长运行子进程。 | | `tools` | 项目需要的可执行文件。 | | `paths` | 必需的本地文件或目录。 | | `env.` | AgentSeek 应检查的环境变量。默认值优先级低于 `env_file` 和 shell 变量。 | @@ -105,23 +107,33 @@ command = ["npm", "install", "--prefix", "frontend"] ## 环境检查 -AgentSeek 从生命周期默认值、可选 `env_file` 和当前进程环境检查环境需求: +每次非 dry-run 的 `agentseek dev` 调用都会解析一个 immutable snapshot。该快照由 +项目 `env_file` 与非空的启动环境值创建: ```text -lifecycle default < env_file < shell environment +lifecycle env_file < non-empty launch environment ``` -显式导出的空 shell 值视为未设置,因此就绪检查和启动的子进程都会一致地 -使用下一个非空来源。 +在生命周期 dotenv 中,`KEY=` 表示一个存在但为空的赋值;裸 `KEY` 不产生赋值。 +原始启动值为空时,会在创建快照前省略,因此 dotenv 值可以补上它。键一旦出现在 +snapshot 中,即使值为 `""`,在 child boundary 也不再可替换。 -只有 `[env.]` 下声明的 key 及其 aliases 会从 `env_file` 读取以检查就绪。 -模板不需要声明项目可能使用的每一个运行时变量。`agentseek dev` 会把项目 env -文件传给长运行子进程,当前 shell 环境最后应用;生命周期默认值不会注入子进程。 +readiness、内部 preflight 与每个长运行 child 都使用同一个 snapshot。生命周期默认值 +可以满足 readiness,但绝不会进入 snapshot。只有声明在 `[env.]` 的 key 及其 +aliases 会参与 readiness 检查。child 只接收最终值;AgentSeek 不传递源路径、provenance +或要求再次解析的指令。child 可以从自己的低优先级来源补齐缺失 key,但不能替换已继承 +的 key。`agentseek task` 不继承生命周期 `env_file`,其行为保持不变。 + +使用 API completion contract 的生命周期进程需要 +`agentseek-api >= 0.2.2`。`agentseek dev --dry-run` 只打印计划,不读取生命周期 +dotenv。缺失、无法解码或 malformed dotenv 会在任何 child 启动前返回 `exit 2`,不创建 +部分 snapshot,且诊断不得包含值;裸 `KEY` 仍是有效语法。 ## 生命周期 v1 第一阶段范围 Version 1 支持必需工具、必需路径、项目环境需求、HTTP live 检查、长运行进程和一次性任务。 -它不支持可选 tool/path 检查、TCP 检查、进程级环境覆盖、多个 env 文件或 env 插值。 +它不支持可选 tool/path 检查、TCP 检查、进程级环境覆盖或多个 env 文件。生命周期 schema +不新增独立插值模式:配置的 `env_file` 仍采用受支持的 python-dotenv 文件内插值语义。 ## 生命周期 v2 编写字段 diff --git a/docs/reference/template-authoring-contract.md b/docs/reference/template-authoring-contract.md index 63f20f3c..397c5685 100644 --- a/docs/reference/template-authoring-contract.md +++ b/docs/reference/template-authoring-contract.md @@ -81,17 +81,24 @@ core repository and exact dependency snapshot recorded by the catalog release; normal template changes must not replace them with the catalog repository or a mutable branch. -Environment resolution for lifecycle checks: +Readiness-only environment resolution: ```text lifecycle default < env_file < shell environment ``` -Lifecycle defaults and `.env` values validate readiness. AgentSeek does not -inject lifecycle defaults into child processes. The project `env_file` is -passed to long-running `agentseek dev` child processes, and shell variables -take precedence. Process commands may load any additional runtime -configuration themselves. +For `agentseek dev`, AgentSeek resolves `env_file` once, overlays non-empty +launch values once, and passes one immutable snapshot to readiness and every +long-running child. Lifecycle defaults validate readiness only and never enter +the child snapshot; `agentseek task` keeps its normal launch environment and +does not inherit lifecycle `env_file`. + +Templates that run agentseek-api require `agentseek-api >= 0.2.2` and pin one +exact published version in the generated dependency file. Lifecycle process +commands use direct argv. Shell wrappers, duplicated dotenv loading, and +editable or local API checkouts do not satisfy the release contract. The exact +version pin and catalog digest are delivered in the later template/catalog +stage, not by AgentSeek core. ## Task Names diff --git a/docs/reference/template-authoring-contract.zh.md b/docs/reference/template-authoring-contract.zh.md index 57132905..d036a77b 100644 --- a/docs/reference/template-authoring-contract.zh.md +++ b/docs/reference/template-authoring-contract.zh.md @@ -73,14 +73,20 @@ sources: `_agentseek_source_ref`。它们必须指向 catalog release 配对的 core 仓库与精确 依赖快照;常规模板修改不能把它们替换为 catalog 仓库或可变分支。 -生命周期检查的环境变量优先级: +仅用于 readiness 的环境变量优先级: ```text lifecycle default < env_file < shell environment ``` -生命周期默认值只用于检查就绪状态,不会注入子进程。`agentseek dev` 会把项目 -`.env` 传给长运行子进程,且 shell 变量优先;process command 仍可自行加载额外运行配置。 +对于 `agentseek dev`,AgentSeek 只解析一次 `env_file`,只覆盖一次非空启动值,并将一个 +immutable snapshot 传给 readiness 和所有长运行 child。生命周期默认值只验证 readiness, +不会进入 child snapshot;`agentseek task` 保留其正常启动环境,不继承生命周期 `env_file`。 + +运行 agentseek-api 的模板需要 `agentseek-api >= 0.2.2`,并在生成的依赖文件中 pin 一个 +exact published version。生命周期 process command 使用 direct argv。shell wrapper、重复 +dotenv 加载,以及 editable 或本地 API checkout 都不满足发布契约。精确版本 pin 与 catalog +digest 在后续 template/catalog 阶段交付,不由 AgentSeek core 提供。 ## Task 命名 diff --git a/src/skills/agentseek-lifecycle/references/agentseek-lifecycle.md b/src/skills/agentseek-lifecycle/references/agentseek-lifecycle.md index f6b9799c..84272ecb 100644 --- a/src/skills/agentseek-lifecycle/references/agentseek-lifecycle.md +++ b/src/skills/agentseek-lifecycle/references/agentseek-lifecycle.md @@ -24,13 +24,20 @@ Projects may expose additional spec tasks. Run them through `agentseek task`. - Declare tools under `[tools]` with a `required` list. - Declare file and directory prerequisites under `[paths]` with a `required` list. - Declare only environment variables AgentSeek should check under `[env.]`. Defaults are lower priority than `env_file` and shell variables. -- Use top-level `env_file` when AgentSeek should read a project-local env file for checks and development processes. During `agentseek dev`, values from this file are passed to child processes; explicitly exported shell variables take precedence. +- `agentseek dev` resolves `env_file` once, overlays non-empty launch values, and reuses one immutable snapshot for checks and all long-running child processes. +- Lifecycle defaults are readiness-only. A dotenv `KEY=` remains present and empty; bare `KEY` contributes no child assignment. +- A missing, undecodable, or malformed dotenv returns `exit 2` before any child starts; no partial snapshot or value-bearing diagnostic is allowed. +- `agentseek task` does not inherit lifecycle `env_file`. +- Child commands receive final values, not source instructions. An agentseek-api child using this contract requires `agentseek-api >= 0.2.2`. +- Keep process commands as direct argv arrays; do not add shell wrappers to repair precedence. - Put public service URLs under `[services.]`. - Put long-running process commands under `[processes.]`. Do not declare process-level environment overrides. - Put task commands under `[tasks.]`. Task `cwd` values are project-relative and must exist before the task starts. Version 1 deliberately does not support optional tool/path checks, TCP checks, -process env overrides, multiple env files, or env interpolation. +process env overrides, or multiple env files. It adds no lifecycle-schema +interpolation mode: a configured `env_file` uses the supported python-dotenv +file-local interpolation semantics. ## Command Semantics diff --git a/tests/test_docs_lifecycle.py b/tests/test_docs_lifecycle.py index 870c57dc..2a8f42d2 100644 --- a/tests/test_docs_lifecycle.py +++ b/tests/test_docs_lifecycle.py @@ -8,6 +8,8 @@ import pytest +from agentseek.cli.lifecycle.compatibility import MINIMUM_AGENTSEEK_API_VERSION + ROOT = Path(__file__).resolve().parents[1] TEMPLATES_ROOT = ROOT / "templates" TEMPLATE_INDEX = TEMPLATES_ROOT / "index.json" @@ -394,3 +396,45 @@ def test_choose_template_guides_match_locked_catalog_runtime(guide: Path) -> Non assert "langgraph dev" in text, guide assert "agentseek-api dev" not in text, guide + + +@pytest.mark.parametrize( + "reference", + ( + *LIFECYCLE_REFERENCES, + ROOT / "src" / "skills" / "agentseek-lifecycle" / "references" / "agentseek-lifecycle.md", + ), +) +def test_lifecycle_references_define_the_immutable_environment_boundary(reference: Path) -> None: + """Lifecycle references must describe the one-time child environment contract.""" + text = reference.read_text(encoding="utf-8") + lines = text.splitlines() + + assert "immutable snapshot" in text + assert "`KEY=`" in text + assert "`KEY`" in text + assert "malformed dotenv" in text + assert "exit 2" in text + assert f"`agentseek-api >= {MINIMUM_AGENTSEEK_API_VERSION}`" in text + assert any("`agentseek dev`" in line and "snapshot" in line for line in lines) + assert any("`agentseek task`" in line and "`env_file`" in line for line in lines) + assert "multiple env files, or env interpolation." not in text + assert "多个 env 文件或 env 插值" not in text + + +@pytest.mark.parametrize( + "reference", + ( + ROOT / "docs" / "guides" / "create-template.md", + ROOT / "docs" / "guides" / "create-template.zh.md", + ROOT / "docs" / "reference" / "template-authoring-contract.md", + ROOT / "docs" / "reference" / "template-authoring-contract.zh.md", + ), +) +def test_template_authoring_requires_a_compatible_released_api(reference: Path) -> None: + """Template authors must pin a released runtime API with direct process argv.""" + text = reference.read_text(encoding="utf-8") + + assert f"`agentseek-api >= {MINIMUM_AGENTSEEK_API_VERSION}`" in text + assert "exact published version" in text + assert "direct argv" in text From 57073363887b7c018a4fa10d9b44cec818ff7ae9 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Mon, 17 Aug 2026 01:03:54 +0800 Subject: [PATCH 16/21] docs: clarify lifecycle environment boundaries --- docs/get-started/index.zh.md | 4 +- docs/guides/create-template.md | 9 ++-- docs/guides/create-template.zh.md | 14 ++++-- docs/reference/lifecycle-spec.md | 46 +++++++++++-------- docs/reference/lifecycle-spec.zh.md | 41 +++++++++++------ docs/reference/template-authoring-contract.md | 14 ++++-- .../template-authoring-contract.zh.md | 19 +++++--- .../references/agentseek-lifecycle.md | 13 ++++-- tests/cli_commands/test_lifecycle_authored.py | 7 ++- tests/test_docs_lifecycle.py | 26 +++++++++++ 10 files changed, 132 insertions(+), 61 deletions(-) diff --git a/docs/get-started/index.zh.md b/docs/get-started/index.zh.md index 06f25050..c531655d 100644 --- a/docs/get-started/index.zh.md +++ b/docs/get-started/index.zh.md @@ -42,8 +42,8 @@ agentseek task frontend 在 `.env` 或运行 AgentSeek 的环境里,设置所选模板需要的模型和 provider 凭证。 对于 `agentseek dev`,AgentSeek 只读取一次项目 `env_file`,只覆盖一次非空启动变量, -并将同一个 immutable snapshot 复用于 readiness 和每个长运行进程。生命周期默认值仅用于 -检查。一次性的 `agentseek task` 命令保留其正常启动环境,不继承 `env_file`。 +并将同一个不可变快照(immutable snapshot)复用于就绪检查和每个长运行进程。生命周期 +默认值仅用于检查。一次性的 `agentseek task` 命令保留其正常启动环境,不继承 `env_file`。 ## 检查并运行 diff --git a/docs/guides/create-template.md b/docs/guides/create-template.md index d4f1be6f..00ec1669 100644 --- a/docs/guides/create-template.md +++ b/docs/guides/create-template.md @@ -97,9 +97,12 @@ adapters. Add provider-specific keys only when the selected SDK requires them. Document how runtime code maps aliases and which value wins. Declare the same required names under `[env.*]` in the lifecycle file. AgentSeek -uses those declarations for readiness checks. During `agentseek dev`, the -project `.env` is also passed to long-running child processes, with exported -shell variables taking precedence. +uses those declarations for readiness checks. For non-dry-run `agentseek dev`, +it reads `env_file` once, overlays non-empty launch values once, and reuses one +immutable snapshot for readiness and all long-running child processes. +Lifecycle defaults remain checks only. A dotenv `KEY=` is present and empty, +while bare `KEY` assigns nothing. One-shot `agentseek task` commands keep their +normal launch environment and do not inherit `env_file`. ## 5. Define The Lifecycle diff --git a/docs/guides/create-template.zh.md b/docs/guides/create-template.zh.md index 2651453d..e7ca750c 100644 --- a/docs/guides/create-template.zh.md +++ b/docs/guides/create-template.zh.md @@ -89,7 +89,10 @@ AGENTSEEK_API_BASE= 应用在多个原生 provider adapter 之间切换时,增加 `AGENTSEEK_MODEL_PROVIDER`。只有所选 SDK 确实要求时,才增加 provider 专属密钥。文档必须说明运行时代码如何映射别名,以及冲突时谁优先。 在 lifecycle 文件的 `[env.*]` 中声明同一组必需名称。AgentSeek 用这些声明检查 -就绪状态;`agentseek dev` 会把项目 `.env` 传给长运行子进程,shell 变量优先。 +就绪状态。对于非 dry-run 的 `agentseek dev`,它只读取一次 `env_file`,只覆盖一次 +非空启动值,并将同一个不可变快照(immutable snapshot)复用于就绪检查和所有长运行 +子进程。生命周期默认值只用于检查;dotenv 中的 `KEY=` 表示存在但为空,裸 `KEY` 不产生 +赋值。一次性的 `agentseek task` 命令保留正常启动环境,不继承 `env_file`。 ## 5. 定义生命周期 @@ -139,10 +142,11 @@ Python 或 backend 依赖统一使用 `sync`,独立 frontend 依赖树使用 ` ### 已发布 API 契约 -运行 agentseek-api 的模板需要 `agentseek-api >= 0.2.2`,并在生成的依赖文件中 pin 一个 -exact published version。生命周期 process command 使用 direct argv。shell wrapper、重复 -dotenv 加载,以及 editable 或本地 API checkout 都不满足发布契约。精确版本 pin 与 catalog -digest 在后续 template/catalog 阶段交付,不由 AgentSeek core 提供。 +运行 agentseek-api 的模板需要 `agentseek-api >= 0.2.2`,并在生成的依赖文件中固定一个 +已发布的精确版本(exact published version)。生命周期进程命令使用直接参数数组 +(direct argv)。Shell 包装、重复 dotenv 加载,以及 editable 或本地 API checkout 都不 +满足发布契约。精确版本 pin 与 catalog digest 在后续 template/catalog 阶段交付,不由 +AgentSeek core 提供。 Server 默认绑定 loopback。支持远程开发时,增加并说明 host override。浏览器 frontend 必须根据浏览器地址推导 backend host,或接受显式 public API URL。 diff --git a/docs/reference/lifecycle-spec.md b/docs/reference/lifecycle-spec.md index ae7f0ed2..03ed5693 100644 --- a/docs/reference/lifecycle-spec.md +++ b/docs/reference/lifecycle-spec.md @@ -3,12 +3,16 @@ title: Lifecycle Spec type: reference audience: [A2] runs: no -verified_on: 2026-07-28 +verified_on: 2026-08-17 sources: - src/agentseek/cli/lifecycle/spec.py - src/agentseek/cli/lifecycle/environment.py + - src/agentseek/cli/lifecycle/dotenv_adapter.py - src/agentseek/cli/lifecycle/compatibility.py - src/agentseek/cli/lifecycle/core.py + - src/agentseek/cli/commands/dev.py + - src/agentseek/cli/commands/doctor.py + - src/agentseek/cli/commands/info.py - src/agentseek/cli/lifecycle/authored.py - src/agentseek/cli/lifecycle/normalize.py - src/agentseek/cli/lifecycle/json_output.py @@ -108,32 +112,38 @@ than `0` and no greater than `300`; `attempts` is a positive integer. ## Environment Checks AgentSeek resolves one immutable snapshot per non-dry-run `agentseek dev` -invocation. The snapshot is created from the project `env_file` and non-empty -launch environment values: +invocation. It captures the launch environment once, then creates the snapshot +from the project `env_file` and non-empty captured launch environment values: ```text -lifecycle env_file < non-empty launch environment +lifecycle env_file < non-empty captured launch environment ``` -In a lifecycle dotenv, `KEY=` is a present empty assignment, while bare `KEY` -assigns nothing. An empty raw launch value is omitted before the snapshot is -created, so a dotenv value can fill it. Once a key is present in the snapshot, -including as `""`, it is final at the child boundary. +Bounded python-dotenv resolves physical bindings in order and falls back to the +captured launch environment. In a lifecycle dotenv, `KEY=` is a present empty +assignment, while bare `KEY` assigns nothing. An empty raw launch value is +omitted before the snapshot is created, so a dotenv value can fill it. Readiness, the internal preflight, and every long-running child consume the same snapshot. Lifecycle defaults may satisfy readiness but never enter the snapshot. Only declared `[env.]` keys and aliases participate in -readiness checks. The child receives only final values: AgentSeek does not send -source paths, provenance, or instructions to repeat resolution. A child may -fill absent keys from its own lower sources but may not replace inherited -present keys. `agentseek task` does not inherit lifecycle `env_file`; its -behavior is unchanged. +readiness checks. AgentSeek guarantees only the initial child environment/snapshot, +which contains resolved values, not source paths, provenance, or instructions +to repeat resolution. Compatible child configuration completion may fill absent +keys but must not replace inherited present keys. Arbitrary child code can +mutate its own process environment; the prohibition against +duplicated override-loading is an authoring rule, not an AgentSeek enforcement +claim. `agentseek task` does not inherit lifecycle `env_file`; its behavior is +unchanged. Lifecycle processes using the API completion contract require `agentseek-api >= 0.2.2`. `agentseek dev --dry-run` prints the plan without -reading the lifecycle dotenv. A missing, undecodable, or malformed dotenv -creates no partial snapshot, starts no child, and returns `exit 2` with a -value-free diagnostic; bare `KEY` remains valid syntax. +reading the lifecycle dotenv. The missing, undecodable, or malformed dotenv +guarantee applies only to non-dry-run `agentseek dev`: it creates no partial +snapshot, starts no child, and returns `exit 2` with a value-free diagnostic; +bare `KEY` remains valid syntax. Standalone `agentseek info` reports dotenv status without +creating a snapshot. Standalone `agentseek doctor --strict` +renders readiness failures, such as a missing dotenv, and returns `exit 1`. ## Lifecycle v1 first-phase scope @@ -141,8 +151,8 @@ Version 1 supports required tools, required paths, project environment requirements, HTTP live checks, long-running processes, and one-shot tasks. It does not support optional tool/path checks, TCP checks, process env overrides, or multiple env files. It adds no lifecycle-schema interpolation -mode: a configured `env_file` uses the supported python-dotenv file-local -interpolation semantics. +mode: for a configured `env_file`, bounded python-dotenv resolves physical +bindings in order and falls back to the captured launch environment. ## Lifecycle v2 authored fields diff --git a/docs/reference/lifecycle-spec.zh.md b/docs/reference/lifecycle-spec.zh.md index 1a1ee37e..24f7d2c4 100644 --- a/docs/reference/lifecycle-spec.zh.md +++ b/docs/reference/lifecycle-spec.zh.md @@ -3,12 +3,16 @@ title: 生命周期规范 type: reference audience: [A2] runs: no -verified_on: 2026-07-28 +verified_on: 2026-08-17 sources: - src/agentseek/cli/lifecycle/spec.py - src/agentseek/cli/lifecycle/environment.py + - src/agentseek/cli/lifecycle/dotenv_adapter.py - src/agentseek/cli/lifecycle/compatibility.py - src/agentseek/cli/lifecycle/core.py + - src/agentseek/cli/commands/dev.py + - src/agentseek/cli/commands/doctor.py + - src/agentseek/cli/commands/info.py - src/agentseek/cli/lifecycle/authored.py - src/agentseek/cli/lifecycle/normalize.py - src/agentseek/cli/lifecycle/json_output.py @@ -107,33 +111,40 @@ command = ["npm", "install", "--prefix", "frontend"] ## 环境检查 -每次非 dry-run 的 `agentseek dev` 调用都会解析一个 immutable snapshot。该快照由 -项目 `env_file` 与非空的启动环境值创建: +每次非 dry-run(non-dry-run)的 `agentseek dev` 调用都会只创建一次不可变快照(immutable snapshot)。 +它会先捕获启动环境(captured launch environment),再用项目 `env_file` 与其中的非空值创建该快照: ```text -lifecycle env_file < non-empty launch environment +lifecycle env_file < non-empty captured launch environment ``` -在生命周期 dotenv 中,`KEY=` 表示一个存在但为空的赋值;裸 `KEY` 不产生赋值。 -原始启动值为空时,会在创建快照前省略,因此 dotenv 值可以补上它。键一旦出现在 -snapshot 中,即使值为 `""`,在 child boundary 也不再可替换。 +受限的 python-dotenv 会按文件中物理绑定出现的顺序(physical bindings in order)解析, +并在文件内没有值时回退到已捕获的启动环境。在生命周期 dotenv 中,`KEY=` 表示一个 +存在但为空的赋值;裸 `KEY` 不产生赋值。原始启动值为空时,会在创建快照前省略,因此 +dotenv 值可以补上它。 -readiness、内部 preflight 与每个长运行 child 都使用同一个 snapshot。生命周期默认值 -可以满足 readiness,但绝不会进入 snapshot。只有声明在 `[env.]` 的 key 及其 -aliases 会参与 readiness 检查。child 只接收最终值;AgentSeek 不传递源路径、provenance -或要求再次解析的指令。child 可以从自己的低优先级来源补齐缺失 key,但不能替换已继承 -的 key。`agentseek task` 不继承生命周期 `env_file`,其行为保持不变。 +就绪检查、内部预检与每个长运行子进程都使用同一个快照。生命周期默认值可以满足 +就绪检查,但绝不会进入快照。只有声明在 `[env.]` 的 key 及其 aliases 会参与 +就绪检查。AgentSeek 只保证初始子进程环境/快照(initial child environment/snapshot): +其中只有已解析的值,不包含源路径、provenance 或要求再次解析的指令。兼容的子进程配置 +补全可以填入缺失 key,但不得替换继承的已有 key。任意子进程代码(arbitrary child code) +仍可自行修改其进程环境;禁止重复加载覆盖配置只是模板编写约束,不是 AgentSeek 的 +强制保证。`agentseek task` 不继承生命周期 `env_file`,其行为保持不变。 使用 API completion contract 的生命周期进程需要 `agentseek-api >= 0.2.2`。`agentseek dev --dry-run` 只打印计划,不读取生命周期 -dotenv。缺失、无法解码或 malformed dotenv 会在任何 child 启动前返回 `exit 2`,不创建 -部分 snapshot,且诊断不得包含值;裸 `KEY` 仍是有效语法。 +dotenv。缺失、无法解码或 malformed dotenv 的严格保证只适用于非 dry-run 的 +`agentseek dev`:它会在任何子进程启动前返回 `exit 2`,不创建部分快照,且诊断不得 +包含值;裸 `KEY` 仍是有效语法。单独运行 `agentseek info` 仍会报告 dotenv 状态 +(dotenv status),不会创建快照。单独严格运行 `agentseek doctor --strict` 会渲染 +就绪失败(例如 dotenv 缺失),并返回 `exit 1`。 ## 生命周期 v1 第一阶段范围 Version 1 支持必需工具、必需路径、项目环境需求、HTTP live 检查、长运行进程和一次性任务。 它不支持可选 tool/path 检查、TCP 检查、进程级环境覆盖或多个 env 文件。生命周期 schema -不新增独立插值模式:配置的 `env_file` 仍采用受支持的 python-dotenv 文件内插值语义。 +不新增独立插值模式:配置的 `env_file` 使用受限的 python-dotenv,按文件中物理绑定出现的 +顺序解析,并回退到已捕获的启动环境。 ## 生命周期 v2 编写字段 diff --git a/docs/reference/template-authoring-contract.md b/docs/reference/template-authoring-contract.md index 397c5685..1ac965c7 100644 --- a/docs/reference/template-authoring-contract.md +++ b/docs/reference/template-authoring-contract.md @@ -91,14 +91,18 @@ For `agentseek dev`, AgentSeek resolves `env_file` once, overlays non-empty launch values once, and passes one immutable snapshot to readiness and every long-running child. Lifecycle defaults validate readiness only and never enter the child snapshot; `agentseek task` keeps its normal launch environment and -does not inherit lifecycle `env_file`. +does not inherit lifecycle `env_file`. AgentSeek guarantees only the initial +child environment/snapshot; compatible child configuration completion may fill +absent keys but must not replace inherited present keys. Arbitrary child code can +mutate its own process environment. Templates that run agentseek-api require `agentseek-api >= 0.2.2` and pin one exact published version in the generated dependency file. Lifecycle process -commands use direct argv. Shell wrappers, duplicated dotenv loading, and -editable or local API checkouts do not satisfy the release contract. The exact -version pin and catalog digest are delivered in the later template/catalog -stage, not by AgentSeek core. +commands use direct argv. Shell wrappers, duplicated dotenv or override +loading, and editable or local API checkouts do not satisfy the release +contract. The duplicated override-loading prohibition is an authoring rule, +not an AgentSeek enforcement claim. The exact version pin and catalog digest +are delivered in the later template/catalog stage, not by AgentSeek core. ## Task Names diff --git a/docs/reference/template-authoring-contract.zh.md b/docs/reference/template-authoring-contract.zh.md index d036a77b..fc0c829d 100644 --- a/docs/reference/template-authoring-contract.zh.md +++ b/docs/reference/template-authoring-contract.zh.md @@ -80,13 +80,18 @@ lifecycle default < env_file < shell environment ``` 对于 `agentseek dev`,AgentSeek 只解析一次 `env_file`,只覆盖一次非空启动值,并将一个 -immutable snapshot 传给 readiness 和所有长运行 child。生命周期默认值只验证 readiness, -不会进入 child snapshot;`agentseek task` 保留其正常启动环境,不继承生命周期 `env_file`。 - -运行 agentseek-api 的模板需要 `agentseek-api >= 0.2.2`,并在生成的依赖文件中 pin 一个 -exact published version。生命周期 process command 使用 direct argv。shell wrapper、重复 -dotenv 加载,以及 editable 或本地 API checkout 都不满足发布契约。精确版本 pin 与 catalog -digest 在后续 template/catalog 阶段交付,不由 AgentSeek core 提供。 +不可变快照(immutable snapshot)传给就绪检查和所有长运行子进程。生命周期默认值只验证 +就绪检查,不会进入子进程快照;`agentseek task` 保留其正常启动环境,不继承生命周期 +`env_file`。AgentSeek 只保证初始子进程环境/快照 +(initial child environment/snapshot):兼容的子进程配置补全可以填入缺失 key, +但不得替换继承的已有 key;任意子进程代码仍可自行修改其进程环境。 + +运行 agentseek-api 的模板需要 `agentseek-api >= 0.2.2`,并在生成的依赖文件中固定一个 +已发布的精确版本(exact published version)。生命周期进程命令使用直接参数数组 +(direct argv)。Shell 包装、重复 dotenv 或覆盖加载,以及 editable 或本地 API checkout +都不满足发布契约。禁止重复覆盖加载是模板编写约束,不表示 AgentSeek 会对任意子进程 +强制执行。精确版本 pin 与 catalog digest 在后续 template/catalog 阶段交付,不由 AgentSeek +core 提供。 ## Task 命名 diff --git a/src/skills/agentseek-lifecycle/references/agentseek-lifecycle.md b/src/skills/agentseek-lifecycle/references/agentseek-lifecycle.md index 84272ecb..ecdcd427 100644 --- a/src/skills/agentseek-lifecycle/references/agentseek-lifecycle.md +++ b/src/skills/agentseek-lifecycle/references/agentseek-lifecycle.md @@ -24,20 +24,23 @@ Projects may expose additional spec tasks. Run them through `agentseek task`. - Declare tools under `[tools]` with a `required` list. - Declare file and directory prerequisites under `[paths]` with a `required` list. - Declare only environment variables AgentSeek should check under `[env.]`. Defaults are lower priority than `env_file` and shell variables. -- `agentseek dev` resolves `env_file` once, overlays non-empty launch values, and reuses one immutable snapshot for checks and all long-running child processes. +- Only non-dry-run `agentseek dev` resolves `env_file` once, overlays non-empty captured launch environment values, and reuses one immutable snapshot for checks and all long-running child processes. - Lifecycle defaults are readiness-only. A dotenv `KEY=` remains present and empty; bare `KEY` contributes no child assignment. -- A missing, undecodable, or malformed dotenv returns `exit 2` before any child starts; no partial snapshot or value-bearing diagnostic is allowed. +- Bounded python-dotenv resolves physical bindings in order and falls back to the captured launch environment; the lifecycle schema adds no interpolation mode. +- For non-dry-run `agentseek dev`, a missing, undecodable, or malformed dotenv returns `exit 2` before any child starts; no partial snapshot or value-bearing diagnostic is allowed. Standalone `agentseek info` reports dotenv status, while standalone `agentseek doctor --strict` renders readiness failures and returns `exit 1`. - `agentseek task` does not inherit lifecycle `env_file`. -- Child commands receive final values, not source instructions. An agentseek-api child using this contract requires `agentseek-api >= 0.2.2`. +- AgentSeek guarantees only the initial child environment/snapshot: it has resolved values, not source instructions. Compatible child configuration completion may fill absent keys but must not replace inherited present keys; arbitrary child code can mutate its own process environment. - Keep process commands as direct argv arrays; do not add shell wrappers to repair precedence. +- Do not add duplicated override-loading: it is an authoring prohibition, not an AgentSeek enforcement claim. An agentseek-api child using this contract requires `agentseek-api >= 0.2.2`. - Put public service URLs under `[services.]`. - Put long-running process commands under `[processes.]`. Do not declare process-level environment overrides. - Put task commands under `[tasks.]`. Task `cwd` values are project-relative and must exist before the task starts. Version 1 deliberately does not support optional tool/path checks, TCP checks, process env overrides, or multiple env files. It adds no lifecycle-schema -interpolation mode: a configured `env_file` uses the supported python-dotenv -file-local interpolation semantics. +interpolation mode; configured `env_file` parsing uses bounded python-dotenv, +which resolves physical bindings in order and falls back to the captured launch +environment. ## Command Semantics diff --git a/tests/cli_commands/test_lifecycle_authored.py b/tests/cli_commands/test_lifecycle_authored.py index 57257f6b..9be4493d 100644 --- a/tests/cli_commands/test_lifecycle_authored.py +++ b/tests/cli_commands/test_lifecycle_authored.py @@ -153,24 +153,29 @@ def test_v2_constants_and_public_exports_are_versioned_and_typed() -> None: assert package_spec is AuthoredLifecycleSpec -def test_lifecycle_package_exports_only_the_safe_normalization_boundary() -> None: +def test_lifecycle_package_exports_the_lifecycle_environment_boundary() -> None: import agentseek.cli.lifecycle as lifecycle assert lifecycle.NormalizedLifecycleProject is NormalizedLifecycleProject assert lifecycle.NormalizationWarning is NormalizationWarning assert lifecycle.normalize_lifecycle is normalize_lifecycle assert lifecycle.__all__ == [ + "MINIMUM_AGENTSEEK_API_VERSION", "LIFECYCLE_SPEC_FILE", "REQUIRED_COMMANDS", "SUPPORTED_LIFECYCLE_VERSION", "SUPPORTED_LIFECYCLE_VERSIONS", "AuthoredLifecycleSpec", + "EnvironmentOrigin", + "LifecycleDotenvError", + "LifecycleEnvironmentSnapshot", "LifecycleProject", "NormalizationWarning", "NormalizedLifecycleProject", "lifecycle_spec_exists", "load_lifecycle_project", "normalize_lifecycle", + "resolve_project_environment", "run_lifecycle_task", "run_task_cli", ] diff --git a/tests/test_docs_lifecycle.py b/tests/test_docs_lifecycle.py index 2a8f42d2..938f4ce6 100644 --- a/tests/test_docs_lifecycle.py +++ b/tests/test_docs_lifecycle.py @@ -411,10 +411,19 @@ def test_lifecycle_references_define_the_immutable_environment_boundary(referenc lines = text.splitlines() assert "immutable snapshot" in text + assert "non-dry-run" in text assert "`KEY=`" in text assert "`KEY`" in text assert "malformed dotenv" in text assert "exit 2" in text + assert "`agentseek info`" in text + assert "dotenv status" in text + assert "`agentseek doctor --strict`" in text + assert "exit 1" in text + assert "physical bindings in order" in text + assert "captured launch environment" in text + assert "initial child environment/snapshot" in text + assert "arbitrary child code" in text.lower() assert f"`agentseek-api >= {MINIMUM_AGENTSEEK_API_VERSION}`" in text assert any("`agentseek dev`" in line and "snapshot" in line for line in lines) assert any("`agentseek task`" in line and "`env_file`" in line for line in lines) @@ -438,3 +447,20 @@ def test_template_authoring_requires_a_compatible_released_api(reference: Path) assert f"`agentseek-api >= {MINIMUM_AGENTSEEK_API_VERSION}`" in text assert "exact published version" in text assert "direct argv" in text + + +@pytest.mark.parametrize( + "guide", + ( + ROOT / "docs" / "guides" / "create-template.md", + ROOT / "docs" / "guides" / "create-template.zh.md", + ), +) +def test_template_guides_define_the_one_time_dev_environment_boundary(guide: Path) -> None: + """Template guides must not describe dotenv as a generic child pass-through.""" + text = guide.read_text(encoding="utf-8") + + assert "immutable snapshot" in text + assert "`KEY=`" in text + assert "`agentseek task`" in text + assert "`env_file`" in text From 256f20930f396e08ee6d66fc8033a0e8144b186b Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Mon, 17 Aug 2026 01:12:56 +0800 Subject: [PATCH 17/21] docs: qualify lifecycle snapshot summaries --- docs/get-started/index.md | 2 +- docs/get-started/index.zh.md | 2 +- docs/guides/create-template.zh.md | 8 +- docs/reference/lifecycle-spec.md | 2 +- docs/reference/lifecycle-spec.zh.md | 16 +-- docs/reference/template-authoring-contract.md | 2 +- .../template-authoring-contract.zh.md | 13 +-- tests/test_docs_lifecycle.py | 107 ++++++++++++++++-- 8 files changed, 119 insertions(+), 33 deletions(-) diff --git a/docs/get-started/index.md b/docs/get-started/index.md index 620c271f..374472a4 100644 --- a/docs/get-started/index.md +++ b/docs/get-started/index.md @@ -44,7 +44,7 @@ agentseek task frontend Set the model and provider credentials required by the selected template in `.env` or the environment used to run AgentSeek. -For `agentseek dev`, AgentSeek reads the project `env_file` once, overlays +For non-dry-run `agentseek dev`, AgentSeek reads the project `env_file` once, overlays non-empty launch variables once, and reuses that immutable snapshot for readiness and every long-running process. Lifecycle defaults are checks only. One-shot `agentseek task` commands keep their normal launch environment and do diff --git a/docs/get-started/index.zh.md b/docs/get-started/index.zh.md index c531655d..6d86bad5 100644 --- a/docs/get-started/index.zh.md +++ b/docs/get-started/index.zh.md @@ -41,7 +41,7 @@ agentseek task frontend 在 `.env` 或运行 AgentSeek 的环境里,设置所选模板需要的模型和 provider 凭证。 -对于 `agentseek dev`,AgentSeek 只读取一次项目 `env_file`,只覆盖一次非空启动变量, +对于非 dry-run 的 `agentseek dev`,AgentSeek 只读取一次项目 `env_file`,只覆盖一次非空启动变量, 并将同一个不可变快照(immutable snapshot)复用于就绪检查和每个长运行进程。生命周期 默认值仅用于检查。一次性的 `agentseek task` 命令保留其正常启动环境,不继承 `env_file`。 diff --git a/docs/guides/create-template.zh.md b/docs/guides/create-template.zh.md index e7ca750c..c72eadf1 100644 --- a/docs/guides/create-template.zh.md +++ b/docs/guides/create-template.zh.md @@ -21,7 +21,7 @@ sources: ## 前置条件 -- 本地已有独立 catalog checkout,并已完成 `uv sync`。 +- 本地已有独立 catalog 检出副本,并已完成 `uv sync`。 - 已明确生成应用的目标,并找到一个运行时相近的现有模板。 - 已选择唯一的 `type/name` spec。除非同时扩展 CLI 的类型支持,否则复用 `bub`、`deepagents` 或 `langchain`。 @@ -144,9 +144,9 @@ Python 或 backend 依赖统一使用 `sync`,独立 frontend 依赖树使用 ` 运行 agentseek-api 的模板需要 `agentseek-api >= 0.2.2`,并在生成的依赖文件中固定一个 已发布的精确版本(exact published version)。生命周期进程命令使用直接参数数组 -(direct argv)。Shell 包装、重复 dotenv 加载,以及 editable 或本地 API checkout 都不 -满足发布契约。精确版本 pin 与 catalog digest 在后续 template/catalog 阶段交付,不由 -AgentSeek core 提供。 +(direct argv)。Shell 包装、重复 dotenv 加载,以及可编辑安装或本地 API 检出副本都不 +满足发布契约。精确版本固定与模板目录摘要在后续模板目录阶段交付,不由 AgentSeek +core 提供。 Server 默认绑定 loopback。支持远程开发时,增加并说明 host override。浏览器 frontend 必须根据浏览器地址推导 backend host,或接受显式 public API URL。 diff --git a/docs/reference/lifecycle-spec.md b/docs/reference/lifecycle-spec.md index 03ed5693..d56b570c 100644 --- a/docs/reference/lifecycle-spec.md +++ b/docs/reference/lifecycle-spec.md @@ -97,7 +97,7 @@ command = ["npm", "install", "--prefix", "frontend"] | Section | Purpose | | --- | --- | -| `env_file` | Optional project-local dotenv file resolved once by `agentseek dev` for declared checks and long-running child processes. | +| `env_file` | Optional project-local dotenv file resolved once by non-dry-run `agentseek dev` for declared checks and long-running child processes. | | `tools` | Required executables used by the project. | | `paths` | Required local files or directories. | | `env.` | Environment variables AgentSeek should check. Defaults are lower priority than `env_file` and shell variables. | diff --git a/docs/reference/lifecycle-spec.zh.md b/docs/reference/lifecycle-spec.zh.md index 24f7d2c4..e37c3951 100644 --- a/docs/reference/lifecycle-spec.zh.md +++ b/docs/reference/lifecycle-spec.zh.md @@ -97,7 +97,7 @@ command = ["npm", "install", "--prefix", "frontend"] | 段落 | 作用 | | --- | --- | -| `env_file` | 可选的项目本地 dotenv 文件,由 `agentseek dev` 解析一次,用于声明的环境检查和长运行子进程。 | +| `env_file` | 可选的项目本地 dotenv 文件,仅由非 dry-run 的 `agentseek dev` 解析一次,用于声明的环境检查和长运行子进程。 | | `tools` | 项目需要的可执行文件。 | | `paths` | 必需的本地文件或目录。 | | `env.` | AgentSeek 应检查的环境变量。默认值优先级低于 `env_file` 和 shell 变量。 | @@ -111,23 +111,23 @@ command = ["npm", "install", "--prefix", "frontend"] ## 环境检查 -每次非 dry-run(non-dry-run)的 `agentseek dev` 调用都会只创建一次不可变快照(immutable snapshot)。 -它会先捕获启动环境(captured launch environment),再用项目 `env_file` 与其中的非空值创建该快照: +每次非 dry-run 的 `agentseek dev` 调用都会只创建一次不可变快照(immutable snapshot)。 +它会先捕获启动环境,再用项目 `env_file` 与已捕获启动环境中的非空值创建该快照: ```text lifecycle env_file < non-empty captured launch environment ``` -受限的 python-dotenv 会按文件中物理绑定出现的顺序(physical bindings in order)解析, +受限的 python-dotenv 会按文件中物理绑定出现的顺序解析, 并在文件内没有值时回退到已捕获的启动环境。在生命周期 dotenv 中,`KEY=` 表示一个 存在但为空的赋值;裸 `KEY` 不产生赋值。原始启动值为空时,会在创建快照前省略,因此 dotenv 值可以补上它。 就绪检查、内部预检与每个长运行子进程都使用同一个快照。生命周期默认值可以满足 就绪检查,但绝不会进入快照。只有声明在 `[env.]` 的 key 及其 aliases 会参与 -就绪检查。AgentSeek 只保证初始子进程环境/快照(initial child environment/snapshot): +就绪检查。AgentSeek 只保证初始子进程环境/快照: 其中只有已解析的值,不包含源路径、provenance 或要求再次解析的指令。兼容的子进程配置 -补全可以填入缺失 key,但不得替换继承的已有 key。任意子进程代码(arbitrary child code) +补全可以填入缺失 key,但不得替换继承的已有 key。任意子进程代码 仍可自行修改其进程环境;禁止重复加载覆盖配置只是模板编写约束,不是 AgentSeek 的 强制保证。`agentseek task` 不继承生命周期 `env_file`,其行为保持不变。 @@ -135,8 +135,8 @@ dotenv 值可以补上它。 `agentseek-api >= 0.2.2`。`agentseek dev --dry-run` 只打印计划,不读取生命周期 dotenv。缺失、无法解码或 malformed dotenv 的严格保证只适用于非 dry-run 的 `agentseek dev`:它会在任何子进程启动前返回 `exit 2`,不创建部分快照,且诊断不得 -包含值;裸 `KEY` 仍是有效语法。单独运行 `agentseek info` 仍会报告 dotenv 状态 -(dotenv status),不会创建快照。单独严格运行 `agentseek doctor --strict` 会渲染 +包含值;裸 `KEY` 仍是有效语法。单独运行 `agentseek info` 仍会报告 dotenv 状态, +不会创建快照。单独严格运行 `agentseek doctor --strict` 会渲染 就绪失败(例如 dotenv 缺失),并返回 `exit 1`。 ## 生命周期 v1 第一阶段范围 diff --git a/docs/reference/template-authoring-contract.md b/docs/reference/template-authoring-contract.md index 1ac965c7..ff61905a 100644 --- a/docs/reference/template-authoring-contract.md +++ b/docs/reference/template-authoring-contract.md @@ -87,7 +87,7 @@ Readiness-only environment resolution: lifecycle default < env_file < shell environment ``` -For `agentseek dev`, AgentSeek resolves `env_file` once, overlays non-empty +For non-dry-run `agentseek dev`, AgentSeek resolves `env_file` once, overlays non-empty launch values once, and passes one immutable snapshot to readiness and every long-running child. Lifecycle defaults validate readiness only and never enter the child snapshot; `agentseek task` keeps its normal launch environment and diff --git a/docs/reference/template-authoring-contract.zh.md b/docs/reference/template-authoring-contract.zh.md index fc0c829d..287ca292 100644 --- a/docs/reference/template-authoring-contract.zh.md +++ b/docs/reference/template-authoring-contract.zh.md @@ -79,18 +79,17 @@ sources: lifecycle default < env_file < shell environment ``` -对于 `agentseek dev`,AgentSeek 只解析一次 `env_file`,只覆盖一次非空启动值,并将一个 +对于非 dry-run 的 `agentseek dev`,AgentSeek 只解析一次 `env_file`,只覆盖一次非空启动值,并将一个 不可变快照(immutable snapshot)传给就绪检查和所有长运行子进程。生命周期默认值只验证 就绪检查,不会进入子进程快照;`agentseek task` 保留其正常启动环境,不继承生命周期 -`env_file`。AgentSeek 只保证初始子进程环境/快照 -(initial child environment/snapshot):兼容的子进程配置补全可以填入缺失 key, +`env_file`。AgentSeek 只保证初始子进程环境/快照:兼容的子进程配置补全可以填入缺失 key, 但不得替换继承的已有 key;任意子进程代码仍可自行修改其进程环境。 运行 agentseek-api 的模板需要 `agentseek-api >= 0.2.2`,并在生成的依赖文件中固定一个 已发布的精确版本(exact published version)。生命周期进程命令使用直接参数数组 -(direct argv)。Shell 包装、重复 dotenv 或覆盖加载,以及 editable 或本地 API checkout +(direct argv)。Shell 包装、重复 dotenv 或覆盖加载,以及可编辑安装或本地 API 检出副本 都不满足发布契约。禁止重复覆盖加载是模板编写约束,不表示 AgentSeek 会对任意子进程 -强制执行。精确版本 pin 与 catalog digest 在后续 template/catalog 阶段交付,不由 AgentSeek +强制执行。精确版本固定与模板目录摘要在后续模板目录阶段交付,不由 AgentSeek core 提供。 ## Task 命名 @@ -147,11 +146,11 @@ core 提供。 | 检查 | 命令或依据 | | --- | --- | -| 完整 catalog 契约 | 在独立 catalog checkout 中运行 `make check`。 | +| 完整 catalog 契约 | 在独立 catalog 检出副本中运行 `make check`。 | | 注册表与自包含 | Catalog 测试要求注册表与目录完全一致、只含普通文件/目录,并确保每个模板子树自包含。 | | 默认渲染和生命周期 smoke | Catalog 测试渲染每个注册模板,并用配对 core 快照验证严格 lifecycle v2。 | | 生成项目检查 | 使用 `agentseek create --no-input` 渲染本地模板。 | -| Core 文档 | 本规范变化时,在 AgentSeek core checkout 中运行 `make docs-test`。 | +| Core 文档 | 本规范变化时,在 AgentSeek core 检出副本中运行 `make docs-test`。 | ## 相关页面 diff --git a/tests/test_docs_lifecycle.py b/tests/test_docs_lifecycle.py index 938f4ce6..a75c206e 100644 --- a/tests/test_docs_lifecycle.py +++ b/tests/test_docs_lifecycle.py @@ -17,6 +17,13 @@ ROOT / "docs" / "reference" / "lifecycle-spec.md", ROOT / "docs" / "reference" / "lifecycle-spec.zh.md", ) +LIFECYCLE_SNAPSHOT_SUMMARIES = ( + ROOT / "docs" / "get-started" / "index.md", + ROOT / "docs" / "get-started" / "index.zh.md", + ROOT / "docs" / "reference" / "template-authoring-contract.md", + ROOT / "docs" / "reference" / "template-authoring-contract.zh.md", + *LIFECYCLE_REFERENCES, +) LIFECYCLE_V2_SPEC_URL = "https://github.com/ob-labs/agentseek/blob/main/specs/lifecycle-v2-service-discovery.md" ROOT_DOTENV_EXAMPLE = ROOT / ".env.example" ROOT_READMES = ( @@ -411,19 +418,27 @@ def test_lifecycle_references_define_the_immutable_environment_boundary(referenc lines = text.splitlines() assert "immutable snapshot" in text - assert "non-dry-run" in text assert "`KEY=`" in text assert "`KEY`" in text assert "malformed dotenv" in text assert "exit 2" in text assert "`agentseek info`" in text - assert "dotenv status" in text assert "`agentseek doctor --strict`" in text assert "exit 1" in text - assert "physical bindings in order" in text - assert "captured launch environment" in text - assert "initial child environment/snapshot" in text - assert "arbitrary child code" in text.lower() + if reference.name.endswith(".zh.md"): + assert "非 dry-run" in text + assert "dotenv 状态" in text + assert "物理绑定出现的顺序" in text + assert "已捕获的启动环境" in text + assert "初始子进程环境/快照" in text + assert "任意子进程代码" in text + else: + assert "non-dry-run" in text + assert "dotenv status" in text + assert "physical bindings in order" in text + assert "captured launch environment" in text + assert "initial child environment/snapshot" in text + assert "arbitrary child code" in text.lower() assert f"`agentseek-api >= {MINIMUM_AGENTSEEK_API_VERSION}`" in text assert any("`agentseek dev`" in line and "snapshot" in line for line in lines) assert any("`agentseek task`" in line and "`env_file`" in line for line in lines) @@ -450,13 +465,25 @@ def test_template_authoring_requires_a_compatible_released_api(reference: Path) @pytest.mark.parametrize( - "guide", + ("guide", "former_generic_guidance"), ( - ROOT / "docs" / "guides" / "create-template.md", - ROOT / "docs" / "guides" / "create-template.zh.md", + ( + ROOT / "docs" / "guides" / "create-template.md", + "During `agentseek dev`, the\n" + "project `.env` is also passed to long-running child processes, with exported\n" + "shell variables taking precedence.", + ), + ( + ROOT / "docs" / "guides" / "create-template.zh.md", + "在 lifecycle 文件的 `[env.*]` 中声明同一组必需名称。AgentSeek 用这些声明检查\n" + "就绪状态\uff1b`agentseek dev` 会把项目 `.env` 传给长运行子进程\uff0cshell 变量优先。", + ), ), ) -def test_template_guides_define_the_one_time_dev_environment_boundary(guide: Path) -> None: +def test_template_guides_define_the_one_time_dev_environment_boundary( + guide: Path, + former_generic_guidance: str, +) -> None: """Template guides must not describe dotenv as a generic child pass-through.""" text = guide.read_text(encoding="utf-8") @@ -464,3 +491,63 @@ def test_template_guides_define_the_one_time_dev_environment_boundary(guide: Pat assert "`KEY=`" in text assert "`agentseek task`" in text assert "`env_file`" in text + assert former_generic_guidance not in text + + +@pytest.mark.parametrize("reference", LIFECYCLE_SNAPSHOT_SUMMARIES) +def test_lifecycle_snapshot_summaries_explicitly_exclude_dry_run(reference: Path) -> None: + """Every public snapshot summary must reserve resolution for non-dry-run dev.""" + text = reference.read_text(encoding="utf-8") + qualification = "非 dry-run" if reference.name.endswith(".zh.md") else "non-dry-run" + + assert qualification in text + assert "`agentseek dev`" in text + if reference in LIFECYCLE_REFERENCES: + env_file_row = next(line for line in text.splitlines() if line.startswith("| `env_file`")) + + assert qualification in env_file_row + assert "`agentseek dev`" in env_file_row + + +@pytest.mark.parametrize( + "reference", + ( + ROOT / "docs" / "guides" / "create-template.zh.md", + ROOT / "docs" / "reference" / "template-authoring-contract.zh.md", + ), +) +def test_chinese_template_authoring_localizes_release_contract_terms(reference: Path) -> None: + """Chinese authoring guidance keeps only the required English contract phrases.""" + text = reference.read_text(encoding="utf-8") + + assert "\uff08immutable snapshot\uff09" in text + assert "\uff08exact published version\uff09" in text + assert "\uff08direct argv\uff09" in text + assert "editable" not in text + assert "checkout" not in text + assert re.search(r"\bpin\b", text) is None + assert "digest" not in text + + +@pytest.mark.parametrize( + "reference", + ( + ROOT / "docs" / "get-started" / "index.zh.md", + ROOT / "docs" / "guides" / "create-template.zh.md", + ROOT / "docs" / "reference" / "lifecycle-spec.zh.md", + ROOT / "docs" / "reference" / "template-authoring-contract.zh.md", + ), +) +def test_chinese_lifecycle_docs_do_not_code_switch_nonmandatory_parenthetical_terms(reference: Path) -> None: + """Chinese lifecycle prose keeps only the required English contract parentheticals.""" + text = reference.read_text(encoding="utf-8") + + for term in ( + "\uff08non-dry-run\uff09", + "\uff08captured launch environment\uff09", + "\uff08physical bindings in order\uff09", + "\uff08initial child environment/snapshot\uff09", + "\uff08arbitrary child code\uff09", + "\uff08dotenv status\uff09", + ): + assert term not in text From 4dad0be2689df44e81d875cda58ea519f2458d54 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Mon, 17 Aug 2026 01:27:07 +0800 Subject: [PATCH 18/21] fix: sort lifecycle public exports --- src/agentseek/cli/lifecycle/__init__.py | 2 +- tests/cli_commands/test_lifecycle_authored.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agentseek/cli/lifecycle/__init__.py b/src/agentseek/cli/lifecycle/__init__.py index 0082e64b..9d52eb7f 100644 --- a/src/agentseek/cli/lifecycle/__init__.py +++ b/src/agentseek/cli/lifecycle/__init__.py @@ -25,8 +25,8 @@ ) __all__ = [ - "MINIMUM_AGENTSEEK_API_VERSION", "LIFECYCLE_SPEC_FILE", + "MINIMUM_AGENTSEEK_API_VERSION", "REQUIRED_COMMANDS", "SUPPORTED_LIFECYCLE_VERSION", "SUPPORTED_LIFECYCLE_VERSIONS", diff --git a/tests/cli_commands/test_lifecycle_authored.py b/tests/cli_commands/test_lifecycle_authored.py index 9be4493d..89b010ce 100644 --- a/tests/cli_commands/test_lifecycle_authored.py +++ b/tests/cli_commands/test_lifecycle_authored.py @@ -160,8 +160,8 @@ def test_lifecycle_package_exports_the_lifecycle_environment_boundary() -> None: assert lifecycle.NormalizationWarning is NormalizationWarning assert lifecycle.normalize_lifecycle is normalize_lifecycle assert lifecycle.__all__ == [ - "MINIMUM_AGENTSEEK_API_VERSION", "LIFECYCLE_SPEC_FILE", + "MINIMUM_AGENTSEEK_API_VERSION", "REQUIRED_COMMANDS", "SUPPORTED_LIFECYCLE_VERSION", "SUPPORTED_LIFECYCLE_VERSIONS", From 1626c1160c45de25623655ce9230796a10a83af3 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Mon, 17 Aug 2026 01:36:33 +0800 Subject: [PATCH 19/21] fix: reject invalid lifecycle dotenv environments --- src/agentseek/cli/lifecycle/dotenv_adapter.py | 12 +++ .../test_lifecycle_environment.py | 73 ++++++++++++++++++- tests/test_entrypoint.py | 57 +++++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) diff --git a/src/agentseek/cli/lifecycle/dotenv_adapter.py b/src/agentseek/cli/lifecycle/dotenv_adapter.py index d1305001..8cc1e5a1 100644 --- a/src/agentseek/cli/lifecycle/dotenv_adapter.py +++ b/src/agentseek/cli/lifecycle/dotenv_adapter.py @@ -46,9 +46,21 @@ def parse_lifecycle_dotenv( for binding in bindings: if binding.key is None: continue + if "\x00" in binding.key or "=" in binding.key: + raise LifecycleDotenvError( + path, + "has an invalid variable name", + line=binding.original.line, + ) value = ( None if binding.value is None else "".join(atom.resolve(context) for atom in parse_variables(binding.value)) ) + if value is not None and "\x00" in value: + raise LifecycleDotenvError( + path, + "contains a NUL character in a resolved value", + line=binding.original.line, + ) values[binding.key] = value context[binding.key] = value return values diff --git a/tests/cli_commands/test_lifecycle_environment.py b/tests/cli_commands/test_lifecycle_environment.py index 5ed169af..1076cff9 100644 --- a/tests/cli_commands/test_lifecycle_environment.py +++ b/tests/cli_commands/test_lifecycle_environment.py @@ -5,6 +5,7 @@ import pytest import agentseek.cli.lifecycle.environment as environment_module +from agentseek.cli.lifecycle.dotenv_adapter import parse_lifecycle_dotenv from agentseek.cli.lifecycle.environment import ( EnvironmentOrigin, LifecycleDotenvError, @@ -13,6 +14,56 @@ ) +@pytest.mark.parametrize( + ("contents", "category", "canary"), + [ + ("NUL_KEY_CANARY\x00TAIL=value\n", "variable name", "NUL_KEY_CANARY"), + ("'EQUALS_KEY_CANARY=TAIL'=value\n", "variable name", "EQUALS_KEY_CANARY"), + ("SAFE=${AMBIENT}\n", "resolved value", "NUL_VALUE_CANARY"), + ], + ids=["nul-key", "equals-key", "resolved-value"], +) +def test_dotenv_adapter_rejects_subprocess_incompatible_bindings_without_echoing_content( + tmp_path, + contents, + category, + canary, +) -> None: + env_file = tmp_path / ".env" + env_file.write_text(contents, encoding="utf-8") + + with pytest.raises(LifecycleDotenvError) as raised: + parse_lifecycle_dotenv( + env_file, + ambient={"AMBIENT": f"prefix\x00{canary}"}, + ) + + diagnostic = str(raised.value) + assert raised.value.line == 1 + assert category in diagnostic + assert canary not in diagnostic + assert "\x00" not in diagnostic + assert "\\x00" not in diagnostic + + +def test_dotenv_adapter_preserves_physical_order_empty_and_unicode_values(tmp_path) -> None: + env_file = tmp_path / ".env" + env_file.write_text( + "BASE=模型\nDEPENDENT=${BASE}/路径\nEQUALS_VALUE=left=right\nEXPLICIT_EMPTY=\nVALUELESS\n", + encoding="utf-8", + ) + + values = parse_lifecycle_dotenv(env_file, ambient={"BASE": "ambient"}) + + assert values == { + "BASE": "模型", + "DEPENDENT": "模型/路径", + "EQUALS_VALUE": "left=right", + "EXPLICIT_EMPTY": "", + "VALUELESS": None, + } + + def test_snapshot_applies_only_nonempty_launch_values_over_dotenv(tmp_path, monkeypatch) -> None: env_file = tmp_path / ".env" env_file.write_text( @@ -35,8 +86,9 @@ def test_snapshot_applies_only_nonempty_launch_values_over_dotenv(tmp_path, monk def test_snapshot_preserves_dotenv_empty_and_omits_valueless_binding(tmp_path, monkeypatch) -> None: env_file = tmp_path / ".env" - env_file.write_text("EXPLICIT_EMPTY=\nVALUELESS\n", encoding="utf-8") + env_file.write_text("EXPLICIT_EMPTY=\nUNICODE=模型/路径\nVALUELESS\n", encoding="utf-8") monkeypatch.delenv("EXPLICIT_EMPTY", raising=False) + monkeypatch.delenv("UNICODE", raising=False) monkeypatch.delenv("VALUELESS", raising=False) snapshot = resolve_lifecycle_environment(env_file=env_file) @@ -44,6 +96,8 @@ def test_snapshot_preserves_dotenv_empty_and_omits_valueless_binding(tmp_path, m assert "EXPLICIT_EMPTY" in snapshot.values assert snapshot.values["EXPLICIT_EMPTY"] == "" assert snapshot.origins["EXPLICIT_EMPTY"] is EnvironmentOrigin.ENV_FILE + assert snapshot.values["UNICODE"] == "模型/路径" + assert snapshot.origins["UNICODE"] is EnvironmentOrigin.ENV_FILE assert "VALUELESS" not in snapshot.values assert "VALUELESS" not in snapshot.origins @@ -149,6 +203,23 @@ def test_resolver_rejects_malformed_dotenv_without_partial_snapshot( assert "must-not-leak" not in str(raised.value) +@pytest.mark.parametrize( + "contents", + [ + "NUL_KEY_CANARY\x00TAIL=value\n", + "'EQUALS_KEY_CANARY=TAIL'=value\n", + "SAFE=value\x00NUL_VALUE_CANARY\n", + ], + ids=["nul-key", "equals-key", "resolved-value"], +) +def test_snapshot_rejects_subprocess_incompatible_dotenv_binding_without_partial_result(tmp_path, contents) -> None: + env_file = tmp_path / ".env" + env_file.write_text(contents, encoding="utf-8") + + with pytest.raises(LifecycleDotenvError): + resolve_lifecycle_environment(env_file=env_file, launch_environment={}) + + def test_resolver_rejects_missing_and_invalid_utf8_sources(tmp_path) -> None: with pytest.raises(LifecycleDotenvError, match="does not exist"): resolve_lifecycle_environment( diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py index 19ae7688..a97a9738 100644 --- a/tests/test_entrypoint.py +++ b/tests/test_entrypoint.py @@ -7,6 +7,8 @@ import sys from pathlib import Path +import pytest + def test_agentseek_command_shows_help() -> None: command = shutil.which("agentseek") @@ -45,6 +47,61 @@ def test_agentseek_invalid_mode_exits_without_traceback() -> None: assert "Traceback" not in result.stderr +@pytest.mark.parametrize( + ("dotenv_contents", "canary"), + [ + ("NUL_KEY_CANARY\x00TAIL=value\n", "NUL_KEY_CANARY"), + ("'EQUALS_KEY_CANARY=TAIL'=value\n", "EQUALS_KEY_CANARY"), + ("SAFE=value\x00NUL_VALUE_CANARY\n", "NUL_VALUE_CANARY"), + ], + ids=["nul-key", "equals-key", "resolved-value"], +) +def test_agentseek_dev_rejects_subprocess_incompatible_dotenv_before_starting_child( + tmp_path: Path, + dotenv_contents: str, + canary: str, +) -> None: + command = [sys.executable, "-m", "agentseek"] + spec_dir = tmp_path / ".agentseek" + spec_dir.mkdir() + marker = tmp_path / "child.started" + child_command = [ + sys.executable, + "-c", + "from pathlib import Path; Path('child.started').write_text('started', encoding='utf-8')", + ] + (spec_dir / "lifecycle.toml").write_text( + "\n".join([ + "version = 2", + 'template = "test/invalid-dotenv-environment"', + 'name = "Invalid dotenv environment"', + 'env_file = "lifecycle.env"', + "", + "[processes.app]", + f"command = {json.dumps(child_command)}", + ]), + encoding="utf-8", + ) + (tmp_path / "lifecycle.env").write_text(dotenv_contents, encoding="utf-8") + + result = subprocess.run( # noqa: S603 + [*command, "dev", "--skip-check"], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + ) + + output = result.stdout + result.stderr + assert result.returncode == 2 + assert not marker.exists() + assert "Invalid lifecycle environment." in result.stderr + assert canary not in output + assert "\x00" not in output + assert "\\x00" not in output + assert "Traceback" not in output + + def test_agentseek_task_does_not_inherit_dotenv_secrets(tmp_path: Path) -> None: command = shutil.which("agentseek") assert command is not None From 9ebeb0279c47b0d3121ddfdf540f92bc370d574d Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Mon, 17 Aug 2026 01:49:15 +0800 Subject: [PATCH 20/21] fix: reap contract helper after graceful parent exit --- .../check_agentseek_api_lifecycle_contract.py | 13 ++-- .../test_agentseek_api_lifecycle_contract.py | 66 +++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/scripts/check_agentseek_api_lifecycle_contract.py b/scripts/check_agentseek_api_lifecycle_contract.py index 2d800529..46c2c123 100644 --- a/scripts/check_agentseek_api_lifecycle_contract.py +++ b/scripts/check_agentseek_api_lifecycle_contract.py @@ -157,14 +157,17 @@ def _run_agentseek( raise RuntimeError(message) from None with suppress(ProcessLookupError): process.send_signal(signal.SIGTERM) + requires_force_kill = False try: process.wait(timeout=graceful_shutdown_timeout_seconds) except subprocess.TimeoutExpired: - _terminate_tracked_posix_process_group( - helper_process_marker, - grace_seconds=helper_process_group_grace_seconds, - reap_timeout_seconds=fallback_reap_timeout_seconds, - ) + requires_force_kill = True + _terminate_tracked_posix_process_group( + helper_process_marker, + grace_seconds=helper_process_group_grace_seconds, + reap_timeout_seconds=fallback_reap_timeout_seconds, + ) + if requires_force_kill: _kill_and_reap_agentseek(process, timeout_seconds=fallback_reap_timeout_seconds) message = "agentseek dev exceeded the lifecycle-contract timeout" raise TimeoutError(message) from None diff --git a/tests/test_agentseek_api_lifecycle_contract.py b/tests/test_agentseek_api_lifecycle_contract.py index 75126b37..92285f06 100644 --- a/tests/test_agentseek_api_lifecycle_contract.py +++ b/tests/test_agentseek_api_lifecycle_contract.py @@ -31,6 +31,31 @@ time.sleep(0.05) """ +_PARENT_EXITS_DURING_GRACE_WITH_PRIVATE_HELPER = """ +import pathlib +import signal +import subprocess +import sys +import time + +helper = pathlib.Path(sys.argv[1]) +marker = pathlib.Path(sys.argv[2]) +subprocess.Popen( + [sys.executable, str(helper), str(marker)], + start_new_session=True, +) + +deadline = time.monotonic() + 5 +while not marker.is_file(): + if time.monotonic() >= deadline: + raise TimeoutError("helper did not publish its process marker") + time.sleep(0.05) + +signal.signal(signal.SIGTERM, lambda *_args: sys.exit(0)) +while True: + time.sleep(0.05) +""" + def _load_contract_script() -> ModuleType: script = Path(__file__).resolve().parents[1] / "scripts" / "check_agentseek_api_lifecycle_contract.py" @@ -80,6 +105,47 @@ def test_contract_main_rejects_windows_before_helper_generation(monkeypatch: pyt assert str(result.value) == "published agentseek-api lifecycle contract requires POSIX process-group support" +@pytest.mark.skipif(os.name == "nt", reason="the contract timeout fallback requires POSIX process groups") +def test_timeout_reaps_private_helper_when_parent_exits_during_grace(tmp_path: Path) -> None: + contract = _load_contract_script() + marker = tmp_path / "helper-process.json" + helper = tmp_path / "blocked_helper.py" + helper.write_text(_BLOCKED_SEPARATE_SESSION_HELPER, encoding="utf-8") + parent = tmp_path / "graceful_parent.py" + parent.write_text(_PARENT_EXITS_DURING_GRACE_WITH_PRIVATE_HELPER, encoding="utf-8") + child_pid: int | None = None + parent_pid: int | None = None + started = time.monotonic() + + try: + with pytest.raises(TimeoutError): + contract._run_agentseek( + [sys.executable, str(parent), str(helper), str(marker)], + cwd=tmp_path, + env=dict(os.environ), + timeout_seconds=1.0, + graceful_shutdown_timeout_seconds=1.0, + helper_process_marker=marker, + helper_process_group_grace_seconds=0.1, + fallback_reap_timeout_seconds=1.0, + ) + + elapsed = time.monotonic() - started + assert elapsed < 4.0, "graceful-parent cleanup exceeded its bounded timeout" + assert marker.is_file(), "helper process did not publish its private marker" + observed = json.loads(marker.read_text(encoding="utf-8")) + child_pid = observed["pid"] + parent_pid = observed["parent_pid"] + assert observed["pgid"] == child_pid, "helper did not run in a private process group" + assert _wait_until(lambda: not _process_is_running(parent_pid)), "parent survived its graceful shutdown" + assert _wait_until(lambda: not _process_is_running(child_pid)), "helper survived graceful-parent cleanup" + finally: + if child_pid is not None: + _force_stop(child_pid) + if parent_pid is not None: + _force_stop(parent_pid) + + @pytest.mark.skipif(os.name == "nt", reason="the contract timeout fallback requires POSIX process groups") def test_timeout_fallback_reaps_actual_agentseek_parent_and_separate_session_helper(tmp_path: Path) -> None: contract = _load_contract_script() From 183fc098b965b68eb4f950d3de2b14a75421ee06 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Mon, 17 Aug 2026 02:02:35 +0800 Subject: [PATCH 21/21] fix: make lifecycle test cleanup portable --- tests/test_agentseek_api_lifecycle_contract.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_agentseek_api_lifecycle_contract.py b/tests/test_agentseek_api_lifecycle_contract.py index 92285f06..c756fe6b 100644 --- a/tests/test_agentseek_api_lifecycle_contract.py +++ b/tests/test_agentseek_api_lifecycle_contract.py @@ -5,7 +5,6 @@ import importlib.util import json import os -import signal import sys import time from pathlib import Path @@ -13,6 +12,8 @@ import pytest +_POSIX_SIGKILL_NUMBER = 9 + _BLOCKED_SEPARATE_SESSION_HELPER = """ import json import os @@ -90,7 +91,7 @@ def _force_stop(pid: int) -> None: if not _process_is_running(pid): return try: - os.kill(pid, signal.SIGKILL) + os.kill(pid, _POSIX_SIGKILL_NUMBER) except ProcessLookupError: return