From 46b35161e99f300e49f76b91b7f4518907e0bede Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 23 Aug 2026 10:53:10 +0300 Subject: [PATCH] feat: keep agent work durable without external bridge --- cmd/agent-worker/local_fallback.go | 32 +++++++++++++++++++ cmd/agent-worker/local_fallback_test.go | 24 ++++++++++++++ cmd/agent-worker/main.go | 7 ++-- cmd/agent-worker/status.go | 9 +++++- cmd/agent-worker/status_test.go | 12 +++++++ dev/ACTIVE_TASK.json | 4 +-- ...000203-local-agent-execution-fallback.json | 23 +++++++++++++ 7 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 cmd/agent-worker/local_fallback.go create mode 100644 cmd/agent-worker/local_fallback_test.go create mode 100644 dev/tasks/DEV-000203-local-agent-execution-fallback.json diff --git a/cmd/agent-worker/local_fallback.go b/cmd/agent-worker/local_fallback.go new file mode 100644 index 0000000..672a475 --- /dev/null +++ b/cmd/agent-worker/local_fallback.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + "time" + + "git.cin.su/m/Assistant/contracts/work" +) + +// localFallbackExecutor keeps the durable work path alive when no external +// executor is reachable. It never claims that an external action happened. +// The work owner records the WAITING callback and can retry after a bridge is +// configured or a human chooses the next step. +type localFallbackExecutor struct { + now func() time.Time +} + +func newLocalFallbackExecutor() localFallbackExecutor { + return localFallbackExecutor{now: time.Now} +} + +func (e localFallbackExecutor) Execute(_ context.Context, request work.AgentExecutionRequest) (work.AgentExecutionResult, error) { + now := time.Now() + if e.now != nil { + now = e.now() + } + return work.AgentExecutionResult{ + State: work.AgentExecutionWaiting, + Summary: "Внешний Agent Execution Bridge недоступен; локальный fallback сохранил durable работу и ожидает исполнителя.", + ObservedAtUnixMillis: now.UnixMilli(), + }, nil +} diff --git a/cmd/agent-worker/local_fallback_test.go b/cmd/agent-worker/local_fallback_test.go new file mode 100644 index 0000000..6101aa2 --- /dev/null +++ b/cmd/agent-worker/local_fallback_test.go @@ -0,0 +1,24 @@ +package main + +import ( + "context" + "testing" + "time" + + "git.cin.su/m/Assistant/contracts/work" +) + +func TestLocalFallbackNeverClaimsExternalExecution(t *testing.T) { + when := time.UnixMilli(1_700_000_000_000) + executor := localFallbackExecutor{now: func() time.Time { return when }} + result, err := executor.Execute(context.Background(), work.AgentExecutionRequest{Intent: work.AgentDispatchIntent{DispatchID: "dispatch-1"}}) + if err != nil { + t.Fatal(err) + } + if result.State != work.AgentExecutionWaiting || result.ObservedAtUnixMillis != when.UnixMilli() { + t.Fatalf("unexpected fallback result: %+v", result) + } + if result.OutcomeRef != "" || result.Summary == "" { + t.Fatalf("fallback must not claim an outcome: %+v", result) + } +} diff --git a/cmd/agent-worker/main.go b/cmd/agent-worker/main.go index 5348805..33e8717 100644 --- a/cmd/agent-worker/main.go +++ b/cmd/agent-worker/main.go @@ -41,6 +41,7 @@ func main() { } defer repository.Close() var executor ports.AgentExecutor + localFallback := false if bridgeURL, bridgeConfigured := loadProtectedBridgeURL(); bridgeConfigured { executor, err = agentbridge.NewHTTPExecutor(bridgeURL, &http.Client{Timeout: *leaseTTL}) if err != nil { @@ -48,10 +49,12 @@ func main() { os.Exit(1) } } else { - fmt.Fprintln(os.Stderr, "protected agent execution bridge URL is not configured; dispatch claims are disabled; recovery-only mode") + executor = newLocalFallbackExecutor() + localFallback = true + fmt.Fprintln(os.Stderr, "protected agent execution bridge URL is not configured; local fallback preserves durable work without external mutation") } go func() { - if serveErr := http.ListenAndServe(*statusAddr, workerStatusHandler(executor != nil)); serveErr != nil { + if serveErr := http.ListenAndServe(*statusAddr, workerStatusHandler(executor != nil && !localFallback, localFallback)); serveErr != nil { fmt.Fprintln(os.Stderr, "agent worker status server:", serveErr) } }() diff --git a/cmd/agent-worker/status.go b/cmd/agent-worker/status.go index 72f9ed5..dc3b523 100644 --- a/cmd/agent-worker/status.go +++ b/cmd/agent-worker/status.go @@ -11,15 +11,22 @@ type workerStatusResponse struct { Mode string `json:"mode"` Ready bool `json:"ready"` Reason string `json:"reason"` + LocalFallback bool `json:"local_fallback"` } -func workerStatusHandler(executorConfigured bool) http.Handler { +func workerStatusHandler(executorConfigured bool, localFallback ...bool) http.Handler { + local := len(localFallback) > 0 && localFallback[0] status := workerStatusResponse{ Runtime: "agent-worker", ExecutorConfigured: executorConfigured, Mode: "recovery-only", Ready: false, Reason: "protected agent execution bridge URL is not configured", + LocalFallback: local, + } + if local { + status.Mode = "local-fallback" + status.Reason = "external bridge unavailable; durable work is retained without external mutation" } if executorConfigured { status.Mode = "execution" diff --git a/cmd/agent-worker/status_test.go b/cmd/agent-worker/status_test.go index 0e36b6c..4922eac 100644 --- a/cmd/agent-worker/status_test.go +++ b/cmd/agent-worker/status_test.go @@ -38,6 +38,18 @@ func TestWorkerStatusReportsRecoveryOnlyWithoutExecutor(t *testing.T) { } } +func TestWorkerStatusReportsLocalFallback(t *testing.T) { + response := httptest.NewRecorder() + workerStatusHandler(false, true).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/status", nil)) + var value workerStatusResponse + if err := json.NewDecoder(response.Body).Decode(&value); err != nil { + t.Fatal(err) + } + if response.Code != http.StatusOK || value.Ready || value.Mode != "local-fallback" || !value.LocalFallback { + t.Fatalf("unexpected status: code=%d value=%+v", response.Code, value) + } +} + func TestWorkerReadyzRequiresExecutor(t *testing.T) { response := httptest.NewRecorder() workerStatusHandler(false).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil)) diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index fe919bb..d7afd29 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000202-authenticated-ui-quick-smoke.json", - "task_id": "DEV-000202", + "task_file": "dev/tasks/DEV-000203-local-agent-execution-fallback.json", + "task_id": "DEV-000203", "task_revision": 1 } diff --git a/dev/tasks/DEV-000203-local-agent-execution-fallback.json b/dev/tasks/DEV-000203-local-agent-execution-fallback.json new file mode 100644 index 0000000..155ee86 --- /dev/null +++ b/dev/tasks/DEV-000203-local-agent-execution-fallback.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000203", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "bee238b61e4760bd864355b893f1fbb188b3f4ce", + "risk_tier": "E", + "intent": "Provide a first-party local Agent Execution fallback that preserves durable work without claiming external execution when the optional bridge is unavailable.", + "requirement_ids": ["WP-301", "local agent execution fallback", "degraded operation"], + "allowed_change_scope": {"modules": ["agent-worker"], "paths": ["cmd/agent-worker/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000203-local-agent-execution-fallback.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "deploy/**", "security/**", "contracts/**", "modules/**", ".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": ["AgentExecutionRequest", "AgentExecutionResult", "durable worker lease/callback path"], "may_change": ["worker degraded-mode status projection"]}, + "semantic_owner": "work", + "canonical_state": ["existing work dispatch, lease and callback state"], + "state_transitions": ["dispatch -> durable lease/heartbeat -> explicit WAITING checkpoint when external bridge is absent"], + "side_effects_and_privileges": ["no external mutation; no new credentials"], + "persistence_and_migrations": ["reuse existing work persistence; no migration"], + "failure_retry_restart": ["WAITING remains evidence-bearing and stale lease recovery remains authoritative"], + "assumptions": ["local fallback is degraded coordination/recovery, not a claim of completed agent work", "external bridge remains optional"], + "acceptance_scenarios": ["missing bridge selects local-fallback mode", "fallback produces WAITING with no outcome reference", "status exposes local_fallback=true", "external bridge path remains unchanged"], + "required_tests": ["go test ./cmd/agent-worker", "go vet ./cmd/agent-worker", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "quick production worker status"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"] +} -- 2.54.0