feat: keep agent work durable without external bridge #180

Merged
m merged 1 commits from codex/wp-301-local-fallback into main 2026-08-23 07:56:00 +00:00
7 changed files with 106 additions and 5 deletions
+32
View File
@@ -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
}
+24
View File
@@ -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)
}
}
+5 -2
View File
@@ -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)
}
}()
+8 -1
View File
@@ -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"
+12
View File
@@ -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))
+2 -2
View File
@@ -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
}
@@ -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"]
}