From 702cf6f94972b058006427444ed1a21fa28ca660 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 16 Sep 2026 01:40:07 +0000 Subject: [PATCH 1/2] feat(intentflow): execute auto_read query plans from HandleTurn Default legacy stays a no-op; QueryGuard refuses generate keys until U8. Co-authored-by: Cursor --- .doc/system-design/module-boundaries.yaml | 1 + internal/app/app.go | 2 +- internal/app/intentflow/plans.go | 4 +- internal/app/intentflow/query.go | 45 +++++++ internal/app/intentflow/turn.go | 149 ++++++++++++++++++++++ internal/app/intentflow/turn_test.go | 80 ++++++++++++ internal/chat/action_executor.go | 7 +- internal/chat/session.go | 25 ++++ internal/server/server.go | 10 +- 9 files changed, 316 insertions(+), 7 deletions(-) create mode 100644 internal/app/intentflow/query.go create mode 100644 internal/app/intentflow/turn.go create mode 100644 internal/app/intentflow/turn_test.go diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml index c87d3d00..b9b7c460 100644 --- a/.doc/system-design/module-boundaries.yaml +++ b/.doc/system-design/module-boundaries.yaml @@ -29,6 +29,7 @@ imports: - workflowdef chat: - app/chatflow + - app/intentflow - app/stageflow - coordinator - ctxmgr diff --git a/internal/app/app.go b/internal/app/app.go index 52934929..58b0f081 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -193,7 +193,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) { rt.Docflow.UoW = uow.NewPG(pool) rt.Docflow.Events = events.PGSink{} rt.IntentPlans = intentflow.NewPlans(rt.Docflow.UoW, intentflow.NewRepository(), intentflow.Options{ - Owner: intentflow.NewFakeActionOwner(), + Owner: intentflow.QueryGuard{Inner: intentflow.NewFakeActionOwner()}, Events: events.PGSink{}, }) } diff --git a/internal/app/intentflow/plans.go b/internal/app/intentflow/plans.go index f5136e1a..7b671590 100644 --- a/internal/app/intentflow/plans.go +++ b/internal/app/intentflow/plans.go @@ -99,12 +99,12 @@ func visibleTo(plan *Plan, p Principal) bool { } // HandleTurn stays a no-op while MULTI_INTENT_PLAN_TIER is legacy/shadow (default). -// U7 will map utterances onto CreatePlan; chat must not grow a second owner. +// U7 maps read-only utterances onto CreatePlan when auto_read is enabled. func (p *Plans) HandleTurn(ctx context.Context, in TurnInput) (*TurnResult, error) { if err := requirePrincipal(in.Principal); err != nil { return nil, err } - return &TurnResult{}, nil + return p.handleTurnAutoRead(ctx, in) } func (p *Plans) GetPlan(ctx context.Context, principal Principal, planID string) (*PlanView, error) { diff --git a/internal/app/intentflow/query.go b/internal/app/intentflow/query.go new file mode 100644 index 00000000..403de9ad --- /dev/null +++ b/internal/app/intentflow/query.go @@ -0,0 +1,45 @@ +package intentflow + +import "context" + +// QueryGuard only authorizes low-risk query actions opened from auto_read. +// Write/generate keys fail closed until a later adapter is wired. +type QueryGuard struct { + Inner ActionOwner + Policy *BatchActionPolicy +} + +func (g QueryGuard) Authorize(ctx context.Context, exec StepExec) error { + policy := g.Policy + if policy == nil { + policy = DefaultBatchActionPolicy() + } + row, err := policy.Lookup(exec.ActionKey) + if err != nil || row.RejectPlan || row.RequiresBatchConfirm { + return ErrPlanActionForbidden + } + if inner, ok := g.Inner.(StepAuthorizer); ok { + return inner.Authorize(ctx, exec) + } + if g.Inner == nil { + return ErrPlanActionForbidden + } + return nil +} + +func (g QueryGuard) Execute(ctx context.Context, exec StepExec) (Receipt, error) { + if err := g.Authorize(ctx, exec); err != nil { + return Receipt{}, err + } + if g.Inner == nil { + return Receipt{}, ErrPlanActionForbidden + } + return g.Inner.Execute(ctx, exec) +} + +func (g QueryGuard) LookupReceipt(ctx context.Context, idempotencyKey string) (Receipt, bool, error) { + if g.Inner == nil { + return Receipt{}, false, nil + } + return g.Inner.LookupReceipt(ctx, idempotencyKey) +} diff --git a/internal/app/intentflow/turn.go b/internal/app/intentflow/turn.go new file mode 100644 index 00000000..a995e928 --- /dev/null +++ b/internal/app/intentflow/turn.go @@ -0,0 +1,149 @@ +package intentflow + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + + "chat-agent-system/internal/app/uow" + "chat-agent-system/internal/intent" +) + +func (p *Plans) handleTurnAutoRead(ctx context.Context, in TurnInput) (*TurnResult, error) { + tier := CurrentFeatureFlags().PlanTier + if !tier.Allows(TierAutoRead) { + return &TurnResult{}, nil + } + text := strings.TrimSpace(in.Text) + if text == "" { + return &TurnResult{}, nil + } + rec := intent.NewRecognizer() + res, err := rec.Recognize(ctx, text, nil) + if err != nil || res == nil || res.PlanProposal == nil { + return &TurnResult{}, nil + } + proposal := res.PlanProposal + if proposal.ComboHint != intent.ComboReadOnly || len(proposal.Segments) < 2 || len(proposal.Segments) > 4 { + return &TurnResult{}, nil + } + policy := DefaultBatchActionPolicy() + drafts := make([]StepDraft, 0, len(proposal.Segments)) + for i, seg := range proposal.Segments { + key := "" + if seg.Frame != nil { + key = seg.Frame.ActionKey + } + if key == "" { + return &TurnResult{}, nil + } + row, err := policy.Lookup(key) + if err != nil || row.RejectPlan || row.RequiresBatchConfirm || !tier.Allows(row.OpenFromTier) { + return &TurnResult{}, nil + } + summary := strings.TrimSpace(seg.Normalized) + if summary == "" { + summary = strings.TrimSpace(seg.Text) + } + drafts = append(drafts, StepDraft{ + ActionKey: key, + ActionDigest: fmt.Sprintf("%s:%d:%s", proposal.Digest, i, key), + SafeSummary: summary, + ExecutionMode: ModeLive, + }) + } + turnID := in.ClientTurnID + if turnID == "" { + turnID = "turn-" + shortDigest(text) + } + digest := in.RequestDigest + if digest == "" { + digest = shortDigest(text) + } + created, err := p.CreatePlan(ctx, CreateCommand{ + Principal: in.Principal, + ClientTurnID: turnID, + RequestDigest: digest, + SegmenterVersion: proposal.SegmenterVersion, + Status: PlanAwaitingConfirmation, + ExecutionMode: ModeLive, + WritePlan: false, + SafeSummary: text, + Steps: drafts, + }) + if err != nil { + return nil, err + } + confirmed, err := p.DecidePlan(ctx, DecisionCommand{ + Principal: in.Principal, + PlanID: created.PlanID, + ExpectedRevision: created.Revision, + DecisionNonce: "auto-read:" + created.PlanID + ":" + digest, + DecisionDigest: "auto-confirm:" + digest, + Kind: DecisionConfirm, + }) + if err != nil { + return &TurnResult{Plan: created}, err + } + view, advErr := p.AdvanceEligible(ctx, "auto-read", confirmed.PlanID) + if view == nil { + view = confirmed + } + return &TurnResult{Plan: view}, advErr +} + +func shortDigest(text string) string { + sum := sha256.Sum256([]byte(text)) + return hex.EncodeToString(sum[:8]) +} + +// AdvanceEligible claims and executes currently eligible steps. Independent +// query failures do not block later independent steps. +func (p *Plans) AdvanceEligible(ctx context.Context, workerID, planID string) (*PlanView, error) { + var prin Principal + var ids []string + err := p.withTx(ctx, func(ctx context.Context, scope uow.TxScope) error { + plan, err := p.store.GetPlan(ctx, scope, planID) + if err != nil { + return err + } + prin = plan.Principal + for _, s := range plan.Steps { + if s.Status == StepEligible || s.Status == StepPending { + ids = append(ids, s.StepID) + } + } + return nil + }) + if err != nil { + return nil, err + } + var last *PlanView + var firstErr error + for _, id := range ids { + claimed, claimErr := p.ClaimStep(ctx, workerID, planID, id) + if claimErr != nil { + if firstErr == nil { + firstErr = claimErr + } + continue + } + if claimed == nil { + continue + } + got, execErr := p.ExecuteClaimed(ctx, claimed) + if execErr != nil { + if firstErr == nil { + firstErr = execErr + } + continue + } + last = got + } + if last == nil { + return p.GetPlan(ctx, prin, planID) + } + return last, firstErr +} diff --git a/internal/app/intentflow/turn_test.go b/internal/app/intentflow/turn_test.go new file mode 100644 index 00000000..ffe19422 --- /dev/null +++ b/internal/app/intentflow/turn_test.go @@ -0,0 +1,80 @@ +package intentflow_test + +import ( + "context" + "testing" + + "chat-agent-system/internal/app/intentflow" +) + +func TestHandleTurnAutoReadPersistsAndExecutesQueries(t *testing.T) { + intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierAutoRead}) + t.Cleanup(intentflow.ResetFeatureFlagsForTest) + p, _, owner := testPlans() + ctx := context.Background() + res, err := p.HandleTurn(ctx, intentflow.TurnInput{ + Principal: principal(), Text: "查一下项目风险,然后查相关法规", + ClientTurnID: "turn-ar-1", RequestDigest: "d-ar-1", + }) + if err != nil { + t.Fatal(err) + } + if res == nil || res.Plan == nil { + t.Fatal("auto_read must persist a plan") + } + if res.Plan.Status != intentflow.PlanExecuting && res.Plan.Status != intentflow.PlanCompleted { + t.Fatalf("status %s", res.Plan.Status) + } + again, err := p.HandleTurn(ctx, intentflow.TurnInput{ + Principal: principal(), Text: "查一下项目风险,然后查相关法规", + ClientTurnID: "turn-ar-1", RequestDigest: "d-ar-1", + }) + if err != nil { + t.Fatal(err) + } + if again.Plan.PlanID != res.Plan.PlanID { + t.Fatal("replay must reuse plan") + } + total := 0 + for _, s := range res.Plan.Steps { + total += owner.ExecCount(s.StepID) + } + _ = total + if owner.ExecCount("") < 0 { + t.Fatal("exec map") + } + n := 0 + for _, s := range again.Plan.Steps { + // idempotency keys are not step IDs; count via succeeded status + if s.Status == intentflow.StepSucceeded { + n++ + } + } + if n == 0 { + t.Fatalf("expected executed steps, plan=%+v", again.Plan) + } +} + +func TestHandleTurnWriteUtteranceStaysLegacyUnderAutoRead(t *testing.T) { + intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierAutoRead}) + t.Cleanup(intentflow.ResetFeatureFlagsForTest) + p, _, _ := testPlans() + res, err := p.HandleTurn(context.Background(), intentflow.TurnInput{ + Principal: principal(), Text: "查法规并生成报价单", + ClientTurnID: "turn-w", RequestDigest: "d-w", + }) + if err != nil { + t.Fatal(err) + } + if res.Plan != nil { + t.Fatalf("writes must not persist at auto_read: %+v", res.Plan) + } +} + +func TestQueryGuardRejectsGenerate(t *testing.T) { + g := intentflow.QueryGuard{Inner: intentflow.NewFakeActionOwner()} + _, err := g.Execute(context.Background(), intentflow.StepExec{ActionKey: "task.run", Principal: principal()}) + if err == nil { + t.Fatal("task.run must be forbidden by QueryGuard") + } +} diff --git a/internal/chat/action_executor.go b/internal/chat/action_executor.go index d98c4e33..571d005d 100644 --- a/internal/chat/action_executor.go +++ b/internal/chat/action_executor.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "chat-agent-system/internal/app/intentflow" "chat-agent-system/internal/app/stageflow" "chat-agent-system/internal/coordinator" "chat-agent-system/internal/docgov" @@ -15,8 +16,10 @@ import ( // ActionServices 对话层执行 PendingAction 所需的后端服务。 type ActionServices struct { - DocGov *docgov.GovernanceEngine - TaskRuns *taskrun.Manager + DocGov *docgov.GovernanceEngine + TaskRuns *taskrun.Manager + IntentPlans intentflow.Service + Environment string } // ActionExecResult 动作执行结果摘要。 diff --git a/internal/chat/session.go b/internal/chat/session.go index fae3289e..18ebc21e 100644 --- a/internal/chat/session.go +++ b/internal/chat/session.go @@ -8,6 +8,7 @@ import ( "time" "chat-agent-system/internal/app/chatflow" + "chat-agent-system/internal/app/intentflow" "chat-agent-system/internal/coordinator" "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/intent" @@ -38,6 +39,7 @@ type ChatResponse struct { AdvisorBrief *squad.AdvisorBriefing `json:"advisor_brief,omitempty"` InteractiveCard *intent.InteractiveCardPayload `json:"interactive_card,omitempty"` PendingExpectation *intent.PendingExpectation `json:"pending_expectation,omitempty"` + IntentPlan *intentflow.PlanView `json:"intent_plan,omitempty"` } // Session 多轮对话与协同会话管理器 @@ -333,6 +335,28 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str return nil, fmt.Errorf("intent recognition failed: %w", err) } + var persistedPlan *intentflow.PlanView + if s.Actions != nil && s.Actions.IntentPlans != nil && s.UserID > 0 && s.ProjectID > 0 { + env := s.Actions.Environment + if env == "" { + env = "dev" + } + turnID := clientTurnID + if turnID == "" { + turnID = fmt.Sprintf("turn-%d", time.Now().UnixNano()) + } + tr, planErr := s.Actions.IntentPlans.HandleTurn(ctx, intentflow.TurnInput{ + Principal: intentflow.Principal{ + UserID: s.UserID, Environment: env, ProjectID: s.ProjectID, SessionID: s.ID, + }, + Text: userInput, ClientTurnID: turnID, RequestDigest: turnID + ":" + userInput, + }) + if planErr == nil && tr != nil && tr.Plan != nil { + persistedPlan = tr.Plan + recRes.IntentPlan = nil + } + } + if streamer != nil && recRes.TopIntent != nil { streamer.EmitStatus( string(recRes.TopIntent.Intent), @@ -581,6 +605,7 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str AdvisorBrief: coordRes.AdvisorBrief, InteractiveCard: coordRes.InteractiveCard, PendingExpectation: s.UserCtx.PendingExpectation, + IntentPlan: persistedPlan, }, nil } diff --git a/internal/server/server.go b/internal/server/server.go index 5411cf6b..6226816a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -510,9 +510,15 @@ func (s *Server) getOrCreateSession( sess := chat.NewSession(sessionID, userCtx, s.rec, s.engine) sess.ChatLog = s.chatLog sess.CtxMgr = s.ctxMgr + env := "dev" + if s.cfg != nil && s.cfg.Env != "" { + env = s.cfg.Env + } sess.Actions = &chat.ActionServices{ - DocGov: s.gov, - TaskRuns: s.taskRuns, + DocGov: s.gov, + TaskRuns: s.taskRuns, + IntentPlans: s.intentPlans, + Environment: env, } // 若从持久层恢复了会话历史,回填消息列表、事实黑板与近期对话 (G3)。 From fb19b63baf133c605d2d2ef08328d7339514de01 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 16 Sep 2026 01:40:07 +0000 Subject: [PATCH 2/2] docs(test): record U7 auto_read query execution report Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 5 +- .../2026-09-16-multi-intent-u7-auto-read.md | 14 ++++++ .../2026-09-15-multi-intent-hitl.md | 2 +- .../2026-09-16-multi-intent-u7-auto-read.md | 14 ++++++ ...6-multi-intent-u7-auto-read-test-report.md | 48 +++++++++++++++++++ 5 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 .doc/ops/checklists/2026-09-16-multi-intent-u7-auto-read.md create mode 100644 .doc/ops/short-term/2026-09-16-multi-intent-u7-auto-read.md create mode 100644 .doc/test-reports/2026-09-16-multi-intent-u7-auto-read-test-report.md diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 818a7843..bc76abd9 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,7 +5,7 @@ ## 焦点 -1. **MULTI-INTENT HITL**:U6 已合入本地 `dev`([U6 报告](../test-reports/2026-09-16-multi-intent-u6-web-cards-test-report.md));下一单元 U7 只读执行 +1. **MULTI-INTENT HITL**:U7 auto_read 进行中(`feature/multi-intent-u7-auto-read`) 2. **发布门禁**:CI `TEST_DATABASE_URL`、真实 PI 三工作流 E2E、前端人工抽检仍开放([gap-matrix](../remediation/gap-matrix.md)) 3. **CHAT-INTENT**:双标签 busy 已签(项目 **6275**);不删 `sess-p143-u9`;项目 143 只读 @@ -13,6 +13,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor | 2026-09-16-multi-intent-u7-auto-read | feature/multi-intent-u7-auto-read | - | intent-plan | done | | Cursor | 2026-09-16-multi-intent-u6-web-cards | feature/multi-intent-u6-web-cards | - | web | done | | Cursor | 2026-09-16-multi-intent-u5-decision-api | feature/multi-intent-u5-decision-api | - | intent-plan | done | | Cursor | 2026-09-16-multi-intent-u4-outbox-projection | feature/multi-intent-u4-outbox-projection | - | events | done | @@ -90,7 +91,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | - | - | - | +| Cursor | merging | 2026-09-16 | U7 auto_read merge | - | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/.doc/ops/checklists/2026-09-16-multi-intent-u7-auto-read.md b/.doc/ops/checklists/2026-09-16-multi-intent-u7-auto-read.md new file mode 100644 index 00000000..87c57aa8 --- /dev/null +++ b/.doc/ops/checklists/2026-09-16-multi-intent-u7-auto-read.md @@ -0,0 +1,14 @@ +--- +title: "U7 auto_read checklist" +status: done +created: 2026-09-16 +related_task: "2026-09-16-multi-intent-u7-auto-read" +--- + +# U7 + +- [x] legacy HandleTurn 不落盘 +- [x] auto_read 查询创建并执行 +- [x] 写话语不落盘 +- [x] QueryGuard 拒绝 task.run +- [x] chat 调用 HandleTurn;arch 绿 diff --git a/.doc/ops/short-term/2026-09-15-multi-intent-hitl.md b/.doc/ops/short-term/2026-09-15-multi-intent-hitl.md index 79e16f06..436b5b4e 100644 --- a/.doc/ops/short-term/2026-09-15-multi-intent-hitl.md +++ b/.doc/ops/short-term/2026-09-15-multi-intent-hitl.md @@ -57,7 +57,7 @@ mutex: "-" 4. [x] U4 接 Track E outbox/dispatcher,只投影状态事实([独立任务](./2026-09-16-multi-intent-u4-outbox-projection.md);[报告](../../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md)); 5. [x] U5 建统一 decision command 与动作 owner 授权端口([独立任务](./2026-09-16-multi-intent-u5-decision-api.md);[报告](../../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md)); 6. [x] U6 取消计划动作的前端本地成功路径,以 PlanView 恢复([独立任务](./2026-09-16-multi-intent-u6-web-cards.md);[报告](../../test-reports/2026-09-16-multi-intent-u6-web-cards-test-report.md)); -7. [ ] U7 只开放独立低风险查询; +7. [x] U7 只开放独立低风险查询([独立任务](./2026-09-16-multi-intent-u7-auto-read.md);[报告](../../test-reports/2026-09-16-multi-intent-u7-auto-read-test-report.md)); 8. [ ] U8 只接 task generate,完成首版 real PI 闭环; 9. [ ] U9 作为独立可选实验接语义分段; 10. [ ] U10–U12 分别按 mutate/docgov/workflow/run-control owner 扩面。 diff --git a/.doc/ops/short-term/2026-09-16-multi-intent-u7-auto-read.md b/.doc/ops/short-term/2026-09-16-multi-intent-u7-auto-read.md new file mode 100644 index 00000000..1ca92c68 --- /dev/null +++ b/.doc/ops/short-term/2026-09-16-multi-intent-u7-auto-read.md @@ -0,0 +1,14 @@ +--- +title: "多意图 HITL U7:低风险只读执行" +status: done +created: 2026-09-16 +updated: 2026-09-16 +owner: Cursor +branch: feature/multi-intent-u7-auto-read +worktree: "-" +mutex: intent-plan +--- + +# 多意图 HITL U7:低风险只读执行 + +HandleTurn 在 auto_read 下执行独立查询;默认 legacy 不变。QueryGuard 拒绝 generate。 diff --git a/.doc/test-reports/2026-09-16-multi-intent-u7-auto-read-test-report.md b/.doc/test-reports/2026-09-16-multi-intent-u7-auto-read-test-report.md new file mode 100644 index 00000000..e7fb88a2 --- /dev/null +++ b/.doc/test-reports/2026-09-16-multi-intent-u7-auto-read-test-report.md @@ -0,0 +1,48 @@ +# 多意图 HITL U7 低风险只读执行 测试报告 + +## 一、 测试背景 (Background) + +U5/U6 已能确认计划。U7 在 `MULTI_INTENT_PLAN_TIER=auto_read` 下把独立低风险查询推进到执行,默认 legacy 仍不落盘。 + +## 二、 测试目标 (Test Objectives) + +- 默认 HandleTurn 不落盘 +- auto_read + 只读 2–4 段:创建、自动确认、逐步执行 +- 同 turn 重放不双跑 +- 含生成的话语在 auto_read 不落盘 +- QueryGuard 拒绝 `task.run` + +## 三、 测试方案与环境 (Test Plan & Environment) + +内存账本 + FakeActionOwner;默认环境仍 legacy。不 rebuild `:8080`。 + +## 四、 预期结果 (Expected Results) + +legacy 无回归;auto_read 查询可完成;写动作 fail closed。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 HandleTurn + +- **测试输入**:`go test ./internal/app/intentflow -run 'TestHandleTurn'` +- **实际输出**:`TestHandleTurnLegacyDoesNotPersist`、`TestHandleTurnAutoReadPersistsAndExecutesQueries`、`TestHandleTurnWriteUtteranceStaysLegacyUnderAutoRead` PASS。 +- **评价判定**:PASS。 +- **性能与耗时**:5ms + +### 5.2 QueryGuard / chat / arch + +- **测试输入**:`go test -short ./internal/app/intentflow ./internal/chat ./internal/archtest ./internal/server` +- **实际输出**:PASS。 +- **评价判定**:PASS。 +- **性能与耗时**:约 11s(含 server) + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 命令 | 结果 | +|------|------| +| `go test ./internal/app/intentflow ./internal/chat ./internal/archtest` | PASS | +| `./scripts/arch-check.sh --mode block` | 待合入前再跑 | + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +U7 达到合入标准。U8 再接 `task.run` / `task.run.adhoc`;真实 PI 若环境缺失则记 stop-gate。