diff --git a/cmd/work-graph-runtime/main.go b/cmd/work-graph-runtime/main.go index 1ee20f8..dd69603 100644 --- a/cmd/work-graph-runtime/main.go +++ b/cmd/work-graph-runtime/main.go @@ -61,6 +61,19 @@ func main() { } writeJSON(w, map[string]any{"graph": graph, "scheduled_nodes": ready}) }) + mux.HandleFunc("POST /v1/work-graphs/orchestration/next", func(w http.ResponseWriter, r *http.Request) { + var request work.SolutionSearchInput + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&request); err != nil { + http.Error(w, "invalid orchestration input", http.StatusBadRequest) + return + } + decision, err := application.DecideSolutionSearch(request) + if err != nil { + http.Error(w, "invalid orchestration input", http.StatusUnprocessableEntity) + return + } + writeJSON(w, decision) + }) mux.HandleFunc("POST /v1/work-graphs/complete", func(w http.ResponseWriter, r *http.Request) { var request application.GraphCompleteRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&request); err != nil { diff --git a/contracts/work/orchestration.go b/contracts/work/orchestration.go new file mode 100644 index 0000000..a83ef3c --- /dev/null +++ b/contracts/work/orchestration.go @@ -0,0 +1,42 @@ +package work + +type OrchestrationState string +type SearchOptionKind string + +const ( + OrchestrationSearching OrchestrationState = "SEARCHING" + OrchestrationExecuting OrchestrationState = "EXECUTING" + OrchestrationVerifying OrchestrationState = "VERIFYING" + OrchestrationWaiting OrchestrationState = "WAITING" + OrchestrationCompleted OrchestrationState = "COMPLETED" + OrchestrationCancelled OrchestrationState = "CANCELLED" + + SearchOptionAgents SearchOptionKind = "AGENT_TEAM" + SearchOptionCapability SearchOptionKind = "CAPABILITY_DISCOVERY" + SearchOptionResearch SearchOptionKind = "TRUTH_RESEARCH" + SearchOptionReflection SearchOptionKind = "REFLECTION_MEMORY" + SearchOptionHuman SearchOptionKind = "HUMAN_ATTENTION" +) + +// SolutionSearchInput is the durable orchestration snapshot. The scheduler +// stores the graph/goal separately; this value only decides the next bounded +// search wave and never grants authority by itself. +type SolutionSearchInput struct { + GoalID GoalID + GoalState GoalState + SearchRound int + MaxRound int + CancelRequested bool + ActiveOptionCount int + VerificationReady bool + Unresolved bool +} + +type SolutionSearchDecision struct { + GoalID GoalID + State OrchestrationState + ContinueSearching bool + NextSearchRound int + Options []SearchOptionKind + Reason string +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index a133578..6e5bd80 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000230-development-gate-controller.json", - "task_id": "DEV-000230", + "task_file": "dev/tasks/DEV-000231-solution-orchestrator.json", + "task_id": "DEV-000231", "task_revision": 1 } diff --git a/dev/tasks/DEV-000231-solution-orchestrator.json b/dev/tasks/DEV-000231-solution-orchestrator.json new file mode 100644 index 0000000..0b42661 --- /dev/null +++ b/dev/tasks/DEV-000231-solution-orchestrator.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000231", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "45874f218646d7879dfcbc814782fe56bf0aa06a", + "risk_tier": "E", + "intent": "Provide a durable, parallel-first solution-search decision policy for Work Graph goals so unresolved work continues through independent agent, capability, research and reflection avenues instead of dead-ending after one bounded wave.", + "requirement_ids": ["solution orchestration", "parallel agent team", "durable retry waves", "independent verification"], + "allowed_change_scope": {"modules": ["work-graph", "work-domain"], "paths": ["contracts/work/orchestration.go", "modules/work/domain/orchestration.go", "modules/work/domain/orchestration_test.go", "modules/work/application/orchestration.go", "cmd/work-graph-runtime/main.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000231-solution-orchestrator.json", "docs/PROJECT_STATUS.md"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "deploy/**", "LLM-local/**", "modules/work/secrets/**"]}, + "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": ["Work Goal state", "durable Work Graph scheduling", "independent verification result"], "may_change": ["typed solution-search decision contract", "Work Graph orchestration decision endpoint"]}, + "semantic_owner": "Work solution orchestration policy", + "canonical_state": ["existing Work Goal and Work Graph records; caller persists the returned wave decision"], + "state_transitions": ["SEARCHING -> EXECUTING -> VERIFYING -> COMPLETED", "SEARCHING -> WAITING -> SEARCHING", "any unresolved state -> next durable search wave", "any active state -> CANCELLED"], + "side_effects_and_privileges": ["pure policy decision and unauthenticated internal runtime route only; no provider call, secret access, code mutation, promotion or deployment"], + "persistence_and_migrations": ["none; this stage returns a typed decision for the existing durable scheduler"], + "failure_retry_restart": ["bounded wave exhaustion becomes WAITING with a new wave number and human-attention avenue; it is never represented as failure; cancellation and independently verified completion are terminal"], + "assumptions": ["the existing Work Graph dispatcher or next integration stage will persist the decision and fan out options", "model quality and provider routing remain separate capability decisions"], + "acceptance_scenarios": ["first unresolved goal selects all independent search avenues", "active wave remains EXECUTING", "exhausted wave continues with a higher durable round and human attention", "only cancellation or independently verified completion stops search", "invalid input is rejected", "the decision endpoint does not mutate source, secrets or production"], + "required_tests": ["go test ./modules/work/domain ./modules/work/application ./cmd/work-graph-runtime/...", "go vet ./modules/work/domain ./modules/work/application ./cmd/work-graph-runtime/...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production health smoke"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"] +} diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index b042991..ecac54a 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -24,8 +24,8 @@ - `COMPLETION_MATRIX = docs/evidence/audit-completion-matrix-2026-08-23-v3.json`; all seven objective areas are mapped to current repository/production proof, with external bridge, GPU promotion/quality and deep-acceptance gaps explicit. -- `ACTIVE_TASK = dev/tasks/DEV-000230-development-gate-controller.json revision 1`; authenticated Core now exposes Work-owned durable development gate creation and optimistic-concurrency progression. Code execution, protected-main access, automatic promotion and production mutation remain outside this controller. +- `ACTIVE_TASK = dev/tasks/DEV-000231-solution-orchestrator.json revision 1`; Work Graph now exposes a typed parallel-first solution-search decision: active waves execute, unresolved goals open agent/capability/research/reflection avenues, and bounded exhaustion advances to a durable next wave with human attention instead of failure. Automatic dispatcher fan-out and persistence of the returned decision remain the next integration step. - `LINEAGE_DECISION = docs/GOVERNANCE_LINEAGE_DECISION_2026-08-22.md`; historical packets remain provenance and are not current authority. - `REGISTRY_STATUS = YAML and JSON semantic mirrors agree` - `PRODUCTION_DOMAIN = docker.cin.su`; shared docker-test is not used for this project. -- `NEXT_WORK = qualify an independently operated Agent Execution bridge when available and complete deferred deep browser acceptance with a current signed-in session; retain local fallback and general-first task evaluation when either capability is unavailable.` +- `NEXT_WORK = connect this decision policy to the durable Work Graph dispatcher/worker so every unresolved goal persists its next wave, fans out independent options and joins verified evidence; retain local fallback, truth research and governed development gates.` diff --git a/modules/work/application/orchestration.go b/modules/work/application/orchestration.go new file mode 100644 index 0000000..1e0cff2 --- /dev/null +++ b/modules/work/application/orchestration.go @@ -0,0 +1,10 @@ +package application + +import ( + "git.cin.su/m/Assistant/contracts/work" + "git.cin.su/m/Assistant/modules/work/domain" +) + +func DecideSolutionSearch(input work.SolutionSearchInput) (work.SolutionSearchDecision, error) { + return domain.DecideSolutionSearch(input) +} diff --git a/modules/work/domain/orchestration.go b/modules/work/domain/orchestration.go new file mode 100644 index 0000000..a5b6ab1 --- /dev/null +++ b/modules/work/domain/orchestration.go @@ -0,0 +1,51 @@ +package domain + +import ( + "errors" + "git.cin.su/m/Assistant/contracts/work" +) + +var ErrInvalidSolutionSearch = errors.New("invalid solution search input") + +// DecideSolutionSearch keeps an unresolved goal alive across bounded waves. +// A wave has limits, but an unresolved goal is never converted into FAILED +// merely because one wave is exhausted. The caller persists the decision and +// schedules the next wave through the durable Work Graph. +func DecideSolutionSearch(input work.SolutionSearchInput) (work.SolutionSearchDecision, error) { + if input.GoalID == "" || input.SearchRound < 0 || input.MaxRound < 1 || input.ActiveOptionCount < 0 { + return work.SolutionSearchDecision{}, ErrInvalidSolutionSearch + } + decision := work.SolutionSearchDecision{GoalID: input.GoalID, NextSearchRound: input.SearchRound} + if input.CancelRequested || input.GoalState == work.GoalCancelled { + decision.State = work.OrchestrationCancelled + decision.Reason = "goal cancellation is durable and stops further search waves" + return decision, nil + } + if input.GoalState == work.GoalCompleted && input.VerificationReady { + decision.State = work.OrchestrationCompleted + decision.Reason = "goal has an independently verified accepted result" + return decision, nil + } + if input.ActiveOptionCount > 0 { + decision.State = work.OrchestrationExecuting + decision.ContinueSearching = true + decision.Options = []work.SearchOptionKind{work.SearchOptionAgents} + decision.Reason = "current solution wave is still running" + return decision, nil + } + decision.State = work.OrchestrationSearching + decision.ContinueSearching = true + decision.NextSearchRound++ + decision.Options = []work.SearchOptionKind{work.SearchOptionAgents, work.SearchOptionCapability, work.SearchOptionResearch, work.SearchOptionReflection} + if input.Unresolved { + decision.Reason = "previous wave did not establish a verified solution; start all independent search avenues" + } else { + decision.Reason = "no verified result exists; start the first parallel solution wave" + } + if input.SearchRound >= input.MaxRound { + decision.State = work.OrchestrationWaiting + decision.Options = append(decision.Options, work.SearchOptionHuman) + decision.Reason = "bounded wave budget exhausted; continue with a new durable wave and human attention, never claim failure" + } + return decision, nil +} diff --git a/modules/work/domain/orchestration_test.go b/modules/work/domain/orchestration_test.go new file mode 100644 index 0000000..b9c6c63 --- /dev/null +++ b/modules/work/domain/orchestration_test.go @@ -0,0 +1,30 @@ +package domain + +import ( + "git.cin.su/m/Assistant/contracts/work" + "testing" +) + +func TestSolutionSearchDoesNotDeadEndAfterBudgetedWave(t *testing.T) { + decision, err := DecideSolutionSearch(work.SolutionSearchInput{GoalID: "goal-1", GoalState: work.GoalInProgress, SearchRound: 3, MaxRound: 3, Unresolved: true}) + if err != nil { + t.Fatal(err) + } + if !decision.ContinueSearching || decision.State != work.OrchestrationWaiting || decision.NextSearchRound != 4 { + t.Fatalf("unexpected exhausted-wave decision: %#v", decision) + } + if len(decision.Options) != 5 || decision.Options[4] != work.SearchOptionHuman { + t.Fatalf("all search avenues were not retained: %#v", decision.Options) + } +} + +func TestSolutionSearchStopsOnlyForCancelOrVerifiedCompletion(t *testing.T) { + completed, err := DecideSolutionSearch(work.SolutionSearchInput{GoalID: "goal-1", GoalState: work.GoalCompleted, VerificationReady: true, MaxRound: 1}) + if err != nil || completed.ContinueSearching || completed.State != work.OrchestrationCompleted { + t.Fatalf("verified completion was not terminal: %#v %v", completed, err) + } + cancelled, err := DecideSolutionSearch(work.SolutionSearchInput{GoalID: "goal-1", GoalState: work.GoalInProgress, CancelRequested: true, MaxRound: 1}) + if err != nil || cancelled.ContinueSearching || cancelled.State != work.OrchestrationCancelled { + t.Fatalf("cancel was not terminal: %#v %v", cancelled, err) + } +}