From fa62322ed25399789de4122abedfd8f24be45c6f Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 12:11:29 +0300 Subject: [PATCH] feat(work): add deterministic dry-run dispatch --- contracts/work/dry_run_dispatch.go | 18 +++++++++ dev/ACTIVE_TASK.json | 4 +- .../DEV-000122-agent-dry-run-dispatch.json | 23 +++++++++++ modules/work/domain/dry_run_dispatch.go | 26 +++++++++++++ .../agent_execution/dry_run_dispatch_test.go | 39 +++++++++++++++++++ 5 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 contracts/work/dry_run_dispatch.go create mode 100644 dev/tasks/DEV-000122-agent-dry-run-dispatch.json create mode 100644 modules/work/domain/dry_run_dispatch.go create mode 100644 tests/agent_execution/dry_run_dispatch_test.go diff --git a/contracts/work/dry_run_dispatch.go b/contracts/work/dry_run_dispatch.go new file mode 100644 index 0000000..2556032 --- /dev/null +++ b/contracts/work/dry_run_dispatch.go @@ -0,0 +1,18 @@ +package work + +import "time" + +type DryRunDispatch struct { + Intent AgentDispatchIntent + Lease AgentLease + Callbacks []AgentCallback +} + +func (value DryRunDispatch) CallbackCount() int { return len(value.Callbacks) } + +func (value DryRunDispatch) LastObservedAt() time.Time { + if len(value.Callbacks) == 0 { + return time.Time{} + } + return value.Callbacks[len(value.Callbacks)-1].ObservedAt +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 1de2854..d2d64de 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000121-agent-execution-persistence.json", - "task_id": "DEV-000121", + "task_file": "dev/tasks/DEV-000122-agent-dry-run-dispatch.json", + "task_id": "DEV-000122", "task_revision": 1 } diff --git a/dev/tasks/DEV-000122-agent-dry-run-dispatch.json b/dev/tasks/DEV-000122-agent-dry-run-dispatch.json new file mode 100644 index 0000000..5c73d14 --- /dev/null +++ b/dev/tasks/DEV-000122-agent-dry-run-dispatch.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000122", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "8081b4d", + "risk_tier": "E", + "intent": "Implement an opt-in deterministic dry-run dispatcher and fake worker cycle over the Agent Execution Plane contracts.", + "requirement_ids": ["WP-301", "Dry-run dispatcher", "Deterministic fake worker"], + "allowed_change_scope": {"modules": ["work-agent-execution"], "paths": ["contracts/work/dry_run_dispatch.go", "modules/work/domain/dry_run_dispatch.go", "tests/agent_execution/dry_run_dispatch_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000122-agent-dry-run-dispatch.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-000120 dispatch intent", "DEV-000116 lease/fencing", "DEV-000117 callback sequence"], "may_change": ["Dry-run dispatch result value" ]}, + "semantic_owner": "work-agent-execution", + "canonical_state": ["DryRunDispatch value; no production state"], + "state_transitions": ["validated intent -> deterministic lease -> heartbeat callback -> result callback"], + "side_effects_and_privileges": ["Pure domain construction only; no external mutation"], + "persistence_and_migrations": ["Uses DEV-000121 persistence contract but does not apply migration"], + "failure_retry_restart": ["Invalid intent, worker, token, time or TTL is rejected"], + "assumptions": ["Runtime dispatcher and production worker remain separate follow-up work"], + "acceptance_scenarios": ["Same input produces byte-for-byte equivalent typed output", "Invalid intent is rejected", "Callbacks are ordered and inside lease window"], + "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/domain/dry_run_dispatch.go b/modules/work/domain/dry_run_dispatch.go new file mode 100644 index 0000000..551ec8c --- /dev/null +++ b/modules/work/domain/dry_run_dispatch.go @@ -0,0 +1,26 @@ +package domain + +import ( + "errors" + "time" + + "git.cin.su/m/Assistant/contracts/work" +) + +var ErrDryRunDispatchRejected = errors.New("dry-run dispatch rejected") + +func BuildDryRunDispatch(intent work.AgentDispatchIntent, workerID string, token work.AgentFencingToken, now time.Time, ttl time.Duration) (work.DryRunDispatch, error) { + if ValidateDispatchIntent(intent) != nil || workerID == "" || token == 0 || now.IsZero() || ttl <= 0 { + return work.DryRunDispatch{}, ErrDryRunDispatchRejected + } + lease, err := AcquireAgentLease(intent.RunID, workerID, token, now, ttl) + if err != nil { + return work.DryRunDispatch{}, ErrDryRunDispatchRejected + } + heartbeat := work.AgentCallback{RunID: intent.RunID, WorkerID: workerID, FencingToken: token, Sequence: 1, Kind: work.AgentCallbackHeartbeat, ObservedAt: now.Add(ttl / 3)} + result := work.AgentCallback{RunID: intent.RunID, WorkerID: workerID, FencingToken: token, Sequence: 2, Kind: work.AgentCallbackResult, ObservedAt: now.Add((ttl * 2) / 3)} + if ValidateAgentCallback(heartbeat, lease, 0) != nil || ValidateAgentCallback(result, lease, heartbeat.Sequence) != nil { + return work.DryRunDispatch{}, ErrDryRunDispatchRejected + } + return work.DryRunDispatch{Intent: intent, Lease: lease, Callbacks: []work.AgentCallback{heartbeat, result}}, nil +} diff --git a/tests/agent_execution/dry_run_dispatch_test.go b/tests/agent_execution/dry_run_dispatch_test.go new file mode 100644 index 0000000..33532f6 --- /dev/null +++ b/tests/agent_execution/dry_run_dispatch_test.go @@ -0,0 +1,39 @@ +package agent_execution_test + +import ( + "testing" + "time" + + "git.cin.su/m/Assistant/contracts/work" + workdomain "git.cin.su/m/Assistant/modules/work/domain" +) + +func TestBuildDryRunDispatchProducesDeterministicCallbacks(t *testing.T) { + now := time.UnixMilli(1_700_000_000_000) + intent := 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: now.UnixMilli(), + } + first, err := workdomain.BuildDryRunDispatch(intent, "worker-1", 7, now, 15*time.Minute) + if err != nil { + t.Fatal(err) + } + second, err := workdomain.BuildDryRunDispatch(intent, "worker-1", 7, now, 15*time.Minute) + if err != nil { + t.Fatal(err) + } + if first.CallbackCount() != 2 || first.Lease != second.Lease || first.Callbacks[0] != second.Callbacks[0] || first.Callbacks[1] != second.Callbacks[1] { + t.Fatal("dry-run dispatch is not deterministic") + } + if first.Callbacks[1].Sequence != 2 || !first.LastObservedAt().Before(first.Lease.ExpiresAt) { + t.Fatal("callback sequence or lease window is invalid") + } +} + +func TestBuildDryRunDispatchRejectsInvalidIntent(t *testing.T) { + _, err := workdomain.BuildDryRunDispatch(work.AgentDispatchIntent{}, "worker-1", 1, time.UnixMilli(1_700_000_000_000), time.Minute) + if err != workdomain.ErrDryRunDispatchRejected { + t.Fatalf("expected rejection, got %v", err) + } +}