merge: candidate u9u12-keys-rollout-2026-09-25 into dev

This commit is contained in:
weichen
2026-09-25 14:39:51 +00:00
8 changed files with 527 additions and 15 deletions
@@ -0,0 +1,73 @@
---
title: "P8: U9–U12 剩余键逐键放量"
task_id: u9u12-keys-rollout-2026-09-25
status: in_progress
created: 2026-09-25
updated: 2026-09-25
owner: cursor-auto
task_type: implementation
task_tier: small
policy_revision: "agent-governance-2026-09-21-r1"
branch: feature/u9u12-keys-rollout-2026-09-25
worktree: .worktrees/u9u12-keys-rollout-2026-09-25
base_dev_sha: "9f7bbc25a05853aceecad4c60233fa8d38a8990a"
write_scope:
- internal/app/
- internal/app/intentflow/
- internal/config/
- .doc/ops/short-term/
- .doc/test-reports/
allowed_environments: []
head_sha: ""
mutex: intent-plan
review_state: pending
integration_state: pending
deployment: not_required
cleanup: pending
cleanup_owner: independent maintainer after owner exit
cleanup_trigger: acceptance green + optional owner exit
remote_sync: not_requested
---
# P8: U9–U12 剩余键逐键放量
## 用户目标与授权
- 用户目标:`/goal 把这些任务都进行规划,并推进至完成` → 总控 [P8 批次](./2026-09-25-planned-tasks-closeout-plan.md)。
- 来源:MATRIX `intent-plan-u9u12` 行尾注「未开放:`document.review.approve/reject`、`document.final.*`、`document.proposal.decide`、`workflow.switch`、`run.restart`(validated_only)」。
- 交付终点:逐键独立 flag + 真实引擎路由 + 单测验收 + 真实 dev PI 证据;**禁 full 一次放量**。
## 实施内容
1. **逐键 flag**(`intentflow/flags.go`):`U11LiveReview`、`U11LiveFinal`、`U11LiveProposal`、`U12LiveSwitch`、`U12LiveRestart`(env `MULTI_INTENT_U11_LIVE_REVIEW` 等,默认全 OFF)。域名总 flag 开启后细粒度键仍须单独打开。
2. **live 集合函数化**(`intentflow/expansion.go`):`DocGovLiveActionKeys()`/`RunControlLiveActionKeys()` 按当前 flag 动态返回;基线键(claim/review.submit、workflow.run/run.resume)不受影响。新增 `IsDocGovLiveKey`/`IsRunControlLiveKey` 判定。
3. **DocGovOwner 路由扩展**(`app/docgov_owner.go`):
- `document.review.approve` → `ReviewApprove`(RoleReviewer)
- `document.review.reject` → `ReviewReject`(RoleReviewer,reason 走 slots)
- `document.final.approve` → `ApproverApprove`(RoleApprover)
- `document.final.reject` → `ApproverReject`(RoleApprover,rollback→drafting)
- `document.proposal.decide` → `DecideModificationProposal`(proposal_id/approve slots;收据走 `commitReceipt`)
4. **RunControlOwner 路由扩展**(`app/run_control_owner.go`):
- `workflow.switch` → `ActivateWorkflow`(workflow_key slot 必填校验)
- `run.restart` → `CreateAndStart`(全新 run,不续 workspace,幂等键防重)
5. **policy 表**(`intentflow/policy.go`):7 个新键从 `TierFull` 提到 `TierConfirm`(confirm 档进计划;真实执行仍受 per-key flag 限制)。
6. **验收单测**(`app/live_keys_rollout_test.go`):默认 OFF 不入 live、逐键 flag 独立、review.approve 真实状态推进(reviewing→approving)+ 幂等重放、flag OFF 时 dry-run 零状态推进。
## 非目标
- 不改 tier 默认(仍 legacy);compose 不动。
- `run.resume` 已 live 无需动。
- 真实 dev 逐键 PI 证据:flag 打开后另行采集(见测试报告)。
## 验证与证据
| 范围 | 命令 | 结果 |
|---|---|---|
| P8 新用例 | `go test -short -run 'TestP8' ./internal/app/` | PASS |
| app/intent/coordinator 全量 | `go test -short ./internal/app/... ./internal/intent/... ./internal/coordinator/...` | PASS(13 包) |
| 架构门禁 | `./scripts/arch-check.sh --mode block` | PASS |
## 收口与交接
- 详见测试报告:[2026-09-25-p8-u9u12-keys-rollout-test-report.md](../../test-reports/2026-09-25-p8-u9u12-keys-rollout-test-report.md)
- 后续:dev 运维按键打开 flag(`configs/.env.dev`),采集真实 PI 证据后更新 MATRIX 行尾注。
@@ -0,0 +1,50 @@
# P8 U9–U12 剩余键逐键放量 测试报告
## 一、 测试背景 (Background)
MATRIX `intent-plan-u9u12` 行(done / deferred_enhancement)尾注:`document.review.approve/reject`、`document.final.approve/reject`、`document.proposal.decide`、`workflow.switch`、`run.restart` 仍为 validated_only(dry-run)。总控计划 P8 授权逐键放量:每键独立 flag + 真实引擎路由,禁 full 一次放量。
## 二、 测试目标 (Test Objectives)
1. 默认 flag 全 OFF 时新键保持 dry-run(不进 live 集合)。
2. 逐键 flag 独立打开只放量对应键。
3. 新键路由真实领域引擎(docgov 状态机 / projectworkflow / taskrun)。
4. 幂等重放返回同收据;flag OFF 时零状态推进。
5. 既有基线键行为不变;arch-check PASS。
## 三、 测试方案与环境 (Test Plan & Environment)
- OS:Linux 7.0.0-31-generic;Go 1.27.1。
- 被测 worktree:`.worktrees/u9u12-keys-rollout-2026-09-25`(base dev `9f7bbc25`)。
- 层级:工程层 Go 单测(真实 docgov 引擎内存实例);真实 dev PI 逐键证据在本批 flag 落 configs 后采集(另见报告更新)。
## 四、 预期结果 (Expected Results)
| # | 用例 | 预期 |
|---|---|---|
| T1 | 默认 OFF | 5 docgov 键 + 2 run-control 键不在 live 集合;基线 4 键仍在 |
| T2 | 逐键 flag | 全开时 7 键 live;只开 review 时 final/proposal 仍 dry-run |
| T3 | review.approve live | 文档 reviewing→approving;收据 `docgov:<docID>`;重放同收据 |
| T4 | review.approve flag OFF | dry-run 收据非引擎路径;状态零推进 |
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
- T1:PASS(`TestP8LiveKeysDefaultOff`)
- T2:PASS(`TestP8LiveKeysPerKeyFlags`)
- T3:PASS(`TestP8DocGovReviewApproveLive`;含幂等重放断言)
- T4:PASS(`TestP8DocGovReviewApproveDryRunWhenFlagOff`)
- 全量回归:`go test -short ./internal/app/... ./internal/intent/... ./internal/coordinator/...` 13 包全 ok
- 架构:`arch-check --mode block` production gate passed
## 六、 自动化测试套件汇总 (Test Suite Execution)
| 套件 | 结果 | 通过率 |
|---|---|---|
| `go test -run 'TestP8' ./internal/app/` | PASS | 4/4 |
| app/intent/coordinator 短测 | PASS | 100%(13 包) |
| arch-check block | PASS | — |
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
- 结论:代码层达到交付终点——逐键 flag 机制 + 真实引擎路由 + 单测验收全绿。
- 后续:`configs/.env.dev` 逐键打开 flag(先 review,后 final/proposal/switch/restart)采集真实 PI 证据;证据齐后在 MATRIX 行尾注登记「已 live 键集」扩展。`run.resume` 的自然语言触发证据仍欠(既有尾注遗留)。
+67 -2
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"sync" "sync"
"chat-agent-system/internal/app/intentflow" "chat-agent-system/internal/app/intentflow"
@@ -38,8 +39,7 @@ func NewDocGovOwner(gov *docgov.GovernanceEngine, access ProjectAccess, resolve
} }
func (d *DocGovOwner) isLiveKey(key string) bool { func (d *DocGovOwner) isLiveKey(key string) bool {
_, ok := intentflow.DocGovLiveActionKeys[key] return intentflow.IsDocGovLiveKey(key)
return ok
} }
func (d *DocGovOwner) Authorize(ctx context.Context, exec intentflow.StepExec) error { func (d *DocGovOwner) Authorize(ctx context.Context, exec intentflow.StepExec) error {
@@ -107,6 +107,20 @@ func (d *DocGovOwner) Execute(ctx context.Context, exec intentflow.StepExec) (in
updated, err = d.Gov.RecordDocumentViewed(ctx, docID, exec.Principal.UserID, role, name) updated, err = d.Gov.RecordDocumentViewed(ctx, docID, exec.Principal.UserID, role, name)
case "document.review.submit": case "document.review.submit":
updated, err = d.Gov.SubmitForReview(ctx, docID, exec.Principal.UserID, role, name) updated, err = d.Gov.SubmitForReview(ctx, docID, exec.Principal.UserID, role, name)
case "document.review.approve":
updated, err = d.Gov.ReviewApprove(ctx, docID, exec.Principal.UserID, docgov.RoleReviewer, name, docgovCommentFromExec(exec))
case "document.review.reject":
updated, err = d.Gov.ReviewReject(ctx, docID, exec.Principal.UserID, docgov.RoleReviewer, name, docgovReasonFromExec(exec))
case "document.final.approve":
updated, err = d.Gov.ApproverApprove(ctx, docID, exec.Principal.UserID, docgov.RoleApprover, name, docgovCommentFromExec(exec))
case "document.final.reject":
updated, err = d.Gov.ApproverReject(ctx, docID, exec.Principal.UserID, docgov.RoleApprover, name, docgovReasonFromExec(exec), docgov.StateDrafting)
case "document.proposal.decide":
var prop *docgov.ChangeProposal
prop, err = d.Gov.DecideModificationProposal(ctx, proposalIDFromExec(exec), exec.Principal.UserID, docgovProposalApproveFromExec(exec), docgovReasonFromExec(exec))
if err == nil && prop != nil {
return d.commitReceipt(exec, "docgov:proposal:"+prop.ProposalID, fmt.Sprintf("docgov:proposal:%s:%s", exec.ActionKey, prop.Status))
}
default: default:
return intentflow.Receipt{}, intentflow.ErrPlanActionForbidden return intentflow.Receipt{}, intentflow.ErrPlanActionForbidden
} }
@@ -132,6 +146,57 @@ func (d *DocGovOwner) Execute(ctx context.Context, exec intentflow.StepExec) (in
return rec, nil return rec, nil
} }
// commitReceipt 与主路径共用收据落盘(proposal 无 DocumentRecord 版本号可引用)。
func (d *DocGovOwner) commitReceipt(exec intentflow.StepExec, ref, digest string) (intentflow.Receipt, error) {
rec := intentflow.Receipt{
IdempotencyKey: exec.IdempotencyKey,
ActionDigest: exec.ActionDigest,
ResultRef: ref,
ResultDigest: digest,
SafeSummary: exec.ActionKey + " accepted",
}
d.mu.Lock()
defer d.mu.Unlock()
if existing, ok := d.receipts[exec.IdempotencyKey]; ok {
if existing.ActionDigest != exec.ActionDigest {
return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict
}
return existing, nil
}
d.receipts[exec.IdempotencyKey] = rec
return rec, nil
}
func docgovCommentFromExec(exec intentflow.StepExec) string {
return slotValue(exec, "comment")
}
func docgovReasonFromExec(exec intentflow.StepExec) string {
for _, key := range []string{"reason", "reject_reason", "decision_reason"} {
if v := slotValue(exec, key); v != "" {
return v
}
}
return "用户确认执行"
}
func proposalIDFromExec(exec intentflow.StepExec) string {
return slotValue(exec, "proposal_id")
}
func docgovProposalApproveFromExec(exec intentflow.StepExec) bool {
v := slotValue(exec, "approve")
if v == "" {
v = slotValue(exec, "proposal_action")
}
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "approve", "accept", "采纳":
return true
default:
return false
}
}
func (d *DocGovOwner) LookupReceipt(ctx context.Context, idempotencyKey string) (intentflow.Receipt, bool, error) { func (d *DocGovOwner) LookupReceipt(ctx context.Context, idempotencyKey string) (intentflow.Receipt, bool, error) {
if d == nil { if d == nil {
return intentflow.Receipt{}, false, nil return intentflow.Receipt{}, false, nil
+53 -4
View File
@@ -209,10 +209,59 @@ var runControlActionKeys = map[string]struct{}{
"run.restart": {}, "run.restart": {},
} }
// DocGovLiveActionKeys are the first U11 keys wired to the real docgov engine. // DocGovLiveActionKeys are the U11 keys wired to the real docgov engine.
var DocGovLiveActionKeys = map[string]struct{}{ // 基线键(claim / review.submit)随 U11 总 flag 放量;P8 逐键键(review approve/reject、
"document.claim": {}, // final.*、proposal.decide)须额外打开对应 LIVE_* flag 才进入 live 集合。
"document.review.submit": {}, func DocGovLiveActionKeys() map[string]struct{} {
keys := map[string]struct{}{
"document.claim": {},
"document.review.submit": {},
}
f := CurrentFeatureFlags()
if f.U11LiveReview {
keys["document.review.approve"] = struct{}{}
keys["document.review.reject"] = struct{}{}
}
if f.U11LiveFinal {
keys["document.final.approve"] = struct{}{}
keys["document.final.reject"] = struct{}{}
}
if f.U11LiveProposal {
keys["document.proposal.decide"] = struct{}{}
}
return keys
}
// RunControlLiveActionKeys are the U12 keys wired to the real run-control adapters.
// 基线键(workflow.run / run.resume)随 U12 总 flag 放量;P8 逐键键(workflow.switch、
// run.restart)须额外打开对应 LIVE_* flag 才进入 live 集合。
func RunControlLiveActionKeys() map[string]struct{} {
keys := map[string]struct{}{
"workflow.run": {},
"run.resume": {},
}
f := CurrentFeatureFlags()
if f.U12LiveSwitch {
keys["workflow.switch"] = struct{}{}
}
if f.U12LiveRestart {
keys["run.restart"] = struct{}{}
}
return keys
}
// IsDocGovLiveKey reports whether the docgov key routes to the live engine
// under current flags (used by adapters and tests).
func IsDocGovLiveKey(key string) bool {
_, ok := DocGovLiveActionKeys()[key]
return ok
}
// IsRunControlLiveKey reports whether the run-control key routes to the live
// adapter under current flags.
func IsRunControlLiveKey(key string) bool {
_, ok := RunControlLiveActionKeys()[key]
return ok
} }
// LiveOwners carries production adapters from the composition root (U10–U12). // LiveOwners carries production adapters from the composition root (U10–U12).
+12
View File
@@ -29,6 +29,13 @@ type FeatureFlags struct {
U10Mutate bool // MULTI_INTENT_U10_MUTATE — low/medium mutate adapters U10Mutate bool // MULTI_INTENT_U10_MUTATE — low/medium mutate adapters
U11DocGov bool // MULTI_INTENT_U11_DOCGOV — docgov action adapters U11DocGov bool // MULTI_INTENT_U11_DOCGOV — docgov action adapters
U12RunControl bool // MULTI_INTENT_U12_RUN_CONTROL — workflow/run-control adapters U12RunControl bool // MULTI_INTENT_U12_RUN_CONTROL — workflow/run-control adapters
// 逐键 live 放量 flag(P8):域名总 flag 开启后,细粒度键仍须单独打开才走真实引擎;
// 未打开的键保持 validated_only dry-run。默认全 OFF,禁止 full 一次放量。
U11LiveReview bool // MULTI_INTENT_U11_LIVE_REVIEW — document.review.approve/reject
U11LiveFinal bool // MULTI_INTENT_U11_LIVE_FINAL — document.final.approve/reject
U11LiveProposal bool // MULTI_INTENT_U11_LIVE_PROPOSAL — document.proposal.decide
U12LiveSwitch bool // MULTI_INTENT_U12_LIVE_SWITCH — workflow.switch
U12LiveRestart bool // MULTI_INTENT_U12_LIVE_RESTART — run.restart
} }
var ( var (
@@ -44,6 +51,11 @@ func defaultFeatureFlags() FeatureFlags {
U10Mutate: envBool("MULTI_INTENT_U10_MUTATE", false), U10Mutate: envBool("MULTI_INTENT_U10_MUTATE", false),
U11DocGov: envBool("MULTI_INTENT_U11_DOCGOV", false), U11DocGov: envBool("MULTI_INTENT_U11_DOCGOV", false),
U12RunControl: envBool("MULTI_INTENT_U12_RUN_CONTROL", false), U12RunControl: envBool("MULTI_INTENT_U12_RUN_CONTROL", false),
U11LiveReview: envBool("MULTI_INTENT_U11_LIVE_REVIEW", false),
U11LiveFinal: envBool("MULTI_INTENT_U11_LIVE_FINAL", false),
U11LiveProposal: envBool("MULTI_INTENT_U11_LIVE_PROPOSAL", false),
U12LiveSwitch: envBool("MULTI_INTENT_U12_LIVE_SWITCH", false),
U12LiveRestart: envBool("MULTI_INTENT_U12_LIVE_RESTART", false),
} }
} }
+9
View File
@@ -120,6 +120,15 @@ func defaultBatchRows() []ActionBatchPolicy {
"document.review.submit", "document.review.submit",
"workflow.run", "workflow.run",
"run.resume", "run.resume",
// P8 逐键放量:confirm 档即可进入计划(真实执行仍受 per-key LIVE flag 限制;
// flag 未开时 Owner 落 dry-run validated_only)。
"document.review.approve",
"document.review.reject",
"document.final.approve",
"document.final.reject",
"document.proposal.decide",
"workflow.switch",
"run.restart",
} { } {
add(ActionBatchPolicy{ add(ActionBatchPolicy{
ActionKey: key, ActionKey: key,
+199
View File
@@ -0,0 +1,199 @@
package app
import (
"context"
"testing"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/docgov"
)
// P8 逐键放量:默认 flag 全关时,新键不得进入 live 集合(仍 dry-run validated_only)。
func TestP8LiveKeysDefaultOff(t *testing.T) {
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
PlanTier: intentflow.TierConfirm,
})
defer intentflow.ResetFeatureFlagsForTest()
for _, key := range []string{
"document.review.approve", "document.review.reject",
"document.final.approve", "document.final.reject",
"document.proposal.decide",
} {
if intentflow.IsDocGovLiveKey(key) {
t.Fatalf("docgov key %s must stay dry-run when LIVE flags off", key)
}
}
for _, key := range []string{"workflow.switch", "run.restart"} {
if intentflow.IsRunControlLiveKey(key) {
t.Fatalf("run-control key %s must stay dry-run when LIVE flags off", key)
}
}
// 基线键不受 per-key flag 影响。
for _, key := range []string{"document.claim", "document.review.submit"} {
if !intentflow.IsDocGovLiveKey(key) {
t.Fatalf("baseline docgov key %s must stay live", key)
}
}
for _, key := range []string{"workflow.run", "run.resume"} {
if !intentflow.IsRunControlLiveKey(key) {
t.Fatalf("baseline run-control key %s must stay live", key)
}
}
}
// 逐键 flag 打开后对应键进入 live 集合,其余仍关闭(禁 full 一次放量)。
func TestP8LiveKeysPerKeyFlags(t *testing.T) {
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
PlanTier: intentflow.TierConfirm,
U11LiveReview: true,
U11LiveFinal: true,
U11LiveProposal: true,
U12LiveSwitch: true,
U12LiveRestart: true,
})
defer intentflow.ResetFeatureFlagsForTest()
for _, key := range []string{
"document.review.approve", "document.review.reject",
"document.final.approve", "document.final.reject",
"document.proposal.decide",
} {
if !intentflow.IsDocGovLiveKey(key) {
t.Fatalf("docgov key %s must be live when flag on", key)
}
}
for _, key := range []string{"workflow.switch", "run.restart"} {
if !intentflow.IsRunControlLiveKey(key) {
t.Fatalf("run-control key %s must be live when flag on", key)
}
}
// 只开 review:final/proposal 仍 dry-run。
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
PlanTier: intentflow.TierConfirm,
U11LiveReview: true,
})
if !intentflow.IsDocGovLiveKey("document.review.approve") {
t.Fatal("review.approve should be live")
}
if intentflow.IsDocGovLiveKey("document.final.approve") {
t.Fatal("final.approve must stay dry-run when only review flag on")
}
if intentflow.IsDocGovLiveKey("document.proposal.decide") {
t.Fatal("proposal.decide must stay dry-run when only review flag on")
}
}
// review.approve 打开后走真实引擎:文档从 in_review → pending_approval。
func TestP8DocGovReviewApproveLive(t *testing.T) {
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
PlanTier: intentflow.TierConfirm,
U11DocGov: true,
U11LiveReview: true,
})
defer intentflow.ResetFeatureFlagsForTest()
engine := docgov.NewGovernanceEngine(nil)
ctx := context.Background()
doc, err := engine.CreateOrGetDocument(ctx, "doc_p8_review", 9, "intake", "t", "V0.5", "", 1, "A", 2, "R", 3, "P")
if err != nil {
t.Fatal(err)
}
if _, err := engine.SubmitForReview(ctx, doc.DocID, 1, docgov.RoleAuthor, "A"); err != nil {
t.Fatal(err)
}
cur, err := engine.GetDocument(ctx, doc.DocID)
if err != nil {
t.Fatal(err)
}
owner := NewDocGovOwner(engine, func(context.Context, int64, int64) error { return nil }, nil)
rec, err := owner.Execute(ctx, intentflow.StepExec{
ActionKey: "document.review.approve",
IdempotencyKey: "p8-review-approve",
ActionDigest: "d1",
ExpectedResourceVersion: itoa(cur.Version),
Principal: intentflow.Principal{UserID: 2, ProjectID: 9, SessionID: "s"},
SafeSummary: doc.DocID,
})
if err != nil {
t.Fatalf("review approve live: %v", err)
}
if rec.ResultRef != "docgov:"+doc.DocID {
t.Fatalf("unexpected ref %s", rec.ResultRef)
}
after, _ := engine.GetDocument(ctx, doc.DocID)
if after.CurrentState != docgov.StateApproving {
t.Fatalf("state after approve = %s, want %s", after.CurrentState, docgov.StateApproving)
}
// 幂等重放同键返回同一收据。
rec2, err := owner.Execute(ctx, intentflow.StepExec{
ActionKey: "document.review.approve",
IdempotencyKey: "p8-review-approve",
ActionDigest: "d1",
ExpectedResourceVersion: itoa(cur.Version),
Principal: intentflow.Principal{UserID: 2, ProjectID: 9, SessionID: "s"},
SafeSummary: doc.DocID,
})
if err != nil || rec2.IdempotencyKey != rec.IdempotencyKey {
t.Fatalf("idempotent replay: rec=%+v err=%v", rec2, err)
}
}
// flag 关闭时 review.approve 落 dry-run(Forbidden 而非真实状态推进)。
func TestP8DocGovReviewApproveDryRunWhenFlagOff(t *testing.T) {
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
PlanTier: intentflow.TierConfirm,
U11DocGov: true,
})
defer intentflow.ResetFeatureFlagsForTest()
engine := docgov.NewGovernanceEngine(nil)
ctx := context.Background()
doc, err := engine.CreateOrGetDocument(ctx, "doc_p8_dry", 9, "intake", "t", "V0.5", "", 1, "A", 2, "R", 3, "P")
if err != nil {
t.Fatal(err)
}
owner := NewDocGovOwner(engine, func(context.Context, int64, int64) error { return nil }, nil)
rec, err := owner.Execute(ctx, intentflow.StepExec{
ActionKey: "document.review.approve",
IdempotencyKey: "p8-dry",
ActionDigest: "d1",
ExpectedResourceVersion: "1",
Principal: intentflow.Principal{UserID: 2, ProjectID: 9, SessionID: "s"},
SafeSummary: doc.DocID,
})
if err != nil {
t.Fatalf("dry-run execute: %v", err)
}
if rec.ResultRef == "docgov:"+doc.DocID {
t.Fatal("flag off must not hit real engine receipt")
}
after, _ := engine.GetDocument(ctx, doc.DocID)
if after.CurrentState == docgov.StateApproving {
t.Fatal("dry-run must not advance document state")
}
}
func itoa(v int) string {
if v == 0 {
return "0"
}
neg := v < 0
if neg {
v = -v
}
var b [20]byte
i := len(b)
for v > 0 {
i--
b[i] = byte('0' + v%10)
v /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}
+64 -9
View File
@@ -13,9 +13,8 @@ import (
"chat-agent-system/internal/taskrun" "chat-agent-system/internal/taskrun"
) )
var runControlLiveKeys = map[string]struct{}{ func (r *RunControlOwner) isLiveKey(key string) bool {
"workflow.run": {}, return intentflow.IsRunControlLiveKey(key)
"run.resume": {},
} }
// RunControlOwner is the U12 live adapter for workflow.run and run.resume. // RunControlOwner is the U12 live adapter for workflow.run and run.resume.
@@ -41,11 +40,6 @@ func NewRunControlOwner(access ProjectAccess, pw *projectworkflow.Service, runs
} }
} }
func (r *RunControlOwner) isLiveKey(key string) bool {
_, ok := runControlLiveKeys[key]
return ok
}
func (r *RunControlOwner) Authorize(ctx context.Context, exec intentflow.StepExec) error { func (r *RunControlOwner) Authorize(ctx context.Context, exec intentflow.StepExec) error {
if r == nil || r.Denied { if r == nil || r.Denied {
return intentflow.ErrPlanActionForbidden return intentflow.ErrPlanActionForbidden
@@ -61,7 +55,7 @@ func (r *RunControlOwner) Authorize(ctx context.Context, exec intentflow.StepExe
return intentflow.ErrPlanActionForbidden return intentflow.ErrPlanActionForbidden
} }
} }
if exec.ActionKey == "run.resume" { if exec.ActionKey == "run.resume" || exec.ActionKey == "run.restart" {
workDir := slotValue(exec, "work_dir") workDir := slotValue(exec, "work_dir")
if workDir == "" { if workDir == "" {
workDir = strings.TrimSpace(exec.SafeSummary) workDir = strings.TrimSpace(exec.SafeSummary)
@@ -70,6 +64,11 @@ func (r *RunControlOwner) Authorize(ctx context.Context, exec intentflow.StepExe
return intentflow.ErrPlanActionForbidden return intentflow.ErrPlanActionForbidden
} }
} }
if exec.ActionKey == "workflow.switch" {
if slotValue(exec, "workflow_key") == "" {
return intentflow.ErrPlanActionForbidden
}
}
return nil return nil
} }
@@ -177,6 +176,62 @@ func (r *RunControlOwner) runLive(ctx context.Context, exec intentflow.StepExec)
return "", err return "", err
} }
return created.RunID, nil return created.RunID, nil
case "run.restart":
// 重启语义:不续用原 workspace,从 intake 重新起一个全新 run(幂等键防重复)。
if r.TaskRuns == nil {
return "", fmt.Errorf("run control: taskrun manager missing")
}
if existing, err := r.TaskRuns.FindByIdempotencyKey(ctx, exec.IdempotencyKey); err != nil {
return "", err
} else if existing != nil && existing.RunID != "" {
return existing.RunID, nil
}
projectName := ""
if r.ProjectName != nil {
projectName, _ = r.ProjectName(ctx, exec.Principal.ProjectID)
}
runID := planRunID(exec.IdempotencyKey)
taskKey := slotValue(exec, "task_key")
if taskKey == "" {
taskKey = "intake"
}
rec := &taskrun.RunRecord{
RunID: runID,
SessionID: exec.Principal.SessionID,
ProjectID: exec.Principal.ProjectID,
UserID: exec.Principal.UserID,
IdempotencyKey: exec.IdempotencyKey,
TaskKey: taskKey,
Status: "pending",
}
created, err := r.TaskRuns.CreateAndStart(ctx, runID, rec, bizdev.TaskRunRequest{
TaskKey: taskKey,
UserID: exec.Principal.UserID,
ProjectID: exec.Principal.ProjectID,
SessionID: exec.Principal.SessionID,
ProjectName: projectName,
}, bizdev.StageRunRequest{}, "")
if err != nil {
return "", err
}
return created.RunID, nil
case "workflow.switch":
if r.Workflow == nil {
return "", fmt.Errorf("run control: workflow service missing")
}
workflowKey := slotValue(exec, "workflow_key")
if workflowKey == "" {
return "", intentflow.ErrPlanActionForbidden
}
res, err := r.Workflow.ActivateWorkflow(ctx, exec.Principal.ProjectID, exec.Principal.UserID,
projectworkflow.ActivateWorkflowInput{
WorkflowKey: workflowKey,
Reason: slotValue(exec, "reason"),
})
if err != nil {
return "", err
}
return fmt.Sprintf("workflow_switch:%d:%s", res.ProjectID, res.ActiveWorkflowKey), nil
default: default:
return "", intentflow.ErrPlanActionForbidden return "", intentflow.ErrPlanActionForbidden
} }