From 7f82240a8f592446bdf573fccdb628d21639c903 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 12:13:35 +0300 Subject: [PATCH] feat(work): orchestrate dry-run dispatch persistence --- dev/ACTIVE_TASK.json | 4 +- ...EV-000123-agent-dry-run-orchestration.json | 23 +++++++++++ modules/work/application/dry_run_dispatch.go | 29 ++++++++++++++ modules/work/ports/execution.go | 13 ++++++ .../dry_run_orchestration_test.go | 40 +++++++++++++++++++ 5 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000123-agent-dry-run-orchestration.json create mode 100644 modules/work/application/dry_run_dispatch.go create mode 100644 modules/work/ports/execution.go create mode 100644 tests/agent_execution/dry_run_orchestration_test.go diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index d2d64de..b098dbd 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000122-agent-dry-run-dispatch.json", - "task_id": "DEV-000122", + "task_file": "dev/tasks/DEV-000123-agent-dry-run-orchestration.json", + "task_id": "DEV-000123", "task_revision": 1 } diff --git a/dev/tasks/DEV-000123-agent-dry-run-orchestration.json b/dev/tasks/DEV-000123-agent-dry-run-orchestration.json new file mode 100644 index 0000000..09734f1 --- /dev/null +++ b/dev/tasks/DEV-000123-agent-dry-run-orchestration.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000123", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "fa62322", + "risk_tier": "E", + "intent": "Connect the opt-in deterministic dry-run dispatcher to a narrow Work-owned execution persistence port and application coordinator.", + "requirement_ids": ["WP-301", "Work execution port", "Dry-run orchestration"], + "allowed_change_scope": {"modules": ["work-agent-execution"], "paths": ["modules/work/ports/execution.go", "modules/work/application/dry_run_dispatch.go", "tests/agent_execution/dry_run_orchestration_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000123-agent-dry-run-orchestration.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-000121 persistence methods", "DEV-000122 dry-run dispatch"], "may_change": ["Work execution port and application coordinator"]}, + "semantic_owner": "work-agent-execution", + "canonical_state": ["Persistence remains owned by Work repository"], + "state_transitions": ["dispatch intent -> lease -> ordered callbacks"], + "side_effects_and_privileges": ["Explicit Work-owned port only; no runtime entrypoint or production enablement"], + "persistence_and_migrations": ["Reuses DEV-000121 schema; no migration"], + "failure_retry_restart": ["Idempotent repository methods make retries safe; partial failure is returned to caller"], + "assumptions": ["Production dispatcher remains disabled until a separate runtime canary task"], + "acceptance_scenarios": ["All persistence operations are invoked in deterministic order", "Invalid dispatch never reaches the store"], + "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/application/dry_run_dispatch.go b/modules/work/application/dry_run_dispatch.go new file mode 100644 index 0000000..7191529 --- /dev/null +++ b/modules/work/application/dry_run_dispatch.go @@ -0,0 +1,29 @@ +package application + +import ( + "context" + "time" + + "git.cin.su/m/Assistant/contracts/work" + "git.cin.su/m/Assistant/modules/work/domain" + "git.cin.su/m/Assistant/modules/work/ports" +) + +func ExecuteDryRunDispatch(ctx context.Context, store ports.ExecutionStore, intent work.AgentDispatchIntent, workerID string, token work.AgentFencingToken, now time.Time, ttl time.Duration) (work.DryRunDispatch, error) { + dispatch, err := domain.BuildDryRunDispatch(intent, workerID, token, now, ttl) + if err != nil { + return work.DryRunDispatch{}, err + } + if err := store.SaveDispatchIntent(ctx, dispatch.Intent); err != nil { + return work.DryRunDispatch{}, err + } + if err := store.SaveAgentLease(ctx, dispatch.Lease); err != nil { + return work.DryRunDispatch{}, err + } + for _, callback := range dispatch.Callbacks { + if err := store.AcceptAgentCallback(ctx, callback); err != nil { + return work.DryRunDispatch{}, err + } + } + return dispatch, nil +} diff --git a/modules/work/ports/execution.go b/modules/work/ports/execution.go new file mode 100644 index 0000000..0dd711f --- /dev/null +++ b/modules/work/ports/execution.go @@ -0,0 +1,13 @@ +package ports + +import ( + "context" + + "git.cin.su/m/Assistant/contracts/work" +) + +type ExecutionStore interface { + SaveDispatchIntent(context.Context, work.AgentDispatchIntent) error + SaveAgentLease(context.Context, work.AgentLease) error + AcceptAgentCallback(context.Context, work.AgentCallback) error +} diff --git a/tests/agent_execution/dry_run_orchestration_test.go b/tests/agent_execution/dry_run_orchestration_test.go new file mode 100644 index 0000000..282669b --- /dev/null +++ b/tests/agent_execution/dry_run_orchestration_test.go @@ -0,0 +1,40 @@ +package agent_execution_test + +import ( + "context" + "testing" + "time" + + "git.cin.su/m/Assistant/contracts/work" + "git.cin.su/m/Assistant/modules/work/application" +) + +type recordingExecutionStore struct { + operations []string +} + +func (s *recordingExecutionStore) SaveDispatchIntent(context.Context, work.AgentDispatchIntent) error { + s.operations = append(s.operations, "intent") + return nil +} +func (s *recordingExecutionStore) SaveAgentLease(context.Context, work.AgentLease) error { + s.operations = append(s.operations, "lease") + return nil +} +func (s *recordingExecutionStore) AcceptAgentCallback(context.Context, work.AgentCallback) error { + s.operations = append(s.operations, "callback") + return nil +} + +func TestExecuteDryRunDispatchPersistsInOrder(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()} + store := &recordingExecutionStore{} + result, err := application.ExecuteDryRunDispatch(context.Background(), store, intent, "worker-1", 7, now, 15*time.Minute) + if err != nil { + t.Fatal(err) + } + if result.CallbackCount() != 2 || len(store.operations) != 4 || store.operations[0] != "intent" || store.operations[1] != "lease" || store.operations[2] != "callback" || store.operations[3] != "callback" { + t.Fatalf("unexpected persistence sequence: %#v", store.operations) + } +}