From d2338b148597bf9172fd2bebc8943b9e3ba2bd71 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 12:15:50 +0300 Subject: [PATCH] feat(work): gate agent canary dispatch --- dev/ACTIVE_TASK.json | 4 +- dev/tasks/DEV-000124-agent-canary-gate.json | 23 +++++++++ modules/work/application/canary.go | 35 +++++++++++++ tests/agent_execution/canary_test.go | 54 +++++++++++++++++++++ 4 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000124-agent-canary-gate.json create mode 100644 modules/work/application/canary.go create mode 100644 tests/agent_execution/canary_test.go diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index b098dbd..92c2015 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000123-agent-dry-run-orchestration.json", - "task_id": "DEV-000123", + "task_file": "dev/tasks/DEV-000124-agent-canary-gate.json", + "task_id": "DEV-000124", "task_revision": 1 } diff --git a/dev/tasks/DEV-000124-agent-canary-gate.json b/dev/tasks/DEV-000124-agent-canary-gate.json new file mode 100644 index 0000000..e1adc17 --- /dev/null +++ b/dev/tasks/DEV-000124-agent-canary-gate.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000124", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "7f82240", + "risk_tier": "E", + "intent": "Add an explicit opt-in canary gate for the dry-run agent dispatcher with implementation allowlist and bounded dispatch count.", + "requirement_ids": ["WP-301", "Opt-in canary", "Bounded dispatch"], + "allowed_change_scope": {"modules": ["work-agent-execution"], "paths": ["modules/work/application/canary.go", "tests/agent_execution/canary_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000124-agent-canary-gate.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/**", "runtime/**", "adapters/**", "database/**", ".gitea/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["DEV-000123 dry-run orchestration"], "may_change": ["Canary configuration and gate errors"]}, + "semantic_owner": "work-agent-execution", + "canonical_state": ["Canary configuration is caller-owned; dispatch state remains Work-owned"], + "state_transitions": ["disabled/unauthorized/limited -> rejected; enabled and allowed -> dry-run orchestration"], + "side_effects_and_privileges": ["No side effect before all canary gates pass"], + "persistence_and_migrations": ["Reuses existing persistence; no migration"], + "failure_retry_restart": ["Caller receives typed gate failure; retries remain bounded by used dispatch count"], + "assumptions": ["Runtime wiring and production configuration are separate follow-up work"], + "acceptance_scenarios": ["Disabled canary writes nothing", "Wrong implementation writes nothing", "Limit is enforced", "Allowed canary persists four records"], + "required_tests": ["go test ./...", "go vet ./...", "go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl check-architecture", "go run ./cmd/devctl/independent", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/work/application/canary.go b/modules/work/application/canary.go new file mode 100644 index 0000000..92caae1 --- /dev/null +++ b/modules/work/application/canary.go @@ -0,0 +1,35 @@ +package application + +import ( + "context" + "errors" + "time" + + "git.cin.su/m/Assistant/contracts/work" + "git.cin.su/m/Assistant/modules/work/ports" +) + +var ( + ErrCanaryDisabled = errors.New("agent canary is disabled") + ErrCanaryNotAllowed = errors.New("agent implementation is not canary-allowed") + ErrCanaryLimitReached = errors.New("agent canary dispatch limit reached") +) + +type CanaryConfig struct { + Enabled bool + ImplementationID string + MaxDispatches int +} + +func ExecuteCanaryDryRun(ctx context.Context, store ports.ExecutionStore, config CanaryConfig, usedDispatches int, intent work.AgentDispatchIntent, workerID string, token work.AgentFencingToken, now time.Time, ttl time.Duration) (work.DryRunDispatch, error) { + if !config.Enabled { + return work.DryRunDispatch{}, ErrCanaryDisabled + } + if config.ImplementationID == "" || intent.ImplementationID != config.ImplementationID { + return work.DryRunDispatch{}, ErrCanaryNotAllowed + } + if config.MaxDispatches <= 0 || usedDispatches >= config.MaxDispatches { + return work.DryRunDispatch{}, ErrCanaryLimitReached + } + return ExecuteDryRunDispatch(ctx, store, intent, workerID, token, now, ttl) +} diff --git a/tests/agent_execution/canary_test.go b/tests/agent_execution/canary_test.go new file mode 100644 index 0000000..8463bb2 --- /dev/null +++ b/tests/agent_execution/canary_test.go @@ -0,0 +1,54 @@ +package agent_execution_test + +import ( + "context" + "testing" + "time" + + "git.cin.su/m/Assistant/contracts/work" + "git.cin.su/m/Assistant/modules/work/application" +) + +type canaryStore struct{ writes int } + +func (s *canaryStore) SaveDispatchIntent(context.Context, work.AgentDispatchIntent) error { + s.writes++ + return nil +} +func (s *canaryStore) SaveAgentLease(context.Context, work.AgentLease) error { s.writes++; return nil } +func (s *canaryStore) AcceptAgentCallback(context.Context, work.AgentCallback) error { + s.writes++ + return nil +} + +func canaryIntent() work.AgentDispatchIntent { + return work.AgentDispatchIntent{DispatchID: "dispatch-1", IdempotencyKey: "idem-1", DelegationID: "delegation-1", RunID: "run-1", ImplementationID: "fake-worker", ImplementationRevision: 1, SourceRevision: "source-1", WorktreeID: "worktree-1", VerificationPlanRef: "verify-1", CreatedAtUnixMillis: 1_700_000_000_000} +} + +func TestCanaryRequiresExplicitEnablementAndAllowlist(t *testing.T) { + store := &canaryStore{} + config := application.CanaryConfig{Enabled: false, ImplementationID: "fake-worker", MaxDispatches: 1} + _, err := application.ExecuteCanaryDryRun(context.Background(), store, config, 0, canaryIntent(), "worker-1", 7, time.UnixMilli(1_700_000_000_000), time.Minute) + if err != application.ErrCanaryDisabled || store.writes != 0 { + t.Fatalf("disabled canary wrote state or returned wrong error: %v/%d", err, store.writes) + } + config.Enabled = true + config.ImplementationID = "other-worker" + _, err = application.ExecuteCanaryDryRun(context.Background(), store, config, 0, canaryIntent(), "worker-1", 7, time.UnixMilli(1_700_000_000_000), time.Minute) + if err != application.ErrCanaryNotAllowed || store.writes != 0 { + t.Fatalf("unallowed canary wrote state or returned wrong error: %v/%d", err, store.writes) + } +} + +func TestCanaryHonorsDispatchLimit(t *testing.T) { + store := &canaryStore{} + config := application.CanaryConfig{Enabled: true, ImplementationID: "fake-worker", MaxDispatches: 1} + _, err := application.ExecuteCanaryDryRun(context.Background(), store, config, 1, canaryIntent(), "worker-1", 7, time.UnixMilli(1_700_000_000_000), time.Minute) + if err != application.ErrCanaryLimitReached || store.writes != 0 { + t.Fatalf("limited canary wrote state or returned wrong error: %v/%d", err, store.writes) + } + _, err = application.ExecuteCanaryDryRun(context.Background(), store, config, 0, canaryIntent(), "worker-1", 7, time.UnixMilli(1_700_000_000_000), time.Minute) + if err != nil || store.writes != 4 { + t.Fatalf("enabled canary did not persist expected cycle: %v/%d", err, store.writes) + } +}