From 016135d94610a7e6de48f0757f2bdf60a80fdf6c Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 23 Sep 2026 15:26:46 +0000 Subject: [PATCH] =?UTF-8?q?feat(intentflow):=20wire=20U9=E2=80=93U12=20liv?= =?UTF-8?q?e=20adapters=20for=20multi-intent=20closeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect LLM segment proposals into HandleTurn, replace U10–U12 dry-run stubs with production owners (blackboard supplement, docgov claim/submit, workflow run/resume) when flags are on, and update policy/sysadmin maturity metadata. --- .../2026-09-24-intent-plan-u9u12-live.md | 43 ++++ internal/app/app.go | 34 ++- internal/app/docgov_owner.go | 167 +++++++++++++ internal/app/docgov_owner_test.go | 81 +++++++ internal/app/intentflow/expansion.go | 40 +++- internal/app/intentflow/policy.go | 14 ++ internal/app/intentflow/turn.go | 40 +++- internal/app/intentflow/turn_u9_test.go | 97 ++++++++ internal/app/intentflow/u9_proposal.go | 220 ++++++++++++++++++ internal/app/mutate_owner.go | 145 ++++++++++++ internal/app/mutate_owner_test.go | 70 ++++++ internal/app/project_blackboard_port.go | 60 +++++ internal/app/run_control_owner.go | 207 ++++++++++++++++ internal/app/run_control_owner_test.go | 49 ++++ internal/sysadmin/features.go | 16 +- internal/sysadmin/service_test.go | 8 +- 16 files changed, 1262 insertions(+), 29 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-24-intent-plan-u9u12-live.md create mode 100644 internal/app/docgov_owner.go create mode 100644 internal/app/docgov_owner_test.go create mode 100644 internal/app/intentflow/turn_u9_test.go create mode 100644 internal/app/intentflow/u9_proposal.go create mode 100644 internal/app/mutate_owner.go create mode 100644 internal/app/mutate_owner_test.go create mode 100644 internal/app/project_blackboard_port.go create mode 100644 internal/app/run_control_owner.go create mode 100644 internal/app/run_control_owner_test.go diff --git a/.doc/ops/short-term/2026-09-24-intent-plan-u9u12-live.md b/.doc/ops/short-term/2026-09-24-intent-plan-u9u12-live.md new file mode 100644 index 00000000..17eab253 --- /dev/null +++ b/.doc/ops/short-term/2026-09-24-intent-plan-u9u12-live.md @@ -0,0 +1,43 @@ +--- +status: in_progress +write_scope: internal/app,intentflow,internal/sysadmin,.doc/ops/short-term +mutex: intent-plan +base_dev_sha: 4e601e95 +review_state: self-reviewed +integration_state: pending +deployment: not_run +cleanup: pending +remote_sync: not_pushed +owner: Cursor +task_id: intent-plan-u9u12-live-2026-09-24 +--- + +# intent-plan U9–U12 live 接线(Track I3) + +## 目标 + +- **U9**:`HandleTurn` 在 `RulesCanDecide==false` 且 elevated tier 时调用 `ActiveSegmentProposer`;失败 fail-closed;成功用 LLM spans + 确定性识别构建计划(`segmenter.v2.llm`)。 +- **U10**:`project.fact.supplement` → `MutateOwner`(`ctxmgr.UpdateProjectBlackboard`,与 SupplementInfo 同源黑板)。 +- **U11**:`document.claim`、`document.review.submit` → `DocGovOwner`(docgov 引擎);其余 docgov key 仍 dry-run。 +- **U12**:`workflow.run`、`run.resume` → `RunControlOwner`(projectworkflow + taskrun);其余仍 dry-run。 + +## 验证 + +```bash +./scripts/arch-check.sh --mode block +go test -short ./internal/app/intentflow/ ./internal/app/ ./internal/sysadmin/ -count=1 \ + -run '^(TestHandleTurn|TestMutate|TestDocGov|TestRunControl|TestExpansion|TestBatch|TestActive|TestStub|TestRule|TestU10|TestU11|TestU12|TestBuild|TestDryRun|TestDefault|TestFeatures|TestQuery|TestTask|TestLive|TestProductionComposition)' +``` + +## 未 live 的 action key(MATRIX 开放清单) + +| 域 | 仍为 validated-only / dry-run | +|---|---| +| U10 | (仅 `project.fact.supplement` live;无其它 mutate key) | +| U11 | `document.review.approve/reject`, `document.final.approve/reject`, `document.proposal.decide` | +| U12 | `workflow.switch`, `run.restart` | + +## 收口 + +- 合入:`agentctl validate` → `queue enqueue` 或 integrate +- FEATURE-MATRIX:由父任务更新 diff --git a/internal/app/app.go b/internal/app/app.go index 6a9157cb..91d1e88a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -286,18 +286,25 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) { livePI := cfg != nil && strings.TrimSpace(cfg.PiAgentRunnerURL) != "" taskOwner := NewTaskRunAdapter() taskOwner.Starter = LiveTaskStarter(taskMgr, livePI) - queryOwner := NewQueryOwner(func(ctx context.Context, projectID, userID int64) error { - if rt.Authorizer == nil { - return intentflow.ErrPlanActionForbidden - } - return rt.Authorizer.RequireProjectAccess(ctx, projectID, userID) - }, parts.KB) - // BuildOwnerMux registers U10–U12 dry-run stubs only when independent flags are on. - // Production query path uses QueryOwner (not FakeActionOwner). + queryOwner := NewQueryOwner(projectAccess(rt), parts.KB) + pflags := intentflow.CurrentFeatureFlags() + liveOwners := intentflow.LiveOwners{} + if pflags.U10Mutate { + liveOwners.Mutate = NewMutateOwner(projectAccess(rt), projectBlackboardPort(pool, parts.Ctx)) + } + if pflags.U11DocGov { + liveOwners.DocGov = NewDocGovOwner(parts.Gov, projectAccess(rt), nil) + } + if pflags.U12RunControl { + rc := NewRunControlOwner(projectAccess(rt), pwSvc, taskMgr) + rc.ProjectName = projectNameLookup(pool) + liveOwners.RunControl = rc + } rt.IntentPlans = intentflow.NewPlans(rt.Docflow.UoW, intentflow.NewRepository(), intentflow.Options{ - Owner: intentflow.BuildOwnerMux( + Owner: intentflow.BuildOwnerMuxWithLive( intentflow.QueryGuard{Inner: queryOwner}, taskOwner, + liveOwners, ), Events: events.PGSink{}, }) @@ -362,6 +369,15 @@ func sysadminFeatureValues(cfg *config.Config) func() map[string]string { } } +func projectAccess(rt *Runtime) ProjectAccess { + return func(ctx context.Context, projectID, userID int64) error { + if rt == nil || rt.Authorizer == nil { + return intentflow.ErrPlanActionForbidden + } + return rt.Authorizer.RequireProjectAccess(ctx, projectID, userID) + } +} + func formatProjectAllowlist(ids []int64) string { if len(ids) == 0 { return "" diff --git a/internal/app/docgov_owner.go b/internal/app/docgov_owner.go new file mode 100644 index 00000000..54bfa8c6 --- /dev/null +++ b/internal/app/docgov_owner.go @@ -0,0 +1,167 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "sync" + + "chat-agent-system/internal/app/intentflow" + "chat-agent-system/internal/docgov" +) + +const frozenProject143 = int64(143) + +// DocGovRoleResolver maps a user to a docgov role for a document action. +type DocGovRoleResolver func(ctx context.Context, docID string, userID int64) docgov.UserRole + +// DocGovOwner executes live U11 keys via the docgov engine; other keys stay dry-run. +type DocGovOwner struct { + Gov *docgov.GovernanceEngine + Access ProjectAccess + Resolve DocGovRoleResolver + Denied bool + dryRun *intentflow.DryRunAdapter + + mu sync.Mutex + receipts map[string]intentflow.Receipt +} + +func NewDocGovOwner(gov *docgov.GovernanceEngine, access ProjectAccess, resolve DocGovRoleResolver) *DocGovOwner { + return &DocGovOwner{ + Gov: gov, + Access: access, + Resolve: resolve, + dryRun: intentflow.NewDocGovDryRunAdapter(), + receipts: map[string]intentflow.Receipt{}, + } +} + +func (d *DocGovOwner) isLiveKey(key string) bool { + _, ok := intentflow.DocGovLiveActionKeys[key] + return ok +} + +func (d *DocGovOwner) Authorize(ctx context.Context, exec intentflow.StepExec) error { + if d == nil || d.Denied { + return intentflow.ErrPlanActionForbidden + } + if !d.isLiveKey(exec.ActionKey) { + return d.dryRun.Authorize(ctx, exec) + } + if exec.Principal.UserID <= 0 || exec.Principal.ProjectID <= 0 || exec.Principal.SessionID == "" { + return intentflow.ErrPlanActionForbidden + } + if exec.Principal.ProjectID == frozenProject143 { + return intentflow.ErrPlanActionForbidden + } + if d.Access != nil { + if err := d.Access(ctx, exec.Principal.ProjectID, exec.Principal.UserID); err != nil { + return intentflow.ErrPlanActionForbidden + } + } + if exec.ExpectedResourceVersion == "" { + return intentflow.ErrPlanActionVersionConflict + } + docID := docIDFromExec(exec) + if docID == "" || d.Gov == nil { + return intentflow.ErrPlanActionForbidden + } + doc, err := d.Gov.GetDocument(ctx, docID) + if err != nil || doc == nil { + return intentflow.ErrPlanActionForbidden + } + if docVersion(doc) != exec.ExpectedResourceVersion { + return intentflow.ErrPlanActionVersionConflict + } + return nil +} + +func (d *DocGovOwner) Execute(ctx context.Context, exec intentflow.StepExec) (intentflow.Receipt, error) { + if !d.isLiveKey(exec.ActionKey) { + return d.dryRun.Execute(ctx, exec) + } + if err := d.Authorize(ctx, exec); err != nil { + return intentflow.Receipt{}, err + } + d.mu.Lock() + if rec, ok := d.receipts[exec.IdempotencyKey]; ok { + d.mu.Unlock() + if rec.ActionDigest != exec.ActionDigest { + return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict + } + return rec, nil + } + d.mu.Unlock() + + docID := docIDFromExec(exec) + role := docgov.RoleAuthor + if d.Resolve != nil { + role = d.Resolve(ctx, docID, exec.Principal.UserID) + } + name := "user" + var updated *docgov.DocumentRecord + var err error + switch exec.ActionKey { + case "document.claim": + updated, err = d.Gov.RecordDocumentViewed(ctx, docID, exec.Principal.UserID, role, name) + case "document.review.submit": + updated, err = d.Gov.SubmitForReview(ctx, docID, exec.Principal.UserID, role, name) + default: + return intentflow.Receipt{}, intentflow.ErrPlanActionForbidden + } + if err != nil { + return intentflow.Receipt{}, err + } + rec := intentflow.Receipt{ + IdempotencyKey: exec.IdempotencyKey, + ActionDigest: exec.ActionDigest, + ResultRef: "docgov:" + docID, + ResultDigest: fmt.Sprintf("docgov:%s:ver=%s", exec.ActionKey, docVersion(updated)), + 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 (d *DocGovOwner) LookupReceipt(ctx context.Context, idempotencyKey string) (intentflow.Receipt, bool, error) { + if d == nil { + return intentflow.Receipt{}, false, nil + } + d.mu.Lock() + rec, ok := d.receipts[idempotencyKey] + d.mu.Unlock() + if ok { + return rec, true, nil + } + return d.dryRun.LookupReceipt(ctx, idempotencyKey) +} + +func docIDFromExec(exec intentflow.StepExec) string { + if len(exec.Slots) > 0 { + var slots map[string]string + if json.Unmarshal(exec.Slots, &slots) == nil { + if id := strings.TrimSpace(slots["doc_id"]); id != "" { + return id + } + } + } + return strings.TrimSpace(exec.SafeSummary) +} + +func docVersion(doc *docgov.DocumentRecord) string { + if doc == nil { + return "" + } + return strconv.Itoa(doc.Version) +} diff --git a/internal/app/docgov_owner_test.go b/internal/app/docgov_owner_test.go new file mode 100644 index 00000000..d7df1d4a --- /dev/null +++ b/internal/app/docgov_owner_test.go @@ -0,0 +1,81 @@ +package app + +import ( + "context" + "testing" + + "chat-agent-system/internal/app/intentflow" + "chat-agent-system/internal/docgov" +) + +func TestDocGovOwnerProject143Forbidden(t *testing.T) { + engine := docgov.NewGovernanceEngine(nil) + ctx := context.Background() + doc, err := engine.CreateOrGetDocument(ctx, "doc_test_143", 99, "intake", "t", "V0.5", "", 1, "A", 2, "R", 3, "P") + if err != nil { + t.Fatal(err) + } + owner := NewDocGovOwner(engine, nil, nil) + _, err = owner.Execute(ctx, intentflow.StepExec{ + ActionKey: "document.claim", + IdempotencyKey: "143-deny", ActionDigest: "d", + ExpectedResourceVersion: "1", + Principal: intentflow.Principal{UserID: 1, ProjectID: 143, SessionID: "s"}, + SafeSummary: doc.DocID, + }) + if err != intentflow.ErrPlanActionForbidden { + t.Fatalf("project 143 must stay read-only, got %v", err) + } +} + +func TestDocGovOwnerMissingVersionConflict(t *testing.T) { + engine := docgov.NewGovernanceEngine(nil) + ctx := context.Background() + doc, err := engine.CreateOrGetDocument(ctx, "doc_ver_test", 5, "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) + _, err = owner.Execute(ctx, intentflow.StepExec{ + ActionKey: "document.claim", + IdempotencyKey: "ver-miss", ActionDigest: "d", + ExpectedResourceVersion: "", + Principal: intentflow.Principal{UserID: 1, ProjectID: 5, SessionID: "s"}, + SafeSummary: doc.DocID, + }) + if err != intentflow.ErrPlanActionVersionConflict { + t.Fatalf("missing version: %v", err) + } + _, err = owner.Execute(ctx, intentflow.StepExec{ + ActionKey: "document.claim", + IdempotencyKey: "ver-stale", ActionDigest: "d", + ExpectedResourceVersion: "999", + Principal: intentflow.Principal{UserID: 1, ProjectID: 5, SessionID: "s"}, + SafeSummary: doc.DocID, + }) + if err != intentflow.ErrPlanActionVersionConflict { + t.Fatalf("stale version: %v", err) + } +} + +func TestDocGovOwnerAuthNegative(t *testing.T) { + engine := docgov.NewGovernanceEngine(nil) + ctx := context.Background() + doc, err := engine.CreateOrGetDocument(ctx, "doc_auth", 5, "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 intentflow.ErrPlanActionForbidden + }, nil) + _, err = owner.Execute(ctx, intentflow.StepExec{ + ActionKey: "document.claim", + IdempotencyKey: "auth-no", ActionDigest: "d", + ExpectedResourceVersion: "1", + Principal: intentflow.Principal{UserID: 9, ProjectID: 5, SessionID: "s"}, + SafeSummary: doc.DocID, + }) + if err != intentflow.ErrPlanActionForbidden { + t.Fatalf("auth negative: %v", err) + } +} diff --git a/internal/app/intentflow/expansion.go b/internal/app/intentflow/expansion.go index ae5dc70f..9b11d9c5 100644 --- a/internal/app/intentflow/expansion.go +++ b/internal/app/intentflow/expansion.go @@ -209,6 +209,20 @@ var runControlActionKeys = map[string]struct{}{ "run.restart": {}, } +// DocGovLiveActionKeys are the first U11 keys wired to the real docgov engine. +var DocGovLiveActionKeys = map[string]struct{}{ + "document.claim": {}, + "document.review.submit": {}, +} + +// LiveOwners carries production adapters from the composition root (U10–U12). +// Nil fields fall back to dry-run scaffolding when the corresponding flag is on. +type LiveOwners struct { + Mutate ActionOwner + DocGov ActionOwner + RunControl ActionOwner +} + // DryRunAdapter is a fail-closed / dry-run ActionOwner for U10–U12 scaffolding. type DryRunAdapter struct { mu sync.Mutex @@ -298,18 +312,36 @@ func (a *DryRunAdapter) LookupReceipt(_ context.Context, idempotencyKey string) return rec, ok, nil } -// BuildOwnerMux registers U10–U12 dry-run owners only when their flags are on. +// BuildOwnerMux registers U10–U12 owners when their flags are on. +// Pass LiveOwners via BuildOwnerMuxWithLive for production adapters. func BuildOwnerMux(query, task ActionOwner) OwnerMux { + return BuildOwnerMuxWithLive(query, task, LiveOwners{}) +} + +// BuildOwnerMuxWithLive wires optional live adapters from the composition root. +func BuildOwnerMuxWithLive(query, task ActionOwner, live LiveOwners) OwnerMux { m := OwnerMux{Query: query, Task: task} f := CurrentFeatureFlags() if f.U10Mutate { - m.Mutate = NewMutateDryRunAdapter() + if live.Mutate != nil { + m.Mutate = live.Mutate + } else { + m.Mutate = NewMutateDryRunAdapter() + } } if f.U11DocGov { - m.DocGov = NewDocGovDryRunAdapter() + if live.DocGov != nil { + m.DocGov = live.DocGov + } else { + m.DocGov = NewDocGovDryRunAdapter() + } } if f.U12RunControl { - m.RunControl = NewRunControlDryRunAdapter() + if live.RunControl != nil { + m.RunControl = live.RunControl + } else { + m.RunControl = NewRunControlDryRunAdapter() + } } return m } diff --git a/internal/app/intentflow/policy.go b/internal/app/intentflow/policy.go index 1a9e1caf..cba24e8a 100644 --- a/internal/app/intentflow/policy.go +++ b/internal/app/intentflow/policy.go @@ -114,6 +114,20 @@ func defaultBatchRows() []ActionBatchPolicy { AllowedModes: []ExecutionMode{ModeValidatedOnly, ModeLive}, }) } + for _, key := range []string{ + "project.fact.supplement", + "document.claim", + "document.review.submit", + "workflow.run", + "run.resume", + } { + add(ActionBatchPolicy{ + ActionKey: key, + RequiresBatchConfirm: true, + OpenFromTier: TierConfirm, + AllowedModes: []ExecutionMode{ModeValidatedOnly, ModeLive}, + }) + } add(ActionBatchPolicy{ ActionKey: "security.forbidden", RejectPlan: true, diff --git a/internal/app/intentflow/turn.go b/internal/app/intentflow/turn.go index c09edcf6..250b9d3d 100644 --- a/internal/app/intentflow/turn.go +++ b/internal/app/intentflow/turn.go @@ -35,13 +35,45 @@ func (p *Plans) handleTurnElevated(ctx context.Context, in TurnInput) (*TurnResu if text == "" { return &TurnResult{}, nil } + digest := in.RequestDigest + if digest == "" { + digest = shortDigest(text) + } + rec := intent.NewRecognizer() res, err := rec.Recognize(ctx, text, nil) - if err != nil || res == nil || res.PlanProposal == nil { + if err != nil || res == nil { + recordCounterfactual(cfPassthrough) + return &TurnResult{}, nil + } + + var proposal *intent.PlanProposal + if flags.U9Segment && !RulesCanDecide(text) && tier.Allows(TierShadow) { + sp, propErr := ActiveSegmentProposer().Propose(ctx, SegmentProposalRequest{ + Text: text, + RequestDigest: digest, + MaxSegments: 4, + }) + if propErr != nil { + return &TurnResult{}, nil + } + if sp == nil || len(sp.Spans) < 2 || len(sp.Spans) > 4 { + return &TurnResult{}, nil + } + if err := ValidateProposedSpans(text, sp.Spans); err != nil { + return &TurnResult{}, nil + } + proposal = buildPlanFromLLMSpans(ctx, text, res, sp.Spans) + if proposal == nil || len(proposal.Segments) < 2 { + return &TurnResult{}, nil + } + } else { + proposal = res.PlanProposal + } + if proposal == nil { recordCounterfactual(cfPassthrough) return &TurnResult{}, nil } - proposal := res.PlanProposal route := counterfactualRoute(proposal) recordCounterfactual(route) @@ -96,10 +128,6 @@ func (p *Plans) handleTurnElevated(ctx context.Context, in TurnInput) (*TurnResu if turnID == "" { turnID = "turn-" + shortDigest(text) } - digest := in.RequestDigest - if digest == "" { - digest = shortDigest(text) - } status := PlanAwaitingConfirmation mode := ModeLive diff --git a/internal/app/intentflow/turn_u9_test.go b/internal/app/intentflow/turn_u9_test.go new file mode 100644 index 00000000..5946b316 --- /dev/null +++ b/internal/app/intentflow/turn_u9_test.go @@ -0,0 +1,97 @@ +package intentflow_test + +import ( + "context" + "testing" + + "chat-agent-system/internal/app/intentflow" +) + +type stubSpanProposer struct { + spans []intentflow.SegmentSpan + err error +} + +func (s stubSpanProposer) Propose(context.Context, intentflow.SegmentProposalRequest) (*intentflow.SegmentProposal, error) { + if s.err != nil { + return nil, s.err + } + return &intentflow.SegmentProposal{ + SchemaVersion: intentflow.SegmentProposalSchemaV1, + Digest: "stub-digest", + Spans: s.spans, + }, nil +} + +func TestHandleTurnU9FailClosedOnMalformedProposal(t *testing.T) { + intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{ + PlanTier: intentflow.TierAutoRead, U9Segment: true, ProjectAllowlist: []int64{42}, + }) + t.Cleanup(intentflow.ResetFeatureFlagsForTest) + intentflow.SetLLMSegmentProposer(stubSpanProposer{err: intentflow.ErrSegmentProposalMalformed}) + t.Cleanup(func() { intentflow.SetLLMSegmentProposer(nil) }) + + p, _, _ := testPlans() + res, err := p.HandleTurn(context.Background(), intentflow.TurnInput{ + Principal: principal(), Text: "帮我查法规再查案例", + ClientTurnID: "u9-fail", RequestDigest: "u9-fail-d", + }) + if err != nil { + t.Fatal(err) + } + if res.Plan != nil { + t.Fatalf("malformed U9 must fail closed: %+v", res.Plan) + } +} + +func TestHandleTurnU9SkipsLLMWhenRulesDecide(t *testing.T) { + intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{ + PlanTier: intentflow.TierAutoRead, U9Segment: true, ProjectAllowlist: []int64{42}, + }) + t.Cleanup(intentflow.ResetFeatureFlagsForTest) + intentflow.SetLLMSegmentProposer(stubSpanProposer{ + spans: []intentflow.SegmentSpan{{Start: 0, End: 1, Text: "x"}}, + }) + t.Cleanup(func() { intentflow.SetLLMSegmentProposer(nil) }) + + p, _, _ := testPlans() + res, err := p.HandleTurn(context.Background(), intentflow.TurnInput{ + Principal: principal(), Text: "查一下项目风险,然后查相关法规", + ClientTurnID: "u9-rules", RequestDigest: "u9-rules-d", + }) + if err != nil { + t.Fatal(err) + } + if res == nil || res.Plan == nil { + t.Fatal("rules-decided connector split must still plan") + } +} + +func TestHandleTurnU9AllowlistStillGates(t *testing.T) { + intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{ + PlanTier: intentflow.TierAutoRead, U9Segment: true, + }) + t.Cleanup(intentflow.ResetFeatureFlagsForTest) + intentflow.ResetMetricsForTest() + text := "帮我查法规再查案例" + intentflow.SetLLMSegmentProposer(stubSpanProposer{spans: []intentflow.SegmentSpan{ + {Start: 0, End: 15, Text: "帮我查法规"}, + {Start: 15, End: 27, Text: "再查案例"}, + }}) + t.Cleanup(func() { intentflow.SetLLMSegmentProposer(nil) }) + + p, _, _ := testPlans() + res, err := p.HandleTurn(context.Background(), intentflow.TurnInput{ + Principal: principal(), Text: text, + ClientTurnID: "u9-allow", RequestDigest: "u9-allow-d", + }) + if err != nil { + t.Fatal(err) + } + if res.Plan != nil { + t.Fatalf("empty allowlist must deny U9 elevated tier: %+v", res.Plan) + } + if intentflow.Metrics().Snapshot().AllowlistDenied < 1 { + t.Fatal("expected allowlist_denied metric") + } +} diff --git a/internal/app/intentflow/u9_proposal.go b/internal/app/intentflow/u9_proposal.go new file mode 100644 index 00000000..62e0a693 --- /dev/null +++ b/internal/app/intentflow/u9_proposal.go @@ -0,0 +1,220 @@ +package intentflow + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + + "chat-agent-system/internal/intent" +) + +const SegmenterVersionLLM = "segmenter.v2.llm" + +// buildPlanFromLLMSpans runs deterministic recognition per LLM span (U9). +func buildPlanFromLLMSpans( + ctx context.Context, + fullText string, + primary *intent.IntentRecognitionResult, + spans []SegmentSpan, +) *intent.PlanProposal { + if len(spans) < 2 || len(spans) > 4 { + return nil + } + rec := intent.NewRecognizer() + proposed := make([]intent.ProposedSegment, 0, len(spans)) + for i, span := range spans { + segText := fullText[span.Start:span.End] + inSeg := intent.InputSegment{ + Text: segText, + Normalized: normalizeSegmentText(segText), + Span: intent.TextSpan{Start: span.Start, End: span.End}, + Evidence: "llm", + StructureConfidence: "medium", + } + text := strings.TrimSpace(inSeg.Normalized) + if text == "" { + text = strings.TrimSpace(inSeg.Text) + } + var frame *intent.IntentFrameV2 + var missing []string + leaf, err := rec.Recognize(ctx, text, nil) + if err == nil && leaf != nil { + frame = intent.BuildIntentFrameV2(leaf, intent.DefaultRegistry()) + if leaf.TopIntent != nil { + missing = append([]string{}, leaf.TopIntent.MissingSlots...) + } + if frame != nil && len(frame.MissingSlots) > 0 { + missing = append(missing, frame.MissingSlots...) + } + if leaf.TopIntent != nil && leaf.TopIntent.Intent == intent.IntentSecurityForbidden { + p := &intent.PlanProposal{ + SegmenterVersion: SegmenterVersionLLM, + ComboHint: intent.ComboRejected, + Evidence: []intent.IntentEvidence{{Kind: "segment_security", Detail: string(intent.ComboRejected), Weight: 1}}, + Segments: []intent.ProposedSegment{ + proposedSegmentFromInput(inSeg, i, frame, missing), + }, + } + p.Digest = llmProposalDigest(p) + return p + } + } + seg := proposedSegmentFromInput(inSeg, i, frame, uniqueStrings(missing)) + if i > 0 { + seg.UnresolvedAnaphora = collectAnaphora(text) + seg.DependsOn = []string{fmt.Sprintf("%d", i)} + } + proposed = append(proposed, seg) + } + p := &intent.PlanProposal{ + SegmenterVersion: SegmenterVersionLLM, + Segments: proposed, + ComboHint: comboHintForSegments(proposed, primary), + Evidence: []intent.IntentEvidence{ + {Kind: "segmenter", Detail: SegmenterVersionLLM, Weight: 1}, + {Kind: "split", Detail: "llm", Weight: 0.5}, + }, + } + p.Digest = llmProposalDigest(p) + return p +} + +func proposedSegmentFromInput(s intent.InputSegment, i int, frame *intent.IntentFrameV2, missing []string) intent.ProposedSegment { + return intent.ProposedSegment{ + StepID: fmt.Sprintf("%d", i+1), + Text: s.Text, + Normalized: s.Normalized, + Span: s.Span, + Connector: s.Connector, + Evidence: s.Evidence, + StructureConfidence: s.StructureConfidence, + Frame: frame, + MissingSlots: missing, + } +} + +func comboHintForSegments(segs []intent.ProposedSegment, primary *intent.IntentRecognitionResult) intent.ComboHint { + if len(segs) <= 1 { + return intent.ComboSingle + } + needsClarify := false + needsConfirm := false + allQuery := true + seenKeys := map[string]int{} + for _, s := range segs { + if s.StructureConfidence == "low" || len(s.UnresolvedAnaphora) > 0 || len(s.MissingSlots) > 0 || s.Frame == nil { + needsClarify = true + } + if s.Frame == nil { + allQuery = false + continue + } + if s.Frame.Score > 0 && s.Frame.Score < 0.6 { + needsClarify = true + } + if isWriteFrame(s.Frame) { + needsConfirm = true + allQuery = false + } + if s.Frame.Goal != intent.GoalQuery && s.Frame.Goal != "" { + allQuery = false + } + if s.Frame.ActionKey != "" { + seenKeys[s.Frame.ActionKey]++ + if seenKeys[s.Frame.ActionKey] > 1 { + needsClarify = true + } + } + } + if needsClarify { + return intent.ComboNeedsClarify + } + if needsConfirm { + return intent.ComboNeedsConfirm + } + if allQuery { + return intent.ComboReadOnly + } + if primary != nil && primary.RequiresClarifying { + return intent.ComboNeedsClarify + } + return intent.ComboNeedsConfirm +} + +func isWriteFrame(frame *intent.IntentFrameV2) bool { + if frame == nil { + return false + } + switch frame.Goal { + case intent.GoalGenerate, intent.GoalMutate, intent.GoalControl: + return true + } + switch frame.LegacyIntent { + case intent.IntentAdvanceStage, intent.IntentTriggerTask, intent.IntentTriggerAdhocTask, + intent.IntentSupplementInfo, intent.IntentSwitchWorkflow, intent.IntentTriggerWorkflow, + intent.IntentRunResume, intent.IntentRunRestart: + return true + } + return false +} + +func llmProposalDigest(p *intent.PlanProposal) string { + if p == nil { + return "" + } + h := sha256.New() + _, _ = h.Write([]byte(p.SegmenterVersion)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(p.ComboHint)) + for _, s := range p.Segments { + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(s.Normalized)) + _, _ = h.Write([]byte{0}) + if s.Frame != nil { + _, _ = h.Write([]byte(s.Frame.ActionKey)) + } + for _, a := range s.UnresolvedAnaphora { + _, _ = h.Write([]byte{1}) + _, _ = h.Write([]byte(a)) + } + } + return hex.EncodeToString(h.Sum(nil)) +} + +func normalizeSegmentText(s string) string { + s = strings.TrimSpace(s) + return strings.Join(strings.Fields(s), " ") +} + +func uniqueStrings(in []string) []string { + if len(in) == 0 { + return nil + } + seen := map[string]struct{}{} + var out []string + for _, s := range in { + s = strings.TrimSpace(s) + if s == "" { + continue + } + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + return out +} + +func collectAnaphora(text string) []string { + markers := []string{"这个", "那个", "它", "其", "上述", "刚才的", "该结果", "这份"} + var out []string + for _, m := range markers { + if strings.Contains(text, m) { + out = append(out, m) + } + } + return out +} diff --git a/internal/app/mutate_owner.go b/internal/app/mutate_owner.go new file mode 100644 index 00000000..378ac8b8 --- /dev/null +++ b/internal/app/mutate_owner.go @@ -0,0 +1,145 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + + "chat-agent-system/internal/app/intentflow" +) + +const mutateActionKey = "project.fact.supplement" + +// ProjectBlackboardPort reads/writes the same project blackboard as SupplementInfo. +type ProjectBlackboardPort struct { + CurrentVersion func(ctx context.Context, projectID int64) (string, error) + MergeFacts func(ctx context.Context, projectID int64, facts map[string]string) (newVersion string, err error) +} + +// MutateOwner is the U10 live adapter for project.fact.supplement only. +type MutateOwner struct { + Access ProjectAccess + Blackboard ProjectBlackboardPort + Denied bool + + mu sync.Mutex + receipts map[string]intentflow.Receipt +} + +func NewMutateOwner(access ProjectAccess, bb ProjectBlackboardPort) *MutateOwner { + return &MutateOwner{ + Access: access, + Blackboard: bb, + receipts: map[string]intentflow.Receipt{}, + } +} + +func (m *MutateOwner) Authorize(ctx context.Context, exec intentflow.StepExec) error { + if m == nil || m.Denied { + return intentflow.ErrPlanActionForbidden + } + if exec.ActionKey != mutateActionKey { + return intentflow.ErrPlanActionForbidden + } + if exec.Principal.UserID <= 0 || exec.Principal.ProjectID <= 0 || exec.Principal.SessionID == "" { + return intentflow.ErrPlanActionForbidden + } + if m.Access != nil { + if err := m.Access(ctx, exec.Principal.ProjectID, exec.Principal.UserID); err != nil { + return intentflow.ErrPlanActionForbidden + } + } + if exec.ExpectedResourceVersion == "" { + return intentflow.ErrPlanActionVersionConflict + } + if m.Blackboard.CurrentVersion != nil { + got, err := m.Blackboard.CurrentVersion(ctx, exec.Principal.ProjectID) + if err != nil { + return err + } + if got != exec.ExpectedResourceVersion { + return intentflow.ErrPlanActionVersionConflict + } + } + return nil +} + +func (m *MutateOwner) Execute(ctx context.Context, exec intentflow.StepExec) (intentflow.Receipt, error) { + if err := m.Authorize(ctx, exec); err != nil { + return intentflow.Receipt{}, err + } + m.mu.Lock() + if rec, ok := m.receipts[exec.IdempotencyKey]; ok { + m.mu.Unlock() + if rec.ActionDigest != exec.ActionDigest { + return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict + } + return rec, nil + } + m.mu.Unlock() + + facts := supplementFactsFromExec(exec) + if len(facts) == 0 { + return intentflow.Receipt{}, intentflow.ErrPlanActionForbidden + } + newVer := exec.ExpectedResourceVersion + if m.Blackboard.MergeFacts != nil { + got, err := m.Blackboard.MergeFacts(ctx, exec.Principal.ProjectID, facts) + if err != nil { + return intentflow.Receipt{}, err + } + newVer = got + } + + rec := intentflow.Receipt{ + IdempotencyKey: exec.IdempotencyKey, + ActionDigest: exec.ActionDigest, + ResultRef: fmt.Sprintf("blackboard:%d", exec.Principal.ProjectID), + ResultDigest: fmt.Sprintf("supplement:ver=%s", newVer), + SafeSummary: "project facts supplemented", + } + m.mu.Lock() + defer m.mu.Unlock() + if existing, ok := m.receipts[exec.IdempotencyKey]; ok { + if existing.ActionDigest != exec.ActionDigest { + return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict + } + return existing, nil + } + m.receipts[exec.IdempotencyKey] = rec + return rec, nil +} + +func (m *MutateOwner) LookupReceipt(_ context.Context, idempotencyKey string) (intentflow.Receipt, bool, error) { + if m == nil { + return intentflow.Receipt{}, false, nil + } + m.mu.Lock() + defer m.mu.Unlock() + rec, ok := m.receipts[idempotencyKey] + return rec, ok, nil +} + +func supplementFactsFromExec(exec intentflow.StepExec) map[string]string { + if len(exec.Slots) > 0 { + var slots map[string]string + if json.Unmarshal(exec.Slots, &slots) == nil && len(slots) > 0 { + out := map[string]string{} + for k, v := range slots { + if strings.TrimSpace(v) != "" { + out[k] = strings.TrimSpace(v) + } + } + if len(out) > 0 { + return out + } + } + } + text := strings.TrimSpace(exec.SafeSummary) + if text == "" { + return nil + } + return map[string]string{"supplement_doc": text} +} diff --git a/internal/app/mutate_owner_test.go b/internal/app/mutate_owner_test.go new file mode 100644 index 00000000..c7c2e048 --- /dev/null +++ b/internal/app/mutate_owner_test.go @@ -0,0 +1,70 @@ +package app + +import ( + "context" + "testing" + + "chat-agent-system/internal/app/intentflow" +) + +func TestMutateOwnerMissingRevisionConflict(t *testing.T) { + m := NewMutateOwner(nil, ProjectBlackboardPort{}) + ctx := context.Background() + _, err := m.Execute(ctx, intentflow.StepExec{ + ActionKey: "project.fact.supplement", + IdempotencyKey: "k1", ActionDigest: "d1", + Principal: intentflow.Principal{UserID: 1, ProjectID: 2, SessionID: "s"}, + SafeSummary: "补充目标市场为国内", + }) + if err != intentflow.ErrPlanActionVersionConflict { + t.Fatalf("want version conflict, got %v", err) + } +} + +func TestMutateOwnerDigestReplay(t *testing.T) { + ver := "1" + m := NewMutateOwner(nil, ProjectBlackboardPort{ + CurrentVersion: func(context.Context, int64) (string, error) { return ver, nil }, + MergeFacts: func(context.Context, int64, map[string]string) (string, error) { + return "2", nil + }, + }) + ctx := context.Background() + exec := intentflow.StepExec{ + ActionKey: "project.fact.supplement", + IdempotencyKey: "k2", ActionDigest: "d2", + ExpectedResourceVersion: ver, + Principal: intentflow.Principal{UserID: 1, ProjectID: 2, SessionID: "s"}, + SafeSummary: "补充目标市场为国内", + } + first, err := m.Execute(ctx, exec) + if err != nil { + t.Fatal(err) + } + again, err := m.Execute(ctx, exec) + if err != nil || again.ResultDigest != first.ResultDigest { + t.Fatalf("idempotent replay: %v %+v", err, again) + } + exec.ActionDigest = "other" + if _, err := m.Execute(ctx, exec); err != intentflow.ErrPlanDecisionConflict { + t.Fatalf("digest mismatch: %v", err) + } +} + +func TestMutateOwnerForbiddenWithoutAccess(t *testing.T) { + m := NewMutateOwner(func(context.Context, int64, int64) error { + return intentflow.ErrPlanActionForbidden + }, ProjectBlackboardPort{ + CurrentVersion: func(context.Context, int64) (string, error) { return "1", nil }, + }) + _, err := m.Execute(context.Background(), intentflow.StepExec{ + ActionKey: "project.fact.supplement", + IdempotencyKey: "k3", ActionDigest: "d3", + ExpectedResourceVersion: "1", + Principal: intentflow.Principal{UserID: 1, ProjectID: 2, SessionID: "s"}, + SafeSummary: "补充材料", + }) + if err != intentflow.ErrPlanActionForbidden { + t.Fatalf("want forbidden, got %v", err) + } +} diff --git a/internal/app/project_blackboard_port.go b/internal/app/project_blackboard_port.go new file mode 100644 index 00000000..8c695212 --- /dev/null +++ b/internal/app/project_blackboard_port.go @@ -0,0 +1,60 @@ +package app + +import ( + "context" + "strconv" + + "chat-agent-system/internal/ctxmgr" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +func projectBlackboardPort(pool *pgxpool.Pool, ctxMgr *ctxmgr.ContextManager) ProjectBlackboardPort { + return ProjectBlackboardPort{ + CurrentVersion: func(ctx context.Context, projectID int64) (string, error) { + if pool != nil { + var version int64 + err := pool.QueryRow(ctx, ` + SELECT COALESCE(version, 0) FROM project_blackboards WHERE project_id = $1 + `, projectID).Scan(&version) + if err == pgx.ErrNoRows { + return "0", nil + } + if err != nil { + return "", err + } + return strconv.FormatInt(version, 10), nil + } + return "0", nil + }, + MergeFacts: func(ctx context.Context, projectID int64, facts map[string]string) (string, error) { + if ctxMgr == nil { + return "0", nil + } + before, _ := projectBlackboardPort(pool, ctxMgr).CurrentVersion(ctx, projectID) + if err := ctxMgr.UpdateProjectBlackboard(ctx, projectID, facts); err != nil { + return "", err + } + after, err := projectBlackboardPort(pool, ctxMgr).CurrentVersion(ctx, projectID) + if err != nil { + return before, nil + } + return after, nil + }, + } +} + +func projectNameLookup(pool *pgxpool.Pool) func(context.Context, int64) (string, error) { + return func(ctx context.Context, projectID int64) (string, error) { + if pool == nil || projectID <= 0 { + return "", nil + } + var name string + err := pool.QueryRow(ctx, `SELECT COALESCE(name, '') FROM projects WHERE id = $1`, projectID).Scan(&name) + if err == pgx.ErrNoRows { + return "", nil + } + return name, err + } +} diff --git a/internal/app/run_control_owner.go b/internal/app/run_control_owner.go new file mode 100644 index 00000000..2f374886 --- /dev/null +++ b/internal/app/run_control_owner.go @@ -0,0 +1,207 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + + "chat-agent-system/internal/app/intentflow" + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/taskrun" +) + +var runControlLiveKeys = map[string]struct{}{ + "workflow.run": {}, + "run.resume": {}, +} + +// RunControlOwner is the U12 live adapter for workflow.run and run.resume. +type RunControlOwner struct { + Access ProjectAccess + Workflow *projectworkflow.Service + TaskRuns *taskrun.Manager + ProjectName func(ctx context.Context, projectID int64) (string, error) + Denied bool + dryRun *intentflow.DryRunAdapter + + mu sync.Mutex + receipts map[string]intentflow.Receipt +} + +func NewRunControlOwner(access ProjectAccess, pw *projectworkflow.Service, runs *taskrun.Manager) *RunControlOwner { + return &RunControlOwner{ + Access: access, + Workflow: pw, + TaskRuns: runs, + dryRun: intentflow.NewRunControlDryRunAdapter(), + receipts: map[string]intentflow.Receipt{}, + } +} + +func (r *RunControlOwner) isLiveKey(key string) bool { + _, ok := runControlLiveKeys[key] + return ok +} + +func (r *RunControlOwner) Authorize(ctx context.Context, exec intentflow.StepExec) error { + if r == nil || r.Denied { + return intentflow.ErrPlanActionForbidden + } + if !r.isLiveKey(exec.ActionKey) { + return r.dryRun.Authorize(ctx, exec) + } + if exec.Principal.UserID <= 0 || exec.Principal.ProjectID <= 0 || exec.Principal.SessionID == "" { + return intentflow.ErrPlanActionForbidden + } + if r.Access != nil { + if err := r.Access(ctx, exec.Principal.ProjectID, exec.Principal.UserID); err != nil { + return intentflow.ErrPlanActionForbidden + } + } + if exec.ActionKey == "run.resume" { + workDir := slotValue(exec, "work_dir") + if workDir == "" { + workDir = strings.TrimSpace(exec.SafeSummary) + } + if workDir == "" { + return intentflow.ErrPlanActionForbidden + } + } + return nil +} + +func (r *RunControlOwner) Execute(ctx context.Context, exec intentflow.StepExec) (intentflow.Receipt, error) { + if !r.isLiveKey(exec.ActionKey) { + return r.dryRun.Execute(ctx, exec) + } + if err := r.Authorize(ctx, exec); err != nil { + return intentflow.Receipt{}, err + } + r.mu.Lock() + if rec, ok := r.receipts[exec.IdempotencyKey]; ok { + r.mu.Unlock() + if rec.ActionDigest != exec.ActionDigest { + return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict + } + return rec, nil + } + r.mu.Unlock() + + ref, err := r.runLive(ctx, exec) + if err != nil { + return intentflow.Receipt{}, err + } + rec := intentflow.Receipt{ + IdempotencyKey: exec.IdempotencyKey, + ActionDigest: exec.ActionDigest, + ResultRef: ref, + ResultDigest: fmt.Sprintf("run_control:%s", exec.ActionKey), + SafeSummary: exec.ActionKey + " accepted", + } + r.mu.Lock() + defer r.mu.Unlock() + if existing, ok := r.receipts[exec.IdempotencyKey]; ok { + if existing.ActionDigest != exec.ActionDigest { + return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict + } + return existing, nil + } + r.receipts[exec.IdempotencyKey] = rec + return rec, nil +} + +func (r *RunControlOwner) runLive(ctx context.Context, exec intentflow.StepExec) (string, error) { + switch exec.ActionKey { + case "workflow.run": + if r.Workflow == nil { + return "", fmt.Errorf("run control: workflow service missing") + } + workflowKey := slotValue(exec, "workflow_key") + if workflowKey == "" { + workflowKey = "main" + } + runID := planRunID(exec.IdempotencyKey) + startedBy := exec.Principal.UserID + created, err := r.Workflow.CreateWorkflowRun(ctx, &projectworkflow.WorkflowRun{ + RunID: runID, + ProjectID: exec.Principal.ProjectID, + WorkflowKey: workflowKey, + StartedBy: &startedBy, + Status: "running", + }) + if err != nil { + return "", err + } + return "workflow_run:" + created.RunID, nil + case "run.resume": + if r.TaskRuns == nil { + return "", fmt.Errorf("run control: taskrun manager missing") + } + workDir := slotValue(exec, "work_dir") + if workDir == "" { + workDir = strings.TrimSpace(exec.SafeSummary) + } + if workDir == "" { + return "", intentflow.ErrPlanActionForbidden + } + 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) + rec := &taskrun.RunRecord{ + RunID: runID, + SessionID: exec.Principal.SessionID, + ProjectID: exec.Principal.ProjectID, + UserID: exec.Principal.UserID, + IdempotencyKey: exec.IdempotencyKey, + TaskKey: "intake", + Status: "pending", + } + created, err := r.TaskRuns.CreateAndStart(ctx, runID, rec, bizdev.TaskRunRequest{ + TaskKey: "intake", + UserID: exec.Principal.UserID, + ProjectID: exec.Principal.ProjectID, + SessionID: exec.Principal.SessionID, + ProjectName: projectName, + }, bizdev.StageRunRequest{}, workDir) + if err != nil { + return "", err + } + return created.RunID, nil + default: + return "", intentflow.ErrPlanActionForbidden + } +} + +func (r *RunControlOwner) LookupReceipt(ctx context.Context, idempotencyKey string) (intentflow.Receipt, bool, error) { + if r == nil { + return intentflow.Receipt{}, false, nil + } + r.mu.Lock() + rec, ok := r.receipts[idempotencyKey] + r.mu.Unlock() + if ok { + return rec, true, nil + } + return r.dryRun.LookupReceipt(ctx, idempotencyKey) +} + +func slotValue(exec intentflow.StepExec, key string) string { + if len(exec.Slots) == 0 { + return "" + } + var slots map[string]string + if json.Unmarshal(exec.Slots, &slots) != nil { + return "" + } + return strings.TrimSpace(slots[key]) +} diff --git a/internal/app/run_control_owner_test.go b/internal/app/run_control_owner_test.go new file mode 100644 index 00000000..9769a6b6 --- /dev/null +++ b/internal/app/run_control_owner_test.go @@ -0,0 +1,49 @@ +package app + +import ( + "context" + "testing" + + "chat-agent-system/internal/app/intentflow" +) + +func TestRunControlOwnerForbiddenWithoutAccess(t *testing.T) { + owner := NewRunControlOwner(func(context.Context, int64, int64) error { + return intentflow.ErrPlanActionForbidden + }, nil, nil) + _, err := owner.Execute(context.Background(), intentflow.StepExec{ + ActionKey: "workflow.run", + IdempotencyKey: "rc-auth", ActionDigest: "d", + Principal: intentflow.Principal{UserID: 1, ProjectID: 2, SessionID: "s"}, + }) + if err != intentflow.ErrPlanActionForbidden { + t.Fatalf("want forbidden, got %v", err) + } +} + +func TestRunControlOwnerResumeMissingWorkDirFailClosed(t *testing.T) { + owner := NewRunControlOwner(func(context.Context, int64, int64) error { return nil }, nil, nil) + _, err := owner.Execute(context.Background(), intentflow.StepExec{ + ActionKey: "run.resume", + IdempotencyKey: "rc-resume", ActionDigest: "d", + Principal: intentflow.Principal{UserID: 1, ProjectID: 2, SessionID: "s"}, + }) + if err != intentflow.ErrPlanActionForbidden { + t.Fatalf("missing work_dir must fail closed, got %v", err) + } +} + +func TestRunControlOwnerNonLiveKeyUsesDryRun(t *testing.T) { + owner := NewRunControlOwner(nil, nil, nil) + rec, err := owner.Execute(context.Background(), intentflow.StepExec{ + ActionKey: "workflow.switch", + StepID: "s", IdempotencyKey: "rc-sw", ActionDigest: "d", + Principal: intentflow.Principal{UserID: 1, ProjectID: 2, SessionID: "s"}, + }) + if err != nil { + t.Fatal(err) + } + if rec.SafeSummary == "" { + t.Fatal("expected dry-run summary") + } +} diff --git a/internal/sysadmin/features.go b/internal/sysadmin/features.go index 44b13574..ff0582b2 100644 --- a/internal/sysadmin/features.go +++ b/internal/sysadmin/features.go @@ -59,20 +59,20 @@ var featureMaturityCatalog = map[string]featureMaturity{ reason: "空值拒绝所有 elevated tier;* 放开全部;逗号分隔项目 ID", }, "MULTI_INTENT_U9_SEGMENT": { - state: implDryRun, adapter: adapterUnavailable, - reason: "仅有 PI proposer,尚未进入真实聊天分段;提议请求已使用 reasoning profile", + state: implLive, adapter: adapterReady, + reason: "HandleTurn 在规则无法分段且 elevated tier 时调用 PI proposer;失败 fail-closed;RulesCanDecide 跳过 LLM", }, "MULTI_INTENT_U10_MUTATE": { - state: implDryRun, adapter: adapterUnavailable, - reason: "适配器仍是 dry-run/validated-only", + state: implLive, adapter: adapterReady, + reason: "project.fact.supplement 经 MutateOwner 写项目黑板(ctxmgr 端口);需 ExpectedResourceVersion", }, "MULTI_INTENT_U11_DOCGOV": { - state: implDryRun, adapter: adapterUnavailable, - reason: "适配器仍是 dry-run/validated-only", + state: implLive, adapter: adapterDegraded, + reason: "document.claim / document.review.submit 接 docgov 引擎;其余 docgov key 仍 dry-run;项目 143 只读", }, "MULTI_INTENT_U12_RUN_CONTROL": { - state: implDryRun, adapter: adapterUnavailable, - reason: "适配器仍是 dry-run/validated-only", + state: implLive, adapter: adapterDegraded, + reason: "workflow.run / run.resume 接 projectworkflow 与 taskrun;其余 run-control key 仍 dry-run", }, "SEMANTIC_SUMMARY_SHADOW": { state: implShadow, adapter: adapterDegraded, diff --git a/internal/sysadmin/service_test.go b/internal/sysadmin/service_test.go index ffe8c1d8..ef082652 100644 --- a/internal/sysadmin/service_test.go +++ b/internal/sysadmin/service_test.go @@ -193,8 +193,12 @@ func TestFeatures_ConfiguredValueDoesNotImplyReadiness(t *testing.T) { t.Fatalf("confirm tier value must not imply rollout-ready: %+v", tier) } u9 := byKey["MULTI_INTENT_U9_SEGMENT"] - if u9.Value != "true" || u9.AdapterReadiness != "unavailable" { - t.Fatalf("U9 flag must not imply a live segmenter: %+v", u9) + if u9.Value != "true" || u9.ImplementationState != "live" || u9.AdapterReadiness != "ready" { + t.Fatalf("U9 flag on must reflect live HandleTurn wiring: %+v", u9) + } + u10 := byKey["MULTI_INTENT_U10_MUTATE"] + if u10.ImplementationState != "live" || u10.AdapterReadiness != "ready" { + t.Fatalf("U10 live mutate adapter: %+v", u10) } if byKey["MULTI_INTENT_ENABLED"].ImplementationState != "live" || byKey["MULTI_INTENT_ENABLED"].AdapterReadiness != "ready" { t.Fatalf("legacy multi-intent remains the live path: %+v", byKey["MULTI_INTENT_ENABLED"])