diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 195572d..40456eb 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000113-model-qualification-gate.json", - "task_id": "DEV-000113", + "task_file": "dev/tasks/DEV-000114-run-liveness-assessment.json", + "task_id": "DEV-000114", "task_revision": 1 } diff --git a/dev/tasks/DEV-000114-run-liveness-assessment.json b/dev/tasks/DEV-000114-run-liveness-assessment.json new file mode 100644 index 0000000..9fb4ba7 --- /dev/null +++ b/dev/tasks/DEV-000114-run-liveness-assessment.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000114", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "a65ee37", + "risk_tier": "E", + "intent": "Add deterministic AgentRun liveness assessment for stale-run recovery without requeueing or mutating production records.", + "requirement_ids": ["WP-302", "Heartbeat timeout", "Typed stale evidence"], + "allowed_change_scope": {"modules": ["work-recovery"], "paths": ["modules/work/domain/**", "contracts/work/**", "tests/work_recovery/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000114-run-liveness-assessment.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "migrations/**", "deploy/**", "security/**", "cmd/**", "runtime/**", "adapters/**", ".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": ["Existing Work AgentRun lifecycle", "ADR-0017 delegation ownership"], "may_change": ["Work recovery liveness projection"]}, + "semantic_owner": "work-recovery", + "canonical_state": [], + "state_transitions": ["run timestamp evidence -> LIVE/STALE/INVALID", "stale evidence -> recovery decision pending"], + "side_effects_and_privileges": ["Pure assessment; no requeue, resume, cancellation, persistence or external mutation"], + "persistence_and_migrations": ["No schema or migration changes"], + "failure_retry_restart": ["Clock/invalid timestamps produce INVALID rather than stale or healthy"], + "assumptions": ["UpdatedAt is the latest durable heartbeat/checkpoint projection available", "Recovery policy chooses the next action separately", "RUNNING alone is not proof of liveness"], + "acceptance_scenarios": ["Recent run is LIVE", "Old running run is STALE with age", "Completed/blocked run is not stale", "Invalid clock/timestamp is INVALID", "No production state changes"], + "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", "run liveness tests"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/work/domain/liveness.go b/modules/work/domain/liveness.go new file mode 100644 index 0000000..1e24466 --- /dev/null +++ b/modules/work/domain/liveness.go @@ -0,0 +1,45 @@ +package domain + +import ( + "time" + + "git.cin.su/m/Assistant/contracts/work" +) + +type RunLivenessState string + +const ( + RunLivenessLive RunLivenessState = "LIVE" + RunLivenessStale RunLivenessState = "STALE" + RunLivenessInvalid RunLivenessState = "INVALID" + RunLivenessNotRun RunLivenessState = "NOT_RUNNING" +) + +type RunLiveness struct { + State RunLivenessState + Age time.Duration + ObservedAt time.Time +} + +func AssessRunLiveness(run work.AgentRun, observedAt time.Time, heartbeatTimeout time.Duration) RunLiveness { + result := RunLiveness{ObservedAt: observedAt} + if observedAt.IsZero() || heartbeatTimeout <= 0 || run.UpdatedAtUnixMillis < 1 { + result.State = RunLivenessInvalid + return result + } + if run.State != work.AgentRunStarted && run.State != work.AgentRunRunning { + result.State = RunLivenessNotRun + return result + } + updated := time.UnixMilli(run.UpdatedAtUnixMillis) + result.Age = observedAt.Sub(updated) + if result.Age < 0 { + result.State = RunLivenessInvalid + return result + } + result.State = RunLivenessLive + if result.Age > heartbeatTimeout { + result.State = RunLivenessStale + } + return result +} diff --git a/tests/work_recovery/liveness_test.go b/tests/work_recovery/liveness_test.go new file mode 100644 index 0000000..783ece4 --- /dev/null +++ b/tests/work_recovery/liveness_test.go @@ -0,0 +1,33 @@ +package work_recovery_test + +import ( + "testing" + "time" + + "git.cin.su/m/Assistant/contracts/work" + workdomain "git.cin.su/m/Assistant/modules/work/domain" +) + +func TestAssessRunLivenessClassifiesDurableHeartbeat(t *testing.T) { + now := time.UnixMilli(10000) + run := work.AgentRun{ID: "run-1", DelegationID: "delegation-1", ImplementationID: "agent-1", ImplementationRevision: 1, Protocol: work.AgentExecutionProtocol("v1"), State: work.AgentRunRunning, StartedAtUnixMillis: 9000, UpdatedAtUnixMillis: 9000} + if got := workdomain.AssessRunLiveness(run, now.Add(100*time.Millisecond), time.Second); got.State != workdomain.RunLivenessStale || got.Age != 1100*time.Millisecond { + t.Fatalf("stale = %#v", got) + } + run.UpdatedAtUnixMillis = 9500 + if got := workdomain.AssessRunLiveness(run, now, time.Second); got.State != workdomain.RunLivenessLive { + t.Fatalf("live = %#v", got) + } +} + +func TestAssessRunLivenessDoesNotTreatCompletedAsStale(t *testing.T) { + run := work.AgentRun{State: work.AgentRunCompleted, UpdatedAtUnixMillis: 1} + got := workdomain.AssessRunLiveness(run, time.UnixMilli(10000), time.Second) + if got.State != workdomain.RunLivenessNotRun { + t.Fatalf("completed = %#v", got) + } + invalid := workdomain.AssessRunLiveness(run, time.Time{}, time.Second) + if invalid.State != workdomain.RunLivenessInvalid { + t.Fatalf("invalid = %#v", invalid) + } +}