Compare commits

...
Author SHA1 Message Date
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
weichen d6669cd9c8 Add StepSpec.Brief rendered as the task-instruction section of TASK.md
ci / vet-and-test (pull_request) Successful in 25s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: the Q3 compiler maps core.WorkflowDef prompt steps onto squad graph
steps; the rendered prompt text needs a first-class carrier in the step
task brief (until now only leader delegations had a briefing channel).

Evidence: TestStepBriefRendersIntoTaskFile asserts the section presence,
body, and position (after targets, before the context table); full squad
suite green (SELF), CI authoritative (GATE).

Impact: additive StepSpec field brief + writeTaskFile section; no behavior
change when Brief is empty.

Refs: Q3 plan WP1 (2026-10-07-agent-stack-q2q3-plans.md); Dev task card
.task/feature-agent-stack-q3.md
2026-10-08 02:10:03 +00:00
weichen bcc8d080b0 Merge pull request 'Add task-board driver with claim/lease/complete and 12-topology aggregate (Q2 WP4)' (#45) from feature/q2-taskboard-wp4 into main
ci / vet-and-test (push) Successful in 26s
ci / docker-checkout-hostgateway (push) Successful in 1s
Merge pull request 'Add task-board driver with claim/lease/complete and 12-topology aggregate (Q2 WP4)' (#45) from feature/q2-taskboard-wp4 into main
2026-10-08 01:30:58 +00:00
weichen acbe744142 Add task-board driver with claim/lease/complete and 12-topology aggregate (Q2 WP4)
ci / vet-and-test (pull_request) Successful in 26s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Q2 acceptance 1 requires a scripted-backend conformance for every one of
the 12 builtin topologies; task_board was the last unlit driver.

Evidence: wp4_conformance_test.go: diamond-dependency board respects the
dependency gate in claim order; orphan lease (claimed + expired) re-arms on
driver re-entry and re-claims with Attempts=2 via Resume; non-critical task
failure cascades dependents to skipped and finishes degraded; fail-closed
when tasks remain unclosable. TestTopologyConformance_allTwelve runs all 12
names (10 runnable to completion, handoff/mailbox rejected as primitives).
Full suite green with -race.

Impact: task_board lit in the topology registry (all 12 topologies now
covered); RunState gains Board (persisted in runstate.json); RunOptions gains
TaskBoardLeaseSeconds; leader topology now maps to mode before plan
confirmation so Topology=leader runs no longer hit the confirmation gate.

Refs: Q2 plan WP4 (2026-10-07-agent-stack-q2q3-plans.md); task card
.task/feature-agent-stack-q2.md
2026-10-08 01:29:03 +00:00
weichen aec2a4aa0f Merge pull request 'Add review driver, handoff primitive, and group-chat driver (Q2 WP3)' (#44) from feature/q2-review-handoff-groupchat-wp3 into main
ci / vet-and-test (push) Successful in 25s
ci / docker-checkout-hostgateway (push) Successful in 0s
Merge pull request 'Add review driver, handoff primitive, and group-chat driver (Q2 WP3)' (#44) from feature/q2-review-handoff-groupchat-wp3 into main
2026-10-08 01:13:54 +00:00
weichen c88100967d Add review driver, handoff primitive, and group-chat driver (Q2 WP3)
ci / vet-and-test (pull_request) Successful in 34s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
Why: Q2 acceptance 2 requires the squad review topology to be equivalent to
today's backend runReview flow (parallel finders, per-finding verification,
drop unverified); handoff and group_chat complete 11 of 12 builtin topologies.

Evidence: wp3_conformance_test.go mirrors backend review_test.go assertions
(true finder concurrency via release gate, verify calls = 5 unique after
dedupe, only PASS kept, dropped-count line, severity order); handoff records
ownership transfer, target receives filtered history, finalize takes
chain-tail output, unknown target role fails closed; group-chat rotate /
close-marker / moderator conformance with budget stop and fail-closed
unknown nominee. Full suite green with -race.

Impact: review/group_chat drivers lit in the topology registry (task_board
remains fail-closed until WP4); RunState gains Handoffs (persisted);
RunOptions gains GroupChat*/Review* knobs; runOneStep validates handoff
targets and injects handoff context; finalize prefers chain-tail deliverable.
Parse/dedupe/verdict/severity/report formats ported verbatim from backend
internal/runtime/review.go for byte-level report equivalence.

Refs: Q2 plan WP3 (2026-10-07-agent-stack-q2q3-plans.md); task card
.task/feature-agent-stack-q2.md
2026-10-08 01:11:51 +00:00
weichen 01d5b96cda Merge pull request 'Add leader progress ledger, replan cap, and mailbox with actor attribution (Q2 WP2)' (#43) from feature/q2-leader-mailbox-wp2 into main
ci / vet-and-test (push) Successful in 25s
ci / docker-checkout-hostgateway (push) Successful in 0s
Merge pull request 'Add leader progress ledger, replan cap, and mailbox with actor attribution (Q2 WP2)' (#43) from feature/q2-leader-mailbox-wp2 into main
2026-10-08 00:36:38 +00:00
weichen 6d76111a16 Add leader progress ledger, replan cap, and mailbox with actor attribution (Q2 WP2)
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: Q2 acceptance requires leader runs to detect stuck delegation loops and
cap replans instead of spinning to the turn limit, and a mailbox primitive
whose messages can never resolve a pending permission approval (acceptance 3).

Evidence: squad conformance tests (leader_mailbox_conformance_test.go):
ledger stuck->replan, replan cap finalize, mailbox delivery after hold,
mailbox-cannot-approve-permission (structural + actor second defense),
post guards; full suite green with -race.

Impact: LeaderState gains Progress/StuckTurns/Replans/MaxReplans/ReplanNote
(persisted in leader.json); RunState gains Mailbox (persisted in
runstate.json); RespondDecision gains Actor - non-human actors are rejected
on choice gates; step briefs and leader task briefs include pending mail.

Refs: Q2 plan WP2 (2026-10-07-agent-stack-q2q3-plans.md); task card
.task/feature-agent-stack-q2.md
2026-10-08 00:34:46 +00:00
weichen 8103b8da35 Merge pull request 'Add the topology surface with loop, router, and deadline mechanics (Q2 WP1)' (#42) from feature/q2-topology-wp1 into main
ci / vet-and-test (push) Successful in 25s
ci / docker-checkout-hostgateway (push) Successful in 1s
Add the topology surface with loop, router, and deadline mechanics (Q2 WP1)
2026-10-08 00:05:06 +00:00
weichen f3a67ced9c Add the topology surface with loop, router, and deadline mechanics (Q2 WP1)
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: the completion doc Q2 requires twelve builtin topologies each with
a scripted-backend conformance; today the engine has only the
standard/leader mode string with no topology abstraction, no loop
re-arm (cycles are fail-closed by design), no in-graph routing, and no
run deadline.
Evidence: GOWORK=off go vet + go test -count=1 green across squad and
all three adapter modules; ten conformance/validation tests:
sequential, parallel, loop-until-field (exact iteration count +
persisted LoopRuns), loop-budget-exhausted (loop_exhausted event, run
still completes), router-selects-branch (loser skipped, run NOT
degraded), router-no-match fail-closed, evaluator-optimizer (draft
re-armed via loop body until verdict=pass), long-running deadline
(pending steps skipped, degraded closeout), unknown/primitive/
unregistered-driver fail-closed, builtin registry completeness (12).
Impact: additive — Topology/Deadline on RunOptions, Topology on
Blueprint, Loop/LoopRuns/Branches/Handoff on StepSpec; empty topology
keeps byte-identical standard behavior; MaxIterations is the total
iteration cap (first run included); router-skipped steps are exempt
from degraded derivation; runParallel now preserves the first specific
RunFailed error instead of re-wrapping via failStep; deadline metadata
uses RFC3339Nano (second-precision RFC3339 truncated sub-second
deadlines into instant expiry).
Refs: Agent-System-Documents#44 (Q2 plan WP1)
2026-10-08 00:03:24 +00:00
weichen c462bc4ae5 Merge pull request 'Carry the split usage fields through squad events (N3 WP6)' (#41) from feature/n3-usage-fields-wp6-r1 into main
ci / vet-and-test (push) Successful in 22s
ci / docker-checkout-hostgateway (push) Successful in 1s
Carry the split usage fields through squad events (N3 WP6)
2026-10-07 17:13:55 +00:00
weichen cff355d7e6 Carry the split usage fields through squad events (N3 WP6)
ci / vet-and-test (pull_request) Successful in 21s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: the exec bridge now threads Last*/cache (Agent-Native#39); squad
ProcessEvent carries them to hosts (the product's squad runner reads
them for usage accounting).
Evidence: GOWORK=off go vet green across squad and adapters/native;
nested pin at the merged native.
Impact: additive event fields.
Refs: Agent-Native#39, N3 plan WP6
2026-10-07 17:12:24 +00:00
weichen 8b1000eb09 Merge pull request 'Point the nested agent-native pin at the cache-on-message_end follow-up' (#40) from feature/n3-wp6-followup-cascade into main
ci / vet-and-test (push) Successful in 23s
ci / docker-checkout-hostgateway (push) Successful in 1s
Point the nested agent-native pin at the cache-on-message_end follow-up
2026-10-07 17:06:09 +00:00
weichen ef17c6630a Point the nested agent-native pin at the cache-on-message_end follow-up
ci / vet-and-test (pull_request) Successful in 23s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: native main carries the cache-on-message_end follow-up; the Dev
combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#37
2026-10-07 17:04:39 +00:00
weichen 40ce140cad Merge pull request 'Point the nested agent-native pin at the usage split and default retry' (#39) from feature/n3-wp6-cascade into main
ci / vet-and-test (push) Successful in 25s
ci / docker-checkout-hostgateway (push) Successful in 1s
Point the nested agent-native pin at the usage split and default retry
2026-10-07 16:33:36 +00:00
weichen b72bbe48a1 Point the nested agent-native pin at the usage split and default retry
ci / vet-and-test (pull_request) Successful in 23s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: native main carries the WP6 usage split and default retry; the
Dev combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#35
2026-10-07 16:32:54 +00:00
weichen bf7fe8429b Merge pull request 'Point the nested agent-native pin at the memory port work' (#38) from feature/n3-wp5-cascade into main
ci / vet-and-test (push) Successful in 22s
ci / docker-checkout-hostgateway (push) Successful in 1s
Point the nested agent-native pin at the memory port work
2026-10-07 16:23:00 +00:00
weichen 6dc03c02a9 Point the nested agent-native pin at the memory port work
ci / vet-and-test (pull_request) Successful in 22s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: native main carries the memory port and memory_write (N3 WP5);
the Dev combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#34
2026-10-07 16:22:15 +00:00
weichen ff36b23191 Merge pull request 'Point the nested agent-native pin at the checkpoint closure work' (#37) from feature/n3-wp3-cascade into main
ci / vet-and-test (push) Successful in 23s
ci / docker-checkout-hostgateway (push) Successful in 1s
Point the nested agent-native pin at the checkpoint closure work
2026-10-07 15:52:54 +00:00
weichen d775b4b205 Point the nested agent-native pin at the checkpoint closure work
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: native main carries the checkpoint closure (N3 WP3); the Dev
combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#33
2026-10-07 15:51:23 +00:00
weichen f38cf7c441 Merge pull request 'Point the nested agent-native pin at the OTel GenAI alignment' (#36) from feature/n3-wp4-cascade into main
ci / vet-and-test (push) Successful in 24s
ci / docker-checkout-hostgateway (push) Successful in 1s
Point the nested agent-native pin at the OTel GenAI alignment
2026-10-07 15:39:54 +00:00
weichen a9948da781 Point the nested agent-native pin at the OTel GenAI alignment
ci / vet-and-test (pull_request) Successful in 25s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: native main carries the GenAI span convention work (N3 WP4); the
Dev combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#32
2026-10-07 15:38:08 +00:00
weichen 90077b6601 Merge pull request 'Point the nested agent-native pin at the JournalOnly session work' (#35) from feature/n3-wp2-cascade into main
ci / vet-and-test (push) Successful in 26s
ci / docker-checkout-hostgateway (push) Successful in 0s
Point the nested agent-native pin at the JournalOnly session work
2026-10-07 15:25:05 +00:00
weichen b3363e83b9 Point the nested agent-native pin at the JournalOnly session work
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: native main carries JournalOnly sessions and the seed-persist fix
(N3 WP2); the Dev combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#31
2026-10-07 15:23:26 +00:00
weichen 931e5ae410 Merge pull request 'Point the nested agent-native pin at the RunState repair work' (#34) from feature/n3-wp1-cascade into main
ci / vet-and-test (push) Successful in 24s
ci / docker-checkout-hostgateway (push) Successful in 1s
Point the nested agent-native pin at the RunState repair work
2026-10-07 15:05:33 +00:00
weichen ec2c6a3495 Point the nested agent-native pin at the RunState repair work
ci / vet-and-test (pull_request) Successful in 25s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: native main carries repairOrphanTail and the N6 fix (N3 WP1); the
Dev combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#30
2026-10-07 15:03:54 +00:00
weichen 6306451552 Merge pull request 'Point the nested agent-native pin at the subagent Background field' (#33) from feature/n2-wp5-cascade into main
ci / vet-and-test (push) Successful in 42s
ci / docker-checkout-hostgateway (push) Successful in 0s
Point the nested agent-native pin at the subagent Background field
2026-10-07 14:17:52 +00:00
weichen 088d7f8543 Point the nested agent-native pin at the subagent Background field
ci / vet-and-test (pull_request) Successful in 26s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: native main carries subagent Request.Background (N2 WP5); the Dev
combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#29
2026-10-07 14:11:09 +00:00
weichen 9d3faefb97 Merge pull request 'Point the nested agent-native pin at the skills spec validation' (#32) from feature/n2-wp4-cascade into main
ci / vet-and-test (push) Successful in 29s
ci / docker-checkout-hostgateway (push) Successful in 1s
Point the nested agent-native pin at the skills spec validation
2026-10-07 13:54:30 +00:00
weichen c70d7c96d9 Point the nested agent-native pin at the skills spec validation
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
ci / vet-and-test (pull_request) Successful in 27s
Why: native main (7425b32b) validates skills against the open spec
(N2 WP4); the Dev combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#28, N2 WP4 cascade
2026-10-07 13:53:03 +00:00
weichen 92f4e925d2 Merge pull request 'Point the nested agent-native pin at the MCP 2026-07-28 work' (#31) from feature/n2-wp3-cascade into main
ci / vet-and-test (push) Successful in 29s
ci / docker-checkout-hostgateway (push) Successful in 1s
Point the nested agent-native pin at the MCP 2026-07-28 work
2026-10-07 13:42:03 +00:00
weichen 1bd42ab980 Point the nested agent-native pin at the MCP 2026-07-28 work
ci / vet-and-test (pull_request) Successful in 26s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: native main (77e7f978) aligns the MCP client with 2026-07-28
(N2 WP3); the Dev combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#27, N2 WP3 cascade
2026-10-07 13:40:27 +00:00
weichen 41ea27668c Merge pull request 'Point the nested agent-native pin at the Anthropic client work' (#30) from feature/n2-wp2-cascade into main
ci / vet-and-test (push) Successful in 26s
ci / docker-checkout-hostgateway (push) Successful in 1s
Point the nested agent-native pin at the Anthropic client work
2026-10-07 13:24:26 +00:00
weichen b4f1d26f33 Point the nested agent-native pin at the Anthropic client work
ci / vet-and-test (pull_request) Successful in 26s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: native main (d6053baf) adds the native Anthropic Messages client
(N2 WP2); the Dev combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#26, N2 WP2 cascade
2026-10-07 13:22:51 +00:00
weichen 56941bf8ea Merge pull request 'Point the nested agent-native pin at the N2 WP1 work' (#29) from feature/n2-wp1-cascade into main
ci / vet-and-test (push) Successful in 43s
ci / docker-checkout-hostgateway (push) Successful in 1s
Point the nested agent-native pin at the N2 WP1 work
2026-10-07 13:11:16 +00:00
weichen fe8de0e057 Point the nested agent-native pin at the N2 WP1 work
ci / vet-and-test (pull_request) Successful in 25s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: native main (64b8bd4c) adds the tool-result spill and cache
contract tests (N2 WP1); the Dev combination check requires the nested
pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-Native#25, N2 WP1 cascade
2026-10-07 13:05:53 +00:00
weichen 029edc08e7 Merge pull request 'Point the nested agent-native pin at the CWD-clean fix' (#28) from feature/native-cascade-follow into main
ci / vet-and-test (push) Successful in 35s
ci / docker-checkout-hostgateway (push) Successful in 0s
Point the nested agent-native pin at the CWD-clean fix
2026-10-07 11:50:46 +00:00
weichen 1d86344c85 Point the nested agent-native pin at the CWD-clean fix
ci / vet-and-test (pull_request) Successful in 35s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: native main (ead690be) keeps workspace-less requests out of the
process CWD; the Dev CI build step requires a clean tree after tests.
Evidence: go build ./... green; tree-only pin move.
Impact: none beyond the pin.
Refs: Agent-Native#24, Agent-System-Dev#242
2026-10-07 11:48:58 +00:00
weichen 551e11ec19 Merge pull request 'Point the nested agent-intent and agent-native pins at the I2 cascade' (#27) from feature/agent-stack-i2 into main
ci / vet-and-test (push) Successful in 28s
ci / docker-checkout-hostgateway (push) Successful in 0s
Point the nested agent-intent and agent-native pins at the I2 cascade
2026-10-07 11:32:47 +00:00
weichen 83e72b4eaa Point the nested agent-intent and agent-native pins at the I2 cascade
ci / vet-and-test (pull_request) Successful in 23s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: the Dev combination check requires every nested agent-intent pin
to equal 343b1e6 (I2 increments 1-2 on intent main); the nested native
pin follows to 3a1ad5a (which itself carries intent 343b1e6).
Evidence: tree-only pin moves; squad code does not consume the new
ports.
Impact: none.
Refs: Agent-Native#23, I2 increment 3 cascade
2026-10-07 11:31:25 +00:00
weichen eba77e782f Merge pull request 'Converge run persistence on SQLite through the Store port (Q1 WP5)' (#26) from feature/agent-stack-q1 into main
ci / vet-and-test (push) Successful in 41s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-07 10:31:16 +00:00
weichen 90ac98ea65 Converge run persistence on SQLite through the Store port
ci / vet-and-test (pull_request) Successful in 23s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Q1 WP5 - platform's SQLite becomes an implementation of the
squad Store port so run state and orchestration state live in one
database, and platform's in-process execution goes through the
Executor port instead of bypassing it (X6).

Evidence: all five modules go vet clean and go test ok, including
engine-on-SQLite (state in DB, no run-level JSON files, second
engine recovers from the same DB), SQLiteStore round trip, and the
full squad compatibility net.

Impact: runState/hitlGate exported so the port is implementable;
Options.Store wires into engineDeps; leader persistence and
checkpoints route through the port; PrepareWorkspace no longer
writes run-level JSON (st.save owns it); platform RunInProcess
takes squad.Executor (callers updated); serve mounts the engine on
platform's RunStore - single orchestration.db for both.

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md Q1 WP5
2026-10-07 10:29:47 +00:00
weichen 1c3995beaa Merge pull request 'Add the Store port with tiered checkpoints and centralize termination (Q1 WP4/WP6/WP7)' (#25) from feature/agent-stack-q1 into main
ci / vet-and-test (push) Successful in 25s
ci / docker-checkout-hostgateway (push) Successful in 0s
2026-10-07 10:02:01 +00:00
weichen 626b2a3f34 Add the Store port with tiered checkpoints and centralize termination
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: Q1 WP4/WP6/WP7 - run persistence goes through a Store port
(FileStore carries the current layout), superstep boundaries write
tiered snapshots with a graph fingerprint for recovery continuity,
and the four hardcoded stop conditions move behind one Termination
registry with And/Or composition.

Evidence: go vet clean; full suite ok including new checkpoint,
continuity-rejection, termination-primitive, grant-cap and the
Q23-updated leader closeout tests; cmd module green with
SweepExpired wired into the sweeper loop.

Impact: runState saves via Store (FileStore unchanged by default);
advance writes sync/async/on_exit checkpoints and Resume rejects
graph-shape changes from the crash window; leader turn cap now
degrades through finalize instead of a bare failure (Q23); manual
repair grants are bounded (Q26); wakeup cap and step timeout read
from the same registry; the dead SweepExpired is now called (Q6).

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md Q1 WP4/WP6/WP7
2026-10-07 10:00:33 +00:00
weichen b34d6ed187 Merge pull request 'Persist attempt ids and verify products on interrupted recovery (Q1 WP3)' (#24) from feature/agent-stack-q1 into main
ci / vet-and-test (push) Successful in 24s
ci / docker-checkout-hostgateway (push) Successful in 0s
2026-10-07 09:19:15 +00:00
weichen 96d1e0e49c Persist attempt ids and verify products on interrupted recovery
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: Q1 WP3 - each step execution carries a stable attempt id
(runID/stepID/N) for audit and idempotency, and auto-mode
interrupted recovery must not re-execute a step whose products all
landed before the crash (side effects exactly once).

Evidence: go vet clean; full squad suite ok, including the new
kill-at-superstep-boundary test (completed steps never re-execute,
running-with-products judged complete by attempt) and
missing-product retry tests.

Impact: StepSpec gains AttemptID set when entering running and used
as the execution id; recoverInterrupted (HitlAuto) checks the
step's targets first - all present means completed with artifact
merge, otherwise the legacy pending retry. Transient retries share
the same AttemptID.

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md Q1 WP3
2026-10-07 09:18:08 +00:00
weichen 3870489614 Merge pull request 'Drive workflow execution as supersteps over a compiled dependency graph (Q1 WP1/WP2)' (#23) from feature/agent-stack-q1 into main
ci / vet-and-test (push) Successful in 24s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-07 09:08:21 +00:00
weichen f90abafe79 Drive workflow execution as supersteps over a compiled dependency graph
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Q1 WP1/WP2 - replace the linear step scan with per-round graph
compilation and layer advancement so Needs drives scheduling and
write-conflicting steps never run concurrently.

Evidence: go vet clean in all five modules; go test ok everywhere,
including the full compatibility net (engine, leader, recovery, r2
regression, intent, evaluator suites) and new graph tests.

Impact: new squad/graph.go (compile/layer/conflict); StepSpec gains
Writes; advance advances by superstep (parallel run of
conflict-free members, serial for write-conflicts, single-member
groups keep dynamic adjustment). Legacy semantics preserved:
adjacent-Parallel mapping, degraded-continue on non-critical
failure (failed counts as satisfied), empty status = pending,
cycle/unknown/dup needs fail-closed.

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md Q1 WP1/WP2
2026-10-07 09:06:52 +00:00
weichen bb02683d89 Merge pull request 'Point the nested agent-native pin at the intent-archive cascade head' (#22) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 23s
ci / docker-checkout-hostgateway (push) Successful in 0s
2026-10-07 08:28:25 +00:00
weichen 4e8d684125 Point the nested agent-native pin at the intent-archive cascade
ci / vet-and-test (pull_request) Successful in 22s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Agent-Native PR #22 landed after Squad #21; squad's nested
native must carry the same head or Dev check-pins fails.

Evidence: 78b6c647 is the #22 merge on agent-native main.

Impact: Nested pin only.
2026-10-07 08:27:39 +00:00
weichen 5403b6e7a9 Merge pull request 'Point the nested intent pin at the eval-v2 head' (#21) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 26s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-07 08:24:36 +00:00
weichen ebbebdf20b Point the nested intent pins at the eval-v2 head
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Agent-Intent PR #8 landed the multi-turn eval format; nested
copies must carry it or Dev check-pins fails.

Evidence: intent main head is the #8 merge.

Impact: Nested pins only.
2026-10-07 08:22:54 +00:00
weichen 592a410288 Merge pull request 'Point the nested agent-native pin at the intent cascade head' (#20) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 23s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-07 07:49:50 +00:00
weichen 7fe41e86a1 Point the nested agent-native pin at the intent cascade head
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: Agent-Native PR #21 landed the nested intent bump; squad's
nested native must carry the same head or Dev check-pins fails.

Evidence: 6ea69293 is the #21 merge on agent-native main; squad
modules run next.

Impact: Nested pin only.
2026-10-07 07:48:40 +00:00
weichen 6b7a991750 Merge pull request 'Enable NFKC folding in the squad intent gate (I1 WP4)' (#19) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 26s
ci / docker-checkout-hostgateway (push) Successful in 0s
2026-10-07 07:44:14 +00:00
weichen cb398dadc6 Enable NFKC folding in the squad intent gate and bump the intent pin
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Both intent hosts should fold compatibility-equivalent input
(backend intentgate already does); the nested pin carries the I1
command model the gate depends on.

Evidence: adapters/intent suite ok; all five squad modules green.

Impact: NFKC on in NewFromOverlay; nested agent-intent at 2554b50.
2026-10-07 07:42:52 +00:00
weichen 7ecc04ee61 Merge pull request 'Point the nested agent-native pin at the WP5 head' (#18) from feature/agent-stack-n1 into main
ci / vet-and-test (push) Successful in 22s
ci / docker-checkout-hostgateway (push) Successful in 0s
2026-10-07 06:38:25 +00:00
weichen 3bf100fd9b Point the nested agent-native pin at the WP5 head
ci / vet-and-test (pull_request) Successful in 22s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Dev moved its agent-native pin to c9c2f23 (PRs #19/#20);
check-pins requires the same combination in squad's nested copy.

Evidence: c9c2f23 is the #20 merge on agent-native main; squad
suites run next.

Impact: Nested pin only.
2026-10-07 06:36:39 +00:00
weichen fc9620e05f Merge pull request 'Pass the permission ask port through the native register Config (N1 WP4)' (#17) from feature/agent-stack-n1 into main
ci / vet-and-test (push) Successful in 21s
ci / docker-checkout-hostgateway (push) Successful in 0s
2026-10-07 05:11:42 +00:00
weichen ef14bda11f Pass the permission ask port through the native register Config
ci / vet-and-test (pull_request) Successful in 21s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Squad members must reach the host's HITL verdict when a kernel
hook answers ask - the new native GateOptions.PermissionAsk field
carries it; nil preserves the legacy ask=reject behavior.

Evidence: go vet clean in all five modules; go test ok everywhere.

Impact: Additive Config field plus its buildOptions mapping.
2026-10-07 05:08:42 +00:00
weichen bf5b210bd1 Point the nested agent-native pin at the N1 spec and guardrails increment
Why: Library PR #18 landed AgentSpec, guardrails, subagent events and
the GateOptions.PermissionAsk field; squad's register mapping needs
that field, and check-pins requires the same combination.

Evidence: native main head is the #18 merge; squad suites run next.

Impact: Nested pin only.
2026-10-07 05:07:37 +00:00
weichen a75d359ade Merge pull request 'Point the nested agent-native pin at the merged N1 increment' (#16) from feature/agent-stack-n1 into main
ci / vet-and-test (push) Successful in 21s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-07 04:43:07 +00:00
weichen 1051dc5ae5 Point the nested agent-native pin at the merged N1 increment
ci / vet-and-test (pull_request) Successful in 20s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Dev moved its agent-native pin to c2befaf (PR #17); squad's
nested pin must carry the same combination or check-pins fails.

Evidence: c2befaf is the merge of PR #17 on agent-native main; squad
test suite runs next.

Impact: Nested pin only.
2026-10-07 04:41:43 +00:00
weichen bf5b389d55 Pin agent-native message shaping
ci / vet-and-test (push) Successful in 24s
ci / docker-checkout-hostgateway (push) Successful in 0s
Merge pull request 'Pin agent-native message shaping' (#15) from feature/sillytavern-review into main
2026-10-04 19:44:56 +00:00
64 changed files with 6862 additions and 232 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;放弃路径注明原因>。
+67
View File
@@ -0,0 +1,67 @@
# 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
enabled: true
antigravity:
adapter: antigravity
executable: agy
model: auto
enabled: true
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
+2
View File
@@ -33,11 +33,13 @@ type Options struct {
// NewFromOverlay 从 JSON overlay 构造闸门,并把 guard 域标成受保护域。
// 命令行用它,这样命令本身不必 import agent-intent。
// NFKC 开启:全角/兼容等价输入与词条同形折叠(与 backend intentgate 一致)。
func NewFromOverlay(ctx context.Context, fsys fs.FS, roots ...string) (*Gate, error) {
return New(ctx, Options{
IntentOptions: intent.Options{
GuardDomains: []string{"guard"},
ProtectedDomains: []string{"guard"},
NFKC: true,
},
FS: fsys,
Roots: roots,
+6 -1
View File
@@ -47,6 +47,9 @@ type Config struct {
Trace func(name string, attrs map[string]string) func()
CodeOrchestration bool
Ask func(ctx context.Context, question string) (string, error)
// PermissionAsk 把内核钩子的 ask 决策转交宿主裁决(nil = 内核保持
// ask 按拒绝处理的旧行为)。经 nativexec.GateOptions 透传。
PermissionAsk func(ctx context.Context, call llm.ToolCall) runtime.AskVerdict
}
// Register 把 "agent-native" 工厂注册进解析器。
@@ -92,7 +95,7 @@ func buildOptions(cfg Config, role squad.RoleSpec) (nativexec.Options, error) {
GateOptions: nativexec.GateOptions{
BeforeTool: cfg.BeforeTool, AfterTool: cfg.AfterTool, Stop: cfg.Stop,
Reminders: cfg.Reminders, RepeatGuard: cfg.RepeatGuard, Hooks: cfg.Hooks,
Ask: cfg.Ask,
Ask: cfg.Ask, PermissionAsk: cfg.PermissionAsk,
},
}
if cfg.Client != nil {
@@ -135,6 +138,8 @@ func toNative(req squad.ExecRequest) exec.Request {
prev(squad.ProcessEvent{
Type: ev.Type, Text: ev.Text, Tool: ev.Tool, Detail: ev.Detail,
PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens,
LastPromptTokens: ev.LastPromptTokens, LastCompletionTokens: ev.LastCompletionTokens,
CacheReadTokens: ev.CacheReadTokens, CacheWriteTokens: ev.CacheWriteTokens,
})
}
}
+2
View File
@@ -4,10 +4,12 @@ go 1.27
require (
agent-native v0.0.0
agent-squad v0.0.0
modernc.org/sqlite v1.37.0
)
require (
error-code-system v0.0.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+5 -7
View File
@@ -11,7 +11,7 @@ import (
"testing"
"time"
"agent-native/runtime/llm"
agentsquad "agent-squad/squad"
)
func TestOrchestrationFlow(t *testing.T) {
@@ -116,12 +116,10 @@ func TestOrchestrationFlow(t *testing.T) {
if err != nil {
t.Fatal(err)
}
text, err := s.RunInProcess(context.Background(), woke, work, func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}}
close(ch)
return ch
})
text, err := s.RunInProcess(context.Background(), woke, work, stubExecutor{result: &agentsquad.ExecResult{
Success: true, Logs: "done",
Patches: map[string][]byte{"output.md": []byte("done")},
}})
if err != nil || text != "done" {
t.Fatalf("in-process %q %v", text, err)
}
+230
View File
@@ -0,0 +1,230 @@
package platform
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"time"
"agent-squad/squad"
)
// SQLiteStore 把 squad 的 Store 端口落到本库(Q1 WP5):run/gate/checkpoint/leader
// 与平台其余表同一个 SQLite 文件——运行状态与平台状态不再各记一份。
// 表名带 squad_run_ 前缀,与平台既有 23 张表并存不冲突。
type SQLiteStore struct {
db *sql.DB
}
// OpenRunStore 在本 Store 的底层连接上提供 squad.Store 端口(schema 随 migrate 建)。
func (s *Store) RunStore() *SQLiteStore {
return &SQLiteStore{db: s.db}
}
const runSchema = `
CREATE TABLE IF NOT EXISTS squad_run_state (
ws_dir TEXT PRIMARY KEY,
meta TEXT NOT NULL, workflow TEXT NOT NULL, squad TEXT NOT NULL, bb TEXT NOT NULL,
template TEXT, approved TEXT, repair_budget_remaining INTEGER NOT NULL DEFAULT 0,
repair_budget_granted INTEGER NOT NULL DEFAULT 0,
clarifications TEXT, steering TEXT, usage TEXT, updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS squad_run_gate (
ws_dir TEXT PRIMARY KEY, gate TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS squad_run_checkpoint (
ws_dir TEXT NOT NULL, superstep INTEGER NOT NULL, at TEXT NOT NULL,
graph_hash TEXT NOT NULL, steps TEXT NOT NULL,
PRIMARY KEY (ws_dir, superstep)
);
CREATE TABLE IF NOT EXISTS squad_run_leader (
ws_dir TEXT PRIMARY KEY, data TEXT NOT NULL
);
`
func (s *SQLiteStore) migrate() error {
_, err := s.db.Exec(runSchema)
return err
}
// OpenSQLiteStore 独立打开一个 squad.Store(不经 platform.Store 的场景,如测试)。
func OpenSQLiteStore(path string) (*SQLiteStore, error) {
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
if err != nil {
return nil, err
}
s := &SQLiteStore{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
return nil, err
}
return s, nil
}
func j(v any) string {
b, err := json.Marshal(v)
if err != nil {
return ""
}
return string(b)
}
func (s *SQLiteStore) SaveRun(st *squad.RunState) error {
if err := s.migrate(); err != nil {
return err
}
_, err := s.db.Exec(`INSERT INTO squad_run_state
(ws_dir, meta, workflow, squad, bb, template, approved, repair_budget_remaining, repair_budget_granted, clarifications, steering, usage, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(ws_dir) DO UPDATE SET
meta=excluded.meta, workflow=excluded.workflow, squad=excluded.squad, bb=excluded.bb,
template=excluded.template, approved=excluded.approved,
repair_budget_remaining=excluded.repair_budget_remaining,
repair_budget_granted=excluded.repair_budget_granted,
clarifications=excluded.clarifications, steering=excluded.steering,
usage=excluded.usage, updated_at=excluded.updated_at`,
st.Meta.WorkDir, j(st.Meta), j(st.Plan.Workflow), j(st.Plan.Squad), j(st.BB),
st.Template, j(st.Approved), st.RepairBudgetRemaining, st.RepairBudgetGranted,
j(st.Clarifications), j(st.Steering), j(st.Usage), now())
return err
}
func (s *SQLiteStore) LoadRun(wsDir string) (*squad.RunState, error) {
if err := s.migrate(); err != nil {
return nil, err
}
st := &squad.RunState{}
var meta, wf, sq, bb, template, approved, clar, steering, usage string
row := s.db.QueryRow(`SELECT meta, workflow, squad, bb, template, approved,
repair_budget_remaining, repair_budget_granted, clarifications, steering, usage
FROM squad_run_state WHERE ws_dir = ?`, wsDir)
var granted int
err := row.Scan(&meta, &wf, &sq, &bb, &template, &approved,
&st.RepairBudgetRemaining, &granted, &clar, &steering, &usage)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("load run %s: %w", wsDir, os.ErrNotExist)
}
return nil, err
}
st.RepairBudgetGranted = granted
for pair, dst := range map[string]any{
meta: &st.Meta, wf: &st.Plan.Workflow, sq: &st.Plan.Squad, bb: &st.BB,
approved: &st.Approved, clar: &st.Clarifications, steering: &st.Steering, usage: &st.Usage,
} {
if err := json.Unmarshal([]byte(pair), dst); err != nil {
return nil, fmt.Errorf("load run %s: parse: %w", wsDir, err)
}
}
st.Template = template
if st.Approved == nil {
st.Approved = map[string]bool{}
}
return st, nil
}
func (s *SQLiteStore) SaveGate(wsDir string, gate *squad.HitlGate) error {
if err := s.migrate(); err != nil {
return err
}
if gate == nil {
_, err := s.db.Exec(`DELETE FROM squad_run_gate WHERE ws_dir = ?`, wsDir)
return err
}
_, err := s.db.Exec(`INSERT INTO squad_run_gate (ws_dir, gate) VALUES (?,?)
ON CONFLICT(ws_dir) DO UPDATE SET gate=excluded.gate`, wsDir, j(gate))
return err
}
func (s *SQLiteStore) LoadGate(wsDir string) (*squad.HitlGate, error) {
if err := s.migrate(); err != nil {
return nil, err
}
var raw string
err := s.db.QueryRow(`SELECT gate FROM squad_run_gate WHERE ws_dir = ?`, wsDir).Scan(&raw)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
gate := &squad.HitlGate{}
if err := json.Unmarshal([]byte(raw), gate); err != nil {
return nil, fmt.Errorf("parse gate: %w", err)
}
return gate, nil
}
func (s *SQLiteStore) SaveCheckpoint(wsDir string, cp squad.Checkpoint) error {
if err := s.migrate(); err != nil {
return err
}
_, err := s.db.Exec(`INSERT INTO squad_run_checkpoint (ws_dir, superstep, at, graph_hash, steps)
VALUES (?,?,?,?,?)
ON CONFLICT(ws_dir, superstep) DO UPDATE SET at=excluded.at, graph_hash=excluded.graph_hash, steps=excluded.steps`,
wsDir, cp.Superstep, cp.At.UTC().Format("2006-01-02T15:04:05Z07:00"), cp.GraphHash, j(cp.StepStates))
return err
}
func (s *SQLiteStore) LoadLatestCheckpoint(wsDir string) (*squad.Checkpoint, error) {
if err := s.migrate(); err != nil {
return nil, err
}
var cp squad.Checkpoint
var at, steps string
err := s.db.QueryRow(`SELECT superstep, at, graph_hash, steps FROM squad_run_checkpoint
WHERE ws_dir = ? ORDER BY superstep DESC LIMIT 1`, wsDir).
Scan(&cp.Superstep, &at, &cp.GraphHash, &steps)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
t, _ := parseTime(at)
cp.At = t
if err := json.Unmarshal([]byte(steps), &cp.StepStates); err != nil {
return nil, fmt.Errorf("parse checkpoint steps: %w", err)
}
return &cp, nil
}
func (s *SQLiteStore) SaveLeader(wsDir string, data []byte) error {
if err := s.migrate(); err != nil {
return err
}
_, err := s.db.Exec(`INSERT INTO squad_run_leader (ws_dir, data) VALUES (?,?)
ON CONFLICT(ws_dir) DO UPDATE SET data=excluded.data`, wsDir, string(data))
return err
}
func (s *SQLiteStore) LoadLeader(wsDir string) ([]byte, error) {
if err := s.migrate(); err != nil {
return nil, err
}
var raw string
err := s.db.QueryRow(`SELECT data FROM squad_run_leader WHERE ws_dir = ?`, wsDir).Scan(&raw)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return []byte(raw), nil
}
// Close 只在独立打开(OpenSQLiteStore)时调用;经 platform.Store 打开时由平台统一关闭。
func (s *SQLiteStore) Close() error { return s.db.Close() }
var _ squad.Store = (*SQLiteStore)(nil)
// parseTime 解析检查点时间(RFC3339Nano 与 RFC3339 两态)。
func parseTime(s string) (time.Time, error) {
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
if t, err := time.Parse(layout, s); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("bad time %q", s)
}
+188
View File
@@ -0,0 +1,188 @@
package platform
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"agent-squad/squad"
)
func newTestRunState(wsDir string) *squad.RunState {
return &squad.RunState{
Meta: squad.RunMetadata{
ID: "run_test", Status: "executing", WorkDir: wsDir,
CreatedAt: time.Now(), UpdatedAt: time.Now(),
Metadata: map[string]string{"mode": ""},
},
Plan: squad.SquadPlan{
Workflow: squad.WorkflowPlan{
WorkflowID: "wf1", Status: "in_progress",
Steps: []squad.StepSpec{
{ID: "s1", Role: "researcher", Status: "completed", Attempts: 1, AttemptID: "run_test/s1/1"},
{ID: "s2", Role: "writer", Status: "pending"},
},
},
Squad: squad.SquadConfig{SquadName: "demo"},
},
BB: squad.BlackboardState{Facts: []squad.FactItem{{ID: "f1", Claim: "x"}}},
Template: "tpl",
Approved: map[string]bool{"step:s1": true},
RepairBudgetRemaining: 1,
RepairBudgetGranted: 0,
Clarifications: []string{"c1"},
Steering: []string{"st1"},
Usage: map[string]*squad.Usage{"writer": {PromptTokens: 10, CompletionTokens: 5, Steps: 1}},
}
}
// TestSQLiteStoreRoundTrip:run/gate/checkpoint/leader 全链路(Q1 WP5 端口实现)。
func TestSQLiteStoreRoundTrip(t *testing.T) {
st, err := OpenSQLiteStore(filepath.Join(t.TempDir(), "runs.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
ws := filepath.Join(t.TempDir(), "runs", "run_test")
orig := newTestRunState(ws)
if err := st.SaveRun(orig); err != nil {
t.Fatal(err)
}
got, err := st.LoadRun(ws)
if err != nil {
t.Fatal(err)
}
if got.Meta.ID != "run_test" || got.Plan.Workflow.WorkflowID != "wf1" || got.Template != "tpl" {
t.Fatalf("loaded = %+v", got.Meta)
}
if !got.Approved["step:s1"] || got.RepairBudgetRemaining != 1 || len(got.Clarifications) != 1 || len(got.Steering) != 1 {
t.Fatalf("scalars = %+v", got)
}
if got.Usage["writer"].PromptTokens != 10 {
t.Fatalf("usage = %+v", got.Usage)
}
if _, err := st.LoadRun(filepath.Join(t.TempDir(), "nope")); err == nil {
t.Fatal("missing run should error")
}
// gate:写→读→删。
gate := &squad.HitlGate{PrevStatus: squad.RunExecuting}
if err := st.SaveGate(ws, gate); err != nil {
t.Fatal(err)
}
if g2, err := st.LoadGate(ws); err != nil || g2 == nil {
t.Fatalf("load gate: %v %+v", err, g2)
}
if err := st.SaveGate(ws, nil); err != nil {
t.Fatal(err)
}
if g2, err := st.LoadGate(ws); err != nil || g2 != nil {
t.Fatalf("gate after delete: %v %+v", err, g2)
}
// checkpoint:两层,latest 取最大 superstep。
if err := st.SaveCheckpoint(ws, squad.Checkpoint{Superstep: 1, At: time.Now(), GraphHash: "h1", StepStates: []squad.StepSnapshot{{ID: "s1", Status: "completed", AttemptID: "run_test/s1/1"}}}); err != nil {
t.Fatal(err)
}
if err := st.SaveCheckpoint(ws, squad.Checkpoint{Superstep: 2, At: time.Now(), GraphHash: "h2"}); err != nil {
t.Fatal(err)
}
latest, err := st.LoadLatestCheckpoint(ws)
if err != nil || latest == nil {
t.Fatalf("latest: %v", err)
}
if latest.Superstep != 2 || latest.GraphHash != "h2" {
t.Fatalf("latest = %+v", latest)
}
if l2, err := st.LoadLatestCheckpoint(filepath.Join(t.TempDir(), "nope")); err != nil || l2 != nil {
t.Fatalf("latest for unknown: %v %+v", err, l2)
}
// leader bytes。
if err := st.SaveLeader(ws, []byte(`{"turns":2}`)); err != nil {
t.Fatal(err)
}
data, err := st.LoadLeader(ws)
if err != nil || string(data) != `{"turns":2}` {
t.Fatalf("leader = %q %v", data, err)
}
}
// TestEngineRunsOnSQLiteStore:引擎注入 SQLiteStore 后运行状态进 SQLite、
// 不再写 run 级 JSON 文件;第二引擎可从中恢复(验收第三条的实证形态)。
func TestEngineRunsOnSQLiteStore(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "runs.db")
runStore, err := OpenSQLiteStore(dbPath)
if err != nil {
t.Fatal(err)
}
defer runStore.Close()
dir := t.TempDir()
reg := squad.NewRoleRegistry()
for _, spec := range []squad.RoleSpec{{Role: "researcher", Backend: "script"}, {Role: "writer", Backend: "script"}, {Role: "reviewer", Backend: "script"}} {
if err := reg.Register(spec); err != nil {
t.Fatal(err)
}
}
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("{}")}}})
blueprints := squad.NewBlueprintRegistry()
if err := blueprints.Register(squad.Blueprint{
Key: "demo", Title: "入库演示", Match: []string{"入库演示"},
Steps: []squad.StepSpec{
{ID: "s1", Role: "researcher", Title: "调研", Targets: []string{"notes.md", "fields.json"}},
{ID: "s2", Role: "writer", Title: "成文", Targets: []string{"output.md"}},
{ID: "s3", Role: "reviewer", 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 || res.Status != squad.RunCompleted {
t.Fatalf("run: %v %+v", err, res)
}
// run 级 JSON 不在盘上(状态全进了 SQLite)。
for _, f := range []string{"workspace.json", "workflow.json", "squad.json", "blackboard.json", "runstate.json"} {
if _, err := os.Stat(filepath.Join(res.WorkDir, f)); !os.IsNotExist(err) {
t.Fatalf("%s should not be on disk with SQLite store (err=%v)", f, err)
}
}
// 状态可经端口读回。
st, err := runStore.LoadRun(res.WorkDir)
if err != nil {
t.Fatal(err)
}
if st.Plan.Workflow.Status != "completed" {
t.Fatalf("workflow status = %s", st.Plan.Workflow.Status)
}
latest, err := runStore.LoadLatestCheckpoint(res.WorkDir)
if err != nil || latest == nil {
t.Fatalf("latest checkpoint: %v", err)
}
// 第二引擎(同库)恢复可用。
eng2, 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)
}
if _, err := eng2.Inspect(res.RunID); err != nil {
t.Fatalf("inspect from second engine: %v", err)
}
}
const goodReportLite = "# 测试交付物\n\n## 概述\n\n这是由脚本后端生成的合格交付物,长度超过五十个字符以满足最小质检要求。\n"
+35 -18
View File
@@ -9,8 +9,7 @@ import (
"path/filepath"
"strings"
"agent-native/runtime"
"agent-native/runtime/llm"
"agent-squad/squad"
)
// CLICommands are the external tools a daemon probes, in order.
@@ -196,36 +195,54 @@ func (s *Store) RunCommand(ctx context.Context, task Task, command, workdir stri
return s.FinishTask(task.ID, "completed", "")
}
// RunInProcess executes the task with AgentSession. stream is required; the
// product supplies a provider, tests supply a script.
func (s *Store) RunInProcess(ctx context.Context, task Task, workdir string, stream llm.StreamFunc) (string, error) {
// RunInProcess executes the task through the squad.Executor port(Q1 WP5 修 X6:
// 不再绕过执行端口直驱 agent-native runtime)。调用方经 adapters/native 等
// 适配器构造 Executor;nil 即 fail-closed。
func (s *Store) RunInProcess(ctx context.Context, task Task, workdir string, exec squad.Executor) (string, error) {
if exec == nil {
return "", fmt.Errorf("RunInProcess requires an executor (no bypass)")
}
if err := s.MarkRunning(task.ID); err != nil {
return "", err
}
issue, _ := s.GetIssue(task.IssueID)
agent := runtime.NewAgentSession(runtime.Config{
Model: "agent-native",
Stream: stream,
MaxTurns: 4,
OnEvent: func(ev runtime.Event) {
res, err := exec.Execute(ctx, squad.ExecRequest{
WorkDir: workdir,
Prompt: issue.Title + "\n" + issue.Body,
Targets: []string{"output.md"},
OnEvent: func(ev squad.ProcessEvent) {
_ = s.AddTaskMessage(task.ID, ev.Type, ev.Text)
},
})
res, err := agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: issue.Title + "\n" + issue.Body})
if err != nil {
_ = s.FinishTask(task.ID, "failed", "config")
return "", err
}
if res.Err != nil {
_ = s.FinishTask(task.ID, "failed", "model")
return res.Text, res.Err
if res == nil || !res.Success {
reason := "model"
if res != nil && res.ErrorReason != "" {
reason = res.ErrorReason
}
_ = s.FinishTask(task.ID, "failed", reason)
if res != nil {
return res.Logs, fmt.Errorf("executor failed: %s", res.ErrorReason)
}
return "", fmt.Errorf("executor failed")
}
_ = s.AddUsage(task.ID, "agent-native", res.Usage.PromptTokens, res.Usage.CompletionTokens, res.Usage.CacheReadTokens, res.Usage.CacheWriteTokens)
if len(res.Patches) > 0 {
_, _ = squad.MaterializeTargets(workdir, []string{"output.md"}, res.Patches)
}
_ = s.AddUsage(task.ID, exec.Name(), res.PromptTokens, res.CompletionTokens, 0, 0)
if err := s.FinishTask(task.ID, "completed", ""); err != nil {
return res.Text, err
return res.Logs, err
}
_ = os.WriteFile(filepath.Join(workdir, "output.md"), []byte(res.Text), 0o644)
return res.Text, nil
if data, rerr := os.ReadFile(filepath.Join(workdir, "output.md")); rerr == nil {
return string(data), nil
}
if err := os.WriteFile(filepath.Join(workdir, "output.md"), []byte(res.Logs), 0o644); err != nil {
return res.Logs, err
}
return res.Logs, nil
}
// AgentCLI applies a task-token command: comment, status, sub-issue, or activity.
+30
View File
@@ -0,0 +1,30 @@
package platform
import (
"context"
"agent-squad/squad"
)
// stubExecutor 是测试用的 squad.Executor(X6 后调用方经端口注入)。
type stubExecutor struct {
result *squad.ExecResult
err error
}
func (s stubExecutor) Name() string { return "stub" }
func (s stubExecutor) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
if s.result == nil {
return nil, s.err
}
// 每次调用返回拷贝:调用方会改写 Success 字段,共享指针会跨步骤串味。
cp := *s.result
if s.result.Patches != nil {
cp.Patches = map[string][]byte{}
for k, v := range s.result.Patches {
cp.Patches[k] = v
}
}
return &cp, s.err
}
+13 -7
View File
@@ -86,6 +86,7 @@ type app struct {
files *store.FileEventSink
st *store.FileStore
log *logsys.Logger
plat *platform.Store
}
func dataDir() string {
@@ -159,12 +160,20 @@ func assemble(dir, plannerName, modelOverride, intentRoot string) (*app, error)
}
}
}
// 单库收编(Q1 WP5):引擎运行状态与平台编排状态进同一个 SQLite。
// 库打不开即失败(与 serve 现状一致),不允许静默退回文件态。
plat, err := platform.Open(filepath.Join(dir, "orchestration.db"))
if err != nil {
return nil, fmt.Errorf("编排库打开失败: %w", err)
}
engine, err := squad.NewEngine(squad.Options{
DataDir: dir, Registry: reg, Blueprints: bps, Planner: planner,
Backends: backends, Sink: fileSink, IntentGate: gate,
IntentCatalog: catalog, IntentRunner: runner,
Store: plat.RunStore(),
})
if err != nil {
_ = plat.Close()
return nil, err
}
logCfg, err := config.Load("")
@@ -178,7 +187,7 @@ func assemble(dir, plannerName, modelOverride, intentRoot string) (*app, error)
if err != nil {
return nil, err
}
return &app{engine: engine, sink: fanout, files: fileSink, st: st, log: logger}, nil
return &app{engine: engine, sink: fanout, files: fileSink, st: st, log: logger, plat: plat}, nil
}
func (a *app) close() {
@@ -223,12 +232,9 @@ func cmdServe(args []string) int {
srv := server.New(a.engine, a.sink, a.st)
srv.SetToken(*token)
srv.SetLogger(a.log)
if plat, err := platform.Open(filepath.Join(*data, "orchestration.db")); err != nil {
fmt.Fprintln(os.Stderr, "编排库打开失败:", err)
return 1
} else {
srv.SetPlatform(plat)
defer plat.Close()
if a.plat != nil {
srv.SetPlatform(a.plat)
defer a.plat.Close()
}
stopSweep := srv.StartWakeupSweeper(15 * time.Second)
defer stopSweep()
+12 -7
View File
@@ -9,8 +9,8 @@ import (
"path/filepath"
"time"
"agent-native/runtime/llm"
platform "agent-squad/adapters/sqlite"
"agent-squad/squad"
)
func cmdPlatform(args []string) int {
@@ -99,7 +99,7 @@ func cmdPlatform(args []string) int {
return 1
}
}
text, err := st.RunInProcess(context.Background(), task, fs.Arg(1), scriptedOK)
text, err := st.RunInProcess(context.Background(), task, fs.Arg(1), scriptedExecutor{})
return printPlatform(map[string]string{"text": text}, err)
default:
fmt.Fprintln(os.Stderr, "未知编排命令", args[0], args[1])
@@ -111,11 +111,16 @@ func taskByID(st *platform.Store, id string) (platform.Task, error) {
return st.TaskByToken(id)
}
func scriptedOK(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
close(ch)
return ch
// scriptedExecutor 是 daemon run-native 冒烟路径的脚本执行器(X6 后经端口注入)。
type scriptedExecutor struct{}
func (scriptedExecutor) Name() string { return "scripted" }
func (scriptedExecutor) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
return &squad.ExecResult{
Success: true, Logs: "ok",
Patches: map[string][]byte{"output.md": []byte("ok")},
}, nil
}
func printPlatform(v any, err error) int {
+2
View File
@@ -462,6 +462,8 @@ func (s *Server) StartWakeupSweeper(interval time.Duration) (stop func()) {
return
case <-t.C:
s.engine.SweepWakeups(context.Background())
// Q6:与事件唤醒并列,过期挂起按 ExpireAction 合成决策(此前是死代码)。
s.engine.SweepExpired(context.Background(), time.Now())
}
}
}()
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 "$@"
+173
View File
@@ -0,0 +1,173 @@
package squad
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
)
// perStepRecordingBackend 按子工作区归因计数(steps 的 role→tasks/NN_role 布局)。
type perStepRecordingBackend struct {
mu sync.Mutex
calls map[string]int
}
func (b *perStepRecordingBackend) Name() string { return "recording" }
func (b *perStepRecordingBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
b.mu.Lock()
if b.calls == nil {
b.calls = map[string]int{}
}
b.calls[filepath.Base(req.WorkDir)]++
b.mu.Unlock()
out := map[string][]byte{}
for _, t := range req.Targets {
out[t] = []byte(goodReport)
}
if _, err := MaterializeTargets(req.WorkDir, req.Targets, out); err != nil {
return &ExecResult{Success: false, ErrorReason: err.Error()}, nil
}
return &ExecResult{Success: true, Logs: "ok"}, nil
}
func (b *perStepRecordingBackend) countOf(sub string) int {
b.mu.Lock()
defer b.mu.Unlock()
return b.calls[sub]
}
func newAttemptTestEngine(t *testing.T, backend *perStepRecordingBackend, dir string) *SquadEngine {
t.Helper()
reg := NewRoleRegistry()
for _, role := range []string{"researcher", "writer", "reviewer"} {
if err := reg.Register(RoleSpec{Role: role, Backend: "recording"}); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance("recording", backend)
eng, err := NewEngine(Options{
DataDir: dir, Registry: reg, Planner: mockScriptPlanner(baseSteps()...),
Backends: backends, Sink: NopSink{},
})
if err != nil {
t.Fatal(err)
}
return eng
}
func flipToInterrupted(t *testing.T, workDir string, stepIdx int) {
t.Helper()
st, err := loadRunState(workDir)
if err != nil {
t.Fatal(err)
}
st.Meta.Status = string(RunExecuting)
st.Meta.CompletedAt = nil
wf := st.Workflow()
wf.Status = "running"
wf.Steps[stepIdx].Status = "running"
wf.Steps[stepIdx].Attempts = 1
st.setWorkflow(wf)
if err := st.save(); err != nil {
t.Fatal(err)
}
}
// TestAttemptIDFormat:attempt id 稳定落盘且形如 runID/stepID/N(Q1 WP3)。
func TestAttemptIDFormat(t *testing.T) {
backend := &perStepRecordingBackend{}
dir := t.TempDir()
eng := newAttemptTestEngine(t, backend, dir)
res, err := eng.Run(context.Background(), Goal{Text: "格式"}, RunOptions{})
if err != nil || res.Status != RunCompleted {
t.Fatalf("run: %v %+v", err, res)
}
if res.Plan == nil {
t.Fatal("no plan")
}
for _, s := range res.Plan.Steps {
want := res.RunID + "/" + s.ID + "/1"
if s.AttemptID != want {
t.Fatalf("step %s attempt id = %q, want %q", s.ID, s.AttemptID, want)
}
}
}
// TestKilledAtSuperstepBoundarySideEffectsOnce:杀进程于 superstep 边界后恢复,
// 已完成步骤不重新执行(执行次数=1);running 且产物齐全的步骤按 attempt 判定
// 完成、不 rerun——Q1 验收第一条(WP3)。
func TestKilledAtSuperstepBoundarySideEffectsOnce(t *testing.T) {
backend := &perStepRecordingBackend{}
dir := t.TempDir()
eng := newAttemptTestEngine(t, backend, dir)
res, err := eng.Run(context.Background(), Goal{Text: "先跑完再中断", HitlMode: HitlAuto}, RunOptions{})
if err != nil || res.Status != RunCompleted {
t.Fatalf("setup run: %v %+v", err, res)
}
before := map[string]int{}
st, err := loadRunState(res.WorkDir)
if err != nil {
t.Fatal(err)
}
for _, s := range st.Plan.Workflow.Steps {
before[s.ID] = backend.countOf(filepath.Base(filepath.FromSlash(s.SubWorkspace)))
}
// 第二步停在 running(产物其实在盘上——首次运行真实写出的)。
flipToInterrupted(t, res.WorkDir, 1)
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)
}
for _, s := range res.Plan.Steps {
if s.Status != "completed" && s.Status != "skipped" {
t.Fatalf("step %s not finished: %s", s.ID, s.Status)
}
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])
}
}
}
// TestInterruptedMissingProductsRetries:running 崩溃且产物缺失时按 auto 一次重试(WP3)。
func TestInterruptedMissingProductsRetries(t *testing.T) {
backend := &perStepRecordingBackend{}
dir := t.TempDir()
eng := newAttemptTestEngine(t, backend, dir)
res, err := eng.Run(context.Background(), Goal{Text: "先跑完再中断", HitlMode: HitlAuto}, RunOptions{})
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 {
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)
}
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)
}
got := backend.countOf(filepath.Base(sub))
if got != 2 {
t.Fatalf("missing-product step executed %d times, want 2(auto 一次重试)", got)
}
}
+41
View File
@@ -0,0 +1,41 @@
package squad
import (
"context"
"strings"
"testing"
)
// brief_test.go — Q3 WP1a:StepSpec.Brief 渲染进 TASK.md「任务指令」节。
func TestStepBriefRendersIntoTaskFile(t *testing.T) {
rec := &promptRecordingBackend{prompts: map[string][]string{}}
engine := newRecordingEngine(t, rec, []StepSpec{
{ID: "s1", Role: "researcher", Title: "起草", Targets: []string{"output.md"},
Brief: "写一份关于「太空电梯」的简报,篇幅:短。\n必须包含单独一行「核对标记:P4」。"},
})
res, err := engine.Run(context.Background(), Goal{Text: "带任务指令"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
var prompt string
for _, p := range rec.prompts["01_researcher"] {
prompt += p
}
if !strings.Contains(prompt, "## 任务指令") {
t.Fatalf("TASK.md 缺任务指令节:%q", prompt)
}
if !strings.Contains(prompt, "核对标记:P4") {
t.Fatalf("任务指令正文缺失:%q", prompt)
}
// 指令节在期望产出之后、上下文表之前。
iOut := strings.Index(prompt, "期望产出")
iBrief := strings.Index(prompt, "## 任务指令")
iCtx := strings.Index(prompt, "## 任务上下文资源")
if !(iOut < iBrief && iBrief < iCtx) {
t.Fatalf("任务指令节位置错误:%d/%d/%d", iOut, iBrief, iCtx)
}
}
+113
View File
@@ -0,0 +1,113 @@
package squad
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// TestCheckpointFilesAtBoundaries:sync 默认档——每个 superstep 边界有快照,
// latest 指向最后一层且图指纹与重编译一致(Q1 WP4)。
func TestCheckpointFilesAtBoundaries(t *testing.T) {
eng, _ := newTestEngine(t, mockScriptPlanner(baseSteps()...), map[string]map[string]string{
"01_researcher": {"notes.md": goodReport, "fields.json": "{}"},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}, NopSink{})
res, err := eng.Run(context.Background(), Goal{Text: "快照"}, RunOptions{})
if err != nil || res.Status != RunCompleted {
t.Fatalf("run: %v %+v", err, res)
}
latest, err := FileStore{}.LoadLatestCheckpoint(res.WorkDir)
if err != nil || latest == nil {
t.Fatalf("latest checkpoint: %v", err)
}
if latest.Superstep < 3 {
t.Fatalf("superstep = %d, want >= 3(三步各占一层)", latest.Superstep)
}
g, err := compileGraph(res.Plan.Steps)
if err != nil {
t.Fatal(err)
}
if latest.GraphHash != g.hash() {
t.Fatalf("hash = %s, want %s", latest.GraphHash, g.hash())
}
if len(latest.StepStates) != 3 {
t.Fatalf("step states = %d", len(latest.StepStates))
}
if _, err := os.Stat(filepath.Join(res.WorkDir, "checkpoints", "001.json")); err != nil {
t.Fatalf("first boundary snapshot missing: %v", err)
}
}
// TestCheckpointAsyncAndOnExit:async 在退出前落完;on_exit 只有最终快照。
func TestCheckpointAsyncAndOnExit(t *testing.T) {
for _, mode := range []string{"async", "on_exit"} {
eng, _ := newTestEngine(t, mockScriptPlanner(baseSteps()...), map[string]map[string]string{
"01_researcher": {"notes.md": goodReport, "fields.json": "{}"},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}, NopSink{})
res, err := eng.Run(context.Background(), Goal{Text: "快照"}, RunOptions{CheckpointMode: mode})
if err != nil || res.Status != RunCompleted {
t.Fatalf("%s run: %v %+v", mode, err, res)
}
latest, err := FileStore{}.LoadLatestCheckpoint(res.WorkDir)
if err != nil || latest == nil {
t.Fatalf("%s latest: %v", mode, err)
}
if latest.Superstep < 3 {
t.Fatalf("%s superstep = %d", mode, latest.Superstep)
}
}
}
// TestGraphContinuityRejectsStructuralChange:崩溃期间图形态被改则拒绝恢复(WP1 裁定)。
func TestGraphContinuityRejectsStructuralChange(t *testing.T) {
eng, _ := newTestEngine(t, mockScriptPlanner(baseSteps()...), map[string]map[string]string{
"01_researcher": {"notes.md": goodReport, "fields.json": "{}"},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}, NopSink{})
dir := eng.opts.DataDir
res, err := eng.Run(context.Background(), Goal{Text: "先跑完再中断", HitlMode: HitlAuto}, RunOptions{})
if err != nil || res.Status != RunCompleted {
t.Fatalf("setup run: %v %+v", err, res)
}
// 翻回 running(仅状态变化,图结构不变——应先过连续性校验)。
st, err := loadRunState(res.WorkDir)
if err != nil {
t.Fatal(err)
}
st.Meta.Status = string(RunExecuting)
st.Meta.CompletedAt = nil
wf := st.Workflow()
wf.Status = "running"
wf.Steps[1].Status = "running"
wf.Steps[1].Attempts = 1
// 再改结构:把 s3 的 needs 改成另一个合法 id——编译能过但指纹必变。
wf.Steps[2].Needs = []string{"s1"}
st.setWorkflow(wf)
if err := st.save(); err != nil {
t.Fatal(err)
}
// 用一个新的引擎在同一目录上恢复(图编译本身合法:needs 引用存在的 id)。
reg := NewRoleRegistry()
for _, role := range []string{"researcher", "writer", "reviewer"} {
if err := reg.Register(RoleSpec{Role: role, Backend: "script"}); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance("script", &scriptBackend{script: map[string]map[string]string{}})
eng2, err := NewEngine(Options{DataDir: dir, Registry: reg, Planner: eng.opts.Planner, Backends: backends, Sink: NopSink{}})
if err != nil {
t.Fatal(err)
}
_, err = eng2.Resume(context.Background(), res.RunID)
if err == nil || !strings.Contains(err.Error(), "图形态不一致") && !strings.Contains(err.Error(), "graph continuity") {
t.Fatalf("expected continuity rejection, got %v", err)
}
}
+238 -34
View File
@@ -35,6 +35,9 @@ type Options struct {
HitlPolicy HitlPolicy
// RepairBudget 质检失败自动修复次数(默认 1)。
RepairBudget int
// Store 运行状态持久化端口(Q1 WP4/WP5);nil 回落 FileStore。
// 注入 SQLiteStore 后运行状态进 SQLite(装配期一次,跨 run 一致)。
Store Store
// IntentGate 可选意图闸门:Run 入口一次确定性裁决(nil = 不启用,
// 行为与原实现一致)。见 intent.go;实现方为 squadintent 适配器。
IntentGate IntentGate
@@ -60,6 +63,31 @@ type engineDeps struct {
StepTimeout time.Duration
HitlPolicy HitlPolicy
IntentRunner IntentRunner
// Store 运行状态持久化端口(Q1 WP4);nil 回落 FileStore(装配期注入替换实现)。
Store Store
}
// storeOr 返回注入的 Store,缺省 FileStore。
func (d engineDeps) storeOr() Store {
if d.Store != nil {
return d.Store
}
return FileStore{}
}
// loadRun 经端口加载运行状态,并让它携带同一端口(后续 save 自然走同一实现)。
// runDir 是运行的工作区目录(所有 run 级 JSON 的根)。
func (e *SquadEngine) runDir(runID string) string {
return filepath.Join(e.opts.DataDir, "runs", runID)
}
func (e *SquadEngine) loadRun(wsDir string) (*RunState, error) {
st, err := e.deps.storeOr().LoadRun(wsDir)
if err != nil {
return nil, err
}
st.store = e.deps.storeOr()
return st, nil
}
// RunOptions 单次运行的可选项。
@@ -75,6 +103,30 @@ type RunOptions struct {
LeaderRole string `json:"leader_role,omitempty"`
// MaxLeaderTurns leader 模式每运行的 leader 轮次上限(默认 8,防失控)。
MaxLeaderTurns int `json:"max_leader_turns,omitempty"`
// CheckpointMode superstep 边界快照档位(Q1 WP4):sync(默认)| async | on_exit。
CheckpointMode string `json:"checkpoint_mode,omitempty"`
// Topology 显式拓扑名(Q2;优先于 Blueprint.Topology;空 = standard 图)。
// 未注册名 / primitive 单跑 / driver 未点亮均 fail-closed。
Topology string `json:"topology,omitempty"`
// Deadline 运行级截止时刻(Q2 long_running):superstep 边界检查,
// 超期把未完步骤置 skipped 并降级收口。零值 = 不设限。
Deadline time.Time `json:"deadline,omitempty"`
// MaxReplans leader 模式的重规划上限(Q2;与 MaxLeaderTurns 独立)。
// 0 = 默认 DefaultMaxReplans。
MaxReplans int `json:"max_replans,omitempty"`
// GroupChatPolicy group_chat 拓扑发言人策略(Q2):rotate(默认)| moderator。
GroupChatPolicy string `json:"group_chat_policy,omitempty"`
// GroupChatModerator 主持人角色 key(moderator 策略必填,须在名册内)。
GroupChatModerator string `json:"group_chat_moderator,omitempty"`
// GroupChatMaxTurns 群聊轮次预算(0 = DefaultMaxGroupChatTurns)。
GroupChatMaxTurns int `json:"group_chat_max_turns,omitempty"`
// ReviewVerifyRole review 拓扑核实角色 key(默认 "verifier")。
ReviewVerifyRole string `json:"review_verify_role,omitempty"`
// ReviewTarget 被评审材料(默认取 context/goal.md)。
ReviewTarget string `json:"review_target,omitempty"`
// TaskBoardLeaseSeconds task_board 任务租约时限(秒;0 = 默认 600)。
// 孤儿租约(claimed 且过期)在驱动重入时再武装回 open。
TaskBoardLeaseSeconds int `json:"task_board_lease_seconds,omitempty"`
}
// SquadEngine 小队引擎门面:Run / Respond / Resume / Inspect / ListRuns。
@@ -131,7 +183,7 @@ func NewEngine(opts Options) (*SquadEngine, error) {
Registry: opts.Registry, Backends: opts.Backends, Evaluator: opts.Evaluator,
Checker: opts.Checker, Sink: opts.Sink, Workspaces: ws,
StepTimeout: opts.StepTimeout, HitlPolicy: opts.HitlPolicy,
IntentRunner: opts.IntentRunner,
IntentRunner: opts.IntentRunner, Store: opts.Store,
}
return &SquadEngine{opts: opts, deps: deps, ws: ws, runLocks: map[string]*sync.Mutex{}}, nil
}
@@ -206,6 +258,42 @@ func (e *SquadEngine) Run(ctx context.Context, goal Goal, runOpts RunOptions) (*
meta.Metadata["max_leader_turns"] = fmt.Sprintf("%d", runOpts.MaxLeaderTurns)
}
}
if runOpts.CheckpointMode != "" {
meta.Metadata["checkpoint_mode"] = runOpts.CheckpointMode
}
if runOpts.Topology != "" {
meta.Metadata["topology"] = runOpts.Topology
// leader 拓扑映射到委派循环须发生在计划确认之前(确认门按
// mode=leader 豁免);后置映射会让 leader 运行误触计划确认。
if runOpts.Topology == TopologyLeader && meta.Metadata["mode"] == "" {
meta.Metadata["mode"] = "leader"
}
}
if !runOpts.Deadline.IsZero() {
// RFC3339Nano:秒级 RFC3339 会截断亚秒精度,短截止会被立刻判超期。
meta.Metadata["deadline"] = runOpts.Deadline.UTC().Format(time.RFC3339Nano)
}
if runOpts.MaxReplans > 0 {
meta.Metadata["max_replans"] = fmt.Sprintf("%d", runOpts.MaxReplans)
}
if runOpts.GroupChatPolicy != "" {
meta.Metadata["group_chat_policy"] = runOpts.GroupChatPolicy
}
if runOpts.GroupChatModerator != "" {
meta.Metadata["group_chat_moderator"] = runOpts.GroupChatModerator
}
if runOpts.GroupChatMaxTurns > 0 {
meta.Metadata["group_chat_max_turns"] = fmt.Sprintf("%d", runOpts.GroupChatMaxTurns)
}
if runOpts.ReviewVerifyRole != "" {
meta.Metadata["review_verify_role"] = runOpts.ReviewVerifyRole
}
if runOpts.ReviewTarget != "" {
meta.Metadata["review_target"] = runOpts.ReviewTarget
}
if runOpts.TaskBoardLeaseSeconds > 0 {
meta.Metadata["task_board_lease_seconds"] = fmt.Sprintf("%d", runOpts.TaskBoardLeaseSeconds)
}
e.deps.Sink.Emit(Event{Type: EvRunStarted, RunID: runID, Text: goal.Text, At: time.Now()})
// 意图闸门(可选,入口一次裁决;显式 TaskKey 优先,不重复过闸)。
@@ -239,12 +327,12 @@ func (e *SquadEngine) Run(ctx context.Context, goal Goal, runOpts RunOptions) (*
}
}
st := &runState{Meta: meta, Approved: map[string]bool{}, RepairBudgetRemaining: e.opts.RepairBudget}
st := &RunState{Meta: meta, Approved: map[string]bool{}, RepairBudgetRemaining: e.opts.RepairBudget, store: e.deps.storeOr()}
return e.planAndExecute(ctx, st, runOpts)
}
// planAndExecute 规划 → 挂起或执行;st 可为已有运行状态(澄清续跑复用)。
func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts RunOptions) (*RunResult, error) {
func (e *SquadEngine) planAndExecute(ctx context.Context, st *RunState, runOpts RunOptions) (*RunResult, error) {
drv := &flowDriver{opts: e.deps}
meta := &st.Meta
@@ -266,6 +354,10 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
planOut = &PlanOutput{Plan: plan}
} else {
bpMatch, _ := e.opts.Blueprints.Match(meta.Goal.Text, runOpts.TaskKey)
// 拓扑解析(Q2):RunOptions 优先,Blueprint 兜底。
if meta.Metadata["topology"] == "" && bpMatch.Topology != "" {
meta.Metadata["topology"] = bpMatch.Topology
}
planOut, err = e.opts.Planner.Plan(ctx, PlanInput{
Goal: meta.Goal, Blueprint: bpPtr(bpMatch), Registry: e.opts.Registry, RunID: meta.ID,
IntentCatalog: e.opts.IntentCatalog,
@@ -299,7 +391,7 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
Context: fmt.Sprintf("原因:%s\n问题:\n%s", planOut.Clarification.Reason, strings.Join(qs, "\n")),
CreatedAt: time.Now(), Status: "pending",
}
gate := &hitlGate{Request: *req, PrevStatus: RunPlanning}
gate := &HitlGate{Request: *req, PrevStatus: RunPlanning}
// 澄清挂起也需要工作区承载 pending.json。
if err := e.materializeWorkspace(meta, st, &SquadPlan{Workflow: WorkflowPlan{WorkflowID: NewPlanID(), Status: "pending", Steps: nil}}); err != nil {
return nil, err
@@ -339,7 +431,7 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
CreatedAt: time.Now(), Status: "pending",
Reason: "plan",
}
gate := &hitlGate{Request: *req, Plan: planOut.Plan, PrevStatus: RunPlanning}
gate := &HitlGate{Request: *req, Plan: planOut.Plan, PrevStatus: RunPlanning}
if err := st.saveGate(gate); err != nil {
return nil, err
}
@@ -352,6 +444,20 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
if err := e.materializeWorkspace(meta, st, planOut.Plan); err != nil {
return nil, err
}
// 拓扑校验与分发(Q2):未注册名 / primitive 单跑 / driver 未点亮 fail-closed;
// leader 拓扑映射到既有 Mode 委派循环(不新造执行面)。
if topo := meta.Metadata["topology"]; topo != "" {
kind, terr := checkTopology(topo)
if terr != nil {
return e.failNew(meta, terr)
}
if kind == TopologyMode && topo == TopologyLeader {
if meta.Metadata["mode"] == "" {
meta.Metadata["mode"] = "leader"
}
}
e.deps.Sink.Emit(Event{Type: EvPhase, RunID: meta.ID, Text: "拓扑 " + topo, At: time.Now()})
}
// leader-delegation 模式:忽略规划步骤,进 leader 委派循环。
if meta.Metadata["mode"] == "leader" {
e.initLeaderState(st)
@@ -364,6 +470,15 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
}
return out, nil
}
// driver 类拓扑(Q2 WP3+):按名分发专用驱动(review/group_chat;
// task_board 属 WP4,未点亮前 checkTopology 已 fail-closed)。
if out, handled, derr := e.dispatchTopologyDriver(ctx, st); handled {
if derr != nil {
return nil, derr
}
e.finalizeEvent(out)
return out, nil
}
out, _, err := drv.advance(ctx, st)
if err != nil {
return nil, err
@@ -373,7 +488,7 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
}
// initLeaderState 按运行选项初始化 leader 循环状态。
func (e *SquadEngine) initLeaderState(st *runState) {
func (e *SquadEngine) initLeaderState(st *RunState) {
leaderRole := st.Meta.Metadata["leader_role"]
if leaderRole == "" {
if _, ok := findAgent(st.Plan.Squad.Agents, "leader"); ok {
@@ -386,7 +501,13 @@ func (e *SquadEngine) initLeaderState(st *runState) {
maxTurns = n
}
}
st.Leader = &LeaderState{LeaderRole: leaderRole, MaxTurns: maxTurns}
maxReplans := DefaultMaxReplans
if v := st.Meta.Metadata["max_replans"]; v != "" {
if n := parseIntDefault(v, DefaultMaxReplans); n > 0 {
maxReplans = n
}
}
st.Leader = &LeaderState{LeaderRole: leaderRole, MaxTurns: maxTurns, MaxReplans: maxReplans}
st.saveLeader()
}
@@ -402,12 +523,12 @@ func parseIntDefault(s string, def int) int {
}
// isLeaderRun 该运行是否处于 leader-delegation 模式。
func (e *SquadEngine) isLeaderRun(st *runState) bool {
func (e *SquadEngine) isLeaderRun(st *RunState) bool {
return st.Meta.Metadata["mode"] == "leader"
}
// resumeAfterHold 挂起解除后的统一续跑入口(按模式分发)。
func (e *SquadEngine) resumeAfterHold(ctx context.Context, st *runState) (*RunResult, error) {
func (e *SquadEngine) resumeAfterHold(ctx context.Context, st *RunState) (*RunResult, error) {
if e.isLeaderRun(st) {
out, gate, err := e.runLeaderLoop(ctx, st)
if err != nil {
@@ -418,6 +539,15 @@ func (e *SquadEngine) resumeAfterHold(ctx context.Context, st *runState) (*RunRe
}
return out, nil
}
// driver 类拓扑续跑:驱动自身幂等(review 已完成 finder 不重跑;
// group_chat 按已落盘轮次续转)。
if out, handled, err := e.dispatchTopologyDriver(ctx, st); handled {
if err != nil {
return nil, err
}
e.finalizeEvent(out)
return out, nil
}
drv := &flowDriver{opts: e.deps}
out, _, err := drv.advance(ctx, st)
if err != nil {
@@ -427,17 +557,43 @@ func (e *SquadEngine) resumeAfterHold(ctx context.Context, st *runState) (*RunRe
return out, nil
}
// dispatchTopologyDriver 按运行拓扑分发 driver 类拓扑。
// handled=false 表示非 driver 拓扑(走既有图/模式路径)。
func (e *SquadEngine) dispatchTopologyDriver(ctx context.Context, st *RunState) (*RunResult, bool, error) {
topo := st.Meta.Metadata["topology"]
if topo == "" {
return nil, false, nil
}
spec, ok := LookupTopology(topo)
if !ok || spec.Kind != TopologyDriver {
return nil, false, nil
}
switch topo {
case TopologyReview:
out, err := e.runReviewDriver(ctx, st)
return out, true, err
case TopologyGroupChat:
out, err := e.runGroupChatDriver(ctx, st)
return out, true, err
case TopologyTaskBoard:
out, err := e.runTaskBoardDriver(ctx, st)
return out, true, err
default:
return e.driverFail(st, fmt.Sprintf("topology: %q 驱动未实现(fail-closed)", topo)), true, nil
}
}
// materializeWorkspace 落盘工作区与子工作区(已存在则只更新计划并重建待执行子目录)。
func (e *SquadEngine) materializeWorkspace(meta *RunMetadata, st *runState, plan *SquadPlan) error {
func (e *SquadEngine) materializeWorkspace(meta *RunMetadata, st *RunState, plan *SquadPlan) error {
if st.Meta.WorkDir == "" {
st.Meta = *meta
wsDir, err := e.ws.PrepareWorkspace(&st.Meta, plan)
_, err := e.ws.PrepareWorkspace(&st.Meta, plan)
if err != nil {
return err
}
st.Template = plan.Template
bb, _ := loadBlackboard(wsDir)
st.BB = *bb
// 新运行的黑板在内存初始化;持久化由后续 st.save() 经 Store 端口完成。
st.BB = BlackboardState{Fields: map[string]string{}}
} else {
// 复用既有工作区:磁盘黑板为权威(澄清续跑/计划更新场景)。
if bb, err := loadBlackboard(st.Meta.WorkDir); err == nil {
@@ -489,7 +645,7 @@ func (e *SquadEngine) Respond(ctx context.Context, runID string, decision Respon
func (e *SquadEngine) respondLocked(ctx context.Context, runID string, decision RespondDecision) (*RunResult, error) {
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
st, err := loadRunState(wsDir)
st, err := e.loadRun(wsDir)
if err != nil {
return nil, fmt.Errorf("load run %s: %w", runID, err)
}
@@ -509,6 +665,17 @@ func (e *SquadEngine) respondLocked(ctx context.Context, runID string, decision
}
req := &gate.Request
// 非 human actor 不得决议 choice 门(Q2 验收三的第二道防线:邮箱/代理
// 消息即便伪造 Respond 调用也清不掉 permission/confirmation/quality 门)。
if decision.Actor != "" && decision.Actor != ActorHuman && choiceHoldKind(req.Kind) &&
strings.TrimSpace(decision.Choice) != "" {
e.deps.Sink.Emit(Event{Type: EvPhase, RunID: runID,
Text: fmt.Sprintf("拒绝非人工 actor(%s) 决议 %s 门(请求 %s)", decision.Actor, req.Kind, req.ID), At: time.Now()})
return e.noteAndKeepHold(st, gate, RespondDecision{
RequestID: decision.RequestID,
Answer: fmt.Sprintf("[blocked] 非人工 actor(%s) 尝试决议本门,已拒绝;等待人工裁决。", decision.Actor),
})
}
if strings.TrimSpace(decision.Choice) == "" && strings.TrimSpace(decision.Answer) != "" && choiceHoldKind(req.Kind) {
return e.noteAndKeepHold(st, gate, decision)
}
@@ -543,7 +710,7 @@ func choiceHoldKind(kind HumanRequestKind) bool {
}
}
func (e *SquadEngine) noteAndKeepHold(st *runState, gate *hitlGate, decision RespondDecision) (*RunResult, error) {
func (e *SquadEngine) noteAndKeepHold(st *RunState, gate *HitlGate, decision RespondDecision) (*RunResult, error) {
if err := st.addSteering(decision.Answer); err != nil {
return nil, err
}
@@ -555,7 +722,7 @@ func (e *SquadEngine) noteAndKeepHold(st *runState, gate *hitlGate, decision Res
return heldResult(st, gate), nil
}
func clearSameGate(st *runState, requestID string) error {
func clearSameGate(st *RunState, requestID string) error {
current, err := loadGate(st.Meta.WorkDir)
if err != nil || current == nil || current.Request.ID != requestID {
return err
@@ -563,7 +730,7 @@ func clearSameGate(st *runState, requestID string) error {
return st.saveGate(nil)
}
func (e *SquadEngine) applyDecision(ctx context.Context, st *runState, gate *hitlGate, decision RespondDecision) (*RunResult, error) {
func (e *SquadEngine) applyDecision(ctx context.Context, st *RunState, gate *HitlGate, decision RespondDecision) (*RunResult, error) {
req := &gate.Request
now := time.Now()
drv := &flowDriver{opts: e.deps}
@@ -694,8 +861,17 @@ func (e *SquadEngine) applyDecision(ctx context.Context, st *runState, gate *hit
case "approve":
return e.manualPass(st, decision)
case "retry":
// 追加一次修复预算并重跑交付步骤。
// 追加一次修复预算并重跑交付步骤。累计追加有上限(Q26),
// 上限内才加——无上限的人工 retry 会把运行变成永动机。
term := e.terminationOf(st)
if st.RepairBudgetGranted >= term.MaxRepairGranted {
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("人工修复追加已达上限 %d,不再追加(Q26)", term.MaxRepairGranted)})
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: fmt.Sprintf("repair budget grants exhausted (%d/%d)", st.RepairBudgetGranted, term.MaxRepairGranted),
WorkDir: st.Meta.WorkDir}, nil
}
st.RepairBudgetRemaining++
st.RepairBudgetGranted++
st.Meta.Status = string(RunExecuting)
_ = st.save()
deliverStepID := lastCompletedStepID(st)
@@ -722,7 +898,7 @@ func (e *SquadEngine) applyDecision(ctx context.Context, st *runState, gate *hit
}
// respondClarification 澄清回应:答案并入目标后重新规划。
func (e *SquadEngine) respondClarification(ctx context.Context, st *runState, gate *hitlGate, decision RespondDecision) (*RunResult, error) {
func (e *SquadEngine) respondClarification(ctx context.Context, st *RunState, gate *HitlGate, decision RespondDecision) (*RunResult, error) {
choice := strings.ToLower(decision.Choice)
if choice == "abort" {
return e.cancelRun(st, "人工中止(澄清阶段)"), nil
@@ -757,7 +933,7 @@ func (e *SquadEngine) respondClarification(ctx context.Context, st *runState, ga
}
// bootstrapMinimalSquad 注册通用最小编制(organizer/specialist/writer/leader)。
func (e *SquadEngine) bootstrapMinimalSquad(st *runState) {
func (e *SquadEngine) bootstrapMinimalSquad(st *RunState) {
backend := st.Meta.Backend
if backend == "" {
backend = e.opts.DefaultBackend
@@ -777,7 +953,7 @@ func (e *SquadEngine) bootstrapMinimalSquad(st *runState) {
}
// manualPass 人工放行质检:标记 ManualPass 并收口。
func (e *SquadEngine) manualPass(st *runState, decision RespondDecision) (*RunResult, error) {
func (e *SquadEngine) manualPass(st *RunState, decision RespondDecision) (*RunResult, error) {
report := e.deps.Checker.Check(CheckRequest{
RunID: st.Meta.ID, Workspace: st.Meta.WorkDir, Deliverable: "output.md",
Plan: planPtr(st), Squad: &st.Plan.Squad, Template: st.Template,
@@ -806,7 +982,7 @@ func (e *SquadEngine) manualPass(st *runState, decision RespondDecision) (*RunRe
Summary: summarize(st, *report, status) + "(质检人工放行)"}, nil
}
func (e *SquadEngine) cancelRun(st *runState, reason string) *RunResult {
func (e *SquadEngine) cancelRun(st *RunState, reason string) *RunResult {
now := time.Now()
st.Meta.Status = string(RunCancelled)
st.Meta.CompletedAt = &now
@@ -820,11 +996,11 @@ func (e *SquadEngine) cancelRun(st *runState, reason string) *RunResult {
}
// failStepResume 权限拒绝后的失败步骤处理(关键失败/非关键降级)。
func (d *flowDriver) failStepResume(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
func (d *flowDriver) failStepResume(ctx context.Context, st *RunState, idx int) (*RunResult, *HitlGate, error) {
return d.failStep(ctx, st, idx)
}
func (e *SquadEngine) respondStepApproval(ctx context.Context, st *runState, req *HumanRequest, decision RespondDecision) (*RunResult, error) {
func (e *SquadEngine) respondStepApproval(ctx context.Context, st *RunState, req *HumanRequest, decision RespondDecision) (*RunResult, error) {
switch strings.ToLower(decision.Choice) {
case "approve":
st.Approved["step:"+req.StepID] = true
@@ -842,7 +1018,7 @@ func (e *SquadEngine) respondStepApproval(ctx context.Context, st *runState, req
return e.resumeAfterHold(ctx, st)
}
func (e *SquadEngine) respondInterrupted(ctx context.Context, st *runState, req *HumanRequest, decision RespondDecision) (*RunResult, error) {
func (e *SquadEngine) respondInterrupted(ctx context.Context, st *RunState, req *HumanRequest, decision RespondDecision) (*RunResult, error) {
switch strings.ToLower(decision.Choice) {
case "retry":
if !markStep(st, req.StepID, "pending", "") {
@@ -862,7 +1038,7 @@ func (e *SquadEngine) respondInterrupted(ctx context.Context, st *runState, req
return e.resumeAfterHold(ctx, st)
}
func markStep(st *runState, id, status, errText string) bool {
func markStep(st *RunState, id, status, errText string) bool {
for i := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[i].ID != id {
continue
@@ -876,7 +1052,7 @@ func markStep(st *runState, id, status, errText string) bool {
// recoverInterrupted 处理崩溃时停在 running 的步骤。
// interactive 挂起确认,不自动再执行;auto 允许一次重试;disabled 直接失败。
func (e *SquadEngine) recoverInterrupted(ctx context.Context, st *runState) (*RunResult, error) {
func (e *SquadEngine) recoverInterrupted(ctx context.Context, st *RunState) (*RunResult, error) {
for i := range st.Plan.Workflow.Steps {
step := st.Plan.Workflow.Steps[i]
if step.Status != "running" {
@@ -903,6 +1079,29 @@ func (e *SquadEngine) recoverInterrupted(ctx context.Context, st *runState) (*Ru
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
@@ -933,7 +1132,7 @@ func (e *SquadEngine) Resume(ctx context.Context, runID string) (*RunResult, err
defer release()
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
st, err := loadRunState(wsDir)
st, err := e.loadRun(wsDir)
if err != nil {
return nil, fmt.Errorf("load run %s: %w", runID, err)
}
@@ -944,6 +1143,11 @@ func (e *SquadEngine) Resume(ctx context.Context, runID string) (*RunResult, err
if RunStatus(st.Meta.Status).Terminal() {
return e.Inspect(runID)
}
// 图连续性校验(Q1 WP4/WP1 裁定):崩溃期间的检查点图指纹与当前 Steps
// 重编译不一致 = 图形态被改过(动态调整或外力),拒绝恢复并提示重新规划。
if err := validateGraphContinuity(st); err != nil {
return nil, err
}
if out, err := e.recoverInterrupted(ctx, st); out != nil || err != nil {
return out, err
}
@@ -956,7 +1160,7 @@ func (e *SquadEngine) Inspect(runID string) (*RunResult, error) {
return nil, invalidID(runID)
}
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
st, err := loadRunState(wsDir)
st, err := e.loadRun(wsDir)
if err != nil {
return nil, fmt.Errorf("load run %s: %w", runID, err)
}
@@ -1003,7 +1207,7 @@ func (e *SquadEngine) ListRuns() ([]RunMetadata, error) {
if !ent.IsDir() {
continue
}
st, err := loadRunState(filepath.Join(runsDir, ent.Name()))
st, err := e.loadRun(filepath.Join(runsDir, ent.Name()))
if err != nil {
continue
}
@@ -1037,7 +1241,7 @@ func (e *SquadEngine) Steer(runID, text string) error {
}
defer release()
st, err := loadRunState(filepath.Join(e.opts.DataDir, "runs", runID))
st, err := e.loadRun(filepath.Join(e.opts.DataDir, "runs", runID))
if err != nil {
return fmt.Errorf("load run %s: %w", runID, err)
}
@@ -1061,7 +1265,7 @@ func (e *SquadEngine) SetBlackboardField(runID, key, value string) error {
}
defer release()
st, err := loadRunState(filepath.Join(e.opts.DataDir, "runs", runID))
st, err := e.loadRun(filepath.Join(e.opts.DataDir, "runs", runID))
if err != nil {
return fmt.Errorf("load run %s: %w", runID, err)
}
@@ -1143,7 +1347,7 @@ func (e *SquadEngine) fireWakeup(ctx context.Context, runID string) bool {
if err := writeJSON(filepath.Join(e.opts.DataDir, "runs", runID, "pending.json"), gate); err != nil {
return false
}
if gate.Request.WakeupFires >= MaxWakeupFires {
if gate.Request.WakeupFires >= e.terminationOf(nil).MaxWakeupFires {
_, err := e.respondLocked(ctx, runID, RespondDecision{RequestID: gate.Request.ID, Choice: "abort",
Answer: "自动唤醒次数达上限,自动中止"})
return err == nil
@@ -1248,7 +1452,7 @@ func appendAnswer(d RespondDecision) string {
return "(" + d.Answer + ")"
}
func lastCompletedStepID(st *runState) string {
func lastCompletedStepID(st *RunState) string {
id := ""
for _, s := range st.Plan.Workflow.Steps {
if s.Status == "completed" {
+253
View File
@@ -0,0 +1,253 @@
package squad
import (
"fmt"
"hash/fnv"
"strings"
)
// graph 是一次执行图:由权威 Steps 现场编译(Steps 仍是唯一真相源,
// 动态调整改 Steps 后下一轮自然生效)。只携带调度所需的派生信息:
// 依赖、写集与可执行分层。
type graph struct {
needs map[string][]string // step id → 依赖 id(含相邻 Parallel 兼容映射产生的)
order []string // 声明序
writes map[string][]string // step id → 写集
}
// compileGraph 编译步骤图为调度图。规则:
// - 声明 Needs 的步骤:依赖即其 Needs(未知引用/环 fail-closed)。
// - 未声明 Needs 的步骤:保持现状语义——相邻 Parallel=true 的步骤同层
// (依赖 = 上一个「非本组」步骤),非 Parallel 步骤依赖前一个步骤。
// - 混合图允许:Needs 提供剪枝,未声明处保持线性。
func compileGraph(steps []StepSpec) (*graph, error) {
g := &graph{
needs: map[string][]string{},
order: make([]string, 0, len(steps)),
writes: map[string][]string{},
}
ids := map[string]bool{}
for _, s := range steps {
if s.ID == "" {
return nil, fmt.Errorf("graph: 存在空 id 的步骤")
}
if ids[s.ID] {
return nil, fmt.Errorf("graph: 步骤 id 重复:%s", s.ID)
}
ids[s.ID] = true
}
prevNonGroup := -1 // 上一个「非本 Parallel 组」的步骤索引
for i, s := range steps {
g.order = append(g.order, s.ID)
g.writes[s.ID] = append([]string(nil), s.Writes...)
needsDecl := len(s.Needs) > 0
switch {
case needsDecl:
for _, n := range s.Needs {
if !ids[n] {
return nil, fmt.Errorf("graph: 步骤 %s 的 needs 引用未知步骤 %s", s.ID, n)
}
if n == s.ID {
return nil, fmt.Errorf("graph: 步骤 %s 的 needs 引用自身", s.ID)
}
}
g.needs[s.ID] = append([]string(nil), s.Needs...)
prevNonGroup = i
case s.Parallel:
// 相邻 Parallel 组:依赖上一个组外步骤(没有则无依赖)。
if prevNonGroup >= 0 {
g.needs[s.ID] = []string{steps[prevNonGroup].ID}
}
// Parallel 步骤不推进 prevNonGroup:相邻同伴同层。
default:
// 非 Parallel:依赖紧邻的前一步(与旧线性扫描的执行序一致——
// 整组 Parallel 完成后才轮到它,而不是只等组外前一步)。
if i > 0 {
g.needs[s.ID] = []string{steps[i-1].ID}
}
prevNonGroup = i
}
}
if cycle := g.findCycle(); cycle != nil {
return nil, fmt.Errorf("graph: needs 依赖成环:%s", strings.Join(cycle, " → "))
}
// 循环步与路由步校验(Q2):预算必须显式,引用必须存在,两者互斥
//(同一步既循环又分支的语义未定义,fail-closed)。
for _, s := range steps {
if s.Loop != nil && len(s.Branches) > 0 {
return nil, fmt.Errorf("graph: 步骤 %s 不得同时声明 loop 与 branches", s.ID)
}
if s.Loop != nil {
if s.Loop.MaxIterations <= 0 {
return nil, fmt.Errorf("graph: 步骤 %s 的 loop.max_iterations 必须 > 0", s.ID)
}
for _, b := range s.Loop.Body {
if !ids[b] {
return nil, fmt.Errorf("graph: 步骤 %s 的 loop.body 引用未知步骤 %s", s.ID, b)
}
if b == s.ID {
return nil, fmt.Errorf("graph: 步骤 %s 的 loop.body 引用自身", s.ID)
}
}
}
for _, br := range s.Branches {
if br.When.Field == "" {
return nil, fmt.Errorf("graph: 步骤 %s 的分支缺少 when.field", s.ID)
}
if br.When.Equals == "" && br.When.NotEquals == "" {
return nil, fmt.Errorf("graph: 步骤 %s 的分支 when 需要 equals 或 not_equals", s.ID)
}
if len(br.Then) == 0 {
return nil, fmt.Errorf("graph: 步骤 %s 的分支 then 为空", s.ID)
}
for _, t := range br.Then {
if !ids[t] {
return nil, fmt.Errorf("graph: 步骤 %s 的分支 then 引用未知步骤 %s", s.ID, t)
}
if t == s.ID {
return nil, fmt.Errorf("graph: 步骤 %s 的分支 then 引用自身", s.ID)
}
}
}
}
return g, nil
}
// findCycle 报告一条依赖环(没有则 nil)。
func (g *graph) findCycle() []string {
const (
white = iota
gray
black
)
color := map[string]int{}
var stack []string
var visit func(id string) []string
visit = func(id string) []string {
if color[id] == gray {
return append(stack, id)
}
if color[id] == black {
return nil
}
color[id] = gray
stack = append(stack, id)
for _, n := range g.needs[id] {
if c := visit(n); c != nil {
return c
}
}
stack = stack[:len(stack)-1]
color[id] = black
return nil
}
for _, id := range g.order {
if c := visit(id); c != nil {
return c
}
}
return nil
}
// layer 是一个 superstep 的可执行集合:seq 为有写集冲突需按声明序串行的
// 步骤索引,par 为可并行的步骤索引。先执行 par 组(runParallel 语义),
// 再逐个执行 seq(含动态调整),与现状「并行组先于后续顺序步骤」一致。
type layer struct {
par []int
seq []int
}
// stepPending 报告步骤是否仍待执行。蓝图登记的步骤不带 status(空串),
// 旧线性扫描将其视为可执行——这里与之一致:空串等同 pending。
func stepPending(status string) bool {
return status == "" || status == "pending" || status == "running"
}
// nextLayer 计算当前可执行层:状态为 pending/running 且全部依赖已「满足」。
// 依赖满足 = completed/skipped/failed——failed 视为满足与现状「非关键失败
// 降级继续」的线性扫描一致(关键失败时运行已终止,不会走到这里)。
// 无可执行步骤时返回 (layer{}, false):可能全部完成,也可能调度卡死
// (由调用方用 pendingCount 区分)。
func (g *graph) nextLayer(steps []StepSpec) (layer, bool) {
idxOf := map[string]int{}
for i, s := range steps {
idxOf[s.ID] = i
}
satisfied := func(id string) bool {
st := steps[idxOf[id]].Status
return st == "completed" || st == "skipped" || st == "failed"
}
var out layer
for i, s := range steps {
if !stepPending(s.Status) {
continue
}
ready := true
for _, n := range g.needs[s.ID] {
if !satisfied(n) {
ready = false
break
}
}
if !ready {
continue
}
if g.conflictsWithEarlier(out, i, steps) {
out.seq = append(out.seq, i)
} else {
out.par = append(out.par, i)
}
}
return out, len(out.par)+len(out.seq) > 0
}
// conflictsWithEarlier 报告步骤 i 的写集与本层中更早的可执行步骤相交。
func (g *graph) conflictsWithEarlier(l layer, i int, steps []StepSpec) bool {
wi := g.writes[steps[i].ID]
if len(wi) == 0 {
return false
}
earlier := append(append([]int(nil), l.par...), l.seq...)
for _, j := range earlier {
for _, w := range g.writes[steps[j].ID] {
for _, v := range wi {
if w == v {
return true
}
}
}
}
return false
}
// pendingCount 统计仍未终结(pending/running/空串)的步骤数。
func pendingCount(steps []StepSpec) int {
n := 0
for _, s := range steps {
if stepPending(s.Status) {
n++
}
}
return n
}
// hash 是图结构指纹:声明序、依赖与写集的稳定散列。
// 恢复连续性校验用它比对崩溃前后的图形态(Q1 WP4;WP1 的图版本裁定落点)。
func (g *graph) hash() string {
h := fnv.New64a()
for _, id := range g.order {
h.Write([]byte(id))
h.Write([]byte{0})
for _, n := range g.needs[id] {
h.Write([]byte(">"))
h.Write([]byte(n))
}
h.Write([]byte{1})
for _, w := range g.writes[id] {
h.Write([]byte("w"))
h.Write([]byte(w))
}
h.Write([]byte{2})
}
return fmt.Sprintf("%016x", h.Sum64())
}
+139
View File
@@ -0,0 +1,139 @@
package squad
import (
"strings"
"testing"
)
func stepsOf(specs ...StepSpec) []StepSpec {
out := make([]StepSpec, len(specs))
copy(out, specs)
return out
}
func TestCompileGraphLinearAndAdjacentParallel(t *testing.T) {
steps := stepsOf(
StepSpec{ID: "a", Status: "pending"},
StepSpec{ID: "b", Status: "pending", Parallel: true},
StepSpec{ID: "c", Status: "pending", Parallel: true},
StepSpec{ID: "d", Status: "pending"},
)
g, err := compileGraph(steps)
if err != nil {
t.Fatal(err)
}
// 线性链:b/c 同层依赖 a,d 依赖 c。
if len(g.needs["a"]) != 0 {
t.Fatalf("a needs = %v", g.needs["a"])
}
if len(g.needs["b"]) != 1 || g.needs["b"][0] != "a" || g.needs["c"][0] != "a" {
t.Fatalf("parallel needs = %v %v", g.needs["b"], g.needs["c"])
}
if len(g.needs["d"]) != 1 || g.needs["d"][0] != "c" {
t.Fatalf("d needs = %v", g.needs["d"])
}
// 首轮可执行:只有 a。
layer, ok := g.nextLayer(steps)
if !ok || len(layer.par) != 1 || layer.par[0] != 0 || len(layer.seq) != 0 {
t.Fatalf("first layer = %+v", layer)
}
// a 完成后:b、c 同层并行。
steps[0].Status = "completed"
layer, ok = g.nextLayer(steps)
if !ok || len(layer.par) != 2 || layer.par[0] != 1 || layer.par[1] != 2 {
t.Fatalf("second layer = %+v", layer)
}
}
func TestCompileGraphNeedsPruneAndMixed(t *testing.T) {
steps := stepsOf(
StepSpec{ID: "a", Status: "pending"},
StepSpec{ID: "b", Status: "pending"},
StepSpec{ID: "c", Status: "pending", Needs: []string{"a"}},
)
g, err := compileGraph(steps)
if err != nil {
t.Fatal(err)
}
// a 完成后:b(线性依赖 a)与 c(needs a)同层。
steps[0].Status = "completed"
layer, ok := g.nextLayer(steps)
if !ok || len(layer.par) != 2 {
t.Fatalf("layer = %+v", layer)
}
// b 失败(非关键降级)后 c 仍按 needs 调度(与线性扫描的降级继续一致)。
steps[1].Status = "failed"
layer, ok = g.nextLayer(steps)
if !ok || len(layer.par)+len(layer.seq) == 0 {
t.Fatalf("after b failed: layer = %+v", layer)
}
}
func TestCompileGraphRejectsUnknownAndCycleAndDup(t *testing.T) {
if _, err := compileGraph(stepsOf(
StepSpec{ID: "a", Status: "pending", Needs: []string{"ghost"}},
)); err == nil || !strings.Contains(err.Error(), "未知步骤") {
t.Fatalf("unknown ref: %v", err)
}
if _, err := compileGraph(stepsOf(
StepSpec{ID: "a", Status: "pending", Needs: []string{"b"}},
StepSpec{ID: "b", Status: "pending", Needs: []string{"a"}},
)); err == nil || !strings.Contains(err.Error(), "成环") {
t.Fatalf("cycle: %v", err)
}
if _, err := compileGraph(stepsOf(
StepSpec{ID: "a", Status: "pending"},
StepSpec{ID: "a", Status: "pending"},
)); err == nil || !strings.Contains(err.Error(), "重复") {
t.Fatalf("dup: %v", err)
}
if _, err := compileGraph(stepsOf(
StepSpec{ID: "a", Status: "pending", Needs: []string{"a"}},
)); err == nil || !strings.Contains(err.Error(), "自身") {
t.Fatalf("self ref: %v", err)
}
}
func TestWriteSetConflictSerializes(t *testing.T) {
steps := stepsOf(
StepSpec{ID: "z", Status: "completed"},
StepSpec{ID: "a", Status: "pending", Parallel: true, Writes: []string{"report.md"}},
StepSpec{ID: "b", Status: "pending", Parallel: true, Writes: []string{"report.md"}},
StepSpec{ID: "c", Status: "pending", Parallel: true, Writes: []string{"notes.md"}},
StepSpec{ID: "d", Status: "pending", Parallel: true},
)
// 全部首轮可执行(无依赖):b 与 a 冲突 → b 串行;c、d 无冲突 → 并行。
g, err := compileGraph(steps)
if err != nil {
t.Fatal(err)
}
layer, ok := g.nextLayer(steps)
if !ok {
t.Fatal("no layer")
}
if len(layer.seq) != 1 || layer.seq[0] != 2 {
t.Fatalf("seq = %v", layer.seq)
}
if len(layer.par) != 3 || layer.par[0] != 1 || layer.par[1] != 3 || layer.par[2] != 4 {
t.Fatalf("par = %v", layer.par)
}
}
func TestFailedDependencyStillSchedules(t *testing.T) {
// 与线性扫描的「非关键失败降级继续」一致:依赖 failed 仍视为满足。
steps := stepsOf(
StepSpec{ID: "b", Status: "failed"},
StepSpec{ID: "a", Status: "pending", Needs: []string{"b"}},
)
g, err := compileGraph(steps)
if err != nil {
t.Fatal(err)
}
layer, ok := g.nextLayer(steps)
if !ok || len(layer.par) != 1 || layer.par[0] != 1 {
t.Fatalf("failed dependency should still schedule: %+v", layer)
}
if pendingCount(steps) != 1 {
t.Fatalf("pending = %d", pendingCount(steps))
}
}
+355
View File
@@ -0,0 +1,355 @@
package squad
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// groupchat.go — group_chat 拓扑驱动(Q2 WP3)。
// 形态(计划 §三 WP3 与边界 §五):共享转录(BB 字段 transcript +
// group_chat/transcript.md 全量落盘);发言人策略只做**轮转**与**主持人**
// 两种(脚本可判定,LLM 选人策略不进库);预算停机(max_turns,缺省 8)。
// 蓝图步骤 = 参与者名册(按序,不直接执行,driver 标记 skipped 并派生轮次
// 步骤);任一发言含 [close] 标记即收口;主持人模式下主持人输出 JSON 决策
// ({"next":"role"} / {"action":"close"}),未知角色/非法 JSON fail-closed。
// 驱动幂等可恢复:轮次由已落盘的 gc 步骤数重建,挂起(权限/HITL)后
// Respond 重入续转。
func init() {
_ = RegisterTopologyDriver(TopologyGroupChat)
}
// DefaultMaxGroupChatTurns 群聊轮次预算缺省值(预算停机)。
const DefaultMaxGroupChatTurns = 8
// gcRosterSkipReason 名册步骤的 skipped 原因(非执行步骤,不算降级)。
const gcRosterSkipReason = "group_chat: 名册步骤(参与者由驱动接管)"
// gcCloseMarker 任一发言包含该标记(大小写不敏感)即收口。
const gcCloseMarker = "[close]"
// gcTranscriptCapBB 是 BB 转录字段的容量上限(TASK.md 快照只带近端)。
const gcTranscriptCapBB = 4000
// runGroupChatDriver group_chat 拓扑入口。
func (e *SquadEngine) runGroupChatDriver(ctx context.Context, st *RunState) (*RunResult, error) {
drv := &flowDriver{opts: e.deps}
// 名册:首次进入从蓝图步骤取参与者序列并持久化到元数据(恢复时直读)。
roster, err := e.gcRoster(st)
if err != nil {
return e.driverFail(st, err.Error()), nil
}
policy := orDefault(st.Meta.Metadata["group_chat_policy"], "rotate")
moderator := st.Meta.Metadata["group_chat_moderator"]
if policy != "rotate" && policy != "moderator" {
return e.driverFail(st, fmt.Sprintf("group_chat: 未知发言人策略 %q(只支持 rotate/moderator)", policy)), nil
}
if policy == "moderator" {
if moderator == "" || !containsString(roster, moderator) {
return e.driverFail(st, fmt.Sprintf("group_chat: 主持人 %q 不在名册内(fail-closed)", moderator)), nil
}
}
maxTurns := DefaultMaxGroupChatTurns
if v := st.Meta.Metadata["group_chat_max_turns"]; v != "" {
if n := parseIntDefault(v, DefaultMaxGroupChatTurns); n > 0 {
maxTurns = n
}
}
// 名册步骤标记 skipped(首次进入时;恢复重入无 pending 名册步骤)。
st.mu.Lock()
for i := range st.Plan.Workflow.Steps {
s := &st.Plan.Workflow.Steps[i]
if !strings.HasPrefix(s.ID, "gc") && (s.Status == "pending" || s.Status == "running") {
s.Status = "skipped"
s.Error = gcRosterSkipReason
}
}
_ = st.save()
st.mu.Unlock()
for {
// 恢复重入:上次挂起留下的未完 gc 轮次步骤优先续跑(不重新选人)。
if idx := e.gcHeldStepIdx(st); idx >= 0 {
out, gate, err := e.gcRunStep(ctx, drv, st, idx)
if err != nil {
return nil, err
}
if gate != nil {
if out == nil {
out = heldResult(st, gate)
}
return out, nil
}
if out != nil {
return out, nil
}
if e.gcMessageClosed(st, idx) {
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("group_chat:%s 发出收口标记,停机", st.Plan.Workflow.Steps[idx].Role)})
break
}
continue
}
turns := e.gcTurnCount(st)
if turns >= maxTurns {
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("group_chat:轮次预算 %d 用尽,停机收口", maxTurns)})
break
}
speaker, closed, derr := e.gcNextSpeaker(st, roster, policy, moderator, turns)
if derr != nil {
return e.driverFail(st, derr.Error()), nil
}
if closed {
drv.emit(st, Event{Type: EvPhase, Text: "group_chat:主持人收口"})
break
}
out, gate, err := e.gcRunTurn(ctx, drv, st, turns, speaker)
if err != nil {
return nil, err
}
if gate != nil {
if out == nil {
out = heldResult(st, gate)
}
return out, nil
}
if out != nil { // 轮次步骤失败(关键)→ fail-closed 传播
return out, nil
}
if e.gcMessageClosed(st, len(st.Plan.Workflow.Steps)-1) {
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("group_chat:%s 发出收口标记,停机", speaker)})
break
}
}
return e.gcFinish(st, drv), nil
}
// gcRoster 参与者名册(首次从蓝图步骤派生并写入元数据;恢复直读)。
func (e *SquadEngine) gcRoster(st *RunState) ([]string, error) {
if v := st.Meta.Metadata["group_chat_roster"]; v != "" {
return strings.Split(v, ","), nil
}
var roster []string
for _, s := range st.Plan.Workflow.Steps {
if s.Role != "" && !containsString(roster, s.Role) {
roster = append(roster, s.Role)
}
}
if len(roster) == 0 {
return nil, fmt.Errorf("group_chat: 名册为空(蓝图步骤即参与者)")
}
st.mu.Lock()
if st.Meta.Metadata == nil {
st.Meta.Metadata = map[string]string{}
}
st.Meta.Metadata["group_chat_roster"] = strings.Join(roster, ",")
_ = st.save()
st.mu.Unlock()
return roster, nil
}
// gcTurnCount 已完成的轮次数(预算与轮转序号的基准;恢复重入自然续算)。
func (e *SquadEngine) gcTurnCount(st *RunState) int {
n := 0
for _, s := range st.Plan.Workflow.Steps {
if strings.HasPrefix(s.ID, "gc") && s.Status == "completed" {
n++
}
}
return n
}
// gcHeldStepIdx 返回上次挂起留下的未完 gc 轮次步骤下标(无则 -1)。
func (e *SquadEngine) gcHeldStepIdx(st *RunState) int {
for i := range st.Plan.Workflow.Steps {
s := &st.Plan.Workflow.Steps[i]
if strings.HasPrefix(s.ID, "gc") && (s.Status == "pending" || s.Status == "running") {
return i
}
}
return -1
}
// gcNextSpeaker 决定下一发言人。rotate = 轮转;moderator = 主持人裁决
// (上一步是参与者发言 → 主持人接手;主持人决策 close → 收口,next → 指名,
// 非法 JSON / 未知角色 fail-closed)。
func (e *SquadEngine) gcNextSpeaker(st *RunState, roster []string, policy, moderator string, turns int) (string, bool, error) {
if policy == "rotate" {
return roster[turns%len(roster)], false, nil
}
last := e.gcLastStep(st)
if last == nil || last.Role == moderator {
// 首轮或上一步就是主持人(不该连续,防御性让主持人接手)。
if last != nil && last.Role == moderator {
// 解析主持人决策。
dec, err := e.gcParseDecision(st, last)
if err != nil {
return "", false, err
}
if dec.Action == "close" {
return "", true, nil
}
if !containsString(roster, dec.Next) {
return "", false, fmt.Errorf("group_chat: 主持人指名 %q 不在名册内(fail-closed)", dec.Next)
}
return dec.Next, false, nil
}
return moderator, false, nil
}
// 上一步是参与者发言 → 主持人接手裁决。
return moderator, false, nil
}
type gcDecision struct {
Action string `json:"action"`
Next string `json:"next"`
}
// gcParseDecision 宽松解析主持人 message.md 里的 JSON 决策(首 `{` 末 `}`)。
func (e *SquadEngine) gcParseDecision(st *RunState, step *StepSpec) (*gcDecision, error) {
msg := e.gcMessageOf(st, step)
start := strings.Index(msg, "{")
end := strings.LastIndex(msg, "}")
if start < 0 || end <= start {
return nil, fmt.Errorf("group_chat: 主持人输出无 JSON 决策(fail-closed):%q", truncateStr(msg, 80))
}
var dec gcDecision
if err := json.Unmarshal([]byte(msg[start:end+1]), &dec); err != nil {
return nil, fmt.Errorf("group_chat: 主持人决策解析失败(fail-closed): %v", err)
}
if dec.Action == "close" {
return &dec, nil
}
if strings.TrimSpace(dec.Next) == "" {
return nil, fmt.Errorf("group_chat: 主持人决策缺 next/action(fail-closed)")
}
return &dec, nil
}
// gcRunTurn 派生一轮发言步骤并执行。
func (e *SquadEngine) gcRunTurn(ctx context.Context, drv *flowDriver, st *RunState, turns int, speaker string) (*RunResult, *HitlGate, error) {
step := StepSpec{
ID: fmt.Sprintf("gc%02d", turns+1),
Role: speaker,
Title: fmt.Sprintf("群聊第 %d 轮:%s 发言(把发言写入 message.md;同意收口就写 [close])", turns+1, speaker),
Targets: []string{"message.md"},
SubWorkspace: fmt.Sprintf("group_chat/turn_%02d_%s", turns+1, speaker),
Status: "pending",
}
st.mu.Lock()
st.Plan.Workflow.Steps = append(st.Plan.Workflow.Steps, step)
idx := len(st.Plan.Workflow.Steps) - 1
_ = st.save()
st.mu.Unlock()
return e.gcRunStep(ctx, drv, st, idx)
}
// gcRunStep 执行一个轮次步骤:过门 → runOneStep → 转录并入共享状态。
// 挂起(gate)时步骤保持未完,恢复重入经 gcHeldStepIdx 续跑同一步骤。
func (e *SquadEngine) gcRunStep(ctx context.Context, drv *flowDriver, st *RunState, idx int) (*RunResult, *HitlGate, error) {
out, gate, err := drv.gateBeforeRun(ctx, st, idx)
if err != nil || gate != nil || out != nil {
return out, gate, err
}
step := st.Plan.Workflow.Steps[idx]
drv.emit(st, Event{Type: EvPhase, StepID: step.ID, Role: step.Role,
Text: fmt.Sprintf("group_chat:%s 发言(第 %d 轮)", step.Role, e.gcTurnCount(st)+1)})
out, gate, err = drv.runOneStep(ctx, st, idx)
if err != nil || gate != nil || out != nil {
return out, gate, err
}
// 转录并入共享状态:全量落盘 transcript.md,近端进 BB 字段(下一位
// 发言人的 TASK.md 黑板快照可见)。
msg := e.gcMessageOf(st, &st.Plan.Workflow.Steps[idx])
e.gcAppendTranscript(st, e.gcTurnCount(st), step.Role, msg)
return nil, nil, nil
}
// gcAppendTranscript 追加转录(全量文件 + BB 近端快照)。
func (e *SquadEngine) gcAppendTranscript(st *RunState, turn int, speaker, msg string) {
dir := filepath.Join(st.Meta.WorkDir, "group_chat")
_ = os.MkdirAll(dir, 0o755)
line := fmt.Sprintf("%d. %s:%s\n", turn, speaker, strings.TrimSpace(msg))
f, _ := os.OpenFile(filepath.Join(dir, "transcript.md"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if f != nil {
_, _ = f.WriteString(line)
_ = f.Close()
}
st.mu.Lock()
if st.BB.Fields == nil {
st.BB.Fields = map[string]string{}
}
cur := st.BB.Fields["transcript"] + line
if len(cur) > gcTranscriptCapBB {
cur = cur[len(cur)-gcTranscriptCapBB:]
}
st.BB.Fields["transcript"] = cur
_ = st.save()
st.mu.Unlock()
}
// gcLastStep 最后一个已完成的派生轮次步骤(主持人决策解析的锚点)。
func (e *SquadEngine) gcLastStep(st *RunState) *StepSpec {
for i := len(st.Plan.Workflow.Steps) - 1; i >= 0; i-- {
s := &st.Plan.Workflow.Steps[i]
if strings.HasPrefix(s.ID, "gc") && s.Status == "completed" {
return s
}
}
return nil
}
// gcMessageOf 读取某轮的 message.md(缺文件 = 空串)。
func (e *SquadEngine) gcMessageOf(st *RunState, step *StepSpec) string {
data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace), "message.md"))
if err != nil {
return ""
}
return string(data)
}
// gcMessageClosed 第 idx 步的发言是否带收口标记。
func (e *SquadEngine) gcMessageClosed(st *RunState, idx int) bool {
if idx < 0 || idx >= len(st.Plan.Workflow.Steps) {
return false
}
msg := e.gcMessageOf(st, &st.Plan.Workflow.Steps[idx])
return strings.Contains(strings.ToLower(msg), gcCloseMarker)
}
// gcFinish 收口:交付物 = 全量转录;写 output.md;状态落盘;构造结果。
func (e *SquadEngine) gcFinish(st *RunState, drv *flowDriver) *RunResult {
transcript := ""
if data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "group_chat", "transcript.md")); err == nil {
transcript = string(data)
}
report := "# 群聊转录\n\n" + transcript
_ = os.WriteFile(filepath.Join(st.Meta.WorkDir, "output.md"), []byte(report), 0o644)
turns := e.gcTurnCount(st)
st.mu.Lock()
wf := st.Workflow()
wf.Status = string(RunCompleted)
st.setWorkflow(wf)
st.Meta.Status = string(RunCompleted)
now := time.Now()
st.Meta.CompletedAt = &now
_ = st.save()
st.mu.Unlock()
summary := fmt.Sprintf("group_chat:%d 轮发言,转录已收口", turns)
drv.emit(st, Event{Type: EvPhase, Text: summary})
return &RunResult{RunID: st.Meta.ID, Status: RunCompleted, Plan: planPtr(st),
Deliverable: "output.md", Summary: summary, WorkDir: st.Meta.WorkDir}
}
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
+91
View File
@@ -0,0 +1,91 @@
package squad
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// handoff.go — handoff 跨拓扑原语(Q2 WP3)。
// 语义(计划 §三 WP3):StepSpec.Handoff 非空的步骤完成后,最终答案所有权
// 移交给目标角色——① 记录所有权转移(Handoffs,随 RunState 持久化);
// ② 目标角色的下一个步骤收到「过滤历史」:只带移交步骤的主产出,不带全量
// 转录(一次性并入任务书,与 mailbox 同款机制);③ finalize 汇总交付物时
// 取链尾(最后一次移交的目标角色)的最后一个完成步骤产出,而非全图最后
// 完成步骤。目标角色不在小队内 = fail-closed(步骤启动前拒绝)。
// HandoffRecord 是一次所有权转移记录。
type HandoffRecord struct {
FromRole string `json:"from_role"`
FromStep string `json:"from_step"`
ToRole string `json:"to_role"`
Output string `json:"output,omitempty"` // 移交步骤主产出(截断上限内)
At time.Time `json:"at"`
Delivered bool `json:"delivered,omitempty"`
}
// handoffOutputCap 移交上下文的产出截断上限(过滤历史:只带主产出,且限量)。
const handoffOutputCap = 64 * 1024
// takeHandoffFor 取出移交给某角色的未投递记录,标记已投递(一次性)。
// 调用方负责随后 st.save()(或在临界区内)。
func takeHandoffFor(st *RunState, role string) *HandoffRecord {
for i := range st.Handoffs {
h := &st.Handoffs[i]
if h.Delivered || h.ToRole != role {
continue
}
h.Delivered = true
out := *h
return &out
}
return nil
}
// formatHandoffSection 渲染任务书里的移交上下文段(无则空串)。
func formatHandoffSection(h *HandoffRecord) string {
if h == nil {
return ""
}
body := h.Output
if len(body) > handoffOutputCap {
body = body[:handoffOutputCap] + "\n…(已截断)"
}
var b strings.Builder
fmt.Fprintf(&b, "\n\n## 移交上下文(所有权来自 %s,步骤 %s)\n", h.FromRole, h.FromStep)
b.WriteString("以下是移交方的主产出(过滤历史:只含该产出,不含其过程转录):\n\n")
b.WriteString(body)
b.WriteString("\n")
return b.String()
}
// handoffChainTail 返回移交链尾角色(无移交记录返回空串)。
func handoffChainTail(st *RunState) string {
if n := len(st.Handoffs); n > 0 {
return st.Handoffs[n-1].ToRole
}
return ""
}
// recordHandoff 步骤完成时的所有权转移记录:主产出快照(截断上限内)+
// 持久化 + 事件。目标角色合法性已在步骤启动前 fail-closed 校验。
func (d *flowDriver) recordHandoff(st *RunState, step StepSpec, subDir string, targets []string) {
output := ""
if data, err := os.ReadFile(filepath.Join(subDir, firstTarget(targets))); err == nil {
output = string(data)
}
if len(output) > handoffOutputCap {
output = output[:handoffOutputCap] + "\n…(已截断)"
}
st.mu.Lock()
st.Handoffs = append(st.Handoffs, HandoffRecord{
FromRole: step.Role, FromStep: step.ID, ToRole: step.Handoff,
Output: output, At: time.Now(),
})
_ = st.save()
st.mu.Unlock()
d.emit(st, Event{Type: EvPhase, StepID: step.ID, Role: step.Role,
Text: fmt.Sprintf("handoff:%s → %s(最终答案所有权移交)", step.Role, step.Handoff)})
}
+13 -2
View File
@@ -105,8 +105,19 @@ type RespondDecision struct {
Choice string `json:"choice,omitempty"`
// EditPlan 确认计划时的修改指令(附加在 Answer 上传递给引擎)。
EditPlan string `json:"edit_plan,omitempty"`
// Actor 决议发起方(Q2):"" 或 human = 人工(默认,兼容既有调用方);
// agent/system = 非人工。非人工 actor 的 choice 决议在
// permission/confirmation/quality_review 门上一律被拒绝(验收三)。
Actor string `json:"actor,omitempty"`
}
// Actor 归属常量。
const (
ActorHuman = "human"
ActorAgent = "agent"
ActorSystem = "system"
)
// newRequestID 生成请求 ID。
func newRequestID() string {
b := make([]byte, 8)
@@ -136,8 +147,8 @@ func expiredDecision(req HumanRequest, policy HitlPolicy, now time.Time) (Respon
return RespondDecision{RequestID: req.ID, Choice: choice, Answer: "expired"}, true
}
// hitlGate 引擎内部:一次挂起的完整上下文(落盘 pending.json)。
type hitlGate struct {
// HitlGate 引擎内部:一次挂起的完整上下文(落盘 pending.json)。
type HitlGate struct {
Request HumanRequest `json:"request"`
Plan *SquadPlan `json:"plan,omitempty"` // confirmation 时携带候选计划
Steps []StepSpec `json:"steps,omitempty"` // adjustment:建议追加的步骤
+122 -16
View File
@@ -13,7 +13,7 @@ import (
// leaderExecRequest 构造 leader 轮次的执行请求:与标准步骤同等装配
// (过程事件桥、ToolProfile/Skills/工具白名单、执行标识与预算)——
// 修平此前 leader 轮丢弃 OnEvent 与 ToolProfile 的缺口。
func leaderExecRequest(drv *flowDriver, st *runState, leader RoleSpec, workDir, prompt, instructions string, targets []string, timeout time.Duration) ExecRequest {
func leaderExecRequest(drv *flowDriver, st *RunState, leader RoleSpec, workDir, prompt, instructions string, targets []string, timeout time.Duration) ExecRequest {
stepID := fmt.Sprintf("leader_turn_%d", st.Leader.Turns)
var budget *ExecBudget
if timeout > 0 {
@@ -47,12 +47,34 @@ func leaderExecRequest(drv *flowDriver, st *runState, leader RoleSpec, workDir,
// DefaultMaxLeaderTurns leader 模式每运行的 leader 轮次上限。
const DefaultMaxLeaderTurns = 8
// DefaultMaxReplans leader 卡顿触发的重规划上限(Q2;与轮次上限独立)。
const DefaultMaxReplans = 3
// DefaultStuckThreshold 连续无进展轮数达到该值触发一次重规划(Q2)。
const DefaultStuckThreshold = 2
// LeaderState leader 循环状态(workspace leader.json 持久化,重启可恢复)。
type LeaderState struct {
Turns int `json:"turns"`
MaxTurns int `json:"max_turns"`
LeaderRole string `json:"leader_role"`
Delegations []DelegationRecord `json:"delegations"`
// 进度账本(Q2):每轮委派后记一条进展评估;卡顿检测与重规划的凭据。
Progress []ProgressEntry `json:"progress,omitempty"`
// StuckTurns 连续无进展轮数(有进展即清零)。
StuckTurns int `json:"stuck_turns,omitempty"`
// Replans 已触发的重规划次数;MaxReplans 触顶降级收口。
Replans int `json:"replans,omitempty"`
MaxReplans int `json:"max_replans,omitempty"`
// ReplanNote 一次性重规划指令(并入下一轮 leader 任务书后清空)。
ReplanNote string `json:"replan_note,omitempty"`
}
// ProgressEntry 是进度账本的一条评估记录。
type ProgressEntry struct {
Turn int `json:"turn"`
Signal string `json:"signal"` // progress | stuck
Detail string `json:"detail,omitempty"`
}
// DelegationRecord 一次已执行的委派(历史注入下一轮 leader 上下文)。
@@ -80,22 +102,24 @@ type delegation struct {
const leaderSubWorkspace = "tasks/00_leader"
func (st *runState) loadLeader() {
func (st *RunState) loadLeader() {
if st.Leader != nil {
return
}
st.Leader = &LeaderState{MaxTurns: DefaultMaxLeaderTurns}
data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "leader.json"))
if err == nil {
data, err := st.storeOr().LoadLeader(st.Meta.WorkDir)
if err == nil && data != nil {
_ = json.Unmarshal(data, st.Leader)
}
}
func (st *runState) saveLeader() {
func (st *RunState) saveLeader() {
if st.Leader == nil {
return
}
_ = writeJSON(filepath.Join(st.Meta.WorkDir, "leader.json"), st.Leader)
if data, err := json.Marshal(st.Leader); err == nil {
_ = st.storeOr().SaveLeader(st.Meta.WorkDir, data)
}
}
// leaderPrompt 组装 leader 的系统指令(协调契约 + 通用守则)。
@@ -132,7 +156,7 @@ func leaderPrompt(members []RoleSpec, catalogMd string) string {
}
// leaderTaskBrief 组装 leader 本轮的 TASK.md(目标 + 黑板 + 委派历史)。
func leaderTaskBrief(st *runState) string {
func leaderTaskBrief(st *RunState, mail []MailMessage) string {
var b strings.Builder
b.WriteString("# Leader 轮次:评估并委派\n\n")
fmt.Fprintf(&b, "## 运行目标\n%s\n\n", st.Meta.Goal.Text)
@@ -169,13 +193,28 @@ func leaderTaskBrief(st *runState) string {
} else {
b.WriteString("(尚无委派,这是第一轮。)\n\n")
}
if len(st.Leader.Progress) > 0 {
b.WriteString("## 进度账本\n")
for _, p := range st.Leader.Progress {
fmt.Fprintf(&b, "- 第%d轮 [%s] %s\n", p.Turn, p.Signal, p.Detail)
}
fmt.Fprintf(&b, "- 已重规划 %d/%d 次\n\n", st.Leader.Replans, st.Leader.MaxReplans)
}
if st.Leader.ReplanNote != "" {
fmt.Fprintf(&b, "%s\n\n", st.Leader.ReplanNote)
}
if section := formatMailSection(mail); section != "" {
b.WriteString(strings.TrimPrefix(section, "\n\n"))
b.WriteString("\n")
}
b.WriteString("## 本轮产出要求\n\n写 delegation.json(结构见你的指令)到本子目录。\n")
return b.String()
}
// runLeaderLoop leader-delegation 主循环:委派→执行→唤醒,直到
// close/failed/上限/挂起(escalation/gap/权限等由 HITL 处理后重入本函数)。
func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResult, *hitlGate, error) {
func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *RunState) (*RunResult, *HitlGate, error) {
st.loadLeader()
drv := &flowDriver{opts: e.deps}
leader, ok := findAgent(st.Plan.Squad.Agents, st.Leader.LeaderRole)
@@ -204,7 +243,8 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
}
_ = snapshotContextInto(st.Meta.WorkDir, subDir)
for st.Leader.Turns < st.Leader.MaxTurns {
term := e.terminationOf(st)
for st.Leader.Turns < term.MaxLeaderTurns {
// HITL:leader 角色权限升级(与标准模式同语义)。
if leader.BashPermission == "ask" && !st.Approved["role:"+leader.Role] {
if drv.policy(st).mode() != HitlInteractive {
@@ -220,7 +260,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
},
CreatedAt: time.Now(), Status: "pending",
}
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
gate := &HitlGate{Request: *req, PrevStatus: RunExecuting}
if err := st.saveGate(gate); err == nil {
st.Meta.Status = string(RunWaitingHuman)
_ = st.save()
@@ -230,9 +270,20 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
}
// 每轮重写 leader 上下文(黑板/委派历史已变化)。
if err := os.WriteFile(filepath.Join(subDir, "TASK.md"), []byte(leaderTaskBrief(st)), 0o644); err != nil {
// 邮箱消息与重规划指令是一次性的:并入本轮任务书后清空并落盘。
st.mu.Lock()
leaderMail := takeMailFor(st, st.Leader.LeaderRole)
st.mu.Unlock()
if err := os.WriteFile(filepath.Join(subDir, "TASK.md"), []byte(leaderTaskBrief(st, leaderMail)), 0o644); err != nil {
return nil, nil, err
}
if len(leaderMail) > 0 {
_ = st.save()
}
if st.Leader.ReplanNote != "" {
st.Leader.ReplanNote = ""
st.saveLeader()
}
if err := os.WriteFile(filepath.Join(subDir, "AGENTS.md"), []byte(leaderPrompt(members, e.opts.IntentCatalog.intentCatalogMd())), 0o644); err != nil {
return nil, nil, err
}
@@ -246,7 +297,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
if err != nil {
return e.leaderFail(st, err.Error()), nil, nil
}
prompt := readFileOr(filepath.Join(subDir, "TASK.md"), leaderTaskBrief(st))
prompt := readFileOr(filepath.Join(subDir, "TASK.md"), leaderTaskBrief(st, nil))
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))
@@ -292,7 +343,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
},
CreatedAt: time.Now(), Status: "pending",
}
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
gate := &HitlGate{Request: *req, PrevStatus: RunExecuting}
if err := st.saveGate(gate); err == nil {
st.Meta.Status = string(RunWaitingHuman)
_ = st.save()
@@ -335,6 +386,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
memberBrief += "\n\n## Leader 补充指令\n" + dec.Briefing
_ = os.WriteFile(filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace), "TASK.md"), []byte(memberBrief), 0o644)
}
factsBefore := len(st.BB.Facts)
out, gate, err := drv.runOneStep(ctx, st, idx)
if err != nil {
return nil, nil, err
@@ -352,6 +404,50 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
return out, gate, nil // 挂起(gap/权限等),Respond 后重入循环
}
st.Leader.Delegations = append(st.Leader.Delegations, rec)
// 进度账本(Q2):委派完成且有新事实、或不是与上轮同题重复 → 有进展。
factsGrew := len(st.BB.Facts) > factsBefore
sameAsPrev := false
if n := len(st.Leader.Delegations); n >= 2 {
prev := st.Leader.Delegations[n-2]
sameAsPrev = prev.Member == rec.Member && prev.Title == rec.Title
}
if factsGrew || !sameAsPrev {
st.Leader.StuckTurns = 0
st.Leader.Progress = append(st.Leader.Progress, ProgressEntry{
Turn: rec.Turn, Signal: "progress",
Detail: fmt.Sprintf("%s:%s(新事实 %v)", rec.Member, rec.Title, factsGrew),
})
} else {
st.Leader.StuckTurns++
st.Leader.Progress = append(st.Leader.Progress, ProgressEntry{
Turn: rec.Turn, Signal: "stuck",
Detail: fmt.Sprintf("与上轮同题重复且无新事实(连续 %d 轮)", st.Leader.StuckTurns),
})
}
// 卡顿 → 重规划;重规划触顶 → 降级收口(与轮次上限同款 Q23 语义)。
if st.Leader.StuckTurns >= DefaultStuckThreshold {
st.Leader.StuckTurns = 0
st.Leader.Replans++
if st.Leader.Replans >= st.Leader.MaxReplans {
st.saveLeader()
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf(
"重规划达上限 %d,按现有产出降级收口(Q2)", st.Leader.MaxReplans)})
out2, gate2, err2 := drv.finalize(ctx, st)
if err2 != nil {
return nil, nil, err2
}
if gate2 != nil {
return heldResult(st, gate2), gate2, nil
}
return out2, nil, nil
}
st.Leader.ReplanNote = fmt.Sprintf(
"【重规划指令】连续 %d 轮无进展(同题重复且无新事实),已触发第 %d/%d 次重规划。"+
"本轮必须改变策略:换成员、把任务拆小、补充缺失输入,或 escalate 问人;不得原样重发上一次委派。",
DefaultStuckThreshold, st.Leader.Replans, st.Leader.MaxReplans)
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf(
"卡顿检测:触发重规划 %d/%d", st.Leader.Replans, st.Leader.MaxReplans)})
}
st.saveLeader()
// 成员交付快照进 leader inbox(leader 工具沙箱看不到成员目录,
// 须物化到 leader 工作区才能自查交付——InputLayoutSnapshot 的对称应用)。
@@ -368,11 +464,21 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
return e.leaderFail(st, fmt.Sprintf("leader 输出了未知 action %q", dec.Action)), nil, nil
}
}
return e.leaderFail(st, fmt.Sprintf("leader 轮次达上限 %d(防失控护栏),运行停止;可提高 MaxLeaderTurns 或人工介入", st.Leader.MaxTurns)), nil, nil
// Q23:达上限不再裸失败——按现有产出走 finalize 降级收口(质检/汇总路径与正常收口一致)。
drv2 := &flowDriver{opts: e.deps}
drv2.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("leader 轮次达上限 %d,按现有产出降级收口(Q23)", term.MaxLeaderTurns)})
out, gate2, err := drv2.finalize(ctx, st)
if err != nil {
return nil, nil, err
}
if gate2 != nil {
return heldResult(st, gate2), gate2, nil
}
return out, nil, nil
}
// leaderFail 统一的 leader 模式失败收口。
func (e *SquadEngine) leaderFail(st *runState, reason string) *RunResult {
func (e *SquadEngine) leaderFail(st *RunState, reason string) *RunResult {
now := time.Now()
st.Meta.Status = string(RunFailed)
st.Meta.CompletedAt = &now
@@ -386,7 +492,7 @@ func (e *SquadEngine) leaderFail(st *runState, reason string) *RunResult {
}
// snapshotDeliverablesToLeader 把成员交付文件复制进 leader 工作区 inbox/<步骤id>/。
func snapshotDeliverablesToLeader(st *runState, step StepSpec) error {
func snapshotDeliverablesToLeader(st *RunState, step StepSpec) error {
srcDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace))
dstDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(leaderSubWorkspace), "inbox", step.ID)
for _, t := range step.Targets {
+356
View File
@@ -0,0 +1,356 @@
package squad
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// leader_mailbox_conformance_test.go — Q2 WP2 验收:leader 进度账本/重规划
// 上限、mailbox 投递、验收三「mailbox 消息不能把挂起的权限批准掉」。
// ---- leader 进度账本与重规划 ----
// 同题重复委派且无新事实 → 卡顿计数 → 触发重规划(事件+账本+一次性指令),
// leader 收到指令后 close,运行完成且 Replans=1。
func TestLeaderLedgerStuckTriggersReplan(t *testing.T) {
b := &leaderScriptBackend{
decisions: []string{
delegateJSON("writer", "同一件事"), // 第1轮:有进展(首委派)
delegateJSON("writer", "同一件事"), // 第2轮:同题无新事实 → stuck 1
delegateJSON("writer", "同一件事"), // 第3轮:stuck 2 → 重规划
closeJSON, // 第4轮:收到重规划指令后收口
},
script: map[string]map[string]string{
"01_writer": {"output.md": goodReport},
"02_writer": {"output.md": goodReport},
"03_writer": {"output.md": goodReport},
},
prompts: map[string][]string{},
}
sink := &collectSink{}
engine := newLeaderEngineWithSink(t, b, sink)
res, err := engine.Run(context.Background(), Goal{Text: "写一份报告"}, RunOptions{Mode: "leader"})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
st, err := engine.loadRun(engine.runDir(res.RunID))
if err != nil {
t.Fatal(err)
}
st.loadLeader()
if st.Leader.Replans != 1 {
t.Fatalf("Replans = %d,期望 1", st.Leader.Replans)
}
var stuck, progress int
for _, p := range st.Leader.Progress {
switch p.Signal {
case "stuck":
stuck++
case "progress":
progress++
}
}
if stuck != 2 || progress != 1 {
t.Fatalf("账本 progress=%d stuck=%d,期望 1/2", progress, stuck)
}
if sink.countText("触发重规划 1/3") != 1 {
t.Fatal("缺重规划事件")
}
// 重规划指令一次性并入下一轮 leader 任务书。
var sawNote bool
for _, p := range b.prompts["00_leader"] {
if strings.Contains(p, "重规划指令") {
sawNote = true
}
}
if !sawNote {
t.Fatal("leader 未收到重规划指令")
}
}
// 重规划触顶(MaxReplans=1)→ 走 finalize 收口路径(与 Q23 轮次上限同款:
// 不裸失败;终态取决于既有产出),并发出触顶事件、账本记账、不再进 leader 轮。
func TestLeaderReplanCapFinalizes(t *testing.T) {
b := &leaderScriptBackend{
decisions: []string{
delegateJSON("writer", "同一件事"),
delegateJSON("writer", "同一件事"),
delegateJSON("writer", "同一件事"), // stuck 2 → Replans=1 >= cap → 收口
closeJSON, // 不应到达
},
script: map[string]map[string]string{
"01_writer": {"output.md": goodReport},
"02_writer": {"output.md": goodReport},
"03_writer": {"output.md": goodReport},
},
}
sink := &collectSink{}
engine := newLeaderEngineWithSink(t, b, sink)
res, err := engine.Run(context.Background(), Goal{Text: "写一份报告"}, RunOptions{Mode: "leader", MaxReplans: 1})
if err != nil {
t.Fatal(err)
}
switch res.Status {
case RunCompleted, RunDegraded, RunQualityReview:
default:
t.Fatalf("重规划触顶应走 finalize 收口而非 %s(err=%v)", res.Status, res.Error)
}
if sink.countText("重规划达上限 1") != 1 {
t.Fatal("缺触顶事件")
}
st, err := engine.loadRun(engine.runDir(res.RunID))
if err != nil {
t.Fatal(err)
}
st.loadLeader()
if st.Leader.Replans != 1 || st.Leader.MaxReplans != 1 {
t.Fatalf("Replans=%d MaxReplans=%d,期望 1/1", st.Leader.Replans, st.Leader.MaxReplans)
}
if b.turn > 3 {
t.Fatalf("触顶后不应再进 leader 轮(turn=%d)", b.turn)
}
}
func newLeaderEngineWithSink(t *testing.T, backend Executor, sink ProgressSink) *SquadEngine {
t.Helper()
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range []RoleSpec{
{Role: "leader", Name: "主管", Backend: "leader-script", Skills: []string{"协调"}},
{Role: "writer", Name: "撰写", Backend: "leader-script", Skills: []string{"撰写"}},
} {
if err := reg.Register(r); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance("leader-script", backend)
engine, err := NewEngine(Options{DataDir: dir, Registry: reg, Backends: backends, Sink: sink})
if err != nil {
t.Fatal(err)
}
return engine
}
// ---- mailbox:投递 + 验收三 ----
// 挂起(gap)期间投递 → Respond 恢复 → 下一步骤任务书含消息;广播 "*" 同样到达;
// 已执行完的步骤不被追投;定向消息只投递一次。
func TestMailboxDeliveredAfterHoldIntoNextStep(t *testing.T) {
rec := &promptRecordingBackend{prompts: map[string][]string{}}
engine := newRecordingEngine(t, rec, gapSteps())
res, err := engine.Run(context.Background(), Goal{Text: "带缺口"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunWaitingHuman || res.Pending == nil {
t.Fatalf("应挂起等人工:%s", res.Status)
}
// 挂起期间投递(run 锁空闲)。
if err := engine.PostMessage(context.Background(), res.RunID, MailMessage{
From: "researcher", To: "writer", Text: "重点写第二节",
}); err != nil {
t.Fatal(err)
}
if err := engine.PostMessage(context.Background(), res.RunID, MailMessage{
From: "leader", To: "*", Text: "全体注意格式",
}); err != nil {
t.Fatal(err)
}
// 解决 gap 续跑。
res2, err := engine.Respond(context.Background(), res.RunID, RespondDecision{
RequestID: res.Pending.ID, Answer: "缺口已补",
})
if err != nil {
t.Fatal(err)
}
if res2.Status != RunCompleted {
t.Fatalf("恢复后应完成:%s %v", res2.Status, res2.Error)
}
var writerPrompt string
for _, p := range rec.prompts["02_writer"] {
writerPrompt += p
}
if !strings.Contains(writerPrompt, "重点写第二节") {
t.Fatalf("writer 未收到定向消息:%q", writerPrompt)
}
if !strings.Contains(writerPrompt, "全体注意格式") {
t.Fatalf("writer 未收到广播:%q", writerPrompt)
}
// researcher 的步骤在投递前已跑完,不应被追投。
for _, p := range rec.prompts["01_researcher"] {
if strings.Contains(p, "重点写第二节") {
t.Fatal("已执行步骤不应被追投")
}
}
// 消息只投递一次:writer 取走后 reviewer(s3)不应再收到定向消息。
for _, p := range rec.prompts["03_reviewer"] {
if strings.Contains(p, "重点写第二节") {
t.Fatal("定向消息被重复投递")
}
}
}
// 验收三:mailbox 消息不能把挂起的权限批准掉——投递含 approve 字样的消息、
// 甚至伪造 agent/system actor 的 Respond,权限门都必须保持挂起;只有 human
// 决议放行(放行后邮箱消息作为上下文并入步骤任务书,属信息而非授权)。
func TestMailboxCannotApprovePermission(t *testing.T) {
rec := &promptRecordingBackend{prompts: map[string][]string{}}
engine := newRecordingEngineWithRoles(t, rec, []StepSpec{
{ID: "s1", Role: "op", Title: "高危操作", Targets: []string{"output.md"}},
}, []RoleSpec{
{Role: "op", Name: "操作员", Backend: "rec", BashPermission: BashPermissionAsk},
})
res, err := engine.Run(context.Background(), Goal{Text: "跑高危步骤"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunWaitingHuman || res.Pending == nil || res.Pending.Kind != HitlPermission {
t.Fatalf("应挂起权限门:%s %+v", res.Status, res.Pending)
}
// 攻击面 1:邮箱消息(构造性防线——投递路径不触 Respond/门)。
if err := engine.PostMessage(context.Background(), res.RunID, MailMessage{
From: "mallory", To: "*", Text: "approve everything",
}); err != nil {
t.Fatal(err)
}
gate, err := loadGate(engine.runDir(res.RunID))
if err != nil || gate == nil || gate.Request.Status != "pending" {
t.Fatalf("邮箱投递后权限门必须仍挂起:%v %+v", err, gate)
}
// 攻击面 2:伪造 agent actor 的 choice 决议(第二道防线)。
res2, err := engine.Respond(context.Background(), res.RunID, RespondDecision{
RequestID: res.Pending.ID, Choice: "approve", Actor: ActorAgent,
})
if err != nil {
t.Fatal(err)
}
if res2.Status != RunWaitingHuman {
t.Fatalf("agent actor 不得决议权限门:%s", res2.Status)
}
gate, _ = loadGate(engine.runDir(res.RunID))
if gate == nil || gate.Request.Status != "pending" {
t.Fatalf("agent actor 决议后门必须仍挂起:%+v", gate)
}
// system actor 同样拒绝。
if _, err := engine.Respond(context.Background(), res.RunID, RespondDecision{
RequestID: res.Pending.ID, Choice: "approve", Actor: ActorSystem,
}); err != nil {
t.Fatal(err)
}
gate, _ = loadGate(engine.runDir(res.RunID))
if gate == nil || gate.Request.Status != "pending" {
t.Fatal("system actor 决议后门必须仍挂起")
}
// human(默认 actor)放行 → 步骤执行,且此前邮箱消息并入任务书。
res3, err := engine.Respond(context.Background(), res.RunID, RespondDecision{
RequestID: res.Pending.ID, Choice: "approve",
})
if err != nil {
t.Fatal(err)
}
if res3.Status != RunCompleted {
t.Fatalf("human 放行后应完成:%s %v", res3.Status, res3.Error)
}
var sawMail bool
for _, p := range rec.prompts["01_op"] {
if strings.Contains(p, "approve everything") {
sawMail = true
}
}
if !sawMail {
t.Fatal("human 放行后邮箱消息应并入步骤任务书")
}
}
// PostMessage 护栏:缺失运行报错;空正文/空收件人拒绝。
func TestPostMessageGuards(t *testing.T) {
rec := &promptRecordingBackend{prompts: map[string][]string{}}
engine := newRecordingEngine(t, rec, []StepSpec{
{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"output.md"}},
})
if err := engine.PostMessage(context.Background(), "run_missing", MailMessage{To: "x", Text: "y"}); err == nil {
t.Fatal("缺失运行必须报错")
}
res, err := engine.Run(context.Background(), Goal{Text: "x"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s", res.Status)
}
if err := engine.PostMessage(context.Background(), res.RunID, MailMessage{To: "researcher", Text: ""}); err == nil {
t.Fatal("空正文必须拒绝")
}
if err := engine.PostMessage(context.Background(), res.RunID, MailMessage{To: "", Text: "x"}); err == nil {
t.Fatal("空收件人必须拒绝")
}
}
// ---- 夹具 ----
// promptRecordingBackend 按目录记录 prompt;gaps.json 产出阻塞缺口触发挂起,
// 其余目标写合格交付物。
type promptRecordingBackend struct {
prompts map[string][]string
}
func (b *promptRecordingBackend) Name() string { return "rec" }
func (b *promptRecordingBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
b.prompts[key] = append(b.prompts[key], req.Prompt)
patches := map[string][]byte{}
for _, t := range req.Targets {
content := goodReport
switch t {
case "gaps.json":
content = `[{"id":"g1","item":"缺一个输入","owner":"human","blocking":true}]`
case "fields.json":
content = `{}`
}
out := filepath.Join(req.WorkDir, t)
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
return nil, err
}
if err := os.WriteFile(out, []byte(content), 0o644); err != nil {
return nil, err
}
patches[t] = []byte(content)
}
return &ExecResult{Success: true, Patches: patches}, nil
}
func newRecordingEngine(t *testing.T, be Executor, steps []StepSpec) *SquadEngine {
t.Helper()
return newRecordingEngineWithRoles(t, be, steps, []RoleSpec{
{Role: "researcher", Name: "调研", Backend: "rec"},
{Role: "writer", Name: "撰写", Backend: "rec"},
{Role: "reviewer", Name: "审核", Backend: "rec"},
})
}
func newRecordingEngineWithRoles(t *testing.T, be Executor, steps []StepSpec, roles []RoleSpec) *SquadEngine {
t.Helper()
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range roles {
if err := reg.Register(r); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance("rec", be)
engine, err := NewEngine(Options{DataDir: dir, Registry: reg,
Planner: mockScriptPlanner(steps...), Backends: backends, Sink: NopSink{}})
if err != nil {
t.Fatal(err)
}
return engine
}
+7 -2
View File
@@ -200,8 +200,13 @@ func TestLeaderModeTurnCapGuard(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, "上限") {
t.Fatalf("want turn-cap failure, got %s err=%s", res.Status, res.Error)
// Q23:达上限走 finalize 降级收口(completed/degraded/质检挂起),
// 不再是「运行停止」的裸失败。
if res.Status == RunFailed && strings.Contains(res.Error, "运行停止") {
t.Fatalf("Q23 closeout should not be a bare failure, got %s err=%s", res.Status, res.Error)
}
if res.Status != RunCompleted && res.Status != RunDegraded && res.Status != RunQualityReview {
t.Fatalf("Q23 closeout status = %s err=%s", res.Status, res.Error)
}
if b.turn < 3 {
t.Fatalf("leader should have run %d turns, ran %d", 3, b.turn)
+92
View File
@@ -0,0 +1,92 @@
package squad
import (
"context"
"fmt"
"strings"
"time"
)
// mailbox.go — 邮箱原语(Q2 WP2):运行内点对点消息。
// 安全边界(验收三的构造性保证):投递路径永不经过 Respond/gate——
// 消息只在步骤启动时并入任务书;挂起中的人工门(permission/confirmation/
// quality_review)不可能被邮箱消息决议。RespondDecision.Actor 提供
// 第二道防线:非 human actor 的 choice 决议一律拒绝。
// MailMessage 是一条运行内消息。To 是角色 key,"*" = 广播给之后的每个步骤。
type MailMessage struct {
ID string `json:"id"`
From string `json:"from"`
To string `json:"to"`
Text string `json:"text"`
At time.Time `json:"at"`
Delivered bool `json:"delivered,omitempty"`
}
// PostMessage 向一个运行投递邮箱消息。运行执行中(锁被占)返回 ErrRunBusy
// ——挂起等待人工时与回合间隙可投递。消息随 RunState 持久化,重启不丢。
// 本方法不触碰任何 HITL 门(验收三:邮箱永不决议挂起的审批)。
func (e *SquadEngine) PostMessage(ctx context.Context, runID string, msg MailMessage) error {
if strings.TrimSpace(msg.Text) == "" {
return fmt.Errorf("mailbox: 消息正文为空")
}
if strings.TrimSpace(msg.To) == "" {
return fmt.Errorf("mailbox: 收件人必填(角色 key 或 *)")
}
release, err := e.acquire(ctx, runID)
if err != nil {
return err
}
defer release()
wsDir := e.runDir(runID)
st, err := e.loadRun(wsDir)
if err != nil {
return fmt.Errorf("load run %s: %w", runID, err)
}
if msg.ID == "" {
msg.ID = newRequestID()
}
if msg.At.IsZero() {
msg.At = time.Now()
}
st.mu.Lock()
st.Mailbox = append(st.Mailbox, msg)
err = st.save()
st.mu.Unlock()
if err != nil {
return err
}
e.deps.Sink.Emit(Event{Type: EvPhase, RunID: runID, Text: fmt.Sprintf("邮箱消息 %s → %s", msg.From, msg.To), At: time.Now()})
return nil
}
// takeMailFor 取出发给某角色的未投递消息(含广播),标记已投递。
// 调用方负责随后 st.save()(或在临界区内)。
func takeMailFor(st *RunState, role string) []MailMessage {
var out []MailMessage
for i := range st.Mailbox {
m := &st.Mailbox[i]
if m.Delivered {
continue
}
if m.To != role && m.To != "*" {
continue
}
m.Delivered = true
out = append(out, *m)
}
return out
}
// formatMailSection 渲染任务书里的邮箱段(空则返回空串)。
func formatMailSection(msgs []MailMessage) string {
if len(msgs) == 0 {
return ""
}
var b strings.Builder
b.WriteString("\n\n## 邮箱消息\n")
for _, m := range msgs {
fmt.Fprintf(&b, "- [%s] %s:%s\n", m.At.Format(time.RFC3339), m.From, m.Text)
}
return b.String()
}
+54 -4
View File
@@ -66,9 +66,12 @@ type Blueprint struct {
Title string `json:"title"`
Match []string `json:"match,omitempty"` // 关键词命中即视为命中模板
Description string `json:"description,omitempty"`
Agents []RoleSpec `json:"agents,omitempty"` // 默认编制(引用已注册角色或内联角色)
Steps []StepSpec `json:"steps"`
Template string `json:"template,omitempty"` // 交付物骨架模板(Markdown)
// Topology 声明模板的拓扑(Q2;空 = standard 图行为)。
// RunOptions.Topology 优先于本字段。
Topology string `json:"topology,omitempty"`
Agents []RoleSpec `json:"agents,omitempty"` // 默认编制(引用已注册角色或内联角色)
Steps []StepSpec `json:"steps"`
Template string `json:"template,omitempty"` // 交付物骨架模板(Markdown)
}
// PlanningMode 规划模式。
@@ -88,10 +91,16 @@ type StepSpec struct {
Mode string `json:"mode,omitempty"` // 预留
Parallel bool `json:"parallel"`
Needs []string `json:"needs,omitempty"`
// Writes 写集:本步骤会写入的工件相对路径(调度依据)。
// 同一 superstep 内写集相交的步骤强制串行,不相交才可并行(Q1 WP1)。
Writes []string `json:"writes,omitempty"`
Targets []string `json:"targets,omitempty"` // 期望交付物相对路径,缺省 output.md
Status string `json:"status"` // pending, running, completed, failed, skipped
// Attempts 该步骤进入 running 的次数。中断恢复用它决定 auto 策略是否还能重试。
Attempts int `json:"attempts,omitempty"`
Attempts int `json:"attempts,omitempty"`
// AttemptID 当前这次尝试的稳定标识:runID/stepID/N(N=Attempts)。
// 执行事件、产物与副作用记录都挂它;瞬态重试共享同一 AttemptID(Q1 WP3)。
AttemptID string `json:"attempt_id,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
Error string `json:"error,omitempty"`
// RequiresApproval 为 true 时,该步骤启动前进入 HITL 计划确认。
@@ -109,6 +118,47 @@ type StepSpec struct {
Skills []string `json:"skills,omitempty"`
// MinScore 质检总分下限。0 = 不额外设门槛,只看 Checker.Passed。
MinScore int `json:"min_score,omitempty"`
// Loop 非空 = 循环步(Q2 loop 拓扑):完成后条件不满足且预算未尽时
// 重置为 pending 再武装(图不允许环,循环由引擎再武装实现)。
Loop *LoopSpec `json:"loop,omitempty"`
// LoopRuns 已再武装次数(引擎维护,随计划持久化)。
LoopRuns int `json:"loop_runs,omitempty"`
// Branches 非空 = 路由步(Q2 router 拓扑):完成后按黑板字段判定,
// 未选中分支的目标步骤置 skipped(skipped=依赖已满足,join 步照常推进)。
Branches []Branch `json:"branches,omitempty"`
// Handoff 非空 = 完成即把最终答案所有权移交给该角色(Q2 handoff 原语)。
Handoff string `json:"handoff,omitempty"`
// Brief 编译期任务指令(Q3 编译器):非空时渲染进 TASK.md 的
// 「任务指令」节,位于标题/产出之后、上下文表之前。图步的富文本任务书
// 载体(此前只有 leader 委派有 briefing)。
Brief string `json:"brief,omitempty"`
}
// LoopSpec 是循环步的预算与停机条件(Q2)。
type LoopSpec struct {
// MaxIterations 再武装上限(必须 >0;触顶即停,带 loop_exhausted 事件)。
MaxIterations int `json:"max_iterations"`
// UntilField 停机判据的黑板字段名;空 = 只按 MaxIterations 迭代。
UntilField string `json:"until_field,omitempty"`
// UntilEquals 停机值:Fields[UntilField] == UntilEquals 时停。
UntilEquals string `json:"until_equals,omitempty"`
// Body 与本步一起再武装的其他步骤 ID(循环体;如 evaluator-optimizer
// 的评估步)。空 = 只再武装本步。
Body []string `json:"body,omitempty"`
}
// Branch 是路由步的一条分支(Q2)。When 按声明序取首个命中。
type Branch struct {
When BranchCond `json:"when"`
// Then 是本分支保留的步骤 ID(其余分支目标置 skipped)。
Then []string `json:"then"`
}
// BranchCond 是分支判据:黑板字段等值/不等值。
type BranchCond struct {
Field string `json:"field"`
Equals string `json:"equals,omitempty"`
NotEquals string `json:"not_equals,omitempty"`
}
// IsCritical 报告步骤是否关键(nil 视为关键)。
+11 -6
View File
@@ -64,12 +64,17 @@ type ExecBudget struct {
// ProcessEvent 是过程帧。
type ProcessEvent struct {
Type string
Text string
Tool string
Detail string
PromptTokens int
CompletionTokens int
Type string
Text string
Tool string
Detail string
PromptTokens int
CompletionTokens int
// 以下在 Type=usage 时填写(N3 WP6:上一请求与缓存命中拆分)。
LastPromptTokens int
LastCompletionTokens int
CacheReadTokens int
CacheWriteTokens int
}
// ExecResult 是一次步骤执行的终态。
+366
View File
@@ -0,0 +1,366 @@
package squad
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// review.go — review 拓扑驱动(Q2 WP3,验收二)。
// 与 backend internal/runtime/review.go 的 runReview 等价:
// 并行 N finder(semaphore 3)→ 宽松 JSON 解析(首 `[` 末 `]`,丢空 issue)
// → 按 (location,issue) 去重、上限 10 → 每条独立 verifier(尾行
// VERDICT: PASS/FAIL/PARTIAL)→ 只留 PASS(fail-closed)→ severity 稳定排序
// → 报告恒带「已丢弃 N 条未验证」。解析/去重/判定/排序/文案与 backend 同一
// 格式串(计划 §一.5:以报告字节形态为等价判据)。
//
// squad 侧接线:finder = 计划步骤(各自的 findings.json 为目标产物,经
// runOneStep 走完整工作区/事件/HITL 机制);verifier = 驱动内按 verify 角色
// 直接 Execute 的核实调用(对应 backend 的 spawn(verifyProfileID,…)),
// 每次核实落盘 reviews/verify_NN/ 留痕。
func init() {
_ = RegisterTopologyDriver(TopologyReview)
}
// reviewFinding 与 backend reviewFinding 同构(JSON 键一致)。
type reviewFinding struct {
Location string `json:"location"`
Severity string `json:"severity"`
Issue string `json:"issue"`
Evidence string `json:"evidence"`
}
// reviewVerbatimTask 是核实调用的任务文案(与 backend runReview 同串)。
const reviewVerbatimTask = "独立核实这条问题是否成立。材料如下:\n%s\n\n问题:%s\n只在结尾给出一行 VERDICT: PASS、FAIL 或 PARTIAL。"
// parseFindings 宽松解析:首 `[` 末 `]`,丢空 issue(backend 同款)。
func parseFindings(text string) []reviewFinding {
start := strings.Index(text, "[")
end := strings.LastIndex(text, "]")
if start < 0 || end <= start {
return nil
}
var out []reviewFinding
if err := json.Unmarshal([]byte(text[start:end+1]), &out); err != nil {
return nil
}
var kept []reviewFinding
for _, f := range out {
if strings.TrimSpace(f.Issue) == "" {
continue
}
kept = append(kept, f)
}
return kept
}
// dedupeFindings 按 (location,issue) 去重,上限 limit(backend 同款)。
func dedupeFindings(groups [][]reviewFinding, limit int) []reviewFinding {
seen := map[string]bool{}
var out []reviewFinding
for _, group := range groups {
for _, f := range group {
key := strings.TrimSpace(f.Location) + "\n" + strings.TrimSpace(f.Issue)
if seen[key] {
continue
}
seen[key] = true
out = append(out, f)
if len(out) >= limit {
return out
}
}
}
return out
}
// reviewVerdict 尾行 VERDICT 判定(backend 同款:PASS/FAIL/PARTIAL,
// 解析不出 = 空串 = 不通过)。
func reviewVerdict(text string) string {
lines := strings.Split(text, "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if line == "" || !strings.HasPrefix(strings.ToUpper(line), "VERDICT:") {
continue
}
v := strings.TrimSpace(line[len("VERDICT:"):])
v = strings.ToUpper(v)
switch {
case strings.HasPrefix(v, "PASS"):
return "PASS"
case strings.HasPrefix(v, "FAIL"):
return "FAIL"
case strings.HasPrefix(v, "PARTIAL"):
return "PARTIAL"
default:
return ""
}
}
return ""
}
// severityRank 稳定排序键(backend 同款)。
func severityRank(s string) int {
switch strings.ToLower(strings.TrimSpace(s)) {
case "critical":
return 0
case "high":
return 1
case "medium":
return 2
case "low":
return 3
default:
return 4
}
}
// reviewParallel semaphore-3 并行(backend parallel 同款)。
func reviewParallel(n int, fn func(i int)) {
if n <= 0 {
return
}
sem := make(chan struct{}, 3)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
fn(i)
}(i)
}
wg.Wait()
}
// runReviewDriver review 拓扑入口。finder 阶段复用计划步骤(可挂起可恢复:
// 重入时已完成步骤不重跑,findings.json 从各自子工作区读取)。
func (e *SquadEngine) runReviewDriver(ctx context.Context, st *RunState) (*RunResult, error) {
drv := &flowDriver{opts: e.deps}
steps := st.Plan.Workflow.Steps
// verify 角色先行解析(fail-fast:缺角色不浪费 finder 轮次)。
verifyRoleName := orDefault(st.Meta.Metadata["review_verify_role"], "verifier")
verifyRole, ok := findAgent(st.Plan.Squad.Agents, verifyRoleName)
if !ok {
if r, exists := e.deps.Registry.Get(verifyRoleName); exists {
verifyRole, ok = r, true
}
}
if !ok {
return e.driverFail(st, fmt.Sprintf("review: verify 角色 %q 不在小队内(fail-closed)", verifyRoleName)), nil
}
verifyBackend, _, rerr := e.deps.Backends.Resolve(verifyRole, st.Meta.Backend)
if rerr != nil {
return e.driverFail(st, "review: verify 后端解析失败: "+rerr.Error()), nil
}
// 1) finder 阶段:先全组过门(任一要挂起则整组不执行,与 advance 并行组
// 同款语义),再 semaphore-3 并行执行。
var finderIdx []int
for i := range steps {
if steps[i].Status == "pending" || steps[i].Status == "running" {
finderIdx = append(finderIdx, i)
}
}
for _, idx := range finderIdx {
out, gate, err := drv.gateBeforeRun(ctx, st, idx)
if err != nil {
return nil, err
}
if gate != nil {
if out == nil {
out = heldResult(st, gate)
}
return out, nil
}
if out != nil {
return out, nil
}
}
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("review:%d 个 finder 并行找问题", len(finderIdx))})
var holdGate *HitlGate
var holdOut *RunResult
var fatal *RunResult
finderFailed := 0
var fmu sync.Mutex
reviewParallel(len(finderIdx), func(k int) {
idx := finderIdx[k]
out, gate, err := drv.runOneStep(ctx, st, idx)
fmu.Lock()
defer fmu.Unlock()
if err != nil {
if fatal == nil {
fatal = &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: err.Error(), WorkDir: st.Meta.WorkDir}
}
return
}
if gate != nil && holdGate == nil {
holdGate = gate
holdOut = out
return
}
if out != nil && out.Status == RunFailed && fatal == nil {
fatal = out // 关键 finder 失败:fail-closed 传播
return
}
if st.Plan.Workflow.Steps[idx].Status == "failed" {
finderFailed++ // 非关键 finder 失败:宽容(backend spawn err 同款),收口降级
}
})
if fatal != nil {
return fatal, nil
}
if holdGate != nil {
if holdOut == nil {
holdOut = heldResult(st, holdGate)
}
return holdOut, nil
}
// 2) 收集各 finder 的 findings.json(宽松解析;缺文件/坏 JSON = 该 finder
// 零发现,与 backend 同款)。
steps = st.Plan.Workflow.Steps
groups := make([][]reviewFinding, 0, len(steps))
for i := range steps {
if steps[i].Status != "completed" {
continue
}
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(steps[i].SubWorkspace))
data, err := os.ReadFile(filepath.Join(subDir, "findings.json"))
if err != nil {
continue
}
groups = append(groups, parseFindings(string(data)))
}
unique := dedupeFindings(groups, 10)
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("review:去重后 %d 条,逐条独立核实", len(unique))})
// 3) 逐条 verify(semaphore-3 并行;fail-closed:核实调用失败/无判定 = 丢弃)。
target := reviewTargetMaterial(st)
kept := make([]reviewFinding, len(unique))
pass := make([]bool, len(unique))
reviewParallel(len(unique), func(i int) {
f := unique[i]
body, _ := json.Marshal(f)
task := fmt.Sprintf(reviewVerbatimTask, target, body)
vDir := filepath.Join(st.Meta.WorkDir, "reviews", fmt.Sprintf("verify_%02d", i+1))
if err := os.MkdirAll(vDir, 0o755); err != nil {
return
}
instructions := ""
if e.deps.Workspaces != nil {
instructions = e.deps.Workspaces.ResolveInstructions(verifyRole)
}
res, err := verifyBackend.Execute(ctx, ExecRequest{
WorkDir: vDir,
Prompt: task,
Instructions: instructions,
Targets: []string{"verdict.md"},
Timeout: e.deps.StepTimeout,
SessionScope: st.Meta.ID + "/verify_" + fmt.Sprintf("%02d", i+1),
})
if err != nil || res == nil {
return
}
text := string(res.Patches["verdict.md"]) + "\n" + res.Logs
if data, rerr2 := os.ReadFile(filepath.Join(vDir, "verdict.md")); rerr2 == nil {
text = string(data) + "\n" + text
}
if reviewVerdict(text) == "PASS" {
kept[i] = f
pass[i] = true
}
})
var verified []reviewFinding
for i, ok := range pass {
if ok {
verified = append(verified, kept[i])
}
}
// 4) severity 稳定排序 + 等价报告(backend 同格式串)。
sort.SliceStable(verified, func(i, j int) bool {
return severityRank(verified[i].Severity) < severityRank(verified[j].Severity)
})
dropped := len(unique) - len(verified)
var b strings.Builder
if len(verified) == 0 {
b.WriteString("没有验证通过的问题。\n")
}
for _, f := range verified {
fmt.Fprintf(&b, "- [%s] %s:%s\n 证据:%s\n",
orDefault(f.Severity, "medium"), orDefault(f.Location, "未定位"), f.Issue, f.Evidence)
}
fmt.Fprintf(&b, "已丢弃 %d 条未验证\n", dropped)
report := b.String()
// 5) 收口:报告落盘(reviews/report.md + output.md 交付物约定),
// finder 有失败则降级(诚实信号),否则完成。
reviewsDir := filepath.Join(st.Meta.WorkDir, "reviews")
_ = os.MkdirAll(reviewsDir, 0o755)
_ = os.WriteFile(filepath.Join(reviewsDir, "report.md"), []byte(report), 0o644)
_ = os.WriteFile(filepath.Join(st.Meta.WorkDir, "output.md"), []byte(report), 0o644)
st.mu.Lock()
if st.BB.Fields == nil {
st.BB.Fields = map[string]string{}
}
st.BB.Fields["review_verified"] = fmt.Sprintf("%d", len(verified))
st.BB.Fields["review_dropped"] = fmt.Sprintf("%d", dropped)
wf := st.Workflow()
status := RunCompleted
if finderFailed > 0 {
status = RunDegraded
}
wf.Status = string(status)
st.setWorkflow(wf)
st.Meta.Status = string(status)
now := time.Now()
st.Meta.CompletedAt = &now
_ = st.save()
st.mu.Unlock()
summary := fmt.Sprintf("review:保留 %d 条 / 丢弃 %d 条未验证", len(verified), dropped)
if finderFailed > 0 {
summary += fmt.Sprintf("(%d 个 finder 失败,降级)", finderFailed)
}
drv.emit(st, Event{Type: EvPhase, Text: summary})
return &RunResult{RunID: st.Meta.ID, Status: status, Plan: planPtr(st),
Deliverable: "output.md", Summary: summary, WorkDir: st.Meta.WorkDir}, nil
}
// reviewTargetMaterial 取被评审材料:元数据 review_target 优先,
// 其次运行目标(context/goal.md)。
func reviewTargetMaterial(st *RunState) string {
if v := strings.TrimSpace(st.Meta.Metadata["review_target"]); v != "" {
return v
}
if data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "context", "goal.md")); err == nil {
return string(data)
}
return ""
}
// driverFail 驱动级 fail-closed 收口(与 leaderFail 同款语义)。
func (e *SquadEngine) driverFail(st *RunState, reason string) *RunResult {
now := time.Now()
st.mu.Lock()
st.Meta.Status = string(RunFailed)
st.Meta.CompletedAt = &now
wf := st.Workflow()
wf.Status = "failed"
st.setWorkflow(wf)
_ = st.save()
st.mu.Unlock()
(&flowDriver{opts: e.deps}).emit(st, Event{Type: EvPhase, Text: reason})
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: reason, WorkDir: st.Meta.WorkDir}
}
+57 -17
View File
@@ -10,9 +10,9 @@ import (
"time"
)
// runState 引擎内部运行状态(磁盘即权威:workspace.json / workflow.json /
// RunState 引擎内部运行状态(磁盘即权威:workspace.json / workflow.json /
// squad.json / blackboard.json / pending.json 全部落盘,进程重启可恢复)。
type runState struct {
type RunState struct {
Meta RunMetadata `json:"meta"`
Plan SquadPlan `json:"plan"`
BB BlackboardState `json:"bb"`
@@ -21,19 +21,41 @@ type runState struct {
Approved map[string]bool `json:"approved,omitempty"`
// RepairBudgetRemaining 质检失败后的自动重跑剩余次数。
RepairBudgetRemaining int `json:"repair_budget_remaining,omitempty"`
// RepairBudgetGranted 人工 retry 累计追加次数(Q26 上限判定用)。
RepairBudgetGranted int `json:"repair_budget_granted,omitempty"`
// Clarifications 已澄清问答(追加进 Goal 上下文)。
Clarifications []string `json:"clarifications,omitempty"`
// Steering 运行中人工补充指令(在下一步骤边界注入后清空)。
Steering []string `json:"steering,omitempty"`
// Mailbox 运行内点对点消息(Q2):随 RunState 持久化;只在步骤启动时
// 并入任务书,永不触碰 HITL 门。
Mailbox []MailMessage `json:"mailbox,omitempty"`
// Handoffs 所有权转移记录(Q2 handoff 原语):随 RunState 持久化;
// 目标角色的下一步骤一次性接收移交上下文,finalize 取链尾产出。
Handoffs []HandoffRecord `json:"handoffs,omitempty"`
// Board task_board 拓扑的队列状态(Q2):claim/lease/complete 三原语,
// 随 RunState 持久化;孤儿租约在驱动重入时再武装。
Board []BoardTask `json:"board,omitempty"`
// Usage 按角色累计的 token 用量。
Usage map[string]*Usage `json:"usage,omitempty"`
// Leader leader-delegation 模式的循环状态(workspace 内 leader.json 持久化)。
Leader *LeaderState `json:"-"`
// store 运行状态持久化端口(Q1 WP4);nil 回落 FileStore(现状文件态)。
// 替换实现(如 SQLiteStore)在引擎装配期注入。
store Store `json:"-"`
// mu 保护并行步骤对共享状态(黑板/save/steering/usage)的变更。
mu sync.Mutex `json:"-"`
}
func (st *runState) policy() HitlPolicy {
// storeOr 返回注入的 Store,缺省 FileStore。
func (st *RunState) storeOr() Store {
if st.store != nil {
return st.store
}
return FileStore{}
}
func (st *RunState) policy() HitlPolicy {
return mergeHitlPolicy(HitlPolicy{}, st.Meta.HitlPolicyOf())
}
@@ -49,8 +71,14 @@ func (m RunMetadata) HitlPolicyOf() *HitlPolicy {
return p
}
func (st *runState) save() error {
func (st *RunState) save() error {
st.Meta.UpdatedAt = time.Now()
return st.storeOr().SaveRun(st)
}
// persist 是 FileStore 语义的低层落盘(全部写文件)。Store.SaveRun 调它;
// RunState.save 走 Store 端口,替换实现(SQLiteStore)在装配期注入后自然接管。
func (st *RunState) persist() error {
if err := writeJSON(filepath.Join(st.Meta.WorkDir, "workspace.json"), &st.Meta); err != nil {
return err
}
@@ -71,24 +99,32 @@ func (st *runState) save() error {
return writeJSON(filepath.Join(st.Meta.WorkDir, "runstate.json"), diskRunState{
Approved: st.Approved,
RepairBudgetRemaining: st.RepairBudgetRemaining,
RepairBudgetGranted: st.RepairBudgetGranted,
Clarifications: st.Clarifications,
Mailbox: st.Mailbox,
Handoffs: st.Handoffs,
Board: st.Board,
})
}
type diskRunState struct {
Approved map[string]bool `json:"approved,omitempty"`
RepairBudgetRemaining int `json:"repair_budget_remaining,omitempty"`
RepairBudgetGranted int `json:"repair_budget_granted,omitempty"`
Clarifications []string `json:"clarifications,omitempty"`
Mailbox []MailMessage `json:"mailbox,omitempty"`
Handoffs []HandoffRecord `json:"handoffs,omitempty"`
Board []BoardTask `json:"board,omitempty"`
}
// Workflow 返回计划的编排清单(简洁访问器)。
func (st *runState) Workflow() WorkflowPlan { return st.Plan.Workflow }
func (st *RunState) Workflow() WorkflowPlan { return st.Plan.Workflow }
// setWorkflow 写回编排清单。
func (st *runState) setWorkflow(wf WorkflowPlan) { st.Plan.Workflow = wf }
func (st *RunState) setWorkflow(wf WorkflowPlan) { st.Plan.Workflow = wf }
// saveGate 落盘 HITL 挂起上下文(pending.json)。
func (st *runState) saveGate(gate *hitlGate) error {
func (st *RunState) saveGate(gate *HitlGate) error {
if gate == nil {
err := os.Remove(filepath.Join(st.Meta.WorkDir, "pending.json"))
if os.IsNotExist(err) {
@@ -100,8 +136,8 @@ func (st *runState) saveGate(gate *hitlGate) error {
}
// loadRunState 从工作区恢复运行状态。
func loadRunState(wsDir string) (*runState, error) {
st := &runState{}
func loadRunState(wsDir string) (*RunState, error) {
st := &RunState{}
if err := readJSON(filepath.Join(wsDir, "workspace.json"), &st.Meta); err != nil {
return nil, fmt.Errorf("load workspace.json: %w", err)
}
@@ -131,7 +167,11 @@ func loadRunState(wsDir string) (*runState, error) {
st.Approved = disk.Approved
}
st.RepairBudgetRemaining = disk.RepairBudgetRemaining
st.RepairBudgetGranted = disk.RepairBudgetGranted
st.Clarifications = disk.Clarifications
st.Mailbox = disk.Mailbox
st.Handoffs = disk.Handoffs
st.Board = disk.Board
} else if !os.IsNotExist(err) {
return nil, err
}
@@ -143,7 +183,7 @@ func loadRunState(wsDir string) (*runState, error) {
}
// loadGate 恢复挂起上下文;无挂起返回 nil。
func loadGate(wsDir string) (*hitlGate, error) {
func loadGate(wsDir string) (*HitlGate, error) {
data, err := os.ReadFile(filepath.Join(wsDir, "pending.json"))
if err != nil {
if os.IsNotExist(err) {
@@ -151,7 +191,7 @@ func loadGate(wsDir string) (*hitlGate, error) {
}
return nil, err
}
gate := &hitlGate{}
gate := &HitlGate{}
if err := json.Unmarshal(data, gate); err != nil {
return nil, fmt.Errorf("parse pending.json: %w", err)
}
@@ -167,7 +207,7 @@ func readJSON(path string, v any) error {
}
// setStep 并行安全地回填步骤状态(与 save 的全量 marshal 互斥)。
func (st *runState) setStep(idx int, s StepSpec) {
func (st *RunState) setStep(idx int, s StepSpec) {
st.mu.Lock()
st.Plan.Workflow.Steps[idx] = s
st.mu.Unlock()
@@ -175,7 +215,7 @@ func (st *runState) setStep(idx int, s StepSpec) {
// injectSteering 把挂起的运行中人工补充指令追加到 prompt(下一步骤边界注入),
// 注入后清空并落盘。注入发生在步骤开始前,不会污染正在执行的步骤。
func (st *runState) injectSteering(prompt string) string {
func (st *RunState) injectSteering(prompt string) string {
st.mu.Lock()
defer st.mu.Unlock()
if len(st.Steering) == 0 {
@@ -194,7 +234,7 @@ func (st *runState) injectSteering(prompt string) string {
}
// addSteering 追加一条尚未注入的人工补充,并立刻落盘。
func (st *runState) addSteering(text string) error {
func (st *RunState) addSteering(text string) error {
text = strings.TrimSpace(text)
if text == "" {
return nil
@@ -206,7 +246,7 @@ func (st *runState) addSteering(text string) error {
}
// loadSteering 恢复未消费的补充指令。
func (st *runState) loadSteering() {
func (st *RunState) loadSteering() {
data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "steering.json"))
if err != nil {
return
@@ -218,7 +258,7 @@ func (st *runState) loadSteering() {
}
// addUsage 累计角色用量。
func (st *runState) addUsage(role string, promptTokens, completionTokens int) {
func (st *RunState) addUsage(role string, promptTokens, completionTokens int) {
if promptTokens == 0 && completionTokens == 0 {
return
}
@@ -238,7 +278,7 @@ func (st *runState) addUsage(role string, promptTokens, completionTokens int) {
}
// usageSnapshot 输出用量快照(值拷贝)。
func (st *runState) usageSnapshot() map[string]Usage {
func (st *RunState) usageSnapshot() map[string]Usage {
if len(st.Usage) == 0 {
return nil
}
+212
View File
@@ -0,0 +1,212 @@
package squad
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
// Store 是运行状态持久化端口(Q1 WP4):run/gate/checkpoint 的读写以
// 运行工作区为键。现状文件态收编为 FileStore(行为与此前逐文件直写完全一致);
// SQLite 实现进 adapters/sqlite(WP5)。事件流仍归 store/eventsink(子包),
// 不在本端口内重复。
type Store interface {
SaveRun(st *RunState) error
LoadRun(wsDir string) (*RunState, error)
SaveGate(wsDir string, gate *HitlGate) error
LoadGate(wsDir string) (*HitlGate, error)
SaveCheckpoint(wsDir string, cp Checkpoint) error
LoadLatestCheckpoint(wsDir string) (*Checkpoint, error)
// SaveLeader/LoadLeader 持久化 leader 循环状态(raw JSON bytes;
// 文件态即 leader.json)。LoadLeader 不存在时返回 (nil, nil)。
SaveLeader(wsDir string, data []byte) error
LoadLeader(wsDir string) ([]byte, error)
}
// FileStore 把 runs/<id>/ 目录布局呈现为 Store(现状文件态)。
type FileStore struct{}
func (FileStore) SaveRun(st *RunState) error {
return st.persist()
}
func (FileStore) LoadRun(wsDir string) (*RunState, error) {
return loadRunState(wsDir)
}
func (FileStore) SaveGate(wsDir string, gate *HitlGate) error {
st := &RunState{}
st.Meta.WorkDir = wsDir
return st.saveGate(gate)
}
func (FileStore) LoadGate(wsDir string) (*HitlGate, error) {
return loadGate(wsDir)
}
func (FileStore) SaveLeader(wsDir string, data []byte) error {
return os.WriteFile(filepath.Join(wsDir, "leader.json"), data, 0o644)
}
func (FileStore) LoadLeader(wsDir string) ([]byte, error) {
data, err := os.ReadFile(filepath.Join(wsDir, "leader.json"))
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return data, nil
}
func (FileStore) SaveCheckpoint(wsDir string, cp Checkpoint) error {
dir := filepath.Join(wsDir, "checkpoints")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
path := filepath.Join(dir, fmt.Sprintf("%03d.json", cp.Superstep))
if err := writeJSON(path, cp); err != nil {
return err
}
return writeJSON(filepath.Join(dir, "latest.json"), cp)
}
func (FileStore) LoadLatestCheckpoint(wsDir string) (*Checkpoint, error) {
data, err := os.ReadFile(filepath.Join(wsDir, "checkpoints", "latest.json"))
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var cp Checkpoint
if err := json.Unmarshal(data, &cp); err != nil {
return nil, fmt.Errorf("parse latest checkpoint: %w", err)
}
return &cp, nil
}
// Checkpoint 是一个 superstep 边界的状态快照(Q1 WP4)。
// GraphHash 是图结构指纹:恢复时与当前 Steps 重编译比对,不一致说明崩溃
// 期间图形态被改过(动态调整或外力),按 WP1 裁定拒绝恢复并提示重新规划。
type Checkpoint struct {
Superstep int `json:"superstep"`
At time.Time `json:"at"`
GraphHash string `json:"graph_hash"`
StepStates []StepSnapshot `json:"steps"`
wsDir string `json:"-"`
}
// write 立即同步落盘(经当前 Store 端口——SQLite 后端时进数据库)。
func (cp Checkpoint) write(store Store) error {
return store.SaveCheckpoint(cp.wsDir, cp)
}
// StepSnapshot 是检查点里的单步快照。
type StepSnapshot struct {
ID string `json:"id"`
Status string `json:"status"`
AttemptID string `json:"attempt_id,omitempty"`
}
// CheckpointMode 快照写盘档位(Q1 WP4 三档)。
type CheckpointMode string
const (
// CheckpointSync 每个 superstep 边界同步写(默认,与现状「逐步全量落盘」一致)。
CheckpointSync CheckpointMode = "sync"
// CheckpointAsync 边界快照进队列由单 goroutine 串行写(边界不阻塞;恢复粒度同 sync)。
CheckpointAsync CheckpointMode = "async"
// CheckpointOnExit 只在推进退出(终态/挂起/错误)时写最终快照。
CheckpointOnExit CheckpointMode = "on_exit"
)
// checkpointWriter 按档位执行边界快照;async 档单 goroutine 串行落盘。
type checkpointWriter struct {
mode CheckpointMode
store Store
ch chan Checkpoint
done chan error
}
func newCheckpointWriter(mode CheckpointMode, store Store) *checkpointWriter {
if mode == "" {
mode = CheckpointSync
}
if store == nil {
store = FileStore{}
}
w := &checkpointWriter{mode: mode, store: store}
if mode == CheckpointAsync {
w.ch = make(chan Checkpoint, 16)
w.done = make(chan error, 1)
go func() {
var first error
for cp := range w.ch {
if first != nil {
continue
}
if err := cp.write(w.store); err != nil {
first = err
}
}
w.done <- first
}()
}
return w
}
// boundary 由调用方(flowDriver)在 superstep 边界触发:
// sync 立即写;async 入队(满即错,不静默丢);on_exit 跳过。
func (w *checkpointWriter) boundary(wsDir string, cp Checkpoint) error {
cp.wsDir = wsDir
switch w.mode {
case CheckpointAsync:
select {
case w.ch <- cp:
default:
return fmt.Errorf("checkpoint queue full at superstep %d", cp.Superstep)
}
return nil
case CheckpointOnExit:
return nil
default:
return cp.write(w.store)
}
}
// flush 在推进退出前调用:async 落完并报告首个错误;on_exit 写最终快照。
func (w *checkpointWriter) flush(wsDir string, cp Checkpoint) error {
cp.wsDir = wsDir
switch w.mode {
case CheckpointAsync:
close(w.ch)
return <-w.done
case CheckpointOnExit:
return cp.write(w.store)
default:
return nil
}
}
// validateGraphContinuity 恢复连续性校验(Q1 WP4;WP1 的图版本裁定)。
// 最新检查点存在且其图指纹与当前 Steps 重编译不一致时拒绝恢复——
// 崩溃期间图形态被改过,继续推进会把已完成的结果接到错误的图上。
// 检查点不存在(旧运行)或指纹一致则放行。
func validateGraphContinuity(st *RunState) error {
latest, err := st.storeOr().LoadLatestCheckpoint(st.Meta.WorkDir)
if err != nil || latest == nil {
return err
}
g, gerr := compileGraph(st.Plan.Workflow.Steps)
if gerr != nil {
return fmt.Errorf("graph continuity: 当前 Steps 无法编译:%w(请人工介入或重新规划)", gerr)
}
if latest.GraphHash != g.hash() {
return fmt.Errorf("graph continuity: 崩溃前后图形态不一致(checkpoint superstep=%d hash=%s,当前=%s)——可能在动态调整或外部修改后崩溃,请重新规划或人工介入",
latest.Superstep, latest.GraphHash, g.hash())
}
return nil
}
+308
View File
@@ -0,0 +1,308 @@
package squad
import (
"context"
"fmt"
"strings"
"time"
)
// taskboard.go — task_board 拓扑驱动(Q2 WP4)。
// 形态:计划步骤 = 板上任务(Needs = 依赖门);执行语义是队列化的
// claim/lease/complete 三原语——工作者认领任务(带租约时限)、执行、完成;
// 进程死亡留下的孤儿租约(claimed 且租约过期)在驱动重入时再武装回 open
// (Attempts 保留,超限由步骤级重试护栏兜底)。全部任务收口后走标准
// finalize(交付物/质检/降级判定与图拓扑同一收口面)。
// 挂起(HITL)安全:claimed 且步骤未完的任务在重入时续跑,不重复认领。
func init() {
_ = RegisterTopologyDriver(TopologyTaskBoard)
}
// DefaultTaskLeaseSeconds 任务租约缺省时限(秒)。
const DefaultTaskLeaseSeconds = 600
// boardSkipReason 依赖门无法满足(上游 failed/skipped)时的跳过原因。
const boardSkipReason = "task_board: 依赖未满足(上游失败或跳过)"
// BoardTask 是任务板上一条任务的队列状态。
type BoardTask struct {
ID string `json:"id"`
Status string `json:"status"` // open / claimed / done / failed / skipped
ClaimedBy string `json:"claimed_by,omitempty"`
// LeaseExpires 认领租约到期时刻;过期未 complete = 孤儿租约,重入再武装。
LeaseExpires time.Time `json:"lease_expires,omitempty"`
// Attempts 认领次数(孤儿再武装累加;执行重试由步骤级护栏负责)。
Attempts int `json:"attempts,omitempty"`
}
// runTaskBoardDriver task_board 拓扑入口(幂等可恢复)。
func (e *SquadEngine) runTaskBoardDriver(ctx context.Context, st *RunState) (*RunResult, error) {
drv := &flowDriver{opts: e.deps}
e.initBoard(st)
e.rearmOrphanLeases(st)
leaseTTL := DefaultTaskLeaseSeconds
if v := st.Meta.Metadata["task_board_lease_seconds"]; v != "" {
if n := parseIntDefault(v, DefaultTaskLeaseSeconds); n > 0 {
leaseTTL = n
}
}
for {
idx, held := e.boardHeldTask(st)
if !held {
var ok bool
idx, ok = e.boardClaimNext(st, leaseTTL)
if !ok {
// 无可认领:把依赖已死(上游 failed/skipped)的 open 任务级联
// 置 skipped;再无变化则收口。
if !e.boardSkipDeadlocked(st) {
break
}
continue
}
}
out, gate, err := e.boardRunTask(ctx, drv, st, idx)
if err != nil {
return nil, err
}
if gate != nil {
if out == nil {
out = heldResult(st, gate)
}
return out, nil
}
if out != nil { // 关键任务失败:fail-closed 传播(failStep 已置运行失败)
return out, nil
}
e.boardSettleTask(st, idx)
}
return e.boardFinish(ctx, drv, st)
}
// initBoard 首次进入时从计划步骤派生任务板(恢复重入直读持久化状态)。
func (e *SquadEngine) initBoard(st *RunState) {
st.mu.Lock()
defer st.mu.Unlock()
if len(st.Board) > 0 {
return
}
for _, s := range st.Plan.Workflow.Steps {
status := "open"
switch s.Status {
case "completed":
status = "done"
case "failed":
status = "failed"
case "skipped":
status = "skipped"
}
st.Board = append(st.Board, BoardTask{ID: s.ID, Status: status})
}
_ = st.save()
}
// rearmOrphanLeases 孤儿租约再武装:claimed 且租约过期 → open(进程死亡
// 恢复路径;Attempts 保留作观测)。
func (e *SquadEngine) rearmOrphanLeases(st *RunState) {
now := time.Now()
st.mu.Lock()
var rearmed []string
for i := range st.Board {
t := &st.Board[i]
if t.Status == "claimed" && !t.LeaseExpires.IsZero() && t.LeaseExpires.Before(now) {
t.Status = "open"
t.ClaimedBy = ""
rearmed = append(rearmed, t.ID)
}
}
changed := len(rearmed) > 0
if changed {
_ = st.save()
}
st.mu.Unlock()
if changed {
(&flowDriver{opts: e.deps}).emit(st, Event{Type: EvPhase,
Text: fmt.Sprintf("task_board:孤儿租约再武装 %s", strings.Join(rearmed, ","))})
}
}
// boardHeldTask 返回 claimed 且步骤未完的任务(HITL 挂起/中断重入的续跑点)。
func (e *SquadEngine) boardHeldTask(st *RunState) (int, bool) {
for ti := range st.Board {
t := &st.Board[ti]
if t.Status != "claimed" {
continue
}
if idx, s := e.boardStep(st, t.ID); idx >= 0 && (s.Status == "pending" || s.Status == "running") {
return idx, true
}
}
return -1, false
}
// boardClaimNext 认领下一个可执行任务(open 且依赖门全 done),返回步骤下标。
func (e *SquadEngine) boardClaimNext(st *RunState, leaseTTL int) (int, bool) {
st.mu.Lock()
defer st.mu.Unlock()
for ti := range st.Board {
t := &st.Board[ti]
if t.Status != "open" {
continue
}
if !e.boardDepsDoneLocked(st, t.ID) {
continue
}
idx, s := e.boardStepLocked(st, t.ID)
if idx < 0 {
continue
}
t.Status = "claimed"
t.ClaimedBy = s.Role
t.Attempts++
t.LeaseExpires = time.Now().Add(time.Duration(leaseTTL) * time.Second)
_ = st.save()
(&flowDriver{opts: e.deps}).emit(st, Event{Type: EvPhase, StepID: t.ID, Role: s.Role,
Text: fmt.Sprintf("task_board:%s 认领 %s(租约 %ds,第 %d 次)", s.Role, t.ID, leaseTTL, t.Attempts)})
return idx, true
}
return -1, false
}
// boardSkipDeadlocked 把依赖已死(上游 failed/skipped)的 open 任务级联置
// skipped;返回是否有变化(有则继续循环级联)。
func (e *SquadEngine) boardSkipDeadlocked(st *RunState) bool {
st.mu.Lock()
changed := false
for ti := range st.Board {
t := &st.Board[ti]
if t.Status != "open" {
continue
}
if e.boardDepsDeadLocked(st, t.ID) {
t.Status = "skipped"
if idx, s := e.boardStepLocked(st, t.ID); idx >= 0 {
s.Status = "skipped"
s.Error = boardSkipReason
st.Plan.Workflow.Steps[idx] = *s
}
changed = true
}
}
if changed {
_ = st.save()
}
st.mu.Unlock()
return changed
}
// boardRunTask 执行已认领任务:过门 → runOneStep。
func (e *SquadEngine) boardRunTask(ctx context.Context, drv *flowDriver, st *RunState, idx int) (*RunResult, *HitlGate, error) {
out, gate, err := drv.gateBeforeRun(ctx, st, idx)
if err != nil || gate != nil || out != nil {
return out, gate, err
}
return drv.runOneStep(ctx, st, idx)
}
// boardSettleTask 任务结算:步骤 completed → done;failed(非关键降级)→ failed。
func (e *SquadEngine) boardSettleTask(st *RunState, idx int) {
step := st.Plan.Workflow.Steps[idx]
status := "done"
if step.Status == "failed" {
status = "failed"
}
st.mu.Lock()
for i := range st.Board {
if st.Board[i].ID == step.ID {
st.Board[i].Status = status
st.Board[i].LeaseExpires = time.Time{}
}
}
_ = st.save()
st.mu.Unlock()
(&flowDriver{opts: e.deps}).emit(st, Event{Type: EvPhase, StepID: step.ID, Role: step.Role,
Text: fmt.Sprintf("task_board:%s → %s", step.ID, status)})
}
// boardFinish 全部任务收口后走标准 finalize(交付物/质检/降级与图拓扑同面)。
// 仍有 open/claimed 任务 = 依赖不可满足,fail-closed 不静默收口。
func (e *SquadEngine) boardFinish(ctx context.Context, drv *flowDriver, st *RunState) (*RunResult, error) {
done, total := 0, len(st.Board)
for _, t := range st.Board {
switch t.Status {
case "done":
done++
case "open", "claimed":
return e.driverFail(st, fmt.Sprintf("task_board: 任务 %s 无法收口(依赖不可满足)", t.ID)), nil
}
}
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("task_board:收口(done %d/%d)", done, total)})
out, gate, err := drv.finalize(ctx, st)
if err != nil {
return nil, err
}
if gate != nil {
return heldResult(st, gate), nil
}
return out, nil
}
// boardStep 按任务 ID 找步骤(只读副本)。
func (e *SquadEngine) boardStep(st *RunState, id string) (int, *StepSpec) {
st.mu.Lock()
defer st.mu.Unlock()
return e.boardStepLocked(st, id)
}
// boardStepLocked 按任务 ID 找步骤(调用方持锁)。返回下标与副本指针。
func (e *SquadEngine) boardStepLocked(st *RunState, id string) (int, *StepSpec) {
for i := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[i].ID == id {
s := st.Plan.Workflow.Steps[i]
return i, &s
}
}
return -1, nil
}
// boardDepsDoneLocked 依赖门:Needs 全部 done(调用方持锁)。
func (e *SquadEngine) boardDepsDoneLocked(st *RunState, id string) bool {
needs := e.boardNeedsLocked(st, id)
for _, dep := range needs {
found := false
for i := range st.Board {
if st.Board[i].ID == dep {
found = true
if st.Board[i].Status != "done" {
return false
}
}
}
if !found {
return false // 依赖不在板上 = fail-closed 不认领
}
}
return true
}
// boardDepsDeadLocked 依赖已死:任一 Need 为 failed/skipped(级联跳过依据)。
func (e *SquadEngine) boardDepsDeadLocked(st *RunState, id string) bool {
for _, dep := range e.boardNeedsLocked(st, id) {
for i := range st.Board {
if st.Board[i].ID == dep && (st.Board[i].Status == "failed" || st.Board[i].Status == "skipped") {
return true
}
}
}
return false
}
func (e *SquadEngine) boardNeedsLocked(st *RunState, id string) []string {
for i := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[i].ID == id {
return st.Plan.Workflow.Steps[i].Needs
}
}
return nil
}
+120
View File
@@ -0,0 +1,120 @@
package squad
import (
"fmt"
"time"
)
// Termination 运行级停止条件的单一注册点(Q1 WP6):leader 轮次、修复预算、
// 唤醒次数与步骤超时集中声明;复合判定用 And/Or 原语(AutoGen 终止条件语义)。
// 默认值与现网一致;引擎选项在 terminationOf 覆盖对应字段。
type Termination struct {
MaxLeaderTurns int
RepairBudget int
MaxRepairGranted int // 人工 retry 累计加预算上限(Q26):默认 RepairBudget*2
MaxWakeupFires int
StepTimeout time.Duration
}
// DefaultTermination 与现网四处的硬编码取值一致。
func DefaultTermination() Termination {
return Termination{
MaxLeaderTurns: DefaultMaxLeaderTurns,
RepairBudget: 1,
MaxRepairGranted: 2,
MaxWakeupFires: MaxWakeupFires,
StepTimeout: 10 * time.Minute,
}
}
// terminationOf 取本运行的停止条件:引擎选项与运行状态覆盖默认值。
func (e *SquadEngine) terminationOf(st *RunState) Termination {
t := DefaultTermination()
if e.opts.RepairBudget > 0 {
t.RepairBudget = e.opts.RepairBudget
t.MaxRepairGranted = e.opts.RepairBudget * 2
}
if e.opts.StepTimeout > 0 {
t.StepTimeout = e.opts.StepTimeout
}
if st != nil && st.Leader != nil && st.Leader.MaxTurns > 0 {
t.MaxLeaderTurns = st.Leader.MaxTurns
}
return t
}
// Counters 运行期计数,由各判定处喂入(值语义快照)。
type Counters struct {
LeaderTurns int
RepairSpent int
RepairGranted int
WakeupFires int
Deadline time.Time
}
// Cond 是一条停止条件:返回(原因,是否命中)。
type Cond func(c Counters, now time.Time) (string, bool)
// MaxTurnsC 轮次上限。
func MaxTurnsC(n int) Cond {
return func(c Counters, _ time.Time) (string, bool) {
if c.LeaderTurns >= n {
return fmt.Sprintf("leader 轮次达上限 %d", n), true
}
return "", false
}
}
// RepairBudgetC 修复预算耗尽。
func RepairBudgetC(n int) Cond {
return func(c Counters, _ time.Time) (string, bool) {
if c.RepairSpent > n {
return fmt.Sprintf("修复预算耗尽(%d/%d)", c.RepairSpent, n), true
}
return "", false
}
}
// WakeupsC 自动唤醒次数超限。
func WakeupsC(n int) Cond {
return func(c Counters, _ time.Time) (string, bool) {
if c.WakeupFires > n {
return fmt.Sprintf("自动唤醒次数超限(%d/%d)", c.WakeupFires, n), true
}
return "", false
}
}
// DeadlineC 超过截止时刻。
func DeadlineC() Cond {
return func(c Counters, now time.Time) (string, bool) {
if !c.Deadline.IsZero() && now.After(c.Deadline) {
return "超过截止时刻", true
}
return "", false
}
}
// And 全部条件命中才成立。
func And(conds ...Cond) Cond {
return func(c Counters, now time.Time) (string, bool) {
for _, k := range conds {
if _, hit := k(c, now); !hit {
return "", false
}
}
return "all conditions met", true
}
}
// Or 任一条件命中即成立(默认的停止语义)。
func Or(conds ...Cond) Cond {
return func(c Counters, now time.Time) (string, bool) {
for _, k := range conds {
if r, hit := k(c, now); hit {
return r, true
}
}
return "", false
}
}
+82
View File
@@ -0,0 +1,82 @@
package squad
import (
"context"
"strings"
"testing"
"time"
)
// TestTerminationAndOr:And 全中才成立、Or 任一中即成立(WP6 原语)。
func TestTerminationAndOr(t *testing.T) {
c := Counters{LeaderTurns: 8, RepairSpent: 2, WakeupFires: 0}
or := Or(MaxTurnsC(8), WakeupsC(1))
if _, hit := or(c, time.Now()); !hit {
t.Fatal("or should hit on turns")
}
and := And(MaxTurnsC(8), WakeupsC(1))
if _, hit := and(c, time.Now()); hit {
t.Fatal("and should not hit with zero wakeups")
}
and2 := And(MaxTurnsC(8), RepairBudgetC(1))
if _, hit := and2(c, time.Now()); !hit {
t.Fatal("and should hit when all met")
}
dl := DeadlineC()
past := Counters{Deadline: time.Now().Add(-time.Minute)}
if _, hit := dl(past, time.Now()); !hit {
t.Fatal("deadline should hit when past")
}
}
// TestRepairGrantCap:人工 retry 累计追加有上限(Q26)。
func TestRepairGrantCap(t *testing.T) {
engine, _ := newTestEngine(t, mockScriptPlanner(baseSteps()...), map[string]map[string]string{
"01_researcher": {"notes.md": goodReport, "fields.json": "{}"},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}, NopSink{})
res, err := engine.Run(context.Background(), Goal{Text: "先跑完再验", HitlMode: HitlAuto}, RunOptions{})
if err != nil || res.Status != RunCompleted {
t.Fatalf("setup run: %v %+v", err, res)
}
// 预置:修复授予已达上限 + 一个真实的质检挂起门(与 finalize 的形态一致)。
st, err := loadRunState(res.WorkDir)
if err != nil {
t.Fatal(err)
}
st.RepairBudgetGranted = engine.terminationOf(st).MaxRepairGranted
st.Meta.CompletedAt = nil
st.Meta.Status = string(RunQualityReview)
req := &HumanRequest{
ID: newRequestID(), RunID: st.Meta.ID, Kind: HitlQualityReview, Severity: SeverityActionRequired,
Question: "交付物质检未通过且自动修复预算已用尽,请人工裁决:",
Options: []HumanOption{
{Value: "approve", Label: "人工放行(记录人工通过)"},
{Value: "retry", Label: "再修复一次(重跑交付步骤)"},
{Value: "abort", Label: "判定失败"},
},
CreatedAt: time.Now(), Status: "pending",
}
if err := st.saveGate(&HitlGate{Request: *req, PrevStatus: RunExecuting}); err != nil {
t.Fatal(err)
}
if err := st.save(); err != nil {
t.Fatal(err)
}
// 人工 retry 应被拒绝而不是再追加。
out, err := engine.Respond(context.Background(), res.RunID, RespondDecision{Choice: "retry"})
if err != nil {
t.Fatal(err)
}
if out == nil || !strings.Contains(out.Error, "grants exhausted") {
t.Fatalf("expected grant-cap failure, got %+v err=%v", out, err)
}
st2, err := loadRunState(res.WorkDir)
if err != nil {
t.Fatal(err)
}
if st2.RepairBudgetGranted != engine.terminationOf(st2).MaxRepairGranted {
t.Fatalf("granted changed after cap: %d", st2.RepairBudgetGranted)
}
}
+130
View File
@@ -0,0 +1,130 @@
package squad
import "fmt"
// topology.go — 拓扑一等化(Q2):内置拓扑名注册表。
// 拓扑不新造执行面:graph 类编译为 Steps 图(Q1 机制),leader 走既有
// Mode 委派循环,driver 类由引擎按名分发(review/group_chat/task_board),
// primitive 类是跨拓扑原语(handoff/mailbox),不可单独作为运行拓扑。
// 未知名与不可运行Kind一律 fail-closed。
// 内置拓扑名(completion doc Q2 清单的 12 种)。
const (
TopologySequential = "sequential"
TopologyParallel = "parallel"
TopologyLoop = "loop"
TopologyRouter = "router"
TopologyLeader = "leader"
TopologyHandoff = "handoff"
TopologyGroupChat = "group_chat"
TopologyReview = "review"
TopologyEvaluatorOptimizer = "evaluator_optimizer"
TopologyLongRunning = "long_running"
TopologyTaskBoard = "task_board"
TopologyMailbox = "mailbox"
)
// TopologyKind 是拓扑的执行形态。
type TopologyKind string
const (
// TopologyGraph 编译为 Steps 图,由 flowDriver.advance 执行。
TopologyGraph TopologyKind = "graph"
// TopologyMode 走既有 Mode 分支(leader 委派循环)。
TopologyMode TopologyKind = "mode"
// TopologyDriver 由引擎按名分发到专用驱动。
TopologyDriver TopologyKind = "driver"
// TopologyPrimitive 是跨拓扑原语,不可单独运行。
TopologyPrimitive TopologyKind = "primitive"
)
// TopologySpec 是一个内置拓扑的注册项。
type TopologySpec struct {
Name string
Kind TopologyKind
}
// builtinTopologies 是 12 种内置拓扑的注册表(唯一权威清单)。
var builtinTopologies = []TopologySpec{
{Name: TopologySequential, Kind: TopologyGraph},
{Name: TopologyParallel, Kind: TopologyGraph},
{Name: TopologyLoop, Kind: TopologyGraph},
{Name: TopologyRouter, Kind: TopologyGraph},
{Name: TopologyLeader, Kind: TopologyMode},
{Name: TopologyHandoff, Kind: TopologyPrimitive},
{Name: TopologyGroupChat, Kind: TopologyDriver},
{Name: TopologyReview, Kind: TopologyDriver},
{Name: TopologyEvaluatorOptimizer, Kind: TopologyGraph},
{Name: TopologyLongRunning, Kind: TopologyGraph},
{Name: TopologyTaskBoard, Kind: TopologyDriver},
{Name: TopologyMailbox, Kind: TopologyPrimitive},
}
// BuiltinTopologies 返回内置拓扑清单(只读副本)。
func BuiltinTopologies() []TopologySpec {
out := make([]TopologySpec, len(builtinTopologies))
copy(out, builtinTopologies)
return out
}
// LookupTopology 按名查注册项。
func LookupTopology(name string) (TopologySpec, bool) {
for _, t := range builtinTopologies {
if t.Name == name {
return t, true
}
}
return TopologySpec{}, false
}
// registeredDrivers 是已接线的 driver 拓扑(WP 增量逐个点亮;未点亮 = fail-closed)。
var registeredDrivers = map[string]bool{}
// RegisterTopologyDriver 由驱动实现方在 init/装配时点亮一个 driver 拓扑。
func RegisterTopologyDriver(name string) error {
spec, ok := LookupTopology(name)
if !ok {
return fmt.Errorf("topology: 未注册的拓扑名 %q", name)
}
if spec.Kind != TopologyDriver {
return fmt.Errorf("topology: %q 不是 driver 类(kind=%s)", name, spec.Kind)
}
registeredDrivers[name] = true
return nil
}
// checkTopology 校验一次运行的有效拓扑名,返回其 Kind。
// 空名 = 未声明(standard 图行为,兼容既有调用方)。
// 未注册名 / primitive 单跑 / driver 未点亮都 fail-closed。
func checkTopology(name string) (TopologyKind, error) {
if name == "" {
return TopologyGraph, nil
}
spec, ok := LookupTopology(name)
if !ok {
return "", fmt.Errorf("topology: 未知拓扑 %q(内置清单:%s)", name, topologyNames())
}
switch spec.Kind {
case TopologyGraph, TopologyMode:
return spec.Kind, nil
case TopologyPrimitive:
return "", fmt.Errorf("topology: %q 是跨拓扑原语,不可单独作为运行拓扑", name)
case TopologyDriver:
if !registeredDrivers[name] {
return "", fmt.Errorf("topology: %q 驱动未注册(fail-closed)", name)
}
return spec.Kind, nil
}
return "", fmt.Errorf("topology: %q kind 非法", name)
}
func topologyNames() string {
s := ""
for i, t := range builtinTopologies {
if i > 0 {
s += ", "
}
s += t.Name
}
return s
}
+435
View File
@@ -0,0 +1,435 @@
package squad
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// topology_conformance_test.go — Q2 验收一的机器证据:每种拓扑一个
// 脚本化后端的 conformance(零模型、零网络)。
// ---- 脚本化夹具 ----
// collectSink 收集事件流供断言。
type collectSink struct {
mu sync.Mutex
events []Event
}
func (c *collectSink) Emit(ev Event) {
c.mu.Lock()
defer c.mu.Unlock()
c.events = append(c.events, ev)
}
func (c *collectSink) snapshot() []Event {
c.mu.Lock()
defer c.mu.Unlock()
return append([]Event(nil), c.events...)
}
func (c *collectSink) countText(sub string) int {
n := 0
for _, ev := range c.snapshot() {
if strings.Contains(ev.Text, sub) {
n++
}
}
return n
}
// iterBackend 按「步骤子目录 × 第几次执行」回放内容(循环/评估拓扑需要
// 逐轮不同的 fields.json)。
type iterBackend struct {
mu sync.Mutex
calls map[string]int
content func(stepKey string, iter int, target string) []byte
delay time.Duration
}
func (b *iterBackend) Name() string { return "iter" }
func (b *iterBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
if b.delay > 0 {
time.Sleep(b.delay)
}
b.mu.Lock()
b.calls[key]++
iter := b.calls[key]
b.mu.Unlock()
patches := map[string][]byte{}
for _, t := range req.Targets {
var data []byte
if b.content != nil {
data = b.content(key, iter, t)
}
if data == nil {
data = []byte(goodReport)
}
out := filepath.Join(req.WorkDir, t)
_ = os.MkdirAll(filepath.Dir(out), 0o755)
if err := os.WriteFile(out, data, 0o644); err != nil {
return nil, err
}
patches[t] = data
}
return &ExecResult{Success: true, Patches: patches}, nil
}
func (b *iterBackend) totalCalls() int {
b.mu.Lock()
defer b.mu.Unlock()
n := 0
for _, v := range b.calls {
n += v
}
return n
}
func newTopologyEngine(t *testing.T, planner Planner, be Executor, sink ProgressSink) *SquadEngine {
t.Helper()
dir := t.TempDir()
reg := NewRoleRegistry()
for _, spec := range []RoleSpec{
{Role: "researcher", Name: "调研", Backend: "iter"},
{Role: "writer", Name: "撰写", Backend: "iter"},
{Role: "reviewer", Name: "审核", Backend: "iter"},
} {
if err := reg.Register(spec); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance("iter", be)
if sink == nil {
sink = NopSink{}
}
engine, err := NewEngine(Options{
DataDir: dir, Registry: reg, Planner: planner, Backends: backends, Sink: sink,
})
if err != nil {
t.Fatal(err)
}
return engine
}
func fieldsJSON(kv map[string]string) []byte {
b, _ := json.Marshal(kv)
return b
}
// ---- sequential / parallel:既有图语义 + 拓扑名受理 ----
func TestTopologyConformance_sequential(t *testing.T) {
be := &iterBackend{calls: map[string]int{}}
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"notes.md"}},
StepSpec{ID: "s2", Role: "writer", Title: "二", Targets: []string{"output.md"}},
), be, sink)
res, err := engine.Run(context.Background(), Goal{Text: "顺序任务"}, RunOptions{Topology: TopologySequential})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
if be.totalCalls() != 2 {
t.Fatalf("执行次数 = %d", be.totalCalls())
}
if sink.countText("拓扑 sequential") != 1 {
t.Fatal("缺拓扑受理事件")
}
}
func TestTopologyConformance_parallel(t *testing.T) {
be := &iterBackend{calls: map[string]int{}}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "并行甲", Parallel: true, Targets: []string{"a.md"}},
StepSpec{ID: "s2", Role: "writer", Title: "并行乙", Parallel: true, Targets: []string{"b.md"}},
StepSpec{ID: "s3", Role: "reviewer", Title: "汇合", Targets: []string{"output.md"}},
), be, nil)
res, err := engine.Run(context.Background(), Goal{Text: "并行任务"}, RunOptions{Topology: TopologyParallel})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted || be.totalCalls() != 3 {
t.Fatalf("status = %s calls = %d err = %v", res.Status, be.totalCalls(), res.Error)
}
}
// ---- loop:条件停机 + 预算触顶 ----
func TestTopologyConformance_loopUntilField(t *testing.T) {
// 第 1-2 轮 done=no,第 3 轮 done=yes → 恰执行 3 次。
be := &iterBackend{calls: map[string]int{}, content: func(_ string, iter int, target string) []byte {
if target == "fields.json" {
if iter >= 3 {
return fieldsJSON(map[string]string{"done": "yes"})
}
return fieldsJSON(map[string]string{"done": "no"})
}
return nil
}}
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "循环体", Targets: []string{"output.md", "fields.json"},
Loop: &LoopSpec{MaxIterations: 5, UntilField: "done", UntilEquals: "yes"}},
), be, sink)
res, err := engine.Run(context.Background(), Goal{Text: "循环任务"}, RunOptions{Topology: TopologyLoop})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
if be.totalCalls() != 3 {
t.Fatalf("应在条件满足时停机(3 次),实际 %d", be.totalCalls())
}
if sink.countText("再武装") != 2 {
t.Fatalf("再武装事件应 2 次:%d", sink.countText("再武装"))
}
// 计划里的 LoopRuns 持久化为 2。
if len(res.Plan.Steps) != 1 || res.Plan.Steps[0].LoopRuns != 2 {
t.Fatalf("LoopRuns = %+v", res.Plan.Steps)
}
}
func TestTopologyConformance_loopBudgetExhausted(t *testing.T) {
// 条件永不满足:恰执行 MaxIterations 次后触顶(非失败)。
be := &iterBackend{calls: map[string]int{}, content: func(_ string, _ int, target string) []byte {
if target == "fields.json" {
return fieldsJSON(map[string]string{"done": "no"})
}
return nil
}}
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "循环体", Targets: []string{"output.md", "fields.json"},
Loop: &LoopSpec{MaxIterations: 3, UntilField: "done", UntilEquals: "yes"}},
), be, sink)
res, err := engine.Run(context.Background(), Goal{Text: "循环任务"}, RunOptions{Topology: TopologyLoop})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("触顶应是完成而非失败:%s %v", res.Status, res.Error)
}
if be.totalCalls() != 3 {
t.Fatalf("恰 3 次,实际 %d", be.totalCalls())
}
if sink.countText("loop_exhausted") != 1 {
t.Fatal("缺 loop_exhausted 事件")
}
}
// ---- router:分支选中/落选跳过/无命中 fail-closed ----
func routerSteps() []StepSpec {
return []StepSpec{
{ID: "r1", Role: "researcher", Title: "路由", Targets: []string{"output.md", "fields.json"},
Branches: []Branch{
{When: BranchCond{Field: "route", Equals: "fast"}, Then: []string{"fast"}},
{When: BranchCond{Field: "route", NotEquals: "fast"}, Then: []string{"slow"}},
}},
{ID: "fast", Role: "writer", Title: "快线", Needs: []string{"r1"}, Targets: []string{"output.md"}},
{ID: "slow", Role: "writer", Title: "慢线", Needs: []string{"r1"}, Targets: []string{"output.md"}},
}
}
func TestTopologyConformance_routerSelectsBranch(t *testing.T) {
be := &iterBackend{calls: map[string]int{}, content: func(_ string, _ int, target string) []byte {
if target == "fields.json" {
return fieldsJSON(map[string]string{"route": "fast"})
}
return nil
}}
engine := newTopologyEngine(t, mockScriptPlanner(routerSteps()...), be, nil)
res, err := engine.Run(context.Background(), Goal{Text: "路由任务"}, RunOptions{Topology: TopologyRouter})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("分支落选是预期路径,不得降级/失败:%s %v", res.Status, res.Error)
}
byID := map[string]StepSpec{}
for _, s := range res.Plan.Steps {
byID[s.ID] = s
}
if byID["fast"].Status != "completed" {
t.Fatalf("fast = %s", byID["fast"].Status)
}
if byID["slow"].Status != "skipped" || byID["slow"].Error != routerSkipReason {
t.Fatalf("slow = %s %q", byID["slow"].Status, byID["slow"].Error)
}
}
func TestTopologyConformance_routerNoMatchFailsClosed(t *testing.T) {
be := &iterBackend{calls: map[string]int{}, content: func(_ string, _ int, target string) []byte {
if target == "fields.json" {
return fieldsJSON(map[string]string{"route": ""}) // 两条分支都不命中
}
return nil
}}
steps := routerSteps()
// 去掉 NotEquals 兜底分支,制造无命中。
steps[0].Branches = []Branch{{When: BranchCond{Field: "route", Equals: "fast"}, Then: []string{"fast"}}}
engine := newTopologyEngine(t, mockScriptPlanner(steps...), be, nil)
res, err := engine.Run(context.Background(), Goal{Text: "路由任务"}, RunOptions{Topology: TopologyRouter})
if err != nil {
t.Fatal(err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, "无分支命中") {
t.Fatalf("无命中必须 fail-closed:%s %v", res.Status, res.Error)
}
}
// ---- evaluator-optimizer:draft→eval 循环复合,收敛即停 ----
func TestTopologyConformance_evaluatorOptimizer(t *testing.T) {
// eval 第 1-2 轮 verdict=fail,第 3 轮 pass;draft 随 body 再武装。
be := &iterBackend{calls: map[string]int{}, content: func(key string, iter int, target string) []byte {
if target == "fields.json" {
if iter >= 3 {
return fieldsJSON(map[string]string{"verdict": "pass"})
}
return fieldsJSON(map[string]string{"verdict": "fail"})
}
if target == "verdict.md" {
return []byte(fmt.Sprintf("评审第 %d 轮\n\n%s", iter, goodReport))
}
return nil
}}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "draft", Role: "writer", Title: "起草", Targets: []string{"output.md"}},
StepSpec{ID: "eval", Role: "reviewer", Title: "评审", Targets: []string{"verdict.md", "fields.json"},
Loop: &LoopSpec{MaxIterations: 4, UntilField: "verdict", UntilEquals: "pass", Body: []string{"draft"}}},
), be, nil)
res, err := engine.Run(context.Background(), Goal{Text: "起草并评审"}, RunOptions{Topology: TopologyEvaluatorOptimizer})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
byID := map[string]StepSpec{}
for _, s := range res.Plan.Steps {
byID[s.ID] = s
}
if byID["eval"].LoopRuns != 2 {
t.Fatalf("eval 应再武装 2 轮:%+v", byID["eval"])
}
// draft 3 次 + eval 3 次。
if be.totalCalls() != 6 {
t.Fatalf("总执行次数 = %d,期望 6", be.totalCalls())
}
}
// ---- long-running:截止时刻 → 未完步骤 skipped、降级收口 ----
func TestTopologyConformance_longRunningDeadline(t *testing.T) {
// 时序鲁棒设计:每步 300ms、截止 +500ms——装配耗时不影响断言核心:
// s1 必完成(首个 superstep 在截止前开跑),s3 必被跳过(第三个
// superstep 边界必超期),收口必为 degraded。s2 完成与否取决于装配
// 耗时,不做断言。
be := &iterBackend{calls: map[string]int{}, delay: 300 * time.Millisecond}
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"notes.md"}},
StepSpec{ID: "s2", Role: "writer", Title: "二", Targets: []string{"output.md"}},
StepSpec{ID: "s3", Role: "reviewer", Title: "三", Targets: []string{"output.md"}},
), be, sink)
res, err := engine.Run(context.Background(), Goal{Text: "长任务"}, RunOptions{
Topology: TopologyLongRunning,
Deadline: time.Now().Add(500 * time.Millisecond),
})
if err != nil {
t.Fatal(err)
}
if res.Status != RunDegraded {
t.Fatalf("截止后应降级收口:%s %v", res.Status, res.Error)
}
byID := map[string]StepSpec{}
for _, s := range res.Plan.Steps {
byID[s.ID] = s
}
if byID["s1"].Status != "completed" {
t.Fatalf("s1 = %s", byID["s1"].Status)
}
if byID["s3"].Status != "skipped" || byID["s3"].Error != "deadline exceeded" {
t.Fatalf("s3 = %s %q", byID["s3"].Status, byID["s3"].Error)
}
if sink.countText("截止时刻") != 1 {
t.Fatal("缺截止事件")
}
}
// ---- 拓扑校验 fail-closed ----
func TestTopologyValidationFailsClosed(t *testing.T) {
be := &iterBackend{calls: map[string]int{}}
cases := []struct {
name string
topo string
wantErr string
}{
{"未注册名", "bogus_topology", "未知拓扑"},
{"primitive 单跑", TopologyMailbox, "跨拓扑原语"},
{"primitive 单跑 handoff", TopologyHandoff, "跨拓扑原语"},
// review/group_chat(WP3)与 task_board(WP4)驱动均已点亮;
// 12/12 受理面由 TestTopologyConformance_allTwelve 汇总钉住。
}
for _, tc := range cases {
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"output.md"}},
), be, nil)
res, err := engine.Run(context.Background(), Goal{Text: "x"}, RunOptions{Topology: tc.topo})
if err != nil {
t.Fatalf("%s:err = %v", tc.name, err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, tc.wantErr) {
t.Fatalf("%s:%s %q(期望含 %q)", tc.name, res.Status, res.Error, tc.wantErr)
}
}
}
// leader 拓扑名映射到既有 Mode 委派循环(本测只钉「受理且映射」,
// leader 行为 conformance 在 leader_test.go / WP2 增强)。
func TestTopologyLeaderMapsToMode(t *testing.T) {
spec, ok := LookupTopology(TopologyLeader)
if !ok || spec.Kind != TopologyMode {
t.Fatalf("leader 注册形态:%+v %v", spec, ok)
}
if _, err := checkTopology(TopologyLeader); err != nil {
t.Fatalf("leader 拓扑应可受理:%v", err)
}
}
// 12 种内置拓扑全部注册且 Kind 合法(清单完整性)。
func TestBuiltinTopologiesComplete(t *testing.T) {
want := map[string]TopologyKind{
TopologySequential: TopologyGraph, TopologyParallel: TopologyGraph,
TopologyLoop: TopologyGraph, TopologyRouter: TopologyGraph,
TopologyLeader: TopologyMode, TopologyHandoff: TopologyPrimitive,
TopologyGroupChat: TopologyDriver, TopologyReview: TopologyDriver,
TopologyEvaluatorOptimizer: TopologyGraph, TopologyLongRunning: TopologyGraph,
TopologyTaskBoard: TopologyDriver, TopologyMailbox: TopologyPrimitive,
}
got := BuiltinTopologies()
if len(got) != len(want) {
t.Fatalf("注册数 = %d,期望 %d", len(got), len(want))
}
for _, spec := range got {
if k, ok := want[spec.Name]; !ok || k != spec.Kind {
t.Fatalf("拓扑 %s kind = %s(期望 %s)", spec.Name, spec.Kind, k)
}
}
}
+343 -83
View File
@@ -13,96 +13,166 @@ import (
"error-code-system/errcode"
)
// flowDriver 工作流驱动器:推进 runState 直到终态或 HITL 挂起边界。
// flowDriver 工作流驱动器:推进 RunState 直到终态或 HITL 挂起边界。
type flowDriver struct {
opts engineDeps
}
// advance 执行到边界;返回 (结果, 挂起上下文),二者互斥非 nil(infra 错误时都为 nil 且 err 非 nil)。
func (d *flowDriver) advance(ctx context.Context, st *runState) (res *RunResult, gate *hitlGate, err error) {
// 推进模型(Q1 WP1/WP2):每轮从权威 Steps 现场编译调度图,按 superstep
// 分层推进——层内无写集冲突的步骤并行(runParallel 复用),有冲突的按声明序
// 串行(含动态调整);动态调整改 Steps 后下一轮自然生效。
func (d *flowDriver) advance(ctx context.Context, st *RunState) (res *RunResult, gate *HitlGate, err error) {
wf := st.Workflow()
wf.Status = "in_progress"
st.setWorkflow(wf)
d.emit(st, Event{Type: EvPhase, Text: "开始执行工作流"})
i := 0
for i < len(st.Plan.Workflow.Steps) {
step := st.Plan.Workflow.Steps[i]
switch step.Status {
case "completed", "skipped", "failed":
i++
continue
superNo := 0
// 运行级截止(Q2 long_running):superstep 边界检查,超期未完步骤置
// skipped 并降级收口;零值/不可解析 = 不设限。
var runDeadline time.Time
if v := st.Meta.Metadata["deadline"]; v != "" {
// RFC3339Nano 同时接受秒级与纳秒级两种写法。
if t, terr := time.Parse(time.RFC3339Nano, v); terr == nil {
runDeadline = t
}
if out, g, err := d.gateBeforeRun(ctx, st, i); out != nil || g != nil || err != nil {
if out == nil && g != nil {
out = heldResult(st, g)
}
cpw := newCheckpointWriter(CheckpointMode(st.Meta.Metadata["checkpoint_mode"]), st.storeOr())
// 推进退出前 flush:async 落完、on_exit 写最终快照(Q1 WP4 三档的出口语义)。
var lastGraph *graph
defer func() {
if lastGraph == nil {
if g, gerr := compileGraph(st.Plan.Workflow.Steps); gerr == nil {
lastGraph = g
}
return out, g, err
}
if lastGraph != nil {
_ = cpw.flush(st.Meta.WorkDir, snapshotOf(superNo, lastGraph, st.Plan.Workflow.Steps))
}
}()
for {
if !runDeadline.IsZero() && time.Now().After(runDeadline) {
d.emit(st, Event{Type: EvPhase, Text: "运行截止时刻已到,未完步骤按 skipped 收口"})
st.mu.Lock()
for i := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[i].Status == "pending" || st.Plan.Workflow.Steps[i].Status == "running" {
st.Plan.Workflow.Steps[i].Status = "skipped"
st.Plan.Workflow.Steps[i].Error = "deadline exceeded"
}
}
_ = st.save()
st.mu.Unlock()
return d.finalize(ctx, st)
}
g, gerr := compileGraph(st.Plan.Workflow.Steps)
if gerr != nil {
wf2 := st.Workflow()
wf2.Status = "failed"
st.setWorkflow(wf2)
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: "graph: " + gerr.Error(), WorkDir: st.Meta.WorkDir}, nil, nil
}
layer, ok := g.nextLayer(st.Plan.Workflow.Steps)
if !ok {
if n := pendingCount(st.Plan.Workflow.Steps); n > 0 {
wf2 := st.Workflow()
wf2.Status = "failed"
st.setWorkflow(wf2)
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: fmt.Sprintf("graph: 依赖无法满足,仍有 %d 个步骤无法调度", n), WorkDir: st.Meta.WorkDir}, nil, nil
}
break
}
superNo++
d.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("superstep %d 推进(并行 %d 步,串行 %d 步)", superNo, len(layer.par), len(layer.seq))})
if step.Parallel {
// 收集相邻并行组。组内每一步都先过审批和权限,任一需要挂起则整组不执行。
var group []int
for i < len(st.Plan.Workflow.Steps) {
s := st.Plan.Workflow.Steps[i]
if s.Parallel && s.Status != "completed" && s.Status != "skipped" && s.Status != "failed" {
if out, g, err := d.gateBeforeRun(ctx, st, i); out != nil || g != nil || err != nil {
if out == nil && g != nil {
out = heldResult(st, g)
}
return out, g, err
// 并行段:组内每一步都先过审批和权限,任一需要挂起则整组不执行。
if len(layer.par) > 0 {
for _, idx := range layer.par {
if out, g2, err := d.gateBeforeRun(ctx, st, idx); out != nil || g2 != nil || err != nil {
if out == nil && g2 != nil {
out = heldResult(st, g2)
}
group = append(group, i)
i++
continue
return out, g2, err
}
break
}
if out, g, err := d.runParallel(ctx, st, group); out != nil || g != nil || err != nil {
if out == nil && g != nil {
out = heldResult(st, g)
if out, g2, err := d.runParallel(ctx, st, layer.par); out != nil || g2 != nil || err != nil {
if out == nil && g2 != nil {
out = heldResult(st, g2)
}
return out, g, err
return out, g2, err
}
if g2 := d.checkBlockingGaps(st); g2 != nil {
return heldResult(st, g2), g2, nil
}
continue
}
out, g, err := d.runOneStep(ctx, st, i)
if out != nil || g != nil || err != nil {
if out == nil && g != nil {
out = heldResult(st, g)
}
return out, g, err
}
i++
// 阻塞缺口挂起。
if g2 := d.checkBlockingGaps(st); g2 != nil {
return heldResult(st, g2), g2, nil
}
// 动态调整(仅顺序步骤后,避免并发竞态)。
if st.Plan.Workflow.Steps[i-1].AllowDynamicAdjustment && d.opts.Evaluator != nil {
out, g, err := d.applyDynamicAdjustment(ctx, st, i-1)
if out != nil || g != nil || err != nil {
if out == nil && g != nil {
out = heldResult(st, g)
// 单成员并行组实际是顺序执行:与旧线性扫描一致,其后可动态调整
// (多成员组不做,避免并发竞态)。
if len(layer.par) == 1 {
idx := layer.par[0]
if st.Plan.Workflow.Steps[idx].AllowDynamicAdjustment && d.opts.Evaluator != nil {
out, g2, err := d.applyDynamicAdjustment(ctx, st, idx)
if out != nil || g2 != nil || err != nil {
if out == nil && g2 != nil {
out = heldResult(st, g2)
}
return out, g2, err
}
}
return out, g, err
}
}
// 串行段(写集冲突步骤,按声明序逐个执行;顺序步骤后可动态调整)。
for _, idx := range layer.seq {
if out, g2, err := d.gateBeforeRun(ctx, st, idx); out != nil || g2 != nil || err != nil {
if out == nil && g2 != nil {
out = heldResult(st, g2)
}
return out, g2, err
}
out, g2, err := d.runOneStep(ctx, st, idx)
if out != nil || g2 != nil || err != nil {
if out == nil && g2 != nil {
out = heldResult(st, g2)
}
return out, g2, err
}
if g2 := d.checkBlockingGaps(st); g2 != nil {
return heldResult(st, g2), g2, nil
}
if st.Plan.Workflow.Steps[idx].AllowDynamicAdjustment && d.opts.Evaluator != nil {
out, g2, err := d.applyDynamicAdjustment(ctx, st, idx)
if out != nil || g2 != nil || err != nil {
if out == nil && g2 != nil {
out = heldResult(st, g2)
}
return out, g2, err
}
}
}
// superstep 边界快照(sync 立即 / async 入队;on_exit 由 defer 的 flush 写)。
lastGraph = g
if err := cpw.boundary(st.Meta.WorkDir, snapshotOf(superNo, g, st.Plan.Workflow.Steps)); err != nil {
return nil, nil, err
}
}
return d.finalize(ctx, st)
}
// snapshotOf 组装一个 superstep 边界快照:序号、图结构指纹与全量步骤状态。
func snapshotOf(superNo int, g *graph, steps []StepSpec) Checkpoint {
cp := Checkpoint{Superstep: superNo, At: time.Now(), GraphHash: g.hash()}
for _, s := range steps {
cp.StepStates = append(cp.StepStates, StepSnapshot{ID: s.ID, Status: s.Status, AttemptID: s.AttemptID})
}
return cp
}
// gateBeforeRun 在步骤真正执行前检查权限升级和显式审批。
// 返回非空结果或挂起时,调用方不得执行该步骤,也不得执行它所在的并行组。
func (d *flowDriver) gateBeforeRun(ctx context.Context, st *runState, i int) (*RunResult, *hitlGate, error) {
func (d *flowDriver) gateBeforeRun(ctx context.Context, st *RunState, i int) (*RunResult, *HitlGate, error) {
step := st.Plan.Workflow.Steps[i]
role, _ := findAgent(st.Plan.Squad.Agents, step.Role)
if role.BashPermission == BashPermissionAsk && !st.Approved["role:"+role.Role] {
@@ -143,13 +213,14 @@ func (d *flowDriver) gateBeforeRun(ctx context.Context, st *runState, i int) (*R
}
// runParallel 并发执行一个并行组;关键步骤失败即终止整个运行。
func (d *flowDriver) runParallel(ctx context.Context, st *runState, idxs []int) (*RunResult, *hitlGate, error) {
func (d *flowDriver) runParallel(ctx context.Context, st *RunState, idxs []int) (*RunResult, *HitlGate, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var wg sync.WaitGroup
var mu sync.Mutex
failedCritical := -1
var gate *hitlGate
var firstFail *RunResult
var gate *HitlGate
for _, idx := range idxs {
wg.Add(1)
go func(idx int) {
@@ -163,6 +234,11 @@ func (d *flowDriver) runParallel(ctx context.Context, st *runState, idxs []int)
}
if (out != nil && out.Status == RunFailed) || err != nil {
cancel()
// 保留首个带具体错误的失败结果(如 router 无命中);
// failStep 的通用格式只在没有更具体结果时兜底。
if out != nil && out.Status == RunFailed && firstFail == nil {
firstFail = out
}
if st.Plan.Workflow.Steps[idx].IsCritical() && failedCritical < 0 {
failedCritical = idx
}
@@ -173,6 +249,9 @@ func (d *flowDriver) runParallel(ctx context.Context, st *runState, idxs []int)
if gate != nil {
return heldResult(st, gate), gate, nil
}
if firstFail != nil {
return firstFail, nil, nil
}
if failedCritical >= 0 {
return d.failStep(ctx, st, failedCritical)
}
@@ -180,7 +259,7 @@ func (d *flowDriver) runParallel(ctx context.Context, st *runState, idxs []int)
}
// runOneStep 执行单个步骤(含瞬态重试与交付物校验)。
func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
func (d *flowDriver) runOneStep(ctx context.Context, st *RunState, idx int) (*RunResult, *HitlGate, error) {
if err := ctx.Err(); err != nil {
return &RunResult{RunID: st.Meta.ID, Status: RunCancelled, Error: err.Error()}, nil, err
}
@@ -200,6 +279,16 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
return d.failStep(ctx, st, idx)
}
}
// handoff 原语 fail-closed(Q2):目标角色必须可解析,启动前拒绝。
if step.Handoff != "" {
if _, ok := findAgent(st.Plan.Squad.Agents, step.Handoff); !ok {
if _, exists := d.opts.Registry.Get(step.Handoff); !exists {
step.Status, step.Error = "failed", "handoff: 未知目标角色 "+step.Handoff
st.setStep(idx, step)
return d.failStep(ctx, st, idx)
}
}
}
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(orDefault(step.SubWorkspace, fmt.Sprintf("tasks/%02d_%s", idx+1, step.Role))))
if err := os.MkdirAll(subDir, 0o755); err != nil {
return nil, nil, err
@@ -211,6 +300,7 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
d.emit(st, Event{Type: EvStepStarted, StepID: step.ID, Role: step.Role, Text: step.Title})
step.Status = "running"
step.Attempts++
step.AttemptID = fmt.Sprintf("%s/%s/%d", st.Meta.ID, step.ID, step.Attempts)
step.Error = ""
// 与收口落盘同一把锁:并行步骤一边改 Steps、一边全量 marshal 会数据竞争。
st.mu.Lock()
@@ -229,6 +319,18 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
}
prompt := readFileOr(filepath.Join(subDir, "TASK.md"), step.Title)
prompt = st.injectSteering(prompt)
// 邮箱投递(Q2):本步骤启动时把发给该角色(含广播)的未读消息并入
// 任务书;只走 prompt 面,永不触碰 HITL 门(验收三构造性保证)。
st.mu.Lock()
mail := takeMailFor(st, step.Role)
handoff := takeHandoffFor(st, step.Role)
if len(mail) > 0 || handoff != nil {
_ = st.save()
}
st.mu.Unlock()
prompt += formatMailSection(mail)
// handoff 原语(Q2):目标角色的步骤接收过滤历史(只带移交方主产出)。
prompt += formatHandoffSection(handoff)
instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), d.opts.Workspaces.ResolveInstructions(role))
var backend Executor
@@ -283,7 +385,7 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
ToolAllowlist: role.Tools,
Skills: firstNonEmptySlice(step.Skills, role.Skills),
SessionScope: st.Meta.ID + "/" + step.ID,
ExecutionID: st.Meta.ID + "-" + step.ID,
ExecutionID: step.AttemptID,
Budget: stepBudget,
}
if step.IntentID != "" {
@@ -374,11 +476,156 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
}
d.emit(st, Event{Type: EvStepCompleted, StepID: step.ID, Role: step.Role, Text: step.Title,
Data: marshalJSON(map[string]any{"duration_ms": step.DurationMs, "targets": targets})})
// handoff 原语(Q2):完成即移交最终答案所有权——记录 + 主产出快照,
// 供目标角色下一步骤(过滤历史)与 finalize(链尾产出)消费。
if step.Handoff != "" {
d.recordHandoff(st, step, subDir, targets)
}
// 循环步再武装(Q2 loop):停机条件不满足且预算未尽 → 本步与 loop.body
// 一起重置为 pending;触顶发 loop_exhausted 事件后按完成继续。
if step.Loop != nil {
if d.loopShouldRearm(st, step) {
if out, g2, err2 := d.rearmLoop(ctx, st, idx, step); out != nil || g2 != nil || err2 != nil {
return out, g2, err2
}
return nil, nil, nil
}
if step.Loop.UntilField != "" && step.LoopRuns+1 >= step.Loop.MaxIterations {
d.emit(st, Event{Type: EvPhase, StepID: step.ID, Text: fmt.Sprintf("loop_exhausted:%s 达 %d 轮上限,停机条件未满足", step.ID, step.Loop.MaxIterations)})
}
}
// 路由步分支裁决(Q2 router):首个命中的分支保留,其余分支目标置
// skipped;无分支命中 fail-closed 成失败运行。
if len(step.Branches) > 0 {
return d.applyBranches(ctx, st, idx, step)
}
return nil, nil, nil
}
// loopShouldRearm 判定循环步是否再武装:停机条件(黑板字段等值)满足或
// 预算用尽则停。字段判据读的是本轮 mergeStepArtifacts 之后的黑板。
func (d *flowDriver) loopShouldRearm(st *RunState, step StepSpec) bool {
// MaxIterations 是总迭代上限(含首跑):已跑 LoopRuns+1 次。
if step.LoopRuns+1 >= step.Loop.MaxIterations {
return false
}
if step.Loop.UntilField != "" {
st.mu.Lock()
v := st.BB.Fields[step.Loop.UntilField]
st.mu.Unlock()
if v == step.Loop.UntilEquals {
return false
}
}
return true
}
// rearmLoop 把循环步与 loop.body 一起重置为 pending(同一临界区内落盘)。
func (d *flowDriver) rearmLoop(_ context.Context, st *RunState, idx int, step StepSpec) (*RunResult, *HitlGate, error) {
st.mu.Lock()
step.LoopRuns++
step.Status = "pending"
step.Error = ""
st.Plan.Workflow.Steps[idx] = step
for _, bodyID := range step.Loop.Body {
for j := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[j].ID == bodyID && st.Plan.Workflow.Steps[j].Status == "completed" {
st.Plan.Workflow.Steps[j].Status = "pending"
st.Plan.Workflow.Steps[j].Error = ""
}
}
}
err := st.save()
round := step.LoopRuns
st.mu.Unlock()
if err != nil {
return nil, nil, err
}
d.emit(st, Event{Type: EvPhase, StepID: step.ID, Text: fmt.Sprintf("loop 第 %d 轮再武装:%s", round+1, step.ID)})
return nil, nil, nil
}
// applyBranches 裁决路由步分支:When 按声明序取首个命中;未选中分支的
// pending 目标置 skipped(skipped=依赖已满足,join 步照常推进)。
func (d *flowDriver) applyBranches(_ context.Context, st *RunState, _ int, step StepSpec) (*RunResult, *HitlGate, error) {
st.mu.Lock()
fields := map[string]string{}
for k, v := range st.BB.Fields {
fields[k] = v
}
st.mu.Unlock()
var selected *Branch
for i := range step.Branches {
if branchMatches(fields, step.Branches[i].When) {
selected = &step.Branches[i]
break
}
}
if selected == nil {
wf := st.Workflow()
wf.Status = "failed"
st.setWorkflow(wf)
_ = st.save()
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: fmt.Sprintf("router: 步骤 %s 无分支命中(fail-closed)", step.ID), WorkDir: st.Meta.WorkDir}, nil, nil
}
keep := map[string]bool{}
for _, t := range selected.Then {
keep[t] = true
}
skipped := 0
st.mu.Lock()
for j := range st.Plan.Workflow.Steps {
sj := &st.Plan.Workflow.Steps[j]
if sj.ID == step.ID || keep[sj.ID] || sj.Status != "pending" {
continue
}
for _, br := range step.Branches {
inBranch := false
for _, t := range br.Then {
if t == sj.ID {
inBranch = true
break
}
}
if inBranch {
sj.Status = "skipped"
sj.Error = routerSkipReason
skipped++
break
}
}
}
err := st.save()
st.mu.Unlock()
if err != nil {
return nil, nil, err
}
d.emit(st, Event{Type: EvPhase, StepID: step.ID,
Text: fmt.Sprintf("router %s 选中分支 %v(跳过 %d 步)", step.ID, selected.Then, skipped)})
return nil, nil, nil
}
// routerSkipReason 是分支落选的 skipped 标记(finalize 的降级豁免凭据)。
const routerSkipReason = "router: 分支未选中"
// branchMatches 判定分支条件:Equals 非空须相等,NotEquals 非空须不等;
// 字段未设时按零值 "" 参与比较(compileGraph 已保证两者至少其一非空)。
func branchMatches(fields map[string]string, c BranchCond) bool {
v := fields[c.Field]
if c.Equals != "" && v != c.Equals {
return false
}
if c.NotEquals != "" && v == c.NotEquals {
return false
}
return true
}
// failStep 按关键性处理失败步骤:关键 → 运行失败;非关键 → 标记后继续(返回 nil 让循环推进)。
func (d *flowDriver) failStep(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
func (d *flowDriver) failStep(ctx context.Context, st *RunState, idx int) (*RunResult, *HitlGate, error) {
st.mu.Lock()
step := st.Plan.Workflow.Steps[idx]
critical := step.IsCritical()
@@ -402,7 +649,7 @@ func (d *flowDriver) failStep(ctx context.Context, st *runState, idx int) (*RunR
}
// checkBlockingGaps 检查未解决阻塞缺口;interactive 挂起,auto 记录兜底假设并继续。
func (d *flowDriver) checkBlockingGaps(st *runState) *hitlGate {
func (d *flowDriver) checkBlockingGaps(st *RunState) *HitlGate {
gaps := st.BB.UnresolvedBlockingGaps()
if len(gaps) == 0 {
return nil
@@ -444,7 +691,7 @@ func (d *flowDriver) checkBlockingGaps(st *runState) *hitlGate {
Context: strings.Join(lines, "\n"),
CreatedAt: time.Now(), Status: "pending",
}
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
gate := &HitlGate{Request: *req, PrevStatus: RunExecuting}
if err := st.saveGate(gate); err == nil {
st.Meta.Status = string(RunWaitingHuman)
_ = st.save()
@@ -454,7 +701,7 @@ func (d *flowDriver) checkBlockingGaps(st *runState) *hitlGate {
}
// applyDynamicAdjustment 评估步骤产出并应用/审批动态调整。
func (d *flowDriver) applyDynamicAdjustment(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
func (d *flowDriver) applyDynamicAdjustment(ctx context.Context, st *RunState, idx int) (*RunResult, *HitlGate, error) {
step := st.Plan.Workflow.Steps[idx]
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace))
output := readFileOr(filepath.Join(subDir, firstTarget(step.Targets)), "")
@@ -494,7 +741,7 @@ func (d *flowDriver) applyDynamicAdjustment(ctx context.Context, st *runState, i
evalRes.Reason, strings.Join(addLines, "\n"), strings.Join(skipLines, "\n")),
CreatedAt: time.Now(), Status: "pending",
}
gate := &hitlGate{Request: *req, Steps: evalRes.DynamicStepsToAdd, SkipIDs: evalRes.StepsToSkip, PrevStatus: RunExecuting}
gate := &HitlGate{Request: *req, Steps: evalRes.DynamicStepsToAdd, SkipIDs: evalRes.StepsToSkip, PrevStatus: RunExecuting}
if err := st.saveGate(gate); err == nil {
st.Meta.Status = string(RunWaitingHuman)
_ = st.save()
@@ -507,7 +754,7 @@ func (d *flowDriver) applyDynamicAdjustment(ctx context.Context, st *runState, i
}
// applyAdjustment 应用动态调整:新步骤插到最后一步之前,跳过步骤标记 skipped。
func applyAdjustment(st *runState, add []StepSpec, skipIDs []string) {
func applyAdjustment(st *RunState, add []StepSpec, skipIDs []string) {
steps := st.Plan.Workflow.Steps
for _, id := range skipIDs {
for i := range steps {
@@ -534,21 +781,34 @@ func applyAdjustment(st *runState, add []StepSpec, skipIDs []string) {
}
// finalize 收口:汇总交付物 → 质检 → 自动修复 → (必要时)人工裁决 → 清单。
func (d *flowDriver) finalize(ctx context.Context, st *runState) (*RunResult, *hitlGate, error) {
func (d *flowDriver) finalize(ctx context.Context, st *RunState) (*RunResult, *HitlGate, error) {
wf := st.Workflow()
degraded := false
for _, s := range wf.Steps {
if s.Status == "failed" || s.Status == "skipped" {
if s.Status == "failed" {
degraded = true
}
// router 分支落选是预期路径,不算降级(Q2)。
if s.Status == "skipped" && s.Error != routerSkipReason {
degraded = true
}
}
// 汇总交付物:最后一个 completed 步骤的主产出 → output.md。
// handoff 链尾优先(Q2):有所有权移交时,交付物取链尾角色的最后一个
// completed 步骤产出(最终答案所有权在链尾),无则该角色无完成步骤时回落。
var srcStep *StepSpec
for i := range wf.Steps {
if wf.Steps[i].Status == "completed" {
srcStep = &wf.Steps[i]
}
}
if tail := handoffChainTail(st); tail != "" {
for i := range wf.Steps {
if wf.Steps[i].Status == "completed" && wf.Steps[i].Role == tail {
srcStep = &wf.Steps[i]
}
}
}
if srcStep == nil {
wf.Status = "failed"
st.setWorkflow(wf)
@@ -616,7 +876,7 @@ func (d *flowDriver) finalize(ctx context.Context, st *runState) (*RunResult, *h
Context: string(marshalJSON(report)),
CreatedAt: time.Now(), Status: "pending",
}
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
gate := &HitlGate{Request: *req, PrevStatus: RunExecuting}
if err := st.saveGate(gate); err == nil {
st.Meta.Status = string(RunQualityReview)
_ = st.save()
@@ -650,7 +910,7 @@ func (d *flowDriver) finalize(ctx context.Context, st *runState) (*RunResult, *h
}
// rerunForRepair 重置交付物源步骤并重新执行到收口。
func (d *flowDriver) rerunForRepair(ctx context.Context, st *runState, stepID string) (*RunResult, *hitlGate, error) {
func (d *flowDriver) rerunForRepair(ctx context.Context, st *RunState, stepID string) (*RunResult, *HitlGate, error) {
for i := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[i].ID == stepID {
st.Plan.Workflow.Steps[i].Status = "pending"
@@ -668,7 +928,7 @@ func (d *flowDriver) rerunForRepair(ctx context.Context, st *runState, stepID st
}
// heldResult 由挂起上下文构造对外结果(挂起路径统一返回非 nil 结果)。
func heldResult(st *runState, gate *hitlGate) *RunResult {
func heldResult(st *RunState, gate *HitlGate) *RunResult {
status := RunWaitingHuman
switch gate.Request.Kind {
case HitlClarification:
@@ -698,7 +958,7 @@ func waitConditionFrom(gaps []GapItem) *WaitCondition {
}
// hold 生成挂起请求并返回结果。
func (d *flowDriver) hold(st *runState, kind HumanRequestKind, stepID, role, question string, options []HumanOption, context, reason string, extra *hitlGate) (*RunResult, *hitlGate, error) {
func (d *flowDriver) hold(st *RunState, kind HumanRequestKind, stepID, role, question string, options []HumanOption, context, reason string, extra *HitlGate) (*RunResult, *HitlGate, error) {
req := &HumanRequest{
ID: newRequestID(), RunID: st.Meta.ID, StepID: stepID, Kind: kind,
Severity: SeverityActionRequired,
@@ -714,7 +974,7 @@ func (d *flowDriver) hold(st *runState, kind HumanRequestKind, stepID, role, que
}
gate := extra
if gate == nil {
gate = &hitlGate{}
gate = &HitlGate{}
}
gate.Request = *req
gate.PrevStatus = RunExecuting
@@ -731,7 +991,7 @@ func (d *flowDriver) hold(st *runState, kind HumanRequestKind, stepID, role, que
// policy 合并引擎全局策略与 Goal 覆盖(窄者胜)。
// policyRole 在引擎全局/Goal 链之上叠加角色级 HitlPolicy
// (窄者胜;Goal 仍最高)。
func (d *flowDriver) policyRole(st *runState, role RoleSpec) HitlPolicy {
func (d *flowDriver) policyRole(st *RunState, role RoleSpec) HitlPolicy {
p := d.policy(st)
if role.HitlPolicy != nil {
p = mergeHitlPolicy(p, role.HitlPolicy)
@@ -739,11 +999,11 @@ func (d *flowDriver) policyRole(st *runState, role RoleSpec) HitlPolicy {
return p
}
func (d *flowDriver) policy(st *runState) HitlPolicy {
func (d *flowDriver) policy(st *RunState) HitlPolicy {
return mergeHitlPolicy(d.opts.HitlPolicy, st.Meta.HitlPolicyOf())
}
func (d *flowDriver) emit(st *runState, ev Event) {
func (d *flowDriver) emit(st *RunState, ev Event) {
if d.opts.Sink == nil {
return
}
@@ -754,11 +1014,11 @@ func (d *flowDriver) emit(st *runState, ev Event) {
d.opts.Sink.Emit(ev)
}
func (d *flowDriver) emitHuman(st *runState, req *HumanRequest) {
func (d *flowDriver) emitHuman(st *RunState, req *HumanRequest) {
d.emit(st, Event{Type: EvHumanInputRequired, Text: req.Question, Data: marshalJSON(req)})
}
func summarize(st *runState, report QualityReport, status RunStatus) string {
func summarize(st *RunState, report QualityReport, status RunStatus) string {
var b strings.Builder
fmt.Fprintf(&b, "运行 %s:%s。步骤 %d 个(完成 %d/跳过 %d/失败 %d)。质检得分 %.0f。",
st.Meta.ID, status, len(st.Plan.Workflow.Steps),
@@ -769,7 +1029,7 @@ func summarize(st *runState, report QualityReport, status RunStatus) string {
return b.String()
}
func countStatus(st *runState, status string) int {
func countStatus(st *RunState, status string) int {
n := 0
for _, s := range st.Plan.Workflow.Steps {
if s.Status == status {
@@ -779,7 +1039,7 @@ func countStatus(st *runState, status string) int {
return n
}
func buildManifest(st *runState, deliverable string, report QualityReport) Manifest {
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 {
if err != nil || info.IsDir() {
@@ -838,7 +1098,7 @@ func appendLog(dir, line string) {
_, _ = f.WriteString(line)
}
func planPtr(st *runState) *WorkflowPlan {
func planPtr(st *RunState) *WorkflowPlan {
wf := st.Workflow()
return &wf
}
+10 -12
View File
@@ -34,18 +34,9 @@ func (e *WorkspaceEngine) PrepareWorkspace(meta *RunMetadata, plan *SquadPlan) (
}
meta.WorkDir = wsDir
meta.UpdatedAt = time.Now()
if err := writeJSON(filepath.Join(wsDir, "workspace.json"), meta); err != nil {
return "", err
}
if err := writeJSON(filepath.Join(wsDir, "squad.json"), &plan.Squad); err != nil {
return "", err
}
if err := writeJSON(filepath.Join(wsDir, "workflow.json"), &plan.Workflow); err != nil {
return "", err
}
if err := saveBlackboard(wsDir, &BlackboardState{Fields: map[string]string{}}); err != nil {
return "", err
}
// 注意:run 级状态 JSON 不在此处写——由 st.save() 经 Store 端口统一落盘
// (Q1 WP5:SQLite 后端时这些状态进数据库而非文件)。这里只建目录与
// 上下文/模板等运行无关状态的工作区资产。
ctxIdx, err := e.writeContextAssets(wsDir, &meta.Goal, plan)
if err != nil {
return "", err
@@ -198,6 +189,13 @@ func (e *WorkspaceEngine) writeTaskFile(subDir string, step *StepSpec, role Role
fmt.Fprintf(&b, "- 角色:%s(%s)\n", role.Name, role.Role)
fmt.Fprintf(&b, "- 期望产出:%v\n\n", step.Targets)
// 编译期任务指令(Q3):编译器渲染好的富文本任务书,权威仅次于目标。
if strings.TrimSpace(step.Brief) != "" {
b.WriteString("## 任务指令\n\n")
b.WriteString(strings.TrimSpace(step.Brief))
b.WriteString("\n\n")
}
b.WriteString("## 任务上下文资源与权威等级(已快照到本目录,只引用本地路径)\n\n")
b.WriteString("| 资源路径 | 权威等级 | 用途说明 | 使用与引用指导 |\n|---|---|---|---|\n")
b.WriteString("| `context/goal.md` | **authoritative** | 运行目标 | 不可推翻 |\n")
+452
View File
@@ -0,0 +1,452 @@
package squad
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// wp3_conformance_test.go — Q2 WP3 验收:review 拓扑与 backend runReview 等价
// (验收二:并行找问题、逐条验证、丢掉未验证)、handoff 原语(所有权转移 +
// 过滤历史 + finalize 链尾)、group_chat 驱动(轮转/主持人 + 预算停机)。
// ---- review:与 backend review_test.go 同款断言 ----
// reviewScriptBackend finder 目录回放 findings.json(第 3 个进入者放行,钉住
// 真并发);verify_* 目录按 prompt 内容回放判定(与 backend 夹具同文案)。
type reviewScriptBackend struct {
mu sync.Mutex
once sync.Once
entered int
verifyCalls int
release chan struct{}
}
func (b *reviewScriptBackend) Name() string { return "review-script" }
func (b *reviewScriptBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
if strings.HasPrefix(key, "verify_") {
b.mu.Lock()
b.verifyCalls++
b.mu.Unlock()
verdict := "看不出对错"
switch {
case strings.Contains(req.Prompt, "无来源"):
verdict = "核对原文确实没有来源。\nVERDICT: PASS"
case strings.Contains(req.Prompt, "错字"):
verdict = "核对后成立。\nVERDICT: PASS"
case strings.Contains(req.Prompt, "过期数据"):
verdict = "数据仍有效。\nVERDICT: FAIL"
}
return writeOne(req.WorkDir, "verdict.md", verdict), nil
}
// finder:三个必须同时在跑(backend review_test.go:16-33 同款门)。
b.mu.Lock()
b.entered++
n := b.entered
b.mu.Unlock()
if n == 3 {
b.once.Do(func() { close(b.release) })
}
select {
case <-b.release:
case <-time.After(time.Second):
return nil, fmt.Errorf("finders did not run together")
}
var findings string
switch key {
case "01_researcher":
findings = `[{"location":"第2段","severity":"high","issue":"无来源","evidence":"显然"},{"location":"第1段","severity":"low","issue":"错字","evidence":"的地"},{"location":"表1","severity":"medium","issue":"过期数据","evidence":"2020"}]`
case "02_writer":
findings = `[{"location":"第2段","severity":"high","issue":"无来源","evidence":"显然"},{"location":"附录","severity":"medium","issue":"数字不一致","evidence":"3 与 4"}]`
default:
findings = `[{"location":"结尾","severity":"low","issue":"没有结论","evidence":"最后一句"}]`
}
return writeOne(req.WorkDir, "findings.json", findings), nil
}
func writeOne(dir, name, content string) *ExecResult {
out := filepath.Join(dir, name)
_ = os.MkdirAll(filepath.Dir(out), 0o755)
_ = os.WriteFile(out, []byte(content), 0o644)
return &ExecResult{Success: true, Patches: map[string][]byte{name: []byte(content)}}
}
func TestTopologyConformance_review(t *testing.T) {
be := &reviewScriptBackend{release: make(chan struct{})}
sink := &collectSink{}
planner := mockScriptPlanner(
StepSpec{ID: "f1", Role: "researcher", Title: "评审一", Targets: []string{"findings.json"}},
StepSpec{ID: "f2", Role: "writer", Title: "评审二", Targets: []string{"findings.json"}},
StepSpec{ID: "f3", Role: "reviewer", Title: "评审三", Targets: []string{"findings.json"}},
)
planner.Agents = []RoleSpec{
{Role: "researcher", Name: "评审一", Backend: "review-script"},
{Role: "writer", Name: "评审二", Backend: "review-script"},
{Role: "reviewer", Name: "评审三", Backend: "review-script"},
{Role: "verifier", Name: "核实", Backend: "review-script"},
}
engine := newWP3Engine(t, planner, be, sink, []RoleSpec{
{Role: "researcher", Name: "评审一", Backend: "review-script"},
{Role: "writer", Name: "评审二", Backend: "review-script"},
{Role: "reviewer", Name: "评审三", Backend: "review-script"},
{Role: "verifier", Name: "核实", Backend: "review-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "评审材料正文"},
RunOptions{Topology: TopologyReview, ReviewTarget: "材料正文"})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
data, err := os.ReadFile(filepath.Join(res.WorkDir, "output.md"))
if err != nil {
t.Fatal(err)
}
out := string(data)
// 只留 PASS(fail-closed):无来源/错字保留;FAIL 与无判定丢弃。
if !strings.Contains(out, "无来源") || !strings.Contains(out, "错字") ||
strings.Contains(out, "数字不一致") || strings.Contains(out, "没有结论") ||
strings.Contains(out, "过期数据") {
t.Fatalf("filter = %s", out)
}
// 报告恒带丢弃计数(backend 同格式串)。
if !strings.Contains(out, "已丢弃 3 条未验证") {
t.Fatalf("dropped = %s", out)
}
// 去重后逐条验证:verify 调用数 = unique 数 = 5。
if be.verifyCalls != 5 {
t.Fatalf("verify calls = %d, want 5 after dedupe", be.verifyCalls)
}
// severity 稳定排序:high(无来源) 在 low(错字) 前。
if strings.Index(out, "无来源") > strings.Index(out, "错字") {
t.Fatalf("severity order: %s", out)
}
if sink.countText("review:3 个 finder 并行找问题") != 1 ||
sink.countText("review:去重后 5 条") != 1 {
t.Fatal("缺 review 阶段事件")
}
}
// verify 角色缺失 → fail-closed。
func TestReviewVerifyRoleMissingFailsClosed(t *testing.T) {
be := &reviewScriptBackend{release: make(chan struct{})}
planner := mockScriptPlanner(
StepSpec{ID: "f1", Role: "researcher", Title: "评审", Targets: []string{"findings.json"}},
)
engine := newWP3Engine(t, planner, be, NopSink{}, []RoleSpec{
{Role: "researcher", Name: "评审", Backend: "review-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "评审"},
RunOptions{Topology: TopologyReview})
if err != nil {
t.Fatal(err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, "verify 角色") {
t.Fatalf("应 fail-closed:%s %q", res.Status, res.Error)
}
}
// ---- handoff 原语 ----
// handoffScriptBackend 记录 prompt 并按目录回放带标记的产出(≥50 字过最小质检)。
type handoffScriptBackend struct {
mu sync.Mutex
prompts map[string][]string
}
func (b *handoffScriptBackend) Name() string { return "handoff-script" }
func (b *handoffScriptBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
b.mu.Lock()
b.prompts[key] = append(b.prompts[key], req.Prompt)
b.mu.Unlock()
pad := "\n\n这是为了满足最小质检长度要求而补充的正文内容,超过五十个字符。"
var content string
switch key {
case "01_researcher":
content = "MARK_RESEARCHER 调研笔记。" + pad
case "02_writer":
content = "MARK_WRITER 终稿。" + pad
default:
content = "MARK_REVIEWER 评审意见。" + pad
}
name := "output.md"
if len(req.Targets) > 0 {
name = req.Targets[0]
}
return writeOne(req.WorkDir, name, content), nil
}
func TestTopologyConformance_handoff(t *testing.T) {
be := &handoffScriptBackend{prompts: map[string][]string{}}
sink := &collectSink{}
engine := newWP3Engine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "调研", Targets: []string{"notes.md"}, Handoff: "writer"},
StepSpec{ID: "s2", Role: "writer", Title: "成文", Targets: []string{"output.md"}, Needs: []string{"s1"}},
StepSpec{ID: "s3", Role: "reviewer", Title: "终审", Targets: []string{"output.md"}, Needs: []string{"s2"}},
), be, sink, []RoleSpec{
{Role: "researcher", Name: "调研", Backend: "handoff-script"},
{Role: "writer", Name: "撰写", Backend: "handoff-script"},
{Role: "reviewer", Name: "审核", Backend: "handoff-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "带移交的三步"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
// ① 所有权转移记录持久化。
st, err := engine.loadRun(engine.runDir(res.RunID))
if err != nil {
t.Fatal(err)
}
if len(st.Handoffs) != 1 || st.Handoffs[0].FromRole != "researcher" || st.Handoffs[0].ToRole != "writer" {
t.Fatalf("Handoffs = %+v", st.Handoffs)
}
if !strings.Contains(st.Handoffs[0].Output, "MARK_RESEARCHER") {
t.Fatalf("移交产出快照缺失:%q", st.Handoffs[0].Output)
}
if sink.countText("handoff:researcher → writer") != 1 {
t.Fatal("缺所有权移交事件")
}
// ② 目标收过滤历史:writer 的任务书带移交上下文,reviewer 不带。
var writerPrompt, reviewerPrompt string
for _, p := range be.prompts["02_writer"] {
writerPrompt += p
}
for _, p := range be.prompts["03_reviewer"] {
reviewerPrompt += p
}
if !strings.Contains(writerPrompt, "移交上下文") || !strings.Contains(writerPrompt, "MARK_RESEARCHER") {
t.Fatalf("writer 未收到过滤历史:%q", writerPrompt)
}
if strings.Contains(reviewerPrompt, "移交上下文") {
t.Fatal("非目标角色不应收到移交上下文")
}
// ③ finalize 取链尾输出:交付物 = writer(链尾)产出,而非最后完成的 reviewer。
data, err := os.ReadFile(filepath.Join(res.WorkDir, "output.md"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "MARK_WRITER") || strings.Contains(string(data), "MARK_REVIEWER") {
t.Fatalf("交付物应为链尾 writer 产出:%s", data)
}
}
// handoff 目标角色未知 → 步骤启动前 fail-closed。
func TestHandoffUnknownRoleFailsClosed(t *testing.T) {
be := &handoffScriptBackend{prompts: map[string][]string{}}
engine := newWP3Engine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "调研", Handoff: "ghost"},
), be, NopSink{}, []RoleSpec{
{Role: "researcher", Name: "调研", Backend: "handoff-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "x"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, "handoff: 未知目标角色 ghost") {
t.Fatalf("应 fail-closed:%s %q", res.Status, res.Error)
}
}
// ---- group_chat 驱动 ----
// gcScriptBackend 按目录回放 message.md。
type gcScriptBackend struct {
messages map[string]string
}
func (b *gcScriptBackend) Name() string { return "gc-script" }
func (b *gcScriptBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
msg := b.messages[key]
if msg == "" {
msg = "发言:" + key
}
return writeOne(req.WorkDir, "message.md", msg), nil
}
func TestTopologyConformance_groupChatRotateBudget(t *testing.T) {
be := &gcScriptBackend{messages: map[string]string{
"turn_01_researcher": "第一句",
"turn_02_writer": "第二句",
"turn_03_researcher": "第三句",
}}
sink := &collectSink{}
engine := newWP3Engine(t, mockScriptPlanner(
StepSpec{ID: "p1", Role: "researcher", Title: "参与者甲"},
StepSpec{ID: "p2", Role: "writer", Title: "参与者乙"},
), be, sink, []RoleSpec{
{Role: "researcher", Name: "甲", Backend: "gc-script"},
{Role: "writer", Name: "乙", Backend: "gc-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "群聊"},
RunOptions{Topology: TopologyGroupChat, GroupChatMaxTurns: 3})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
// 预算停机:恰好 3 轮,轮转序 researcher→writer→researcher。
if sink.countText("轮次预算 3 用尽") != 1 {
t.Fatal("缺预算停机事件")
}
data, err := os.ReadFile(filepath.Join(res.WorkDir, "output.md"))
if err != nil {
t.Fatal(err)
}
out := string(data)
for _, want := range []string{"1. researcher:第一句", "2. writer:第二句", "3. researcher:第三句"} {
if !strings.Contains(out, want) {
t.Fatalf("转录缺 %q:%s", want, out)
}
}
if strings.Contains(out, "4. ") {
t.Fatalf("超出预算仍在发言:%s", out)
}
// 名册步骤不执行,标记 skipped(非降级原因)。
st, _ := engine.loadRun(engine.runDir(res.RunID))
for _, s := range st.Plan.Workflow.Steps {
if s.ID == "p1" || s.ID == "p2" {
if s.Status != "skipped" || s.Error != gcRosterSkipReason {
t.Fatalf("名册步骤 %s = %s/%q", s.ID, s.Status, s.Error)
}
}
}
}
func TestTopologyConformance_groupChatCloseMarker(t *testing.T) {
be := &gcScriptBackend{messages: map[string]string{
"turn_01_researcher": "我先说",
"turn_02_writer": "同意收口 [close]",
}}
sink := &collectSink{}
engine := newWP3Engine(t, mockScriptPlanner(
StepSpec{ID: "p1", Role: "researcher", Title: "甲"},
StepSpec{ID: "p2", Role: "writer", Title: "乙"},
), be, sink, []RoleSpec{
{Role: "researcher", Name: "甲", Backend: "gc-script"},
{Role: "writer", Name: "乙", Backend: "gc-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "群聊"},
RunOptions{Topology: TopologyGroupChat, GroupChatMaxTurns: 8})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s", res.Status)
}
if sink.countText("发出收口标记") != 1 {
t.Fatal("缺收口标记事件")
}
st, _ := engine.loadRun(engine.runDir(res.RunID))
if n := countGcSteps(st); n != 2 {
t.Fatalf("收口标记后应停在 2 轮,实际 %d", n)
}
}
func TestTopologyConformance_groupChatModerator(t *testing.T) {
be := &gcScriptBackend{messages: map[string]string{
"turn_01_researcher": `{"next":"writer","reason":"请乙补充"}`,
"turn_02_writer": "补一段分析",
"turn_03_researcher": `{"action":"close","reason":"讨论充分"}`,
}}
sink := &collectSink{}
engine := newWP3Engine(t, mockScriptPlanner(
StepSpec{ID: "p1", Role: "researcher", Title: "主持人"},
StepSpec{ID: "p2", Role: "writer", Title: "乙"},
), be, sink, []RoleSpec{
{Role: "researcher", Name: "主持", Backend: "gc-script"},
{Role: "writer", Name: "乙", Backend: "gc-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "主持人群聊"}, RunOptions{
Topology: TopologyGroupChat, GroupChatPolicy: "moderator",
GroupChatModerator: "researcher", GroupChatMaxTurns: 8,
})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
if sink.countText("主持人收口") != 1 {
t.Fatal("缺主持人收口事件")
}
data, _ := os.ReadFile(filepath.Join(res.WorkDir, "output.md"))
out := string(data)
for _, want := range []string{"1. researcher", "2. writer:补一段分析", "3. researcher"} {
if !strings.Contains(out, want) {
t.Fatalf("转录缺 %q:%s", want, out)
}
}
st, _ := engine.loadRun(engine.runDir(res.RunID))
if n := countGcSteps(st); n != 3 {
t.Fatalf("主持人模式应 3 轮收口,实际 %d", n)
}
}
// 主持人指名不在名册 → fail-closed。
func TestGroupChatModeratorUnknownNextFailsClosed(t *testing.T) {
be := &gcScriptBackend{messages: map[string]string{
"turn_01_researcher": `{"next":"ghost"}`,
}}
engine := newWP3Engine(t, mockScriptPlanner(
StepSpec{ID: "p1", Role: "researcher", Title: "主持人"},
StepSpec{ID: "p2", Role: "writer", Title: "乙"},
), be, NopSink{}, []RoleSpec{
{Role: "researcher", Name: "主持", Backend: "gc-script"},
{Role: "writer", Name: "乙", Backend: "gc-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "x"}, RunOptions{
Topology: TopologyGroupChat, GroupChatPolicy: "moderator", GroupChatModerator: "researcher",
})
if err != nil {
t.Fatal(err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, "不在名册内") {
t.Fatalf("应 fail-closed:%s %q", res.Status, res.Error)
}
}
// ---- 夹具 ----
func countGcSteps(st *RunState) int {
n := 0
for _, s := range st.Plan.Workflow.Steps {
if strings.HasPrefix(s.ID, "gc") && s.Status == "completed" {
n++
}
}
return n
}
func newWP3Engine(t *testing.T, planner Planner, be Executor, sink ProgressSink, roles []RoleSpec) *SquadEngine {
t.Helper()
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range roles {
if err := reg.Register(r); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance(be.Name(), be)
engine, err := NewEngine(Options{DataDir: dir, Registry: reg, Planner: planner, Backends: backends, Sink: sink})
if err != nil {
t.Fatal(err)
}
return engine
}
+355
View File
@@ -0,0 +1,355 @@
package squad
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// wp4_conformance_test.go — Q2 WP4 验收:task_board 拓扑(claim/lease/complete
// 原语 + 依赖门 + 孤儿租约再武装)与全 12 拓扑 conformance 汇总跑。
// ---- task_board ----
// 菱形依赖 a→(b,c)→d:认领序尊重依赖门,全部 done,标准 finalize 收口。
func TestTopologyConformance_taskBoard(t *testing.T) {
be := &iterBackend{calls: map[string]int{}}
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "a", Role: "researcher", Title: "任务a", Targets: []string{"a.md"}},
StepSpec{ID: "b", Role: "writer", Title: "任务b", Needs: []string{"a"}, Targets: []string{"b.md"}},
StepSpec{ID: "c", Role: "reviewer", Title: "任务c", Needs: []string{"a"}, Targets: []string{"c.md"}},
StepSpec{ID: "d", Role: "writer", Title: "任务d", Needs: []string{"b", "c"}, Targets: []string{"output.md"}},
), be, sink)
res, err := engine.Run(context.Background(), Goal{Text: "任务板"}, RunOptions{Topology: TopologyTaskBoard})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
// 认领序尊重依赖门:a 先于 b/c,d 最后(板上按步骤序认领,确定性)。
var order []string
for _, ev := range sink.snapshot() {
if strings.Contains(ev.Text, "认领") {
order = append(order, ev.StepID)
}
}
want := []string{"a", "b", "c", "d"}
if strings.Join(order, ",") != strings.Join(want, ",") {
t.Fatalf("认领序 = %v,期望 %v", order, want)
}
st, _ := engine.loadRun(engine.runDir(res.RunID))
for _, task := range st.Board {
if task.Status != "done" {
t.Fatalf("任务 %s = %s,期望 done", task.ID, task.Status)
}
}
if sink.countText("task_board:收口(done 4/4)") != 1 {
t.Fatal("缺收口事件")
}
}
// 孤儿租约再武装:崩溃(ctx 取消)留下 claimed+过期租约 → Resume 重入
// 再武装回 open、重新认领(Attempts=2)并跑完。
func TestTaskBoardOrphanLeaseRearms(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
crashed := false
be := &iterBackend{calls: map[string]int{}, content: func(key string, _ int, _ string) []byte {
if key == "02_writer" && !crashed {
crashed = true
cancel() // 模拟进程死亡:runOneStep 在 ctx 检查处退出,步骤回到 pending
}
return nil
}}
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "t1", Role: "researcher", Title: "一", Targets: []string{"a.md"}},
StepSpec{ID: "t2", Role: "writer", Title: "二", Needs: []string{"t1"}, Targets: []string{"output.md"}},
), be, sink)
res0, err := engine.Run(ctx, Goal{Text: "任务板"}, RunOptions{Topology: TopologyTaskBoard})
if err != nil {
t.Fatal(err)
}
if res0.Status != RunCancelled {
t.Fatalf("取消后应为 RunCancelled:%s", res0.Status)
}
// 崩溃现场:t2 claimed、租约未过期;步骤在盘上是 running(setStep 只改
// 内存,取消路径的 pending 未落盘)——进程死亡即此形态。测试把租约回拨
// 成过期(孤儿)并把步骤置回 pending(模拟重启前的一致化),再走 Resume。
st, err := engine.loadRun(engine.runDir(res0.RunID))
if err != nil {
t.Fatal(err)
}
found := false
for i := range st.Board {
if st.Board[i].ID == "t2" {
found = true
if st.Board[i].Status != "claimed" || st.Board[i].Attempts != 1 {
t.Fatalf("崩溃现场 t2 = %+v", st.Board[i])
}
st.Board[i].LeaseExpires = time.Now().Add(-time.Hour) // 租约过期 = 孤儿
}
}
if !found {
t.Fatal("板上无 t2")
}
for i := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[i].ID == "t2" && st.Plan.Workflow.Steps[i].Status == "running" {
st.Plan.Workflow.Steps[i].Status = "pending"
}
}
if err := st.save(); err != nil {
t.Fatal(err)
}
// 重入:孤儿租约再武装 → 重新认领 → 跑完收口。
res, err := engine.Resume(context.Background(), st.Meta.ID)
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("恢复后应完成:%s %v", res.Status, res.Error)
}
if sink.countText("孤儿租约再武装 t2") != 1 {
t.Fatal("缺孤儿租约再武装事件")
}
st2, _ := engine.loadRun(engine.runDir(res.RunID))
for _, task := range st2.Board {
if task.ID == "t2" && task.Attempts != 2 {
t.Fatalf("再武装后 Attempts = %d,期望 2", task.Attempts)
}
}
}
// 依赖门级联:非关键任务失败 → 下游 open 任务级联 skipped → 降级收口。
func TestTaskBoardDependencyGateCascade(t *testing.T) {
be := &iterBackend{calls: map[string]int{}, content: func(key string, _ int, _ string) []byte {
return nil
}}
failBe := &failOnceBackend{inner: be, failKey: "02_writer"}
crit := false
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "t0", Role: "researcher", Title: "零", Targets: []string{"output.md"}},
StepSpec{ID: "t1", Role: "writer", Title: "一", Needs: []string{"t0"}, Targets: []string{"b.md"}, Critical: &crit},
StepSpec{ID: "t2", Role: "reviewer", Title: "二", Needs: []string{"t1"}, Targets: []string{"c.md"}},
), failBe, sink)
res, err := engine.Run(context.Background(), Goal{Text: "任务板"}, RunOptions{Topology: TopologyTaskBoard})
if err != nil {
t.Fatal(err)
}
if res.Status != RunDegraded {
t.Fatalf("非关键失败+级联跳过应降级收口:%s %v", res.Status, res.Error)
}
st, _ := engine.loadRun(engine.runDir(res.RunID))
byID := map[string]BoardTask{}
for _, task := range st.Board {
byID[task.ID] = task
}
if byID["t0"].Status != "done" || byID["t1"].Status != "failed" || byID["t2"].Status != "skipped" {
t.Fatalf("板状态 = %+v", byID)
}
for _, s := range st.Plan.Workflow.Steps {
if s.ID == "t2" && (s.Status != "skipped" || s.Error != boardSkipReason) {
t.Fatalf("t2 = %s %q", s.Status, s.Error)
}
}
}
// failOnceBackend 对指定目录恒定失败(非瞬态),其余委托 inner。
type failOnceBackend struct {
inner Executor
failKey string
}
func (b *failOnceBackend) Name() string { return b.inner.Name() }
func (b *failOnceBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
if key == b.failKey {
return &ExecResult{Success: false, ErrorReason: "fatal: 拒绝执行"}, nil
}
return b.inner.Execute(ctx, req)
}
// ---- 12/12 汇总 ----
// TestTopologyConformance_allTwelve 全 12 内置拓扑汇总跑:每个拓扑名一次
// 脚本化最小运行(零模型零网络)。graph/mode/driver 十种断言完成收口;
// handoff/mailbox 两个 primitive 断言 fail-closed 拒绝单跑(其原语能力分别
// 由 TestTopologyConformance_handoff 与 WP2 mailbox 测试钉住)。
func TestTopologyConformance_allTwelve(t *testing.T) {
specs := BuiltinTopologies()
if len(specs) != 12 {
t.Fatalf("内置拓扑 = %d,期望 12", len(specs))
}
for _, spec := range specs {
spec := spec
t.Run(spec.Name, func(t *testing.T) {
switch spec.Name {
case TopologySequential:
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"output.md"}},
), &iterBackend{calls: map[string]int{}}, RunOptions{}, RunCompleted)
case TopologyParallel:
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "甲", Parallel: true, Targets: []string{"a.md"}},
StepSpec{ID: "s2", Role: "writer", Title: "乙", Parallel: true, Targets: []string{"output.md"}},
), &iterBackend{calls: map[string]int{}}, RunOptions{}, RunCompleted)
case TopologyLoop:
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "循环体", Targets: []string{"output.md", "fields.json"},
Loop: &LoopSpec{MaxIterations: 2, UntilField: "done", UntilEquals: "yes"}},
), &iterBackend{calls: map[string]int{}, content: func(_ string, _ int, target string) []byte {
if target == "fields.json" {
return fieldsJSON(map[string]string{"done": "no"})
}
return nil
}}, RunOptions{}, RunCompleted)
case TopologyRouter:
runAgg(t, spec.Name, mockScriptPlanner(routerSteps()...),
&iterBackend{calls: map[string]int{}, content: func(_ string, _ int, target string) []byte {
if target == "fields.json" {
return fieldsJSON(map[string]string{"route": "fast"})
}
return nil
}}, RunOptions{}, RunCompleted)
case TopologyEvaluatorOptimizer:
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "draft", Role: "writer", Title: "起草", Targets: []string{"output.md"}},
StepSpec{ID: "eval", Role: "reviewer", Title: "评审", Targets: []string{"verdict.md", "fields.json"},
Loop: &LoopSpec{MaxIterations: 4, UntilField: "verdict", UntilEquals: "pass", Body: []string{"draft"}}},
), &iterBackend{calls: map[string]int{}, content: func(_ string, iter int, target string) []byte {
if target == "fields.json" {
if iter >= 3 {
return fieldsJSON(map[string]string{"verdict": "pass"})
}
return fieldsJSON(map[string]string{"verdict": "fail"})
}
return nil
}}, RunOptions{}, RunCompleted)
case TopologyLongRunning:
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"output.md"}},
), &iterBackend{calls: map[string]int{}},
RunOptions{Deadline: time.Now().Add(5 * time.Second)}, RunCompleted)
case TopologyLeader:
b := &leaderScriptBackend{
decisions: []string{delegateJSON("writer", "撰写简报"), closeJSON},
script: map[string]map[string]string{"01_writer": {"output.md": goodReport}},
}
engine := newLeaderEngine(t, b)
res, err := engine.Run(context.Background(), Goal{Text: "汇总"}, RunOptions{Topology: TopologyLeader})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
case TopologyReview:
planner := mockScriptPlanner(
StepSpec{ID: "f1", Role: "researcher", Title: "评审", Targets: []string{"findings.json"}},
)
planner.Agents = []RoleSpec{
{Role: "researcher", Name: "评审", Backend: "agg-review"},
{Role: "verifier", Name: "核实", Backend: "agg-review"},
}
engine := newAggEngine(t, planner, &aggReviewBackend{}, []RoleSpec{
{Role: "researcher", Name: "评审", Backend: "agg-review"},
{Role: "verifier", Name: "核实", Backend: "agg-review"},
})
res, err := engine.Run(context.Background(), Goal{Text: "评审"},
RunOptions{Topology: TopologyReview, ReviewTarget: "材料"})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
data, _ := os.ReadFile(filepath.Join(res.WorkDir, "output.md"))
if !strings.Contains(string(data), "已丢弃 0 条未验证") || !strings.Contains(string(data), "样例问题") {
t.Fatalf("报告 = %s", data)
}
case TopologyGroupChat:
engine := newAggEngine(t, mockScriptPlanner(
StepSpec{ID: "p1", Role: "researcher", Title: "甲"},
StepSpec{ID: "p2", Role: "writer", Title: "乙"},
), &gcScriptBackend{}, []RoleSpec{
{Role: "researcher", Name: "甲", Backend: "gc-script"},
{Role: "writer", Name: "乙", Backend: "gc-script"},
})
res, err := engine.Run(context.Background(), Goal{Text: "群聊"},
RunOptions{Topology: TopologyGroupChat, GroupChatMaxTurns: 2})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
case TopologyTaskBoard:
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "t1", Role: "researcher", Title: "一", Targets: []string{"a.md"}},
StepSpec{ID: "t2", Role: "writer", Title: "二", Needs: []string{"t1"}, Targets: []string{"output.md"}},
), &iterBackend{calls: map[string]int{}}, RunOptions{}, RunCompleted)
case TopologyHandoff, TopologyMailbox:
// primitive:不可单独作为运行拓扑(fail-closed);原语能力由
// 专项 conformance 钉住(handoff/mailbox 测试)。
runAgg(t, spec.Name, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"output.md"}},
), &iterBackend{calls: map[string]int{}}, RunOptions{}, RunFailed)
default:
t.Fatalf("未覆盖的拓扑 %q", spec.Name)
}
})
}
}
// runAgg 汇总跑的最小引擎:graph 类拓扑共用 iter 三角色装配。
func runAgg(t *testing.T, topo string, planner Planner, be Executor, opts RunOptions, want RunStatus) {
t.Helper()
opts.Topology = topo
engine := newTopologyEngine(t, planner, be, nil)
res, err := engine.Run(context.Background(), Goal{Text: "汇总跑 " + topo}, opts)
if err != nil {
t.Fatal(err)
}
if res.Status != want {
t.Fatalf("status = %s err = %q,期望 %s", res.Status, res.Error, want)
}
}
// aggReviewBackend 汇总跑用的最小 review 后端:finder 出一条发现,verify 判 PASS。
type aggReviewBackend struct{}
func (b *aggReviewBackend) Name() string { return "agg-review" }
func (b *aggReviewBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
if strings.HasPrefix(key, "verify_") {
return writeOne(req.WorkDir, "verdict.md", "核对成立。\nVERDICT: PASS"), nil
}
return writeOne(req.WorkDir, "findings.json",
`[{"location":"第1段","severity":"high","issue":"样例问题","evidence":"样例证据"}]`), nil
}
// newAggEngine 自定义角色表的汇总引擎(backend 实例名 = be.Name())。
func newAggEngine(t *testing.T, planner Planner, be Executor, roles []RoleSpec) *SquadEngine {
t.Helper()
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range roles {
if err := reg.Register(r); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance(be.Name(), be)
engine, err := NewEngine(Options{DataDir: dir, Registry: reg, Planner: planner, Backends: backends, Sink: NopSink{}})
if err != nil {
t.Fatal(err)
}
return engine
}