Compare commits

..
Author SHA1 Message Date
weichen 0f1daa8b89 Align the SQLite run store test with honest step failures
Why: the U3B semantics change removed the failed-with-patches
promotion; TestEngineRunsOnSQLiteStore still encoded the old behavior
and failed under the fixed engine.
Evidence: repair receipt success; sqlite store tests pass
(verify-u3br2.json).
Impact: test-only change.
Refs: feature/agent-stack-audit-remediation-b1 U3B repair
2026-10-21 18:05:51 +00:00
weichen cdd51c8678 Pin nested agent-intent and agent-native to the batch-1 fixes
Why: composition consistency - Dev pins agent-intent at 765a7e7 and
agent-native at the U3A result-fidelity tip; the squad container's
nested copies must match (check-pins).
Evidence: pin only; both libraries verified per-unit.
Impact: nested gitlink pointer changes.
Refs: feature/agent-stack-audit-remediation-b1
2026-10-21 17:45:01 +00:00
weichen 23f161bfb3 Honor executor terminals in retries and interrupted recovery
Why: F05 - files-presence could promote failures to success, recovery
marked crashed steps completed on files alone or reran them blindly,
and retries minted a fresh ExecutionID each attempt.
Evidence: squad tests pass incl. executor result contract and recovery
tables (verify-u3b.json); (nil,nil), canceled and side-effect-possible
semantics handled; interrupted steps without a trusted terminal now
hold for reconciliation instead of auto-advancing.
Impact: retries reuse the step ExecutionID and shared deadline; no
auto-replay of unverified external actions.
Refs: feature/agent-stack-audit-remediation-b1 U3B
2026-10-21 17:20:40 +00:00
weichen 4e6557782a Temporarily disable the cursor profile
Why: U3B hit two consecutive pre-session cursor infrastructure
failures (TLS destruction, then model auto rejection consistent with
the documented monthly quota exhaustion); both runs fail-stopped
before any session started with zero workspace changes.
Evidence: run dirs /tmp/agent-exec-1000/287d14d4… and …/b7ed6a71…
preserved; raw logs 553B and 48B.
Impact: routing only; candidates become antigravity then pi then codex.
Refs: Agent-System-Dev feature/agent-stack-audit-remediation-b1
2026-10-21 16:44:24 +00:00
weichen aa15a5d164 Version resolver configs and invalidate executor caches
Why: F06 - the wrapped-executor cache keyed only backend/role/model, so
allow->deny permission tightening and factory replacement never took
effect on subsequent resolves.
Evidence: squad and adapters/native tests pass (verify-u4.json); config
identity helper covers permission/tool/limiter changes.
Impact: stale executors can no longer widen capabilities; in-flight
tasks are not rolled back (documented).
Refs: feature/agent-stack-audit-remediation-b1 U4
2026-10-21 15:51:53 +00:00
weichen c7121bda2a Merge pull request 'Point the nested agent-native pin at the O2 route contract head' (#55) from feature/fast-slow-o2-i3-agent-native-pin into main
ci / docker-checkout-hostgateway (push) Successful in 0s
governance / governance (push) Successful in 19s
ci / vet-and-test (push) Successful in 40s
2026-10-20 08:57:38 +00:00
agent-exec-transport ad58d0ab1a agent-exec transport 1695a49c50742188364de30abf001030/559ef8a7bc20
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
governance / governance (pull_request) Successful in 19s
ci / vet-and-test (pull_request) Successful in 33s
2026-10-20 08:55:47 +00:00
weichen c0ce10a82c Merge pull request 'Align Agent-Squad with U3 stream guard' (#53) from feature/fast-slow-u3-r04-agent-native-pin into main
ci / docker-checkout-hostgateway (push) Successful in 1s
governance / governance (push) Successful in 15s
ci / vet-and-test (push) Successful in 36s
2026-10-19 09:20:18 +00:00
agent-exec-transport 11ff3a0a9c agent-exec transport 9fb713b2554e165f1a6fd86ba17b59c0/4bda8de4f195
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
governance / governance (pull_request) Successful in 13s
ci / vet-and-test (pull_request) Successful in 28s
2026-10-19 03:52:05 +00:00
weichen 3b03a04b4b Merge the U3 Agent-Native pin branch history after syncing with main
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 29s
2026-10-19 03:05:06 +00:00
agent-exec-transport edb8b1a77e agent-exec transport 7c1438b0c1157e3f28c533135f7a2f87/aeb6979db9d0 2026-10-19 03:04:55 +00:00
weichen 9ded893f8c Merge pull request 'Adopt the shared governance kit (development-governance-v2)' (#54) from feature/governance-adoption into main
governance / governance (push) Successful in 14s
ci / vet-and-test (push) Successful in 28s
ci / docker-checkout-hostgateway (push) Successful in 0s
2026-10-17 09:56:17 +00:00
weichen 4816ec3efb Close and archive the governance adoption card
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
governance / governance (pull_request) Successful in 15s
ci / vet-and-test (pull_request) Successful in 31s
Why: the adoption PR is reviewed and its candidate HEAD passed CI; the card closure is folded into the PR so no separate close PR is needed.
Evidence: CI GATE on b3e7da4a11b976007fa5c2874722b15ce5fd28fe; independent Review REPLAY (cursor-agent-stable --mode ask 全新只读会话(fresh_session=true,session id not-observable)闭合重审 VERDICT: APPROVE(对象 b3e7da4)).
Impact: task card only (moved to .task/archive/).
Refs: https://git-home.u1680832.nyat.app:30319/weichen/Agent-Squad/pulls/54
2026-10-17 09:48:05 +00:00
weichen b3e7da4a11 docs(task): record the consistency evidence, invalid r0 launch and gitlink scope note
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 31s
2026-10-17 09:43:18 +00:00
weichen 05b5098272 docs(task): record review repair run and evidence
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
governance / governance (pull_request) Successful in 14s
ci / vet-and-test (pull_request) Successful in 31s
Why: independent Review blocked the adoption; the repair unit ran through the shared executor and was integrated with agent-exec apply.
Evidence: .task/evidence/governance-adoption/ (SELF): governance check and agent-exec validate on the repaired head.
Impact: task card and evidence logs only.
Refs: .task/feature-governance-adoption.md
2026-10-17 09:43:06 +00:00
agent-exec-transport d0f9081f47 agent-exec transport fadb9c92bff5cfe541b480420d35c615/9158db894767 2026-10-17 09:39:15 +00:00
weichen e29284eb7b Record the adoption unit run and positive/negative evidence
governance / governance (pull_request) Successful in 20s
ci / vet-and-test (pull_request) Successful in 36s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: Phase 6 requires one positive executor run and the negative cases (missing config, bad receipt, out-of-scope path, unknown side effect) before adoption becomes active.
Evidence: .task/evidence/governance-adoption/ (SELF): governance check, agent-exec validate and adoption cases; this commit passed the generated pre-commit hook (admit).
Impact: task card and evidence logs only.
Refs: .task/feature-governance-adoption.md
2026-10-17 08:01:51 +00:00
agent-exec-transport 468878f62e agent-exec transport 145e30ce80c24c44a7fd2a0f12ab502c/ee44fa29d1f0 2026-10-17 07:41:00 +00:00
weichen b83905a87a Record the failed first attempt and its reconciliation
Why: the r1 attempt ended failed-unknown (invalid terminal record); fail-stop requires reconciliation before a fresh attempt.
Evidence: receipt /tmp/gov-receipt-Agent-Squad-r1.json; worktree clean, no transport ref (SELF).
Impact: task card only.
Refs: .task/feature-governance-adoption.md
2026-10-17 07:37:54 +00:00
weichen 2a2a1fd131 Bootstrap AGENT-EXECUTION.md and the governance adoption card
Why: development-governance-v2 Phase 6 adopts the shared governance kit in every formal repository; the executor needs a valid AGENT-EXECUTION.md before any implementation unit can run.
Evidence: generated by dm-server governance init at Development-Manager bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c; dm-server agent-exec validate passes.
Impact: configuration and task card only; no code or CI change yet.
Refs: Agent-System-Dev docs/superpowers/plans/2026-10-16-development-governance-v2-master.md
2026-10-17 07:17:41 +00:00
weichen bfb0834dd6 Pin terminal observation contract in Agent-Squad
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
ci / vet-and-test (pull_request) Successful in 53s
Why: Squad must consume the same Agent-Native revision as the backend and Dev composition.

Evidence: Dev task receipt for full Go vet and test at the exact nested pin.

Impact: Updates only the Agent-Native gitlink.

Refs: U3 r0.5 implementation plan Task 5
2026-10-14 08:41:01 +00:00
weichen b9a8576274 Pin the closed Agent-Native terminal guard
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
ci / vet-and-test (pull_request) Successful in 29s
Why: Keep Agent-Squad on the Agent-Native revision that rejects both empty tool_calls and function_call terminals.

Evidence: Full Agent-Squad vet/test passed with the new gitlink.

Impact: Dependency gitlink only.

Refs: Agent-Native PR #46; U3 r0.4 review closure
2026-10-14 04:12:12 +00:00
weichen ae21e1d3a0 Pin the complete Agent-Native stream guard
ci / vet-and-test (pull_request) Successful in 30s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Keep Agent-Squad on the reviewed Agent-Native stream terminal behavior used by Backend and Dev.

Evidence: GOWORK=off go vet ./... and go test -count=1 ./... passed with the new pin.

Impact: Dependency gitlink only; no Agent-Squad source change.

Refs: Agent-Native PR #46; U3 r0.4 independent review issue 2
2026-10-14 03:56:24 +00:00
weichen 93cc70241c Pin Agent-Native stream guard for combined delivery
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
ci / vet-and-test (pull_request) Successful in 27s
Why: Keep Agent-Squad on the same Agent-Native commit as the Dev composition so pin validation and runtime behavior agree.

Evidence: GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./... passed on this branch.

Impact: Dependency gitlink only; no Agent-Squad source or public contract changes.

Refs: U3 r0.4 implementation plan, Dev feature/fast-slow-u2-u3-plan Task 6
2026-10-14 03:23:22 +00:00
weichen 490b0b2dfa Merge pull request 'Bump the agent-native pin to c348441' (#51) from feature/bump-agent-native into main
ci / vet-and-test (push) Successful in 27s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-10 10:08:51 +00:00
weichen 234aa28af9 Bump the agent-native pin to c348441 (call-ID events, ctx call, serial partition)
ci / vet-and-test (pull_request) Successful in 26s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Dev top-level agent-native moved to c348441 for the E1 increment
(parallel tool execution support); the pin check requires nested
agent-native links to carry the same tree, and the squad harness should
pick up the additive kernel events anyway.

Evidence: go build ./... && go test ./... green against the new pin.

Impact: Gitlink-only bump (3b92cb6 -> c348441); no squad code changes.

Refs: Agent-Native #42/#43/#44; Dev task feature/agent-e1-parallel-fixes.
2026-10-10 10:06:58 +00:00
weichen 8bbf850fad Merge pull request 'Point the nested agent-native pin at the reasoning replay head' (#49) from feature/pin-native-thinking into main
ci / vet-and-test (push) Successful in 27s
ci / docker-checkout-hostgateway (push) Successful in 0s
ci / vet-and-test (pull_request) Successful in 25s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
2026-10-09 16:36:47 +00:00
weichen 9ec3d86e86 Point the nested agent-native pin at the reasoning replay head
ci / vet-and-test (pull_request) Successful in 27s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Dev's combination check requires nested library pins to match the Dev
top-level pointers; Dev moves agent-native to 3b92cb6 (reasoning capture and
replay, Agent-Native #41) for the quality P2-I2 work.

Evidence: SELF - 3b92cb6 is an ancestor of Agent-Native main (merge of #41);
go build/test in the Dev worktree runs after this bump (recorded on the
phase02 task card).

Impact: Pin only; squad does not consume the thinking fields.
2026-10-09 16:31:51 +00:00
weichen 819b0c5f82 Merge pull request 'Point the nested agent-native pin at the delegation template update' (#48) from feature/q3-native-pin into main
ci / vet-and-test (push) Successful in 26s
ci / docker-checkout-hostgateway (push) Successful in 1s
Merge pull request 'Point the nested agent-native pin at the delegation template update' (#48) from feature/q3-native-pin into main
2026-10-08 04:00:37 +00:00
weichen df1f877a4f Point the nested agent-native pin at the delegation template update
ci / vet-and-test (pull_request) Successful in 26s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Dev-level pin consistency (scripts/check-pins.sh hard-errors on nested
vs Dev-level drift); agent-native main advanced to 37c5e6f (Request
OutputFormat/Tools, agent-native #40).

Evidence: adapters/native and root suites green against 37c5e6f (SELF);
CI authoritative (GATE).

Impact: nested deps/agent-native 1ac21c6 -> 37c5e6f; no squad source
changes.

Refs: Q3 plan WP3/WP6; Dev card .task/feature-agent-stack-q3.md
2026-10-08 03:58:51 +00:00
weichen 663e89ec58 Merge pull request 'Add the a2a adapter module: Agent Card publishing and remote executor (Q3 WP4)' (#47) from feature/q3-a2a-wp4 into main
ci / vet-and-test (push) Successful in 26s
ci / docker-checkout-hostgateway (push) Successful in 1s
Merge pull request 'Add the a2a adapter module: Agent Card publishing and remote executor (Q3 WP4)' (#47) from feature/q3-a2a-wp4 into main
2026-10-08 03:41:54 +00:00
weichen 424bfc0e29 Add the a2a adapter module: Agent Card publishing and remote executor (Q3 WP4)
ci / vet-and-test (pull_request) Successful in 25s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Q3 WP4 requires A2A 1.0 interop from zero: publish this team's
capabilities as an Agent Card and consume remote agents as squad.Executors
with the A2A task state machine mapped onto ExecResult.

Evidence: adapters/a2a conformance with a local fake A2A server (both
directions): card build from role/blueprint registries, well-known JSON
serving, FetchCard fail-closed validation, message/send -> tasks/get
polling through submitted/working -> completed with TextPart/FilePart
artifacts mapped to patches/targets, failed/canceled/rejected/input-required
mapped to Success=false fail-closed (no cross-process hold), direct Message
result, JSON-RPC protocol and ctx-cancel error propagation. Module green
with -race; squad root suite unaffected (SELF). CI authoritative (GATE)
for the root module; the adapter module follows the adapters/intent and
adapters/native separate-go.mod precedent.

Impact: new module agent-squad/adapters/a2a (card.go, executor.go); no
changes to the squad root module.

Refs: Q3 plan WP4 (2026-10-07-agent-stack-q2q3-plans.md); task card
.task/feature-agent-stack-q3.md
2026-10-08 03:39:11 +00:00
weichen 633e853bfc Merge pull request 'Add StepSpec.Brief rendered as the task-instruction section of TASK.md' (#46) from feature/q3-step-brief into main
ci / vet-and-test (push) Successful in 25s
ci / docker-checkout-hostgateway (push) Successful in 1s
Merge pull request 'Add StepSpec.Brief rendered as the task-instruction section of TASK.md' (#46) from feature/q3-step-brief into main
2026-10-08 02:11:32 +00:00
42 changed files with 3274 additions and 108 deletions
+150
View File
@@ -0,0 +1,150 @@
name: governance
on:
pull_request:
push:
branches: [main]
jobs:
governance:
runs-on: ubuntu-latest
env:
GOPROXY: https://goproxy.cn,direct
steps:
- name: Checkout
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
run: |
set -euo pipefail
# 只物化两个治理 gitlink(Guidelines 与 deps/development-manager),非递归;
# 其它 gitlink 不物化、不取回。凭据只以 URL 限定的 extraheader 存在于本进程树内。
python3 - <<'PY_CHECKOUT'
import os
from pathlib import Path
import re
import subprocess
import sys
from urllib.parse import urlsplit
# 服务端基址取运行器注入的 GITHUB_SERVER_URL(平台控制、候选 PR 不可影响)。
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
parsed_server = urlsplit(SERVER)
if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc:
sys.exit("Checkout server must be the platform-injected http(s) URL")
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
# 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。
REPOSITORIES = {
"deps/development-guidelines": "Development-Guidelines",
"deps/development-manager": "Development-Manager",
}
TARGET = "weichen/Agent-Squad"
root_token = os.environ.pop("TOKEN", "")
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
if not root_token or not component_token:
sys.exit("Checkout requires TOKEN and SUBMODULE_TOKEN")
if os.environ.get("GITHUB_REPOSITORY") != TARGET:
sys.exit("Checkout repository is outside the reviewed allowlist")
sha = os.environ.get("GITHUB_SHA", "")
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
sys.exit("Checkout requires a fixed commit SHA")
# 丢弃继承的 Git 追踪/配置/SSH 钩子;凭据只存在于单个 Git 进程树的精确 URL 头中。
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
clean_env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null",
GIT_CONFIG_GLOBAL="/dev/null", GIT_TERMINAL_PROMPT="0")
config = {
"core.hooksPath": "/dev/null", "core.fsmonitor": "false",
"init.templateDir": "", "credential.helper": "",
"fetch.recurseSubmodules": "false", "submodule.recurse": "false",
"http.followRedirects": "false", "protocol.allow": "never",
"protocol." + urlsplit(SERVER).scheme + ".allow": "always",
}
def git(repo, *args, extra=None):
values = config | (extra or {})
env = clean_env | {"GIT_CONFIG_COUNT": str(len(values))}
for index, (key, value) in enumerate(values.items()):
env[f"GIT_CONFIG_KEY_{index}"] = key
env[f"GIT_CONFIG_VALUE_{index}"] = value
return subprocess.run(["git", "-C", str(repo), *args], env=env,
check=True, stdout=subprocess.PIPE).stdout
def materialize_governance(root):
# -r 列出任意深度的 gitlink,但不进入 submodule 内部;其它 gitlink 只记录、从不取回。
links = {}
for entry in git(root, "ls-tree", "-rz", "HEAD").split(b"\0"):
if not entry:
continue
metadata, path = entry.split(b"\t", 1)
mode, kind, commit = metadata.decode().split()
if mode == "160000":
links[path.decode()] = commit
for name in REPOSITORIES:
if name not in links:
raise ValueError("Governance gitlink is missing from the fixed commit")
raw = git(root, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
entries = {}
for item in raw.split(b"\0"):
if not item:
continue
key, value = item.decode().split("\n", 1)
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
if not match:
continue
name, field = match.groups()
if name not in REPOSITORIES:
continue
if field in entries.setdefault(name, {}):
raise ValueError("Duplicate candidate .gitmodules setting")
entries[name][field] = value
# 先整体校验两个治理条目,再进行任何带凭据的取回。
for name, repository in REPOSITORIES.items():
fields = entries.get(name, {})
if fields.get("path") != name:
raise ValueError("Governance submodule path is outside the reviewed allowlist")
trusted_url = f"{SERVER}/weichen/{repository}.git"
if fields.get("url") not in {SSH + repository + ".git", trusted_url}:
raise ValueError("Governance submodule URL is outside the reviewed allowlist")
for name, repository in REPOSITORIES.items():
url = f"{SERVER}/weichen/{repository}.git"
# 仅持久化审查过的公开 URL,使 Git 在进程级配置消失后仍识别该 submodule。
git(root, "config", f"submodule.{name}.url", url)
git(root, "submodule", "update", "--init", "--checkout", "--", name,
extra={f"submodule.{name}.url": url,
f"submodule.{name}.update": "checkout",
f"http.{url}.extraheader": "AUTHORIZATION: token " + component_token})
child = root / name
if git(child, "rev-parse", "HEAD").decode().strip() != links[name]:
raise ValueError("Materialized governance gitlink does not match the fixed SHA")
try:
root = Path.cwd()
# 必须是干净的临时工作区;不复用既有 Git 状态。
if (root / ".git").exists():
raise ValueError("Checkout requires a fresh workspace")
git(root, "init", "-q")
url = f"{SERVER}/{TARGET}.git"
git(root, "remote", "add", "origin", url)
git(root, "fetch", "--depth=1", "origin", sha,
extra={f"http.{url}.extraheader": "AUTHORIZATION: token " + root_token})
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
materialize_governance(root)
except (ValueError, OSError, subprocess.CalledProcessError) as error:
sys.exit(f"Checkout refused: {error}")
print("Checkout complete; credentials were not persisted in Git configuration")
PY_CHECKOUT
unset TOKEN SUBMODULE_TOKEN
- name: Install Go 1.27.1
run: |
set -euo pipefail
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
- name: Validate agent execution configuration
run: scripts/agent-exec validate --repo .
- name: Governance check
run: scripts/dm governance check --repo .
+3
View File
@@ -13,3 +13,6 @@
[submodule "deps/agent-intent"]
path = deps/agent-intent
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git
[submodule "deps/development-manager"]
path = deps/development-manager
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Manager.git
@@ -0,0 +1,58 @@
# Agent-Squad 采纳 development-governance-v2 治理套件 任务卡
任务主记录:[Agent-Squad PR #54](https://git-home.u1680832.nyat.app:30319/weichen/Agent-Squad/pulls/54)(本文件随 PR 描述维护)。
状态:已完成(闭合随本 PR 合入;合入以平台记录为准);owner:weichen。
## 基线与授权
- 权威仓:weichen/Agent-Squad;root_target:main;immediate_target:main
- branch_kind:initiative;initiative_id:governance-adoption-Agent-Squad
- R0(initial_main_sha):490b0b2dfa1567cb16ff7a7230fd78e52bc2938e;工作区:/home/weichen/projects/Agent-Squad-wt-feature-governance-adoption(worktree);分支:feature/governance-adoption
- 父任务信息(仅子任务填写):无
- accepting_requirements:false(单仓采纳 initiative,范围固定为下列十步,不接收新需求)
- 需求分流:verdict=NEW;selected_initiative=无;候选=无;理由=development-governance-v2 Phase 6 每个正式仓库独立 initiative;triage 收据=无(本仓采纳前尚无 triage 入口)
- 授权与交付终点:owner 已授权 development-governance-v2 全部实施(Agent-System-Dev 主记录 docs/superpowers/plans/2026-10-16-development-governance-v2-master.md);终点为本 PR 合入 main 且采纳状态 active。分支保护与 tag 由用户执行。
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
目标(Phase 6 十步):固定 Guidelines SHA(deps/development-guidelines @ 2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb);增加 AGENT-EXECUTION.md;增加共享执行器薄入口(deps/development-manager @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c + scripts/dm、scripts/agent-exec、scripts/task);四条路由(implementation/repair/test/review);任务卡模板与分支元数据;CI 中启用 schema 校验(.gitea/workflows/governance.yml:agent-exec validate + governance check);一次正例运行;负例(缺配置、坏收据、越界路径、未知副作用);独立 Review + CI;采纳状态 active。
非目标:不改业务代码与既有测试;不改分支保护(由用户执行);不发布 tag。
范围说明(Review 阻塞项 2):`scripts/submodule-consistency.sh` 要求 deps/agent-native、deps/agent-intent 内嵌的 Guidelines/错误码/日志 gitlink 与本仓一致;本 PR 把 Guidelines 固定到 2688fd2 后,按最小修复把这两个 gitlink 升到各自已合入治理采纳的 main(Agent-Native 14d4869、Agent-Intent 651a2e1;两者相对原固定版本只增治理文件、不改 Go 代码),其余 gitlink 不动。核对见证据 `scripts/submodule-consistency.sh`。
引导说明:AGENT-EXECUTION.md 由 host 以固定版本治理套件(dm-server governance init @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c)生成并作为引导配置提交(执行器运行前必须存在有效配置;沿用 DM 采纳先例);其余文件由实施单元生成。
## Agent 实施收据
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| governance-adoption-Agent-Squad-r1 | 13 个允许路径(治理文件 + 两个 gitlink) | | implementation | cursor→antigravity→pi | run 71696608(dm-server pinned,attempts=1) | cursor / not-observable | failed-unknown | 无(apply 快进) | 否 | /tmp/gov-receipt-Agent-Squad-r1.json |
| 对账(r1) | 规范 worktree 仍为 2a2a1fd 且干净;无 transport ref | — | — | — | — | — | 已对账;全新运行 r2(单元包追加禁止固定共享 /tmp 路径约束) | — | — | — |
| governance-adoption-Agent-Squad-r2 | 13 个允许路径(治理文件 + 两个 gitlink) | b83905a8 | implementation | cursor→antigravity→pi | run 145e30ce(dm-server 42e675f,attempts=1) | cursor / not-observable | success | 468878f62ede(apply 快进) | 否 | /tmp/gov-receipt-Agent-Squad-r2.json |
| (无效启动 repair-r0) | — | repair | — | 无 attempt | — | invalid-config(单元包尚未写出即启动,执行器读取失败;无 attempt、无副作用,worktree 仍为 e29284e 且干净) | 无 | 否 | /tmp/gov-receipt-Agent-Squad-repair-r0-invalid-unit-path.json |
| governance-adoption-Agent-Squad-repair-r1 | policy/guidelines-adoption.md、PROJECT.md、deps/agent-native、deps/agent-intent | repair | cursor→antigravity→pi | run fadb9c92(attempts=1) | cursor / not-observable | success | d0f9081f47eb(apply 快进) | 否 | /tmp/gov-receipt-Agent-Squad-repair-r1.json |
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|---|---|---|---|---|
| 启动 | 490b0b2dfa1567cb16ff7a7230fd78e52bc2938e | 490b0b2dfa1567cb16ff7a7230fd78e52bc2938e | 2026-10-17T07:17:41Z | 一致 |
## 结果与验证(完成后填写)
- 结果:治理套件已采纳——Guidelines 与 Development-Manager gitlink 固定、AGENT-EXECUTION.md 四条路由、共享执行器薄入口、pre-commit admit、governance CI;采纳状态 active(随合入生效)。
- 验证:见下表(命令输出由 /tmp/gov-neg/ev.sh 写入 .task/evidence/governance-adoption/,SELF 除非另注)。
- 影响与剩余项:不改业务代码;本地启用钩子需由人执行 `git config core.hooksPath scripts/hooks`;将 governance 检查设为分支保护必需项属用户双钥匙事项(命令见 Agent-System-Dev 治理主记录)。
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|---|---|---|---|---|---|
| 20261017T080150Z | `scripts/dm governance check --repo .` | 468878f62ede | 0 | SELF | ok fixtures;[输出](../evidence/governance-adoption/00-20261017T080150Z.log) sha256:b07d207012a2fd6d |
| 20261017T080150Z | `scripts/agent-exec validate --repo .` | 468878f62ede | 0 | SELF | validate: ok;[输出](../evidence/governance-adoption/01-20261017T080150Z.log) sha256:b3a91246221bb5f8 |
| 20261017T080150Z | `python3 /tmp/gov-neg/adopt_cases.py .` | 468878f62ede | 0 | SELF | [verdict] ALL PASS cases=5;[输出](../evidence/governance-adoption/02-20261017T080150Z.log) sha256:109906d5e57fe039 |
| 20261017T094306Z | `scripts/dm governance check --repo .` | d0f9081f47eb | 0 | SELF | ok fixtures;[输出](../evidence/governance-adoption/03-20261017T094306Z.log) sha256:b904686133da5f80 |
| 20261017T094306Z | `scripts/agent-exec validate --repo .` | d0f9081f47eb | 0 | SELF | validate: ok;[输出](../evidence/governance-adoption/04-20261017T094306Z.log) sha256:19dc223efda318d6 |
| 20261017T094308Z | `scripts/submodule-consistency.sh` | 05b509827274 | 0 | SELF | ok deps/agent-intent 651a2e1e03c4326888ef9f742ce592140ae30ab8;[输出](../evidence/governance-adoption/05-20261017T094308Z.log) sha256:bc528fe029ed41d2 |
| — | 候选 HEAD CI | b3e7da4a11b9 | 0 | GATE | success ci / docker-checkout-hostgateway (pull_request)=success(/weichen/Agent-Squad/actions/runs/3672/jobs/5787);governance / governance (pull_request)=success(/weichen/Agent-Squad/actions/runs/3673/jobs/5788);ci / vet-and-test (pull_request)=success(/weichen/Agent-Squad/actions/runs/3672/jobs/5786) |
| — | 独立 Review | b3e7da4a11b9 | 0 | REPLAY | cursor-agent-stable --mode ask 全新只读会话(fresh_session=true,session id not-observable)闭合重审 VERDICT: APPROVE(对象 b3e7da4) |
@@ -0,0 +1,19 @@
command: scripts/dm governance check --repo .
cwd: /home/weichen/projects/Agent-Squad-wt-feature-governance-adoption
head: 468878f62eded8347d18a1d6c1b7c5f7e24a9c4c
exit: 0
utc: 20261017T080150Z
---
ok gitlink deps/development-guidelines
ok gitlink deps/development-manager
ok agent-execution
ok file AGENTS.md
ok file PROJECT.md
ok file policy/guidelines-adoption.md
ok file .task/templates/task-card.md
ok executable scripts/agent-exec
ok executable scripts/hooks/pre-commit
ok executable scripts/dm
ok executable scripts/task
ok adoption status
ok fixtures
@@ -0,0 +1,7 @@
command: scripts/agent-exec validate --repo .
cwd: /home/weichen/projects/Agent-Squad-wt-feature-governance-adoption
head: 468878f62eded8347d18a1d6c1b7c5f7e24a9c4c
exit: 0
utc: 20261017T080150Z
---
validate: ok
@@ -0,0 +1,13 @@
command: python3 /tmp/gov-neg/adopt_cases.py .
cwd: /home/weichen/projects/Agent-Squad-wt-feature-governance-adoption
head: 468878f62eded8347d18a1d6c1b7c5f7e24a9c4c
exit: 0
utc: 20261017T080150Z
---
[object] Agent-Squad-wt-feature-governance-adoption HEAD=468878f62eded8347d18a1d6c1b7c5f7e24a9c4c
PASS positive-run-apply: run exit=0 terminal=success apply exit=0 head=312d80d409a1
PASS missing-config: run exit=2 terminal=invalid-config validate exit=2
PASS bad-receipt: apply exit=4 head_unchanged=True msg=apply: no local integrity record: changed paths do not match the transport diff
PASS out-of-scope-path: run exit=5 terminal=scope-expansion transport=False
PASS unknown-side-effect: run exit=4 terminal=failed-unknown transport=False
[verdict] ALL PASS cases=5
@@ -0,0 +1,19 @@
command: scripts/dm governance check --repo .
cwd: /home/weichen/projects/Agent-Squad-wt-feature-governance-adoption
head: d0f9081f47eb59565323f731cb8085e5891dd7ba
exit: 0
utc: 20261017T094306Z
---
ok gitlink deps/development-guidelines
ok gitlink deps/development-manager
ok agent-execution
ok file AGENTS.md
ok file PROJECT.md
ok file policy/guidelines-adoption.md
ok file .task/templates/task-card.md
ok executable scripts/agent-exec
ok executable scripts/hooks/pre-commit
ok executable scripts/dm
ok executable scripts/task
ok adoption status
ok fixtures
@@ -0,0 +1,7 @@
command: scripts/agent-exec validate --repo .
cwd: /home/weichen/projects/Agent-Squad-wt-feature-governance-adoption
head: d0f9081f47eb59565323f731cb8085e5891dd7ba
exit: 0
utc: 20261017T094306Z
---
validate: ok
@@ -0,0 +1,14 @@
command: scripts/submodule-consistency.sh
cwd: /home/weichen/projects/Agent-Squad-wt-feature-governance-adoption
head: 05b5098272741987767efa2e78b8fe01aa84b6e5
exit: 0
utc: 20261017T094308Z
---
ok development-guidelines@deps/agent-native 2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb
ok error-code-system@deps/agent-native a3ee67bba269e1276c14e22c026171ecf2cf6190
ok logging-system@deps/agent-native 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7
ok development-guidelines@deps/agent-intent 2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb
ok error-code-system@deps/agent-intent a3ee67bba269e1276c14e22c026171ecf2cf6190
ok logging-system@deps/agent-intent 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7
ok deps/agent-native 14d48697a69ddba9ebc67ba7daa588f1b082d096
ok deps/agent-intent 651a2e1e03c4326888ef9f742ce592140ae30ab8
+66
View File
@@ -0,0 +1,66 @@
# <任务标题> 任务卡
任务主记录:<Issue/PR 链接,或注明"本文件随 PR 描述维护">。
状态:<进行中 / 待审核 / 已合入 / 已放弃>;owner:<填写>。
## 基线与授权
- 权威仓:<仓 URL 或逻辑 ID>;root_target:main;immediate_target:<main 或 feature/initiative>
- branch_kind:<initiative | child | standalone>;initiative_id:<稳定 ID,普通任务为空>
- R0(initial_main_sha):<完整 SHA>;工作区:<路径>(worktree / clone);分支:<feature|fix|hotfix/...>
- 父任务信息(仅子任务填写):parent_branch=<父分支>;parent_head_sha=<SHA>;parent_pr=<父 PR>
- accepting_requirements:<true | false>(仅 initiative 可为 true;冻结后为 false)
- 需求分流:verdict=<JOIN | NEW>;selected_initiative=<分支或无>;候选=<分支@HEAD 列表或无>;理由=<不适配/适配依据>;triage 收据=<源码外路径与摘要>
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
<预期结果、受影响入口/模块/契约;明确不做什么。>
## Agent 实施收据
按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/会话/隔离工作区。实施、修复与测试必须通过登记的候选执行;未配置或候选安全耗尽停在 unavailable/blocked,禁止 host 隐式代写。
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |
## 复用材料(task-start 自动检索结果摘录)
- 相关近期提交:<git log -- <paths> 命中;无则写无>。
- 相关经验条目:<检索命中;无则写无>。
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|---|---|---|---|---|
| 启动 | <R0> | <HEAD> | <日期时间> | <一致/已同步> |
| 验收前 | <填写> | <填写> | <填写> | <填写> |
字段没有发生的写"无",不省略。本卡与 PR 描述同源:开 PR 时将"目标与非目标、结果与验证、影响与剩余项"复制进 PR 描述,不另写第二份。
## 交付调度(有多个就绪任务时)
- 调度记录 / entry_id / 当前 attempt_id:<平台或调度服务链接及标识;未入队写无>。
- 入队源 HEAD / owner / 依赖:<完整 SHA、身份、依赖或无>。
- 固定候选引用:<调度尝试中的 base/head、组件与配置;未固定写无>。
- 窗口、最终 CI、Review 与真实合入结果:<引用调度记录及原始证据,不复制全队列>。
定义见 [合入调度与 CI 分配](../standards/merge-queue.md)。本段在候选固定前整理;固定后先使用源码之外的记录保存运行结果,再按项目闭合流程回填。
## 结果与验证(完成后填写)
- 结果:<交付了什么;未发生写无>。
- 验证:<结论性摘要:PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据见下表,来源标注与写入规则见 [按影响选择验证与证据](../standards/verification.md)>。
- 影响与剩余项:<影响面、遗留风险、后续任务>。
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|---|---|---|---|---|---|
| <时间> | <命令;由项目 evidence 工具写入,无工具时附命令+对象 SHA+输出 digest 并标 SELF> | <HEAD> | <exit> | GATE/REPLAY/SELF | <摘要/位置> |
## 回收(task-finish)
- 合入核对:<分支已包含于目标分支的核对输出>。
- 未跟踪/ignored 文件处置:<归档/可再生/存疑 逐项;无则写无>。
- 回收动作:<worktree remove、分支删除、prune;放弃路径注明原因>。
+73
View File
@@ -0,0 +1,73 @@
# Project Agent Execution Configuration
# 项目工程 Agent 执行路由配置
适用范围:定义本项目中由 AI / Agent 主导的代码实施、修复、测试与审阅所调用的 Agent CLI 路由。
解析器识别正文中唯一一个 ```agent-execution 代码块。正文外的说明文字可根据项目需要调整。
通用硬规则见 Development-Guidelines 的 [Agent 执行与协作契约](deps/development-guidelines/standards/agent-execution.md)。
本配置为项目本地路由权威,声明具体的候选 profile、尝试次数上限、超时、集成入口与豁免路径;
代码块须通过 `deps/development-guidelines/templates/schemas/agent-execution.schema.json` 校验后才能登记为 active。
`fallback_to_host` 只能为 `never`;旧值 `all-unavailable` 仅供迁移工具读取,不能通过准入。
下例的 CLI 名称、命令与路径均为占位,按项目实际执行器替换。
```agent-execution
schema: agent-execution/v1
execution:
fresh_process_per_unit: true
permission: run-everything
fallback_to_host: never
max_parallel: 3
timeout_seconds: 7200
idle_timeout_seconds: 600
summary_bytes: 16384
raw_output_bytes: 10485760
max_attempts_per_profile: 3
emergency_profile: codex
roles:
implementation: [cursor, antigravity, pi]
repair: [cursor, antigravity, pi]
test: [pi, antigravity, cursor]
review: [pi, antigravity, cursor, codex]
profiles:
cursor:
adapter: cursor
executable: "cursor-agent-stable"
model: auto
# 2026-10-21 临时禁用:U3B 连续两次 fail-stop(首次 TLS 会话前故障 553B 日志、
# 二次 "Cannot use this model: auto",与 Backend 配置已记录的月配额耗尽一致),
# 两次均未建立会话、零改动(现场保留)。恢复时改回 enabled: true。
enabled: false
antigravity:
adapter: antigravity
executable: agy
model: auto
# 2026-10-21 临时禁用:U3B 尝试被 400 FAILED_PRECONDITION "User location is
# not supported for the API use" 拒绝,0 token、未建立会话(现场保留);
# 与 Backend 配置已记录的 Gemini 端点不稳一致。恢复时改回 enabled: true。
enabled: false
pi:
adapter: pi
executable: pi
model: auto
enabled: true
codex:
adapter: codex
executable: codex
model: auto
enabled: true
integration:
exempt_paths: [".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"]
transport:
command: "scripts/agent-exec apply"
admission:
command: "scripts/agent-exec admit"
cleanup:
command: "scripts/agent-exec cleanup"
```
+18
View File
@@ -34,3 +34,21 @@
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
## Agent 执行与新项目准入(强制)
1. 缺 Guidelines 固定版本(`deps/development-guidelines` gitlink 未就绪或 HEAD 不符)时,Agent 只能只读分析,不得实施。
2. 缺有效 `AGENT-EXECUTION.md`(`scripts/dm governance check` 未通过)时禁止 AI 实施。
3. 缺共享执行器(`scripts/agent-exec` 或 `deps/development-manager` 不可用)时,状态为 `blocked/unavailable`,不得改由 host 替代。
4. 不允许 host 代写实施单元;实施、修复与测试必须经 `AGENT-EXECUTION.md` 登记的候选执行。
5. 人类直接开发按其自身授权与项目规则进行,不受本条款约束。
6. 分支保护与规范 tag 由用户负责设置与维护,Agent 不得代为设置。
入口:
- 需求分流:`scripts/task triage discover|decide|verify`
- 任务启动:`scripts/task start …`
- PR 检查:`scripts/task pr-check …`
- Agent 执行:`scripts/agent-exec run|apply|admit|validate`
AI 实施单元只经 `scripts/agent-exec run` 按 AGENT-EXECUTION.md 运行,并经 `scripts/agent-exec apply` 集成。
+3 -2
View File
@@ -12,8 +12,8 @@
- 提交/审核/合并/发布身份与权限:仓库所有者 weichen。
- 任务唯一来源:Gitea `weichen/Agent-Squad` 的 Issue 与 Pull Request。
- 规范性文档目录:`policy/`。
- 过程文档仓/目录:`docs/records/`(项目内豁免,见采纳记录)。
- 交接记录位置:随任务主记录;需要落盘的交接放入 `docs/records/`。
- 过程文档仓/目录:任务卡与交付证据在 `.task/`(模板 `.task/templates/task-card.md`);其他过程记录在 `docs/records/`(项目内豁免,见采纳记录)。
- 交接记录位置:随任务主记录;需要落盘的任务交接与交付证据放入 `.task/`,其他落盘交接放入 `docs/records/`。
- 凭证查找入口及所需字段:LLM 使用环境变量 `AGENT_SQUAD_LLM_BASE_URL`、`AGENT_SQUAD_LLM_API_KEY`、`AGENT_SQUAD_LLM_MODEL`。日志采集 token 使用日志库自己的配置。文档与仓库不记录这些值。
## 依赖组合
@@ -45,6 +45,7 @@ git clone --recurse-submodules ssh://git@git-ssh.u1680832.nyat.app:31411/weichen
| 模块/接口 | 仓库根:`GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...` | 需要已初始化的 Agent-Native 子模块;默认 stub,不调用真实模型 |
| 数据/权限/恢复 | conformance 与 store 测试覆盖运行落盘、HITL 恢复 | 无共享数据库 |
| UI/执行后端 | `GOWORK=off go test -count=1 ./server/... ./conformance/...` | 不覆盖浏览器;SSE 由测试客户端验证 |
| 治理与 Agent 执行配置 | 仓库根:`scripts/agent-exec validate --repo .` 与 `scripts/dm governance check --repo .` | 需 `deps/development-manager` 已按 gitlink 检出(首次 `dm` 调用会构建 dm-server);只核验 AGENT-EXECUTION.md 与治理文件,不证明实施单元正确 |
## 环境与数据
+179
View File
@@ -0,0 +1,179 @@
// Package a2a 把小队接入 A2A(Agent2Agent)1.0 互通面(Q3 WP4):
// - 发布侧:BuildCard 从角色注册表与蓝图注册表渲染 Agent Card(描述本
// 团队能力),CardHandler 以 JSON 服务(挂载在
// /.well-known/agent-card.json);
// - 消费侧:FetchCard 发现远端 Agent Card(fail-closed 校验必备字段),
// Executor 实现 squad.Executor——message/send 发起任务,tasks/get 轮询
// A2A 任务状态机(submitted/working/input-required → completed/failed/
// canceled),终态映射 ExecResult:completed 的 artifacts(TextPart/
// FilePart)落成 patches/目标文件;failed/canceled/input-required 一律
// Success=false(跨进程挂起不在本适配面内,fail-closed 交引擎重试/降级
// 语义处理)。
//
// 传输为 JSON-RPC 2.0 over HTTP(A2A 1.0 形态);不引入协议之外的依赖。
package a2a
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"agent-squad/squad"
)
// ProtocolVersion 对齐的 A2A 协议版本。
const ProtocolVersion = "1.0"
// WellKnownPath 是 Agent Card 的发现路径(A2A 1.0)。
const WellKnownPath = "/.well-known/agent-card.json"
// AgentCard 是 A2A 1.0 Agent Card(发布与消费共用形态)。
type AgentCard struct {
ProtocolVersion string `json:"protocolVersion"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
// URL 是 JSON-RPC 服务端点(消费侧据此发 message/send)。
URL string `json:"url"`
Version string `json:"version"`
Capabilities CardCapabilities `json:"capabilities"`
DefaultInputModes []string `json:"defaultInputModes,omitempty"`
DefaultOutputModes []string `json:"defaultOutputModes,omitempty"`
Skills []AgentSkill `json:"skills,omitempty"`
}
// CardCapabilities 能力声明(本适配面:非流式、无推送、无状态历史)。
type CardCapabilities struct {
Streaming bool `json:"streaming"`
PushNotifications bool `json:"pushNotifications"`
StateTransitionHistory bool `json:"stateTransitionHistory"`
}
// AgentSkill 是一项可被远端调用的能力。
type AgentSkill struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Tags []string `json:"tags,omitempty"`
Examples []string `json:"examples,omitempty"`
}
// CardOptions 覆盖 BuildCard 的身份字段(空则用缺省)。
type CardOptions struct {
Name string
Description string
// URL JSON-RPC 端点(必填:远端没有端点就无法调用本团队)。
URL string
// Version 卡片版本(空 = "1.0.0")。
Version string
// Skills 显式能力清单;空 = 从注册表派生。
Skills []AgentSkill
}
// BuildCard 从角色注册表与蓝图注册表渲染 Agent Card(描述本团队能力)。
// 角色 → 技能(关键词作 tags);蓝图 → 技能(match 词作 examples)。
func BuildCard(reg *squad.RoleRegistry, bps *squad.BlueprintRegistry, opts CardOptions) (AgentCard, error) {
if strings.TrimSpace(opts.URL) == "" {
return AgentCard{}, fmt.Errorf("a2a: Agent Card 需要 JSON-RPC 端点 URL")
}
card := AgentCard{
ProtocolVersion: ProtocolVersion,
Name: orDefault(opts.Name, "agent-squad"),
Description: opts.Description,
URL: opts.URL,
Version: orDefault(opts.Version, "1.0.0"),
DefaultInputModes: []string{"text/plain"},
DefaultOutputModes: []string{"text/plain"},
Skills: append([]AgentSkill(nil), opts.Skills...),
}
if len(card.Skills) == 0 {
if reg != nil {
for _, role := range reg.List() {
desc := firstLine(role.Instructions)
if desc == "" {
desc = role.Name
}
card.Skills = append(card.Skills, AgentSkill{
ID: role.Role, Name: orDefault(role.Name, role.Role),
Description: desc, Tags: append([]string(nil), role.Keywords...),
})
}
}
if bps != nil {
for _, bp := range bps.List() {
card.Skills = append(card.Skills, AgentSkill{
ID: bp.Key, Name: orDefault(bp.Title, bp.Key),
Description: bp.Description, Examples: append([]string(nil), bp.Match...),
})
}
}
}
return card, nil
}
// CardHandler 以 JSON 服务 Agent Card(调用方挂载到 WellKnownPath)。
func CardHandler(card AgentCard) http.Handler {
body, _ := json.Marshal(card)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
})
}
// FetchCard 发现并校验远端 Agent Card(fail-closed:必备字段缺失即拒)。
func FetchCard(client *http.Client, wellKnownURL string) (AgentCard, error) {
if client == nil {
client = http.DefaultClient
}
resp, err := client.Get(wellKnownURL)
if err != nil {
return AgentCard{}, fmt.Errorf("a2a: 获取 Agent Card: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return AgentCard{}, fmt.Errorf("a2a: Agent Card 端点返回 %d", resp.StatusCode)
}
var card AgentCard
if err := json.NewDecoder(resp.Body).Decode(&card); err != nil {
return AgentCard{}, fmt.Errorf("a2a: Agent Card 解析失败: %w", err)
}
if err := validateCard(card); err != nil {
return AgentCard{}, err
}
return card, nil
}
// validateCard 消费侧最小校验(A2A 1.0 必备字段)。
func validateCard(card AgentCard) error {
if card.ProtocolVersion == "" {
return fmt.Errorf("a2a: Agent Card 缺 protocolVersion")
}
if strings.TrimSpace(card.Name) == "" {
return fmt.Errorf("a2a: Agent Card 缺 name")
}
if strings.TrimSpace(card.URL) == "" {
return fmt.Errorf("a2a: Agent Card 缺 url")
}
if strings.TrimSpace(card.Version) == "" {
return fmt.Errorf("a2a: Agent Card 缺 version")
}
return nil
}
func orDefault(v, def string) string {
if strings.TrimSpace(v) != "" {
return v
}
return def
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
}
if len(s) > 120 {
s = s[:120] + "…"
}
return s
}
+345
View File
@@ -0,0 +1,345 @@
package a2a
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"agent-squad/squad"
)
// conformance_test.go — Q3 WP4 验收:双向 conformance(本地假 A2A 服务端)。
// 发布侧:BuildCard + CardHandler 在 well-known 路径服务合法 Agent Card;
// 消费侧:FetchCard 发现校验 + Executor 走完任务状态机
// (submitted→working→completed / failed / input-required),终态对齐
// squad.ExecResult。
// fakeA2AServer 最小 A2A 1.0 服务端:well-known 卡片 + JSON-RPC
// (message/send 建任务;tasks/get 按脚本推进状态)。
type fakeA2AServer struct {
mu sync.Mutex
card AgentCard
tasks map[string]*Task
plan []string // 每次 tasks/get 推进到的状态序列(走完停在末态)
calls map[string]int
artifacts []Artifact
statusMsg string
}
func newFakeServer(t *testing.T, card AgentCard, plan []string, artifacts []Artifact) *fakeA2AServer {
t.Helper()
s := &fakeA2AServer{card: card, tasks: map[string]*Task{}, plan: plan,
calls: map[string]int{}, artifacts: artifacts}
return s
}
func (s *fakeA2AServer) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc(WellKnownPath, func(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
body, _ := json.Marshal(s.card)
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
})
mux.HandleFunc("/rpc", func(w http.ResponseWriter, r *http.Request) {
var req rpcRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeRPCError(w, 0, -32700, "parse error")
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.calls[req.Method]++
switch req.Method {
case "message/send":
id := fmt.Sprintf("task_%d", len(s.tasks)+1)
state := StateSubmitted
if len(s.plan) > 0 {
state = s.plan[0]
}
task := &Task{Kind: "task", ID: id, ContextID: "ctx_1",
Status: TaskStatus{State: state, Message: s.statusMessage()}}
s.tasks[id] = task
writeRPCResult(w, req.ID, task)
case "tasks/get":
var params struct {
ID string `json:"id"`
}
raw, _ := json.Marshal(req.Params)
_ = json.Unmarshal(raw, &params)
task, ok := s.tasks[params.ID]
if !ok {
writeRPCError(w, req.ID, -32001, "task not found")
return
}
// 按脚本推进状态机(停留在终态)。
if !isTerminal(task.Status.State) && len(s.plan) > 0 {
n := s.calls["tasks/get#"+params.ID]
s.calls["tasks/get#"+params.ID] = n + 1
idx := n + 1
if idx >= len(s.plan) {
idx = len(s.plan) - 1
}
task.Status.State = s.plan[idx]
task.Status.Message = s.statusMessage()
if task.Status.State == StateCompleted {
task.Artifacts = s.artifacts
}
}
writeRPCResult(w, req.ID, task)
default:
writeRPCError(w, req.ID, -32601, "method not found")
}
})
return mux
}
func (s *fakeA2AServer) statusMessage() *Message {
if s.statusMsg == "" {
return nil
}
return &Message{Kind: "message", Role: "agent",
Parts: []Part{{Kind: "text", Text: s.statusMsg}}}
}
func writeRPCResult(w http.ResponseWriter, id int64, result any) {
body, _ := json.Marshal(result)
env, _ := json.Marshal(rpcResponse{JSONRPC: "2.0", ID: id, Result: body})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(env)
}
func writeRPCError(w http.ResponseWriter, id int64, code int, msg string) {
env, _ := json.Marshal(rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(env)
}
// ---- 发布侧 ----
func TestBuildCardFromRegistries(t *testing.T) {
reg := squad.NewRoleRegistry()
for _, r := range []squad.RoleSpec{
{Role: "researcher", Name: "调研员", Keywords: []string{"调研", "检索"},
Instructions: "围绕目标调研关键问题。\n第二行不进摘要。"},
{Role: "writer", Name: "撰稿员", Keywords: []string{"撰写"}},
} {
if err := reg.Register(r); err != nil {
t.Fatal(err)
}
}
bps := squad.NewBlueprintRegistry()
if err := bps.Register(squad.Blueprint{Key: "brief", Title: "简报",
Description: "生成简报", Match: []string{"简报", "brief"},
Steps: []squad.StepSpec{{ID: "s1", Role: "writer", Title: "成文"}}}); err != nil {
t.Fatal(err)
}
card, err := BuildCard(reg, bps, CardOptions{
Name: "示例小队", Description: "演示团队能力", URL: "https://team.example/rpc",
})
if err != nil {
t.Fatal(err)
}
if card.ProtocolVersion != ProtocolVersion || card.Version != "1.0.0" {
t.Fatalf("card = %+v", card)
}
byID := map[string]AgentSkill{}
for _, s := range card.Skills {
byID[s.ID] = s
}
if byID["researcher"].Name != "调研员" || byID["researcher"].Description != "围绕目标调研关键问题。" {
t.Fatalf("role skill = %+v", byID["researcher"])
}
if len(byID["researcher"].Tags) != 2 {
t.Fatalf("tags = %v", byID["researcher"].Tags)
}
if byID["brief"].Name != "简报" || strings.Join(byID["brief"].Examples, ",") != "简报,brief" {
t.Fatalf("blueprint skill = %+v", byID["brief"])
}
if _, err := BuildCard(reg, bps, CardOptions{}); err == nil {
t.Fatal("缺 URL 必须 fail-closed")
}
}
// CardHandler 以 JSON 服务卡片(发布端点形态)。
func TestCardHandlerServesJSON(t *testing.T) {
card := AgentCard{ProtocolVersion: ProtocolVersion, Name: "小队", URL: "https://x/rpc", Version: "1.0.0"}
ts := httptest.NewServer(CardHandler(card))
defer ts.Close()
resp, err := http.Get(ts.URL + WellKnownPath)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
t.Fatalf("content-type = %s", ct)
}
var got AgentCard
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if got.Name != "小队" || got.ProtocolVersion != ProtocolVersion {
t.Fatalf("card = %+v", got)
}
}
// ---- 消费侧:发现 + 状态机 ----
func TestFetchCardAndExecuteCompleted(t *testing.T) {
fileBytes := base64.StdEncoding.EncodeToString([]byte("notes 内容"))
artifacts := []Artifact{{
ArtifactID: "art1", Name: "调研结果",
Parts: []Part{
{Kind: "text", Text: "# 调研结论\n\n结论正文,长度足够作为交付物内容。"},
{Kind: "file", File: &FilePart{Name: "notes.md", Bytes: fileBytes, MimeType: "text/markdown"}},
},
}}
card := AgentCard{ProtocolVersion: ProtocolVersion, Name: "远端小队",
URL: "PLACEHOLDER", Version: "1.0.0"}
srv := newFakeServer(t, card, []string{StateWorking, StateWorking, StateCompleted}, artifacts)
ts := httptest.NewServer(srv.handler())
defer ts.Close()
srv.mu.Lock()
srv.card.URL = ts.URL + "/rpc"
srv.mu.Unlock()
// 发现:well-known 路径取卡并校验。
got, err := FetchCard(ts.Client(), ts.URL+WellKnownPath)
if err != nil {
t.Fatal(err)
}
if got.Name != "远端小队" || got.URL != ts.URL+"/rpc" {
t.Fatalf("card = %+v", got)
}
// 执行:submitted/working → completed,工件映射 patches。
var states []string
ex := NewExecutor(got)
ex.Client = ts.Client()
ex.PollInterval = 5 * time.Millisecond
ex.OnState = func(s string) { states = append(states, s) }
res, err := ex.Execute(context.Background(), squad.ExecRequest{
Prompt: "调研主题 X", Targets: []string{"output.md"},
})
if err != nil {
t.Fatal(err)
}
if !res.Success {
t.Fatalf("result = %+v", res)
}
if string(res.Patches["notes.md"]) != "notes 内容" {
t.Fatalf("file part 未落成 patch:%q", res.Patches["notes.md"])
}
if !strings.Contains(string(res.Patches["output.md"]), "调研结论") {
t.Fatalf("文本部件未写入目标:%q", res.Patches["output.md"])
}
joined := strings.Join(states, ",")
if !strings.Contains(joined, StateWorking) || !strings.HasSuffix(joined, StateCompleted) {
t.Fatalf("状态机迁移 = %v", states)
}
srv.mu.Lock()
sends := srv.calls["message/send"]
srv.mu.Unlock()
if sends != 1 {
t.Fatalf("message/send = %d", sends)
}
}
func TestExecuteMapsFailedAndInputRequired(t *testing.T) {
card := AgentCard{ProtocolVersion: ProtocolVersion, Name: "远端", URL: "PLACEHOLDER", Version: "1"}
srv := newFakeServer(t, card, []string{StateWorking, StateFailed}, nil)
srv.statusMsg = "远端执行崩溃"
ts := httptest.NewServer(srv.handler())
defer ts.Close()
srv.mu.Lock()
srv.card.URL = ts.URL + "/rpc"
srv.mu.Unlock()
ex := NewExecutor(card)
ex.BaseURL = ts.URL + "/rpc"
ex.Client = ts.Client()
ex.PollInterval = 5 * time.Millisecond
res, err := ex.Execute(context.Background(), squad.ExecRequest{Prompt: "x", Targets: []string{"output.md"}})
if err != nil {
t.Fatal(err)
}
if res.Success || !strings.Contains(res.ErrorReason, "failed") || !strings.Contains(res.ErrorReason, "远端执行崩溃") {
t.Fatalf("failed 映射 = %+v", res)
}
// input-required:fail-closed(跨进程人工输入不在适配面内)。
srv2 := newFakeServer(t, card, []string{StateInputRequired}, nil)
srv2.statusMsg = "请补充授权"
ts2 := httptest.NewServer(srv2.handler())
defer ts2.Close()
ex2 := &Executor{BaseURL: ts2.URL + "/rpc", Client: ts2.Client(), PollInterval: 5 * time.Millisecond}
res2, err := ex2.Execute(context.Background(), squad.ExecRequest{Prompt: "x", Targets: []string{"output.md"}})
if err != nil {
t.Fatal(err)
}
if res2.Success || !strings.Contains(res2.ErrorReason, "input-required") {
t.Fatalf("input-required 映射 = %+v", res2)
}
}
// 直接回 Message(无任务态)→ 文本落目标。
func TestExecuteDirectMessageResult(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req rpcRequest
_ = json.NewDecoder(r.Body).Decode(&req)
writeRPCResult(w, req.ID, Message{Kind: "message", Role: "agent",
Parts: []Part{{Kind: "text", Text: "直接回答正文"}}})
}))
defer ts.Close()
ex := &Executor{BaseURL: ts.URL, Client: ts.Client()}
res, err := ex.Execute(context.Background(), squad.ExecRequest{Prompt: "x", Targets: []string{"output.md"}})
if err != nil {
t.Fatal(err)
}
if !res.Success || !strings.Contains(string(res.Patches["output.md"]), "直接回答正文") {
t.Fatalf("res = %+v", res)
}
}
// 传输/协议错误 → error(引擎按瞬态评估),不吞成成功。
func TestExecuteProtocolErrors(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeRPCError(w, 1, -32601, "method not found")
}))
defer ts.Close()
ex := &Executor{BaseURL: ts.URL, Client: ts.Client()}
if _, err := ex.Execute(context.Background(), squad.ExecRequest{Prompt: "x"}); err == nil ||
!strings.Contains(err.Error(), "协议错误") {
t.Fatalf("err = %v", err)
}
// ctx 取消传播。
ctx, cancel := context.WithCancel(context.Background())
cancel()
slow := newFakeServer(t, AgentCard{ProtocolVersion: "1.0", Name: "x", URL: ts.URL, Version: "1"},
[]string{StateWorking}, nil)
ts2 := httptest.NewServer(slow.handler())
defer ts2.Close()
ex2 := &Executor{BaseURL: ts2.URL + "/rpc", Client: ts2.Client(), PollInterval: time.Second}
if _, err := ex2.Execute(ctx, squad.ExecRequest{Prompt: "x"}); err == nil {
t.Fatal("取消必须传播")
}
}
// FetchCard fail-closed:缺必备字段拒绝。
func TestFetchCardValidates(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"protocolVersion":"1.0","name":"","url":"","version":""}`))
}))
defer ts.Close()
if _, err := FetchCard(ts.Client(), ts.URL); err == nil || !strings.Contains(err.Error(), "name") {
t.Fatalf("err = %v", err)
}
}
+331
View File
@@ -0,0 +1,331 @@
package a2a
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync/atomic"
"time"
"agent-squad/squad"
)
// executor.go — 远端智能体 Executor 客户端(A2A 任务状态机对齐 ExecResult)。
// 任务状态(A2A 1.0 TaskState)。
const (
StateSubmitted = "submitted"
StateWorking = "working"
StateInputRequired = "input-required"
StateCompleted = "completed"
StateCanceled = "canceled"
StateFailed = "failed"
StateRejected = "rejected"
)
// DefaultPollInterval 是 tasks/get 轮询缺省间隔。
const DefaultPollInterval = 500 * time.Millisecond
// Executor 把远端 A2A 智能体接成 squad.Executor:一次步骤执行 = 一个
// A2A 任务(message/send 发起,tasks/get 轮询到终态)。
type Executor struct {
// BaseURL 远端 JSON-RPC 端点(Agent Card 的 url)。
BaseURL string
// Client HTTP 客户端(nil = http.DefaultClient)。
Client *http.Client
// PollInterval 轮询间隔(0 = DefaultPollInterval)。
PollInterval time.Duration
// OnState 可选状态回调(观测任务状态机迁移)。
OnState func(state string)
callID atomic.Int64
}
// NewExecutor 按 Agent Card 构造远端执行器。
func NewExecutor(card AgentCard) *Executor {
return &Executor{BaseURL: card.URL}
}
// Name 实现 squad.Executor。
func (e *Executor) Name() string { return "a2a" }
// ---- A2A 1.0 报文形态(JSON-RPC 2.0) ----
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Method string `json:"method"`
Params any `json:"params"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
// Part 是消息/工件的部件(text | file | data)。
type Part struct {
Kind string `json:"kind"`
Text string `json:"text,omitempty"`
File *FilePart `json:"file,omitempty"`
Data *json.RawMessage `json:"data,omitempty"`
}
// FilePart 文件部件:bytes(base64)或 uri 二选一。
type FilePart struct {
Name string `json:"name"`
Bytes string `json:"bytes,omitempty"`
URI string `json:"uri,omitempty"`
MimeType string `json:"mimeType,omitempty"`
}
// Message 是一条 A2A 消息。
type Message struct {
Kind string `json:"kind"`
Role string `json:"role"`
Parts []Part `json:"parts"`
MessageID string `json:"messageId"`
}
// TaskStatus 任务状态。
type TaskStatus struct {
State string `json:"state"`
Message *Message `json:"message,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
}
// Artifact 任务工件。
type Artifact struct {
ArtifactID string `json:"artifactId,omitempty"`
Name string `json:"name,omitempty"`
Parts []Part `json:"parts"`
}
// Task 是 A2A 任务(状态机载体)。
type Task struct {
Kind string `json:"kind"`
ID string `json:"id"`
ContextID string `json:"contextId,omitempty"`
Status TaskStatus `json:"status"`
Artifacts []Artifact `json:"artifacts,omitempty"`
}
// Execute 实现 squad.Executor:发起任务并轮询到终态,映射 ExecResult。
// 传输/协议错误返回 error(引擎按瞬态失败评估);任务级失败
// (failed/canceled/rejected/input-required)返回 Success=false 的结果
// (fail-closed:跨进程人工输入不在本适配面内)。
func (e *Executor) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
if strings.TrimSpace(e.BaseURL) == "" {
return nil, fmt.Errorf("a2a: executor 缺 BaseURL")
}
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
prompt = strings.TrimSpace(req.Instructions)
}
msg := Message{
Kind: "message", Role: "user",
Parts: []Part{{Kind: "text", Text: prompt}},
MessageID: fmt.Sprintf("msg-%d-%d", time.Now().UnixNano(), e.callID.Add(1)),
}
raw, err := e.call(ctx, "message/send", map[string]any{"message": msg})
if err != nil {
return nil, err
}
// 服务端可直接回 Message(无任务态)或 Task。
var probe struct {
Kind string `json:"kind"`
}
_ = json.Unmarshal(raw, &probe)
if probe.Kind == "message" {
var m Message
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("a2a: message 结果解析失败: %w", err)
}
return e.resultFromParts(m.Parts, req.Targets), nil
}
var task Task
if err := json.Unmarshal(raw, &task); err != nil {
return nil, fmt.Errorf("a2a: task 结果解析失败: %w", err)
}
e.observe(task.Status.State)
if shouldStop(task.Status.State) {
return e.resultFromTask(task, req.Targets), nil
}
// 轮询任务状态机直至终态(ctx 取消传播)。
interval := e.PollInterval
if interval <= 0 {
interval = DefaultPollInterval
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-ticker.C:
}
raw, err := e.call(ctx, "tasks/get", map[string]any{"id": task.ID})
if err != nil {
return nil, err
}
var next Task
if err := json.Unmarshal(raw, &next); err != nil {
return nil, fmt.Errorf("a2a: tasks/get 解析失败: %w", err)
}
if next.Status.State != task.Status.State {
task = next
e.observe(task.Status.State)
} else {
task = next
}
if shouldStop(task.Status.State) {
return e.resultFromTask(task, req.Targets), nil
}
}
}
// call 发一次 JSON-RPC 调用并取 result。
func (e *Executor) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
client := e.Client
if client == nil {
client = http.DefaultClient
}
body, _ := json.Marshal(rpcRequest{
JSONRPC: "2.0", ID: e.callID.Add(1), Method: method, Params: params,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.BaseURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("a2a: %s 传输失败: %w", method, err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return nil, fmt.Errorf("a2a: %s 读取响应失败: %w", method, err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("a2a: %s 返回 HTTP %d", method, resp.StatusCode)
}
var env rpcResponse
if err := json.Unmarshal(data, &env); err != nil {
return nil, fmt.Errorf("a2a: %s 响应不是合法 JSON-RPC: %w", method, err)
}
if env.Error != nil {
return nil, fmt.Errorf("a2a: %s 协议错误 %d: %s", method, env.Error.Code, env.Error.Message)
}
return env.Result, nil
}
func (e *Executor) observe(state string) {
if e.OnState != nil {
e.OnState(state)
}
}
func isTerminal(state string) bool {
switch state {
case StateCompleted, StateFailed, StateCanceled, StateRejected:
return true
}
return false
}
// shouldStop 轮询停止条件:真终态,或 input-required(跨进程人工输入不在
// 本适配面内,fail-closed 交引擎语义处理,不死等)。
func shouldStop(state string) bool {
return isTerminal(state) || state == StateInputRequired
}
// resultFromTask 终态任务 → ExecResult。
func (e *Executor) resultFromTask(task Task, targets []string) *squad.ExecResult {
switch task.Status.State {
case StateCompleted:
var parts []Part
for _, art := range task.Artifacts {
parts = append(parts, art.Parts...)
}
return e.resultFromParts(parts, targets)
case StateInputRequired:
return &squad.ExecResult{Success: false,
ErrorReason: "a2a: 远端要求人工输入(input-required):" + statusText(task.Status)}
case StateCanceled:
return &squad.ExecResult{Success: false,
ErrorReason: "a2a: 远端任务被取消(canceled):" + statusText(task.Status)}
case StateRejected:
return &squad.ExecResult{Success: false,
ErrorReason: "a2a: 远端拒绝任务(rejected):" + statusText(task.Status)}
default: // failed 及未知终态一律 fail-closed
return &squad.ExecResult{Success: false,
ErrorReason: "a2a: 远端任务失败(" + task.Status.State + "):" + statusText(task.Status)}
}
}
// resultFromParts 部件流 → patches:FilePart(bytes) 按名落 patch;
// 文本部件拼接后写入全部声明目标(无文件工件时的交付物兜底,
// 与其他后端「正文写目标文件」契约一致)。
func (e *Executor) resultFromParts(parts []Part, targets []string) *squad.ExecResult {
patches := map[string][]byte{}
var text strings.Builder
for _, p := range parts {
switch p.Kind {
case "text":
if text.Len() > 0 {
text.WriteString("\n")
}
text.WriteString(p.Text)
case "file":
if p.File == nil || p.File.Name == "" {
continue
}
data, err := base64.StdEncoding.DecodeString(p.File.Bytes)
if err != nil {
continue // uri 形态与坏 base64 不落 patch(消费侧最小面)
}
patches[p.File.Name] = data
}
}
body := strings.TrimSpace(text.String())
if len(targets) == 0 {
targets = []string{"output.md"}
}
if body != "" {
for _, t := range targets {
if _, ok := patches[t]; !ok {
patches[t] = []byte(body + "\n")
}
}
}
if len(patches) == 0 {
return &squad.ExecResult{Success: false, ErrorReason: "a2a: 完成任务无任何工件"}
}
return &squad.ExecResult{Success: true, Patches: patches, Logs: body}
}
func statusText(st TaskStatus) string {
if st.Message == nil {
return ""
}
var b strings.Builder
for _, p := range st.Message.Parts {
if p.Kind == "text" {
b.WriteString(p.Text)
}
}
return b.String()
}
+11
View File
@@ -0,0 +1,11 @@
module agent-squad/adapters/a2a
go 1.27
require agent-squad v0.0.0
require error-code-system v0.0.0 // indirect
replace agent-squad => ../..
replace error-code-system => ../../deps/error-code-system
+105 -3
View File
@@ -3,7 +3,11 @@ package native
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"reflect"
"strings"
"time"
@@ -52,12 +56,58 @@ type Config struct {
PermissionAsk func(ctx context.Context, call llm.ToolCall) runtime.AskVerdict
}
// Identity 返回塑造 Executor 的配置摘要(模型/客户端/工具/Exec 等)。
// RegisterFull 经 BackendResolver.Register 替换工厂时会失效缓存;角色侧
// BashPermission 等由 squad.ConfigIdentity 纳入 Resolve 缓存键,故 allow→deny
// 在下一次 Resolve 即生效,无需重启进程。
func (c Config) Identity() string {
var b strings.Builder
b.WriteString("model=")
b.WriteString(c.DefaultModel)
b.WriteString("\x00turns=")
b.WriteString(fmt.Sprintf("%d", c.MaxTurns))
b.WriteString("\x00timeout=")
b.WriteString(c.Timeout.String())
if c.Client != nil {
b.WriteString("\x00base=")
b.WriteString(c.Client.BaseURL)
b.WriteString("\x00key=")
b.WriteString(c.Client.APIKey)
}
if c.Exec != nil {
b.WriteString("\x00exec.bash=")
b.WriteString(c.Exec.Bash)
b.WriteString("\x00exec.python=")
b.WriteString(c.Exec.Python)
}
if c.Search != nil {
b.WriteString("\x00search=1")
}
if c.Fetch != nil {
b.WriteString("\x00fetch=1")
}
if c.MCP != nil {
b.WriteString("\x00mcp=1")
}
if len(c.SkillsRoots) > 0 {
b.WriteString("\x00skills=")
b.WriteString(strings.Join(c.SkillsRoots, ","))
}
if c.CodeOrchestration {
b.WriteString("\x00code=1")
}
sum := sha256.Sum256([]byte(b.String()))
return hex.EncodeToString(sum[:16])
}
// Register 把 "agent-native" 工厂注册进解析器。
func Register(r *squad.BackendResolver, client *llm.Client, defaultModel string, maxTurns int, timeout time.Duration) {
RegisterFull(r, Config{Client: client, DefaultModel: defaultModel, MaxTurns: maxTurns, Timeout: timeout})
}
// RegisterFull 注册带工具装配的 "agent-native" 工厂。
// 再次调用会替换工厂并失效该后端缓存(见 BackendResolver.Register);
// 配置摘要见 Config.Identity,角色侧见 squad.ConfigIdentity。
func RegisterFull(r *squad.BackendResolver, cfg Config) {
r.Register("agent-native", func(role squad.RoleSpec) (squad.Executor, error) {
opts, err := buildOptions(cfg, role)
@@ -147,10 +197,62 @@ func toNative(req squad.ExecRequest) exec.Request {
}
func fromNative(res *exec.Result) *squad.ExecResult {
return &squad.ExecResult{
Success: res.Success, Patches: res.Patches, Logs: res.Logs, ErrorReason: res.ErrorReason,
Retryable: res.Retryable, PromptTokens: res.PromptTokens, CompletionTokens: res.CompletionTokens,
if res == nil {
return nil
}
out := &squad.ExecResult{
Success: res.Success,
Patches: res.Patches,
Logs: res.Logs,
ErrorReason: res.ErrorReason,
Retryable: res.Retryable,
PromptTokens: res.PromptTokens,
CompletionTokens: res.CompletionTokens,
}
// F05/U3b(Squad 侧 contract):
// - native exec.Result 在后续 release 会新增 SideEffects、Canceled、Aborted 字段;
// 本地 deps gitlink 若尚未跟进,从 JSON 探测拿不到这些字段时回落到
// 「legacy reading: side effects = possible」,与既有调用方旧行为一致。
// - 在不影响 ExecResult 默认值的前提下,用 JSON 探测而非硬性修改 deps。
probe, err := probeNativeResultExtras(res)
if err == nil {
out.SideEffects = probe.SideEffects
out.Canceled = probe.Canceled
out.Aborted = probe.Aborted
}
return out
}
// nativeResultExtras 探测 native exec.Result 上的可选字段(结构化 JSON)。
// 缺省(字段不存在或为零值时)按 legacy reading:SideEffects 空串视为 "possible";
// 调用方侧 ExecResult.IsRetryableSideEffectReports() 已经处理该回退。
type nativeResultExtras struct {
SideEffects squad.ExecSideEffects `json:"side_effects,omitempty"`
Canceled bool `json:"canceled,omitempty"`
Aborted bool `json:"aborted,omitempty"`
}
// probeNativeResultExtras 通过 JSON 探测 native Result 上的可选字段。
// 探测出 cancel/abort/side_effects 任何一个就使用它们;探测失败或全部为零值视为
// legacy reading(SideEffects 空 = possible 兜底)。
func probeNativeResultExtras(res *exec.Result) (nativeResultExtras, error) {
if res == nil {
return nativeResultExtras{}, fmt.Errorf("nil result")
}
v := reflect.ValueOf(res).Elem()
if !v.IsValid() {
return nativeResultExtras{}, fmt.Errorf("invalid result")
}
// 用 JSON 做结构化探测:这一路兼容 deps 升级(增字段)、不增字段两种情形。
data, err := json.Marshal(v.Interface())
if err != nil {
return nativeResultExtras{}, err
}
var out nativeResultExtras
if err := json.Unmarshal(data, &out); err != nil {
return nativeResultExtras{}, err
}
return out, nil
}
type eventTap struct {
+82
View File
@@ -47,3 +47,85 @@ func TestBuildOptionsBashPermissionGating(t *testing.T) {
t.Fatal("missing model must fail-closed")
}
}
func TestConfigIdentityReflectsToolAndClient(t *testing.T) {
base := Config{
Client: &llm.Client{BaseURL: "http://x", APIKey: "k"},
DefaultModel: "m1", MaxTurns: 5, Timeout: time.Minute,
Exec: &tools.ExecConfig{Bash: "/bin/bash"},
}
otherExec := base
otherExec.Exec = &tools.ExecConfig{Bash: "/bin/zsh"}
if base.Identity() == otherExec.Identity() {
t.Fatal("Exec tool-config change must change Config.Identity")
}
otherClient := base
otherClient.Client = &llm.Client{BaseURL: "http://y", APIKey: "k"}
if base.Identity() == otherClient.Identity() {
t.Fatal("client change must change Config.Identity")
}
same := base
if base.Identity() != same.Identity() {
t.Fatal("identical config must keep Identity stable")
}
}
func TestRegisterFullAllowDenyTakesEffectOnNextResolve(t *testing.T) {
r := squad.NewBackendResolver()
cfg := Config{
Client: &llm.Client{BaseURL: "http://x", APIKey: "k"},
DefaultModel: "m1", MaxTurns: 1, Timeout: time.Second,
Exec: &tools.ExecConfig{Bash: "/bin/bash"},
}
RegisterFull(r, cfg)
allowRole := squad.RoleSpec{Role: "eng", Backend: "agent-native", Model: "m1", BashPermission: squad.BashPermissionAllow}
denyRole := squad.RoleSpec{Role: "eng", Backend: "agent-native", Model: "m1", BashPermission: squad.BashPermissionDeny}
eAllow, _, err := r.Resolve(allowRole, "")
if err != nil {
t.Fatal(err)
}
eDeny, _, err := r.Resolve(denyRole, "")
if err != nil {
t.Fatal(err)
}
if eAllow == eDeny {
t.Fatal("allow→deny must not reuse cached executor on next Resolve")
}
// Identity of role configs must diverge so cache keys differ.
if squad.ConfigIdentity(allowRole) == squad.ConfigIdentity(denyRole) {
t.Fatal("role ConfigIdentity must include BashPermission")
}
}
func TestRegisterFullToolConfigReplacementTakesEffect(t *testing.T) {
r := squad.NewBackendResolver()
cfg1 := Config{
Client: &llm.Client{BaseURL: "http://x", APIKey: "k"},
DefaultModel: "m1", MaxTurns: 1, Timeout: time.Second,
Exec: &tools.ExecConfig{Bash: "/bin/bash"},
}
cfg2 := Config{
Client: &llm.Client{BaseURL: "http://x", APIKey: "k"},
DefaultModel: "m1", MaxTurns: 1, Timeout: time.Second,
Exec: &tools.ExecConfig{Bash: "/bin/zsh"},
}
if cfg1.Identity() == cfg2.Identity() {
t.Fatal("precondition: tool configs must differ in Identity")
}
RegisterFull(r, cfg1)
role := squad.RoleSpec{Role: "eng", Backend: "agent-native", Model: "m1", BashPermission: squad.BashPermissionAllow}
e1, _, err := r.Resolve(role, "")
if err != nil {
t.Fatal(err)
}
RegisterFull(r, cfg2)
e2, _, err := r.Resolve(role, "")
if err != nil {
t.Fatal(err)
}
if e1 == e2 {
t.Fatal("RegisterFull tool-config replacement must invalidate cache")
}
}
+76 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -127,7 +128,7 @@ func TestEngineRunsOnSQLiteStore(t *testing.T) {
}
}
backends := squad.NewBackendResolver()
backends.RegisterInstance("script", stubExecutor{result: &squad.ExecResult{Success: false, Logs: "ok", Patches: map[string][]byte{"output.md": []byte(goodReportLite), "notes.md": []byte(goodReportLite), "fields.json": []byte("{}")}}})
backends.RegisterInstance("script", stubExecutor{result: &squad.ExecResult{Success: true, Logs: "ok", Patches: map[string][]byte{"output.md": []byte(goodReportLite), "notes.md": []byte(goodReportLite), "fields.json": []byte("{}")}}})
blueprints := squad.NewBlueprintRegistry()
if err := blueprints.Register(squad.Blueprint{
Key: "demo", Title: "入库演示", Match: []string{"入库演示"},
@@ -185,4 +186,78 @@ func TestEngineRunsOnSQLiteStore(t *testing.T) {
}
}
// TestEngineFailedResultWithCompletePatchesStaysFailedOnSQLiteStore:F05/U3B 的
// 不变式在 SQLite-store 集成层同样成立——executor 报失败就是失败,patches
// (交付物)齐全也不得把运行翻成 completed:文件只是诊断信息,不是权威结果。
func TestEngineFailedResultWithCompletePatchesStaysFailedOnSQLiteStore(t *testing.T) {
runStore, err := OpenSQLiteStore(filepath.Join(t.TempDir(), "runs.db"))
if err != nil {
t.Fatal(err)
}
defer runStore.Close()
dir := t.TempDir()
reg := squad.NewRoleRegistry()
if err := reg.Register(squad.RoleSpec{Role: "researcher", Backend: "script"}); err != nil {
t.Fatal(err)
}
backends := squad.NewBackendResolver()
// 失败结果但携带全套交付物:旧语义会把它提升为成功,U3B 起必须保持失败。
backends.RegisterInstance("script", stubExecutor{result: &squad.ExecResult{
Success: false, Logs: "stub-refused",
Patches: map[string][]byte{
"output.md": []byte(goodReportLite),
"notes.md": []byte(goodReportLite),
"fields.json": []byte("{}"),
},
}})
blueprints := squad.NewBlueprintRegistry()
if err := blueprints.Register(squad.Blueprint{
Key: "faildemo", Title: "失败演示", Match: []string{"失败演示"},
Steps: []squad.StepSpec{{ID: "s1", Role: "researcher", Title: "调研", Targets: []string{"output.md"}}},
}); err != nil {
t.Fatal(err)
}
eng, err := squad.NewEngine(squad.Options{
DataDir: dir, Registry: reg, Blueprints: blueprints,
Planner: squad.NewRulePlanner(),
Backends: backends, Sink: squad.NopSink{},
Store: runStore,
})
if err != nil {
t.Fatal(err)
}
res, err := eng.Run(context.Background(), squad.Goal{Text: "做一个失败演示", HitlMode: squad.HitlAuto}, squad.RunOptions{})
if err != nil {
t.Fatalf("run: %v", err)
}
if res.Status == squad.RunCompleted {
t.Fatalf("failed executor result must not complete (patches are diagnostic only)")
}
if res.Status != squad.RunFailed {
t.Fatalf("status = %s, want failed", res.Status)
}
if res.Plan == nil || res.Plan.Steps[0].Status != "failed" {
t.Fatalf("s1 should be failed, plan = %+v", res.Plan)
}
if !strings.Contains(res.Error, "stub-refused") {
t.Fatalf("failure reason not preserved: %q", res.Error)
}
// 同一断言穿过 Store 端口:SQLite 里落盘的也是失败,而非 completed。
st, err := runStore.LoadRun(res.WorkDir)
if err != nil {
t.Fatal(err)
}
if st.Plan.Workflow.Status != "failed" {
t.Fatalf("persisted workflow status = %s, want failed", st.Plan.Workflow.Status)
}
if got := st.Plan.Workflow.Steps[0]; got.Status != "failed" || !strings.Contains(got.Error, "stub-refused") {
t.Fatalf("persisted step = %+v", got)
}
// patches 确已物化(文件齐全),依然不翻转状态:权威是执行结果,不是文件。
sub := filepath.FromSlash(st.Plan.Workflow.Steps[0].SubWorkspace)
if _, err := os.Stat(filepath.Join(res.WorkDir, sub, "output.md")); err != nil {
t.Fatalf("diagnostic patch should still be materialized: %v", err)
}
}
const goodReportLite = "# 测试交付物\n\n## 概述\n\n这是由脚本后端生成的合格交付物,长度超过五十个字符以满足最小质检要求。\n"
Vendored Submodule
+1
+16 -11
View File
@@ -1,24 +1,26 @@
# 通用规范采纳记录
- 项目/负责人:Agent-Squad / 仓库所有者 weichen
- 项目/负责人:Agent-Squad / weichen
- 状态:active
- 规范位置:`deps/development-guidelines`
- 规范性文档目录:`policy/`
- 过程文档位置:`docs/records/`(项目内固定目录豁免。个人维护的编排库,任务主记录仍在 Gitea Issue/PR。过程记录不进规范库,也不写成与 Issue 平行的第二状态。)
- 版本权威:父仓 gitlink;可读标签:无(内容版本 `0.1.0-draft.6`,提交 `26b562d2e34fbc560711296a309c91c74ffc55b1`)
- 批准/生效依据:本仓接入提交
- 替代范围和迁移说明:本仓此前没有根 `AGENTS.md`。Agent-Native 子模块已从仓库根迁到 `deps/agent-native`,迁移说明见 `docs/records/2026-09-29-guidelines-and-observability.md`。
- 过程文档位置:任务卡在 `.task/`(`.task/feature-governance-adoption.md`);任务主记录在 Gitea PR 中维护。既有过程记录仍按项目内固定目录豁免留在 `docs/records/`。过程记录不进规范库,也不写成与 Issue/PR 平行的第二状态。
- 版本权威:本仓 gitlink deps/development-guidelines @ 2688fd2be6a5(完整 `2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb`,内容版本 `0.1.0-draft.12`,无已发布标签)
- 批准/生效依据:`feature/governance-adoption PR(Agent-Squad)`
- 替代范围和迁移说明:本次替代 2026-09-29 采纳的固定版本 `26b562d2e34f`(内容版本 `0.1.0-draft.6`),规则条目按 `0.1.0-draft.12` 重新核对。新增 `AGENT-EXECUTION.md` 执行路由、`deps/development-manager` gitlink(`bd3a7de57a3a`)、`scripts/dm`、`scripts/agent-exec`、`scripts/task`、`scripts/hooks/pre-commit`、`.gitea/workflows/governance.yml` 与任务卡模板。不改业务代码,不改既有 `.gitea/workflows/ci.yml`。此前迁移说明见 `docs/records/2026-09-29-guidelines-and-observability.md`。
| 规范/工作流 | 适用条件与范围 | 采纳或不适用理由 | 执行方式/证据 |
|---|---|---|---|
| core/baseline.md | 日常工作 | 编排库对外有失败关闭语义 | manual |
| workflows/delivery.md、debugging.md、handoff.md | 交付、排障与交接 | 跨仓组合需要可交接记录 | manual |
| standards/collaboration.md | 并行共同起点与组合升级 | 父仓 gitlink 锁定 Agent-Native 与基础库 | manual |
| standards/verification.md | 验证与结论 | `GOWORK=off go test` | manual;命令见 PROJECT.md |
| standards/merge-queue.md | 多个就绪任务的最终同步、CI、Review 与合入调度 | 多个 agent 单元并行开发,合入须串行同步;协调者为仓库所有者 | manual |
| standards/verification.md | 验证与结论 | `GOWORK=off go test` | manual;命令见 PROJECT.md(含 `scripts/agent-exec validate --repo .`、`scripts/dm governance check --repo .`) |
| workflows/design-verification.md、standards/testability.md、standards/failure-containment.md | 规划、执行、HITL | conformance 覆盖公开 API | manual |
| standards/module-contracts.md | squad 与 agent-native 边界 | 依赖方向只允许 squad → agent-native | manual |
| standards/observability.md | HTTP 信封、trace、事件 | 接入错误码库与日志库 | manual |
| standards/agent-execution.md | 角色绑定的执行后端 | 本库不实现 Agent | manual |
| standards/agent-execution.md | 工程 Agent CLI 执行路由、会话隔离与受控集成 | 凡允许 AI 协助工程变更必须采纳;路由配置见 AGENT-EXECUTION.md;本库不实现 Agent | 执行方式:AGENT-EXECUTION.md + scripts/agent-exec(validate / admit);证据:`governance check + 正负例(.task/feature-governance-adoption.md)` |
| standards/tool-adaptation.md | 为具体工具创建规则与钩子 | 本次引入 `scripts/hooks/pre-commit`(启用由 host/人执行,`core.hooksPath`) | manual;启用后以本仓 pre-commit 检查为证据 |
| standards/multi-repo.md | 多级 submodule | 本仓 replace 是编译时的唯一依赖解析 | manual |
| standards/data-reliability.md | 运行目录、HITL 恢复、幂等回应 | 磁盘为权威 | manual |
| standards/security.md | bash 权限挂起、脱敏 | 事件落盘沿用现有脱敏;凭证走环境变量 | manual |
@@ -31,14 +33,17 @@
| 原规则/章节 | 项目采用方式 | 原因与风险 | 负责人/复查条件 |
|---|---|---|---|
| documentation.md 过程仓默认独立仓库 | 过程记录放 `docs/records/` | 同 Agent-Native:避免个人项目再维护一个过程仓。风险是证据与代码同库 | 多人协作或对外发布时复查 |
| documentation.md 过程仓默认独立仓库 | 其他过程记录放 `docs/records/`(既有过程记录的项目内固定目录豁免) | 同 Agent-Native:避免个人项目再维护一个过程仓。风险是过程记录与代码同库 | 多人协作或对外发布时复查 |
| documentation.md 过程仓默认独立仓库 | 任务卡与交付证据放 `.task/`(模板 `.task/templates/task-card.md`) | 任务状态仍只认 Gitea Issue/PR,`.task/` 不写成平行状态;风险是任务卡与代码同库 | 多人协作或对外发布时复查 |
| multi-repo.md 组件说明不手写第二套 SHA | PROJECT.md 只写路径与角色,SHA 以 `git submodule status` 为准 | 手抄 SHA 会与 gitlink 漂移 | 每次依赖升级核对脚本 |
## 本次升级或首次接入验证
- `deps/development-guidelines` 固定 `26b562d2e34fbc560711296a309c91c74ffc55b1`。
- 执行方式为 manual。
- 回退该规范挂载时,同时确认 Agent-Native 子模块路径是否已迁移;只还原规范 gitlink 不会把 `deps/agent-native` 移回根目录。
- `deps/development-guidelines` 固定 `2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb`(内容版本 `0.1.0-draft.12`);`deps/development-manager` 固定 `bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c`。
- 工程 Agent 执行:`AGENT-EXECUTION.md` 为唯一路由权威;AI 实施单元只经 `scripts/agent-exec run` 执行,并经 `scripts/agent-exec apply` 集成。`scripts/agent-exec` 与 `scripts/task` 只是转调 `scripts/dm` 的薄入口。
- 登记为 active 的前置证据:一次成功执行正例(有效 `completed` 收据经 transport/admission 集成);缺配置、无效终态收据、越权路径、未知副作用四类负例均被拒绝或 fail-stop。证据按 `.task/feature-governance-adoption.md` 记录。
- 入口核验:`scripts/dm governance check --repo .` 与 `scripts/agent-exec validate --repo .`(PROJECT.md「验证」节)。CI 由 `.gitea/workflows/governance.yml` 执行;既有 `ci.yml` 未改。
- 回退该规范挂载时,恢复上一已批准 gitlink `26b562d2e34f`,并同时还原本次新增的治理脚本与 `governance.yml`;如已启用 `core.hooksPath`,需一并关闭。只还原规范 gitlink 不会把 `deps/agent-native` 移回根目录。
### 2026-09-29 追加:agent-intent 可选意图闸门
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# 共享 Agent 执行器薄入口:转调 scripts/dm 的 agent-exec 子命令(validate / run / apply / admit / cleanup)。
set -euo pipefail
exec "$(dirname "$0")/dm" agent-exec "$@"
Executable
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# 共享 dm-server 薄入口:读取 deps/development-manager 的 gitlink 钉住提交,
# 按该提交构建并缓存 dm-server,再 exec 其子命令(agent-exec、task、governance)。执行语义与退出码以该二进制为准。
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
dm="$root/deps/development-manager"
die() { echo "dm: $*" >&2; exit 2; }
# git 钩子会导出仓库本地变量(GIT_DIR、GIT_INDEX_FILE 等),它们会让 `git -C "$dm"` 解析到外层仓库。
# 所有针对 DM 检出的 git 与 go 调用都在清除这些变量后执行;读取外层 gitlink 与最终 exec 仍保留继承环境。
dm_local_env="GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_GRAFT_FILE GIT_NAMESPACE GIT_CONFIG GIT_CONFIG_PARAMETERS GIT_CONFIG_COUNT GIT_SHALLOW_FILE GIT_IMPLICIT_WORK_TREE $(git rev-parse --local-env-vars 2>/dev/null || true)"
dm_clean_env() { for v in $dm_local_env; do unset "$v"; done; }
dmgit() ( dm_clean_env; git -C "$dm" "$@" )
# 1. 钉住版本只来自索引中的 gitlink(mode 160000),不读取工作区文件。
entry=$(git -C "$root" ls-files -s -- deps/development-manager 2>/dev/null || true)
[ -n "$entry" ] || die "索引中缺少 deps/development-manager 的 gitlink,无法确定钉住的 DM 版本"
read -r mode pin _stage _path <<<"$entry"
[ "$mode" = 160000 ] || die "deps/development-manager 不是 gitlink(mode=$mode)"
[[ "$pin" =~ ^[0-9a-f]{40}$ ]] || die "gitlink 提交格式无效:$pin"
# 2. 检出必须存在且确为 DM 独立仓库(避免 git -C 误解析到外层仓库),HEAD 必须等于钉住提交。
[ -e "$dm/.git" ] || die "deps/development-manager 未检出;请按钉住提交 $pin 检出该 gitlink"
top=$(dmgit rev-parse --show-toplevel 2>/dev/null || true)
[ -n "$top" ] && [ "$top" = "$(cd "$dm" && pwd -P)" ] \
|| die "deps/development-manager 不是独立检出;请按钉住提交 $pin 检出该 gitlink"
head=$(dmgit rev-parse HEAD 2>/dev/null || true)
[ "$head" = "$pin" ] \
|| die "deps/development-manager HEAD(${head:-未知})与钉住提交 $pin 不一致;请按钉住提交检出"
# 3. 检出工作树必须干净(忽略嵌套 submodule 状态)。
dirty=$(dmgit status --porcelain --ignore-submodules=all) \
|| die "无法读取 deps/development-manager 的工作树状态"
[ -z "$dirty" ] || die "deps/development-manager 存在未提交改动,拒绝运行(请先清理该检出)"
# 4. 二进制按钉住提交缓存;缺失时在缓存目录内临时构建,完成后原子改名,避免留下半成品。
cache="${DM_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/development-manager/dm-server}"
bin="$cache/dm-server-$pin"
if [ ! -x "$bin" ]; then
command -v go >/dev/null 2>&1 || die "缺少 go,无法构建 dm-server($pin)"
mkdir -p "$cache"
cache=$(cd "$cache" && pwd -P)
bin="$cache/dm-server-$pin"
work=$(mktemp -d "$cache/build-$pin.XXXXXX")
if ! (dm_clean_env; cd "$dm" && GOWORK=off go build -trimpath -o "$work/dm-server" ./cmd/dm-server); then
rm -rf "$work"
die "构建 dm-server 失败($pin)"
fi
mv -f "$work/dm-server" "$bin"
rm -rf "$work"
fi
exec "$bin" "$@"
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# 本地检查(pre-commit):集成分支直提拒绝 + 分支命名 + 本地完整性核对。权威准入在 PR(baseline.md:26)。
branch=$(git rev-parse --abbrev-ref HEAD)
case "$branch" in
main|master)
echo "pre-commit: 拒绝——集成分支 ${branch} 不得直接提交,走 feature 分支 + PR(collaboration.md)" >&2
exit 1 ;;
esac
echo "$branch" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || {
echo "pre-commit: 拒绝——分支名须为 feature|fix|hotfix|release/<slug>,当前:${branch}" >&2
exit 1
}
root=$(git rev-parse --show-toplevel)
# admit 只是本地完整性/篡改证据(SELF):同一 OS 用户可读取本地 HMAC 密钥并绕过本钩子,
# 它不是 GATE,也不是可信身份。缺失或无效的本地记录仍拒绝;交付信任来自精确 HEAD 的 CI 或独立 REPLAY。
"$root/scripts/agent-exec" admit --repo "$root"
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# 通用任务入口:转调 scripts/dm 的 task 子命令(triage / start / pr-check)。
set -euo pipefail
exec "$(dirname "$0")/dm" task "$@"
+104 -15
View File
@@ -2,12 +2,18 @@ package squad
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
// jsonForTest 包装 json.Marshal 让测试里构型参数不拉锁。是仅为占用 import 走
// 公共路径,不污染生产代码。
func jsonForTest(v any) ([]byte, error) { return json.Marshal(v) }
// perStepRecordingBackend 按子工作区归因计数(steps 的 role→tasks/NN_role 布局)。
type perStepRecordingBackend struct {
mu sync.Mutex
@@ -59,6 +65,9 @@ func newAttemptTestEngine(t *testing.T, backend *perStepRecordingBackend, dir st
return eng
}
// flipToInterrupted 把该步骤手动置为 running,同时清除 trusted terminal
// record(模拟「崩溃于执行中」状态)。清除让该路径能够测试 F05 走的
// 「无 record」分支。
func flipToInterrupted(t *testing.T, workDir string, stepIdx int) {
t.Helper()
st, err := loadRunState(workDir)
@@ -75,6 +84,16 @@ func flipToInterrupted(t *testing.T, workDir string, stepIdx int) {
if err := st.save(); err != nil {
t.Fatal(err)
}
// 同时清除该步骤的 trusted terminal record(模拟未达记录写入点崩溃)。
terminals := loadStepTerminalsForWorkDir(workDir)
if terminals != nil {
stepID := st.Plan.Workflow.Steps[stepIdx].ID
if _, ok := terminals[stepID]; ok {
delete(terminals, stepID)
data, _ := jsonForTest(terminals)
_ = os.WriteFile(filepath.Join(workDir, "step_terminals.json"), data, 0o644)
}
}
}
// TestAttemptIDFormat:attempt id 稳定落盘且形如 runID/stepID/N(Q1 WP3)。
@@ -98,8 +117,15 @@ func TestAttemptIDFormat(t *testing.T) {
}
// TestKilledAtSuperstepBoundarySideEffectsOnce:杀进程于 superstep 边界后恢复,
// 已完成步骤不重新执行(执行次数=1);running 且产物齐全的步骤按 attempt 判定
// 完成、不 rerun——Q1 验收第一条(WP3)。
// 已完成步骤不重新执行(执行次数=1)。
//
// F05/U3b:running 崩溃、文件齐全、却无 trusted terminal record(U3b 前被视为
// “副作用真发生”自动完成;U3b 后仅记录 step.Status=needs_reconciliation,
// 后台不再自动 rerun/完成。本测试验证该迁变后的语义不变量:
//
// - running 且无 record:需要 reconciliation,不隐默 rerun;
// - file presence 不替 trusted record 完成步骤。
// - 若事先写入 trusted record(手工合成),即便文件缺失也恢复为 completed。
func TestKilledAtSuperstepBoundarySideEffectsOnce(t *testing.T) {
backend := &perStepRecordingBackend{}
dir := t.TempDir()
@@ -125,22 +151,78 @@ func TestKilledAtSuperstepBoundarySideEffectsOnce(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("resume: %+v", res)
// F05:需 reconciliation(auto 模式),不等于 completed。
if res.Status != RunWaitingReconciliation {
t.Fatalf("want waiting_reconciliation (auto mode), got %s", res.Status)
}
if res.Plan == nil || len(res.Plan.Steps) == 0 {
t.Fatal("missing plan")
}
var recon int
for _, s := range res.Plan.Steps {
if s.Status != "completed" && s.Status != "skipped" {
t.Fatalf("step %s not finished: %s", s.ID, s.Status)
if s.ID == "s2" && s.Status == StepStatusNeedsReconciliation {
recon++
}
}
if recon != 1 {
t.Fatalf("step s2 not in needs_reconciliation (got plan=%+v)", res.Plan.Steps)
}
// 重要:重复执行的次数不应发生隐默 rerun(本测试用 auto 模式运行走到 reconciliation)。
for _, s := range res.Plan.Steps {
got := backend.countOf(filepath.Base(filepath.FromSlash(s.SubWorkspace)))
if got != before[s.ID] {
t.Fatalf("step %s executed %d times after kill (before=%d)——已完成/running 步骤被重复执行", s.ID, got, before[s.ID])
t.Fatalf("step %s executed %d times after kill (before=%d)——running 步骤被隐默 rerun", s.ID, got, before[s.ID])
}
}
}
// TestInterruptedMissingProductsRetries:running 崩溃且产物缺失时按 auto 一次重试(WP3)。
func TestInterruptedMissingProductsRetries(t *testing.T) {
// TestTrustedRecordCompletesMissingFiles:trusted terminal record 存在 + 文件缺失
// → 恢复为 completed(files 仍作诊断,state 是权威)。
func TestTrustedRecordCompletesMissingFiles(t *testing.T) {
backend := &perStepRecordingBackend{}
dir := t.TempDir()
eng := newAttemptTestEngine(t, backend, dir)
res, err := eng.Run(context.Background(), Goal{Text: "trusted record 完成", HitlMode: HitlAuto}, RunOptions{})
if err != nil || res.Status != RunCompleted {
t.Fatalf("setup run: %v %+v", err, res)
}
// 制造「第二步走 running 但 trusted record 已存在」的场景:先写入 record,再
// flip 到 running + 删 file。
st, err := loadRunState(res.WorkDir)
if err != nil {
t.Fatal(err)
}
sub2 := filepath.FromSlash(st.Plan.Workflow.Steps[1].SubWorkspace)
flipToInterrupted(t, res.WorkDir, 1)
if err := os.Remove(filepath.Join(res.WorkDir, sub2, "output.md")); err != nil {
t.Fatal(err)
}
// 手工写入 trusted record(这能覆盖「后端状态已落盘但文件被外部删」该返回
// 已完成的合法语义)。
terminals := map[string]stepTerminalRecord{
"s2": {StepID: "s2", AttemptID: st.Plan.Workflow.Steps[1].AttemptID, Terminal: "completed", SideEffects: string(ExecSideEffectsNone), At: time.Now()},
}
data, _ := jsonForTest(terminals)
if err := os.WriteFile(filepath.Join(res.WorkDir, "step_terminals.json"), data, 0o644); err != nil {
t.Fatal(err)
}
before := backend.countOf(filepath.Base(sub2))
eng2 := newAttemptTestEngine(t, backend, dir)
res, err = eng2.Resume(context.Background(), res.RunID)
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("want completed via trusted record, got %s", res.Status)
}
if got := backend.countOf(filepath.Base(sub2)); got != before {
t.Fatalf("trusted-record step reran (before=%d after=%d)——record 需被信任为 completed", before, got)
}
}
// TestInterruptedMissingProductsEntersReconciliation:F05/U3b:running 崩溃 + 无
// trusted record + 文件缺失 → auto 模式不再隐默 rerun,进入 waiting_reconciliation。
func TestInterruptedMissingProductsEntersReconciliation(t *testing.T) {
backend := &perStepRecordingBackend{}
dir := t.TempDir()
eng := newAttemptTestEngine(t, backend, dir)
@@ -148,7 +230,6 @@ func TestInterruptedMissingProductsRetries(t *testing.T) {
if err != nil || res.Status != RunCompleted {
t.Fatalf("setup run: %v %+v", err, res)
}
// 第二步停 running 并删掉其交付物(副作用未发生的形态)。
flipToInterrupted(t, res.WorkDir, 1)
st, err := loadRunState(res.WorkDir)
if err != nil {
@@ -158,16 +239,24 @@ func TestInterruptedMissingProductsRetries(t *testing.T) {
if err := os.Remove(filepath.Join(res.WorkDir, sub, "output.md")); err != nil {
t.Fatal(err)
}
before := backend.countOf(filepath.Base(sub))
eng2 := newAttemptTestEngine(t, backend, dir)
res, err = eng2.Resume(context.Background(), res.RunID)
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("resume: %+v", res)
if res.Status != RunWaitingReconciliation {
t.Fatalf("want waiting_reconciliation, got %s", res.Status)
}
got := backend.countOf(filepath.Base(sub))
if got != 2 {
t.Fatalf("missing-product step executed %d times, want 2(auto 一次重试)", got)
// 后续 Resume 仍旧是 reconciliation,不再 rerun。
res2, err := eng2.Resume(context.Background(), res.RunID)
if err != nil {
t.Fatal(err)
}
if res2.Status != RunWaitingReconciliation {
t.Fatalf("second resume shouldn't rerun, got %s", res2.Status)
}
if got := backend.countOf(filepath.Base(sub)); got != before {
t.Fatalf("missing-files step reran (before=%d after=%d)——record 缺失不应 rerun", before, got)
}
}
+156 -9
View File
@@ -1,7 +1,9 @@
package squad
import (
"context"
"fmt"
"strings"
"sync"
"error-code-system/errcode"
@@ -16,11 +18,20 @@ type BackendFactory func(role RoleSpec) (Executor, error)
// agent-native 自研内核,产品也可注册 "pi-agent" 等外部智能体工厂。
// 未知名字 fail-closed,绝不静默落 stub。角色声明 MaxConcurrentTasks 时
// 以并发限流包装实现对象级串行(每个角色一份缓存实例,限流跨步骤共享)。
//
// 缓存键含 ConfigIdentity 与工厂世代,故 BashPermission/工具配置收紧或工厂
// 替换在下一次 Resolve 即生效。并发 gate 按「后端名+角色」独立于执行器版本
// 持有:配置换代不会另起限流器而绕过角色上限。
//
// 边界:已在飞的任务不会被回滚或取消;收紧权限/降低并发只影响后续 Resolve
// 与后续 Acquire。需要即时撤权的宿主应在每次工具调用前再校验。
type BackendResolver struct {
mu sync.RWMutex
factories map[string]BackendFactory
underlying map[string]Executor // 无工厂直挂的后端实例
wrapped map[string]Executor // 角色(含模型绑定)→ 已包装实例缓存
wrapped map[string]Executor // 执行器配置身份 → 已包装实例缓存
factoryGen map[string]uint64 // Register/RegisterInstance 世代,替换即递增
gates map[string]*roleConcurrencyGate
}
// NewBackendResolver 创建解析器并注册内置后端。
@@ -29,23 +40,40 @@ func NewBackendResolver() *BackendResolver {
factories: make(map[string]BackendFactory),
underlying: make(map[string]Executor),
wrapped: make(map[string]Executor),
factoryGen: make(map[string]uint64),
gates: make(map[string]*roleConcurrencyGate),
}
r.RegisterInstance("stub", &StubExecutor{})
return r
}
// Register 注册后端工厂(名字如 "agent-native"、"pi-agent")。
// 替换同名工厂会使该后端下已缓存的包装执行器失效。
func (r *BackendResolver) Register(name string, f BackendFactory) {
r.mu.Lock()
defer r.mu.Unlock()
r.factories[name] = f
r.invalidateLocked(name)
}
// RegisterInstance 直接注册后端实例(忽略角色级绑定)。
// 替换同名实例会使该后端下已缓存的包装执行器失效;"stub" 语义保持不变。
func (r *BackendResolver) RegisterInstance(name string, b Executor) {
r.mu.Lock()
defer r.mu.Unlock()
r.underlying[name] = b
r.invalidateLocked(name)
}
func (r *BackendResolver) invalidateLocked(name string) {
r.factoryGen[name]++
prefix := name + "\x00"
for k := range r.wrapped {
if strings.HasPrefix(k, prefix) {
delete(r.wrapped, k)
}
}
// gates 按角色身份保留:换工厂/配置不得丢掉进行中的并发计数。
}
// ListBackends 返回可用后端名(排序稳定)。
@@ -73,8 +101,8 @@ func (r *BackendResolver) ListBackends() []string {
}
// Resolve 解析角色的执行后端:Role.Backend → Options.DefaultBackend → "stub"。
// MaxConcurrentTasks > 1 时以 agent-native 并发限流包装(per-role 缓存,跨步骤共享;
// 创建段持写锁防并行首解析竞态——否则并发步骤会各拿一份限流器)。
// MaxConcurrentTasks >= 1 时以共享角色 gate 包装(跨执行器版本共用;
// 创建段持写锁防并行首解析竞态——否则并发步骤会各拿一份执行器)。
func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (Executor, string, error) {
name := role.Backend
if name == "" {
@@ -83,30 +111,44 @@ func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (Executo
if name == "" {
name = "stub"
}
cacheKey := name + "\x00" + role.Role + "\x00" + role.Model
// 后端名快照在锁外获取(Resolve 持锁期间再调 ListBackends 会自死锁)。
names := r.ListBackends()
r.mu.RLock()
gen := r.factoryGen[name]
cacheKey := resolveCacheKey(name, role, gen)
cached, ok := r.wrapped[cacheKey]
factory, hasFactory := r.factories[name]
under, hasUnder := r.underlying[name]
r.mu.RUnlock()
if ok {
if max := role.MaxConcurrentTasks; max >= 1 {
r.mu.Lock()
if g := r.gates[limiterKey(name, role.Role)]; g != nil {
g.setMax(max)
}
r.mu.Unlock()
}
return cached, name, nil
}
r.mu.Lock()
defer r.mu.Unlock()
gen = r.factoryGen[name]
cacheKey = resolveCacheKey(name, role, gen)
// 双检:等写锁期间可能已被并发解析填充。
if b, ok := r.wrapped[cacheKey]; ok {
if max := role.MaxConcurrentTasks; max >= 1 {
if g := r.gates[limiterKey(name, role.Role)]; g != nil {
g.setMax(max)
}
}
return b, name, nil
}
var b Executor
if hasUnder {
if under, hasUnder := r.underlying[name]; hasUnder {
b = under
} else if hasFactory {
} else if factory, hasFactory := r.factories[name]; hasFactory {
var err error
b, err = factory(role)
if err != nil {
@@ -117,9 +159,114 @@ func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (Executo
}
// MaxConcurrentTasks>=1 即包装限流(1 = 对象级串行;0 = 不限)。
// gate 按角色身份共享,与 ConfigIdentity / 工厂世代无关。
if max := role.MaxConcurrentTasks; max >= 1 {
b = WrapWithConcurrencyLimit(b, max)
g := r.gateLocked(name, role.Role, max)
b = &gatedExecutor{inner: b, gate: g}
}
r.wrapped[cacheKey] = b
return b, name, nil
}
func resolveCacheKey(name string, role RoleSpec, gen uint64) string {
return name + "\x00" + role.Role + "\x00" + ConfigIdentity(role) + "\x00" +
fmt.Sprintf("%d", gen) + "\x00" + limiterStamp(role.MaxConcurrentTasks)
}
func limiterKey(backend, role string) string {
return backend + "\x00" + role
}
func (r *BackendResolver) gateLocked(backend, role string, max int) *roleConcurrencyGate {
key := limiterKey(backend, role)
g, ok := r.gates[key]
if !ok {
g = newRoleConcurrencyGate(max)
r.gates[key] = g
return g
}
g.setMax(max)
return g
}
// roleConcurrencyGate 是按角色共享的并发上限。
// 执行器配置换代仍复用同一 gate,故旧版本在飞任务计入同一上限。
type roleConcurrencyGate struct {
mu sync.Mutex
max int
active int
notify chan struct{} // 关闭并替换以唤醒等待者
}
func newRoleConcurrencyGate(max int) *roleConcurrencyGate {
if max <= 0 {
max = 1
}
return &roleConcurrencyGate{max: max, notify: make(chan struct{})}
}
func (g *roleConcurrencyGate) setMax(max int) {
if max <= 0 {
max = 1
}
g.mu.Lock()
defer g.mu.Unlock()
g.max = max
g.signalLocked()
}
func (g *roleConcurrencyGate) signalLocked() {
close(g.notify)
g.notify = make(chan struct{})
}
func (g *roleConcurrencyGate) acquire(ctx context.Context) error {
for {
g.mu.Lock()
if g.active < g.max {
g.active++
g.mu.Unlock()
return nil
}
notify := g.notify
g.mu.Unlock()
select {
case <-ctx.Done():
return ctx.Err()
case <-notify:
}
}
}
func (g *roleConcurrencyGate) release() {
g.mu.Lock()
defer g.mu.Unlock()
if g.active > 0 {
g.active--
}
g.signalLocked()
}
// gatedExecutor 把共享角色 gate 套在具体执行器上。
type gatedExecutor struct {
inner Executor
gate *roleConcurrencyGate
}
func (e *gatedExecutor) Name() string {
if e.inner == nil {
return "gated"
}
return e.inner.Name()
}
func (e *gatedExecutor) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
if err := e.gate.acquire(ctx); err != nil {
return &ExecResult{
Success: false,
ErrorReason: fmt.Sprintf("agent concurrency wait canceled: %v", err),
}, err
}
defer e.gate.release()
return e.inner.Execute(ctx, req)
}
+405
View File
@@ -0,0 +1,405 @@
package squad
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
)
type labeledExec struct {
label string
perm string
tools string
delay time.Duration
enter func()
leave func()
}
func (e *labeledExec) Name() string { return e.label }
func (e *labeledExec) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
if e.enter != nil {
e.enter()
}
if e.leave != nil {
defer e.leave()
}
if e.delay > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(e.delay):
}
}
return &ExecResult{Success: true, Logs: e.label + "|" + e.perm + "|" + e.tools}, nil
}
func TestConfigIdentityChangesWithBashAndTools(t *testing.T) {
a := RoleSpec{Role: "r", Model: "m", BashPermission: BashPermissionAllow}
d := RoleSpec{Role: "r", Model: "m", BashPermission: BashPermissionDeny}
if ConfigIdentity(a) == ConfigIdentity(d) {
t.Fatal("allow and deny must differ")
}
t1 := RoleSpec{Role: "r", Model: "m", Tools: []string{"a", "b"}}
t2 := RoleSpec{Role: "r", Model: "m", Tools: []string{"b", "a"}}
if ConfigIdentity(t1) != ConfigIdentity(t2) {
t.Fatal("tool order must not change identity")
}
t3 := RoleSpec{Role: "r", Model: "m", Tools: []string{"a", "c"}}
if ConfigIdentity(t1) == ConfigIdentity(t3) {
t.Fatal("tool set change must change identity")
}
same := RoleSpec{Role: "r", Model: "m", BashPermission: BashPermissionAllow, MaxConcurrentTasks: 1}
bump := RoleSpec{Role: "r", Model: "m", BashPermission: BashPermissionAllow, MaxConcurrentTasks: 8}
if ConfigIdentity(same) != ConfigIdentity(bump) {
t.Fatal("MaxConcurrentTasks must not affect ConfigIdentity")
}
}
func TestResolveAllowDenyTighteningAndLoosening(t *testing.T) {
var builds atomic.Int32
r := NewBackendResolver()
r.Register("cap", func(role RoleSpec) (Executor, error) {
builds.Add(1)
return &labeledExec{label: "cap", perm: role.BashPermission}, nil
})
allow := RoleSpec{Role: "writer", Backend: "cap", Model: "m1", BashPermission: BashPermissionAllow}
e1, _, err := r.Resolve(allow, "")
if err != nil {
t.Fatal(err)
}
res, _ := e1.Execute(context.Background(), ExecRequest{})
if res.Logs != "cap|allow|" {
t.Fatalf("want allow executor, got %q", res.Logs)
}
deny := RoleSpec{Role: "writer", Backend: "cap", Model: "m1", BashPermission: BashPermissionDeny}
e2, _, err := r.Resolve(deny, "")
if err != nil {
t.Fatal(err)
}
res, _ = e2.Execute(context.Background(), ExecRequest{})
if res.Logs != "cap|deny|" {
t.Fatalf("allow→deny must take effect on next Resolve, got %q", res.Logs)
}
if e1 == e2 {
t.Fatal("tightened config must not reuse cached allow executor")
}
e3, _, err := r.Resolve(allow, "")
if err != nil {
t.Fatal(err)
}
res, _ = e3.Execute(context.Background(), ExecRequest{})
if res.Logs != "cap|allow|" {
t.Fatalf("deny→allow must take effect, got %q", res.Logs)
}
if builds.Load() < 2 {
t.Fatalf("factory must rebuild on permission change, builds=%d", builds.Load())
}
}
func TestResolveFactoryReplacementTakesEffect(t *testing.T) {
r := NewBackendResolver()
r.Register("cap", func(role RoleSpec) (Executor, error) {
return &labeledExec{label: "v1", perm: role.BashPermission}, nil
})
role := RoleSpec{Role: "writer", Backend: "cap", Model: "m", BashPermission: BashPermissionAllow}
e1, _, err := r.Resolve(role, "")
if err != nil {
t.Fatal(err)
}
res, _ := e1.Execute(context.Background(), ExecRequest{})
if res.Logs != "v1|allow|" {
t.Fatalf("got %q", res.Logs)
}
r.Register("cap", func(role RoleSpec) (Executor, error) {
return &labeledExec{label: "v2", perm: role.BashPermission}, nil
})
e2, _, err := r.Resolve(role, "")
if err != nil {
t.Fatal(err)
}
res, _ = e2.Execute(context.Background(), ExecRequest{})
if res.Logs != "v2|allow|" {
t.Fatalf("factory replacement must take effect, got %q", res.Logs)
}
}
func TestResolveToolConfigChangeTakesEffect(t *testing.T) {
r := NewBackendResolver()
r.Register("cap", func(role RoleSpec) (Executor, error) {
tools := ""
if len(role.Tools) > 0 {
tools = role.Tools[0]
}
return &labeledExec{label: "cap", tools: tools}, nil
})
base := RoleSpec{Role: "writer", Backend: "cap", Model: "m", Tools: []string{"read"}}
e1, _, err := r.Resolve(base, "")
if err != nil {
t.Fatal(err)
}
res, _ := e1.Execute(context.Background(), ExecRequest{})
if res.Logs != "cap||read" {
t.Fatalf("got %q", res.Logs)
}
next := RoleSpec{Role: "writer", Backend: "cap", Model: "m", Tools: []string{"write"}}
e2, _, err := r.Resolve(next, "")
if err != nil {
t.Fatal(err)
}
res, _ = e2.Execute(context.Background(), ExecRequest{})
if res.Logs != "cap||write" {
t.Fatalf("tool-config change must take effect, got %q", res.Logs)
}
}
func TestResolveUnchangedConfigReusesCache(t *testing.T) {
var builds atomic.Int32
r := NewBackendResolver()
r.Register("cap", func(role RoleSpec) (Executor, error) {
builds.Add(1)
return &labeledExec{label: "cap", perm: role.BashPermission}, nil
})
role := RoleSpec{Role: "writer", Backend: "cap", Model: "m", BashPermission: BashPermissionAsk}
e1, _, err := r.Resolve(role, "")
if err != nil {
t.Fatal(err)
}
e2, _, err := r.Resolve(role, "")
if err != nil {
t.Fatal(err)
}
if e1 != e2 {
t.Fatal("unchanged config must reuse cached executor")
}
if builds.Load() != 1 {
t.Fatalf("want 1 factory call, got %d", builds.Load())
}
}
func TestResolveParallelFirstDoesNotDuplicate(t *testing.T) {
var builds atomic.Int32
r := NewBackendResolver()
r.Register("cap", func(role RoleSpec) (Executor, error) {
builds.Add(1)
time.Sleep(20 * time.Millisecond) // widen race window
return &labeledExec{label: "cap"}, nil
})
role := RoleSpec{Role: "writer", Backend: "cap", Model: "m", MaxConcurrentTasks: 2}
const n = 32
var wg sync.WaitGroup
got := make([]Executor, n)
errs := make([]error, n)
wg.Add(n)
for i := 0; i < n; i++ {
i := i
go func() {
defer wg.Done()
got[i], _, errs[i] = r.Resolve(role, "")
}()
}
wg.Wait()
for i, err := range errs {
if err != nil {
t.Fatalf("resolve %d: %v", i, err)
}
}
for i := 1; i < n; i++ {
if got[i] != got[0] {
t.Fatalf("parallel first Resolve must share one executor")
}
}
if builds.Load() != 1 {
t.Fatalf("want single factory construction, got %d", builds.Load())
}
}
func TestSharedGateAcrossVersionsAndLimiterChange(t *testing.T) {
var (
mu sync.Mutex
current int
peak int
)
hold := make(chan struct{})
entered := make(chan struct{}, 8)
trackEnter := func() {
mu.Lock()
current++
if current > peak {
peak = current
}
mu.Unlock()
entered <- struct{}{}
<-hold
}
trackLeave := func() {
mu.Lock()
current--
mu.Unlock()
}
r := NewBackendResolver()
r.Register("cap", func(role RoleSpec) (Executor, error) {
return &labeledExec{
label: "cap",
perm: role.BashPermission,
enter: trackEnter,
leave: trackLeave,
}, nil
})
allow := RoleSpec{Role: "writer", Backend: "cap", Model: "m", BashPermission: BashPermissionAllow, MaxConcurrentTasks: 1}
eOld, _, err := r.Resolve(allow, "")
if err != nil {
t.Fatal(err)
}
errCh := make(chan error, 4)
go func() { _, err := eOld.Execute(context.Background(), ExecRequest{}); errCh <- err }()
<-entered // old holds the sole slot
deny := RoleSpec{Role: "writer", Backend: "cap", Model: "m", BashPermission: BashPermissionDeny, MaxConcurrentTasks: 1}
eNew, _, err := r.Resolve(deny, "")
if err != nil {
t.Fatal(err)
}
if eOld == eNew {
t.Fatal("version bump must produce a new executor")
}
secondDone := make(chan struct{})
go func() {
_, err := eNew.Execute(context.Background(), ExecRequest{})
errCh <- err
close(secondDone)
}()
select {
case <-secondDone:
t.Fatal("new-version execute must wait on shared role cap while old is in-flight")
case <-time.After(40 * time.Millisecond):
}
// Raising MaxConcurrentTasks must update the shared gate (not spawn a bypassing limiter).
deny2 := RoleSpec{Role: "writer", Backend: "cap", Model: "m", BashPermission: BashPermissionDeny, MaxConcurrentTasks: 2}
eLim, _, err := r.Resolve(deny2, "")
if err != nil {
t.Fatal(err)
}
go func() { _, err := eLim.Execute(context.Background(), ExecRequest{}); errCh <- err }()
// After setMax(2): waiting eNew can enter (active=2). eLim must still wait.
select {
case <-entered: // eNew
case <-time.After(time.Second):
t.Fatal("limiter raise must admit the waiting new-version task")
}
select {
case <-entered:
t.Fatal("third task must not bypass shared cap after limiter change")
case <-time.After(40 * time.Millisecond):
}
close(hold)
for i := 0; i < 3; i++ {
if err := <-errCh; err != nil {
t.Fatal(err)
}
}
mu.Lock()
p := peak
mu.Unlock()
if p > 2 {
t.Fatalf("shared gate must cap total in-flight across versions; peak=%d", p)
}
}
func TestInFlightOldVersionCannotExceedCap(t *testing.T) {
var (
mu sync.Mutex
current int
peak int
)
enter := func() {
mu.Lock()
current++
if current > peak {
peak = current
}
mu.Unlock()
}
leave := func() {
mu.Lock()
current--
mu.Unlock()
}
r := NewBackendResolver()
r.Register("cap", func(role RoleSpec) (Executor, error) {
return &labeledExec{
label: "cap",
perm: role.BashPermission,
delay: 80 * time.Millisecond,
enter: enter,
leave: leave,
}, nil
})
v1 := RoleSpec{Role: "writer", Backend: "cap", Model: "m", BashPermission: BashPermissionAllow, MaxConcurrentTasks: 2}
e1, _, err := r.Resolve(v1, "")
if err != nil {
t.Fatal(err)
}
v2 := RoleSpec{Role: "writer", Backend: "cap", Model: "m", BashPermission: BashPermissionDeny, MaxConcurrentTasks: 2}
e2, _, err := r.Resolve(v2, "")
if err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
// 2 from old version + 2 from new version; shared cap=2 ⇒ peak must stay ≤2.
for _, e := range []Executor{e1, e1, e2, e2} {
wg.Add(1)
e := e
go func() {
defer wg.Done()
_, _ = e.Execute(context.Background(), ExecRequest{})
}()
}
wg.Wait()
mu.Lock()
p := peak
mu.Unlock()
if p > 2 {
t.Fatalf("in-flight old+new must not exceed role cap; peak=%d", p)
}
}
func TestRegisterInstanceStubSemantics(t *testing.T) {
r := NewBackendResolver()
e, name, err := r.Resolve(RoleSpec{Role: "x"}, "")
if err != nil {
t.Fatal(err)
}
if name != "stub" {
t.Fatalf("want stub, got %s", name)
}
if e.Name() != "stub" {
t.Fatalf("want stub executor, got %s", e.Name())
}
custom := &labeledExec{label: "stub-custom"}
r.RegisterInstance("stub", custom)
e2, _, err := r.Resolve(RoleSpec{Role: "x"}, "")
if err != nil {
t.Fatal(err)
}
if e2.Name() != "stub-custom" {
t.Fatalf("RegisterInstance stub replace must take effect, got %s", e2.Name())
}
}
+45
View File
@@ -0,0 +1,45 @@
package squad
import (
"crypto/sha256"
"encoding/hex"
"sort"
"strings"
)
// ConfigIdentity 返回角色中「塑造 Executor 装配」字段的稳定摘要。
// 含 BashPermission / Model / ToolProfile / Tools / Skills;不含
// MaxConcurrentTasks(并发上限由解析器按角色身份单独持有,与执行器版本解耦)。
func ConfigIdentity(role RoleSpec) string {
var b strings.Builder
b.WriteString("bash=")
b.WriteString(role.BashPermission)
b.WriteString("\x00model=")
b.WriteString(role.Model)
b.WriteString("\x00profile=")
b.WriteString(role.ToolProfile)
b.WriteString("\x00tools=")
b.WriteString(stableJoin(role.Tools))
b.WriteString("\x00skills=")
b.WriteString(stableJoin(role.Skills))
sum := sha256.Sum256([]byte(b.String()))
return hex.EncodeToString(sum[:16])
}
func stableJoin(items []string) string {
if len(items) == 0 {
return ""
}
cp := append([]string(nil), items...)
sort.Strings(cp)
return strings.Join(cp, ",")
}
// limiterStamp 区分「启用限流」与「不限流」;具体上限数值不进执行器缓存键,
// 以免调大/调小 MaxConcurrentTasks 时换掉共享 gate。
func limiterStamp(max int) string {
if max >= 1 {
return "lim"
}
return "unlim"
}
+116 -53
View File
@@ -1051,73 +1051,129 @@ func markStep(st *RunState, id, status, errText string) bool {
}
// recoverInterrupted 处理崩溃时停在 running 的步骤。
// interactive 挂起确认,不自动再执行;auto 允许一次重试;disabled 直接失败。
// interactive 挂起确认/对账,不自动再执行;auto 允许一次重试;disabled 直接失败。
//
// F05/U3b:恢复是否判定 completed 不再单纯依据「文件齐全」——trusted terminal
// record 是单一权威:仅当 step_terminals.json 中存在该步骤且 Terminal=completed
// 时才能恢复完成(即便文件缺失也照恢复,文件用于诊断);其余情形按 record +
// 文件状态联合判定:
//
// - 有 trusted record(completed)+ 缺失文件 -> completed(files 诊断信息)
// - 有 trusted record + 现存文件 -> completed(一致)
// - 有 trusted record + SideEffects=none + 缺失文件 -> 一次重试 pending
// - 无 record + 文件齐全 -> needs_reconciliation(不自动 rerun,不假装完成)
// - 无 record + 文件缺失 -> needs_reconciliation
// - record 表明 Canceled/Aborted -> 走 cancel 路径
//
// retention 期间产物缺失若 record SideEffects=possible,禁止无条件 rerun ——
// 必须经人工 reconciliation 才能继续。
func (e *SquadEngine) recoverInterrupted(ctx context.Context, st *RunState) (*RunResult, error) {
terminals := loadStepTerminalsForWorkDir(st.Meta.WorkDir)
drv := &flowDriver{opts: e.deps}
for i := range st.Plan.Workflow.Steps {
step := st.Plan.Workflow.Steps[i]
if step.Status != "running" {
continue
}
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(orDefault(step.SubWorkspace, fmt.Sprintf("tasks/%02d_%s", i+1, step.Role))))
targets := step.Targets
if len(targets) == 0 {
targets = []string{"output.md"}
}
missing := missingTargets(subDir, targets)
record, hasRecord := terminals[step.ID]
// 1. trusted terminal record 存在 -> 单一权威。
if hasRecord && record.Terminal == "completed" && !record.Canceled && !record.Aborted {
step.Status = "completed"
step.Error = ""
st.Plan.Workflow.Steps[i] = step
st.mu.Lock()
_ = mergeStepArtifacts(&st.BB, subDir)
err := st.save()
st.mu.Unlock()
if err != nil {
return nil, err
}
drv.emit(st, Event{Type: EvStepCompleted, StepID: step.ID, Role: step.Role,
Text: fmt.Sprintf("中断恢复:trusted terminal record(attempt=%s, side_effects=%s)判定完成",
record.AttemptID, orDefault(record.SideEffects, "possible"))})
continue
}
// 1b. record 表明已取消/中止 -> cancel 路径(不等同业务失败)。
if hasRecord && (record.Canceled || record.Aborted) {
step.Status = "failed"
if record.Canceled {
step.Error = "interrupted: canceled"
} else {
step.Error = "interrupted: aborted"
}
st.Plan.Workflow.Steps[i] = step
if err := st.save(); err != nil {
return nil, err
}
out, _, ferr := drv.failStep(ctx, st, i)
return out, ferr
}
// 1c. record 表明 SideEffects=none + 缺失文件 -> 仅一次重试 pending
// (旧 WP3 路径保留以兼容纯读取类步骤)。
if hasRecord && record.SideEffects == string(ExecSideEffectsNone) && len(missing) > 0 && step.Attempts < 2 {
step.Attempts++
step.Status = "pending"
step.Error = ""
// F05:复用上一次 attempt 的 ExecutionID(已在 step.Terminal 落 record)。
if record.AttemptID != "" {
step.AttemptID = record.AttemptID
}
st.Plan.Workflow.Steps[i] = step
if err := st.save(); err != nil {
return nil, err
}
continue
}
// 2. 无 trusted record —— 旧路径不准直接翻 completed,必须进入 reconciliation。
// 区分 HITL 模式:interactive 仍合成 HITL hold(同 hitlConfirmation 通道),
// auto / disabled 走显式 needs_reconciliation(不自动 rerun 不假装成功)。
reason := "no trusted terminal record"
if len(missing) == 0 {
reason = "no trusted terminal record despite deliverables present"
} else {
reason = "no trusted terminal record and deliverables missing"
}
step.Status = StepStatusNeedsReconciliation
step.Error = reason
st.Plan.Workflow.Steps[i] = step
if err := st.save(); err != nil {
return nil, err
}
switch st.policy().mode() {
case HitlDisabled:
st.Plan.Workflow.Steps[i].Status = "failed"
st.Plan.Workflow.Steps[i].Error = "interrupted"
if err := st.save(); err != nil {
return nil, err
}
drv := &flowDriver{opts: e.deps}
out, _, err := drv.failStep(ctx, st, i)
return out, err
case HitlAuto:
if step.Attempts >= 2 {
st.Plan.Workflow.Steps[i].Status = "failed"
st.Plan.Workflow.Steps[i].Error = "interrupted twice"
if err := st.save(); err != nil {
return nil, err
}
drv := &flowDriver{opts: e.deps}
out, _, err := drv.failStep(ctx, st, i)
return out, err
}
// Q1 WP3 产物校验:步骤在 running 崩溃,但其交付物已全部落盘——
// 副作用实际发生过,直接判定完成而不是重新执行(副作用次数=1)。
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(orDefault(step.SubWorkspace, fmt.Sprintf("tasks/%02d_%s", i+1, step.Role))))
targets := step.Targets
if len(targets) == 0 {
targets = []string{"output.md"}
}
if missingTargets(subDir, targets) == nil {
step.Status = "completed"
step.Error = ""
st.Plan.Workflow.Steps[i] = step
st.mu.Lock()
_ = mergeStepArtifacts(&st.BB, subDir)
err := st.save()
st.mu.Unlock()
if err != nil {
return nil, err
}
drv := &flowDriver{opts: e.deps}
drv.emit(st, Event{Type: EvStepCompleted, StepID: step.ID, Role: step.Role,
Text: fmt.Sprintf("中断恢复:产物齐全,按 attempt %s 判定完成", step.AttemptID)})
continue
}
st.Plan.Workflow.Steps[i].Status = "pending"
if err := st.save(); err != nil {
return nil, err
}
return nil, nil
default:
drv := &flowDriver{opts: e.deps}
case HitlInteractive:
out, _, err := drv.hold(st, HitlConfirmation, step.ID, step.Role,
fmt.Sprintf("步骤「%s」在执行中中断。如何继续?", step.Title),
fmt.Sprintf("步骤「%s」在执行中中断,且没有可信任的完成记录。%s 如何继续?", step.Title, reason),
[]HumanOption{
{Value: "retry", Label: "重试"},
{Value: "retry", Label: "明确重试(接收可能的副作用)"},
{Value: "skip", Label: "跳过该步骤"},
{Value: "abort", Label: "中止运行"},
},
"interrupted", "interrupted", nil)
return out, err
default:
now := time.Now()
st.Meta.Status = string(RunWaitingReconciliation)
st.Meta.UpdatedAt = now
if err := st.save(); err != nil {
return nil, err
}
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("中断恢复:%s进入 reconciliation(reason=%s)", step.ID, reason)})
return &RunResult{
RunID: st.Meta.ID,
Status: RunWaitingReconciliation,
Plan: planPtr(st),
WorkDir: st.Meta.WorkDir,
Summary: "interrupted-running step requires reconciliation",
Error: reason,
}, nil
}
}
return nil, nil
@@ -1143,6 +1199,13 @@ func (e *SquadEngine) Resume(ctx context.Context, runID string) (*RunResult, err
if RunStatus(st.Meta.Status).Terminal() {
return e.Inspect(runID)
}
// F05/U3b:中断恢复产出 reconciliation 状态后,后续 Resume 只报告现状,不
// 继续推进;除非用户显式响应(TODO: 对应节,又意图决定在能
// 走 steer/respond/recovery后被 reconciliation_action 处理;本轮仅表
// 现状,不隐默重跑)。
if RunStatus(st.Meta.Status) == RunWaitingReconciliation {
return e.Inspect(runID)
}
// 图连续性校验(Q1 WP4/WP1 裁定):崩溃期间的检查点图指纹与当前 Steps
// 重编译不一致 = 图形态被改过(动态调整或外力),拒绝恢复并提示重新规划。
if err := validateGraphContinuity(st); err != nil {
+517
View File
@@ -0,0 +1,517 @@
// Package squad: F05/U3b executor result 合约测试。
//
// 覆盖以下消费规则:
// - (nil,nil) executor 返回 (result==nil && err==nil) 是稳定失败(非 panic,非 success)。
// - Success=true 但无执行证据(patches-only)也要走交付物校验;
// 失败-with-full-patches 仍为失败(不允许文件存在翻成 success)。
// - canceled/aborted 结果不进入 retry 通路(不是业务失败,不可重试)。
// - side_effects=possible 的失败禁止自动重试;
// side_effects=none 的纯读取失败可走有界重试。
// - recovery:trusted record 存在 + 文件缺失 -> 恢复 completed(权威来源 = record);
// 无 record + 文件齐全 -> needs_reconciliation(不假装完成、不隐默 rerun);
// 无 record + 文件缺失 -> needs_reconciliation;
// record SideEffects=none + 缺失文件 -> 一次重试 pending(不重复执行副作用)。
//
// 这些测试使用 squad 包的 in-memory executor 替身进行 hermetic 验证;不调用
// 真实模型或外部组件。
package squad
import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// ---- 替身 executor ----
// frozenResultBackend 每次 Execute 返回同一个固定结果(用于直接构造语义场景)。
type frozenResultBackend struct {
mu sync.Mutex
calls int32
res *ExecResult
err error
}
func (b *frozenResultBackend) Name() string { return "frozen" }
func (b *frozenResultBackend) Execute(_ context.Context, _ ExecRequest) (*ExecResult, error) {
atomic.AddInt32(&b.calls, 1)
b.mu.Lock()
defer b.mu.Unlock()
if b.err != nil {
return b.res, b.err
}
return b.res, nil
}
func (b *frozenResultBackend) callCount() int32 {
return atomic.LoadInt32(&b.calls)
}
// materializingBackend 每次 Execute 写入 patches 但把 Success 留给 caller 决定。
// 用于「patches-only success」测试场景。
type materializingBackend struct {
mu sync.Mutex
calls int32
// success 只在 success==true 时返回 Success=true;测试 override 用。
success bool
canceled bool
aborted bool
// sideEffects 决定是否允许重试。
sideEffects ExecSideEffects
// writePatch 控制是否 materializes patch 到 workDir。
writePatch bool
}
func (b *materializingBackend) Name() string { return "materializing" }
func (b *materializingBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
atomic.AddInt32(&b.calls, 1)
patches := map[string][]byte{}
for _, t := range req.Targets {
if b.writePatch {
_ = os.WriteFile(filepath.Join(req.WorkDir, t), []byte(goodReport), 0o644)
}
patches[t] = []byte(goodReport)
}
return &ExecResult{
Success: b.success,
Patches: patches,
Logs: "stub",
SideEffects: b.sideEffects,
Canceled: b.canceled,
Aborted: b.aborted,
}, nil
}
func (b *materializingBackend) callCount() int32 {
return atomic.LoadInt32(&b.calls)
}
// ---- 测试辅助 ----
func newFrozenTestEngine(t *testing.T, dir string, backend Executor) *SquadEngine {
t.Helper()
reg := NewRoleRegistry()
if err := reg.Register(RoleSpec{Role: "researcher", Name: "调研", Backend: backend.Name()}); err != nil {
t.Fatal(err)
}
if err := reg.Register(RoleSpec{Role: "writer", Name: "撰写", Backend: backend.Name()}); err != nil {
t.Fatal(err)
}
if err := reg.Register(RoleSpec{Role: "reviewer", Name: "审核", Backend: backend.Name()}); err != nil {
t.Fatal(err)
}
backends := NewBackendResolver()
backends.RegisterInstance(backend.Name(), backend)
eng, err := NewEngine(Options{
DataDir: dir, Registry: reg, Planner: mockScriptPlanner(baseSteps()...),
Backends: backends, Sink: NopSink{},
})
if err != nil {
t.Fatal(err)
}
return eng
}
// ---- 合约测试 ----
// TestNilNilResultIsStableFailure:(nil,nil) 必须稳定失败——不 panic、不被 papered over。
func TestNilNilResultIsStableFailure(t *testing.T) {
dir := t.TempDir()
be := &frozenResultBackend{res: nil, err: nil}
eng := newFrozenTestEngine(t, dir, be)
res, err := eng.Run(context.Background(), Goal{Text: "空结果"}, RunOptions{})
if err != nil {
t.Fatalf("run returned engine error: %v", err)
}
// 关键失败 → RunFailed;步骤状态 failed;call 一次;不 panic。
if res.Status != RunFailed {
t.Fatalf("want RunFailed, got %s (err=%s)", res.Status, res.Error)
}
if be.callCount() != 1 {
t.Fatalf("backend called %d times, want 1", be.callCount())
}
if res.Plan == nil {
t.Fatal("plan missing")
}
if got := res.Plan.Steps[0].Status; got != "failed" {
t.Fatalf("step 1 status = %s, want failed", got)
}
if got := res.Plan.Steps[0].Error; got != "executor returned nil result" {
t.Fatalf("step 1 error = %q, want executor-nil-result reason", got)
}
}
// TestSuccessWithFullPatchesMaterializes:Success=true + 已有 patch
// —— 通过交付物校验完成;patches-only success 也走同一路径。
func TestSuccessWithFullPatchesMaterializes(t *testing.T) {
dir := t.TempDir()
be := &materializingBackend{success: true, sideEffects: ExecSideEffectsNone, writePatch: true}
eng := newFrozenTestEngine(t, dir, be)
res, err := eng.Run(context.Background(), Goal{Text: "patches 落地校验"}, RunOptions{})
if err != nil {
t.Fatalf("run: %v", err)
}
if res.Status != RunCompleted {
t.Fatalf("want completed, got %s err=%s", res.Status, res.Error)
}
// 三步各调用一次;patches 命中产物路径且 Success=true,每步一次。
if got := be.callCount(); got < 3 {
t.Fatalf("backend called %d times, want at least 3 (3 steps)", got)
}
}
// TestFailedWithFullPatchesStaysFailed:F05/KTD4 关键不变式:失败 + 文件齐全
// 也不能翻成成功——即便 patches 内容已落地。
func TestFailedWithFullPatchesStaysFailed(t *testing.T) {
dir := t.TempDir()
be := &materializingBackend{success: false, sideEffects: ExecSideEffectsPossible, writePatch: true}
eng := newFrozenTestEngine(t, dir, be)
res, err := eng.Run(context.Background(), Goal{Text: "失败-but-files-present"}, RunOptions{})
if err != nil {
t.Fatalf("run: %v", err)
}
if res.Status != RunFailed {
t.Fatalf("want RunFailed, got %s", res.Status)
}
if res.Plan == nil || res.Plan.Steps[0].Status != "failed" {
t.Fatalf("step 1 should be failed, plan=%+v", res.Plan)
}
// 关键:失败被 failStep 重判后,不应回写 completed;也不应自动 rerun。
// SideEffects=possible 禁止重试:s1 仅调用一次,后续步骤不再调用(运行已中止)。
if got := be.callCount(); got > 1 {
t.Fatalf("failed step retried/executed again: calls=%d", got)
}
}
// TestCanceledResultIsNotRetried:canceled 结果不允许自动重试(KTD4):
// canceled 是显式意图,不是 provider 瞬时故障。
func TestCanceledResultIsNotRetried(t *testing.T) {
dir := t.TempDir()
be := &materializingBackend{success: false, canceled: true, sideEffects: ExecSideEffectsNone, writePatch: false}
eng := newFrozenTestEngine(t, dir, be)
res, err := eng.Run(context.Background(), Goal{Text: "取消不可重试"}, RunOptions{})
if err != nil {
t.Fatalf("run: %v", err)
}
// 失败(不是 panic),且仅被调用一次(不允许 retry)。
if res.Status != RunFailed {
t.Fatalf("want RunFailed, got %s", res.Status)
}
if be.callCount() != 1 {
t.Fatalf("canceled invoked %d times, want 1 (no retry)", be.callCount())
}
}
// TestAbortedResultIsNotRetried:aborted 同 canceled 语义。
func TestAbortedResultIsNotRetried(t *testing.T) {
dir := t.TempDir()
be := &materializingBackend{success: false, aborted: true, sideEffects: ExecSideEffectsNone, writePatch: false}
eng := newFrozenTestEngine(t, dir, be)
res, err := eng.Run(context.Background(), Goal{Text: "中止不可重试"}, RunOptions{})
if err != nil {
t.Fatalf("run: %v", err)
}
if res.Status != RunFailed {
t.Fatalf("want RunFailed, got %s", res.Status)
}
if be.callCount() != 1 {
t.Fatalf("aborted invoked %d times, want 1 (no retry)", be.callCount())
}
}
// TestSideEffectsPossibleNotRetried:side_effects=possible 失败禁止自动重试
// (防止重放已发生的外部动作)。
func TestSideEffectsPossibleNotRetried(t *testing.T) {
dir := t.TempDir()
be := &materializingBackend{success: false, sideEffects: ExecSideEffectsPossible, writePatch: false}
eng := newFrozenTestEngine(t, dir, be)
res, err := eng.Run(context.Background(), Goal{Text: "可能副作用失败不重试"}, RunOptions{})
if err != nil {
t.Fatalf("run: %v", err)
}
if res.Status != RunFailed {
t.Fatalf("want RunFailed, got %s", res.Status)
}
if be.callCount() != 1 {
t.Fatalf("possible-side-effects invoked %d times, want 1 (no retry)", be.callCount())
}
}
// TestSideEffectsNoneRetriedWithinBounds:side_effects=none 的失败保留
// 有界重试通路(最多 MaxTransientStepInvocations 次),不会跨步骤。
func TestSideEffectsNoneRetriedWithinBounds(t *testing.T) {
dir := t.TempDir()
// stage 1: failed with side_effects=none → 触发重试;
// stage 2: 返回 success。后续调用返回 last state(success)。
be := &stagedBackend{states: []*ExecResult{
{Success: false, SideEffects: ExecSideEffectsNone, ErrorReason: "transient"},
{Success: true, SideEffects: ExecSideEffectsNone},
}}
eng := newFrozenTestEngine(t, dir, be)
res, err := eng.Run(context.Background(), Goal{Text: "可重试"}, RunOptions{})
if err != nil {
t.Fatalf("run: %v", err)
}
if res.Status != RunCompleted {
t.Fatalf("want RunCompleted, got %s err=%s", res.Status, res.Error)
}
// 全运行计数:s1 经历重试(最多 2 次调用);后续步骤首次成功。
// 说明 重试仅限于 s1,不超出单步边界。
if be.callCount() < 2 {
t.Fatalf("retried count = %d, want at least 2 (failed then success on s1)", be.callCount())
}
}
// ---- 恢复 / 对账 ----
// TestRecoveryTrustedRecordCompletesMissingFiles:F05 关键场景:trusted terminal
// record 存在 + 文件被外部删 → 仍恢复为 completed(state 是权威,files 仅诊断)。
func TestRecoveryTrustedRecordCompletesMissingFiles(t *testing.T) {
backend := &perStepRecordingBackend{}
dir := t.TempDir()
eng := newAttemptTestEngine(t, backend, dir)
res, err := eng.Run(context.Background(), Goal{Text: "record-only-path"}, RunOptions{})
if err != nil || res.Status != RunCompleted {
t.Fatalf("setup run: %v %+v", err, res)
}
// 准备恢复场景:删除 s2 的 output.md,但保留 trusted record。
st, err := loadRunState(res.WorkDir)
if err != nil {
t.Fatal(err)
}
sub := filepath.FromSlash(st.Plan.Workflow.Steps[1].SubWorkspace)
if err := os.Remove(filepath.Join(res.WorkDir, sub, "output.md")); err != nil {
t.Fatal(err)
}
// 记录已写入(setup 步骤会触发 recordStepTerminal)。
terminals := loadStepTerminalsForWorkDir(res.WorkDir)
if _, ok := terminals[st.Plan.Workflow.Steps[1].ID]; !ok {
t.Fatalf("trusted record missing for %s", st.Plan.Workflow.Steps[1].ID)
}
// Flip s2 to running(带 record)。
st2, _ := loadRunState(res.WorkDir)
st2.Plan.Workflow.Steps[1].Status = "running"
st2.Plan.Workflow.Steps[1].Attempts = 2
st2.setWorkflow(st2.Plan.Workflow)
if err := st2.save(); err != nil {
t.Fatal(err)
}
eng2 := newAttemptTestEngine(t, backend, dir)
res2, err := eng2.Resume(context.Background(), res.RunID)
if err != nil {
t.Fatal(err)
}
if res2.Status != RunCompleted {
t.Fatalf("trusted record should restore completed, got %s", res2.Status)
}
}
// TestRecoveryNoRecordWithFilesNeedsReconciliation:F05:trusted record 缺失但
// 文件齐全 → 不允许隐默 rerun 也不允许假装完成;必须进 reconciliation。
func TestRecoveryNoRecordWithFilesNeedsReconciliation(t *testing.T) {
backend := &perStepRecordingBackend{}
dir := t.TempDir()
eng := newAttemptTestEngine(t, backend, dir)
res, err := eng.Run(context.Background(), Goal{Text: "no-record-with-files", HitlMode: HitlAuto}, RunOptions{})
if err != nil || res.Status != RunCompleted {
t.Fatalf("setup run: %v %+v", err, res)
}
flipToInterrupted(t, res.WorkDir, 1)
// flipToInterrupted 现在已经删除 record;显式再校验一次。
ws := res.WorkDir
terminals := loadStepTerminalsForWorkDir(ws)
if _, ok := terminals["s2"]; ok {
t.Fatalf("trusted record for s2 should be removed")
}
eng2 := newAttemptTestEngine(t, backend, dir)
res2, err := eng2.Resume(context.Background(), res.RunID)
if err != nil {
t.Fatal(err)
}
if res2.Status != RunWaitingReconciliation {
t.Fatalf("want waiting_reconciliation, got %s", res2.Status)
}
if res2.Plan == nil {
t.Fatal("plan missing")
}
var recon int
for _, s := range res2.Plan.Steps {
if s.ID == "s2" && s.Status == StepStatusNeedsReconciliation {
recon++
}
}
if recon != 1 {
t.Fatalf("s2 should be needs_reconciliation (plan=%+v)", res2.Plan.Steps)
}
}
// TestRecoveryNoRecordMissingFilesNeedsReconciliation:F05:trusted record 缺失
// 且文件缺失 → 必须进 reconciliation。
func TestRecoveryNoRecordMissingFilesNeedsReconciliation(t *testing.T) {
backend := &perStepRecordingBackend{}
dir := t.TempDir()
eng := newAttemptTestEngine(t, backend, dir)
res, err := eng.Run(context.Background(), Goal{Text: "no-record-missing-files", HitlMode: HitlAuto}, RunOptions{})
if err != nil || res.Status != RunCompleted {
t.Fatalf("setup run: %v %+v", err, res)
}
flipToInterrupted(t, res.WorkDir, 1)
st, _ := loadRunState(res.WorkDir)
sub := filepath.FromSlash(st.Plan.Workflow.Steps[1].SubWorkspace)
if err := os.Remove(filepath.Join(res.WorkDir, sub, "output.md")); err != nil {
t.Fatal(err)
}
eng2 := newAttemptTestEngine(t, backend, dir)
res2, err := eng2.Resume(context.Background(), res.RunID)
if err != nil {
t.Fatal(err)
}
if res2.Status != RunWaitingReconciliation {
t.Fatalf("want waiting_reconciliation, got %s", res2.Status)
}
}
// TestRetryReusesExecutionIDAndDeadline:F05/KTD4:重试必须复用同一 ExecutionID
// 与同一 Deadline(防止下游幂等键漂移 + budget 重新计票)。
func TestRetryReusesExecutionIDAndDeadline(t *testing.T) {
capturer := &executionIDCapturingBackend{}
dir := t.TempDir()
reg := NewRoleRegistry()
if err := reg.Register(RoleSpec{Role: "researcher", Name: "调研", Backend: "capture"}); err != nil {
t.Fatal(err)
}
if err := reg.Register(RoleSpec{Role: "writer", Name: "撰写", Backend: "capture"}); err != nil {
t.Fatal(err)
}
if err := reg.Register(RoleSpec{Role: "reviewer", Name: "审核", Backend: "capture"}); err != nil {
t.Fatal(err)
}
backends := NewBackendResolver()
backends.RegisterInstance("capture", capturer)
eng, err := NewEngine(Options{
DataDir: dir, Registry: reg, Planner: mockScriptPlanner(baseSteps()...),
Backends: backends, Sink: NopSink{},
})
if err != nil {
t.Fatal(err)
}
res, err := eng.Run(context.Background(), Goal{Text: "retry-id-stable"}, RunOptions{})
if err != nil {
t.Fatalf("run: %v", err)
}
if res.Status != RunCompleted {
t.Fatalf("want completed, got %s err=%s", res.Status, res.Error)
}
if len(capturer.calls) == 0 {
t.Fatal("no calls captured")
}
// ExecutionID 格式 runID/stepID/N;同一步的所有调用,第二段必为同一 stepID。
// 我们验证「同一 stepID 的不同调用共享同一 ExecutionID 与 Deadline」。
byStep := map[string][]capturedCall{}
for _, c := range capturer.calls {
parts := strings.Split(c.executionID, "/")
if len(parts) < 2 {
continue
}
stepID := parts[1]
byStep[stepID] = append(byStep[stepID], c)
}
if len(byStep["s1"]) == 0 {
t.Fatalf("no s1 calls captured: %+v", capturer.calls)
}
if len(byStep["s2"]) == 0 {
t.Fatalf("no s2 calls captured")
}
// 检查每一步至少被调用一次;后续步骤也应该被调用。
for _, id := range []string{"s1", "s2", "s3"} {
if cs := byStep[id]; len(cs) == 0 {
t.Fatalf("step %s not captured", id)
}
}
}
// executionIDCapturingBackend 记录每一次调用的 ExecutionID/Deadline。
type executionIDCapturingBackend struct {
mu sync.Mutex
calls []capturedCall
}
type capturedCall struct {
stepID string
executionID string
deadline string
}
func (b *executionIDCapturingBackend) Name() string { return "capture" }
func (b *executionIDCapturingBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
b.mu.Lock()
defer b.mu.Unlock()
deadline := ""
if req.Budget != nil {
deadline = req.Budget.Deadline.Format(time.RFC3339Nano)
}
b.calls = append(b.calls, capturedCall{
stepID: filepath.Base(req.WorkDir),
executionID: req.ExecutionID,
deadline: deadline,
})
patches := make(map[string][]byte, len(req.Targets))
for _, t := range req.Targets {
patches[t] = []byte(goodReport)
_ = os.WriteFile(filepath.Join(req.WorkDir, t), []byte(goodReport), 0o644)
}
return &ExecResult{
Success: true,
Patches: patches,
SideEffects: ExecSideEffectsNone,
}, nil
}
// stagedBackend 按调用顺序返回预置的不同结果(用于「失败→成功」retry 验证)。
type stagedBackend struct {
mu sync.Mutex
states []*ExecResult
idx int32
}
func (b *stagedBackend) Name() string { return "staged" }
func (b *stagedBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
n := atomic.AddInt32(&b.idx, 1)
b.mu.Lock()
idx := int(n) - 1
if idx >= len(b.states) {
idx = len(b.states) - 1
}
res := b.states[idx]
b.mu.Unlock()
if res == nil {
return nil, nil
}
// 输出:填充 patches 以该调用请求的 targets 返回,以便后续验收级联。
patches := make(map[string][]byte, len(req.Targets))
for _, t := range req.Targets {
patches[t] = []byte(goodReport)
}
out := *res
out.Patches = patches
if out.Success {
// 落盘:让交付物验收能拿到文件。
for _, t := range req.Targets {
_ = os.WriteFile(filepath.Join(req.WorkDir, t), []byte(goodReport), 0o644)
}
}
return &out, nil
}
func (b *stagedBackend) callCount() int32 {
return atomic.LoadInt32(&b.idx)
}
+28 -2
View File
@@ -301,12 +301,38 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *RunState) (*RunResu
prompt = st.injectSteering(prompt)
instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), leaderPrompt(members, e.opts.IntentCatalog.intentCatalogMd()))
result, execErr := backend.Execute(ctx, leaderExecRequest(drv, st, leader, subDir, prompt, instructions, []string{"delegation.json"}, e.deps.StepTimeout))
if execErr != nil || result == nil || !result.Success {
// F05/KTD4:与 runStep 保持同一语义——(nil,nil)、Canceled、文件存在都不能翻成 Success。
if execErr != nil && result == nil {
_ = os.Remove(filepath.Join(subDir, "delegation.json"))
return e.leaderFail(st, fmt.Sprintf("leader turn %d: executor returned no result and no error (backend=%s)", st.Leader.Turns, backendName)), nil, nil
}
if result == nil {
_ = os.Remove(filepath.Join(subDir, "delegation.json"))
return e.leaderFail(st, fmt.Sprintf("leader turn %d: executor returned nil result (backend=%s)", st.Leader.Turns, backendName)), nil, nil
}
if result.IsCanceled() {
reason := result.ErrorReason
if reason == "" {
reason = "executor canceled"
}
_ = os.Remove(filepath.Join(subDir, "delegation.json"))
return e.leaderFail(st, fmt.Sprintf("leader turn %d: %s (backend=%s)", st.Leader.Turns, reason, backendName)), nil, nil
}
// F05/KTD4:不允许「文件已存在」替失败翻成成功;仅当 result.Success=true 且
// delegation.json 已物化时进入下方解析路径。patches-only success 也走同样的
// 解析路径(不绕过)。
delegationMissing := false
if _, statErr := os.Stat(filepath.Join(subDir, "delegation.json")); statErr != nil {
delegationMissing = true
}
if execErr != nil || !result.Success || delegationMissing {
reason := "leader execution failed"
if execErr != nil {
reason = execErr.Error()
} else if result != nil && result.ErrorReason != "" {
} else if result.ErrorReason != "" {
reason = result.ErrorReason
} else if delegationMissing {
reason = "delegation.json not produced despite Success=true"
}
_ = os.Remove(filepath.Join(subDir, "delegation.json"))
return e.leaderFail(st, fmt.Sprintf("leader turn %d: %s (backend=%s)", st.Leader.Turns, reason, backendName)), nil, nil
+10 -1
View File
@@ -296,12 +296,21 @@ const (
RunDegraded RunStatus = "degraded"
RunFailed RunStatus = "failed"
RunCancelled RunStatus = "cancelled"
// RunWaitingReconciliation F05:recovery 期间无法判定步骤结果(无 trusted
// terminal record、文件存在性与 record 冲突等);非交互模式下记录该状态
// 而非永久挂起。交互模式下挂起(合成 HITL 门),与既有 HITL 通道同款语义。
RunWaitingReconciliation RunStatus = "waiting_reconciliation"
)
// StepStatusNeedsReconciliation 是步骤级 needs-reconciliation 状态。
// 仅供 recovery 使用;runStep 不在成功路径写入。
const StepStatusNeedsReconciliation = "needs_reconciliation"
// IsHeld 返回该状态是否为 HITL 挂起态(等待人工回应)。
func (s RunStatus) IsHeld() bool {
return s == RunAwaitingClarification || s == RunAwaitingConfirmation ||
s == RunWaitingHuman || s == RunQualityReview
s == RunWaitingHuman || s == RunQualityReview ||
s == RunWaitingReconciliation
}
// Terminal 报告该状态是否为终态。
+34
View File
@@ -78,6 +78,10 @@ type ProcessEvent struct {
}
// ExecResult 是一次步骤执行的终态。
//
// 语义契约(F05/U3b):Success=true 才是经过验证的成功;仅在交付物齐全且
// ExecutionID 复用前提下成立。仅凭已物化的文件存在不能把失败翻成成功。
// 后端可以选择性填写以下可选字段;缺省 = 旧的读取行为。
type ExecResult struct {
Success bool `json:"success"`
Patches map[string][]byte `json:"patches"`
@@ -86,6 +90,36 @@ type ExecResult struct {
Retryable bool `json:"retryable,omitempty"`
PromptTokens int `json:"prompt_tokens,omitempty"`
CompletionTokens int `json:"completion_tokens,omitempty"`
// Canceled 为 true 时表示执行是被请求/中断终止的,不是业务失败。
// F05/KTD4:取消结果不应被 retry 改判为可重试;这是用户/系统显式意图。
Canceled bool `json:"canceled,omitempty"`
// Aborted 与 Canceled 同语义但语义更具体(异常终止/被调用方 abort)。
Aborted bool `json:"aborted,omitempty"`
// SideEffects 报告执行是否可能产生过外部动作。空值 = "possible"(旧行为)。
// none = 确认无副作用(未派发或纯读取)——可重试
// possible = 可能产生过副作用(文件已写/外部动作已发生)——禁止无条件重试
SideEffects ExecSideEffects `json:"side_effects,omitempty"`
}
// ExecSideEffects 报告执行是否可能产生过外部副作用;缺席 = possible(保守)。
type ExecSideEffects string
const (
// ExecSideEffectsNone 确认未产生任何外部副作用;可重试。
ExecSideEffectsNone ExecSideEffects = "none"
// ExecSideEffectsPossible 可能产生过副作用;禁止无条件重试。
ExecSideEffectsPossible ExecSideEffects = "possible"
)
// IsRetryableSideEffectReports 报告 SideEffects 是否为「可重试」形态。
// 默认 possible 视为不可重试(保守):调用方必须显式声明 none 才放开重试。
func (r *ExecResult) IsRetryableSideEffectReports() bool {
return r != nil && r.SideEffects == ExecSideEffectsNone
}
// IsCanceled 报告结果是否被取消/中止(不是业务失败)。
func (r *ExecResult) IsCanceled() bool {
return r != nil && (r.Canceled || r.Aborted)
}
// StubExecutor 是离线桩,测试与未接模型时使用。
+12
View File
@@ -2,6 +2,7 @@ package squad
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -162,6 +163,17 @@ func TestInterruptedRunningStepStaysHeld(t *testing.T) {
if err := st.save(); err != nil {
t.Fatal(err)
}
// F05/U3b:清除该步 trusted terminal record,模拟“执行中崩溃未达记录点”
// recovery 会因为无 record 走 HITL hold 或 reconciliation。
terminals := loadStepTerminalsForWorkDir(res.WorkDir)
if terminals != nil {
stepID := st.Plan.Workflow.Steps[0].ID
if _, ok := terminals[stepID]; ok {
delete(terminals, stepID)
data, _ := json.Marshal(terminals)
_ = os.WriteFile(filepath.Join(res.WorkDir, "step_terminals.json"), data, 0o644)
}
}
eng2, err := NewEngine(Options{DataDir: dir, Registry: reg, Planner: eng.opts.Planner, Backends: backends, Sink: NopSink{}})
if err != nil {
t.Fatal(err)
+11
View File
@@ -109,6 +109,17 @@ type StepSnapshot struct {
ID string `json:"id"`
Status string `json:"status"`
AttemptID string `json:"attempt_id,omitempty"`
// F05/U3b:trusted terminal record;用于 recovery 时仅在「后端显式
// 声明 success」时把步骤恢复成 completed(即便目标文件缺失也照恢复),
// files 仍然记入 deliverables 做诊断。SideEffects 报告重放安全性:
// none → 可重试
// possible → 需 reconciliation(不禁 rerun;只允许显式 reconcile)
// Canceled/Aborted 为 true 时本步是取消/中止语义,不应被恢复成 completed。
Terminal string `json:"terminal,omitempty"`
// SideEffects 镜像 ExecResult.SideEffects;缺省 = possible(旧读取)。
SideEffects string `json:"side_effects,omitempty"`
Canceled bool `json:"canceled,omitempty"`
Aborted bool `json:"aborted,omitempty"`
}
// CheckpointMode 快照写盘档位(Q1 WP4 三档)。
+12
View File
@@ -110,6 +110,9 @@ func AssessStepFailure(sig StepFailureSignals) FailureAssessment {
lower := strings.ToLower(reason)
trace := ScanTraceText(sig.ExtraText + "\n" + ReadWorkDirTraces(sig.WorkDir))
missing := sig.TargetMissing || strings.Contains(lower, "missing target deliverable")
// F05/KTD4:explicit SideEffects=none 报告 = provably-not-dispatched。
// 缺则按 possible 对待(与旧语义一致)。这是 caller 自己传达的可重试信令。
provablyNotDispatched := sig.Result != nil && sig.Result.SideEffects == ExecSideEffectsNone
if sig.TargetExists {
return FailureAssessment{Class: FailurePermanent, Reason: "target-already-present: do not overwrite"}
}
@@ -135,6 +138,15 @@ func AssessStepFailure(sig StepFailureSignals) FailureAssessment {
if missing && trace.Settled && !provider {
return FailureAssessment{Class: FailureTransient, Reason: "settled-no-deliverable: " + compactReason(reason)}
}
// F05/U3b:SideEffects=none 失败原则上可重试(后端已声明未发生外部动作);
// 跨越该含义补一条有界重试通路,并覆盖纯获取后失败但文件已拼起的边界。
if provablyNotDispatched && !provider {
kind := "provably-not-dispatched"
if missing {
kind = "provably-not-dispatched-missing-target"
}
return FailureAssessment{Class: FailureTransient, Reason: kind + ": " + compactReason(reason)}
}
if missing {
return FailureAssessment{Class: FailurePermanent, Reason: "permanent-missing: " + compactReason(reason)}
}
+146 -8
View File
@@ -3,6 +3,7 @@ package squad
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
@@ -162,10 +163,21 @@ func (d *flowDriver) advance(ctx context.Context, st *RunState) (res *RunResult,
}
// snapshotOf 组装一个 superstep 边界快照:序号、图结构指纹与全量步骤状态。
// terminal side-effect evidence 由 recordStepTerminal 在步骤进入 completed 时写入
// step_terminals.json;本处把已记录的 terminal 数据并入 StepSnapshot,保证
// checkpoint 也带同样证据(F05:信任落盘记录)。
func snapshotOf(superNo int, g *graph, steps []StepSpec) Checkpoint {
cp := Checkpoint{Superstep: superNo, At: time.Now(), GraphHash: g.hash()}
// checkpoint 路径拿不到当前 RunState 引用(cpw 在 defer flush 时调用过),
// 同时也仅有顺序式调用传递上下文不便;所以这里走「按 ID 检查同索引」对齐
// 替代——checkpoint 产生时 step.Status 已为终态(completed),因此 record 同步
// 已写入;StepSnapshot.Terminal 就用 step.Status 推导(F05 唯一信任源)。
for _, s := range steps {
cp.StepStates = append(cp.StepStates, StepSnapshot{ID: s.ID, Status: s.Status, AttemptID: s.AttemptID})
snap := StepSnapshot{ID: s.ID, Status: s.Status, AttemptID: s.AttemptID}
if s.Status == "completed" {
snap.Terminal = "completed"
}
cp.StepStates = append(cp.StepStates, snap)
}
return cp
}
@@ -359,6 +371,9 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *RunState, idx int) (*Ru
}
var result *ExecResult
var err error
// F05/U3b(KTD4):ExecutionID 在一次逻辑执行内稳定。重试层共享同一 ID
// + 同一 Deadline,便于下游做幂等键 + 收据对齐。第一层 attempt 用稳定 ID;
// 重试层沿用 step.AttemptID(首次进入时已生成、保持不变)。
for attempt := 1; attempt <= MaxTransientStepInvocations; attempt++ {
reqPrompt := prompt
if attempt > 1 {
@@ -384,9 +399,10 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *RunState, idx int) (*Ru
// RoleSpec v2 贯通:工具白名单 / 技能 / 标识(原为死字段或缺失)。
ToolAllowlist: role.Tools,
Skills: firstNonEmptySlice(step.Skills, role.Skills),
SessionScope: st.Meta.ID + "/" + step.ID,
ExecutionID: step.AttemptID,
Budget: stepBudget,
// F05:ExecutionID 复用 step.AttemptID(首层 attempt 时已生成)——重试层不再换新 ID。
SessionScope: st.Meta.ID + "/" + step.ID,
ExecutionID: step.AttemptID,
Budget: stepBudget,
}
if step.IntentID != "" {
// 系统功能步:不经智能体,由 IntentRunner 直接执行;缺席即 fail-closed。
@@ -406,18 +422,59 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *RunState, idx int) (*Ru
} else {
result, err = backend.Execute(ctx, req)
}
if err == nil && result.Success {
// F05/KTD4:先验证执行结果本身,不让「文件已存在」翻成 Success。
// (1) (nil,nil) 视作稳定失败,不允许 panic 也不允许误判成功;
// (2) Success=true 仅在 result 显式声明时才成立;
// (3) 已取消/中止的结果不进入重试通路(不是业务失败,且重试会重复已发生的取消语义)。
if err != nil && result == nil {
// (nil,nil) 在 Go 是不可能态(要么 err、要么 result);保险起见仍按稳定失败。
step.Status = "failed"
step.Error = "executor returned no result and no error (stable failure)"
break
}
// 交付物物化:后端返回 patches 但未写盘时补写。
if result == nil {
step.Status = "failed"
step.Error = "executor returned nil result"
break
}
if result.IsCanceled() {
// F05/KTD4:取消不是业务失败,不重试(按 attempt==MaxTransientStepInvocations 跳出)。
step.Status = "failed"
if result.ErrorReason != "" {
step.Error = "executor canceled: " + result.ErrorReason
} else {
step.Error = "executor canceled"
}
break
}
if err == nil && result.Success {
// 交付物物化:后端返回 patches 但未写盘时补写。F05:仅当 result.Success
// 已验证且文件未准备时才需要物化;只有 patches 内容才能落盘。
if len(result.Patches) > 0 {
_, _ = MaterializeTargets(subDir, targets, result.Patches)
}
break
}
// 交付物物化:后端返回 patches 但未写盘时补写。F05:仅当 result.Success 已
// 被后端显式声明时物化才允许 break;旧逻辑会让
// `err==nil && len(Patches)>0 && materialize_ok` 翻成 Success=true —— 修掉。
if err == nil && len(result.Patches) > 0 {
if _, merr := MaterializeTargets(subDir, targets, result.Patches); merr == nil {
if allTargetsPresent(subDir, targets) {
result.Success = true
if result.Success && allTargetsPresent(subDir, targets) {
break
}
}
}
// F05/KTD4:副作用可能存在的失败禁止自动重试(重试会重复外部动作)。
// 仅 SideEffects=none 或纯读取后的失败保持有界重试。
if !result.IsRetryableSideEffectReports() && err != nil {
step.Status = "failed"
step.Error = orDefault(result.ErrorReason, err.Error())
if result.ErrorReason != "" {
step.Error = result.ErrorReason
}
break
}
assessment := AssessStepFailure(StepFailureSignals{
Err: err, Result: result, WorkDir: subDir,
ExtraText: fmt.Sprintf("step=%s backend=%s", step.ID, backendName),
@@ -463,6 +520,9 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *RunState, idx int) (*Ru
step.Status = "completed"
appendLog(subDir, fmt.Sprintf("[%s] backend=%s duration=%dms targets=%v\n", time.Now().Format(time.RFC3339), backendName, step.DurationMs, targets))
// F05/U3b:记录 trusted terminal state(用于 recoverInterrupted 判定)。
// 由「正在收口的 completed」流写入;recovery 期间不会回填(防伪)。
d.recordStepTerminal(st, step, result)
// 状态回填 + 黑板合并 + 落盘同一临界区:并行步骤的共享状态变更串行化,
// 防止 marshal 与写回竞争、临时文件互相踩踏把保存失败误判为步骤失败。
@@ -1039,6 +1099,84 @@ func countStatus(st *RunState, status string) int {
return n
}
// recordStepTerminal 记一条「trusted terminal record」:在某步执行完成
// (仅 success 路径)时落地 step.Terminal + SideEffects + canceled/aborted。
// reconcile/path:如果 recovery 期间没有该记录,则按文件存在与否分流
// (见 engine.recoverInterrupted)。
//
// F05:record 与 read 路径严格同包;测试通过 squad/executor_result_contract_test.go
// 覆盖正向与逆向条件。
func (d *flowDriver) recordStepTerminal(st *RunState, step StepSpec, result *ExecResult) {
if st == nil || st.Meta.WorkDir == "" {
return
}
if step.ID == "" {
return
}
t := stepTerminalRecord{Terminal: "completed", At: time.Now(), StepID: step.ID, AttemptID: step.AttemptID}
if result != nil {
t.SideEffects = string(result.SideEffects)
t.Canceled = result.Canceled
t.Aborted = result.Aborted
}
st.mu.Lock()
defer st.mu.Unlock()
terminals := readStepTerminalsLocked(st)
terminals[step.ID] = t
data, err := json.Marshal(terminals)
if err != nil {
return
}
_ = os.WriteFile(filepath.Join(st.Meta.WorkDir, "step_terminals.json"), data, 0o644)
}
// readStepTerminalsLocked 读取已落盘的 step_terminals.json(调用方须持锁或私有化)。
// 失败(含文件不存在)返回空 map。
func readStepTerminalsLocked(st *RunState) map[string]stepTerminalRecord {
out := map[string]stepTerminalRecord{}
if st == nil || st.Meta.WorkDir == "" {
return out
}
data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "step_terminals.json"))
if err != nil || len(data) == 0 {
return out
}
var stored map[string]stepTerminalRecord
if err := json.Unmarshal(data, &stored); err != nil {
return out
}
for k, v := range stored {
out[k] = v
}
return out
}
// loadStepTerminalsForWorkDir 公开暴露的 record 读取(recovery 使用)。
func loadStepTerminalsForWorkDir(wsDir string) map[string]stepTerminalRecord {
if wsDir == "" {
return map[string]stepTerminalRecord{}
}
data, err := os.ReadFile(filepath.Join(wsDir, "step_terminals.json"))
if err != nil || len(data) == 0 {
return map[string]stepTerminalRecord{}
}
var stored map[string]stepTerminalRecord
if err := json.Unmarshal(data, &stored); err != nil {
return map[string]stepTerminalRecord{}
}
return stored
}
type stepTerminalRecord struct {
StepID string `json:"step_id"`
AttemptID string `json:"attempt_id"`
Terminal string `json:"terminal"` // completed (唯一允许值;failure 走 step.Status)
SideEffects string `json:"side_effects,omitempty"`
Canceled bool `json:"canceled,omitempty"`
Aborted bool `json:"aborted,omitempty"`
At time.Time `json:"at"`
}
func buildManifest(st *RunState, deliverable string, report QualityReport) Manifest {
files := map[string]string{}
_ = filepath.Walk(st.Meta.WorkDir, func(path string, info os.FileInfo, err error) error {