From dd7fd2cd681da24a8779af4c6fc8e482187cde87 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 16:43:32 +0300 Subject: [PATCH] fix: disable agent dry-run without executor --- cmd/agent-worker/main.go | 6 +-- dev/ACTIVE_TASK.json | 4 +- .../DEV-000148-agent-worker-no-dry-run.json | 23 ++++++++++ modules/work/application/worker.go | 8 ++++ modules/work/application/worker_test.go | 46 +++++++++++++++++++ 5 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 dev/tasks/DEV-000148-agent-worker-no-dry-run.json create mode 100644 modules/work/application/worker_test.go diff --git a/cmd/agent-worker/main.go b/cmd/agent-worker/main.go index 4635317..08f37ba 100644 --- a/cmd/agent-worker/main.go +++ b/cmd/agent-worker/main.go @@ -21,7 +21,7 @@ import ( func main() { workerID := flag.String("worker-id", "agent-worker-1", "durable worker identity") staleAfter := flag.Duration("stale-after", 10*time.Minute, "age after which a claimed item is recovered") - leaseTTL := flag.Duration("lease-ttl", 15*time.Minute, "dry-run lease duration") + leaseTTL := flag.Duration("lease-ttl", 15*time.Minute, "executor lease duration") pollInterval := flag.Duration("poll-interval", 5*time.Second, "database-owned idle wake-up interval") once := flag.Bool("once", false, "run one cycle and exit") flag.Parse() @@ -45,14 +45,14 @@ func main() { os.Exit(1) } } else { - fmt.Fprintln(os.Stderr, "agent execution bridge is not configured; using legacy dry-run dispatch") + fmt.Fprintln(os.Stderr, "agent execution bridge is not configured; dispatch claims are disabled; recovery-only mode") } ctx := context.Background() for { var result application.WorkerCycleResult var cycleErr error if executor == nil { - result, cycleErr = application.ExecuteWorkerCycle(ctx, repository, *workerID, work.AgentFencingToken(1), time.Now().UTC(), *staleAfter, *leaseTTL) + result, cycleErr = application.RecoverOnlyWorkerCycle(ctx, repository, time.Now().UTC(), *staleAfter) } else { result, cycleErr = application.ExecuteWorkerCycleWithExecutor(ctx, repository, executor, *workerID, work.AgentFencingToken(1), time.Now().UTC(), *staleAfter, *leaseTTL) } diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 764dd52..7251f2c 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000147-model-fleet-operations-ui.json", - "task_id": "DEV-000147", + "task_file": "dev/tasks/DEV-000148-agent-worker-no-dry-run.json", + "task_id": "DEV-000148", "task_revision": 1 } diff --git a/dev/tasks/DEV-000148-agent-worker-no-dry-run.json b/dev/tasks/DEV-000148-agent-worker-no-dry-run.json new file mode 100644 index 0000000..6d3b7dd --- /dev/null +++ b/dev/tasks/DEV-000148-agent-worker-no-dry-run.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000148", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "7030a51b69a4d7345c5475d2fcced0e6d0441916", + "risk_tier": "E", + "intent": "Prevent agent-worker from claiming or completing dispatches without a configured real executor while preserving durable stale-lease recovery.", + "requirement_ids": ["WP-301", "WP-302", "INV-AUTH-001", "INV-CONT-001"], + "allowed_change_scope": {"modules": ["work", "agent-worker"], "paths": ["cmd/agent-worker/main.go", "modules/work/application/worker.go", "modules/work/application/worker_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000148-agent-worker-no-dry-run.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", ".gitea/**", "database/**", "contracts/**", "adapters/**", "deploy/**"]}, + "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": ["WorkerStore", "AgentExecutor"], "may_change": []}, + "semantic_owner": "work", + "canonical_state": ["dispatch queue claims and stale lease recovery"], + "state_transitions": ["executor absent -> recovery-only; no new dispatch claim"], + "side_effects_and_privileges": ["work-owner stale claim recovery only when executor is absent"], + "persistence_and_migrations": [], + "failure_retry_restart": ["worker remains restartable and resumes recovery-only mode until bridge is configured"], + "assumptions": ["AGENT_EXECUTION_BRIDGE_URL is the only production activation path for external execution"], + "acceptance_scenarios": ["missing bridge never claims a queued dispatch", "missing bridge never marks work completed", "stale claims are still recovered", "configured bridge path remains unchanged"], + "required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/work/application/worker.go b/modules/work/application/worker.go index 9331ea3..59e2402 100644 --- a/modules/work/application/worker.go +++ b/modules/work/application/worker.go @@ -15,6 +15,14 @@ type WorkerCycleResult struct { Idle bool } +// RecoverOnlyWorkerCycle keeps durable recovery alive while the real executor +// capability is absent. It intentionally does not claim a dispatch: a missing +// executor must never be represented as successful work. +func RecoverOnlyWorkerCycle(ctx context.Context, store ports.WorkerStore, now time.Time, staleAfter time.Duration) (WorkerCycleResult, error) { + recovered, err := store.RecoverStaleClaims(ctx, now.UnixMilli(), staleAfter.Milliseconds()) + return WorkerCycleResult{RecoveredClaims: recovered, Idle: err == nil}, err +} + func ExecuteWorkerCycle(ctx context.Context, store ports.WorkerStore, workerID string, token work.AgentFencingToken, now time.Time, staleAfter, leaseTTL time.Duration) (WorkerCycleResult, error) { recovered, err := store.RecoverStaleClaims(ctx, now.UnixMilli(), staleAfter.Milliseconds()) if err != nil { diff --git a/modules/work/application/worker_test.go b/modules/work/application/worker_test.go new file mode 100644 index 0000000..4acb0ae --- /dev/null +++ b/modules/work/application/worker_test.go @@ -0,0 +1,46 @@ +package application + +import ( + "context" + "testing" + "time" + + "git.cin.su/m/Assistant/contracts/work" +) + +type recoveryOnlyStore struct { + recovered int64 + claimed bool +} + +func (s *recoveryOnlyStore) RecoverStaleClaims(context.Context, int64, int64) (int64, error) { + return s.recovered, nil +} +func (s *recoveryOnlyStore) ClaimNextDispatch(context.Context, string, int64) (work.AgentDispatchQueueItem, error) { + s.claimed = true + return work.AgentDispatchQueueItem{}, nil +} +func (s *recoveryOnlyStore) LoadDispatchIntent(context.Context, string) (work.AgentDispatchIntent, error) { + return work.AgentDispatchIntent{}, nil +} +func (s *recoveryOnlyStore) CompleteDispatch(context.Context, string, string, work.AgentDispatchQueueState, int64) error { + return nil +} +func (s *recoveryOnlyStore) SaveDispatchIntent(context.Context, work.AgentDispatchIntent) error { + return nil +} +func (s *recoveryOnlyStore) SaveAgentLease(context.Context, work.AgentLease) error { return nil } +func (s *recoveryOnlyStore) AcceptAgentCallback(context.Context, work.AgentCallback) error { + return nil +} + +func TestRecoverOnlyWorkerCycleNeverClaimsDispatch(t *testing.T) { + store := &recoveryOnlyStore{recovered: 2} + result, err := RecoverOnlyWorkerCycle(context.Background(), store, time.UnixMilli(1_700_000_000_000), time.Minute) + if err != nil { + t.Fatal(err) + } + if result.RecoveredClaims != 2 || !result.Idle || store.claimed { + t.Fatalf("unexpected recovery-only result: %+v claimed=%t", result, store.claimed) + } +} -- 2.54.0