diff --git a/.github/workflows/test-controller.yml b/.github/workflows/test-controller.yml index dc43ca2c0..2a84092b2 100644 --- a/.github/workflows/test-controller.yml +++ b/.github/workflows/test-controller.yml @@ -4,11 +4,19 @@ on: pull_request: paths: - 'agentteams-controller/**' + - 'copaw/**' + - 'plugins/teamharness/**' + - 'plugins/tests/**' + - 'docs/design/teamharness/**' - '.github/workflows/test-controller.yml' push: branches: [main] paths: - 'agentteams-controller/**' + - 'copaw/**' + - 'plugins/teamharness/**' + - 'plugins/tests/**' + - 'docs/design/teamharness/**' workflow_dispatch: jobs: @@ -30,3 +38,36 @@ jobs: - name: Integration tests (envtest) working-directory: agentteams-controller run: make test-integration + + teamharness: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + + - name: Install focused Python test dependencies + run: | + python -m pip install \ + 'agentscope==2.0.4.post1' \ + 'PyYAML>=6.0,<7' \ + 'pytest>=8.3,<10' \ + 'pytest-asyncio>=0.23,<2' + + - name: Run durable continuation contract tests + env: + NO_PROXY: 127.0.0.1,localhost + run: | + ruby plugins/tests/teamharness/test-contracts.rb + python -m pytest plugins/tests/teamharness/mcp/test_continuation.py -q + python -m pytest plugins/tests/teamharness/test_pull_project.py -q + PYTHONPATH="${GITHUB_WORKSPACE}/copaw/src" \ + python -m pytest copaw/tests/test_taskflow_tool.py -q + ruby plugins/tests/teamharness/mcp/tools/test-taskflow.rb diff --git a/agentteams-controller/cmd/agt/get.go b/agentteams-controller/cmd/agt/get.go index 925fd2147..e513b49c5 100644 --- a/agentteams-controller/cmd/agt/get.go +++ b/agentteams-controller/cmd/agt/get.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "net/url" "strconv" "strings" @@ -101,6 +102,7 @@ func getProjectsCmd() *cobra.Command { var team string var mermaid bool var output string + var includeTasks bool cmd := &cobra.Command{ Use: "projects [name]", @@ -111,14 +113,31 @@ func getProjectsCmd() *cobra.Command { agt get projects --team alpha-team agt get projects demo-project-001 agt get projects demo-project-001 -o json + agt get projects demo-project-001 --include-tasks -o json agt get projects demo-project-001 --mermaid`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client := NewAPIClient() + if includeTasks && len(args) != 1 { + return fmt.Errorf("--include-tasks requires a project name") + } + if includeTasks && output != "json" { + return fmt.Errorf("--include-tasks requires -o json") + } if len(args) == 1 { var resp map[string]any path := "/api/v1/projects/" + args[0] + "/workflow" + query := url.Values{} + if team != "" { + query.Set("team", team) + } + if includeTasks { + query.Set("includeTasks", "true") + } + if encoded := query.Encode(); encoded != "" { + path += "?" + encoded + } if err := client.DoJSON("GET", path, nil, &resp); err != nil { return fmt.Errorf("get project workflow: %w", err) } @@ -181,6 +200,7 @@ func getProjectsCmd() *cobra.Command { cmd.Flags().StringVar(&team, "team", "", "Filter by team name") cmd.Flags().BoolVar(&mermaid, "mermaid", false, "Render workflow as a Mermaid flowchart") + cmd.Flags().BoolVar(&includeTasks, "include-tasks", false, "Include raw TaskMeta details for a named project") cmd.Flags().StringVarP(&output, "output", "o", "", "Output format (json)") return cmd } diff --git a/agentteams-controller/cmd/agt/project_cmd.go b/agentteams-controller/cmd/agt/project_cmd.go index bcbd6ac45..3461d8030 100644 --- a/agentteams-controller/cmd/agt/project_cmd.go +++ b/agentteams-controller/cmd/agt/project_cmd.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "net/url" "os" "github.com/spf13/cobra" @@ -130,6 +131,8 @@ func projectCreateCmd() *cobra.Command { func projectCancelCmd() *cobra.Command { var reason string var replacement string + var submissionID string + var team string cmd := &cobra.Command{ Use: "cancel ", Short: "Cancel a single task (reason required)", @@ -142,11 +145,22 @@ func projectCancelCmd() *cobra.Command { if replacement != "" { body["replacementTaskId"] = replacement } - return projectWrite("POST", "/api/v1/projects/"+args[0]+"/tasks/"+args[1]+"/cancel", body) + if submissionID != "" { + body["submissionId"] = submissionID + } + path := "/api/v1/projects/" + args[0] + "/tasks/" + args[1] + "/cancel" + if team != "" { + query := url.Values{} + query.Set("team", team) + path += "?" + query.Encode() + } + return projectWrite("POST", path, body) }, } cmd.Flags().StringVar(&reason, "reason", "", "cancellation reason (required)") cmd.Flags().StringVar(&replacement, "replacement", "", "optional replacement task id") + cmd.Flags().StringVar(&submissionID, "submission-id", "", "current task submission identity (required when TaskMeta has submission_id)") + cmd.Flags().StringVar(&team, "team", "", "owning team for an ambiguous project id") return cmd } diff --git a/agentteams-controller/cmd/agt/project_cmd_test.go b/agentteams-controller/cmd/agt/project_cmd_test.go new file mode 100644 index 000000000..b74d2c267 --- /dev/null +++ b/agentteams-controller/cmd/agt/project_cmd_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestProjectCancelCommandForwardsSubmissionID(t *testing.T) { + var requestBody map[string]any + var requestMethod string + var requestPath string + var requestTeam string + var decodeErr error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestMethod = r.Method + requestPath = r.URL.Path + requestTeam = r.URL.Query().Get("team") + decodeErr = json.NewDecoder(r.Body).Decode(&requestBody) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + t.Setenv("AGENTTEAMS_CONTROLLER_URL", server.URL) + t.Setenv("AGENTTEAMS_AUTH_TOKEN", "test-token") + + cmd := projectCancelCmd() + cmd.SetArgs([]string{ + "p1", "t1", + "--reason", "superseded", + "--submission-id", "submission-1", + "--team", "alpha-team", + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if decodeErr != nil { + t.Fatalf("decode request: %v", decodeErr) + } + if requestMethod != http.MethodPost || requestPath != "/api/v1/projects/p1/tasks/t1/cancel" { + t.Fatalf("request=%s %s", requestMethod, requestPath) + } + if requestTeam != "alpha-team" { + t.Fatalf("team query=%q, want alpha-team", requestTeam) + } + if requestBody["submissionId"] != "submission-1" { + t.Fatalf("request body=%v, want submissionId", requestBody) + } +} + +func TestGetProjectsCommandForwardsIncludeTasks(t *testing.T) { + requested := make(chan *http.Request, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requested <- r.Clone(r.Context()) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"project_id":"p1","nodes":[],"next":[],"interrupts":[]}`)) + })) + defer server.Close() + t.Setenv("AGENTTEAMS_CONTROLLER_URL", server.URL) + t.Setenv("AGENTTEAMS_AUTH_TOKEN", "test-token") + + cmd := getProjectsCmd() + cmd.SetArgs([]string{"p1", "--include-tasks", "-o", "json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + req := <-requested + if req.URL.Path != "/api/v1/projects/p1/workflow" || req.URL.Query().Get("includeTasks") != "true" { + t.Fatalf("request URL=%s, want workflow?includeTasks=true", req.URL.String()) + } +} + +func TestGetProjectsCommandIncludeTasksRequiresJSONOutput(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + t.Setenv("AGENTTEAMS_CONTROLLER_URL", server.URL) + + cmd := getProjectsCmd() + cmd.SetArgs([]string{"p1", "--include-tasks"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "--include-tasks requires -o json") { + t.Fatalf("error=%v, want JSON output requirement", err) + } + if requests != 0 { + t.Fatalf("requests=%d, want no request for invalid flag combination", requests) + } +} diff --git a/agentteams-controller/internal/server/project_handler.go b/agentteams-controller/internal/server/project_handler.go index d583bf642..f222c4fce 100644 --- a/agentteams-controller/internal/server/project_handler.go +++ b/agentteams-controller/internal/server/project_handler.go @@ -83,11 +83,22 @@ type projectMeta struct { } type projectTaskMeta struct { - TaskID string `json:"task_id"` - Title string `json:"title"` - AssignedTo string `json:"assigned_to"` - DependsOn []string `json:"depends_on"` - Status string `json:"status"` + TaskID string `json:"task_id"` + Title string `json:"title"` + AssignedTo string `json:"assigned_to"` + DependsOn []string `json:"depends_on"` + Status string `json:"status"` + Cancellation *taskCancellationDecision `json:"cancellation,omitempty"` +} + +// taskCancellationDecision is written into the project node before TaskMeta. +// It lets a retry validate the original human decision when the project write +// succeeded but the following TaskMeta write failed. +type taskCancellationDecision struct { + SubmissionID string `json:"submission_id,omitempty"` + Reason string `json:"reason"` + ReplacementTaskID string `json:"replacement_task_id,omitempty"` + CancelledAt string `json:"cancelled_at"` } type loopMeta struct { @@ -189,8 +200,8 @@ type interruptConfig struct { // spec path, submission summary/result status, deliverables list and result // path. TaskMeta is written by TeamHarness taskflow (delegate_task creates // spec.md + meta.json, submit_task adds summary/result_status/deliverables/ -// result_path) and pushed to shared storage via _sync_task, so the same -// dual-prefix scan used for projects applies here. +// result_path) and pushed to shared storage via _sync_task. Reads stay in the +// project's owning scope and never fall back across team/global boundaries. type taskDetail struct { TaskID string `json:"task_id"` ProjectID string `json:"project_id,omitempty"` @@ -202,6 +213,7 @@ type taskDetail struct { Deliverables []any `json:"deliverables,omitempty"` ResultPath string `json:"result_path,omitempty"` CancelReason string `json:"cancel_reason,omitempty"` + SubmissionID string `json:"submission_id,omitempty"` } // normalizeTaskStatus maps ProjectMeta task status to the frontend-friendly @@ -785,13 +797,11 @@ func (h *ProjectHandler) buildWorkflow(meta *projectMeta, team string, includeTa // readTasksDetail reads TaskMeta (shared/tasks/{id}/meta.json) for every task // in the project's graph and returns the detail list in node order. // -// TaskMeta is stored under the same dual-prefix layout as projects -// (teams/{team}/shared/tasks/{id}/meta.json for team members, shared/tasks/ -// {id}/meta.json for standalone workers) — _sync_task pushes the local -// shared/tasks/{id} directory after delegate/ack/submit/cancel. We probe the -// task prefix belonging to this project's team first, then the global -// prefix, mirroring resolveProjectMeta. Reads are concurrent (W7 pattern) so -// N tasks cost ~ceil(N/8) mc subprocess rounds instead of N serial spawns. +// TaskMeta is read only from the project's owning scope: +// teams/{team}/shared/tasks/{id}/meta.json for team projects, or +// shared/tasks/{id}/meta.json for standalone projects. Reads are concurrent +// (W7 pattern) so N tasks cost ~ceil(N/8) storage rounds instead of N serial +// reads. func (h *ProjectHandler) readTasksDetail(meta *projectMeta, team string) []taskDetail { // Collect unique task ids from the graph (project tasks, or loop tasks // for loop plans — same set buildWorkflow renders). @@ -876,6 +886,7 @@ func (h *ProjectHandler) readTasksDetail(meta *projectMeta, team string) []taskD ResultStatus: str(raw["result_status"]), ResultPath: str(raw["result_path"]), CancelReason: str(raw["cancel_reason"]), + SubmissionID: str(raw["submission_id"]), } if raw["project_id"] != nil { detail.ProjectID = str(raw["project_id"]) @@ -2299,6 +2310,7 @@ func isSafeTaskID(s string) bool { // exists. func normalizeReplanTasks(raw []json.RawMessage, previous map[string]projectTaskMeta) ([]projectTaskMeta, error) { out := make([]projectTaskMeta, 0, len(raw)) + included := make(map[string]bool, len(raw)) for _, item := range raw { var m map[string]any if err := json.Unmarshal(item, &m); err != nil { @@ -2311,6 +2323,7 @@ func normalizeReplanTasks(raw []json.RawMessage, previous map[string]projectTask if !isSafeTaskID(taskID) { return nil, fmt.Errorf("taskId must be a safe id: %s", taskID) } + included[taskID] = true prev, hasPrev := previous[taskID] status := firstString(m["status"]) if status == "" && hasPrev { @@ -2322,6 +2335,9 @@ func normalizeReplanTasks(raw []json.RawMessage, previous map[string]projectTask if status == "pending" { status = "planned" } + if hasPrev && prev.Cancellation != nil && status != prev.Status { + return nil, fmt.Errorf("task %s has a committed cancellation and cannot be reopened", taskID) + } title := firstString(m["title"]) if title == "" && hasPrev { title = prev.Title @@ -2353,13 +2369,19 @@ func normalizeReplanTasks(raw []json.RawMessage, previous map[string]projectTask } } out = append(out, projectTaskMeta{ - TaskID: taskID, - Title: title, - AssignedTo: assignee, - DependsOn: deps, - Status: status, + TaskID: taskID, + Title: title, + AssignedTo: assignee, + DependsOn: deps, + Status: status, + Cancellation: prev.Cancellation, }) } + for taskID, prev := range previous { + if prev.Cancellation != nil && !included[taskID] { + return nil, fmt.Errorf("task %s has a committed cancellation and cannot be removed", taskID) + } + } return out, nil } @@ -2437,7 +2459,8 @@ func firstString(values ...any) string { // (shared/tasks/{id}/meta.json) and the project node status is updated to // cancelled. // -// POST /api/v1/projects/{id}/tasks/{taskId}/cancel body: {"reason":"...","replacementTaskId":"..."} +// POST /api/v1/projects/{id}/tasks/{taskId}/cancel +// body: {"reason":"...","replacementTaskId":"...","submissionId":"..."} func (h *ProjectHandler) CancelTask(w http.ResponseWriter, r *http.Request) { projectID := r.PathValue("id") taskID := r.PathValue("taskId") @@ -2482,6 +2505,7 @@ func (h *ProjectHandler) CancelTask(w http.ResponseWriter, r *http.Request) { var reqBody struct { Reason string `json:"reason"` ReplacementTaskID string `json:"replacementTaskId"` + SubmissionID string `json:"submissionId"` } _ = json.NewDecoder(r.Body).Decode(&reqBody) reason := strings.TrimSpace(reqBody.Reason) @@ -2489,6 +2513,11 @@ func (h *ProjectHandler) CancelTask(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "reason is required") return } + replacementTaskID := strings.TrimSpace(reqBody.ReplacementTaskID) + if replacementTaskID != "" && !isPlainToken(replacementTaskID) { + writeError(w, http.StatusBadRequest, "replacementTaskId must be a plain token (letters, digits, '-', '_', '.')") + return + } // Verify the task belongs to this project's graph and find its current // status from the project node. @@ -2497,11 +2526,13 @@ func (h *ProjectHandler) CancelTask(w http.ResponseWriter, r *http.Request) { graphTasks = meta.Loop.Tasks } nodeStatus := "" + var projectCancellation *taskCancellationDecision found := false for _, t := range graphTasks { if t.TaskID == taskID { found = true nodeStatus = t.Status + projectCancellation = t.Cancellation break } } @@ -2517,8 +2548,8 @@ func (h *ProjectHandler) CancelTask(w http.ResponseWriter, r *http.Request) { return } - // Read the task's TaskMeta (dual-prefix) to preserve fields when writing - // back, then stamp status=cancelled + cancel_reason. + // Read the task's TaskMeta from the project's owning scope to preserve fields + // when writing back, then apply the submission fence and cancellation. taskData, err := readTaskMetaFirst(h, r.Context(), taskID, team) if err != nil { writeError(w, http.StatusInternalServerError, "read task meta: "+err.Error()) @@ -2528,13 +2559,98 @@ func (h *ProjectHandler) CancelTask(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "task meta not found") return } + if str(taskData["task_id"]) != taskID || str(taskData["project_id"]) != projectID { + writeError(w, http.StatusNotFound, "task meta not found") + return + } + persistedSubmissionID, _ := taskData["submission_id"].(string) + persistedSubmissionID = strings.TrimSpace(persistedSubmissionID) + requestedSubmissionID := strings.TrimSpace(reqBody.SubmissionID) + if persistedSubmissionID != "" && requestedSubmissionID == "" { + writeError(w, http.StatusConflict, "submissionId is required for the current task submission") + return + } + if requestedSubmissionID != "" && requestedSubmissionID != persistedSubmissionID { + writeError(w, http.StatusConflict, "submissionId does not match the current task submission") + return + } + if projectCancellation != nil && + (strings.TrimSpace(projectCancellation.SubmissionID) != requestedSubmissionID || + strings.TrimSpace(projectCancellation.Reason) != reason || + strings.TrimSpace(projectCancellation.ReplacementTaskID) != replacementTaskID) { + writeError(w, http.StatusConflict, "cancel task conflicts with persisted project cancellation") + return + } + taskStatus, _ := taskData["status"].(string) + taskStatus = strings.TrimSpace(taskStatus) + if isTerminalTaskStatus(taskStatus) && taskStatus != "cancelled" { + writeError(w, http.StatusConflict, "cannot cancel terminal task: "+taskStatus) + return + } + persistedReason, _ := taskData["cancel_reason"].(string) + persistedReplacementTaskID, _ := taskData["replacement_task_id"].(string) + persistedCancelledAt, _ := taskData["cancelled_at"].(string) + continuation, hasContinuation := taskData["continuation"].(map[string]any) + continuationStatus, _ := continuation["status"].(string) + continuationResolution, _ := continuation["resolution"].(string) + continuationStatus = strings.TrimSpace(continuationStatus) + continuationResolution = strings.TrimSpace(continuationResolution) + if hasContinuation && continuationStatus == "resolved" && continuationResolution != "cancelled" { + writeError(w, http.StatusConflict, "task continuation already resolved as a different decision") + return + } + if taskStatus == "cancelled" && (strings.TrimSpace(persistedReason) != reason || + strings.TrimSpace(persistedReplacementTaskID) != replacementTaskID) { + writeError(w, http.StatusConflict, "cancel task conflicts with existing cancellation") + return + } + fullyResolved := taskStatus == "cancelled" && nodeStatus == "cancelled" && + strings.TrimSpace(persistedCancelledAt) != "" && + (!hasContinuation || (continuationStatus == "resolved" && continuationResolution == "cancelled")) + if fullyResolved { + httputil.WriteJSON(w, http.StatusOK, h.buildWorkflow(meta, team, false)) + return + } taskData["status"] = "cancelled" taskData["cancel_reason"] = reason - if reqBody.ReplacementTaskID != "" { - taskData["replacement_task_id"] = reqBody.ReplacementTaskID + cancelledAt := persistedCancelledAt + if strings.TrimSpace(cancelledAt) == "" { + if projectCancellation != nil { + cancelledAt = strings.TrimSpace(projectCancellation.CancelledAt) + } + if hasContinuation { + if cancelledAt == "" { + resolvedAt, _ := continuation["resolved_at"].(string) + cancelledAt = strings.TrimSpace(resolvedAt) + } + } + if cancelledAt == "" { + cancelledAt = utcTimestamp() + } + taskData["cancelled_at"] = cancelledAt + } + if hasContinuation && len(continuation) > 0 { + continuation["status"] = "resolved" + continuation["resolution"] = "cancelled" + if resolvedAt, _ := continuation["resolved_at"].(string); strings.TrimSpace(resolvedAt) == "" { + continuation["resolved_at"] = cancelledAt + } + taskData["continuation"] = continuation + } + if replacementTaskID != "" { + taskData["replacement_task_id"] = replacementTaskID } else { delete(taskData, "replacement_task_id") } + cancellationDecision := projectCancellation + if cancellationDecision == nil { + cancellationDecision = &taskCancellationDecision{ + SubmissionID: requestedSubmissionID, + Reason: reason, + ReplacementTaskID: replacementTaskID, + CancelledAt: cancelledAt, + } + } taskJSON, err := json.Marshal(taskData) if err != nil { writeError(w, http.StatusInternalServerError, "marshal task meta: "+err.Error()) @@ -2545,12 +2661,14 @@ func (h *ProjectHandler) CancelTask(w http.ResponseWriter, r *http.Request) { for i := range meta.Tasks { if meta.Tasks[i].TaskID == taskID { meta.Tasks[i].Status = "cancelled" + meta.Tasks[i].Cancellation = cancellationDecision } } if meta.Loop != nil { for i := range meta.Loop.Tasks { if meta.Loop.Tasks[i].TaskID == taskID { meta.Loop.Tasks[i].Status = "cancelled" + meta.Loop.Tasks[i].Cancellation = cancellationDecision } } } @@ -2580,9 +2698,9 @@ func isTerminalTaskStatus(status string) bool { } } -// readTaskMetaFirst reads a task's TaskMeta from the dual-prefix layout -// (team first, then global) and returns it as a mutable map. Returns nil when -// no readable TaskMeta exists in either prefix. +// readTaskMetaFirst reads a task's TaskMeta from the project's owning scope +// and returns it as a mutable map. Returns nil when no readable TaskMeta +// exists in that scope. func readTaskMetaFirst(h *ProjectHandler, ctx context.Context, taskID, team string) (map[string]any, error) { for _, key := range taskMetaKeys(taskID, team) { data, err := h.oss.GetObject(ctx, key) @@ -2728,18 +2846,25 @@ func (h *ProjectHandler) CreateProject(w http.ResponseWriter, r *http.Request) { }) } -// isPlainToken reports whether s is a safe plain token usable in an object -// key (no path traversal / separators). +// isPlainToken reports whether s matches TeamHarness's safe-id contract: +// [A-Za-z0-9][A-Za-z0-9._-]*. func isPlainToken(s string) bool { - for _, r := range s { + if s == "" { + return false + } + for i, r := range s { + alphaNumeric := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' + if i == 0 && !alphaNumeric { + return false + } switch { - case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + case alphaNumeric: case r == '-', r == '_', r == '.': default: return false } } - return s != "" + return true } // CompleteProject marks a project completed. All tasks must be in a terminal diff --git a/agentteams-controller/internal/server/project_handler_test.go b/agentteams-controller/internal/server/project_handler_test.go index 3950f5169..21fa0e92a 100644 --- a/agentteams-controller/internal/server/project_handler_test.go +++ b/agentteams-controller/internal/server/project_handler_test.go @@ -1492,6 +1492,7 @@ func TestGetProjectWorkflow_IncludeTasksDetail(t *testing.T) { "task_id": "t1", "project_id": "p1", "status": "completed", + "submission_id": "submission-1", "spec_path": "shared/tasks/t1/spec.md", "assigned_to": "@w1", "summary": "Alpha report done", @@ -1535,6 +1536,9 @@ func TestGetProjectWorkflow_IncludeTasksDetail(t *testing.T) { if d1.Summary != "Alpha report done" || d1.ResultStatus != "SUCCESS" || d1.ResultPath != "shared/tasks/t1/result.md" { t.Fatalf("t1 detail wrong: %+v", d1) } + if d1.SubmissionID != "submission-1" { + t.Fatalf("t1 submission_id=%q, want submission-1", d1.SubmissionID) + } if len(d1.Deliverables) != 1 { t.Fatalf("t1 deliverables=%d, want 1", len(d1.Deliverables)) } @@ -3228,6 +3232,95 @@ func TestReplanProject_PreservesPrevious(t *testing.T) { } } +func TestReplanProject_PreservesCancellationDecision(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", "title": "P1", "status": "active", "plan_type": "dag", + "tasks": []map[string]any{{ + "task_id": "t1", "title": "T1", "status": "cancelled", "depends_on": []string{}, + "cancellation": map[string]any{ + "submission_id": "submission-1", "reason": "obsolete", "replacement_task_id": "t2", "cancelled_at": "2026-08-18T00:00:00Z", + }, + }}, + }) + h := newProjectTestHandler(t, store) + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/p1/replan", + strings.NewReader(`{"tasks":[{"taskId":"t1","title":"Still cancelled"}]}`)) + req.SetPathValue("id", "p1") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.ReplanProject(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + + projectData, _ := store.GetObject(context.Background(), "shared/projects/p1/meta.json") + var project map[string]any + _ = json.Unmarshal(projectData, &project) + tasks, _ := project["tasks"].([]any) + node, _ := tasks[0].(map[string]any) + decision, _ := node["cancellation"].(map[string]any) + if node["status"] != "cancelled" || decision["submission_id"] != "submission-1" || + decision["reason"] != "obsolete" || decision["cancelled_at"] != "2026-08-18T00:00:00Z" { + t.Fatalf("replanned node=%v, want preserved cancellation decision", node) + } +} + +func TestReplanProject_CannotReopenCancellationDecision(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", "title": "P1", "status": "active", "plan_type": "dag", + "tasks": []map[string]any{{ + "task_id": "t1", "title": "T1", "status": "cancelled", "depends_on": []string{}, + "cancellation": map[string]any{ + "submission_id": "submission-1", "reason": "obsolete", "cancelled_at": "2026-08-18T00:00:00Z", + }, + }}, + }) + before, _ := store.GetObject(context.Background(), "shared/projects/p1/meta.json") + h := newProjectTestHandler(t, store) + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/p1/replan", + strings.NewReader(`{"tasks":[{"taskId":"t1","title":"Reopened","status":"planned"}]}`)) + req.SetPathValue("id", "p1") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.ReplanProject(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s, want 400", rec.Code, rec.Body.String()) + } + after, _ := store.GetObject(context.Background(), "shared/projects/p1/meta.json") + if string(after) != string(before) { + t.Fatalf("reopen attempt changed project: %s", after) + } +} + +func TestReplanProject_CannotRemoveCancellationDecision(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", "title": "P1", "status": "active", "plan_type": "dag", + "tasks": []map[string]any{{ + "task_id": "t1", "title": "T1", "status": "cancelled", "depends_on": []string{}, + "cancellation": map[string]any{ + "submission_id": "submission-1", "reason": "obsolete", "cancelled_at": "2026-08-18T00:00:00Z", + }, + }}, + }) + before, _ := store.GetObject(context.Background(), "shared/projects/p1/meta.json") + h := newProjectTestHandler(t, store) + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/p1/replan", strings.NewReader(`{"tasks":[]}`)) + req.SetPathValue("id", "p1") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.ReplanProject(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s, want 400", rec.Code, rec.Body.String()) + } + after, _ := store.GetObject(context.Background(), "shared/projects/p1/meta.json") + if string(after) != string(before) { + t.Fatalf("remove attempt changed project: %s", after) + } +} + func TestReplanProject_DuplicateID400(t *testing.T) { store := ossfake.NewMemory() putProject(store, "shared/projects/p1/meta.json", map[string]any{ @@ -3477,6 +3570,13 @@ func TestCancelTask_ActiveTask(t *testing.T) { if task["status"] != "cancelled" || task["cancel_reason"] != "no longer needed" || task["replacement_task_id"] != "t9" { t.Fatalf("task=%v, want cancelled/reason/t9", task) } + cancelledAt, _ := task["cancelled_at"].(string) + if strings.TrimSpace(cancelledAt) == "" { + t.Fatalf("task=%v, want cancelled_at", task) + } + if _, ok := task["continuation"]; ok { + t.Fatalf("legacy task=%v, must not invent continuation without delivery identity", task) + } projData, _ := store.GetObject(context.Background(), "shared/projects/p1/meta.json") var proj map[string]any _ = json.Unmarshal(projData, &proj) @@ -3486,6 +3586,268 @@ func TestCancelTask_ActiveTask(t *testing.T) { } } +func TestCancelTask_SubmittedContinuationResolves(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", "title": "P1", "status": "active", "plan_type": "dag", + "tasks": []map[string]any{ + {"task_id": "t1", "title": "T1", "status": "submitted", "depends_on": []string{}}, + }, + }) + putTask(store, "shared/tasks/t1/meta.json", map[string]any{ + "task_id": "t1", + "project_id": "p1", + "status": "submitted", + "submission_id": "submission-1", + "continuation": map[string]any{ + "status": "pending", + "delivery_id": "delivery-1", + }, + }) + h := newProjectTestHandler(t, store) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/p1/tasks/t1/cancel", + strings.NewReader(`{"reason":"no longer needed","submissionId":"submission-1"}`)) + req.SetPathValue("id", "p1") + req.SetPathValue("taskId", "t1") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.CancelTask(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + taskData, _ := store.GetObject(context.Background(), "shared/tasks/t1/meta.json") + var task map[string]any + _ = json.Unmarshal(taskData, &task) + cancelledAt, _ := task["cancelled_at"].(string) + if task["status"] != "cancelled" || strings.TrimSpace(cancelledAt) == "" { + t.Fatalf("task=%v, want cancelled with stable cancelled_at", task) + } + if task["submission_id"] != "submission-1" { + t.Fatalf("task=%v, must preserve submission identity", task) + } + continuation, _ := task["continuation"].(map[string]any) + if continuation["status"] != "resolved" || continuation["resolution"] != "cancelled" { + t.Fatalf("continuation=%v, want resolved/cancelled", continuation) + } + if continuation["delivery_id"] != "delivery-1" || continuation["resolved_at"] != cancelledAt { + t.Fatalf("continuation=%v, want original delivery id and resolved_at=%s", continuation, cancelledAt) + } +} + +func TestCancelTask_SubmissionFence(t *testing.T) { + tests := []struct { + name string + body string + wantStatus int + }{ + {name: "missing", body: `{"reason":"no longer needed"}`, wantStatus: http.StatusConflict}, + {name: "stale", body: `{"reason":"no longer needed","submissionId":"submission-old"}`, wantStatus: http.StatusConflict}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", "title": "P1", "status": "active", "plan_type": "dag", + "tasks": []map[string]any{ + {"task_id": "t1", "title": "T1", "status": "submitted", "depends_on": []string{}}, + }, + }) + putTask(store, "shared/tasks/t1/meta.json", map[string]any{ + "task_id": "t1", + "project_id": "p1", + "status": "submitted", + "submission_id": "submission-current", + "continuation": map[string]any{ + "status": "pending", + "delivery_id": "delivery-1", + }, + }) + beforeProject, _ := store.GetObject(context.Background(), "shared/projects/p1/meta.json") + beforeTask, _ := store.GetObject(context.Background(), "shared/tasks/t1/meta.json") + h := newProjectTestHandler(t, store) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/p1/tasks/t1/cancel", strings.NewReader(tt.body)) + req.SetPathValue("id", "p1") + req.SetPathValue("taskId", "t1") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.CancelTask(rec, req) + + if rec.Code != tt.wantStatus { + t.Fatalf("status=%d body=%s, want %d", rec.Code, rec.Body.String(), tt.wantStatus) + } + afterProject, _ := store.GetObject(context.Background(), "shared/projects/p1/meta.json") + afterTask, _ := store.GetObject(context.Background(), "shared/tasks/t1/meta.json") + if string(afterProject) != string(beforeProject) || string(afterTask) != string(beforeTask) { + t.Fatalf("submission fence changed state: project=%s task=%s", afterProject, afterTask) + } + }) + } +} + +func TestCancelTask_LegacyRejectsUnknownSubmissionID(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", "title": "P1", "status": "active", "plan_type": "dag", + "tasks": []map[string]any{{"task_id": "t1", "title": "T1", "status": "in_progress", "depends_on": []string{}}}, + }) + putTask(store, "shared/tasks/t1/meta.json", map[string]any{ + "task_id": "t1", "project_id": "p1", "status": "in_progress", + }) + beforeProject, _ := store.GetObject(context.Background(), "shared/projects/p1/meta.json") + beforeTask, _ := store.GetObject(context.Background(), "shared/tasks/t1/meta.json") + h := newProjectTestHandler(t, store) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/p1/tasks/t1/cancel", + strings.NewReader(`{"reason":"obsolete","submissionId":"invented"}`)) + req.SetPathValue("id", "p1") + req.SetPathValue("taskId", "t1") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.CancelTask(rec, req) + + if rec.Code != http.StatusConflict { + t.Fatalf("status=%d body=%s, want 409", rec.Code, rec.Body.String()) + } + afterProject, _ := store.GetObject(context.Background(), "shared/projects/p1/meta.json") + afterTask, _ := store.GetObject(context.Background(), "shared/tasks/t1/meta.json") + if string(afterProject) != string(beforeProject) || string(afterTask) != string(beforeTask) { + t.Fatalf("unknown legacy identity changed state: project=%s task=%s", afterProject, afterTask) + } +} + +func TestCancelTask_RepeatedDecisionIsIdempotentAndConflictingPayloadIsRejected(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", "title": "P1", "status": "active", "plan_type": "dag", + "tasks": []map[string]any{ + {"task_id": "t1", "title": "T1", "status": "submitted", "depends_on": []string{}}, + }, + }) + putTask(store, "shared/tasks/t1/meta.json", map[string]any{ + "task_id": "t1", + "project_id": "p1", + "status": "submitted", + "submission_id": "submission-1", + "continuation": map[string]any{ + "status": "pending", + "delivery_id": "delivery-1", + }, + }) + h := newProjectTestHandler(t, store) + + cancel := func(body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/p1/tasks/t1/cancel", strings.NewReader(body)) + req.SetPathValue("id", "p1") + req.SetPathValue("taskId", "t1") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.CancelTask(rec, req) + return rec + } + + body := `{"reason":"superseded","replacementTaskId":"t2","submissionId":"submission-1"}` + if rec := cancel(body); rec.Code != http.StatusOK { + t.Fatalf("first cancel status=%d body=%s", rec.Code, rec.Body.String()) + } + firstProject, _ := store.GetObject(context.Background(), "shared/projects/p1/meta.json") + firstTask, _ := store.GetObject(context.Background(), "shared/tasks/t1/meta.json") + if rec := cancel(body); rec.Code != http.StatusOK { + t.Fatalf("retry status=%d body=%s", rec.Code, rec.Body.String()) + } + secondProject, _ := store.GetObject(context.Background(), "shared/projects/p1/meta.json") + secondTask, _ := store.GetObject(context.Background(), "shared/tasks/t1/meta.json") + if string(secondProject) != string(firstProject) || string(secondTask) != string(firstTask) { + t.Fatalf("identical retry changed state: project=%s task=%s", secondProject, secondTask) + } + + conflicts := []string{ + `{"reason":"different","replacementTaskId":"t2","submissionId":"submission-1"}`, + `{"reason":"superseded","replacementTaskId":"t3","submissionId":"submission-1"}`, + `{"reason":"superseded","submissionId":"submission-1"}`, + } + for _, conflictBody := range conflicts { + rec := cancel(conflictBody) + if rec.Code != http.StatusConflict { + t.Fatalf("conflict body=%s status=%d response=%s", conflictBody, rec.Code, rec.Body.String()) + } + afterProject, _ := store.GetObject(context.Background(), "shared/projects/p1/meta.json") + afterTask, _ := store.GetObject(context.Background(), "shared/tasks/t1/meta.json") + if string(afterProject) != string(firstProject) || string(afterTask) != string(firstTask) { + t.Fatalf("conflicting retry changed state: project=%s task=%s", afterProject, afterTask) + } + } +} + +func TestCancelTask_TaskMetaTerminalDecisionCannotBeOverwritten(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", "title": "P1", "status": "active", "plan_type": "dag", + "tasks": []map[string]any{ + {"task_id": "t1", "title": "T1", "status": "submitted", "depends_on": []string{}}, + }, + }) + putTask(store, "shared/tasks/t1/meta.json", map[string]any{ + "task_id": "t1", "project_id": "p1", "status": "completed", + "submission_id": "submission-1", + }) + beforeProject, _ := store.GetObject(context.Background(), "shared/projects/p1/meta.json") + beforeTask, _ := store.GetObject(context.Background(), "shared/tasks/t1/meta.json") + h := newProjectTestHandler(t, store) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/p1/tasks/t1/cancel", + strings.NewReader(`{"reason":"too late","submissionId":"submission-1"}`)) + req.SetPathValue("id", "p1") + req.SetPathValue("taskId", "t1") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.CancelTask(rec, req) + + if rec.Code != http.StatusConflict { + t.Fatalf("status=%d body=%s, want 409", rec.Code, rec.Body.String()) + } + afterProject, _ := store.GetObject(context.Background(), "shared/projects/p1/meta.json") + afterTask, _ := store.GetObject(context.Background(), "shared/tasks/t1/meta.json") + if string(afterProject) != string(beforeProject) || string(afterTask) != string(beforeTask) { + t.Fatalf("terminal task decision changed: project=%s task=%s", afterProject, afterTask) + } +} + +func TestCancelTask_RejectsTaskMetaOwnedByAnotherProject(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", "title": "P1", "status": "active", "plan_type": "dag", + "tasks": []map[string]any{{"task_id": "t1", "title": "T1", "status": "submitted", "depends_on": []string{}}}, + }) + putTask(store, "shared/tasks/t1/meta.json", map[string]any{ + "task_id": "t1", "project_id": "p2", "status": "submitted", "submission_id": "p2-submission", + "continuation": map[string]any{"status": "pending", "delivery_id": "p2-delivery"}, + }) + beforeProject, _ := store.GetObject(context.Background(), "shared/projects/p1/meta.json") + beforeTask, _ := store.GetObject(context.Background(), "shared/tasks/t1/meta.json") + h := newProjectTestHandler(t, store) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/p1/tasks/t1/cancel", + strings.NewReader(`{"reason":"wrong project","submissionId":"p2-submission"}`)) + req.SetPathValue("id", "p1") + req.SetPathValue("taskId", "t1") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.CancelTask(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status=%d body=%s, want 404", rec.Code, rec.Body.String()) + } + afterProject, _ := store.GetObject(context.Background(), "shared/projects/p1/meta.json") + afterTask, _ := store.GetObject(context.Background(), "shared/tasks/t1/meta.json") + if string(afterProject) != string(beforeProject) || string(afterTask) != string(beforeTask) { + t.Fatalf("ownership mismatch changed state: project=%s task=%s", afterProject, afterTask) + } +} + func TestCancelTask_Terminal409(t *testing.T) { store := ossfake.NewMemory() putProject(store, "shared/projects/p1/meta.json", map[string]any{ @@ -3534,6 +3896,39 @@ func TestCancelTask_NoReason400(t *testing.T) { } } +func TestCancelTask_InvalidReplacementID400(t *testing.T) { + for _, replacement := range []string{"../t2", "..", ".hidden", "-task", "_task"} { + t.Run(replacement, func(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", "title": "P1", "status": "active", "plan_type": "dag", + "tasks": []map[string]any{{"task_id": "t1", "title": "T1", "status": "in_progress", "depends_on": []string{}}}, + }) + putTask(store, "shared/tasks/t1/meta.json", map[string]any{ + "task_id": "t1", "project_id": "p1", "status": "in_progress", + }) + beforeProject, _ := store.GetObject(context.Background(), "shared/projects/p1/meta.json") + beforeTask, _ := store.GetObject(context.Background(), "shared/tasks/t1/meta.json") + h := newProjectTestHandler(t, store) + body, _ := json.Marshal(map[string]any{"reason": "obsolete", "replacementTaskId": replacement}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/p1/tasks/t1/cancel", strings.NewReader(string(body))) + req.SetPathValue("id", "p1") + req.SetPathValue("taskId", "t1") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.CancelTask(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s, want 400", rec.Code, rec.Body.String()) + } + afterProject, _ := store.GetObject(context.Background(), "shared/projects/p1/meta.json") + afterTask, _ := store.GetObject(context.Background(), "shared/tasks/t1/meta.json") + if string(afterProject) != string(beforeProject) || string(afterTask) != string(beforeTask) { + t.Fatalf("invalid replacement changed state: project=%s task=%s", afterProject, afterTask) + } + }) + } +} + func TestCompleteProject_AllTerminal(t *testing.T) { store := ossfake.NewMemory() putProject(store, "shared/projects/p1/meta.json", map[string]any{ @@ -4042,3 +4437,182 @@ func TestCancelTask_RetryConvergesBothObjects(t *testing.T) { t.Fatalf("task=%v, want cancelled + reason after retry", task2) } } + +func TestCancelTask_RetryAfterTaskWriteFailureResolvesContinuation(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", "title": "P1", "status": "active", "plan_type": "dag", + "tasks": []map[string]any{{"task_id": "t1", "title": "T1", "status": "submitted", "depends_on": []string{}}}, + }) + putTask(store, "shared/tasks/t1/meta.json", map[string]any{ + "task_id": "t1", "project_id": "p1", "status": "submitted", + "submission_id": "submission-1", + "continuation": map[string]any{"status": "pending", "delivery_id": "delivery-1"}, + }) + body := `{"reason":"obsolete","replacementTaskId":"t2","submissionId":"submission-1"}` + + failFirst := &failTaskPutOSS{StorageClient: &mcLikeOSS{Memory: store}, failPrefix: "shared/tasks/t1/", failures: 1} + h := newProjectTestHandlerWithOSS(t, failFirst) + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/p1/tasks/t1/cancel", strings.NewReader(body)) + req.SetPathValue("id", "p1") + req.SetPathValue("taskId", "t1") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.CancelTask(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("first attempt status=%d body=%s, want 500", rec.Code, rec.Body.String()) + } + + taskData, _ := store.GetObject(context.Background(), "shared/tasks/t1/meta.json") + var beforeRetry map[string]any + _ = json.Unmarshal(taskData, &beforeRetry) + continuation, _ := beforeRetry["continuation"].(map[string]any) + if beforeRetry["status"] != "submitted" || continuation["status"] != "pending" { + t.Fatalf("task after failed write=%v, want original submitted/pending state", beforeRetry) + } + + h2 := newProjectTestHandler(t, store) + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/projects/p1/tasks/t1/cancel", strings.NewReader(body)) + req2.SetPathValue("id", "p1") + req2.SetPathValue("taskId", "t1") + req2 = withCaller(req2, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec2 := httptest.NewRecorder() + h2.CancelTask(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("retry status=%d body=%s, want 200", rec2.Code, rec2.Body.String()) + } + + taskData, _ = store.GetObject(context.Background(), "shared/tasks/t1/meta.json") + var repaired map[string]any + _ = json.Unmarshal(taskData, &repaired) + repairedContinuation, _ := repaired["continuation"].(map[string]any) + if repaired["status"] != "cancelled" || repaired["cancelled_at"] == "" { + t.Fatalf("repaired task=%v, want cancelled with cancelled_at", repaired) + } + if repairedContinuation["status"] != "resolved" || repairedContinuation["resolution"] != "cancelled" || + repairedContinuation["delivery_id"] != "delivery-1" || repairedContinuation["resolved_at"] == "" { + t.Fatalf("repaired continuation=%v", repairedContinuation) + } +} + +func TestCancelTask_TaskWriteFailureRejectsConflictingRetry(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", "title": "P1", "status": "active", "plan_type": "dag", + "tasks": []map[string]any{{"task_id": "t1", "title": "T1", "status": "submitted", "depends_on": []string{}}}, + }) + putTask(store, "shared/tasks/t1/meta.json", map[string]any{ + "task_id": "t1", "project_id": "p1", "status": "submitted", + "submission_id": "submission-1", + "continuation": map[string]any{"status": "pending", "delivery_id": "delivery-1"}, + }) + + failFirst := &failTaskPutOSS{StorageClient: &mcLikeOSS{Memory: store}, failPrefix: "shared/tasks/t1/", failures: 1} + h := newProjectTestHandlerWithOSS(t, failFirst) + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/p1/tasks/t1/cancel", + strings.NewReader(`{"reason":"first decision","replacementTaskId":"t2","submissionId":"submission-1"}`)) + req.SetPathValue("id", "p1") + req.SetPathValue("taskId", "t1") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.CancelTask(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("first attempt status=%d body=%s, want 500", rec.Code, rec.Body.String()) + } + projectData, _ := store.GetObject(context.Background(), "shared/projects/p1/meta.json") + var project map[string]any + _ = json.Unmarshal(projectData, &project) + projectTasks, _ := project["tasks"].([]any) + decision, _ := projectTasks[0].(map[string]any)["cancellation"].(map[string]any) + if decision["submission_id"] != "submission-1" || decision["reason"] != "first decision" || + decision["replacement_task_id"] != "t2" || decision["cancelled_at"] == "" { + t.Fatalf("project cancellation decision=%v", decision) + } + + h2 := newProjectTestHandler(t, store) + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/projects/p1/tasks/t1/cancel", + strings.NewReader(`{"reason":"different decision","replacementTaskId":"t3","submissionId":"submission-1"}`)) + req2.SetPathValue("id", "p1") + req2.SetPathValue("taskId", "t1") + req2 = withCaller(req2, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec2 := httptest.NewRecorder() + h2.CancelTask(rec2, req2) + if rec2.Code != http.StatusConflict { + t.Fatalf("conflicting retry status=%d body=%s, want 409", rec2.Code, rec2.Body.String()) + } + + taskData, _ := store.GetObject(context.Background(), "shared/tasks/t1/meta.json") + var task map[string]any + _ = json.Unmarshal(taskData, &task) + continuation, _ := task["continuation"].(map[string]any) + if task["status"] != "submitted" || continuation["status"] != "pending" { + t.Fatalf("conflicting retry changed task=%v", task) + } +} + +func TestCancelTask_RetryRepairsPreviouslyCancelledPendingContinuation(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", "title": "P1", "status": "active", "plan_type": "dag", + "tasks": []map[string]any{{"task_id": "t1", "title": "T1", "status": "cancelled", "depends_on": []string{}}}, + }) + putTask(store, "shared/tasks/t1/meta.json", map[string]any{ + "task_id": "t1", "project_id": "p1", "status": "cancelled", + "submission_id": "submission-1", + "cancel_reason": "obsolete", + "continuation": map[string]any{"status": "pending", "delivery_id": "delivery-1"}, + }) + h := newProjectTestHandler(t, store) + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/p1/tasks/t1/cancel", + strings.NewReader(`{"reason":"obsolete","submissionId":"submission-1"}`)) + req.SetPathValue("id", "p1") + req.SetPathValue("taskId", "t1") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.CancelTask(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + + taskData, _ := store.GetObject(context.Background(), "shared/tasks/t1/meta.json") + var task map[string]any + _ = json.Unmarshal(taskData, &task) + continuation, _ := task["continuation"].(map[string]any) + if task["cancelled_at"] == "" || continuation["status"] != "resolved" || + continuation["resolution"] != "cancelled" || continuation["delivery_id"] != "delivery-1" { + t.Fatalf("repaired task=%v", task) + } +} + +func TestCancelTask_RetryRepairsLegacyCancelledTaskWithoutTimestamp(t *testing.T) { + store := ossfake.NewMemory() + putProject(store, "shared/projects/p1/meta.json", map[string]any{ + "project_id": "p1", "title": "P1", "status": "active", "plan_type": "dag", + "tasks": []map[string]any{{"task_id": "t1", "title": "T1", "status": "cancelled", "depends_on": []string{}}}, + }) + putTask(store, "shared/tasks/t1/meta.json", map[string]any{ + "task_id": "t1", "project_id": "p1", "status": "cancelled", "cancel_reason": "obsolete", + }) + h := newProjectTestHandler(t, store) + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/p1/tasks/t1/cancel", + strings.NewReader(`{"reason":"obsolete"}`)) + req.SetPathValue("id", "p1") + req.SetPathValue("taskId", "t1") + req = withCaller(req, &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"}) + rec := httptest.NewRecorder() + h.CancelTask(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + + taskData, _ := store.GetObject(context.Background(), "shared/tasks/t1/meta.json") + var task map[string]any + _ = json.Unmarshal(taskData, &task) + cancelledAt, _ := task["cancelled_at"].(string) + if strings.TrimSpace(cancelledAt) == "" { + t.Fatalf("legacy cancelled task=%v, want repaired cancelled_at", task) + } + if _, ok := task["continuation"]; ok { + t.Fatalf("legacy cancelled task=%v, must not invent continuation", task) + } +} diff --git a/copaw/src/copaw_worker/hooks/tools/projectflow.py b/copaw/src/copaw_worker/hooks/tools/projectflow.py index 18fd69f39..95dabe5ef 100644 --- a/copaw/src/copaw_worker/hooks/tools/projectflow.py +++ b/copaw/src/copaw_worker/hooks/tools/projectflow.py @@ -11,12 +11,17 @@ import urllib.error import urllib.request +import yaml + from agentscope.message import TextBlock from agentscope.tool import ToolResponse from copaw_worker.task import ( FileSystemTaskStore, + TaskDecisionPersistenceError, TaskflowError, + accept_task_result, + cancel_task, canonical_worker_id, complete_project, create_project, @@ -32,6 +37,7 @@ record_loop_iteration, resume_project, ) +from copaw_worker.hooks.tools.filesync import create_sync def _response(payload: dict[str, Any]) -> ToolResponse: @@ -75,6 +81,31 @@ def _store() -> FileSystemTaskStore: return FileSystemTaskStore(_workspace_dir()) +def _current_actor() -> str: + actor = ( + os.getenv("AGENTTEAMS_MATRIX_USER_ID") + or os.getenv("COPAW_MATRIX_USER_ID") + or "" + ).strip() + if not actor: + raise TaskflowError("current actor identity is required") + return actor + + +def _require_team_leader() -> str: + actor = _current_actor() + runtime_path = _working_dir().parent / "runtime" / "runtime.yaml" + try: + config = yaml.safe_load(runtime_path.read_text(encoding="utf-8")) or {} + except (OSError, yaml.YAMLError) as exc: + raise TaskflowError("team leader runtime configuration is required") from exc + member = config.get("member") if isinstance(config, dict) else None + role = str((member or {}).get("role") or "").strip() + if role != "team_leader": + raise TaskflowError(f"action requires team_leader role, current role: {role or 'unknown'}") + return actor + + def _coerce_payload(payload: dict[str, Any] | str | None) -> dict[str, Any]: if isinstance(payload, str): try: @@ -550,10 +581,89 @@ async def projectflow( meta = complete_project(store, project_id=project_id) return _ok(action=action, project=asdict(meta)) + if action in {"accept_task_result", "cancel_task"}: + _require_team_leader() + project_id = _required_str(payload_data, "projectId") + task_id = _required_str(payload_data, "taskId") + submission_id = _required_str(payload_data, "submissionId") + if action == "accept_task_result": + accepted_value = payload_data.get("accepted") + if not isinstance(accepted_value, bool): + raise TaskflowError("payload.accepted must be a boolean") + else: + reason = _required_str(payload_data, "reason") + replacement_task_id = _optional_str(payload_data, "replacementTaskId") + if dryRun: + return _ok( + dryRun=True, + action=action, + projectId=project_id, + taskId=task_id, + submissionId=submission_id, + ) + try: + if action == "accept_task_result": + task, reused = accept_task_result( + store, + project_id=project_id, + task_id=task_id, + submission_id=submission_id, + accepted=accepted_value, + ) + else: + task, reused = cancel_task( + store, + project_id=project_id, + task_id=task_id, + reason=reason, + replacement_task_id=replacement_task_id, + submission_id=submission_id, + ) + except TaskDecisionPersistenceError as exc: + return _error( + f"{action} project state persisted locally but task state write failed: {exc}", + action=action, + projectId=project_id, + taskId=task_id, + task=asdict(exc.task), + reused=False, + synced=False, + retryable=True, + statePersisted=True, + ) + sync = create_sync() + try: + sync.push_shared_path(f"shared/projects/{project_id}/") + sync.push_shared_path( + f"shared/tasks/{task_id}/", + exclude=["spec.md", "base/"], + ) + except Exception as exc: + return _error( + f"{action} state persisted locally but shared-storage sync failed: {exc}", + action=action, + projectId=project_id, + taskId=task_id, + task=asdict(task), + reused=reused, + synced=False, + retryable=True, + statePersisted=True, + ) + return _ok( + action=action, + projectId=project_id, + taskId=task_id, + task=asdict(task), + reused=reused, + synced=True, + ) + raise TaskflowError( "action must be one of: create_project, plan_dag, ready_nodes, " "plan_loop, ready_loop_nodes, record_loop_iteration, " - "check_active_tasks, pause_project, resume_project, complete_project", + "check_active_tasks, pause_project, resume_project, complete_project, " + "accept_task_result, cancel_task", ) except TaskflowError as exc: return _error( diff --git a/copaw/src/copaw_worker/hooks/tools/taskflow.py b/copaw/src/copaw_worker/hooks/tools/taskflow.py index 95644c0c8..2d0a17876 100644 --- a/copaw/src/copaw_worker/hooks/tools/taskflow.py +++ b/copaw/src/copaw_worker/hooks/tools/taskflow.py @@ -29,7 +29,7 @@ commit_task_assignment, is_effective_result, prepare_task, - submit_task, + submit_task_with_outcome, validate_delegate_task, validate_task_result, ) @@ -597,17 +597,46 @@ async def taskflow( if result is not None: dry_run_payload["result"] = asdict(result) return _ok(**dry_run_payload) - meta = submit_task(store, task_id=task_id, result=result, actor=_current_actor()) - task_path = f"shared/tasks/{task_id}/" + meta, reused = submit_task_with_outcome( + store, + task_id=task_id, + result=result, + actor=_current_actor(), + ) result_path = f"shared/tasks/{task_id}/result.md" + meta_path = f"shared/tasks/{task_id}/meta.json" + persisted_result = store.read_task_result(task_id) sync = create_sync() - sync.push_shared_path(task_path, exclude=["spec.md", "base/"]) - sync.stat_shared_path(result_path) + try: + # Publish every result payload before meta.json, which is the + # remote commit point for a submitted task. + payload_paths = list(dict.fromkeys( + [result_path, *persisted_result.deliverables], + )) + for payload_path in payload_paths: + sync.push_shared_path(payload_path) + sync.stat_shared_path(payload_path) + sync.push_shared_path(meta_path) + sync.stat_shared_path(meta_path) + except Exception as exc: + return _error( + "submit_task state persisted locally but shared-storage " + f"sync failed: {exc}", + action=action, + taskId=task_id, + task=asdict(meta), + reused=reused, + synced=False, + verified=False, + retryable=True, + statePersisted=True, + ) response_payload: dict[str, Any] = { "action": action, "task": asdict(meta), "synced": True, "verified": True, + "reused": reused, } if result is not None: response_payload["result"] = asdict(result) diff --git a/copaw/src/copaw_worker/task.py b/copaw/src/copaw_worker/task.py index ca486b5d2..f0e0b39bb 100644 --- a/copaw/src/copaw_worker/task.py +++ b/copaw/src/copaw_worker/task.py @@ -4,22 +4,36 @@ from dataclasses import asdict, dataclass, field from datetime import datetime, timezone +import hashlib import json +import os from pathlib import Path import re +import tempfile +import threading from typing import Any, Protocol +from uuid import uuid4 class TaskflowError(ValueError): """Expected user-facing taskflow error.""" +class TaskDecisionPersistenceError(OSError): + """A terminal plan marker persisted before its TaskMeta projection failed.""" + + def __init__(self, message: str, *, task: "TaskMeta") -> None: + super().__init__(message) + self.task = task + + MARKER_TO_STATUS = { " ": "pending", "~": "delegated", "x": "completed", "!": "blocked", "\u2192": "revision", + "-": "cancelled", } STATUS_TO_MARKER = {value: key for key, value in MARKER_TO_STATUS.items()} RESULT_STATUSES = { @@ -30,6 +44,9 @@ class TaskflowError(ValueError): "INTERRUPTED", } EFFECTIVE_RESULT_STATUSES = {"SUCCESS", "SUCCESS_WITH_NOTES"} +TERMINAL_TASK_STATUSES = {"completed", "revision", "blocked", "cancelled"} +_TASK_MUTATION_LOCKS: dict[str, threading.RLock] = {} +_TASK_MUTATION_LOCKS_GUARD = threading.Lock() @dataclass(frozen=True) @@ -77,6 +94,12 @@ class TaskMeta: acknowledged_at: str | None = None submitted_at: str | None = None event_id: str | None = None + submission_id: str | None = None + result_digest: str | None = None + continuation: dict[str, str] | None = None + cancel_reason: str | None = None + replacement_task_id: str | None = None + cancelled_at: str | None = None @dataclass(frozen=True) @@ -140,8 +163,7 @@ def read_project_plan(self, project_id: str) -> str: def write_project_plan(self, project_id: str, plan: str) -> None: path = self._project_dir(project_id) / "plan.md" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(plan) + _atomic_write_text(path, plan) def read_task_meta(self, task_id: str) -> TaskMeta: path = self._task_dir(task_id) / "meta.json" @@ -157,6 +179,16 @@ def read_task_meta(self, task_id: str) -> TaskMeta: assigned_at=data.get("assigned_at"), acknowledged_at=data.get("acknowledged_at"), submitted_at=data.get("submitted_at"), + submission_id=data.get("submission_id"), + result_digest=data.get("result_digest"), + continuation=( + dict(data["continuation"]) + if isinstance(data.get("continuation"), dict) + else None + ), + cancel_reason=data.get("cancel_reason"), + replacement_task_id=data.get("replacement_task_id"), + cancelled_at=data.get("cancelled_at"), event_id=data.get("event_id"), ) @@ -186,8 +218,7 @@ def read_task_result(self, task_id: str) -> TaskResult: def write_task_result(self, task_id: str, result: TaskResult) -> None: validate_task_result(task_id, result) path = self._task_dir(task_id) / "result.md" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(render_task_result(result)) + _atomic_write_text(path, render_task_result(result)) def create_project( @@ -651,13 +682,22 @@ def is_effective_result(result: TaskResult) -> bool: def ack_task(store: TaskStore, *, task_id: str, actor: str | None = None) -> TaskMeta: """Mark a local task as acknowledged/in progress without touching graph.""" - meta = store.read_task_meta(task_id) - _require_assigned_worker(meta, actor) - _require_task_room(meta) - meta.status = "in_progress" - meta.acknowledged_at = meta.acknowledged_at or _now() - store.write_task_meta(meta) - return meta + with _task_mutation_lock(store, task_id): + meta = store.read_task_meta(task_id) + _require_assigned_worker(meta, actor) + _require_task_room(meta) + if meta.status in TERMINAL_TASK_STATUSES: + raise TaskflowError( + f"ack_task cannot update terminal task: {meta.status}", + ) + if meta.status not in {"assigned", "in_progress"}: + raise TaskflowError( + f"ack_task cannot update task in status: {meta.status}", + ) + meta.status = "in_progress" + meta.acknowledged_at = meta.acknowledged_at or _now() + store.write_task_meta(meta) + return meta def submit_task( @@ -667,20 +707,456 @@ def submit_task( result: TaskResult | None = None, actor: str | None = None, ) -> TaskMeta: + """Submit a task while preserving the original TaskMeta return contract.""" + meta, _ = submit_task_with_outcome( + store, + task_id=task_id, + result=result, + actor=actor, + ) + return meta + + +def submit_task_with_outcome( + store: TaskStore, + *, + task_id: str, + result: TaskResult | None = None, + actor: str | None = None, +) -> tuple[TaskMeta, bool]: """Mark a local task submitted after result.md exists and is valid.""" + with _task_mutation_lock(store, task_id): + return _submit_task_with_outcome_unlocked( + store, + task_id=task_id, + result=result, + actor=actor, + ) + + +def _submit_task_with_outcome_unlocked( + store: TaskStore, + *, + task_id: str, + result: TaskResult | None, + actor: str | None, +) -> tuple[TaskMeta, bool]: meta = store.read_task_meta(task_id) _require_assigned_worker(meta, actor) _require_task_room(meta) + if meta.status in TERMINAL_TASK_STATUSES: + raise TaskflowError( + f"submit_task cannot update terminal task: {meta.status}", + ) + if meta.status == "submitted": + if not meta.submission_id: + return _adopt_legacy_submission( + store, + meta=meta, + result=result, + ) + normalized_result = ( + parse_task_result(render_task_result(result)) + if result is not None + else None + ) + submitted_result: TaskResult | None = None + try: + submitted_result = store.read_task_result(task_id) + except TaskflowError as exc: + if "task result not found" not in str(exc): + raise + normalized_digest = ( + _task_result_digest(normalized_result) + if normalized_result is not None + else None + ) + if ( + submitted_result is not None + and meta.result_digest + and _task_result_digest(submitted_result) != meta.result_digest + ): + raise TaskflowError( + f"task {task_id} persisted result does not match submitted digest", + ) + if normalized_digest is not None and meta.result_digest: + conflicts = normalized_digest != meta.result_digest + else: + conflicts = ( + normalized_result is not None + and submitted_result is not None + and normalized_result != submitted_result + ) + if conflicts: + raise TaskflowError( + f"task {task_id} is already submitted with a different result", + ) + if submitted_result is None: + if normalized_result is None: + raise TaskflowError( + f"task {task_id} result is missing; retry with the original result", + ) + if not meta.result_digest: + raise TaskflowError( + f"task {task_id} result identity is missing; cannot repair safely", + ) + store.write_task_result(task_id, normalized_result) + submitted_result = normalized_result + backfilled_identity = False + if not meta.result_digest: + meta.result_digest = _task_result_digest(submitted_result) + backfilled_identity = True + if not meta.continuation: + delivery_key = "\0".join( + ( + meta.project_id, + meta.task_id, + meta.submission_id, + "result-submitted:v1", + ), + ) + meta.continuation = { + "status": "pending", + "delivery_id": hashlib.sha256( + delivery_key.encode("utf-8"), + ).hexdigest(), + } + backfilled_identity = True + if backfilled_identity: + store.write_task_meta(meta) + return meta, True + if meta.status not in {"assigned", "in_progress"}: + raise TaskflowError( + f"submit_task cannot update task in status: {meta.status}", + ) if result is not None: store.write_task_result(task_id, result) else: store.read_task_result(task_id) + persisted_result = store.read_task_result(task_id) meta.status = "submitted" meta.submitted_at = _now() + meta.submission_id = str(uuid4()) + meta.result_digest = _task_result_digest(persisted_result) + delivery_key = "\0".join( + ( + meta.project_id, + meta.task_id, + meta.submission_id, + "result-submitted:v1", + ), + ) + meta.continuation = { + "status": "pending", + "delivery_id": hashlib.sha256(delivery_key.encode("utf-8")).hexdigest(), + } store.write_task_meta(meta) + return meta, False + + +def _adopt_legacy_submission( + store: TaskStore, + *, + meta: TaskMeta, + result: TaskResult | None, +) -> tuple[TaskMeta, bool]: + """Adopt one pre-identity submission using matching persisted evidence. + + The generated ``submission_id`` is deterministic for crash-safe retries, + but remains an opaque token to callers; its ``legacy-`` prefix is only a + diagnostic marker for migrated state. + """ + if not meta.submitted_at or result is None: + raise TaskflowError( + f"task {meta.task_id} submission identity is missing; " + "cannot reuse safely", + ) + normalized_result = parse_task_result(render_task_result(result)) + persisted_result = store.read_task_result(meta.task_id) + if normalized_result != persisted_result: + raise TaskflowError( + f"task {meta.task_id} submission identity is missing; " + "cannot reuse safely", + ) + + meta.result_digest = _task_result_digest(persisted_result) + adoption_key = "\0".join( + ( + meta.project_id, + meta.task_id, + meta.submitted_at, + meta.result_digest, + "legacy-adoption:v1", + ), + ) + meta.submission_id = "legacy-" + hashlib.sha256( + adoption_key.encode("utf-8"), + ).hexdigest() + delivery_key = "\0".join( + ( + meta.project_id, + meta.task_id, + meta.submission_id, + "result-submitted:v1", + ), + ) + meta.continuation = { + "status": "pending", + "delivery_id": hashlib.sha256(delivery_key.encode("utf-8")).hexdigest(), + } + store.write_task_meta(meta) + return meta, True + + +def _task_result_digest(result: TaskResult) -> str: + """Return the cross-runtime digest for one structured task result. + + The digest intentionally excludes runtime-specific rendering and notes. + Deliverables retain their validated order because their order can carry + meaning for consumers and must not be silently rewritten. + """ + canonical_result = { + "status": result.status.strip(), + "summary": _single_line(result.summary), + "deliverables": list(result.deliverables), + } + canonical_json = json.dumps( + canonical_result, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256( + b"teamharness.task-result.v1\0" + canonical_json, + ).hexdigest() + + +def accept_task_result( + store: TaskStore, + *, + project_id: str, + task_id: str, + accepted: bool, + submission_id: str | None = None, +) -> tuple[TaskMeta, bool]: + """Commit one Leader decision for a durable task submission.""" + with _task_mutation_lock(store, task_id): + meta, result = _validated_submitted_task( + store, + project_id=project_id, + task_id=task_id, + submission_id=submission_id, + ) + terminal_status = { + "SUCCESS": "completed" if accepted else "revision", + "SUCCESS_WITH_NOTES": "completed" if accepted else "revision", + "REVISION_NEEDED": "revision", + "BLOCKED": "blocked", + "INTERRUPTED": "blocked", + }[result.status] + return _commit_task_decision( + store, + meta=meta, + terminal_status=terminal_status, + ) + + +def cancel_task( + store: TaskStore, + *, + project_id: str, + task_id: str, + reason: str, + replacement_task_id: str | None = None, + submission_id: str | None = None, +) -> tuple[TaskMeta, bool]: + """Cancel one durable submitted task without reopening prior decisions.""" + normalized_replacement_task_id = ( + _safe_id(replacement_task_id) + if replacement_task_id is not None + else None + ) + with _task_mutation_lock(store, task_id): + meta = _validated_submission_identity( + store, + project_id=project_id, + task_id=task_id, + submission_id=submission_id, + ) + normalized_reason = _single_line(reason) + if not normalized_reason: + raise TaskflowError("cancel reason is required") + if meta.status == "cancelled": + if ( + meta.cancel_reason != normalized_reason + or meta.replacement_task_id != normalized_replacement_task_id + ): + raise TaskflowError( + f"task {task_id} already has conflicting cancellation", + ) + meta.cancel_reason = normalized_reason + meta.replacement_task_id = normalized_replacement_task_id + meta.cancelled_at = meta.cancelled_at or _now() + return _commit_task_decision( + store, + meta=meta, + terminal_status="cancelled", + ) + + +def _validated_submitted_task( + store: TaskStore, + *, + project_id: str, + task_id: str, + submission_id: str | None, +) -> tuple[TaskMeta, TaskResult]: + meta = _validated_submission_identity( + store, + project_id=project_id, + task_id=task_id, + submission_id=submission_id, + ) + if not meta.result_digest: + raise TaskflowError( + f"task {task_id} submission result digest is missing", + ) + result = store.read_task_result(task_id) + if _task_result_digest(result) != meta.result_digest: + raise TaskflowError( + f"task {task_id} persisted result does not match submitted digest", + ) + return meta, result + + +def _validated_submission_identity( + store: TaskStore, + *, + project_id: str, + task_id: str, + submission_id: str | None, +) -> TaskMeta: + """Validate the durable submission fence without reading result.md.""" + if not isinstance(submission_id, str) or not submission_id.strip(): + raise TaskflowError(f"submissionId is required for task {task_id}") + meta = store.read_task_meta(task_id) + if meta.project_id != project_id: + raise TaskflowError( + f"task {task_id} belongs to project {meta.project_id}, not {project_id}", + ) + plan = store.read_project_plan(project_id) + tasks = parse_loop_tasks(plan) if parse_plan_type(plan) == "loop" else parse_dag_tasks(plan) + _find_task(tasks, task_id) + if meta.status != "submitted" and meta.status not in TERMINAL_TASK_STATUSES: + raise TaskflowError( + f"task {task_id} has no submitted result: {meta.status}", + ) + if not meta.submission_id: + raise TaskflowError( + f"task {task_id} submission identity is incomplete", + ) + if submission_id != meta.submission_id: + raise TaskflowError(f"stale submissionId for task {task_id}") return meta +def _commit_task_decision( + store: TaskStore, + *, + meta: TaskMeta, + terminal_status: str, +) -> tuple[TaskMeta, bool]: + continuation = dict(meta.continuation or {}) + if meta.status in TERMINAL_TASK_STATUSES: + if ( + meta.status == terminal_status + and continuation.get("status") == "resolved" + and continuation.get("resolution") == terminal_status + ): + return meta, True + raise TaskflowError( + f"task {meta.task_id} already has conflicting decision: {meta.status}", + ) + if not continuation.get("delivery_id"): + raise TaskflowError( + f"task {meta.task_id} continuation delivery identity is missing", + ) + + plan = store.read_project_plan(meta.project_id) + plan_type = parse_plan_type(plan) + tasks = parse_loop_tasks(plan) if plan_type == "loop" else parse_dag_tasks(plan) + current = _find_task(tasks, meta.task_id) + if current.status in TERMINAL_TASK_STATUSES and current.status != terminal_status: + raise TaskflowError( + f"task {meta.task_id} already has conflicting decision: {current.status}", + ) + plan_is_terminal = current.status == terminal_status + if not plan_is_terminal: + updated = _replace_task_status(tasks, meta.task_id, terminal_status) + if plan_type == "loop": + loop = parse_loop_plan(plan) + if loop is None: + raise TaskflowError(f"project has no loop plan: {meta.project_id}") + updated_plan = replace_loop_plan( + plan, + LoopPlan( + goal=loop.goal, + stop_condition=loop.stop_condition, + iteration_template=loop.iteration_template, + max_iterations=loop.max_iterations, + current_iteration=loop.current_iteration, + status=loop.status, + tasks=updated, + history=loop.history, + ), + ) + else: + updated_plan = replace_dag_tasks(plan, updated) + store.write_project_plan(meta.project_id, updated_plan) + plan_is_terminal = True + + meta.status = terminal_status + continuation.update({ + "status": "resolved", + "resolution": terminal_status, + "resolved_at": continuation.get("resolved_at") or _now(), + }) + meta.continuation = continuation + try: + store.write_task_meta(meta) + except OSError as exc: + if plan_is_terminal: + raise TaskDecisionPersistenceError( + str(exc), + task=meta, + ) from exc + raise + return meta, False + + +def _task_mutation_lock(store: TaskStore, task_id: str) -> Any: + """Serialize one task's mutations inside a CoPaw process. + + Taskflow constructs a new FileSystemTaskStore for each tool call, so the + registry is keyed by the resolved meta.json path rather than store object. + Shared-storage replicas still need a remote compare-and-swap contract; this + lock only closes concurrent calls in one runtime process. + """ + if not isinstance(store, FileSystemTaskStore): + return _NoopLock() + key = str((store._task_dir(task_id) / "meta.json").resolve()) + with _TASK_MUTATION_LOCKS_GUARD: + return _TASK_MUTATION_LOCKS.setdefault(key, threading.RLock()) + + +class _NoopLock: + def __enter__(self) -> None: + return None + + def __exit__(self, *_args: Any) -> None: + return None + + def _require_assigned_worker(meta: TaskMeta, actor: str | None) -> None: current = canonical_worker_id(actor) if not current: @@ -969,7 +1445,7 @@ def validate_task_result(task_id: str, result: TaskResult) -> None: def _parse_dag_line(line: str) -> DagTask | None: match = re.match( - r"^\s*-\s+\[(?P[ x~!\u2192])\]\s+" + r"^\s*-\s+\[(?P[ x~!\-\u2192])\]\s+" r"(?P[A-Za-z0-9_-]+)\s+(?:\u2014|-)\s+" r"(?P.*?)(?:\s+\((?P<meta>.*)\))?\s*$", line, @@ -1138,8 +1614,33 @@ def _read_json(path: Path) -> dict[str, Any]: def _write_json(path: Path, data: dict[str, Any]) -> None: + _atomic_write_text( + path, + json.dumps(data, ensure_ascii=False, indent=2) + "\n", + ) + + +def _atomic_write_text(path: Path, content: str) -> None: + """Replace a state file only after its complete content reaches disk.""" path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n") + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as temporary_file: + temporary_path = Path(temporary_file.name) + temporary_file.write(content) + temporary_file.flush() + os.fsync(temporary_file.fileno()) + os.replace(temporary_path, path) + except BaseException: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + raise def _drop_none(data: dict[str, Any]) -> dict[str, Any]: diff --git a/copaw/tests/test_taskflow_tool.py b/copaw/tests/test_taskflow_tool.py index 8f58b3404..197293ca0 100644 --- a/copaw/tests/test_taskflow_tool.py +++ b/copaw/tests/test_taskflow_tool.py @@ -1,6 +1,10 @@ +from concurrent.futures import ThreadPoolExecutor import json +import os from pathlib import Path +import threading from unittest.mock import MagicMock +from uuid import UUID import pytest @@ -10,11 +14,16 @@ from copaw_worker.hooks.tools.taskflow import taskflow from copaw_worker.task import ( FileSystemTaskStore, + TaskMeta, + TaskResult, TaskflowError, + accept_task_result, add_tasks, + cancel_task, create_project, parse_dag_tasks, parse_loop_plan, + submit_task, ) @@ -35,6 +44,604 @@ def _mock_sync(monkeypatch) -> MagicMock: return mock +def _mock_project_sync(monkeypatch) -> MagicMock: + mock = MagicMock() + monkeypatch.setattr(projectflow_tool, "create_sync", lambda: mock) + return mock + + +def _write_submitted_task( + workspace: Path, + *, + project_id: str = "tp-decision", + task_id: str = "st-decision", + result_status: str = "SUCCESS", +) -> dict: + store = FileSystemTaskStore(workspace) + create_project(store, project_id=project_id, title="Decision project") + from copaw_worker.task import plan_dag + plan_dag( + store, + project_id=project_id, + tasks=[{ + "taskId": task_id, + "title": "Decide this result", + "assignedTo": "@worker:domain", + "dependsOn": [], + }], + ) + task_dir = workspace / "shared" / "tasks" / task_id + task_dir.mkdir(parents=True) + store.write_task_meta(TaskMeta( + task_id=task_id, + project_id=project_id, + task_title="Decide this result", + assigned_to="@worker:domain", + room_id="room:!team:domain", + status="in_progress", + )) + submitted = submit_task( + store, + task_id=task_id, + actor="@worker:domain", + result=TaskResult(status=result_status, summary="Worker result."), + ) + return submitted.__dict__ + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action", ["accept_task_result", "cancel_task"]) +@pytest.mark.parametrize("dry_run", [False, True]) +async def test_projectflow_decision_requires_submission_id_before_side_effects( + tmp_path, + monkeypatch, + action, + dry_run, +): + leader_dir = tmp_path / "leader" + working_dir = leader_dir / ".copaw" + workspace = working_dir / "workspaces/default" + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + _write_team_leader_runtime_config(leader_dir) + _set_actor(monkeypatch, "@leader:domain") + sync = _mock_project_sync(monkeypatch) + _write_submitted_task(workspace) + task_meta_path = workspace / "shared/tasks/st-decision/meta.json" + result_path = workspace / "shared/tasks/st-decision/result.md" + plan_path = workspace / "shared/projects/tp-decision/plan.md" + before = { + task_meta_path: task_meta_path.read_bytes(), + result_path: result_path.read_bytes(), + plan_path: plan_path.read_bytes(), + } + payload = { + "projectId": "tp-decision", + "taskId": "st-decision", + **( + {"accepted": True} + if action == "accept_task_result" + else {"reason": "No longer needed."} + ), + } + + response = _response_json(await projectflow( + action=action, + payload=payload, + dryRun=dry_run, + )) + + assert response["ok"] is False + assert "payload.submissionId is required" in response["error"] + assert {path: path.read_bytes() for path in before} == before + assert sync.method_calls == [] + + +@pytest.mark.parametrize("operation", ["accept", "cancel"]) +def test_domain_decision_requires_submission_id_before_state_write( + tmp_path, + operation, +): + submitted = _write_submitted_task(tmp_path) + store = FileSystemTaskStore(tmp_path) + task_meta_path = tmp_path / "shared/tasks/st-decision/meta.json" + result_path = tmp_path / "shared/tasks/st-decision/result.md" + plan_path = tmp_path / "shared/projects/tp-decision/plan.md" + before = { + task_meta_path: task_meta_path.read_bytes(), + result_path: result_path.read_bytes(), + plan_path: plan_path.read_bytes(), + } + + with pytest.raises(TaskflowError, match="submissionId is required"): + if operation == "accept": + accept_task_result( + store, + project_id="tp-decision", + task_id="st-decision", + accepted=True, + submission_id=None, + ) + else: + cancel_task( + store, + project_id="tp-decision", + task_id="st-decision", + reason="No longer needed.", + submission_id=None, + ) + + assert {path: path.read_bytes() for path in before} == before + assert submitted["submission_id"] + + +@pytest.mark.asyncio +async def test_leader_accepts_submitted_result_and_resolves_continuation( + tmp_path, + monkeypatch, +): + leader_dir = tmp_path / "leader" + working_dir = leader_dir / ".copaw" + workspace = working_dir / "workspaces" / "default" + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + _write_team_leader_runtime_config(leader_dir) + _set_actor(monkeypatch, "@leader:domain") + sync = _mock_project_sync(monkeypatch) + submitted = _write_submitted_task(workspace) + + response = await projectflow( + action="accept_task_result", + payload={ + "projectId": "tp-decision", + "taskId": "st-decision", + "submissionId": submitted["submission_id"], + "accepted": True, + }, + ) + payload = _response_json(response) + + assert payload["ok"] is True + assert payload["task"]["status"] == "completed" + continuation = payload["task"]["continuation"] + assert continuation["delivery_id"] == submitted["continuation"]["delivery_id"] + assert continuation["status"] == "resolved" + assert continuation["resolution"] == "completed" + assert continuation["resolved_at"] + plan = (workspace / "shared/projects/tp-decision/plan.md").read_text() + assert "- [x] st-decision" in plan + assert sync.push_shared_path.call_args_list == [ + (("shared/projects/tp-decision/",), {}), + (("shared/tasks/st-decision/",), {"exclude": ["spec.md", "base/"]}), + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("result_status", "accepted", "expected_status"), + [ + ("SUCCESS_WITH_NOTES", True, "completed"), + ("REVISION_NEEDED", True, "revision"), + ("BLOCKED", True, "blocked"), + ("INTERRUPTED", True, "blocked"), + ("SUCCESS", False, "revision"), + ("SUCCESS_WITH_NOTES", False, "revision"), + ("REVISION_NEEDED", False, "revision"), + ("BLOCKED", False, "blocked"), + ("INTERRUPTED", False, "blocked"), + ], +) +async def test_leader_decision_maps_result_status_to_terminal_state( + tmp_path, + monkeypatch, + result_status, + accepted, + expected_status, +): + leader_dir = tmp_path / "leader" + working_dir = leader_dir / ".copaw" + workspace = working_dir / "workspaces/default" + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + _write_team_leader_runtime_config(leader_dir) + _set_actor(monkeypatch, "@leader:domain") + _mock_project_sync(monkeypatch) + submitted = _write_submitted_task(workspace, result_status=result_status) + + payload = _response_json(await projectflow( + action="accept_task_result", + payload={ + "projectId": "tp-decision", + "taskId": "st-decision", + "submissionId": submitted["submission_id"], + "accepted": accepted, + }, + )) + + assert payload["ok"] is True + assert payload["task"]["status"] == expected_status + assert payload["task"]["continuation"]["resolution"] == expected_status + + +@pytest.mark.asyncio +async def test_leader_cancels_submitted_task_and_retry_is_idempotent(tmp_path, monkeypatch): + leader_dir = tmp_path / "leader" + working_dir = leader_dir / ".copaw" + workspace = working_dir / "workspaces/default" + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + _write_team_leader_runtime_config(leader_dir) + _set_actor(monkeypatch, "@leader:domain") + _mock_project_sync(monkeypatch) + submitted = _write_submitted_task(workspace) + request = { + "projectId": "tp-decision", + "taskId": "st-decision", + "submissionId": submitted["submission_id"], + "reason": "No longer needed.", + "replacementTaskId": "st-replacement", + } + + first = _response_json(await projectflow(action="cancel_task", payload=request)) + second = _response_json(await projectflow(action="cancel_task", payload=request)) + + assert first["ok"] is True + assert first["reused"] is False + assert first["task"]["status"] == "cancelled" + assert first["task"]["continuation"]["resolution"] == "cancelled" + assert first["task"]["cancel_reason"] == "No longer needed." + assert first["task"]["replacement_task_id"] == "st-replacement" + assert first["task"]["cancelled_at"] + assert second["ok"] is True + assert second["reused"] is True + assert second["task"] == first["task"] + + meta_path = workspace / "shared/tasks/st-decision/meta.json" + original_bytes = meta_path.read_bytes() + conflict = _response_json(await projectflow( + action="cancel_task", + payload={**request, "reason": "Different reason."}, + )) + assert conflict["ok"] is False + assert "conflicting cancellation" in conflict["error"] + assert meta_path.read_bytes() == original_bytes + + +@pytest.mark.asyncio +@pytest.mark.parametrize("result_artifact", ["missing", "tampered"]) +async def test_cancel_task_does_not_depend_on_result_artifact_integrity( + tmp_path, + monkeypatch, + result_artifact, +): + leader_dir = tmp_path / "leader" + working_dir = leader_dir / ".copaw" + workspace = working_dir / "workspaces/default" + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + _write_team_leader_runtime_config(leader_dir) + _set_actor(monkeypatch, "@leader:domain") + _mock_project_sync(monkeypatch) + submitted = _write_submitted_task(workspace) + result_path = workspace / "shared/tasks/st-decision/result.md" + if result_artifact == "missing": + result_path.unlink() + else: + result_path.write_text( + "STATUS: SUCCESS\nSUMMARY: Tampered after submission.\n\nDELIVERABLES:\n", + ) + + response = _response_json(await projectflow( + action="cancel_task", + payload={ + "projectId": "tp-decision", + "taskId": "st-decision", + "submissionId": submitted["submission_id"], + "reason": "The work is no longer required.", + }, + )) + + assert response["ok"] is True + assert response["task"]["status"] == "cancelled" + assert response["task"]["continuation"]["status"] == "resolved" + assert response["task"]["continuation"]["resolution"] == "cancelled" + plan = (workspace / "shared/projects/tp-decision/plan.md").read_text() + assert "- [-] st-decision" in plan + + +@pytest.mark.asyncio +async def test_cancel_task_still_rejects_stale_submission_when_result_is_missing( + tmp_path, + monkeypatch, +): + leader_dir = tmp_path / "leader" + working_dir = leader_dir / ".copaw" + workspace = working_dir / "workspaces/default" + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + _write_team_leader_runtime_config(leader_dir) + _set_actor(monkeypatch, "@leader:domain") + sync = _mock_project_sync(monkeypatch) + _write_submitted_task(workspace) + (workspace / "shared/tasks/st-decision/result.md").unlink() + + response = _response_json(await projectflow( + action="cancel_task", + payload={ + "projectId": "tp-decision", + "taskId": "st-decision", + "submissionId": "stale-submission", + "reason": "The work is no longer required.", + }, + )) + + assert response["ok"] is False + assert "stale submissionId" in response["error"] + persisted = json.loads( + (workspace / "shared/tasks/st-decision/meta.json").read_text(), + ) + assert persisted["status"] == "submitted" + plan = (workspace / "shared/projects/tp-decision/plan.md").read_text() + assert "- [ ] st-decision" in plan + assert sync.method_calls == [] + + +@pytest.mark.asyncio +async def test_cancel_task_tool_requires_reason(tmp_path, monkeypatch): + leader_dir = tmp_path / "leader" + working_dir = leader_dir / ".copaw" + workspace = working_dir / "workspaces/default" + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + _write_team_leader_runtime_config(leader_dir) + _set_actor(monkeypatch, "@leader:domain") + _mock_project_sync(monkeypatch) + submitted = _write_submitted_task(workspace) + + response = _response_json(await projectflow( + action="cancel_task", + payload={ + "projectId": "tp-decision", + "taskId": "st-decision", + "submissionId": submitted["submission_id"], + }, + )) + + assert response["ok"] is False + assert "payload.reason is required" in response["error"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "replacement_task_id", + ["../st-next", "", " ", "st next", "st/next", r"st\next"], +) +async def test_cancel_task_rejects_unsafe_replacement_id_without_side_effects( + tmp_path, + monkeypatch, + replacement_task_id, +): + leader_dir = tmp_path / "leader" + working_dir = leader_dir / ".copaw" + workspace = working_dir / "workspaces/default" + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + _write_team_leader_runtime_config(leader_dir) + _set_actor(monkeypatch, "@leader:domain") + sync = _mock_project_sync(monkeypatch) + submitted = _write_submitted_task(workspace) + + plan_path = workspace / "shared/projects/tp-decision/plan.md" + meta_path = workspace / "shared/tasks/st-decision/meta.json" + result_path = workspace / "shared/tasks/st-decision/result.md" + original_plan = plan_path.read_bytes() + original_meta = meta_path.read_bytes() + original_result = result_path.read_bytes() + + response = _response_json(await projectflow( + action="cancel_task", + payload={ + "projectId": "tp-decision", + "taskId": "st-decision", + "submissionId": submitted["submission_id"], + "reason": "Replace the task safely.", + "replacementTaskId": replacement_task_id, + }, + )) + + assert response["ok"] is False + assert "invalid id" in response["error"] + assert plan_path.read_bytes() == original_plan + assert meta_path.read_bytes() == original_meta + assert result_path.read_bytes() == original_result + assert sync.method_calls == [] + + +@pytest.mark.asyncio +async def test_accept_task_result_retry_is_idempotent_but_conflicting_decision_is_rejected( + tmp_path, + monkeypatch, +): + leader_dir = tmp_path / "leader" + working_dir = leader_dir / ".copaw" + workspace = working_dir / "workspaces/default" + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + _write_team_leader_runtime_config(leader_dir) + _set_actor(monkeypatch, "@leader:domain") + _mock_project_sync(monkeypatch) + submitted = _write_submitted_task(workspace) + request = { + "projectId": "tp-decision", + "taskId": "st-decision", + "submissionId": submitted["submission_id"], + "accepted": True, + } + + first = _response_json(await projectflow(action="accept_task_result", payload=request)) + retry = _response_json(await projectflow(action="accept_task_result", payload=request)) + conflict = _response_json(await projectflow( + action="accept_task_result", + payload={**request, "accepted": False}, + )) + + assert first["ok"] is True and first["reused"] is False + assert retry["ok"] is True and retry["reused"] is True + assert retry["task"] == first["task"] + assert conflict["ok"] is False + assert "conflicting decision" in conflict["error"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("case", ["stale", "missing", "tampered", "wrong_project"]) +async def test_accept_task_result_rejects_invalid_submission_evidence( + tmp_path, + monkeypatch, + case, +): + leader_dir = tmp_path / "leader" + working_dir = leader_dir / ".copaw" + workspace = working_dir / "workspaces/default" + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + _write_team_leader_runtime_config(leader_dir) + _set_actor(monkeypatch, "@leader:domain") + sync = _mock_project_sync(monkeypatch) + submitted = _write_submitted_task(workspace) + request = { + "projectId": "tp-decision", + "taskId": "st-decision", + "submissionId": submitted["submission_id"], + "accepted": True, + } + if case == "stale": + request["submissionId"] = "stale-submission" + elif case == "missing": + (workspace / "shared/tasks/st-decision/result.md").unlink() + elif case == "tampered": + (workspace / "shared/tasks/st-decision/result.md").write_text( + "STATUS: SUCCESS\nSUMMARY: Tampered.\n\nDELIVERABLES:\n", + ) + else: + request["projectId"] = "tp-other" + + response = _response_json(await projectflow(action="accept_task_result", payload=request)) + + assert response["ok"] is False + assert response["error"] + assert sync.push_shared_path.call_count == 0 + meta = json.loads((workspace / "shared/tasks/st-decision/meta.json").read_text()) + assert meta["status"] == "submitted" + + +@pytest.mark.asyncio +async def test_task_result_decision_requires_team_leader_role(tmp_path, monkeypatch): + worker_dir = tmp_path / "worker" + working_dir = worker_dir / ".copaw" + workspace = working_dir / "workspaces/default" + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + runtime_dir = worker_dir / "runtime" + runtime_dir.mkdir(parents=True) + (runtime_dir / "runtime.yaml").write_text("member:\n role: worker\n") + _set_actor(monkeypatch, "@worker:domain") + _mock_project_sync(monkeypatch) + submitted = _write_submitted_task(workspace) + + response = _response_json(await projectflow( + action="accept_task_result", + payload={ + "projectId": "tp-decision", + "taskId": "st-decision", + "submissionId": submitted["submission_id"], + "accepted": True, + }, + )) + + assert response["ok"] is False + assert "requires team_leader role" in response["error"] + + +@pytest.mark.asyncio +async def test_accept_task_result_sync_failure_returns_retryable_persisted_state( + tmp_path, + monkeypatch, +): + leader_dir = tmp_path / "leader" + working_dir = leader_dir / ".copaw" + workspace = working_dir / "workspaces/default" + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + _write_team_leader_runtime_config(leader_dir) + _set_actor(monkeypatch, "@leader:domain") + sync = _mock_project_sync(monkeypatch) + sync.push_shared_path.side_effect = RuntimeError("remote unavailable") + submitted = _write_submitted_task(workspace) + + response = _response_json(await projectflow( + action="accept_task_result", + payload={ + "projectId": "tp-decision", + "taskId": "st-decision", + "submissionId": submitted["submission_id"], + "accepted": True, + }, + )) + + assert response["ok"] is False + assert response["retryable"] is True + assert response["statePersisted"] is True + assert response["synced"] is False + assert response["task"]["status"] == "completed" + persisted = json.loads((workspace / "shared/tasks/st-decision/meta.json").read_text()) + assert persisted["status"] == "completed" + + +@pytest.mark.asyncio +async def test_project_plan_terminal_fences_opposite_retry_after_task_meta_write_failure( + tmp_path, + monkeypatch, +): + leader_dir = tmp_path / "leader" + working_dir = leader_dir / ".copaw" + workspace = working_dir / "workspaces/default" + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + _write_team_leader_runtime_config(leader_dir) + _set_actor(monkeypatch, "@leader:domain") + _mock_project_sync(monkeypatch) + submitted = _write_submitted_task(workspace) + original_write = FileSystemTaskStore.write_task_meta + failures = {"remaining": 1} + + def fail_terminal_task_meta_once(store, meta): + if meta.status == "completed" and failures["remaining"]: + failures["remaining"] -= 1 + raise OSError("simulated task-meta write failure") + return original_write(store, meta) + + monkeypatch.setattr(FileSystemTaskStore, "write_task_meta", fail_terminal_task_meta_once) + request = { + "projectId": "tp-decision", + "taskId": "st-decision", + "submissionId": submitted["submission_id"], + "accepted": True, + } + + first = _response_json(await projectflow(action="accept_task_result", payload=request)) + plan_after_failure = (workspace / "shared/projects/tp-decision/plan.md").read_text() + meta_after_failure = json.loads( + (workspace / "shared/tasks/st-decision/meta.json").read_text(), + ) + opposite = _response_json(await projectflow( + action="accept_task_result", + payload={**request, "accepted": False}, + )) + same_retry = _response_json(await projectflow(action="accept_task_result", payload=request)) + + assert first["ok"] is False + assert "simulated task-meta write failure" in first["error"] + assert first["retryable"] is True + assert first["statePersisted"] is True + assert first["synced"] is False + assert first["task"]["status"] == "completed" + assert "- [x] st-decision" in plan_after_failure + assert meta_after_failure["status"] == "submitted" + assert opposite["ok"] is False + assert "conflicting decision" in opposite["error"] + assert (workspace / "shared/projects/tp-decision/plan.md").read_text() == plan_after_failure + assert same_retry["ok"] is True + assert same_retry["task"]["status"] == "completed" + assert same_retry["task"]["continuation"]["resolution"] == "completed" + + def _mock_notify(monkeypatch) -> None: """Patch _notify_task_assignment to return a success result.""" @@ -511,131 +1118,831 @@ async def test_delegate_task_requires_room_id(tmp_path, monkeypatch): ) payload = _response_json(response) - assert payload["ok"] is False - assert payload["error"] == "payload.roomId is required" + assert payload["ok"] is False + assert payload["error"] == "payload.roomId is required" + + +@pytest.mark.asyncio +async def test_delegate_task_rejects_team_leader_dm_room(tmp_path, monkeypatch): + leader_dir = tmp_path / "leader" + working_dir = leader_dir / ".copaw" + _write_team_leader_runtime_config(leader_dir) + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + _set_actor(monkeypatch, "@leader:domain") + + assert _response_json( + await projectflow( + action="create_project", + payload={"projectId": "tp-team-room", "title": "Team room project"}, + ) + )["ok"] is True + assert _response_json( + await projectflow( + action="plan_dag", + payload={ + "projectId": "tp-team-room", + "tasks": [ + { + "taskId": "tp-team-room-01", + "title": "Team task", + "assignedTo": "@worker:domain", + "dependsOn": [], + } + ], + }, + ) + )["ok"] is True + + response = await taskflow( + action="delegate_task", + payload={ + "projectId": "tp-team-room", + "taskId": "tp-team-room-01", + "roomId": "room:!leader-dm:domain", + "spec": "Do work.", + }, + ) + payload = _response_json(response) + + assert payload["ok"] is False + assert "must use the Team Room room:!team:domain" in payload["error"] + + +@pytest.mark.asyncio +async def test_delegate_task_accepts_team_leader_team_room(tmp_path, monkeypatch): + leader_dir = tmp_path / "leader" + working_dir = leader_dir / ".copaw" + _write_team_leader_runtime_config(leader_dir) + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + _set_actor(monkeypatch, "@leader:domain") + _mock_sync(monkeypatch) + _mock_notify(monkeypatch) + + assert _response_json( + await projectflow( + action="create_project", + payload={"projectId": "tp-team-ok", "title": "Team room project"}, + ) + )["ok"] is True + assert _response_json( + await projectflow( + action="plan_dag", + payload={ + "projectId": "tp-team-ok", + "tasks": [ + { + "taskId": "tp-team-ok-01", + "title": "Team task", + "assignedTo": "@worker:domain", + "dependsOn": [], + } + ], + }, + ) + )["ok"] is True + + response = await taskflow( + action="delegate_task", + payload={ + "projectId": "tp-team-ok", + "taskId": "tp-team-ok-01", + "roomId": "room:!team:domain", + "spec": "Do work.", + }, + ) + payload = _response_json(response) + + assert payload["ok"] is True + assert payload["task"]["room_id"] == "room:!team:domain" + # Auto-notification path (PR #1095): delegate_task sends the Matrix + # notification itself with a stable txn_id, then records event_id and + # marks the task assigned. No notificationRequired/nextAction handoff. + assert payload["notification"] == { + "sent": True, + "eventId": "$fake-event-id", + "roomId": "room:!team:domain", + "assignee": "@worker:domain", + } + assert payload["task"]["status"] == "assigned" + assert payload["task"]["event_id"] == "$fake-event-id" + + +@pytest.mark.asyncio +async def test_submit_task_writes_structured_result(tmp_path, monkeypatch): + working_dir = tmp_path / "worker" / ".copaw" + workspace = working_dir / "workspaces" / "default" + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + _set_actor(monkeypatch, "@worker:domain") + _mock_sync(monkeypatch) + + task_dir = workspace / "shared" / "tasks" / "st-01" + task_dir.mkdir(parents=True) + (task_dir / "meta.json").write_text( + json.dumps( + { + "task_id": "st-01", + "project_id": "tp-01", + "task_title": "Task", + "assigned_to": "@worker:domain", + "room_id": "room:!team-room:domain", + "status": "in_progress", + "depends_on": [], + }, + ), + ) + + response = await taskflow( + action="submit_task", + payload={ + "taskId": "st-01", + "status": "SUCCESS", + "summary": "API design completed.", + "deliverables": [ + "shared/tasks/st-01/workspace/api-design.md", + ], + }, + ) + payload = _response_json(response) + + assert payload["ok"] is True + assert payload["task"]["status"] == "submitted" + assert payload["result"] == { + "status": "SUCCESS", + "summary": "API design completed.", + "deliverables": ["shared/tasks/st-01/workspace/api-design.md"], + "notes": [], + } + assert (task_dir / "result.md").read_text() == ( + "STATUS: SUCCESS\n" + "SUMMARY: API design completed.\n\n" + "DELIVERABLES:\n" + "- shared/tasks/st-01/workspace/api-design.md\n" + ) + + +@pytest.mark.asyncio +async def test_submit_task_reuses_identity_for_same_result(tmp_path, monkeypatch): + working_dir = tmp_path / "worker" / ".copaw" + workspace = working_dir / "workspaces" / "default" + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + _set_actor(monkeypatch, "@worker:domain") + _mock_sync(monkeypatch) + + task_dir = workspace / "shared" / "tasks" / "st-01" + task_dir.mkdir(parents=True) + (task_dir / "meta.json").write_text( + json.dumps( + { + "task_id": "st-01", + "project_id": "tp-01", + "task_title": "Task", + "assigned_to": "@worker:domain", + "room_id": "room:!team-room:domain", + "status": "in_progress", + "depends_on": [], + }, + ), + ) + request = { + "taskId": "st-01", + "status": "SUCCESS", + "summary": "API design\ncompleted.", + "deliverables": ["shared/tasks/st-01/workspace/api-design.md"], + } + + first = _response_json(await taskflow(action="submit_task", payload=request)) + first_meta = (task_dir / "meta.json").read_bytes() + first_result = (task_dir / "result.md").read_bytes() + second = _response_json(await taskflow(action="submit_task", payload=request)) + + assert first["ok"] is True + assert first["reused"] is False + UUID(first["task"]["submission_id"]) + assert first["task"]["submitted_at"] + continuation = first["task"]["continuation"] + assert continuation["status"] == "pending" + assert len(continuation["delivery_id"]) == 64 + int(continuation["delivery_id"], 16) + assert second["ok"] is True + assert second["reused"] is True + assert second["task"]["submission_id"] == first["task"]["submission_id"] + assert second["task"]["submitted_at"] == first["task"]["submitted_at"] + assert second["task"]["continuation"] == continuation + assert (task_dir / "meta.json").read_bytes() == first_meta + assert (task_dir / "result.md").read_bytes() == first_result + + +def test_submit_task_domain_api_still_returns_task_meta(tmp_path): + store = FileSystemTaskStore(tmp_path) + task_dir = tmp_path / "shared" / "tasks" / "st-01" + task_dir.mkdir(parents=True) + (task_dir / "meta.json").write_text( + json.dumps( + { + "task_id": "st-01", + "project_id": "tp-01", + "task_title": "Task", + "assigned_to": "worker", + "room_id": "room:!team-room:domain", + "status": "in_progress", + "depends_on": [], + }, + ), + ) + + meta = submit_task( + store, + task_id="st-01", + actor="worker", + result=TaskResult(status="SUCCESS", summary="Done."), + ) + + assert isinstance(meta, TaskMeta) + assert meta.status == "submitted" + + +def test_task_meta_legacy_positional_event_id_round_trips(tmp_path): + """The eleventh positional argument remains the legacy Matrix event ID.""" + meta = TaskMeta( + "st-positional", + "tp-positional", + "Preserve positional API", + "worker-a", + "room:!team-room:domain", + "assigned", + ["st-prerequisite"], + "2026-08-13T01:00:00Z", + "2026-08-13T01:01:00Z", + "2026-08-13T01:02:00Z", + "$legacy-event-id", + ) + + assert meta.event_id == "$legacy-event-id" + assert meta.submission_id is None + assert meta.result_digest is None + assert meta.continuation is None + + store = FileSystemTaskStore(tmp_path) + store.write_task_meta(meta) + + assert store.read_task_meta("st-positional") == meta + + +def test_submit_task_fails_closed_for_legacy_submission_without_identity( + tmp_path, +): + store = FileSystemTaskStore(tmp_path) + task_dir = tmp_path / "shared" / "tasks" / "st-legacy-submitted" + task_dir.mkdir(parents=True) + meta_path = task_dir / "meta.json" + result_path = task_dir / "result.md" + meta_path.write_text( + json.dumps( + { + "task_id": "st-legacy-submitted", + "project_id": "tp-01", + "task_title": "Legacy submitted task", + "assigned_to": "worker", + "room_id": "room:!team-room:domain", + "status": "submitted", + "submitted_at": "2026-08-13T01:02:00Z", + }, + ), + ) + result_path.write_text( + "STATUS: SUCCESS\nSUMMARY: Legacy persisted result.\n\nDELIVERABLES:\n", + ) + original_meta = meta_path.read_bytes() + original_result = result_path.read_bytes() + + with pytest.raises( + TaskflowError, + match="submission identity is missing; cannot reuse safely", + ): + submit_task( + store, + task_id="st-legacy-submitted", + actor="worker", + result=TaskResult(status="SUCCESS", summary="Conflicting retry result."), + ) + + assert meta_path.read_bytes() == original_meta + assert result_path.read_bytes() == original_result + + +def test_submit_task_adopts_matching_legacy_submission_deterministically(tmp_path): + store = FileSystemTaskStore(tmp_path) + task_dir = tmp_path / "shared" / "tasks" / "st-legacy-adopt" + task_dir.mkdir(parents=True) + meta_path = task_dir / "meta.json" + submitted_at = "2026-08-13T01:02:00Z" + meta_path.write_text( + json.dumps( + { + "task_id": "st-legacy-adopt", + "project_id": "tp-01", + "task_title": "Adopt legacy submitted task", + "assigned_to": "worker", + "room_id": "room:!team-room:domain", + "status": "submitted", + "submitted_at": submitted_at, + }, + ), + ) + persisted_result = TaskResult( + status="SUCCESS", + summary="Legacy persisted result.", + ) + store.write_task_result("st-legacy-adopt", persisted_result) + + adopted = submit_task( + store, + task_id="st-legacy-adopt", + actor="worker", + result=persisted_result, + ) + + assert adopted.submission_id == ( + "legacy-3aad8de167e5f9132fcb4cbef84c967b2efc476b028495b19c7a55e3ba7b0cd5" + ) + assert adopted.submitted_at == submitted_at + assert adopted.result_digest + assert adopted.continuation + assert adopted.continuation["status"] == "pending" + assert store.read_task_meta("st-legacy-adopt") == adopted + + +def test_submit_task_cannot_adopt_legacy_submission_without_timestamp(tmp_path): + store = FileSystemTaskStore(tmp_path) + task_dir = tmp_path / "shared" / "tasks" / "st-legacy-no-time" + task_dir.mkdir(parents=True) + meta_path = task_dir / "meta.json" + result_path = task_dir / "result.md" + meta_path.write_text( + json.dumps( + { + "task_id": "st-legacy-no-time", + "project_id": "tp-01", + "task_title": "Legacy task without timestamp", + "assigned_to": "worker", + "room_id": "room:!team-room:domain", + "status": "submitted", + }, + ), + ) + result = TaskResult(status="SUCCESS", summary="Legacy persisted result.") + store.write_task_result("st-legacy-no-time", result) + original_meta = meta_path.read_bytes() + original_result = result_path.read_bytes() + + with pytest.raises( + TaskflowError, + match="submission identity is missing; cannot reuse safely", + ): + submit_task( + store, + task_id="st-legacy-no-time", + actor="worker", + result=result, + ) + + assert meta_path.read_bytes() == original_meta + assert result_path.read_bytes() == original_result + + +def test_submit_task_cannot_adopt_legacy_submission_without_explicit_result(tmp_path): + store = FileSystemTaskStore(tmp_path) + task_dir = tmp_path / "shared" / "tasks" / "st-legacy-no-request-result" + task_dir.mkdir(parents=True) + meta_path = task_dir / "meta.json" + result_path = task_dir / "result.md" + meta_path.write_text( + json.dumps( + { + "task_id": "st-legacy-no-request-result", + "project_id": "tp-01", + "task_title": "Legacy task without retry evidence", + "assigned_to": "worker", + "room_id": "room:!team-room:domain", + "status": "submitted", + "submitted_at": "2026-08-13T01:02:00Z", + }, + ), + ) + store.write_task_result( + "st-legacy-no-request-result", + TaskResult(status="SUCCESS", summary="Legacy persisted result."), + ) + original_meta = meta_path.read_bytes() + original_result = result_path.read_bytes() + + with pytest.raises( + TaskflowError, + match="submission identity is missing; cannot reuse safely", + ): + submit_task( + store, + task_id="st-legacy-no-request-result", + actor="worker", + ) + + assert meta_path.read_bytes() == original_meta + assert result_path.read_bytes() == original_result + + +def test_legacy_adoption_write_failure_is_atomic_and_retry_identity_is_stable( + tmp_path, + monkeypatch, +): + store = FileSystemTaskStore(tmp_path) + task_dir = tmp_path / "shared" / "tasks" / "st-legacy-retry" + task_dir.mkdir(parents=True) + meta_path = task_dir / "meta.json" + meta_path.write_text( + json.dumps( + { + "task_id": "st-legacy-retry", + "project_id": "tp-01", + "task_title": "Crash-safe legacy adoption", + "assigned_to": "worker", + "room_id": "room:!team-room:domain", + "status": "submitted", + "submitted_at": "2026-08-13T01:02:00Z", + }, + ), + ) + result = TaskResult(status="SUCCESS", summary="Legacy persisted result.") + store.write_task_result("st-legacy-retry", result) + original_meta = meta_path.read_bytes() + real_fsync = os.fsync + + def fail_fsync(_fd): + raise OSError("simulated adoption flush failure") + + monkeypatch.setattr(os, "fsync", fail_fsync) + with pytest.raises(OSError, match="simulated adoption flush failure"): + submit_task( + store, + task_id="st-legacy-retry", + actor="worker", + result=result, + ) + + assert meta_path.read_bytes() == original_meta + assert [path.name for path in task_dir.iterdir()] == ["meta.json", "result.md"] + + monkeypatch.setattr(os, "fsync", real_fsync) + adopted = submit_task( + store, + task_id="st-legacy-retry", + actor="worker", + result=result, + ) + persisted = store.read_task_meta("st-legacy-retry") + + assert adopted.submission_id == persisted.submission_id + assert adopted.submission_id == ( + "legacy-b81af822e4be5b2d31dd64df29c7bcefbf780b5aac0aad87c49d8c13753bcf22" + ) + assert adopted.continuation == persisted.continuation + + +def test_submit_task_backfills_result_identity_without_rotating_submission_id(tmp_path): + store = FileSystemTaskStore(tmp_path) + task_dir = tmp_path / "shared" / "tasks" / "st-backfill" + task_dir.mkdir(parents=True) + meta_path = task_dir / "meta.json" + original_submission_id = "a73c43fd-1dda-4d42-88dc-18e598bed353" + submitted_at = "2026-08-13T01:02:00Z" + meta_path.write_text( + json.dumps( + { + "task_id": "st-backfill", + "project_id": "tp-01", + "task_title": "Backfill submitted task", + "assigned_to": "worker", + "room_id": "room:!team-room:domain", + "status": "submitted", + "submitted_at": submitted_at, + "submission_id": original_submission_id, + }, + ), + ) + persisted_result = TaskResult( + status="SUCCESS", + summary="Legacy persisted result.", + ) + store.write_task_result("st-backfill", persisted_result) + + meta = submit_task( + store, + task_id="st-backfill", + actor="worker", + result=persisted_result, + ) + + assert meta.submission_id == original_submission_id + assert meta.submitted_at == submitted_at + assert meta.result_digest + assert meta.continuation == { + "status": "pending", + "delivery_id": ( + "68f56bfa3f68393588c6507ddaa4c65c259572e8ab01ff2db8757fea71be6f19" + ), + } + assert store.read_task_meta("st-backfill") == meta + + +def test_submit_task_uses_cross_runtime_canonical_result_digest(tmp_path): + store = FileSystemTaskStore(tmp_path) + task_dir = tmp_path / "shared" / "tasks" / "st-digest" + task_dir.mkdir(parents=True) + (task_dir / "meta.json").write_text( + json.dumps( + { + "task_id": "st-digest", + "project_id": "tp-01", + "task_title": "Canonical result identity", + "assigned_to": "worker", + "room_id": "room:!team-room:domain", + "status": "in_progress", + "depends_on": [], + }, + ), + ) + + meta = submit_task( + store, + task_id="st-digest", + actor="worker", + result=TaskResult( + status="SUCCESS", + summary=" 完成\n API\t设计 ", + deliverables=[ + "shared/tasks/st-digest/workspace/b.md", + "shared/tasks/st-digest/workspace/a.md", + ], + # Notes are runtime prose and deliberately excluded from the + # shared structured-result identity. + notes=["这段文字不应改变摘要。"], + ), + ) + + assert meta.result_digest == ( + "cb1daffd3cf60982383e60cf0a09a719abb2a2bf378471a494cebad0bf1fbec7" + ) + + +def test_two_different_concurrent_submissions_cannot_both_succeed(tmp_path): + class ConcurrentReadStore(FileSystemTaskStore): + def __init__(self, workspace_dir): + super().__init__(workspace_dir) + self.first_reads = threading.Barrier(2) + + def read_task_meta(self, task_id): + meta = super().read_task_meta(task_id) + if meta.status == "in_progress": + try: + self.first_reads.wait(timeout=0.25) + except threading.BrokenBarrierError: + pass + return meta + + store = ConcurrentReadStore(tmp_path) + task_dir = tmp_path / "shared" / "tasks" / "st-race" + task_dir.mkdir(parents=True) + (task_dir / "meta.json").write_text( + json.dumps( + { + "task_id": "st-race", + "project_id": "tp-01", + "task_title": "Racing task", + "assigned_to": "worker", + "room_id": "room:!team-room:domain", + "status": "in_progress", + "depends_on": [], + }, + ), + ) + results = ( + TaskResult(status="SUCCESS", summary="First competing result."), + TaskResult(status="SUCCESS", summary="Second competing result."), + ) + + def attempt(result): + try: + return submit_task( + store, + task_id="st-race", + actor="worker", + result=result, + ) + except TaskflowError as exc: + return exc + + with ThreadPoolExecutor(max_workers=2) as executor: + outcomes = list(executor.map(attempt, results)) + + successes = [outcome for outcome in outcomes if isinstance(outcome, TaskMeta)] + conflicts = [outcome for outcome in outcomes if isinstance(outcome, TaskflowError)] + assert len(successes) == 1 + assert len(conflicts) == 1 + assert "already submitted with a different result" in str(conflicts[0]) + persisted_meta = store.read_task_meta("st-race") + persisted_result = store.read_task_result("st-race") + assert persisted_meta.submission_id == successes[0].submission_id + assert persisted_result in results + + +@pytest.mark.asyncio +async def test_submit_task_rejects_different_result_without_overwriting(tmp_path, monkeypatch): + working_dir = tmp_path / "worker" / ".copaw" + workspace = working_dir / "workspaces" / "default" + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + _set_actor(monkeypatch, "@worker:domain") + _mock_sync(monkeypatch) + + task_dir = workspace / "shared" / "tasks" / "st-01" + task_dir.mkdir(parents=True) + (task_dir / "meta.json").write_text( + json.dumps( + { + "task_id": "st-01", + "project_id": "tp-01", + "task_title": "Task", + "assigned_to": "@worker:domain", + "room_id": "room:!team-room:domain", + "status": "in_progress", + "depends_on": [], + }, + ), + ) + original = { + "taskId": "st-01", + "status": "SUCCESS", + "summary": "API design completed.", + "deliverables": ["shared/tasks/st-01/workspace/api-design.md"], + } + changed = { + **original, + "summary": "A different result must not replace the submitted one.", + } + + first = _response_json(await taskflow(action="submit_task", payload=original)) + first_meta = (task_dir / "meta.json").read_bytes() + first_result = (task_dir / "result.md").read_bytes() + second = _response_json(await taskflow(action="submit_task", payload=changed)) + + assert first["ok"] is True + assert second["ok"] is False + assert "already submitted with a different result" in second["error"] + assert (task_dir / "meta.json").read_bytes() == first_meta + assert (task_dir / "result.md").read_bytes() == first_result @pytest.mark.asyncio -async def test_delegate_task_rejects_team_leader_dm_room(tmp_path, monkeypatch): - leader_dir = tmp_path / "leader" - working_dir = leader_dir / ".copaw" - _write_team_leader_runtime_config(leader_dir) +async def test_submit_task_rejects_tampered_persisted_result_without_sync( + tmp_path, + monkeypatch, +): + working_dir = tmp_path / "worker" / ".copaw" + workspace = working_dir / "workspaces" / "default" monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) - _set_actor(monkeypatch, "@leader:domain") + _set_actor(monkeypatch, "@worker:domain") + sync = _mock_sync(monkeypatch) - assert _response_json( - await projectflow( - action="create_project", - payload={"projectId": "tp-team-room", "title": "Team room project"}, - ) - )["ok"] is True - assert _response_json( - await projectflow( - action="plan_dag", - payload={ - "projectId": "tp-team-room", - "tasks": [ - { - "taskId": "tp-team-room-01", - "title": "Team task", - "assignedTo": "@worker:domain", - "dependsOn": [], - } - ], + task_dir = workspace / "shared" / "tasks" / "st-tampered" + task_dir.mkdir(parents=True) + meta_path = task_dir / "meta.json" + result_path = task_dir / "result.md" + original_request = { + "taskId": "st-tampered", + "status": "SUCCESS", + "summary": "Original trusted result.", + "deliverables": [], + } + meta_path.write_text( + json.dumps( + { + "task_id": "st-tampered", + "project_id": "tp-01", + "task_title": "Detect local result tampering", + "assigned_to": "@worker:domain", + "room_id": "room:!team-room:domain", + "status": "in_progress", }, - ) - )["ok"] is True + ), + ) - response = await taskflow( - action="delegate_task", - payload={ - "projectId": "tp-team-room", - "taskId": "tp-team-room-01", - "roomId": "room:!leader-dm:domain", - "spec": "Do work.", - }, + first = _response_json( + await taskflow(action="submit_task", payload=original_request), ) - payload = _response_json(response) + assert first["ok"] is True - assert payload["ok"] is False - assert "must use the Team Room room:!team:domain" in payload["error"] + result_path.write_text( + "STATUS: SUCCESS\nSUMMARY: Tampered local result.\n\nDELIVERABLES:\n", + ) + tampered_meta = meta_path.read_bytes() + tampered_result = result_path.read_bytes() + sync.reset_mock() + + retry = _response_json( + await taskflow(action="submit_task", payload=original_request), + ) + + assert retry["ok"] is False + assert "persisted result does not match submitted digest" in retry["error"] + assert meta_path.read_bytes() == tampered_meta + assert result_path.read_bytes() == tampered_result + sync.push_shared_path.assert_not_called() + sync.stat_shared_path.assert_not_called() @pytest.mark.asyncio -async def test_delegate_task_accepts_team_leader_team_room(tmp_path, monkeypatch): - leader_dir = tmp_path / "leader" - working_dir = leader_dir / ".copaw" - _write_team_leader_runtime_config(leader_dir) +@pytest.mark.parametrize("terminal_status", ["completed", "revision", "blocked", "cancelled"]) +async def test_submit_task_rejects_terminal_task_without_rotating_identity( + tmp_path, + monkeypatch, + terminal_status, +): + working_dir = tmp_path / "worker" / ".copaw" + workspace = working_dir / "workspaces" / "default" monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) - _set_actor(monkeypatch, "@leader:domain") + _set_actor(monkeypatch, "@worker:domain") _mock_sync(monkeypatch) - _mock_notify(monkeypatch) - assert _response_json( - await projectflow( - action="create_project", - payload={"projectId": "tp-team-ok", "title": "Team room project"}, - ) - )["ok"] is True - assert _response_json( - await projectflow( - action="plan_dag", - payload={ - "projectId": "tp-team-ok", - "tasks": [ - { - "taskId": "tp-team-ok-01", - "title": "Team task", - "assignedTo": "@worker:domain", - "dependsOn": [], - } - ], + task_dir = workspace / "shared" / "tasks" / "st-terminal" + task_dir.mkdir(parents=True) + meta_path = task_dir / "meta.json" + result_path = task_dir / "result.md" + meta_path.write_text( + json.dumps( + { + "task_id": "st-terminal", + "project_id": "tp-01", + "task_title": "Finished task", + "assigned_to": "@worker:domain", + "room_id": "room:!team-room:domain", + "status": terminal_status, + "depends_on": [], + "submission_id": "submission-original", + "submitted_at": "2026-08-13T01:00:00Z", + "continuation": { + "status": "resolved", + "delivery_id": "delivery-original", + }, }, - ) - )["ok"] is True + ), + ) + result_path.write_text( + "STATUS: SUCCESS\n" + "SUMMARY: Original accepted result.\n\n" + "DELIVERABLES:\n", + ) + original_meta = meta_path.read_bytes() + original_result = result_path.read_bytes() response = await taskflow( - action="delegate_task", + action="submit_task", payload={ - "projectId": "tp-team-ok", - "taskId": "tp-team-ok-01", - "roomId": "room:!team:domain", - "spec": "Do work.", + "taskId": "st-terminal", + "status": "SUCCESS", + "summary": "Late result must not replace the accepted one.", + "deliverables": [], }, ) payload = _response_json(response) - assert payload["ok"] is True - assert payload["task"]["room_id"] == "room:!team:domain" - # Auto-notification path (PR #1095): delegate_task sends the Matrix - # notification itself with a stable txn_id, then records event_id and - # marks the task assigned. No notificationRequired/nextAction handoff. - assert payload["notification"] == { - "sent": True, - "eventId": "$fake-event-id", - "roomId": "room:!team:domain", - "assignee": "@worker:domain", - } - assert payload["task"]["status"] == "assigned" - assert payload["task"]["event_id"] == "$fake-event-id" + assert payload["ok"] is False + assert f"submit_task cannot update terminal task: {terminal_status}" in payload["error"] + assert meta_path.read_bytes() == original_meta + assert result_path.read_bytes() == original_result @pytest.mark.asyncio -async def test_submit_task_writes_structured_result(tmp_path, monkeypatch): +async def test_submit_task_retry_repairs_missing_result_after_sync_failure( + tmp_path, + monkeypatch, +): working_dir = tmp_path / "worker" / ".copaw" workspace = working_dir / "workspaces" / "default" monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) _set_actor(monkeypatch, "@worker:domain") - _mock_sync(monkeypatch) + sync = _mock_sync(monkeypatch) + # The result upload succeeds, then publishing submitted meta fails. This + # is the dangerous split-commit window the retry protocol must repair. + sync.push_shared_path.side_effect = [None, RuntimeError("remote unavailable"), None, None] - task_dir = workspace / "shared" / "tasks" / "st-01" + task_dir = workspace / "shared" / "tasks" / "st-repair" task_dir.mkdir(parents=True) - (task_dir / "meta.json").write_text( + meta_path = task_dir / "meta.json" + result_path = task_dir / "result.md" + meta_path.write_text( json.dumps( { - "task_id": "st-01", + "task_id": "st-repair", "project_id": "tp-01", - "task_title": "Task", + "task_title": "Repairable task", "assigned_to": "@worker:domain", "room_id": "room:!team-room:domain", "status": "in_progress", @@ -643,34 +1950,44 @@ async def test_submit_task_writes_structured_result(tmp_path, monkeypatch): }, ), ) + request = { + "taskId": "st-repair", + "status": "SUCCESS", + "summary": "Result survives a retried remote commit.", + "deliverables": [], + } - response = await taskflow( - action="submit_task", - payload={ - "taskId": "st-01", - "status": "SUCCESS", - "summary": "API design completed.", - "deliverables": [ - "shared/tasks/st-01/workspace/api-design.md", - ], - }, + first = _response_json(await taskflow(action="submit_task", payload=request)) + assert first["ok"] is False + assert first["retryable"] is True + assert first["statePersisted"] is True + assert sync.push_shared_path.call_args_list[0].args == ( + "shared/tasks/st-repair/result.md", ) - payload = _response_json(response) + assert sync.push_shared_path.call_args_list[1].args == ( + "shared/tasks/st-repair/meta.json", + ) + submission_id = first["task"]["submission_id"] + result_digest = first["task"]["result_digest"] - assert payload["ok"] is True - assert payload["task"]["status"] == "submitted" - assert payload["result"] == { - "status": "SUCCESS", - "summary": "API design completed.", - "deliverables": ["shared/tasks/st-01/workspace/api-design.md"], - "notes": [], - } - assert (task_dir / "result.md").read_text() == ( - "STATUS: SUCCESS\n" - "SUMMARY: API design completed.\n\n" - "DELIVERABLES:\n" - "- shared/tasks/st-01/workspace/api-design.md\n" + # Model a restart after an incomplete remote commit: submitted meta + # survived locally, while result.md needs to be reconstructed from the + # caller's identical retry payload. + result_path.unlink() + retried = _response_json(await taskflow(action="submit_task", payload=request)) + + assert retried["ok"] is True + assert retried["reused"] is True + assert retried["task"]["submission_id"] == submission_id + assert retried["task"]["result_digest"] == result_digest + assert "Result survives a retried remote commit." in result_path.read_text() + assert sync.push_shared_path.call_args_list[-2].args == ( + "shared/tasks/st-repair/result.md", + ) + assert sync.push_shared_path.call_args_list[-1].args == ( + "shared/tasks/st-repair/meta.json", ) + assert sync.push_shared_path.call_args_list[-1].kwargs == {} @pytest.mark.asyncio @@ -1411,6 +2728,55 @@ async def test_ack_task_returns_spec_and_calls_sync(tmp_path, monkeypatch): ) +@pytest.mark.asyncio +@pytest.mark.parametrize("terminal_status", ["completed", "revision", "blocked", "cancelled"]) +async def test_ack_task_rejects_terminal_task_without_reopening( + tmp_path, + monkeypatch, + terminal_status, +): + working_dir = tmp_path / "worker" / ".copaw" + workspace = working_dir / "workspaces" / "default" + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + _set_actor(monkeypatch, "@worker:domain") + _mock_sync(monkeypatch) + + task_dir = workspace / "shared" / "tasks" / "st-terminal" + task_dir.mkdir(parents=True) + meta_path = task_dir / "meta.json" + meta_path.write_text( + json.dumps( + { + "task_id": "st-terminal", + "project_id": "tp-01", + "task_title": "Finished task", + "assigned_to": "@worker:domain", + "room_id": "room:!team-room:domain", + "status": terminal_status, + "depends_on": [], + "submission_id": "submission-original", + "submitted_at": "2026-08-13T01:00:00Z", + "continuation": { + "status": "resolved", + "delivery_id": "delivery-original", + }, + }, + ), + ) + (task_dir / "spec.md").write_text("# Finished task\n") + original_meta = meta_path.read_bytes() + + response = await taskflow( + action="ack_task", + payload={"taskId": "st-terminal"}, + ) + payload = _response_json(response) + + assert payload["ok"] is False + assert f"ack_task cannot update terminal task: {terminal_status}" in payload["error"] + assert meta_path.read_bytes() == original_meta + + @pytest.mark.asyncio async def test_submit_task_calls_sync_and_stat(tmp_path, monkeypatch): working_dir = tmp_path / "worker" / ".copaw" @@ -1421,6 +2787,9 @@ async def test_submit_task_calls_sync_and_stat(tmp_path, monkeypatch): task_dir = workspace / "shared" / "tasks" / "st-01" task_dir.mkdir(parents=True) + deliverable_path = task_dir / "workspace" / "output.md" + deliverable_path.parent.mkdir(parents=True) + deliverable_path.write_text("deliverable") (task_dir / "meta.json").write_text( json.dumps( { @@ -1450,10 +2819,92 @@ async def test_submit_task_calls_sync_and_stat(tmp_path, monkeypatch): assert payload["task"]["status"] == "submitted" assert payload["synced"] is True assert payload["verified"] is True - mock.push_shared_path.assert_called_once_with( - "shared/tasks/st-01/", exclude=["spec.md", "base/"], - ) - mock.stat_shared_path.assert_called_once_with("shared/tasks/st-01/result.md") + assert mock.push_shared_path.call_args_list == [ + (("shared/tasks/st-01/result.md",), {}), + (("shared/tasks/st-01/workspace/output.md",), {}), + (("shared/tasks/st-01/meta.json",), {}), + ] + assert mock.stat_shared_path.call_args_list == [ + (("shared/tasks/st-01/result.md",), {}), + (("shared/tasks/st-01/workspace/output.md",), {}), + (("shared/tasks/st-01/meta.json",), {}), + ] + + +@pytest.mark.asyncio +async def test_submit_task_does_not_publish_meta_when_deliverable_sync_fails( + tmp_path, + monkeypatch, +): + working_dir = tmp_path / "worker" / ".copaw" + workspace = working_dir / "workspaces" / "default" + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + _set_actor(monkeypatch, "@worker:domain") + sync = _mock_sync(monkeypatch) + sync.push_shared_path.side_effect = [None, RuntimeError("deliverable unavailable")] + task_dir = workspace / "shared" / "tasks" / "st-publish-last" + (task_dir / "workspace").mkdir(parents=True) + (task_dir / "workspace" / "output.md").write_text("output") + (task_dir / "meta.json").write_text(json.dumps({ + "task_id": "st-publish-last", + "project_id": "tp-01", + "task_title": "Publish last", + "assigned_to": "@worker:domain", + "room_id": "room:!team:domain", + "status": "in_progress", + })) + + response = _response_json(await taskflow( + action="submit_task", + payload={ + "taskId": "st-publish-last", + "status": "SUCCESS", + "summary": "Done.", + "deliverables": ["shared/tasks/st-publish-last/workspace/output.md"], + }, + )) + + assert response["ok"] is False + assert response["retryable"] is True + assert [call.args[0] for call in sync.push_shared_path.call_args_list] == [ + "shared/tasks/st-publish-last/result.md", + "shared/tasks/st-publish-last/workspace/output.md", + ] + + +@pytest.mark.asyncio +async def test_submit_task_publish_last_deduplicates_result_deliverable(tmp_path, monkeypatch): + working_dir = tmp_path / "worker" / ".copaw" + workspace = working_dir / "workspaces/default" + monkeypatch.setenv("COPAW_WORKING_DIR", str(working_dir)) + _set_actor(monkeypatch, "@worker:domain") + sync = _mock_sync(monkeypatch) + task_dir = workspace / "shared/tasks/st-result" + task_dir.mkdir(parents=True) + (task_dir / "meta.json").write_text(json.dumps({ + "task_id": "st-result", + "project_id": "tp-01", + "task_title": "Deduplicate result", + "assigned_to": "@worker:domain", + "room_id": "room:!team:domain", + "status": "in_progress", + })) + + response = _response_json(await taskflow( + action="submit_task", + payload={ + "taskId": "st-result", + "status": "SUCCESS", + "summary": "Done.", + "deliverables": ["shared/tasks/st-result/result.md"], + }, + )) + + assert response["ok"] is True + assert [call.args[0] for call in sync.push_shared_path.call_args_list] == [ + "shared/tasks/st-result/result.md", + "shared/tasks/st-result/meta.json", + ] @pytest.mark.asyncio @@ -1811,3 +3262,111 @@ async def test_validate_delegate_task_returns_task_without_writing( assert not (workspace / "shared" / "tasks" / "st-01" / "meta.json").exists() plan = (workspace / "shared" / "projects" / "tp-01" / "plan.md").read_text() assert "delegated" not in plan + + +def test_task_meta_write_failure_preserves_previous_valid_state(tmp_path, monkeypatch): + store = FileSystemTaskStore(tmp_path) + task_dir = tmp_path / "shared" / "tasks" / "st-atomic" + task_dir.mkdir(parents=True) + meta_path = task_dir / "meta.json" + original = TaskMeta( + task_id="st-atomic", + project_id="tp-atomic", + task_title="Original task state", + assigned_to="worker-a", + status="in_progress", + ) + meta_path.write_text(json.dumps(original.__dict__) + "\n", encoding="utf-8") + original_bytes = meta_path.read_bytes() + + def fail_fsync(_fd): + raise OSError("simulated disk flush failure") + + monkeypatch.setattr(os, "fsync", fail_fsync) + + with pytest.raises(OSError, match="simulated disk flush failure"): + store.write_task_meta( + TaskMeta( + task_id="st-atomic", + project_id="tp-atomic", + task_title="Replacement task state", + assigned_to="worker-a", + status="submitted", + ), + ) + + assert meta_path.read_bytes() == original_bytes + assert store.read_task_meta("st-atomic") == original + assert [path.name for path in task_dir.iterdir()] == ["meta.json"] + + +@pytest.mark.parametrize("failure_stage", ["fsync", "replace"]) +def test_task_result_write_failure_preserves_previous_valid_result( + tmp_path, + monkeypatch, + failure_stage, +): + store = FileSystemTaskStore(tmp_path) + task_id = "st-atomic-result" + task_dir = tmp_path / "shared" / "tasks" / task_id + result_path = task_dir / "result.md" + original = TaskResult( + status="SUCCESS", + summary="Previously published result.", + deliverables=["shared/tasks/st-atomic-result/workspace/output.md"], + ) + replacement = TaskResult( + status="REVISION_NEEDED", + summary="This incomplete replacement must never become visible.", + ) + store.write_task_result(task_id, original) + original_bytes = result_path.read_bytes() + + if failure_stage == "fsync": + def fail_fsync(_fd): + raise OSError("simulated result flush failure") + + monkeypatch.setattr(os, "fsync", fail_fsync) + expected_error = "simulated result flush failure" + else: + def fail_replace(_source, _destination): + raise OSError("simulated result replace failure") + + monkeypatch.setattr(os, "replace", fail_replace) + expected_error = "simulated result replace failure" + + with pytest.raises(OSError, match=expected_error): + store.write_task_result(task_id, replacement) + + assert result_path.read_bytes() == original_bytes + assert store.read_task_result(task_id) == original + assert [path.name for path in task_dir.iterdir()] == ["result.md"] + + +def test_project_plan_replace_failure_preserves_previous_plan(tmp_path, monkeypatch): + store = FileSystemTaskStore(tmp_path) + project_dir = tmp_path / "shared" / "projects" / "tp-atomic" + project_dir.mkdir(parents=True) + plan_path = project_dir / "plan.md" + original_plan = "# Original plan\n\n- [ ] st-01 Original task\n" + plan_path.write_text(original_plan, encoding="utf-8") + replace_paths = [] + + def fail_replace(source, destination): + replace_paths.append((Path(source), Path(destination))) + raise OSError("simulated atomic replace failure") + + monkeypatch.setattr(os, "replace", fail_replace) + + with pytest.raises(OSError, match="simulated atomic replace failure"): + store.write_project_plan( + "tp-atomic", + "# Replacement plan\n\n- [x] st-01 Original task\n", + ) + + assert len(replace_paths) == 1 + source, destination = replace_paths[0] + assert source.parent == destination.parent == project_dir + assert destination == plan_path + assert store.read_project_plan("tp-atomic") == original_plan + assert [path.name for path in project_dir.iterdir()] == ["plan.md"] diff --git a/docs/design/teamharness/project-task-runtime-design.md b/docs/design/teamharness/project-task-runtime-design.md index ed556651f..f39300a8a 100644 --- a/docs/design/teamharness/project-task-runtime-design.md +++ b/docs/design/teamharness/project-task-runtime-design.md @@ -72,6 +72,7 @@ channel 或后续事件唤醒时,通过持久化 project/task state 恢复上 | `reply_route` | 最终 requester report 路由;不得包含 secret。 | | `parent_task_id` | 子项目来自上游 task 时记录关联。 | | `requester_report` | 是否存在待发送 requester report,以及对应 result/report 路径。 | +| `tasks[].cancellation` | Controller 取消任务时先写入的决定 envelope,包含 submission identity、reason、replacement 和首次取消时间;用于 ProjectMeta 已写但 TaskMeta 写失败后的重试校验。 | ### TaskMeta @@ -90,6 +91,12 @@ channel 或后续事件唤醒时,通过持久化 project/task state 恢复上 "assigned_at": "2026-06-06T00:00:00Z", "acknowledged_at": null, "submitted_at": null, + "submission_id": null, + "result_digest": null, + "continuation": null, + "cancel_reason": null, + "replacement_task_id": null, + "cancelled_at": null, "spec_path": "shared/tasks/demo-project-001-01/spec.md", "result_path": "shared/tasks/demo-project-001-01/result.md" } @@ -102,10 +109,90 @@ channel 或后续事件唤醒时,通过持久化 project/task state 恢复上 | `task_title` | 任务标题。 | | `assigned_to` | Worker 标识。 | | `room_id` | assignment room;只表示内部执行房间。 | -| `status` | `assigned` / `in_progress` / `submitted`。 | +| `status` | `assigned` / `in_progress` / `submitted`,或 Leader 写入的终态。 | | `depends_on` | DAG 依赖的 task id 列表。 | | `spec_path` | Worker 输入 spec。 | | `result_path` | Worker 输出 result。 | +| `submitted_at` | 首次持久化提交的 UTC 时间,使用以 `Z` 结尾、精确到秒的 ISO 8601 字符串;重试不得改写。 | +| `submission_id` | 首次 `submit_task` 生成的不透明、不可变提交身份;调用方不得解析或假定 UUID 格式。 | +| `result_digest` | 结构化 TaskResult 的 canonical SHA-256 摘要,用于判断重试是否仍是同一提交。 | +| `continuation` | 待处理或已处理的 continuation marker;字段语义见下文。 | +| `cancel_reason` | 首次有效取消决定持久化的单行原因;相同取消重试必须保持一致。 | +| `replacement_task_id` | 取消后用于替代原 task 的可选 task id;不同 replacement 表示冲突。 | +| `cancelled_at` | 首次取消成功的 UTC 时间;幂等重试不得改写。 | + +首次提交将结构化 result、`status=submitted`、`submitted_at`、`submission_id`、 +`result_digest` 和 pending `continuation` 一起写入 canonical TaskMeta。相同 result 的 +重试必须复用上述身份和时间,不能创建新的逻辑提交;摘要不同的重试作为冲突被拒绝。 +已提交结果不可原地替换。需要修改结果时,Leader 先留下明确的终态决定,再创建新的 +task。`submission_id` 只是比较相等性的 fence;CoPaw 和 runtime-neutral MCP 可以使用 +不同的生成格式,任何消费者都不得从它推导时间、runtime 或 task 信息。 + +#### Canonical result digest + +两套 runtime 使用完全相同的摘要算法。先构造只有以下三个字段的 JSON object: + +```json +{"deliverables":["shared/tasks/demo-project-001-01/result.md"],"status":"SUCCESS","summary":"Completed the assigned work."} +``` + +- `status` 去掉首尾空白。 +- `summary` 把连续的 Unicode whitespace 折叠为一个 ASCII 空格,再去掉首尾空白。 +- `deliverables` 必须先通过 task 目录边界和安全相对路径校验;摘要保持调用方给出的顺序, + 并保留持久化后的路径字符串,不排序、不去重。 +- `notes`、`result.md` 的渲染文本和其他 runtime-specific 字段不参与摘要。 +- JSON 使用 UTF-8、保留非 ASCII 字符、按 key 排序,并使用 `,` 和 `:` 作为无额外 + 空白的分隔符。 + +最后计算下式,并把 `result_digest` 写成 64 个小写十六进制字符: + +```text +sha256(UTF8("teamharness.task-result.v1") || NUL || canonical_json) +``` + +domain prefix 和 NUL 分隔符属于协议,不能省略。这样 CoPaw 与 runtime-neutral MCP +即使生成的 `submission_id` 格式不同,也能对同一个结构化结果得到相同 identity。 + +#### Continuation marker + +首次提交写入: + +```json +{ + "status": "pending", + "delivery_id": "<sha256 hex>" +} +``` + +`delivery_id` 是未来 Controller 用于去重一次“结果已提交”唤醒尝试的稳定 key,公式为: + +```text +sha256(UTF8(project_id || NUL || task_id || NUL || submission_id || NUL || "result-submitted:v1")) +``` + +它不是已经发送成功的 Matrix event id。PR1 不扫描 pending marker,也不发送 Matrix +消息。Leader 验收,或可信 Leader/经 Controller 授权的调用方取消 task 后,状态写入方 +保留原 `delivery_id`,并把 marker 更新为: + +```json +{ + "status": "resolved", + "delivery_id": "<original sha256 hex>", + "resolution": "completed", + "resolved_at": "2026-06-06T00:01:00Z" +} +``` + +`resolution` 是 `completed`、`revision`、`blocked` 或 `cancelled`;`resolved_at` 是 +首次解决 marker 的 UTC 时间。相同终态决定的重试复用已经 resolved 的 marker,不得 +旋转 `delivery_id` 或重新打开 continuation。 + +只有 runtime 配置识别出的可信 Leader 能验收 result。TeamHarness 的两个 `cancel_task` +入口同样只允许可信 Leader;此外,现有 Controller 授权层允许 admin、manager、team +leader 或 L2 human 通过项目 HTTP API 取消 task。无论从哪个入口取消,都必须遵守同一 +submission fence 和 continuation resolution。payload 里的 `role` 只是无可信 runtime +identity 时的兼容输入,不能覆盖一个 Worker runtime 的身份;Worker 只能 `ack_task` 和 +`submit_task`,不得调用 accept、cancel 或以其他方式 resolve continuation。 ### 状态定义 @@ -125,7 +212,8 @@ TaskMeta status: | --- | --- | | `assigned` | Leader 已委派,Worker 尚未 ack。 | | `in_progress` | Worker 已 ack,正在执行。 | -| `submitted` | Worker 已提交 result,等待 Leader check/accept。 | +| `submitted` | Worker 已提交 result,等待 Leader check 和显式 accept/revise/block。 | +| `completed` / `revision` / `blocked` / `cancelled` | Leader 已留下终态决定。 | TaskResult status: @@ -137,6 +225,28 @@ TaskResult status: | `BLOCKED` | Worker 被阻塞。 | | `INTERRUPTED` | Worker 执行被中断。 | +`FAILED` 和 `PARTIAL` 不属于跨 runtime TaskResult 合同。旧 standalone MCP 会在 +`submit_task` 接受这两个值,但后续没有对应的 acceptance 映射;PR1 改为在任何 +TaskMeta 或 ProjectMeta 写入前返回 `unsupported result status`。 + +终态决定写入 TaskMeta 和 plan node 的状态映射固定如下: + +| TaskResult / decision | TaskMeta 与 plan node 终态 | continuation resolution | +| --- | --- | --- | +| `SUCCESS` / `SUCCESS_WITH_NOTES`,Leader 接受 | `completed` | `completed` | +| `SUCCESS` / `SUCCESS_WITH_NOTES`,Leader 要求修订 | `revision` | `revision` | +| `REVISION_NEEDED` | `revision` | `revision` | +| `BLOCKED` / `INTERRUPTED` | `blocked` | `blocked` | +| Leader 或经 Controller 授权的调用方取消 task | `cancelled` | `cancelled` | + +`check_task` 只读取并校验 result,不写终态。只有可信 Leader 能调用 +`accept_task_result`;取消可以由可信 Leader 的 TeamHarness 工具或经 Controller 授权的 +调用方执行。正常验收请求必须 +把 `check_task` 返回的当前 `task.submission_id` 原样放进请求字段 `submissionId`,并 +携带布尔值 `accepted`。只要 TaskMeta 已有 `submission_id`,accept 和 cancel 都必须 +携带这个 `submissionId`;缺失或过期 identity、不同决定的重试以及 Worker 发起的决定 +都必须在写入前被拒绝。无 identity 的 legacy 迁移例外见下文。 + ### 存储布局 Canonical layout: @@ -169,6 +279,30 @@ CoPaw 协议兼容策略: - DingTalk client secret、access token、webhook signing secret 等不得进入 `shared/projects`、`shared/tasks`、room log 或 project report。 +#### 写入与并发边界 + +PR1 的正确性边界是“每个 task 在同一时刻只有一个 runtime writer”。单个 +`meta.json` 或 `plan.md` 使用同目录临时文件、flush/fsync 和原子 replace,避免读者 +看到截断 JSON;CoPaw 还用进程内 per-task lock 串行化同一进程中的并发调用。这些 +机制不是跨进程锁,也不提供共享存储上的 compare-and-swap (CAS)。两个 Controller、 +两个 Pod 或两个 runtime 同时修改同一 task 不在 PR1 的保证范围内;后续 Controller +必须先通过 leader election 或等价所有权机制满足 single-writer 前提。 + +ProjectMeta/plan 和 TaskMeta 是多个独立文件,共享存储同步也不是一个分布式事务。 +runtime-neutral MCP 因此把 project projection 和 task state 都提交到远端,并把中间 +失败返回为 `retryable: true`、`statePersisted: true`、`synced: false`。调用方必须用 +完全相同的 submission 或终态决定重试: + +- `submit_task` 重试用 `result_digest` 识别原提交,补齐 project 的 `submitted` + projection,再补齐远端 project/task state;它不旋转 `submission_id`。 +- `accept_task_result` 先持久化 project 决定;如果随后 TaskMeta 或远端同步失败, + 相同 `submissionId` 与相同决定的重试补写 TaskMeta resolved marker,不重复推进 plan + 或重新打开 requester report。 +- `cancel_task` 同样通过已持久化的取消原因、replacement task 和终态修复缺失的 + project/task 远端 projection;不同取消 payload 被视为冲突。 + +这里的保证是“可检测、可重试、可修复”,不是 exactly-once,也不是跨文件原子提交。 + ### Store Protocol TeamHarness MCP 内部维护 store protocol,先提供 filesystem 实现: @@ -207,7 +341,7 @@ store protocol 的职责只是读写结构化状态和文档文件,不做 DAG | `ready_nodes` | 只计算 DAG 可委派节点。 | | `ready_loop_nodes` | 只计算 Loop 可委派节点。 | | `record_loop_iteration` | 记录 Loop 迭代决策。 | -| `accept_task_result` | Leader 显式把 checked result 接受到 DAG/Loop plan。 | +| `accept_task_result` | Leader 显式把 checked result 接受到 DAG/Loop plan;正常提交必须用当前 `submissionId` 校验。 | | `pause_project` | 暂停项目。 | | `resume_project` | 恢复项目。 | | `complete_project` | 完成项目。 | @@ -226,9 +360,48 @@ return project_id, task_id, assignment_room, reply_route 它不负责发送 Worker assignment message,也不负责发送 requester report。消息发送仍由 `communication` skill 通过对应 channel 工具完成。 -`accept_task_result` 是 project 状态推进的唯一入口。`check_task` 返回 +`accept_task_result` 是 result 验收后推进 project 状态的唯一入口。`check_task` 返回 `effective: true` 后,Leader 仍必须显式调用 `accept_task_result`,这样跨 session -恢复时不会把“result 已提交”和“project 已接受”混在一起。 +恢复时不会把“result 已提交”和“project 已接受”混在一起。正常 TaskMeta 已有 +`submission_id` 时,调用方必须原样传入 `submissionId`;缺失或不匹配都会被拒绝,且 +不得修改 TaskMeta 或 plan。同一 submission 与同一决定的重试是幂等的,不会重复推进 +plan 或重新打开 requester report。如果 project 决定已经持久化、但 TaskMeta 同步失败, +使用完全相同的 payload 重试会修复 TaskMeta,不会产生第二次业务决定。 + +runtime-neutral standalone MCP 的 `accept_task_result` 在接受完成结果时还会设置 +`ProjectMeta.requester_report.pending`。CoPaw 原生 `projectflow` 只提交 plan node 与 +TaskMeta 的终态,不凭空创建 `requester_report`。这只是状态投影差异,不是报告责任 +差异:Leader 在两套 runtime 中都必须按已有 `reply_route` 和 requester report 流程行动; +CoPaw 返回中没有 pending marker 时,也不能据此省略应发送的 requester report。 + +两套运行入口保持相同状态语义,但取消路由不同:CoPaw 原生工具使用 +`projectflow(action=cancel_task)`,runtime-neutral standalone MCP 使用 +`taskflow(action=cancel_task)`。两者都只允许可信 Leader;只要 task 已有 +`submission_id`,两者都必须携带当前 `submissionId`。调用方不得因为工具名不同而 +绕过 submission fence。经 Controller 授权的调用方还可以使用 +`POST /api/v1/projects/{id}/tasks/{taskId}/cancel`。该入口不是 result acceptance;它 +不得自动验收 Worker 输出,并且在 TaskMeta 已有 identity 时也必须携带当前 `submissionId`, +保留原 `delivery_id` 并把 pending continuation 解决为 `cancelled`。 + +Controller 采用 ProjectMeta-first、TaskMeta-second 的写入顺序。首次取消会先把 +`submission_id`、reason、replacement 和 `cancelled_at` 写入对应 plan node 的 +`cancellation` envelope;如果后续 TaskMeta 写入失败,重试必须与该 envelope 完全一致, +否则返回冲突。相同重试复用首次时间并补齐 TaskMeta/continuation,不创建第二个决定。 +Controller replan 和 TeamHarness `plan_dag` / `plan_loop` 归一化都必须保留该 envelope; +带有已提交取消决定的节点不能原地改回非终态,也不能从计划删除后再用同一个 task id +添加回来。需要重新执行时必须创建新的 task id。 + +升级时仅允许基于已持久化证据收养 legacy `submitted` task。CoPaw 要求 legacy +TaskMeta 已有 `submitted_at`,且 Worker 显式重试的完整结构化 result 与磁盘 +`result.md` 完全一致;随后按 +`project_id || NUL || task_id || NUL || submitted_at || NUL || result_digest || NUL || "legacy-adoption:v1"` +确定性生成不透明 identity;此后 CoPaw accept/cancel 必须携带该 identity,CoPaw 决策 +入口本身不迁移缺 ID 状态。standalone MCP 的 `submit_task` 不收养无 identity 的 legacy +提交;可信 Leader 可在未提供 `submissionId` 时验收一个可校验的持久化 legacy result, +先补齐 identity/digest/pending marker,再立即写入同一终态决定。standalone 还保留两条 +既有兼容路径:没有 TaskMeta 的 plan-only acceptance 不制造 TaskMeta;已有 legacy TaskMeta +但没有 identity 的 cancel 可以继续完成取消。证据缺失、结果不一致或调用方提供未知 +identity 时一律 fail closed。 ### taskflow @@ -270,14 +443,16 @@ TASK_BLOCKED: {task_id} - Result: shared/tasks/{task_id}/result.md Leader 收到事件后的固定恢复顺序: ```text -taskflow check_task(task_id) +taskflow check_task(task_id) -> current task.submission_id projectflow resolve_project(taskId=task_id) -projectflow accept_task_result(projectId, taskId, decision) +projectflow accept_task_result(projectId, taskId, submissionId, accepted) communication report through ProjectMeta.reply_route when requester_report.pending projectflow mark_requester_report_sent(projectId) ``` -Leader 不从当前 session 猜 project、reply route 或下一步 DAG/Loop。 +Leader 不从当前 session 猜 project、reply route 或下一步 DAG/Loop。正常提交必须携带 +`submissionId`,`accept_task_result` 会把它作为当前提交的 fence;缺失或过期标识被拒绝。 +同一提交和同一决定的重复调用复用原状态,用于安全修复上一次共享存储同步失败。 ## 3. 流程组织模式与 TEAMS + Skill 实现 @@ -386,27 +561,28 @@ communication reports through ProjectMeta.reply_route when needed | `communication` | Matrix/Team Room/DM requester report 路由。 | 推断 project context。 | | `dingtalk-channel` | DingTalk inbound 识别、保留 `reply_route`、最终回 DingTalk。 | 成为 TeamHarness 内置基础 channel 或保存 DingTalk secret。 | -## 4. Task/Project 兜底策略暂缓 - -异常 loop 中断后的自驱恢复暂不纳入本阶段设计与实现。 - -当前阶段只要求主流程具备可恢复上下文: +## 4. Durable continuation 的 PR1 边界 -- Worker completion/blocker 消息必须携带 `taskId`。 -- Leader 收到 task 事件后用 `resolve_project(taskId)` 恢复 ProjectMeta、 - TaskMeta、plan 和 requester route。 -- Leader 通过 `check_task`、`accept_task_result`、 - `mark_requester_report_sent` 推进正常项目流程。 +PR1 只提供 durable continuation 所需的状态语义和部分写入修复入口,不等于任务已经 +能够自驱恢复。`submit_task` 持久化稳定的 submission identity 与 pending marker; +可信 Leader 使用当前 `submissionId` 和 `accepted` 调用 `accept_task_result`,拒绝过期 +决定,并使相同决定在部分同步失败后可以安全重试;经 Controller 授权的调用方取消 +task 时遵守同一 submission fence 和 resolution 规则;Worker 无权 resolve marker。 -不在本阶段定义或实现: +异常 loop 中断后的自驱恢复、Controller 周期调度、Matrix 唤醒、runtime hook、 +active task 扫描和 pending requester report 重投均明确 deferred。PR2 的 Controller +负责 leader-elected 周期扫描和调度;Matrix channel 负责可靠唤醒。候选必须同时满足: -- runtime hook。 -- 外置 continuation/recovery service。 -- active task 扫描。 -- loop 中断后的自动唤醒。 +```text +TaskMeta.status == submitted +AND continuation.status == pending +AND submission_id != "" +AND delivery_id != "" +AND corresponding project/task node is not terminal +``` -后续如果重新处理异常自驱问题,应作为独立设计进入,而不是混入 -Project/Task canonical state、MCP tool 和 skill 分层的当前阶段。 +Controller 或 channel 不得自行放宽这些条件,也不得重新定义 Task 状态映射。直到 PR2 +落地,pending marker 只是持久化事实,不会自动触发 Leader,也不能声称任务已恢复。 ## 现有实现差距 @@ -423,7 +599,7 @@ Project/Task canonical state、MCP tool 和 skill 分层的当前阶段。 | Result acceptance | `check_task` 与 project 推进边界不够完整。 | 新增/明确 `accept_task_result`,由 Leader 显式推进 project。 | | Resume | 依赖当前 session 容易丢上下文。 | `resolve_project(taskId)` 返回恢复上下文。 | | Requester report | 可能依赖即时 session。 | `requester_report.pending` 进入 ProjectMeta。 | -| Recovery | 暂缓,不作为当前阶段目标。 | 后续独立设计异常 loop 中断后的自驱恢复。 | +| Recovery | PR1 提供 submission identity、pending/resolved marker 和可重试修复语义。 | PR2 由 Controller 周期调度并通过 Matrix 唤醒;PR1 不声称自动恢复。 | ## 推荐落地顺序 diff --git a/docs/usage/project-workflow-api.md b/docs/usage/project-workflow-api.md index 597135085..b50cd5519 100644 --- a/docs/usage/project-workflow-api.md +++ b/docs/usage/project-workflow-api.md @@ -78,7 +78,7 @@ Optional query parameter: | Parameter | Type | Meaning | |:--|:--|:--| -| `includeTasks` | `bool` | When `true`, also read each task's TaskMeta (`shared/tasks/{id}/meta.json`) and attach a `tasks_detail` array with spec/result/deliverable fields. Default `false` keeps the response lightweight. | +| `includeTasks` | `bool` | When `true`, also read each task's TaskMeta (`shared/tasks/{id}/meta.json`) and attach a `tasks_detail` array with spec/result/deliverable fields and the opaque `submission_id` fence. Default `false` keeps the response lightweight. | Response `200 OK`: @@ -124,6 +124,7 @@ Response `200 OK`: "assigned_to": "@w1:matrix.local", "summary": "Alpha report done", "result_status": "SUCCESS", + "submission_id": "submission-123", "deliverables": [{"type": "file", "path": "shared/tasks/t1/output.pdf"}], "result_path": "shared/tasks/t1/result.md" } @@ -134,7 +135,8 @@ Response `200 OK`: `tasks_detail` is only present when `?includeTasks=true`. It surfaces the TaskMeta fields that the project-level `nodes[]` summary does not carry: `spec_path` (task spec file), `summary` / `result_status` / `result_path` -(submission result), `deliverables` (artifact list) and `cancel_reason`. +(submission result), `deliverables` (artifact list), `cancel_reason`, and the +opaque `submission_id` used to fence accept/cancel decisions. TaskMeta is read from the project's owning scope only: team projects read `teams/{team}/shared/tasks/{id}/meta.json`, standalone projects read `shared/tasks/{id}/meta.json`. There is no cross-scope fallback, and a @@ -542,16 +544,42 @@ unknown dependencies, and dependency cycles are rejected with `400`. Preconditions (`409`): `plan_type` must be `dag` (loop replans go through `record_loop_iteration`), status must be `active`, and no task may be `in_progress`/`submitted`. Response `200` returns the updated workflow. +Tasks with a persisted cancellation decision keep that decision when retained +in a replan. They cannot be reopened or removed and then re-added under the +same task id; create a new task id for replacement work. ### `POST /api/v1/projects/{id}/tasks/{taskId}/cancel` -Cancel a single task. Body requires `reason` (and optional -`replacementTaskId`). The task must be mutable — a terminal task -(completed/revision/blocked/cancelled) is rejected with `409`. The task's -`TaskMeta` is stamped `status=cancelled` + `cancel_reason` and the project -node status is updated. Response `200` returns the updated workflow. -Errors: `400` missing reason; `404` task not in project / task meta -missing; `409` terminal task. +Cancel a single task: + +```json +{ + "reason": "no longer needed", + "replacementTaskId": "replacement-01", + "submissionId": "submission-123" +} +``` + +`reason` is required and `replacementTaskId` is optional. `submissionId` is +conditionally required: when TaskMeta already has `submission_id`, callers +must send that exact opaque value. Missing, invented, or stale identities are +rejected before either ProjectMeta or TaskMeta is changed. + +On success, the project node and TaskMeta become `cancelled`; TaskMeta records +stable `cancel_reason` / `replacement_task_id` / `cancelled_at` fields and +resolves an existing pending continuation as `cancelled` without changing its +`delivery_id`. Repeating the same cancellation is idempotent. A different +reason, replacement task, or submission identity conflicts with the committed +decision. Tasks already `completed`, `revision`, or `blocked` cannot be +cancelled. Response `200` returns the updated workflow. + +The Controller writes a small cancellation decision envelope into the project +node before writing TaskMeta. If the second write fails, an identical retry can +finish the TaskMeta/continuation update; a retry with a different reason, +replacement, or submission identity is rejected. + +Errors: `400` missing reason or invalid replacement task id; `404` task not in project / task meta missing; +`409` terminal task, submission fence failure, or conflicting cancellation. ### `POST /api/v1/projects/{id}/complete` @@ -599,6 +627,7 @@ agt get projects # list all agt get projects --team biz-team # filter by team agt get projects demo-project-001 # workflow detail agt get projects demo-project-001 -o json +agt get projects demo-project-001 --include-tasks -o json agt get projects demo-project-001 --mermaid # render DAG as mermaid ``` @@ -607,6 +636,9 @@ or `AGENTTEAMS_AUTH_TOKEN_FILE`) verbatim, so an L2 human can also use it by pointing either variable at their own Matrix access token — no separate CLI auth mode is needed. +`--include-tasks` requires `-o json`; the default detail view does not render +raw TaskMeta fields. + ### `agt project` (the lifecycle write API write commands) `agt project` wraps the write endpoints so a human can intervene without @@ -617,7 +649,8 @@ agt project create --title "New project" --team biz-team --source matrix agt project pause demo-project-001 --reason "customer review" agt project resume demo-project-001 agt project replan demo-project-001 --tasks tasks.json # JSON array file -agt project cancel demo-project-001 demo-project-001-01 --reason "no longer needed" +agt project cancel demo-project-001 demo-project-001-01 \ + --reason "no longer needed" --submission-id submission-123 --team biz-team agt project complete demo-project-001 ``` diff --git a/docs/zh-cn/usage/project-workflow-api.md b/docs/zh-cn/usage/project-workflow-api.md index 167540be8..5ddf3f1d1 100644 --- a/docs/zh-cn/usage/project-workflow-api.md +++ b/docs/zh-cn/usage/project-workflow-api.md @@ -62,7 +62,7 @@ Controller 提供两个只读端点,把 TeamHarness 项目状态 | 参数 | 类型 | 含义 | |:--|:--|:--| -| `includeTasks` | `bool` | 为 `true` 时同时读取每个任务的 TaskMeta(`shared/tasks/{id}/meta.json`),在响应中附加 `tasks_detail` 数组(spec/result/交付物字段)。默认 `false` 保持响应轻量。 | +| `includeTasks` | `bool` | 为 `true` 时同时读取每个任务的 TaskMeta(`shared/tasks/{id}/meta.json`),在响应中附加 `tasks_detail` 数组(spec/result/交付物字段及不透明的 `submission_id` fence)。默认 `false` 保持响应轻量。 | 响应 `200 OK`: @@ -108,6 +108,7 @@ Controller 提供两个只读端点,把 TeamHarness 项目状态 "assigned_to": "@w1:matrix.local", "summary": "Alpha report done", "result_status": "SUCCESS", + "submission_id": "submission-123", "deliverables": [{"type": "file", "path": "shared/tasks/t1/output.pdf"}], "result_path": "shared/tasks/t1/result.md" } @@ -115,7 +116,7 @@ Controller 提供两个只读端点,把 TeamHarness 项目状态 } ``` -`tasks_detail` 仅在 `?includeTasks=true` 时出现。它透传项目级 `nodes[]` 摘要不包含的 TaskMeta 字段:`spec_path`(任务规格文件)、`summary` / `result_status` / `result_path`(提交结果)、`deliverables`(产物清单)与 `cancel_reason`(取消原因)。TaskMeta 按与项目相同的双前缀布局读取(优先 `teams/{team}/shared/tasks/{id}/meta.json`,其次 `shared/tasks/{id}/`),团队作用域的任务优先于任何全局副本。没有 TaskMeta 文件的任务(如尚未委派)会被跳过;单个任务读取错误也会跳过,避免一个坏任务拖垮整个响应。 +`tasks_detail` 仅在 `?includeTasks=true` 时出现。它透传项目级 `nodes[]` 摘要不包含的 TaskMeta 字段:`spec_path`(任务规格文件)、`summary` / `result_status` / `result_path`(提交结果)、`deliverables`(产物清单)、`cancel_reason`(取消原因)以及用于约束 accept/cancel 决定的不透明 `submission_id`。TaskMeta 只从项目所属作用域读取:团队项目读取 `teams/{team}/shared/tasks/{id}/meta.json`,standalone 项目读取 `shared/tasks/{id}/meta.json`,不跨作用域回退。`task_id` 或 `project_id` 不匹配的 TaskMeta 会被拒绝。没有 TaskMeta 文件的任务(如尚未委派)会被跳过;单个任务读取错误也会跳过,避免一个坏任务拖垮整个响应。 节点状态归一化为前端友好枚举: @@ -247,14 +248,37 @@ Controller 提供两个只读端点,把 TeamHarness 项目状态 必须是 `dag`(loop 的重规划走 `record_loop_iteration`)、状态必须是 `active`、不能有 `in_progress`/`submitted` 任务。响应 `200` 返回更新后的 工作流。 +重规划保留已有的 cancellation decision;同一个 task id 不能从已取消状态原地重开, +也不能先从计划删除再以同名任务添加。替代工作必须使用新的 task id。 ### `POST /api/v1/projects/{id}/tasks/{taskId}/cancel` -取消单个任务。请求体要求 `reason`(可选 `replacementTaskId`)。任务必须 -可变——终态任务(completed/revision/blocked/cancelled)以 `409` 拒绝。 -任务的 `TaskMeta` 打上 `status=cancelled` + `cancel_reason`,项目节点状态 -同步更新。响应 `200` 返回更新后的工作流。错误:`400` 缺 reason;`404` -任务不在项目里/任务 meta 缺失;`409` 终态任务。 +取消单个任务: + +```json +{ + "reason": "不再需要", + "replacementTaskId": "replacement-01", + "submissionId": "submission-123" +} +``` + +`reason` 必填,`replacementTaskId` 可选。`submissionId` 是条件必填字段: +TaskMeta 已有 `submission_id` 时,调用方必须传入完全相同的不透明值。缺失、 +凭空构造或过期的 identity 会在 ProjectMeta/TaskMeta 发生任何写入前以 `409` +拒绝。 + +成功后,项目节点和 TaskMeta 都变成 `cancelled`;TaskMeta 持久化稳定的 +`cancel_reason` / `replacement_task_id` / `cancelled_at`,并把已有 pending +continuation 解决为 `cancelled`,原 `delivery_id` 不变。相同取消请求可幂等 +重试;reason、replacement 或 submission identity 不同则与既有决定冲突。 +已经 `completed`、`revision` 或 `blocked` 的任务不能取消。响应 `200` 返回 +更新后的工作流。错误:`400` 缺 reason 或 replacement task id 非法;`404` 任务不在项目里/TaskMeta +缺失;`409` 终态任务、submission fence 失败或取消决定冲突。 + +Controller 先把一份最小 cancellation decision envelope 写入项目节点,再写 +TaskMeta。如果第二次写入失败,完全相同的请求可以补齐 TaskMeta/continuation; +reason、replacement 或 submission identity 不同的重试会被拒绝。 ### `POST /api/v1/projects/{id}/complete` @@ -298,6 +322,7 @@ agt get projects # 列出全部 agt get projects --team biz-team # 按团队过滤 agt get projects demo-project-001 # 工作流详情 agt get projects demo-project-001 -o json +agt get projects demo-project-001 --include-tasks -o json agt get projects demo-project-001 --mermaid # 渲染 DAG 为 mermaid ``` @@ -305,6 +330,8 @@ CLI 原样转发配置的 bearer 令牌(`AGENTTEAMS_AUTH_TOKEN` 或 `AGENTTEAMS_AUTH_TOKEN_FILE`),所以 L2 人类也可以用——把任一变量指向自己的 Matrix 访问令牌即可,无需单独的 CLI 认证模式。 +`--include-tasks` 必须和 `-o json` 一起使用;默认详情视图不渲染原始 TaskMeta 字段。 + ### `agt project`(W-PR-2 写命令) `agt project` 包装写端点,人类无需 raw curl 即可干预: @@ -314,7 +341,8 @@ agt project create --title "新项目" --team biz-team --source matrix agt project pause demo-project-001 --reason "客户评审" agt project resume demo-project-001 agt project replan demo-project-001 --tasks tasks.json # JSON 数组文件 -agt project cancel demo-project-001 demo-project-001-01 --reason "不再需要" +agt project cancel demo-project-001 demo-project-001-01 \ + --reason "不再需要" --submission-id submission-123 --team biz-team agt project complete demo-project-001 ``` diff --git a/plugins/teamharness/mcp/server.py b/plugins/teamharness/mcp/server.py index 4ab6b15b8..ae2fd22b7 100644 --- a/plugins/teamharness/mcp/server.py +++ b/plugins/teamharness/mcp/server.py @@ -13,6 +13,7 @@ import re import subprocess import sys +import tempfile import threading import time from typing import Any @@ -444,7 +445,19 @@ }, "accepted": { "type": "boolean", - "description": "For accept_task_result, false records a revision state instead of accepting the result.", + "description": ( + "For accept_task_result, false records revision for a " + "SUCCESS result. REVISION_NEEDED remains revision, while " + "BLOCKED and INTERRUPTED remain blocked." + ), + }, + "submissionId": { + "type": "string", + "description": ( + "Current submit_task identity required by normal " + "accept_task_result requests. Only migration of a " + "persisted legacy submission without an identity may omit it." + ), }, "publishArtifacts": { "type": "boolean", @@ -486,6 +499,13 @@ "type": "string", "description": "Safe task id used under shared/tasks/{taskId}.", }, + "submissionId": { + "type": "string", + "description": ( + "Current submit_task identity required when cancel_task " + "resolves a normal submitted result." + ), + }, "payload": { "type": "object", "description": "Task payload; flat arguments are also accepted.", @@ -2537,14 +2557,24 @@ def _filesync(arguments: dict[str, Any]) -> dict[str, Any]: "path": normalized, "error": env_error, } - completed = subprocess.run( - command, - check=False, - capture_output=True, - text=True, - timeout=120, - env=mc_env, - ) + try: + completed = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=120, + env=mc_env, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return { + "ok": False, + "tool": "filesync", + "action": action, + "path": normalized, + "error": f"filesync process failed: {exc}", + "retryable": True, + } command_error = _filesync_command_error(completed) if command_error: return { @@ -2594,6 +2624,7 @@ def _payload(arguments: dict[str, Any]) -> dict[str, Any]: "assignedTo": ("assignedTo", "assigned_to"), "dependsOn": ("dependsOn", "depends_on"), "replacementTaskId": ("replacementTaskId", "replacement_task_id"), + "submissionId": ("submissionId", "submission_id"), } for canonical, keys in aliases.items(): if any(data.get(key) for key in keys): @@ -2795,9 +2826,24 @@ def _read_json(path: Path, default: dict[str, Any] | None = None) -> dict[str, A return json.loads(path.read_text(encoding="utf-8")) -def _write_json(path: Path, data: dict[str, Any]) -> None: +def _atomic_write_text(path: Path, text: str) -> None: + """Replace one state projection without exposing a partially written file.""" path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) + temporary_path = Path(temporary_name) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_path, path) + except BaseException: + temporary_path.unlink(missing_ok=True) + raise + + +def _write_json(path: Path, data: dict[str, Any]) -> None: + _atomic_write_text(path, json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n") def _project_dir(arguments: dict[str, Any], project_id: str) -> Path: @@ -2822,13 +2868,35 @@ def _normalize_task(raw: dict[str, Any], previous: dict[str, Any] | None = None) status = str(raw.get("status") or previous.get("status") or "planned") if status == "pending": status = "planned" - return { + cancellation = previous.get("cancellation") if isinstance(previous.get("cancellation"), dict) else None + if cancellation and raw.get("status") is not None and status != str(previous.get("status") or ""): + raise ValueError(f"task {task_id} has a committed cancellation and cannot be reopened") + normalized = { "task_id": task_id, "title": str(raw.get("title") or previous.get("title") or task_id), "assigned_to": str(raw.get("assignedTo") or raw.get("assigned_to") or previous.get("assigned_to") or ""), "depends_on": [str(item) for item in (raw.get("dependsOn") or raw.get("depends_on") or previous.get("depends_on") or [])], "status": status, } + if cancellation: + normalized["cancellation"] = dict(cancellation) + return normalized + + +def _normalize_tasks( + raw_tasks: list[Any], + previous: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + planned = [ + _normalize_task(task, previous.get(str(task.get("taskId") or task.get("task_id")))) + for task in raw_tasks + if isinstance(task, dict) + ] + included = {str(task.get("task_id") or "") for task in planned} + for task_id, old_task in previous.items(): + if isinstance(old_task.get("cancellation"), dict) and task_id not in included: + raise ValueError(f"task {task_id} has a committed cancellation and cannot be removed") + return planned def _validate_task_graph(tasks: list[dict[str, Any]]) -> None: @@ -2968,7 +3036,7 @@ def _write_project_plan(project_dir: Path, project: dict[str, Any]) -> None: else: lines.append(f"- {item}") project_dir.mkdir(parents=True, exist_ok=True) - (project_dir / "plan.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + _atomic_write_text(project_dir / "plan.md", "\n".join(lines) + "\n") def _ready_nodes(project: dict[str, Any]) -> list[dict[str, Any]]: @@ -3040,7 +3108,7 @@ def _resolve_project(arguments: dict[str, Any], payload: dict[str, Any]) -> dict def _accepted_node_status(result_status: Any) -> str: - status = str(result_status or "SUCCESS").strip() + status = _validate_task_result_status(result_status) if status in {"SUCCESS", "SUCCESS_WITH_NOTES"}: return "completed" if status == "REVISION_NEEDED": @@ -3050,6 +3118,109 @@ def _accepted_node_status(result_status: Any) -> str: raise ValueError(f"unsupported result status: {status}") +def _submission_result(task: dict[str, Any]) -> dict[str, Any]: + deliverables = task.get("deliverables") + if not isinstance(deliverables, list): + deliverables = [] + return { + "status": str(task.get("result_status") or task.get("resultStatus") or ""), + "summary": str(task.get("summary") or ""), + "deliverables": [str(item) for item in deliverables], + } + + +def _task_result_digest(result: dict[str, Any]) -> str: + """Return the cross-runtime identity of a structured task result.""" + deliverables = result.get("deliverables") + if not isinstance(deliverables, list): + deliverables = [] + canonical_result = { + "status": str(result.get("status") or "").strip(), + "summary": re.sub(r"\s+", " ", str(result.get("summary") or "")).strip(), + "deliverables": [str(item) for item in deliverables], + } + canonical_json = json.dumps( + canonical_result, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256( + b"teamharness.task-result.v1\0" + canonical_json, + ).hexdigest() + + +def _continuation_delivery_id(project_id: str, task_id: str, submission_id: str) -> str: + identity = "\0".join((project_id, task_id, submission_id, "result-submitted:v1")) + return hashlib.sha256(identity.encode("utf-8")).hexdigest() + + +def _resolve_task_continuation(task: dict[str, Any], resolution: str) -> None: + continuation = task.get("continuation") if isinstance(task.get("continuation"), dict) else {} + if not continuation: + return + continuation["status"] = "resolved" + continuation["resolution"] = resolution + continuation["resolved_at"] = continuation.get("resolved_at") or _utc_timestamp() + task["continuation"] = continuation + + +def _project_task_status(project: dict[str, Any], task_id: str) -> str: + tasks = project.get("tasks", []) if isinstance(project.get("tasks"), list) else [] + loop = project.get("loop") if isinstance(project.get("loop"), dict) else {} + loop_tasks = loop.get("tasks", []) if isinstance(loop.get("tasks"), list) else [] + for task in tasks + loop_tasks: + if isinstance(task, dict) and task.get("task_id") == task_id: + return str(task.get("status") or "") + return "" + + +def _sync_failure_result(result: dict[str, Any], operation: str) -> dict[str, Any]: + result["ok"] = False + result["synced"] = False + result["retryable"] = True + result["statePersisted"] = True + result["error"] = f"{operation} state persisted locally but shared-storage sync failed; retry to complete" + result.pop("notificationNeeded", None) + return result + + +def _persisted_state_failure_result( + *, + tool: str, + action: str, + error: Exception, + task: dict[str, Any] | None = None, + project: dict[str, Any] | None = None, +) -> dict[str, Any]: + result: dict[str, Any] = { + "ok": False, + "tool": tool, + "action": action, + "retryable": True, + "statePersisted": True, + "synced": False, + "error": f"{action} state persisted locally but follow-up state update failed: {error}; retry to complete", + } + if task is not None: + result["task"] = task + if project is not None: + result["project"] = project + return result + + +def _uncommitted_state_failure_result(*, tool: str, action: str, error: Exception) -> dict[str, Any]: + return { + "ok": False, + "tool": tool, + "action": action, + "retryable": True, + "statePersisted": False, + "synced": False, + "error": f"{action} could not persist local state: {error}; retry to complete", + } + + def _payload_bool(value: Any, default: bool) -> bool: if value is None: return default @@ -3071,28 +3242,159 @@ def _payload_bool_field(payload: dict[str, Any], names: tuple[str, ...], default def _accept_task_result(arguments: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: + if _role(arguments) != "leader": + raise ValueError("accept_task_result requires leader role") project_id = _safe_id(payload.get("projectId") or payload.get("project_id"), "projectId") task_id = _safe_id(payload.get("taskId") or payload.get("task_id"), "taskId") project = _read_json(_project_state_path(arguments, project_id)) if not project: raise ValueError("project not found") + task_meta = _read_json(_task_state_path(arguments, task_id)) + task_project_id = _first_text(task_meta.get("project_id"), task_meta.get("projectId")) + if task_project_id and task_project_id != project_id: + raise ValueError("task does not belong to project") + if task_meta: + task_status = str(task_meta.get("status") or "") + if task_status not in {"submitted", *TERMINAL_TASK_STATUSES}: + raise ValueError(f"accept_task_result requires submitted task state, got {task_status or 'missing'}") + requested_submission_id = _first_text(payload.get("submissionId"), payload.get("submission_id")) + persisted_submission_id = _first_text(task_meta.get("submission_id"), task_meta.get("submissionId")) + legacy_identity_persisted = False + if persisted_submission_id and not requested_submission_id: + raise ValueError("submissionId is required for the current task submission") + if task_meta and not persisted_submission_id and requested_submission_id: + raise ValueError("accept_task_result requires a submission identity") + if requested_submission_id and requested_submission_id != persisted_submission_id: + raise ValueError("submissionId does not match the current task submission") + if task_meta: + persisted_result, validation_errors = _task_result_from_meta(task_meta) + if validation_errors: + raise ValueError(f"persisted task result is invalid: {'; '.join(validation_errors)}") + persisted_result_digest = _first_text( + task_meta.get("result_digest"), + task_meta.get("resultDigest"), + ) + if persisted_result_digest and persisted_result_digest != _task_result_digest(persisted_result): + raise ValueError("persisted task result digest does not match its structured result") + if not persisted_submission_id: + persisted_submission_id = uuid.uuid4().hex + task_meta["submission_id"] = persisted_submission_id + task_meta["submitted_at"] = _utc_timestamp() + task_meta["result_digest"] = _task_result_digest(persisted_result) + task_meta["continuation"] = { + "status": "pending", + "delivery_id": _continuation_delivery_id( + project_id, + task_id, + persisted_submission_id, + ), + } + try: + _write_task(arguments, task_meta) + except OSError as exc: + return _uncommitted_state_failure_result( + tool="projectflow", + action="accept_task_result", + error=exc, + ) + legacy_identity_persisted = True result_status_value = payload.get("resultStatus") or payload.get("result_status") + if task_meta: + persisted_result_status = str(task_meta.get("result_status") or "") + if str(result_status_value or "SUCCESS").strip() != persisted_result_status: + raise ValueError("resultStatus does not match the submitted task result") accepted = _payload_bool(payload.get("accepted"), True) node_status = _accepted_node_status(result_status_value) if not accepted and node_status == "completed": result_status_value = "REVISION_NEEDED" node_status = "revision" + current_node_status = _project_task_status(project, task_id) + if current_node_status in TERMINAL_TASK_STATUSES: + if current_node_status != node_status: + raise ValueError(f"task result already decided as {current_node_status}") + try: + _write_project_plan(_project_dir(arguments, project_id), project) + except OSError as exc: + return _persisted_state_failure_result( + tool="projectflow", + action="accept_task_result", + error=exc, + task=task_meta or None, + project=project, + ) + if not _sync_project(arguments, project_id): + return _sync_failure_result({ + "tool": "projectflow", + "action": "accept_task_result", + "project": project, + "task": task_meta or None, + "taskId": task_id, + "submissionId": persisted_submission_id or None, + "nodeStatus": current_node_status, + "accepted": current_node_status == "completed", + "reused": True, + "publishedArtifacts": [], + "notificationNeeded": {}, + }, "accept_task_result project") + repaired_task_fence = bool(task_meta) and ( + str(task_meta.get("status") or "") != current_node_status + or ( + isinstance(task_meta.get("continuation"), dict) + and task_meta["continuation"].get("status") != "resolved" + ) + ) + synced: bool | None = None + if repaired_task_fence: + task_meta["status"] = current_node_status + _resolve_task_continuation(task_meta, current_node_status) + try: + _write_task(arguments, task_meta) + except OSError as exc: + return _persisted_state_failure_result( + tool="projectflow", + action="accept_task_result", + error=exc, + task=task_meta, + project=project, + ) + if task_meta: + # A previous acceptance may have committed locally while its + # shared-storage push failed. Retrying the same decision must + # repair that external side effect without rewriting project + # state or reopening the requester report. + synced = _sync_task(arguments, task_id, exclude=["spec.md", "base/"]) + reused_result = { + "ok": True, + "tool": "projectflow", + "action": "accept_task_result", + "project": project, + "taskId": task_id, + "submissionId": persisted_submission_id or None, + "nodeStatus": current_node_status, + "accepted": current_node_status == "completed", + "reused": True, + "repairedTaskFence": repaired_task_fence, + "publishedArtifacts": [], + "notificationNeeded": {}, + } + if task_meta: + reused_result["task"] = task_meta + if synced is not None: + reused_result["synced"] = synced + if not synced: + return _sync_failure_result(reused_result, "accept_task_result") + return reused_result changed = False - for task in project.get("tasks", []): - if task.get("task_id") == task_id: - task["status"] = node_status + for project_task in project.get("tasks", []): + if project_task.get("task_id") == task_id: + project_task["status"] = node_status changed = True break loop = project.get("loop") if isinstance(project.get("loop"), dict) else {} loop_tasks = loop.get("tasks", []) if isinstance(loop.get("tasks"), list) else [] - for task in loop_tasks: - if task.get("task_id") == task_id: - task["status"] = node_status + for project_task in loop_tasks: + if project_task.get("task_id") == task_id: + project_task["status"] = node_status project["loop"] = loop changed = True break @@ -3114,9 +3416,59 @@ def _accept_task_result(arguments: dict[str, Any], payload: dict[str, Any]) -> d requester_report["pending"] = False requester_report["reason"] = f"task_result_{node_status}" project["requester_report"] = requester_report - _write_json(_project_state_path(arguments, project_id), project) - _write_project_plan(_project_dir(arguments, project_id), project) - _sync_project(arguments, project_id) + try: + _write_json(_project_state_path(arguments, project_id), project) + except OSError as exc: + if legacy_identity_persisted: + return _persisted_state_failure_result( + tool="projectflow", + action="accept_task_result", + error=exc, + task=task_meta, + ) + return _uncommitted_state_failure_result( + tool="projectflow", + action="accept_task_result", + error=exc, + ) + try: + _write_project_plan(_project_dir(arguments, project_id), project) + except OSError as exc: + return _persisted_state_failure_result( + tool="projectflow", + action="accept_task_result", + error=exc, + task=task_meta or None, + project=project, + ) + if not _sync_project(arguments, project_id): + return _sync_failure_result({ + "tool": "projectflow", + "action": "accept_task_result", + "project": project, + "task": task_meta or None, + "taskId": task_id, + "submissionId": persisted_submission_id or None, + "nodeStatus": node_status, + "accepted": node_status == "completed", + "publishedArtifacts": [], + "notificationNeeded": {}, + }, "accept_task_result project") + synced: bool | None = None + if task_meta: + task_meta["status"] = node_status + _resolve_task_continuation(task_meta, node_status) + try: + _write_task(arguments, task_meta) + except OSError as exc: + return _persisted_state_failure_result( + tool="projectflow", + action="accept_task_result", + error=exc, + task=task_meta, + project=project, + ) + synced = _sync_task(arguments, task_id, exclude=["spec.md", "base/"]) publish_artifacts = _payload_bool_field(payload, ("publishArtifacts", "publish_artifacts"), False) published_artifacts = ( _publish_project_artifacts( @@ -3126,16 +3478,18 @@ def _accept_task_result(arguments: dict[str, Any], payload: dict[str, Any]) -> d task_id, _attachment_parent_event_id(payload, arguments), ) - if node_status == "completed" and publish_artifacts else [] + if node_status == "completed" and publish_artifacts and synced is not False else [] ) requester_report = project.get("requester_report") if isinstance(project.get("requester_report"), dict) else {} requester_report_pending = requester_report.get("pending") is True and requester_report.get("task_id") == task_id - return { + result = { "ok": True, "tool": "projectflow", "action": "accept_task_result", "project": project, + "task": task_meta or None, "taskId": task_id, + "submissionId": persisted_submission_id or None, "nodeStatus": node_status, "accepted": node_status == "completed", "publishedArtifacts": published_artifacts, @@ -3146,6 +3500,11 @@ def _accept_task_result(arguments: dict[str, Any], payload: dict[str, Any]) -> d include_reply_route=requester_report_pending, ), } + if synced is not None: + result["synced"] = synced + if not synced: + return _sync_failure_result(result, "accept_task_result") + return result def _mark_requester_report_sent(arguments: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: @@ -3402,11 +3761,7 @@ def _projectflow(arguments: dict[str, Any]) -> dict[str, Any]: raw_tasks = payload.get("tasks") if not isinstance(raw_tasks, list): raise ValueError("tasks must be a list") - planned_tasks = [ - _normalize_task(task, previous.get(str(task.get("taskId") or task.get("task_id")))) - for task in raw_tasks - if isinstance(task, dict) - ] + planned_tasks = _normalize_tasks(raw_tasks, previous) _validate_task_graph(planned_tasks) project["tasks"] = planned_tasks project["plan_type"] = "dag" @@ -3442,11 +3797,7 @@ def _projectflow(arguments: dict[str, Any]) -> dict[str, Any]: ) if current_iteration > max_iterations: raise ValueError("currentIteration cannot exceed maxIterations") - planned_tasks = [ - _normalize_task(task, previous_tasks.get(str(task.get("taskId") or task.get("task_id")))) - for task in raw_tasks - if isinstance(task, dict) - ] + planned_tasks = _normalize_tasks(raw_tasks, previous_tasks) _validate_task_graph(planned_tasks) loop = { "goal": str(payload.get("goal") or previous_loop.get("goal") or "").strip(), @@ -3626,10 +3977,10 @@ def _message_tool_blocked_for_runtime_role() -> bool: def _role(arguments: dict[str, Any]) -> str: - role = str(arguments.get("role") or "").strip() - if not role: - return _runtime_role() - return _normalize_role(role) + runtime_role = _runtime_role() + if runtime_role: + return runtime_role + return _normalize_role(str(arguments.get("role") or "").strip()) def _load_task(arguments: dict[str, Any], task_id: str) -> dict[str, Any]: @@ -3735,8 +4086,11 @@ def _ensure_console_task_meta(arguments: dict[str, Any], task: dict[str, Any]) - for snake_key, camel_key in ( ("acknowledged_by_role", "acknowledgedByRole"), ("result_status", "resultStatus"), + ("result_digest", "resultDigest"), ("result_path", "resultPath"), ("submitted_by_role", "submittedByRole"), + ("submission_id", "submissionId"), + ("submitted_at", "submittedAt"), ): value = _first_text(task.get(snake_key), task.get(camel_key)) if value: @@ -3753,8 +4107,11 @@ def _ensure_console_task_meta(arguments: dict[str, Any], task: dict[str, Any]) - "createdAt", "acknowledgedByRole", "resultStatus", + "resultDigest", "resultPath", "submittedByRole", + "submissionId", + "submittedAt", ): task.pop(key, None) @@ -3764,7 +4121,20 @@ def _write_task(arguments: dict[str, Any], task: dict[str, Any]) -> None: _write_json(_task_state_path(arguments, task["task_id"]), task) -ALLOWED_TASK_RESULT_STATUSES = {"SUCCESS", "SUCCESS_WITH_NOTES", "REVISION_NEEDED", "BLOCKED", "FAILED", "PARTIAL"} +ALLOWED_TASK_RESULT_STATUSES = { + "SUCCESS", + "SUCCESS_WITH_NOTES", + "REVISION_NEEDED", + "BLOCKED", + "INTERRUPTED", +} + + +def _validate_task_result_status(value: Any) -> str: + status = str(value or "SUCCESS").strip() + if status not in ALLOWED_TASK_RESULT_STATUSES: + raise ValueError(f"unsupported result status: {status}") + return status def _validate_task_deliverables(task_id: str, deliverables: list[Any]) -> list[str]: @@ -3808,7 +4178,34 @@ def _task_result_from_meta(task: dict[str, Any]) -> tuple[dict[str, Any], list[s return result, errors -def _sync_task(arguments: dict[str, Any], task_id: str, exclude: list[str] | None = None) -> bool: +def _sync_task( + arguments: dict[str, Any], + task_id: str, + exclude: list[str] | None = None, + result_paths: list[str] | None = None, +) -> bool: + if result_paths is not None: + task_prefix = f"shared/tasks/{task_id}" + local_task_dir = _task_dir(arguments, task_id) + payload_paths: list[str] = [] + if (local_task_dir / "result.md").is_file(): + payload_paths.append(f"{task_prefix}/result.md") + for path in result_paths: + if path not in payload_paths: + payload_paths.append(path) + for path in payload_paths: + for action in ("push", "stat"): + sync_args = dict(arguments) + sync_args.update({"action": action, "path": path}) + if not _filesync(sync_args).get("ok"): + return False + commit_path = f"{task_prefix}/meta.json" + for action in ("push", "stat"): + commit_args = dict(arguments) + commit_args.update({"action": action, "path": commit_path}) + if not _filesync(commit_args).get("ok"): + return False + return True sync_args = dict(arguments) sync_args.update({ "action": "push", @@ -3949,11 +4346,11 @@ def _require_task_mutable(arguments: dict[str, Any], task: dict[str, Any], task_ raise ValueError(f"{action} cannot update terminal task: {terminal_status}") -def _update_project_task(arguments: dict[str, Any], project_id: str, task_id: str, **updates: Any) -> None: +def _update_project_task(arguments: dict[str, Any], project_id: str, task_id: str, **updates: Any) -> bool: path = _project_state_path(arguments, project_id) project = _read_json(path) if not project: - return + return False changed = False for task in project.get("tasks", []): if task.get("task_id") == task_id: @@ -3970,7 +4367,7 @@ def _update_project_task(arguments: dict[str, Any], project_id: str, task_id: st if changed: _write_json(path, project) _write_project_plan(_project_dir(arguments, project_id), project) - _sync_project(arguments, project_id) + return _sync_project(arguments, project_id) def _validate_assignee_membership(room_id: str, assignee: str) -> dict[str, Any]: @@ -4340,6 +4737,9 @@ def _taskflow(arguments: dict[str, Any]) -> dict[str, Any]: if role not in {"worker", "remote-member"}: raise ValueError("ack_task requires worker or remote-member role") task_id = _safe_id(payload.get("taskId") or payload.get("task_id"), "taskId") + local_task = _read_json(_task_state_path(arguments, task_id)) + if local_task: + _require_task_mutable(arguments, local_task, task_id, action) pulled = _pull_task(arguments, task_id) task = _load_task(arguments, task_id) _require_task_mutable(arguments, task, task_id, action) @@ -4367,26 +4767,140 @@ def _taskflow(arguments: dict[str, Any]) -> dict[str, Any]: task = _load_task(arguments, task_id) _require_task_mutable(arguments, task, task_id, action) summary = str(payload.get("summary") or "") - status = str(payload.get("status") or "SUCCESS") + status = _validate_task_result_status(payload.get("status")) deliverables = payload.get("deliverables") or [] if not isinstance(deliverables, list): raise ValueError("deliverables must be a list") deliverables = _validate_task_deliverables(task_id, deliverables) + submitted_result = { + "status": status, + "summary": summary, + "deliverables": deliverables, + } + submitted_digest = _task_result_digest(submitted_result) + if task.get("status") == "submitted": + persisted_digest = _first_text( + task.get("result_digest"), + task.get("resultDigest"), + ) + if not persisted_digest: + persisted_digest = _task_result_digest(_submission_result(task)) + if persisted_digest != submitted_digest: + raise ValueError("submit_task conflicts with existing submission") + submission_id = _first_text( + task.get("submission_id"), + task.get("submissionId"), + ) + if not submission_id: + raise ValueError("legacy submitted task has no submission identity and cannot be resubmitted") + if not task.get("result_digest"): + task["result_digest"] = persisted_digest + try: + _write_task(arguments, task) + except OSError as exc: + return _persisted_state_failure_result( + tool="taskflow", + action=action, + error=exc, + task=task, + ) + try: + project_synced = _update_project_task( + arguments, + task.get("project_id", ""), + task_id, + status="submitted", + ) + except OSError as exc: + return _persisted_state_failure_result( + tool="taskflow", + action=action, + error=exc, + task=task, + ) + if not project_synced: + return _sync_failure_result({ + "tool": "taskflow", + "action": action, + "task": task, + "reused": True, + "publishedArtifacts": [], + }, "submit_task project") + synced = _sync_task( + arguments, + task_id, + exclude=["spec.md", "base/"], + result_paths=deliverables, + ) + result = { + "ok": True, + "tool": "taskflow", + "action": action, + "task": task, + "reused": True, + "publishedArtifacts": [], + "synced": synced, + "notificationNeeded": _notification_needed( + "submit_task", + {"project_id": task.get("project_id", "")}, + task, + summary=f"submit_task: {task_id} ({status})", + ), + } + if not synced: + return _sync_failure_result(result, "submit_task") + return result task_dir = _task_dir(arguments, task_id) task_dir.mkdir(parents=True, exist_ok=True) + submission_id = uuid.uuid4().hex + submitted_at = _utc_timestamp() task.update({ "status": "submitted", "result_status": status, "summary": summary, "deliverables": deliverables, "submitted_by_role": role, + "submission_id": submission_id, + "submitted_at": submitted_at, + "result_digest": submitted_digest, + "continuation": { + "status": "pending", + "delivery_id": _continuation_delivery_id( + _first_text(task.get("project_id"), task.get("projectId")), + task_id, + submission_id, + ), + }, }) if (task_dir / "result.md").is_file(): task["result_path"] = f"shared/tasks/{task_id}/result.md" else: task.pop("result_path", None) - _write_task(arguments, task) - _update_project_task(arguments, task.get("project_id", ""), task_id, status="submitted") + try: + _write_task(arguments, task) + except OSError as exc: + return _uncommitted_state_failure_result(tool="taskflow", action=action, error=exc) + try: + project_synced = _update_project_task( + arguments, + task.get("project_id", ""), + task_id, + status="submitted", + ) + except OSError as exc: + return _persisted_state_failure_result( + tool="taskflow", + action=action, + error=exc, + task=task, + ) + if not project_synced: + return _sync_failure_result({ + "tool": "taskflow", + "action": action, + "task": task, + "publishedArtifacts": [], + }, "submit_task project") published_artifacts = _publish_task_artifacts( arguments, task, @@ -4394,13 +4908,19 @@ def _taskflow(arguments: dict[str, Any]) -> dict[str, Any]: deliverables, _attachment_parent_event_id(payload, arguments), ) - return { + synced = _sync_task( + arguments, + task_id, + exclude=["spec.md", "base/"], + result_paths=deliverables, + ) + result = { "ok": True, "tool": "taskflow", "action": action, "task": task, "publishedArtifacts": published_artifacts, - "synced": _sync_task(arguments, task_id, exclude=["spec.md", "base/"]), + "synced": synced, "notificationNeeded": _notification_needed( "submit_task", {"project_id": task.get("project_id", "")}, @@ -4408,38 +4928,130 @@ def _taskflow(arguments: dict[str, Any]) -> dict[str, Any]: summary=f"submit_task: {task_id} ({status})", ), } + if not synced: + return _sync_failure_result(result, "submit_task") + return result if action == "cancel_task": if role != "leader": raise ValueError("cancel_task requires leader role") task_id = _safe_id(payload.get("taskId") or payload.get("task_id"), "taskId") task = _load_task(arguments, task_id) + requested_submission_id = _first_text( + payload.get("submissionId"), + payload.get("submission_id"), + ) + persisted_submission_id = _first_text( + task.get("submission_id"), + task.get("submissionId"), + ) + if persisted_submission_id and not requested_submission_id: + raise ValueError("submissionId is required for the current task submission") + if requested_submission_id and requested_submission_id != persisted_submission_id: + raise ValueError("submissionId does not match the current task submission") project_id = str(task.get("project_id") or "") - terminal_status = _terminal_task_status(arguments, task, task_id) - if terminal_status: - raise ValueError(f"cannot cancel terminal task: {terminal_status}") reason = str(payload.get("reason") or payload.get("cancelReason") or payload.get("cancel_reason") or "").strip() if not reason: raise ValueError("reason is required") replacement_task_id = payload.get("replacementTaskId") or payload.get("replacement_task_id") + normalized_replacement_task_id = ( + _safe_id(replacement_task_id, "replacementTaskId") if replacement_task_id else "" + ) + terminal_status = _terminal_task_status(arguments, task, task_id) + continuation = task.get("continuation") if isinstance(task.get("continuation"), dict) else {} + cancellation_committed = bool(task.get("cancelled_at")) or continuation.get("status") == "resolved" + if terminal_status == "cancelled" and cancellation_committed: + persisted_reason = str(task.get("cancel_reason") or "").strip() + persisted_replacement_task_id = str(task.get("replacement_task_id") or "").strip() + if ( + persisted_reason != reason + or persisted_replacement_task_id != normalized_replacement_task_id + ): + raise ValueError("cancel_task conflicts with existing cancellation") + try: + project_synced = _update_project_task( + arguments, + project_id, + task_id, + status="cancelled", + ) + except OSError as exc: + return _persisted_state_failure_result( + tool="taskflow", + action=action, + error=exc, + task=task, + ) + if not project_synced: + return _sync_failure_result({ + "tool": "taskflow", + "action": action, + "task": task, + "project": _read_json(_project_state_path(arguments, project_id)) if project_id else {}, + "reused": True, + }, "cancel_task project") + synced = _sync_task(arguments, task_id, exclude=["spec.md", "base/"]) + result = { + "ok": True, + "tool": "taskflow", + "action": action, + "task": task, + "project": _read_json(_project_state_path(arguments, project_id)) if project_id else {}, + "reused": True, + "synced": synced, + } + if not synced: + return _sync_failure_result(result, "cancel_task") + return result + if terminal_status: + raise ValueError(f"cannot cancel terminal task: {terminal_status}") task["status"] = "cancelled" task["cancel_reason"] = reason - if replacement_task_id: - task["replacement_task_id"] = _safe_id(replacement_task_id, "replacementTaskId") + task["cancelled_at"] = _utc_timestamp() + _resolve_task_continuation(task, "cancelled") + if normalized_replacement_task_id: + task["replacement_task_id"] = normalized_replacement_task_id else: task.pop("replacement_task_id", None) - _write_task(arguments, task) + try: + _write_task(arguments, task) + except OSError as exc: + return _uncommitted_state_failure_result(tool="taskflow", action=action, error=exc) - _update_project_task(arguments, project_id, task_id, status="cancelled") - return { + try: + project_synced = _update_project_task( + arguments, + project_id, + task_id, + status="cancelled", + ) + except OSError as exc: + return _persisted_state_failure_result( + tool="taskflow", + action=action, + error=exc, + task=task, + ) + if not project_synced: + return _sync_failure_result({ + "tool": "taskflow", + "action": action, + "task": task, + "project": _read_json(_project_state_path(arguments, project_id)) if project_id else {}, + }, "cancel_task project") + synced = _sync_task(arguments, task_id, exclude=["spec.md", "base/"]) + result = { "ok": True, "tool": "taskflow", "action": action, "task": task, "project": _read_json(_project_state_path(arguments, project_id)) if project_id else {}, - "synced": _sync_task(arguments, task_id, exclude=["spec.md", "base/"]), + "synced": synced, } + if not synced: + return _sync_failure_result(result, "cancel_task") + return result if action == "check_task": if role != "leader": diff --git a/plugins/teamharness/skills/team/project-management/SKILL.md b/plugins/teamharness/skills/team/project-management/SKILL.md index a3e5034c6..f3cea7ad7 100644 --- a/plugins/teamharness/skills/team/project-management/SKILL.md +++ b/plugins/teamharness/skills/team/project-management/SKILL.md @@ -1,6 +1,6 @@ --- name: teamharness-project-management -description: "Use when a Leader maintains durable TeamHarness project state for Quick Task or Project Work: create_quick_project, create_project, plan_dag, plan_loop, ready_nodes, resolve_project, accept_task_result, project completion, and requester report state. Do not use to create Matrix task rooms or send messages." +description: "Use when you act as Leader and maintain durable TeamHarness project state for Quick Task or Project Work: create_quick_project, create_project, plan_dag, plan_loop, ready_nodes, resolve_project, accept_task_result, project completion, and requester report state. Do not use to create Matrix task rooms or send messages." --- # Project Management @@ -10,7 +10,8 @@ Use this skill when maintaining durable project state. A project owns the plan, context, dependencies, and accepted progress. Keep the project plan separate from individual task execution logs. -Only advance a dependency after the Leader accepts the submitted result. +Only advance a dependency after you accept the submitted result as the trusted +Leader runtime. Do not use this skill for ordinary direct replies or lightweight one-off actions. @@ -55,7 +56,7 @@ Write a final project result, when needed, to: shared/projects/{project-id}/result.md ``` -The Leader owns this project result file. Build it from accepted project state +You own this project result file as Leader. Build it from accepted project state and accepted task deliverables; do not ask Workers to write or submit it as a task deliverable. @@ -337,9 +338,15 @@ ready nodes that the Leader needs to resume normal project flow. ## Accepting Worker Results -A Worker `SUCCESS` or `SUCCESS_WITH_NOTES` result is only a candidate result. +Treat a Worker `SUCCESS` or `SUCCESS_WITH_NOTES` result as only a candidate. After `teamharness-task-delegation` checks the task and returns `effective: -true`, decide whether to accept the result. +true`, copy its current `task.submission_id` and decide whether to accept the +result. + +You may make this decision only when the runtime configuration identifies you +as the trusted Leader. Never rely on a payload `role` to override a Worker +runtime, and never let a Worker accept, reject, cancel, or resolve its own +continuation. To accept a result, call `accept_task_result`: @@ -349,6 +356,8 @@ To accept a result, call `accept_task_result`: "payload": { "projectId": "demo-project-001", "taskId": "demo-project-001-01", + "submissionId": "<current task.submission_id from check_task>", + "accepted": true, "resultStatus": "SUCCESS", "summary": "Completed the assigned work." } @@ -356,8 +365,29 @@ To accept a result, call `accept_task_result`: ``` `accept_task_result` updates the DAG or Loop node and records -`requester_report.pending` in ProjectMeta. Keep unresolved nodes in their current -state. +the terminal task decision. With the runtime-neutral standalone MCP it also +records `requester_report.pending` in ProjectMeta. Native CoPaw `projectflow` +commits only the plan and TaskMeta terminal state; it does not invent a +`requester_report`. In both runtimes you must still follow the existing +requester-report flow and `replyRoute`; absence of a CoPaw pending marker is not +permission to omit the report. Keep unresolved nodes in their current state. + +For a normal task that already has a `submission_id`, you must pass that exact +value as `submissionId` to both acceptance and cancellation. Retry only with the +same `submissionId` and `accepted` decision; a missing or stale identity, or a +different terminal decision, is a conflict. The only acceptance exception is +the documented standalone migration of a persisted legacy submission with no +identity. Native CoPaw decisions do not perform that migration; first complete +the evidence-based Worker retry adoption, then use its generated identity. + +To cancel a task, include a reason and, whenever the task has a submission +identity, keep the same `submissionId` fence. In the +CoPaw runtime call `projectflow` with `action: "cancel_task"`; with the +runtime-neutral standalone MCP call `taskflow` with `action: "cancel_task"`. +Both routes are trusted-Leader-only and resolve the existing continuation as +`cancelled` without rotating its `delivery_id`. The standalone MCP retains its +documented compatibility for cancelling legacy TaskMeta that has no identity; +do not invent or supply an unknown identity for that path. Accepting a completed result does not publish the project artifact by default. Write or update `shared/projects/{project-id}/result.md` as the Leader when a @@ -515,7 +545,7 @@ After the requester report is sent, clear the pending flag: State-mutating `projectflow` and `taskflow` operations return a `notificationNeeded` field when they succeed. This field is a hint — the tool -does not send any message automatically. The Leader must act on it. +does not send any message automatically. You must act on it as Leader. When `notificationNeeded` is present in the tool result: diff --git a/plugins/teamharness/skills/team/task-delegation/SKILL.md b/plugins/teamharness/skills/team/task-delegation/SKILL.md index 2781717a9..aa9f45970 100644 --- a/plugins/teamharness/skills/team/task-delegation/SKILL.md +++ b/plugins/teamharness/skills/team/task-delegation/SKILL.md @@ -1,6 +1,6 @@ --- name: teamharness-task-delegation -description: "Use when a Leader turns ready Quick Task or Project Work state into Worker task instructions, sends assignment messages, checks submitted results, and defines completion/blocker report contracts. Do not use to create projects, create rooms, or execute Worker tasks." +description: "Use when you act as Leader to turn ready Quick Task or Project Work state into Worker task instructions, send assignment messages, check submitted results, and define completion/blocker report contracts. Do not use to create projects, create rooms, or execute Worker tasks." --- # Task Delegation @@ -147,12 +147,17 @@ When a Worker reports completion or blocker status, call: If `effective` is false, do not accept the task. Tell the Worker what is missing and wait for a corrected result. -If `effective` is true, return to `teamharness-project-management` and decide -whether to accept the result into project progress. +If `effective` is true, retain the returned current `task.submission_id`, +return to `teamharness-project-management`, and pass that identity as +`submissionId` with an explicit boolean `accepted` decision. You may decide +only as the trusted Leader runtime; do not trust a payload role, and never +delegate acceptance or cancellation to the Worker. For every normal task that +already has a submission identity, omitting `submissionId` is an error for both +accept and cancel; only the documented no-identity legacy migration may omit it. ## Result Contract -Worker results should contain: +Expect Worker results to contain: ```text STATUS: SUCCESS @@ -162,7 +167,7 @@ DELIVERABLES: - shared/tasks/{task-id}/path ``` -For report-style tasks, the Worker may write the full report directly to +For report-style tasks, let the Worker write the full report directly to `shared/tasks/{task-id}/result.md` before calling `submit_task`. The tool records structured status in task metadata and does not create or rewrite `result.md`. Do not treat `result.md` as only a short envelope when it is the @@ -174,6 +179,11 @@ Accepted statuses are: - `SUCCESS_WITH_NOTES` - `REVISION_NEEDED` - `BLOCKED` +- `INTERRUPTED` + +Treat `INTERRUPTED` like `BLOCKED` at the terminal decision boundary: accepting +either status records the task and plan node as `blocked` and resolves the +continuation with `resolution: blocked`. Submitting a result ends that Worker task. If more work is needed, create a new project node and delegate a new task. @@ -190,7 +200,7 @@ completion report. When a Worker reports `TASK_COMPLETED` with a result path, check the result and follow `teamharness-project-management` for acceptance or rejection. -The Leader should check `notificationNeeded` after accepting a task result to +You should check `notificationNeeded` after accepting a task result to determine whether a requester report or downstream notification is due. See `teamharness-project-management` Post-Action Notification for the full protocol. diff --git a/plugins/teamharness/skills/team/task-execution/SKILL.md b/plugins/teamharness/skills/team/task-execution/SKILL.md index 844c02ecd..dc468d0cc 100644 --- a/plugins/teamharness/skills/team/task-execution/SKILL.md +++ b/plugins/teamharness/skills/team/task-execution/SKILL.md @@ -1,6 +1,6 @@ --- name: teamharness-task-execution -description: "Use when a Worker receives TASK_ASSIGNED, acknowledges the task, works inside shared/tasks/{task-id}/, submits with taskflow submit_task, publishes deliverables through submit_task, and reports TASK_COMPLETED or blockers in the Task room." +description: "Use when you act as Worker: receive TASK_ASSIGNED, acknowledge the task, work inside shared/tasks/{task-id}/, submit with taskflow submit_task, publish deliverables through submit_task, and report TASK_COMPLETED or blockers in the Task room." --- # Task Execution @@ -24,7 +24,7 @@ Your assigned task lives under: shared/tasks/{task-id}/ ``` -The Leader owns: +Your Leader owns: ```text shared/tasks/{task-id}/meta.json @@ -49,7 +49,7 @@ shared/projects/{project-id}/result.md If a task spec asks you to write or submit `shared/projects/...`, report that boundary conflict to the Leader. Put Worker-owned deliverables under -`shared/tasks/{task-id}/...`; the Leader owns project-level reports. +`shared/tasks/{task-id}/...`; your Leader owns project-level reports. ## Acknowledge @@ -122,6 +122,37 @@ Use one of: - `SUCCESS_WITH_NOTES` - `REVISION_NEEDED` - `BLOCKED` +- `INTERRUPTED` + +Use `INTERRUPTED` when execution stopped before you could finish. If your Leader +accepts either `INTERRUPTED` or `BLOCKED`, TeamHarness records the task and plan +node as `blocked` and resolves the continuation with `resolution: blocked`. + +Your first persisted submission records `submission_id`, UTC `submitted_at`, +`result_digest`, and a pending `continuation` marker in TaskMeta. Treat +`submission_id` as an opaque fence: compare it for equality, but do not parse it +or assume a UUID format. The digest covers your trimmed status, whitespace- +collapsed summary, and validated deliverable paths in their persisted order; it does +not cover notes or the rendered `result.md` text. + +If shared-storage sync is interrupted, retry with exactly the same status, +summary, and ordered deliverables. Your retry reuses the original submission, +timestamp, digest, and continuation `delivery_id`, and repairs missing project +or task projections. If you change any digest input, the retry conflicts with +the submitted task and you must wait for a Leader decision or a new task. A +pending continuation is durable state for a future Controller; it does not mean +that a Matrix wake was sent or that your Leader has already resumed the task. + +You cannot accept, reject, cancel, or resolve your own submission. Those are +trusted-Leader-only decisions, and putting `role: leader` in a payload cannot +override your Worker runtime identity. After submitting, use the returned +`submissionId` only as an opaque value in reports or exact retries. + +For a legacy task already marked `submitted` without a submission identity, +retry only when TaskMeta has `submitted_at`, and send the complete original +status, summary, and ordered deliverables. CoPaw adopts it only if that result +exactly matches the persisted result; otherwise it fails closed. Do not invent +an identity or try to resolve the legacy task yourself. Submitting ends the task. Do not keep editing the old task after submission unless the Leader assigns a new task. diff --git a/plugins/tests/run-integration-tests.sh b/plugins/tests/run-integration-tests.sh index c2bf0fe43..9bb2189f0 100755 --- a/plugins/tests/run-integration-tests.sh +++ b/plugins/tests/run-integration-tests.sh @@ -13,6 +13,9 @@ ruby plugins/tests/teamharness/test-contracts.rb python3 -m pytest plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py -q python3 -m pytest plugins/tests/teamharness/adapters/qwenpaw/test_package.py -q python3 -m pytest plugins/tests/teamharness/test_pull_project.py -q +python3 -m pytest plugins/tests/teamharness/mcp/test_continuation.py -q +PYTHONPATH="${REPO_ROOT}/copaw/src${PYTHONPATH:+:${PYTHONPATH}}" \ + python3 -m pytest copaw/tests/test_taskflow_tool.py -q ruby plugins/tests/teamharness/mcp/test-server.rb ruby plugins/tests/teamharness/mcp/tools/test-message.rb ruby plugins/tests/teamharness/mcp/tools/test-filesync.rb diff --git a/plugins/tests/teamharness/mcp/test_continuation.py b/plugins/tests/teamharness/mcp/test_continuation.py new file mode 100644 index 000000000..63d98a67f --- /dev/null +++ b/plugins/tests/teamharness/mcp/test_continuation.py @@ -0,0 +1,1930 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import subprocess +import sys +from typing import Any + +import pytest + + +MCP_DIR = Path(__file__).resolve().parents[3] / "teamharness" / "mcp" +if str(MCP_DIR) not in sys.path: + sys.path.insert(0, str(MCP_DIR)) + +import server # noqa: E402 + + +def _tool_payload(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + call_arguments = dict(arguments) + if ( + name == "projectflow" + and call_arguments.get("action") == "accept_task_result" + and "role" not in call_arguments + ): + # Existing accept contract tests exercise a Leader-only operation. + # Keep that caller identity explicit now that the public boundary + # enforces it, while individual authorization tests can override it. + call_arguments["role"] = "leader" + response = server.call_tool(name, call_arguments) + return json.loads(response["content"][0]["text"]) + + +def _write_project_and_task( + workspace: Path, + *, + task_status: str = "in_progress", + project_id: str = "continuation-project", + task_id: str = "continuation-project-01", +) -> tuple[str, str]: + project = { + "project_id": project_id, + "title": "Continuation contract", + "status": "active", + "tasks": [ + { + "task_id": task_id, + "title": "Produce a result", + "assigned_to": "@worker:example.test", + "depends_on": [], + "status": task_status, + } + ], + "requester_report": { + "pending": False, + "sent_at": "2026-08-13T08:00:00Z", + }, + } + task = { + "task_id": task_id, + "project_id": project_id, + "room_id": "!task:example.test", + "status": task_status, + } + server._write_json(workspace / "shared" / "projects" / project_id / "meta.json", project) + server._write_json(workspace / "shared" / "tasks" / task_id / "meta.json", task) + return project_id, task_id + + +def _submit(workspace: Path, task_id: str, **overrides: Any) -> dict[str, Any]: + payload: dict[str, Any] = { + "taskId": task_id, + "status": "SUCCESS", + "summary": "The result is ready.", + "deliverables": [f"shared/tasks/{task_id}/result.md"], + } + payload.update(overrides) + return _tool_payload( + "taskflow", + { + "role": "worker", + "action": "submit_task", + "workspaceDir": str(workspace), + "payload": payload, + }, + ) + + +@pytest.fixture(autouse=True) +def authoritative_project_pull(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep continuation tests focused on the post-pull state contract. + + Project pull success/failure semantics are covered by + ``test_pull_project.py``. Every mutating TeamHarness action now performs + that pull before it reaches the submission and terminal-decision logic. + """ + monkeypatch.setattr(server, "_pull_project", lambda *_args, **_kwargs: True) + + +@pytest.fixture +def successful_side_effects(monkeypatch: pytest.MonkeyPatch) -> dict[str, list[Any]]: + calls: dict[str, list[Any]] = {"publish": [], "sync": [], "project_sync": []} + + def publish(*args: Any, **kwargs: Any) -> list[dict[str, str]]: + calls["publish"].append((args, kwargs)) + return [{"status": "published", "eventId": "$artifact"}] + + def sync(*args: Any, **kwargs: Any) -> bool: + calls["sync"].append((args, kwargs)) + return True + + def project_sync(*args: Any, **kwargs: Any) -> bool: + calls["project_sync"].append((args, kwargs)) + return True + + monkeypatch.setattr(server, "_publish_task_artifacts", publish) + monkeypatch.setattr(server, "_publish_project_artifacts", publish) + monkeypatch.setattr(server, "_sync_task", sync) + monkeypatch.setattr(server, "_sync_project", project_sync) + return calls + + +def test_first_submission_records_stable_continuation_identity( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + + submitted = _submit(tmp_path, task_id) + + assert submitted["ok"] is True + assert submitted.get("reused") is not True + task = submitted["task"] + assert task["submission_id"] + assert task["submitted_at"].endswith("Z") + expected_delivery_id = hashlib.sha256( + "\0".join( + (project_id, task_id, task["submission_id"], "result-submitted:v1") + ).encode() + ).hexdigest() + assert task["continuation"] == { + "status": "pending", + "delivery_id": expected_delivery_id, + } + persisted = json.loads( + (tmp_path / "shared" / "tasks" / task_id / "meta.json").read_text(encoding="utf-8") + ) + assert persisted == task + assert len(successful_side_effects["publish"]) == 1 + + +def test_first_submission_uses_cross_runtime_canonical_result_digest( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], +) -> None: + _project_id, task_id = _write_project_and_task( + tmp_path, + project_id="tp-01", + task_id="st-digest", + ) + + submitted = _submit( + tmp_path, + task_id, + status="SUCCESS", + summary=" 完成\n API\t设计 ", + deliverables=[ + "shared/tasks/st-digest/workspace/b.md", + "shared/tasks/st-digest/workspace/a.md", + ], + # Runtime prose is accepted but does not participate in the shared + # structured-result identity. + notes=["这段文字不应改变摘要。"], + ) + + assert submitted["ok"] is True + assert submitted["task"]["result_digest"] == ( + "cb1daffd3cf60982383e60cf0a09a719abb2a2bf378471a494cebad0bf1fbec7" + ) + persisted = json.loads( + (tmp_path / "shared" / "tasks" / task_id / "meta.json").read_text( + encoding="utf-8" + ) + ) + assert persisted["result_digest"] == submitted["task"]["result_digest"] + + +def test_identical_submission_retry_reuses_first_write_without_republishing( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], +) -> None: + _project_id, task_id = _write_project_and_task(tmp_path) + first = _submit(tmp_path, task_id) + meta_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + first_bytes = meta_path.read_bytes() + + retried = _submit(tmp_path, task_id) + + assert retried["ok"] is True + assert retried["reused"] is True + assert retried["task"]["submission_id"] == first["task"]["submission_id"] + assert retried["task"]["submitted_at"] == first["task"]["submitted_at"] + assert retried["publishedArtifacts"] == [] + assert meta_path.read_bytes() == first_bytes + assert len(successful_side_effects["publish"]) == 1 + + +def test_submission_retry_repairs_project_node_after_partial_write( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + real_update = server._update_project_task + update_attempts = 0 + + def fail_first_project_update(*args: Any, **kwargs: Any) -> bool: + nonlocal update_attempts + update_attempts += 1 + if update_attempts == 1: + raise OSError("forced project update failure") + return real_update(*args, **kwargs) + + monkeypatch.setattr(server, "_update_project_task", fail_first_project_update) + + first = _submit(tmp_path, task_id) + persisted_task = json.loads( + (tmp_path / "shared" / "tasks" / task_id / "meta.json").read_text(encoding="utf-8") + ) + persisted_project = json.loads( + (tmp_path / "shared" / "projects" / project_id / "meta.json").read_text(encoding="utf-8") + ) + + assert first["ok"] is False + assert first["retryable"] is True + assert first["statePersisted"] is True + assert persisted_task["status"] == "submitted" + assert persisted_project["tasks"][0]["status"] == "in_progress" + + retried = _submit(tmp_path, task_id) + repaired_project = json.loads( + (tmp_path / "shared" / "projects" / project_id / "meta.json").read_text(encoding="utf-8") + ) + + assert retried["ok"] is True + assert retried["reused"] is True + assert retried["task"]["submission_id"] == persisted_task["submission_id"] + assert repaired_project["tasks"][0]["status"] == "submitted" + assert len(successful_side_effects["publish"]) == 0 + + +def test_conflicting_submission_retry_preserves_original_meta( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], +) -> None: + _project_id, task_id = _write_project_and_task(tmp_path) + _submit(tmp_path, task_id) + meta_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + first_bytes = meta_path.read_bytes() + + conflict = _submit(tmp_path, task_id, summary="A different result.") + + assert conflict["ok"] is False + assert "conflicts with existing submission" in conflict["error"] + assert meta_path.read_bytes() == first_bytes + assert len(successful_side_effects["publish"]) == 1 + + +def test_legacy_submitted_task_rejects_a_different_result_without_mutation( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path, task_status="submitted") + task_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + legacy_task = json.loads(task_path.read_text(encoding="utf-8")) + legacy_task.update( + { + "result_status": "SUCCESS", + "summary": "The legacy result is ready.", + "deliverables": [f"shared/tasks/{task_id}/result.md"], + "submitted_at": "2026-08-12T08:00:00Z", + } + ) + server._write_json(task_path, legacy_task) + project_path = tmp_path / "shared" / "projects" / project_id / "meta.json" + before_project = project_path.read_bytes() + before_task = task_path.read_bytes() + + conflict = _submit(tmp_path, task_id, summary="A replacement result.") + + assert conflict["ok"] is False + assert "conflicts with existing submission" in conflict["error"] + assert project_path.read_bytes() == before_project + assert task_path.read_bytes() == before_task + assert successful_side_effects["publish"] == [] + assert successful_side_effects["sync"] == [] + + +def test_legacy_submitted_task_rejects_an_identical_retry_without_creating_identity( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path, task_status="submitted") + task_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + legacy_task = json.loads(task_path.read_text(encoding="utf-8")) + legacy_task.update( + { + "result_status": "SUCCESS", + "summary": "The result is ready.", + "deliverables": [f"shared/tasks/{task_id}/result.md"], + "submitted_at": "2026-08-12T08:00:00Z", + } + ) + server._write_json(task_path, legacy_task) + project_path = tmp_path / "shared" / "projects" / project_id / "meta.json" + before_project = project_path.read_bytes() + before_task = task_path.read_bytes() + + retry = _submit(tmp_path, task_id) + + assert retry["ok"] is False + assert "no submission identity" in retry["error"] + assert project_path.read_bytes() == before_project + assert task_path.read_bytes() == before_task + assert successful_side_effects["publish"] == [] + assert successful_side_effects["sync"] == [] + + +def test_submitted_retry_backfills_digest_from_persisted_meta( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], +) -> None: + _project_id, task_id = _write_project_and_task( + tmp_path, + task_status="submitted", + ) + task_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + task = json.loads(task_path.read_text(encoding="utf-8")) + task.update( + { + "resultStatus": "SUCCESS", + "summary": "The result is ready.", + "deliverables": [f"shared/tasks/{task_id}/result.md"], + "submissionId": "legacy-stable-submission", + "submittedAt": "2026-08-12T08:00:00Z", + } + ) + server._write_json(task_path, task) + + retried = _submit(tmp_path, task_id) + + assert retried["ok"] is True + assert retried["reused"] is True + assert retried["task"]["submission_id"] == "legacy-stable-submission" + assert retried["task"]["result_digest"] == ( + "69ebfbd366d793c24c654496546619130c8d62e336b905b217a9e8d099f35496" + ) + persisted = json.loads(task_path.read_text(encoding="utf-8")) + assert persisted["result_digest"] == retried["task"]["result_digest"] + assert "resultDigest" not in persisted + assert "submissionId" not in persisted + + +def test_accept_validates_submission_id_and_resolves_task_fence( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + submitted = _submit(tmp_path, task_id) + submission_id = submitted["task"]["submission_id"] + project_path = tmp_path / "shared" / "projects" / project_id / "meta.json" + before_wrong_id = project_path.read_bytes() + + wrong_id = _tool_payload( + "projectflow", + { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": { + "projectId": project_id, + "taskId": task_id, + "submissionId": "a-different-submission", + "resultStatus": "SUCCESS", + "summary": "The result is ready.", + }, + }, + ) + assert wrong_id["ok"] is False + assert "submissionId does not match" in wrong_id["error"] + assert project_path.read_bytes() == before_wrong_id + + accepted = _tool_payload( + "projectflow", + { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": { + "projectId": project_id, + "taskId": task_id, + "submissionId": submission_id, + "resultStatus": "SUCCESS", + "summary": "The result is ready.", + }, + }, + ) + + assert accepted["ok"] is True + assert accepted["submissionId"] == submission_id + assert accepted["task"]["status"] == "completed" + assert accepted["task"]["continuation"]["status"] == "resolved" + task = json.loads( + (tmp_path / "shared" / "tasks" / task_id / "meta.json").read_text(encoding="utf-8") + ) + assert task["status"] == "completed" + assert task["continuation"]["status"] == "resolved" + assert task["continuation"]["delivery_id"] == submitted["task"]["continuation"]["delivery_id"] + assert task["submission_id"] == submission_id + assert task["continuation"]["resolution"] == "completed" + assert task["continuation"]["resolved_at"].endswith("Z") + + +@pytest.mark.parametrize( + ("operation", "requested_submission_id"), + [ + ("accept", None), + ("accept", "a-stale-submission"), + ("cancel", None), + ("cancel", "a-stale-submission"), + ], +) +def test_terminal_decision_requires_the_current_submission_id_before_side_effects( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], + operation: str, + requested_submission_id: str | None, +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + submitted = _submit(tmp_path, task_id) + task_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + project_path = tmp_path / "shared" / "projects" / project_id / "meta.json" + before_task = task_path.read_bytes() + before_project = project_path.read_bytes() + before_side_effect_counts = { + name: len(calls) for name, calls in successful_side_effects.items() + } + + if operation == "accept": + payload: dict[str, Any] = { + "projectId": project_id, + "taskId": task_id, + "resultStatus": "SUCCESS", + "summary": "This decision must be fenced.", + } + tool = "projectflow" + arguments: dict[str, Any] = { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": payload, + } + else: + payload = { + "taskId": task_id, + "reason": "This decision must be fenced.", + } + tool = "taskflow" + arguments = { + "role": "leader", + "action": "cancel_task", + "workspaceDir": str(tmp_path), + "payload": payload, + } + if requested_submission_id is not None: + payload["submissionId"] = requested_submission_id + + rejected = _tool_payload(tool, arguments) + + assert rejected["ok"] is False + if requested_submission_id is None: + assert "submissionId is required" in rejected["error"] + else: + assert "submissionId does not match" in rejected["error"] + assert task_path.read_bytes() == before_task + assert project_path.read_bytes() == before_project + assert { + name: len(calls) for name, calls in successful_side_effects.items() + } == before_side_effect_counts + assert submitted["task"]["submission_id"] + + +def test_repeated_accept_same_decision_is_noop_and_conflicting_decision_is_rejected( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + submitted = _submit(tmp_path, task_id) + submission_id = submitted["task"]["submission_id"] + accept_payload = { + "projectId": project_id, + "taskId": task_id, + "submissionId": submission_id, + "resultStatus": "SUCCESS", + "summary": "The result is ready.", + } + first = _tool_payload( + "projectflow", + { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": accept_payload, + }, + ) + assert first["ok"] is True + marked = _tool_payload( + "projectflow", + { + "action": "mark_requester_report_sent", + "workspaceDir": str(tmp_path), + "payload": {"projectId": project_id, "sentAt": "2026-08-13T09:00:00Z"}, + }, + ) + assert marked["ok"] is True + project_path = tmp_path / "shared" / "projects" / project_id / "meta.json" + task_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + before_retry_project = project_path.read_bytes() + before_retry_task = task_path.read_bytes() + + retried = _tool_payload( + "projectflow", + { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": {**accept_payload, "summary": "A retry must not replace the first report."}, + }, + ) + assert retried["ok"] is True + assert retried["reused"] is True + assert retried["publishedArtifacts"] == [] + assert retried["project"]["requester_report"] == { + "pending": False, + "reason": "task_result_accepted", + "report_path": f"shared/projects/{project_id}/result.md", + "result_status": "SUCCESS", + "sent_at": "2026-08-13T09:00:00Z", + "summary": "The result is ready.", + "task_id": task_id, + } + assert project_path.read_bytes() == before_retry_project + assert task_path.read_bytes() == before_retry_task + assert len(successful_side_effects["sync"]) == 3 # submit, first accept, retry repair + + conflict = _tool_payload( + "projectflow", + { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": { + **accept_payload, + "accepted": False, + }, + }, + ) + assert conflict["ok"] is False + assert "already decided as completed" in conflict["error"] + assert project_path.read_bytes() == before_retry_project + assert task_path.read_bytes() == before_retry_task + + +def test_cancel_resolves_pending_continuation_without_replacing_identity( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], +) -> None: + _project_id, task_id = _write_project_and_task(tmp_path) + submitted = _submit(tmp_path, task_id) + continuation = submitted["task"]["continuation"] + + cancelled = _tool_payload( + "taskflow", + { + "role": "leader", + "action": "cancel_task", + "workspaceDir": str(tmp_path), + "payload": { + "taskId": task_id, + "submissionId": submitted["task"]["submission_id"], + "reason": "superseded", + }, + }, + ) + + assert cancelled["ok"] is True + assert cancelled["task"]["continuation"]["status"] == "resolved" + assert cancelled["task"]["continuation"]["delivery_id"] == continuation["delivery_id"] + assert cancelled["task"]["submission_id"] == submitted["task"]["submission_id"] + assert cancelled["task"]["continuation"]["resolution"] == "cancelled" + + +@pytest.mark.parametrize("action", ["ack_task", "submit_task"]) +def test_late_worker_updates_cannot_revive_a_terminal_task( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], + action: str, +) -> None: + project_id, task_id = _write_project_and_task(tmp_path, task_status="completed") + project_path = tmp_path / "shared" / "projects" / project_id / "meta.json" + task_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + before_project = project_path.read_bytes() + before_task = task_path.read_bytes() + payload: dict[str, Any] = {"taskId": task_id} + if action == "submit_task": + payload.update( + { + "status": "SUCCESS", + "summary": "This result arrived after the task was completed.", + "deliverables": [f"shared/tasks/{task_id}/result.md"], + } + ) + + late_update = _tool_payload( + "taskflow", + { + "role": "worker", + "action": action, + "workspaceDir": str(tmp_path), + "payload": payload, + }, + ) + + assert late_update["ok"] is False + assert f"{action} cannot update terminal task: completed" in late_update["error"] + assert project_path.read_bytes() == before_project + assert task_path.read_bytes() == before_task + assert successful_side_effects["publish"] == [] + assert successful_side_effects["sync"] == [] + + +def test_late_submit_cannot_revive_a_project_terminal_fence_when_task_meta_is_stale( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path, task_status="completed") + task_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + stale_task = json.loads(task_path.read_text(encoding="utf-8")) + stale_task["status"] = "in_progress" + server._write_json(task_path, stale_task) + project_path = tmp_path / "shared" / "projects" / project_id / "meta.json" + before_project = project_path.read_bytes() + before_task = task_path.read_bytes() + late_submit = _submit(tmp_path, task_id) + + assert late_submit["ok"] is False + assert "submit_task cannot update terminal task: completed" in late_submit["error"] + assert project_path.read_bytes() == before_project + assert task_path.read_bytes() == before_task + assert successful_side_effects["publish"] == [] + assert successful_side_effects["sync"] == [] + + +def test_repeated_accept_repairs_a_missing_task_fence_without_reopening_report( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + submitted = _submit(tmp_path, task_id) + submission_id = submitted["task"]["submission_id"] + project_path = tmp_path / "shared" / "projects" / project_id / "meta.json" + project = json.loads(project_path.read_text(encoding="utf-8")) + project["tasks"][0]["status"] = "completed" + project["requester_report"] = { + "pending": False, + "sent_at": "2026-08-13T09:00:00Z", + "task_id": task_id, + "reason": "task_result_accepted", + } + server._write_json(project_path, project) + task_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + stale_task = json.loads(task_path.read_text(encoding="utf-8")) + assert stale_task["status"] == "submitted" + assert stale_task["continuation"]["status"] == "pending" + original_resolved_at = "2026-08-13T08:59:00Z" + stale_task["continuation"].update( + { + "status": "resolved", + "resolution": "completed", + "resolved_at": original_resolved_at, + } + ) + server._write_json(task_path, stale_task) + + repaired = _tool_payload( + "projectflow", + { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": { + "projectId": project_id, + "taskId": task_id, + "submissionId": submission_id, + "resultStatus": "SUCCESS", + "summary": "This retry repairs only the task fence.", + }, + }, + ) + + assert repaired["ok"] is True + assert repaired["reused"] is True + assert repaired["repairedTaskFence"] is True + assert repaired["synced"] is True + repaired_task = json.loads(task_path.read_text(encoding="utf-8")) + assert repaired_task["status"] == "completed" + assert repaired_task["continuation"]["status"] == "resolved" + assert repaired_task["continuation"]["resolved_at"] == original_resolved_at + assert repaired["project"]["requester_report"] == project["requester_report"] + assert repaired["publishedArtifacts"] == [] + + +def test_accept_retry_repairs_plan_after_meta_write_succeeds( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + submitted = _submit(tmp_path, task_id) + project_dir = tmp_path / "shared" / "projects" / project_id + plan_path = project_dir / "plan.md" + server._write_project_plan(project_dir, json.loads((project_dir / "meta.json").read_text(encoding="utf-8"))) + before_plan = plan_path.read_bytes() + real_write_plan = server._write_project_plan + plan_attempts = 0 + + def fail_first_plan_write(*args: Any, **kwargs: Any) -> None: + nonlocal plan_attempts + plan_attempts += 1 + if plan_attempts == 1: + raise OSError("forced plan write failure") + real_write_plan(*args, **kwargs) + + monkeypatch.setattr(server, "_write_project_plan", fail_first_plan_write) + arguments = { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": { + "projectId": project_id, + "taskId": task_id, + "submissionId": submitted["task"]["submission_id"], + "resultStatus": "SUCCESS", + "summary": "Accepted once.", + }, + } + + first = _tool_payload("projectflow", arguments) + committed_project = json.loads((project_dir / "meta.json").read_text(encoding="utf-8")) + + assert first["ok"] is False + assert first["retryable"] is True + assert first["statePersisted"] is True + assert committed_project["tasks"][0]["status"] == "completed" + assert plan_path.read_bytes() == before_plan + + retried = _tool_payload("projectflow", arguments) + repaired_plan = plan_path.read_text(encoding="utf-8") + + assert retried["ok"] is True + assert retried["reused"] is True + assert "status: completed" in repaired_plan + assert plan_attempts == 2 + + +def test_legacy_accept_without_task_meta_remains_supported( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id = "legacy-project" + task_id = "legacy-project-01" + project = { + "project_id": project_id, + "title": "Legacy plan-only project", + "status": "active", + "tasks": [{"task_id": task_id, "status": "planned"}], + } + server._write_json(tmp_path / "shared" / "projects" / project_id / "meta.json", project) + + accepted = _tool_payload( + "projectflow", + { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": { + "projectId": project_id, + "taskId": task_id, + "resultStatus": "SUCCESS", + "summary": "Accepted through the legacy plan-only path.", + }, + }, + ) + + assert accepted["ok"] is True + assert accepted["submissionId"] is None + assert accepted["nodeStatus"] == "completed" + assert accepted["task"] is None + assert "synced" not in accepted + assert not (tmp_path / "shared" / "tasks" / task_id / "meta.json").exists() + assert successful_side_effects["sync"] == [] + + +def test_accept_retry_repairs_failed_task_meta_sync_without_reopening_report( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + submitted = _submit(tmp_path, task_id) + submission_id = submitted["task"]["submission_id"] + sync_outcomes = iter((False, True)) + monkeypatch.setattr(server, "_sync_task", lambda *_args, **_kwargs: next(sync_outcomes)) + payload = { + "projectId": project_id, + "taskId": task_id, + "submissionId": submission_id, + "resultStatus": "SUCCESS", + "summary": "Accepted once.", + "publishArtifacts": True, + } + + first = _tool_payload( + "projectflow", + { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": payload, + }, + ) + assert first["ok"] is False + assert first["retryable"] is True + assert first["statePersisted"] is True + assert first["synced"] is False + assert "notificationNeeded" not in first + assert len(successful_side_effects["publish"]) == 1 # submit only + marked = _tool_payload( + "projectflow", + { + "action": "mark_requester_report_sent", + "workspaceDir": str(tmp_path), + "payload": {"projectId": project_id, "sentAt": "2026-08-13T09:00:00Z"}, + }, + ) + report_after_send = marked["project"]["requester_report"] + + retried = _tool_payload( + "projectflow", + { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": payload, + }, + ) + + assert retried["ok"] is True + assert retried["reused"] is True + assert retried["repairedTaskFence"] is False + assert retried["synced"] is True + assert retried["project"]["requester_report"] == report_after_send + assert retried["publishedArtifacts"] == [] + assert len(successful_side_effects["publish"]) == 1 + + +def test_cancel_retry_repairs_failed_sync_and_keeps_the_original_decision( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + submitted = _submit(tmp_path, task_id) + sync_outcomes = iter((False, True)) + monkeypatch.setattr(server, "_sync_task", lambda *_args, **_kwargs: next(sync_outcomes)) + cancel_arguments = { + "role": "leader", + "action": "cancel_task", + "workspaceDir": str(tmp_path), + "payload": { + "taskId": task_id, + "submissionId": submitted["task"]["submission_id"], + "reason": "superseded", + "replacementTaskId": "continuation-project-02", + }, + } + + first = _tool_payload("taskflow", cancel_arguments) + assert first["ok"] is False + assert first["retryable"] is True + assert first["statePersisted"] is True + assert first["synced"] is False + assert "notificationNeeded" not in first + + retried = _tool_payload("taskflow", cancel_arguments) + assert retried["ok"] is True + assert retried["reused"] is True + assert retried["synced"] is True + assert retried["task"]["submission_id"] == submitted["task"]["submission_id"] + assert retried["task"]["continuation"]["status"] == "resolved" + assert retried["task"]["continuation"]["resolution"] == "cancelled" + assert retried["project"]["tasks"][0]["status"] == "cancelled" + + conflict = _tool_payload( + "taskflow", + { + **cancel_arguments, + "payload": { + "taskId": task_id, + "submissionId": submitted["task"]["submission_id"], + "reason": "a different cancellation", + }, + }, + ) + assert conflict["ok"] is False + assert "conflicts with existing cancellation" in conflict["error"] + + +def test_cancel_retry_repairs_project_node_after_partial_write( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + real_update = server._update_project_task + update_attempts = 0 + + def fail_first_project_update(*args: Any, **kwargs: Any) -> bool: + nonlocal update_attempts + update_attempts += 1 + if update_attempts == 1: + raise OSError("forced project update failure") + return real_update(*args, **kwargs) + + monkeypatch.setattr(server, "_update_project_task", fail_first_project_update) + arguments = { + "role": "leader", + "action": "cancel_task", + "workspaceDir": str(tmp_path), + "payload": {"taskId": task_id, "reason": "superseded"}, + } + + first = _tool_payload("taskflow", arguments) + persisted_task = json.loads( + (tmp_path / "shared" / "tasks" / task_id / "meta.json").read_text(encoding="utf-8") + ) + persisted_project = json.loads( + (tmp_path / "shared" / "projects" / project_id / "meta.json").read_text(encoding="utf-8") + ) + + assert first["ok"] is False + assert first["retryable"] is True + assert first["statePersisted"] is True + assert persisted_task["status"] == "cancelled" + assert persisted_project["tasks"][0]["status"] == "in_progress" + + retried = _tool_payload("taskflow", arguments) + repaired_project = json.loads( + (tmp_path / "shared" / "projects" / project_id / "meta.json").read_text(encoding="utf-8") + ) + + assert retried["ok"] is True + assert retried["reused"] is True + assert repaired_project["tasks"][0]["status"] == "cancelled" + assert successful_side_effects["publish"] == [] + + +def test_legacy_cancelled_task_still_rejects_a_second_cancel( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], +) -> None: + _project_id, task_id = _write_project_and_task(tmp_path, task_status="cancelled") + task_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + task = json.loads(task_path.read_text(encoding="utf-8")) + task["cancel_reason"] = "legacy cancel" + server._write_json(task_path, task) + + retried = _tool_payload( + "taskflow", + { + "role": "leader", + "action": "cancel_task", + "workspaceDir": str(tmp_path), + "payload": {"taskId": task_id, "reason": "legacy cancel"}, + }, + ) + + assert retried["ok"] is False + assert "cannot cancel terminal task: cancelled" in retried["error"] + assert successful_side_effects["sync"] == [] + + +def test_submit_sync_failure_is_retryable_and_reuses_persisted_submission( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + successful_side_effects: dict[str, list[Any]], +) -> None: + _project_id, task_id = _write_project_and_task(tmp_path) + outcomes = iter((False, True)) + monkeypatch.setattr(server, "_sync_task", lambda *_args, **_kwargs: next(outcomes)) + + first = _submit(tmp_path, task_id) + assert first["ok"] is False + assert first["retryable"] is True + assert first["statePersisted"] is True + assert first["synced"] is False + assert "notificationNeeded" not in first + submission_id = first["task"]["submission_id"] + + retried = _submit(tmp_path, task_id) + assert retried["ok"] is True + assert retried["reused"] is True + assert retried["synced"] is True + assert retried["task"]["submission_id"] == submission_id + assert len(successful_side_effects["publish"]) == 1 + + +@pytest.mark.parametrize( + "error", + [ + OSError("mc is unavailable"), + subprocess.TimeoutExpired(cmd=["mc", "mirror"], timeout=120), + ], + ids=("os-error", "timeout"), +) +def test_submit_filesync_process_failure_is_a_retryable_public_result( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + error: Exception, +) -> None: + _project_id, task_id = _write_project_and_task(tmp_path) + monkeypatch.setattr(server, "_publish_task_artifacts", lambda *_args, **_kwargs: []) + + def fail_filesync(*_args: Any, **_kwargs: Any) -> Any: + raise error + + monkeypatch.setattr(server.subprocess, "run", fail_filesync) + + result = _submit(tmp_path, task_id) + + assert result["ok"] is False + assert result["retryable"] is True + assert result["statePersisted"] is True + assert result["synced"] is False + assert "shared-storage sync failed" in result["error"] + persisted = json.loads( + (tmp_path / "shared" / "tasks" / task_id / "meta.json").read_text(encoding="utf-8") + ) + assert persisted["status"] == "submitted" + assert persisted["submission_id"] + + +def test_accept_sync_failure_is_retryable_without_reopening_side_effects( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + submitted = _submit(tmp_path, task_id) + submission_id = submitted["task"]["submission_id"] + outcomes = iter((False, True)) + monkeypatch.setattr(server, "_sync_task", lambda *_args, **_kwargs: next(outcomes)) + arguments = { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": { + "projectId": project_id, + "taskId": task_id, + "submissionId": submission_id, + "resultStatus": "SUCCESS", + "summary": "Accepted once.", + }, + } + + first = _tool_payload("projectflow", arguments) + assert first["ok"] is False + assert first["retryable"] is True + assert first["statePersisted"] is True + assert first["synced"] is False + assert "notificationNeeded" not in first + assert first["publishedArtifacts"] == [] + + retried = _tool_payload("projectflow", arguments) + assert retried["ok"] is True + assert retried["reused"] is True + assert retried["synced"] is True + assert retried["publishedArtifacts"] == [] + + +def test_cancel_sync_failure_is_retryable_and_repairs_without_changing_reason( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + successful_side_effects: dict[str, list[Any]], +) -> None: + _project_id, task_id = _write_project_and_task(tmp_path) + submitted = _submit(tmp_path, task_id) + outcomes = iter((False, True)) + monkeypatch.setattr(server, "_sync_task", lambda *_args, **_kwargs: next(outcomes)) + arguments = { + "role": "leader", + "action": "cancel_task", + "workspaceDir": str(tmp_path), + "payload": { + "taskId": task_id, + "submissionId": submitted["task"]["submission_id"], + "reason": "superseded", + }, + } + + first = _tool_payload("taskflow", arguments) + assert first["ok"] is False + assert first["retryable"] is True + assert first["statePersisted"] is True + assert first["synced"] is False + assert "notificationNeeded" not in first + + retried = _tool_payload("taskflow", arguments) + assert retried["ok"] is True + assert retried["reused"] is True + assert retried["synced"] is True + assert retried["task"]["cancel_reason"] == "superseded" + + +@pytest.mark.parametrize( + ("result_status", "expected_node_status"), + [ + ("SUCCESS", "completed"), + ("SUCCESS_WITH_NOTES", "completed"), + ("REVISION_NEEDED", "revision"), + ("BLOCKED", "blocked"), + ("INTERRUPTED", "blocked"), + ], +) +def test_supported_result_status_round_trips_through_submit_check_and_accept( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + successful_side_effects: dict[str, list[Any]], + result_status: str, + expected_node_status: str, +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + monkeypatch.setattr(server, "_pull_task", lambda *_args, **_kwargs: False) + + submitted = _submit(tmp_path, task_id, status=result_status) + checked = _tool_payload( + "taskflow", + { + "role": "leader", + "action": "check_task", + "workspaceDir": str(tmp_path), + "payload": {"taskId": task_id}, + }, + ) + accepted = _tool_payload( + "projectflow", + { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": { + "projectId": project_id, + "taskId": task_id, + "submissionId": submitted["task"]["submission_id"], + "resultStatus": result_status, + "summary": "Review the submitted result.", + }, + }, + ) + + assert submitted["ok"] is True + assert submitted["task"]["result_status"] == result_status + assert checked["ok"] is True + assert checked["effective"] is True + assert checked["validationErrors"] == [] + assert checked["result"]["status"] == result_status + assert accepted["ok"] is True + assert accepted["nodeStatus"] == expected_node_status + + +@pytest.mark.parametrize("result_status", ["FAILED", "PARTIAL"]) +def test_submit_rejects_unsupported_result_status_before_persisting( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], + result_status: str, +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + project_path = tmp_path / "shared" / "projects" / project_id / "meta.json" + task_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + before_project = project_path.read_bytes() + before_task = task_path.read_bytes() + + rejected = _submit(tmp_path, task_id, status=result_status) + + assert rejected["ok"] is False + assert f"unsupported result status: {result_status}" in rejected["error"] + assert project_path.read_bytes() == before_project + assert task_path.read_bytes() == before_task + assert successful_side_effects["publish"] == [] + assert successful_side_effects["sync"] == [] + + +@pytest.mark.parametrize("task_status", ["assigned", "in_progress"]) +def test_accept_requires_an_existing_task_meta_to_be_submitted( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], + task_status: str, +) -> None: + project_id, task_id = _write_project_and_task(tmp_path, task_status=task_status) + project_path = tmp_path / "shared" / "projects" / project_id / "meta.json" + task_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + before_project = project_path.read_bytes() + before_task = task_path.read_bytes() + + rejected = _tool_payload( + "projectflow", + { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": { + "projectId": project_id, + "taskId": task_id, + "resultStatus": "SUCCESS", + "summary": "There is no submitted result to accept.", + }, + }, + ) + + assert rejected["ok"] is False + assert f"requires submitted task state, got {task_status}" in rejected["error"] + assert project_path.read_bytes() == before_project + assert task_path.read_bytes() == before_task + assert successful_side_effects["publish"] == [] + assert successful_side_effects["sync"] == [] + + +@pytest.mark.parametrize( + ("mutate_task", "expected_error"), + [ + (lambda task: task.pop("submission_id"), "requires a submission identity"), + (lambda task: task.__setitem__("result_status", "UNKNOWN"), "invalid result status: UNKNOWN"), + (lambda task: task.__setitem__("summary", ""), "missing result summary"), + ], + ids=("missing-submission-id", "invalid-result-status", "missing-summary"), +) +def test_accept_rejects_an_invalid_persisted_submission_without_mutation( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], + mutate_task: Any, + expected_error: str, +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + submitted = _submit(tmp_path, task_id) + project_path = tmp_path / "shared" / "projects" / project_id / "meta.json" + task_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + task = json.loads(task_path.read_text(encoding="utf-8")) + mutate_task(task) + server._write_json(task_path, task) + before_project = project_path.read_bytes() + before_task = task_path.read_bytes() + + rejected = _tool_payload( + "projectflow", + { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": { + "projectId": project_id, + "taskId": task_id, + "submissionId": submitted["task"]["submission_id"], + "resultStatus": "SUCCESS", + "summary": "Do not accept corrupt persisted state.", + }, + }, + ) + + assert rejected["ok"] is False + assert expected_error in rejected["error"] + assert project_path.read_bytes() == before_project + assert task_path.read_bytes() == before_task + assert len(successful_side_effects["publish"]) == 1 # submit only + assert len(successful_side_effects["sync"]) == 1 # submit only + + +def test_accept_rejects_result_status_mismatch_without_mutation( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + submitted = _submit(tmp_path, task_id, status="BLOCKED") + project_path = tmp_path / "shared" / "projects" / project_id / "meta.json" + task_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + before_project = project_path.read_bytes() + before_task = task_path.read_bytes() + + rejected = _tool_payload( + "projectflow", + { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": { + "projectId": project_id, + "taskId": task_id, + "submissionId": submitted["task"]["submission_id"], + "resultStatus": "SUCCESS", + "summary": "Do not reinterpret the submitted result.", + }, + }, + ) + + assert rejected["ok"] is False + assert "resultStatus does not match the submitted task result" in rejected["error"] + assert project_path.read_bytes() == before_project + assert task_path.read_bytes() == before_task + assert len(successful_side_effects["publish"]) == 1 # submit only + assert len(successful_side_effects["sync"]) == 1 # submit only + + +@pytest.mark.parametrize( + ("field", "replacement"), + [ + ("summary", "A different but still valid summary."), + ("deliverables", ["shared/tasks/continuation-project-01/other.txt"]), + ], +) +def test_accept_rejects_a_valid_but_tampered_persisted_result_before_any_write( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], + field: str, + replacement: Any, +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + submitted = _submit(tmp_path, task_id) + project_path = tmp_path / "shared" / "projects" / project_id / "meta.json" + task_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + task = json.loads(task_path.read_text(encoding="utf-8")) + task[field] = replacement + server._write_json(task_path, task) + before_project = project_path.read_bytes() + before_task = task_path.read_bytes() + + rejected = _tool_payload( + "projectflow", + { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": { + "projectId": project_id, + "taskId": task_id, + "submissionId": submitted["task"]["submission_id"], + "resultStatus": "SUCCESS", + "summary": "Do not accept a result whose content identity changed.", + }, + }, + ) + + assert rejected["ok"] is False + assert "result digest does not match" in rejected["error"] + assert project_path.read_bytes() == before_project + assert task_path.read_bytes() == before_task + assert len(successful_side_effects["publish"]) == 1 # submit only + assert len(successful_side_effects["sync"]) == 1 # submit only + + +@pytest.mark.parametrize("action", ["accept", "cancel"]) +def test_trusted_worker_role_cannot_forge_leader_state_transitions( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + successful_side_effects: dict[str, list[Any]], + action: str, +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + submitted = _submit(tmp_path, task_id) + project_path = tmp_path / "shared" / "projects" / project_id / "meta.json" + task_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + before_project = project_path.read_bytes() + before_task = task_path.read_bytes() + before_side_effect_counts = {name: len(calls) for name, calls in successful_side_effects.items()} + monkeypatch.setenv("AGENTTEAMS_AGENT_ROLE", "worker") + + if action == "accept": + rejected = _tool_payload( + "projectflow", + { + "role": "leader", + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": { + "role": "leader", + "projectId": project_id, + "taskId": task_id, + "submissionId": submitted["task"]["submission_id"], + "resultStatus": "SUCCESS", + "summary": "A worker cannot accept its own result.", + }, + }, + ) + else: + rejected = _tool_payload( + "taskflow", + { + "role": "leader", + "action": "cancel_task", + "workspaceDir": str(tmp_path), + "payload": { + "role": "leader", + "taskId": task_id, + "reason": "A forged cancellation.", + }, + }, + ) + + assert rejected["ok"] is False + assert "requires leader role" in rejected["error"] + assert project_path.read_bytes() == before_project + assert task_path.read_bytes() == before_task + assert {name: len(calls) for name, calls in successful_side_effects.items()} == before_side_effect_counts + + +def test_trusted_leader_role_can_accept_without_an_argument_role( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + submitted = _submit(tmp_path, task_id) + monkeypatch.setenv("AGENTTEAMS_AGENT_ROLE", "leader") + + accepted = _tool_payload( + "projectflow", + { + "role": "worker", + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": { + "projectId": project_id, + "taskId": task_id, + "submissionId": submitted["task"]["submission_id"], + "resultStatus": "SUCCESS", + "summary": "The trusted leader accepts the result.", + }, + }, + ) + + assert accepted["ok"] is True + assert accepted["nodeStatus"] == "completed" + + +def test_accept_migrates_legacy_submitted_task_without_a_requested_identity( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + _submit(tmp_path, task_id) + task_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + legacy = json.loads(task_path.read_text(encoding="utf-8")) + legacy.pop("submission_id") + legacy.pop("submitted_at") + legacy.pop("result_digest") + legacy.pop("continuation") + server._write_json(task_path, legacy) + + accepted = _tool_payload( + "projectflow", + { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": { + "projectId": project_id, + "taskId": task_id, + "resultStatus": "SUCCESS", + "summary": "Accept the legacy persisted result.", + }, + }, + ) + + assert accepted["ok"] is True + assert accepted["submissionId"] + migrated = accepted["task"] + assert migrated["submission_id"] == accepted["submissionId"] + assert migrated["submitted_at"].endswith("Z") + assert migrated["result_digest"] == server._task_result_digest(server._submission_result(migrated)) + assert migrated["status"] == "completed" + assert migrated["continuation"]["status"] == "resolved" + assert migrated["continuation"]["resolution"] == "completed" + assert migrated["continuation"]["resolved_at"].endswith("Z") + + +def test_legacy_accept_project_write_failure_reports_the_migrated_identity_as_persisted( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + _submit(tmp_path, task_id) + task_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + project_path = tmp_path / "shared" / "projects" / project_id / "meta.json" + legacy = json.loads(task_path.read_text(encoding="utf-8")) + for key in ("submission_id", "submitted_at", "result_digest", "continuation"): + legacy.pop(key) + server._write_json(task_path, legacy) + real_write_json = server._write_json + + def fail_project_write(path: Path, data: dict[str, Any]) -> None: + if path == project_path: + raise OSError("project disk full") + real_write_json(path, data) + + monkeypatch.setattr(server, "_write_json", fail_project_write) + result = _tool_payload( + "projectflow", + { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": { + "projectId": project_id, + "taskId": task_id, + "resultStatus": "SUCCESS", + "summary": "Retry after the project write is repaired.", + }, + }, + ) + + migrated = json.loads(task_path.read_text(encoding="utf-8")) + assert result["ok"] is False + assert result["retryable"] is True + assert result["statePersisted"] is True + assert migrated["submission_id"] + assert migrated["continuation"]["status"] == "pending" + assert json.loads(project_path.read_text(encoding="utf-8"))["tasks"][0]["status"] == "submitted" + + +@pytest.mark.parametrize("operation", ["submit", "cancel"]) +def test_initial_task_state_write_failure_is_retryable_without_a_false_commit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + successful_side_effects: dict[str, list[Any]], + operation: str, +) -> None: + _project_id, task_id = _write_project_and_task(tmp_path) + task_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + before = task_path.read_bytes() + monkeypatch.setattr(server, "_write_task", lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("disk full"))) + + if operation == "submit": + result = _submit(tmp_path, task_id) + else: + result = _tool_payload( + "taskflow", + { + "role": "leader", + "action": "cancel_task", + "workspaceDir": str(tmp_path), + "payload": {"taskId": task_id, "reason": "stop"}, + }, + ) + + assert result["ok"] is False + assert result["retryable"] is True + assert result["statePersisted"] is False + assert result["synced"] is False + assert task_path.read_bytes() == before + + +def test_accept_task_state_write_failure_reports_the_committed_project_decision( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + submitted = _submit(tmp_path, task_id) + monkeypatch.setattr(server, "_write_task", lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("disk full"))) + + result = _tool_payload( + "projectflow", + { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": { + "projectId": project_id, + "taskId": task_id, + "submissionId": submitted["task"]["submission_id"], + "resultStatus": "SUCCESS", + "summary": "The project decision commits first.", + }, + }, + ) + + persisted_project = json.loads( + (tmp_path / "shared" / "projects" / project_id / "meta.json").read_text(encoding="utf-8") + ) + persisted_task = json.loads( + (tmp_path / "shared" / "tasks" / task_id / "meta.json").read_text(encoding="utf-8") + ) + assert result["ok"] is False + assert result["retryable"] is True + assert result["statePersisted"] is True + assert result["synced"] is False + assert persisted_project["tasks"][0]["status"] == "completed" + assert persisted_task["status"] == "submitted" + + +def test_submit_backfill_write_failure_reports_the_existing_submission_as_persisted( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + successful_side_effects: dict[str, list[Any]], +) -> None: + _project_id, task_id = _write_project_and_task(tmp_path) + submitted = _submit(tmp_path, task_id) + task_path = tmp_path / "shared" / "tasks" / task_id / "meta.json" + legacy = json.loads(task_path.read_text(encoding="utf-8")) + legacy.pop("result_digest") + server._write_json(task_path, legacy) + monkeypatch.setattr(server, "_write_task", lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("disk full"))) + + retried = _submit(tmp_path, task_id) + + assert retried["ok"] is False + assert retried["retryable"] is True + assert retried["statePersisted"] is True + assert retried["synced"] is False + assert retried["task"]["submission_id"] == submitted["task"]["submission_id"] + persisted = json.loads(task_path.read_text(encoding="utf-8")) + assert persisted["status"] == "submitted" + assert persisted["submission_id"] == submitted["task"]["submission_id"] + + +@pytest.mark.parametrize("failure_point", ["fsync", "replace"]) +def test_state_projection_atomic_write_preserves_the_previous_file_on_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure_point: str, +) -> None: + state_path = tmp_path / "shared" / "tasks" / "atomic-task" / "meta.json" + server._write_json(state_path, {"task_id": "atomic-task", "status": "assigned"}) + before = state_path.read_bytes() + + if failure_point == "fsync": + monkeypatch.setattr(server.os, "fsync", lambda _fd: (_ for _ in ()).throw(OSError("fsync failed"))) + else: + def fail_replace(source: Path | str, target: Path | str) -> None: + assert Path(source).parent == Path(target).parent + raise OSError("replace failed") + + monkeypatch.setattr(server.os, "replace", fail_replace) + + with pytest.raises(OSError, match=failure_point): + server._write_json(state_path, {"task_id": "atomic-task", "status": "submitted"}) + + assert state_path.read_bytes() == before + assert json.loads(state_path.read_text(encoding="utf-8"))["status"] == "assigned" + assert list(state_path.parent.glob(".meta.json.*.tmp")) == [] + + +@pytest.mark.parametrize("operation", ["submit", "accept", "cancel"]) +def test_state_transition_commits_project_projection_to_shared_storage( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], + operation: str, +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + + if operation == "submit": + result = _submit(tmp_path, task_id) + else: + submitted = _submit(tmp_path, task_id) + if operation == "accept": + result = _tool_payload( + "projectflow", + { + "action": "accept_task_result", + "workspaceDir": str(tmp_path), + "payload": { + "projectId": project_id, + "taskId": task_id, + "submissionId": submitted["task"]["submission_id"], + "resultStatus": "SUCCESS", + "summary": "Commit the project decision.", + }, + }, + ) + else: + result = _tool_payload( + "taskflow", + { + "role": "leader", + "action": "cancel_task", + "workspaceDir": str(tmp_path), + "payload": { + "taskId": task_id, + "submissionId": submitted["task"]["submission_id"], + "reason": "cancelled by test", + }, + }, + ) + + assert result["ok"] is True + assert successful_side_effects["project_sync"] + synced_project_ids = [args[1] for args, _kwargs in successful_side_effects["project_sync"]] + assert project_id in synced_project_ids + + +def test_project_sync_failure_is_retryable_before_task_commit_is_reported( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path) + outcomes = iter((False, True)) + monkeypatch.setattr(server, "_sync_project", lambda *_args, **_kwargs: next(outcomes)) + + first = _submit(tmp_path, task_id) + second = _submit(tmp_path, task_id) + + assert first["ok"] is False + assert first["retryable"] is True + assert first["statePersisted"] is True + assert first["synced"] is False + assert second["ok"] is True + assert second["reused"] is True + assert second["task"]["submission_id"] == first["task"]["submission_id"] + + +def test_submit_pushes_and_verifies_result_payload_before_the_meta_commit_point( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _project_id, task_id = _write_project_and_task(tmp_path) + task_dir = tmp_path / "shared" / "tasks" / task_id + (task_dir / "result.md").write_text("result", encoding="utf-8") + (task_dir / "output.txt").write_text("output", encoding="utf-8") + calls: list[tuple[str, str]] = [] + + def filesync(arguments: dict[str, Any]) -> dict[str, Any]: + calls.append((str(arguments["action"]), str(arguments["path"]))) + return {"ok": True} + + monkeypatch.setattr(server, "_filesync", filesync) + monkeypatch.setattr(server, "_sync_project", lambda *_args, **_kwargs: True) + monkeypatch.setattr(server, "_publish_task_artifacts", lambda *_args, **_kwargs: []) + + submitted = _submit( + tmp_path, + task_id, + deliverables=[f"shared/tasks/{task_id}/output.txt"], + ) + + assert submitted["ok"] is True + assert calls == [ + ("push", f"shared/tasks/{task_id}/result.md"), + ("stat", f"shared/tasks/{task_id}/result.md"), + ("push", f"shared/tasks/{task_id}/output.txt"), + ("stat", f"shared/tasks/{task_id}/output.txt"), + ("push", f"shared/tasks/{task_id}/meta.json"), + ("stat", f"shared/tasks/{task_id}/meta.json"), + ] + + +def test_plan_dag_preserves_a_committed_cancellation_decision( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path, task_status="cancelled") + project_path = tmp_path / "shared" / "projects" / project_id / "meta.json" + project = json.loads(project_path.read_text(encoding="utf-8")) + project["tasks"][0]["cancellation"] = { + "submission_id": "submission-1", + "reason": "obsolete", + "replacement_task_id": "replacement-1", + "cancelled_at": "2026-08-18T00:00:00Z", + } + server._write_json(project_path, project) + + replanned = _tool_payload( + "projectflow", + { + "role": "leader", + "action": "plan_dag", + "workspaceDir": str(tmp_path), + "payload": { + "projectId": project_id, + "tasks": [{"taskId": task_id, "title": "Still cancelled"}], + }, + }, + ) + + assert replanned["ok"] is True + node = replanned["project"]["tasks"][0] + assert node["status"] == "cancelled" + assert node["cancellation"] == project["tasks"][0]["cancellation"] + + +def test_plan_dag_cannot_reopen_a_committed_cancellation_decision( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, task_id = _write_project_and_task(tmp_path, task_status="cancelled") + project_path = tmp_path / "shared" / "projects" / project_id / "meta.json" + project = json.loads(project_path.read_text(encoding="utf-8")) + project["tasks"][0]["cancellation"] = { + "submission_id": "submission-1", + "reason": "obsolete", + "cancelled_at": "2026-08-18T00:00:00Z", + } + server._write_json(project_path, project) + before = project_path.read_bytes() + + rejected = _tool_payload( + "projectflow", + { + "role": "leader", + "action": "plan_dag", + "workspaceDir": str(tmp_path), + "payload": { + "projectId": project_id, + "tasks": [{"taskId": task_id, "title": "Reopened", "status": "planned"}], + }, + }, + ) + + assert rejected["ok"] is False + assert "committed cancellation" in rejected["error"] + assert project_path.read_bytes() == before + + +def test_plan_dag_cannot_remove_a_committed_cancellation_decision( + tmp_path: Path, + successful_side_effects: dict[str, list[Any]], +) -> None: + project_id, _task_id = _write_project_and_task(tmp_path, task_status="cancelled") + project_path = tmp_path / "shared" / "projects" / project_id / "meta.json" + project = json.loads(project_path.read_text(encoding="utf-8")) + project["tasks"][0]["cancellation"] = { + "submission_id": "submission-1", + "reason": "obsolete", + "cancelled_at": "2026-08-18T00:00:00Z", + } + server._write_json(project_path, project) + before = project_path.read_bytes() + + rejected = _tool_payload( + "projectflow", + { + "role": "leader", + "action": "plan_dag", + "workspaceDir": str(tmp_path), + "payload": {"projectId": project_id, "tasks": []}, + }, + ) + + assert rejected["ok"] is False + assert "committed cancellation" in rejected["error"] + assert project_path.read_bytes() == before + + +def test_submit_meta_commit_must_be_remotely_verified_before_success( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _project_id, task_id = _write_project_and_task(tmp_path) + task_dir = tmp_path / "shared" / "tasks" / task_id + (task_dir / "result.md").write_text("result", encoding="utf-8") + calls: list[tuple[str, str]] = [] + meta_stat_failed = False + meta_path = f"shared/tasks/{task_id}/meta.json" + + def filesync(arguments: dict[str, Any]) -> dict[str, Any]: + nonlocal meta_stat_failed + call = (str(arguments["action"]), str(arguments["path"])) + calls.append(call) + if call == ("stat", meta_path) and not meta_stat_failed: + meta_stat_failed = True + return {"ok": False, "error": "remote meta commit is not visible"} + return {"ok": True} + + monkeypatch.setattr(server, "_filesync", filesync) + monkeypatch.setattr(server, "_sync_project", lambda *_args, **_kwargs: True) + monkeypatch.setattr(server, "_publish_task_artifacts", lambda *_args, **_kwargs: []) + + first = _submit(tmp_path, task_id, deliverables=[]) + second = _submit(tmp_path, task_id, deliverables=[]) + + assert first["ok"] is False + assert first["statePersisted"] is True + assert first["retryable"] is True + assert first["synced"] is False + assert second["ok"] is True + assert second["reused"] is True + assert second["task"]["submission_id"] == first["task"]["submission_id"] + assert calls.count(("push", meta_path)) == 2 + assert calls.count(("stat", meta_path)) == 2 + assert calls[-2:] == [("push", meta_path), ("stat", meta_path)] + + +def test_submit_retry_repairs_interrupted_payload_publish_without_rotating_identity( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _project_id, task_id = _write_project_and_task(tmp_path) + task_dir = tmp_path / "shared" / "tasks" / task_id + (task_dir / "result.md").write_text("result", encoding="utf-8") + calls: list[tuple[str, str]] = [] + failed = False + + def filesync(arguments: dict[str, Any]) -> dict[str, Any]: + nonlocal failed + call = (str(arguments["action"]), str(arguments["path"])) + calls.append(call) + if call[0] == "stat" and not failed: + failed = True + return {"ok": False, "error": "remote stat unavailable"} + return {"ok": True} + + monkeypatch.setattr(server, "_filesync", filesync) + monkeypatch.setattr(server, "_sync_project", lambda *_args, **_kwargs: True) + monkeypatch.setattr(server, "_publish_task_artifacts", lambda *_args, **_kwargs: []) + + first = _submit(tmp_path, task_id, deliverables=[]) + second = _submit(tmp_path, task_id, deliverables=[]) + + assert first["ok"] is False + assert first["statePersisted"] is True + assert first["retryable"] is True + assert second["ok"] is True + assert second["reused"] is True + assert second["task"]["submission_id"] == first["task"]["submission_id"] + meta_push = ("push", f"shared/tasks/{task_id}/meta.json") + assert calls.count(meta_push) == 1 + assert calls[-2:] == [ + meta_push, + ("stat", f"shared/tasks/{task_id}/meta.json"), + ] diff --git a/plugins/tests/teamharness/mcp/tools/test-taskflow.rb b/plugins/tests/teamharness/mcp/tools/test-taskflow.rb index 0479e76dc..3520f6566 100644 --- a/plugins/tests/teamharness/mcp/tools/test-taskflow.rb +++ b/plugins/tests/teamharness/mcp/tools/test-taskflow.rb @@ -679,10 +679,12 @@ def block_yaml_import(name, *args, **kwargs): raise AssertionError(f"check_task deliverables should ignore result body bullets: {checked!r}") accepted = payload("projectflow", { + "role": "leader", "action": "accept_task_result", "payload": { "projectId": project_id, "taskId": task_id, + "submissionId": checked["task"]["submission_id"], "resultStatus": checked["result"]["status"], "summary": checked["result"]["summary"], }, @@ -906,7 +908,11 @@ def block_yaml_import(name, *args, **kwargs): completed_cancel = payload("taskflow", { "role": "leader", "action": "cancel_task", - "payload": {"taskId": task_id, "reason": "manual_replan"}, + "payload": { + "taskId": task_id, + "submissionId": checked["task"]["submission_id"], + "reason": "manual_replan", + }, }) if completed_cancel.get("ok") or "cannot cancel terminal task" not in completed_cancel.get("error", ""): raise AssertionError(f"completed task should not be silently cancelled: {completed_cancel!r}") @@ -947,21 +953,51 @@ def block_yaml_import(name, *args, **kwargs): "spec": f"Prepare {terminal_task_id}.", }, }) + terminal_submissions = {} + for terminal_task_id, result_status, result_summary in [ + ("terminal-revision", "SUCCESS", "Needs revision."), + ("terminal-blocked", "BLOCKED", "Blocked."), + ]: + payload("taskflow", { + "role": "worker", + "action": "ack_task", + "payload": {"taskId": terminal_task_id}, + }) + terminal_result_path = pathlib.Path("#{workspace}") / f"shared/tasks/{terminal_task_id}/result.md" + terminal_result_path.write_text(result_summary + "\\n", encoding="utf-8") + terminal_submission = payload("taskflow", { + "role": "worker", + "action": "submit_task", + "payload": { + "taskId": terminal_task_id, + "status": result_status, + "summary": result_summary, + "deliverables": [], + }, + }) + if not terminal_submission.get("ok"): + raise AssertionError(f"terminal fixture submission failed: {terminal_submission!r}") + terminal_submissions[terminal_task_id] = terminal_submission["task"]["submission_id"] payload("projectflow", { + "role": "leader", "action": "accept_task_result", "payload": { "projectId": terminal_project_id, "taskId": "terminal-revision", + "submissionId": terminal_submissions["terminal-revision"], "accepted": False, "resultStatus": "SUCCESS", "summary": "Needs revision.", }, }) payload("projectflow", { + "role": "leader", "action": "accept_task_result", "payload": { "projectId": terminal_project_id, "taskId": "terminal-blocked", + "submissionId": terminal_submissions["terminal-blocked"], + "accepted": True, "resultStatus": "BLOCKED", "summary": "Blocked.", }, @@ -971,14 +1007,25 @@ def block_yaml_import(name, *args, **kwargs): "action": "cancel_task", "payload": {"taskId": "terminal-cancelled", "reason": "manual_replan"}, }) - for terminal_task_id in ["terminal-revision", "terminal-blocked", "terminal-cancelled"]: + for terminal_task_id in ["terminal-revision", "terminal-blocked"]: terminal_cancel = payload("taskflow", { "role": "leader", "action": "cancel_task", - "payload": {"taskId": terminal_task_id, "reason": "manual_replan"}, + "payload": { + "taskId": terminal_task_id, + "submissionId": terminal_submissions[terminal_task_id], + "reason": "manual_replan", + }, }) if terminal_cancel.get("ok") or "cannot cancel terminal task" not in terminal_cancel.get("error", ""): raise AssertionError(f"terminal task should not be silently cancelled: {terminal_task_id} {terminal_cancel!r}") + repeated_cancel = payload("taskflow", { + "role": "leader", + "action": "cancel_task", + "payload": {"taskId": "terminal-cancelled", "reason": "manual_replan"}, + }) + if not repeated_cancel.get("ok") or not repeated_cancel.get("reused"): + raise AssertionError(f"same cancellation should be idempotent: {repeated_cancel!r}") replanned = payload("projectflow", { "action": "plan_dag", @@ -1043,6 +1090,7 @@ def block_yaml_import(name, *args, **kwargs): }, }) rejected = payload("projectflow", { + "role": "leader", "action": "accept_task_result", "payload": { "projectId": revision_project_id, @@ -1073,7 +1121,7 @@ def block_yaml_import(name, *args, **kwargs): "action": "check_task", "payload": {"taskId": task_id}, }) - if not invalid_checked.get("ok") or not invalid_checked.get("effective"): + if not invalid_checked.get("ok") or invalid_checked.get("effective"): raise AssertionError(f"result body should not override task meta validation: {invalid_checked!r}") if invalid_checked.get("validationErrors"): raise AssertionError(f"result body should not create validation errors: {invalid_checked!r}") @@ -1162,9 +1210,17 @@ def block_yaml_import(name, *args, **kwargs): fail!("delegate_task did not push task dir: #{commands.inspect}") unless commands.include?( "mirror #{workspace}/shared/tasks/t-001/ mock/shared/tasks/t-001/ --overwrite" ) - fail!("submit_task did not push only worker-owned files: #{commands.inspect}") unless commands.include?( - "mirror #{workspace}/shared/tasks/t-001/ mock/shared/tasks/t-001/ --overwrite --exclude spec.md --exclude base/" - ) + result_push = "cp #{workspace}/shared/tasks/t-001/result.md mock/shared/tasks/t-001/result.md" + result_stat = "stat mock/shared/tasks/t-001/result.md" + analysis_push = "cp #{workspace}/shared/tasks/t-001/workspace/analysis.md mock/shared/tasks/t-001/workspace/analysis.md" + analysis_stat = "stat mock/shared/tasks/t-001/workspace/analysis.md" + meta_commit = "cp #{workspace}/shared/tasks/t-001/meta.json mock/shared/tasks/t-001/meta.json" + meta_stat = "stat mock/shared/tasks/t-001/meta.json" + ordered_submit_commands = [result_push, result_stat, analysis_push, analysis_stat, meta_commit, meta_stat] + submit_positions = ordered_submit_commands.map { |command| commands.index(command) } + unless submit_positions.all? && submit_positions == submit_positions.sort + fail!("submit_task did not publish result payloads before meta commit: #{commands.inspect}") + end fail!("ack_task did not pull remote task dir: #{commands.inspect}") unless commands.include?( "mirror mock/shared/tasks/remote-001/ #{workspace}/shared/tasks/remote-001 --overwrite" ) diff --git a/plugins/tests/teamharness/test-contracts.rb b/plugins/tests/teamharness/test-contracts.rb index d2066f7d7..25e99cd40 100644 --- a/plugins/tests/teamharness/test-contracts.rb +++ b/plugins/tests/teamharness/test-contracts.rb @@ -8,6 +8,7 @@ plugin_root = repo_root / "plugins/teamharness" manifest_path = plugin_root / "plugin.yaml" boundary_doc = repo_root / "docs/design/teamharness/boundary-and-contracts.md" +runtime_design_doc = repo_root / "docs/design/teamharness/project-task-runtime-design.md" def fail!(message) warn "ERROR: #{message}" @@ -50,6 +51,31 @@ def skill_frontmatter(path) assert(doc.include?(needle), "boundary doc must describe #{needle.inspect}") end +assert_file(runtime_design_doc) +runtime_design = read(runtime_design_doc) +assert(runtime_design.include?("submission_id") && runtime_design.include?("submitted_at") && runtime_design.include?("不透明、不可变提交身份"), "runtime design must define opaque immutable submission identity") +assert(runtime_design.include?("teamharness.task-result.v1") && runtime_design.include?("canonical_json"), "runtime design must define the cross-runtime result digest domain and canonical payload") +assert(runtime_design.include?("notes") && runtime_design.include?("不参与摘要") && runtime_design.include?("保持调用方给出的顺序"), "runtime design must define digest exclusions and deliverable ordering") +assert(runtime_design.include?("result-submitted:v1") && runtime_design.include?("project_id || NUL || task_id || NUL || submission_id"), "runtime design must define the continuation delivery id formula") +assert(runtime_design.include?('"status": "pending"') && runtime_design.include?('"status": "resolved"') && runtime_design.include?('"resolution": "completed"'), "runtime design must define pending and resolved continuation markers") +assert(runtime_design.include?("compare-and-swap (CAS)") && runtime_design.include?("single-writer"), "runtime design must state its single-writer and cross-process CAS boundary") +assert(runtime_design.include?("project projection") && runtime_design.include?("statePersisted: true") && runtime_design.include?("可检测、可重试、可修复"), "runtime design must define retry repair for project and task remote projections") +assert(runtime_design.include?("同一 submission 与同一决定的重试是幂等的"), "runtime design must define idempotent result acceptance") +assert(runtime_design.include?("可信 Leader") && runtime_design.include?("Worker runtime") && runtime_design.include?("不得调用 accept、cancel"), "runtime design must reserve continuation decisions for the trusted Leader") +assert(runtime_design.include?("submissionId") && runtime_design.include?("布尔值 `accepted`") && runtime_design.include?("过期 identity"), "runtime design must fence explicit Leader decisions") +assert(runtime_design.include?("projectflow(action=cancel_task)") && runtime_design.include?("taskflow(action=cancel_task)"), "runtime design must distinguish CoPaw and standalone cancel routes") +assert(runtime_design.include?("legacy-adoption:v1") && runtime_design.include?("standalone MCP 的 `submit_task` 不收养"), "runtime design must define evidence-based legacy adoption") +assert(runtime_design.include?("CoPaw 决策\n入口本身不迁移缺 ID 状态") && runtime_design.include?("plan-only acceptance 不制造 TaskMeta") && runtime_design.include?("没有 identity 的 cancel 可以继续完成取消"), "runtime design must match each runtime's legacy decision compatibility") +assert(%w[cancel_reason replacement_task_id cancelled_at].all? { |field| runtime_design.include?(field) }, "runtime design must define durable cancellation fields") +assert(runtime_design.include?("只要 TaskMeta 已有 `submission_id`") && runtime_design.include?("accept 和 cancel 都必须") && runtime_design.include?("无 identity 的 legacy 迁移例外"), "runtime design must require submission fences outside legacy migration") +assert(runtime_design.include?("standalone MCP 的 `accept_task_result`") && runtime_design.include?("CoPaw 原生 `projectflow`") && runtime_design.include?("不凭空创建 `requester_report`") && runtime_design.include?("不能据此省略"), "runtime design must distinguish requester report projections without dropping report responsibility") +assert(runtime_design.include?("经 Controller 授权的调用方") && runtime_design.include?("POST /api/v1/projects/{id}/tasks/{taskId}/cancel") && runtime_design.include?("不得自动验收"), "runtime design must define authorized Controller cancellation without automatic acceptance") +assert(runtime_design.include?("tasks[].cancellation") && runtime_design.include?("ProjectMeta-first") && runtime_design.include?("cancellation` envelope"), "runtime design must define durable Controller cancellation retry fencing") +assert(runtime_design.include?("plan_dag` / `plan_loop`") && runtime_design.include?("不能原地改回非终态") && runtime_design.include?("不能从计划删除"), "runtime design must preserve cancellation decisions across replans") +assert(runtime_design.include?("TaskMeta.status == submitted") && runtime_design.include?("continuation.status == pending") && runtime_design.include?('submission_id != ""') && runtime_design.include?('delivery_id != ""'), "runtime design must define fail-closed PR2 continuation eligibility") +assert(runtime_design.include?("Controller 周期调度") && runtime_design.include?("Matrix 唤醒") && runtime_design.include?("明确 deferred"), "runtime design must defer Controller scheduling and Matrix wake delivery") +assert(runtime_design.include?("不能声称任务已恢复") && !runtime_design.include?("TASK_CONTINUE") && !runtime_design.include?("QwenPaw plugin"), "runtime design must not claim that wake scheduling or recovery is implemented") + assert(manifest.dig("metadata", "name") == "teamharness", "metadata.name must be teamharness") prompts = manifest.fetch("prompts") @@ -219,6 +245,11 @@ def skill_frontmatter(path) assert(project_skill.include?("meta.json"), "project skill must use CoPaw meta.json state") assert(project_skill.include?("resolve_project"), "project skill must document project context resume") assert(project_skill.include?("accept_task_result"), "project skill must document explicit task result acceptance") +assert(project_skill.include?("trusted Leader") && project_skill.include?('"accepted": true') && project_skill.include?("task.submission_id from check_task"), "project skill must document trusted fenced acceptance") +assert(project_skill.include?('projectflow` with `action: "cancel_task"') && project_skill.include?('taskflow` with `action: "cancel_task"'), "project skill must route cancellation by runtime") +assert(project_skill.include?("you must pass that exact\nvalue as `submissionId` to both acceptance and cancellation") && normalized(project_skill).include?("persisted legacy submission with no identity"), "project skill must require normal submission fences and preserve the legacy exception") +assert(project_skill.include?("Native CoPaw decisions do not perform that migration") && project_skill.include?("compatibility for cancelling legacy TaskMeta that has no identity"), "project skill must distinguish CoPaw and standalone legacy decisions") +assert(project_skill.include?("standalone MCP") && project_skill.include?("Native CoPaw `projectflow`") && project_skill.include?("does not invent a\n`requester_report`") && project_skill.include?("permission to omit the report"), "project skill must preserve requester reporting across runtime projections") assert(!project_skill.include?("check_active_tasks"), "project skill must not document cancelled hook recovery checks") assert(!project_skill.include?("Hook Recovery Checks"), "project skill must not expose cancelled hook recovery checks") assert(project_skill.include?("mark_requester_report_sent"), "project skill must document requester report clearing") @@ -244,6 +275,9 @@ def skill_frontmatter(path) assert(delegation_skill.include?("`teamharness-roomflow` owns task-room creation"), "delegation skill must leave room setup details to roomflow") assert(!delegation_skill.include?("roomBindingScope: \"sender\""), "delegation skill must not duplicate roomflow sender binding details") assert(delegation_skill_text.include?("Do not fall back to the requester/source session"), "delegation skill must keep Project Work assignment inside the task room") +assert(delegation_skill.include?("current `task.submission_id`") && delegation_skill.include?("boolean `accepted`") && delegation_skill.include?("trusted Leader runtime"), "delegation skill must preserve the trusted acceptance fence") +assert(delegation_skill.include?("omitting `submissionId` is an error") && delegation_skill.include?("no-identity legacy migration"), "delegation skill must require a fence for normal accept and cancel decisions") +assert(delegation_skill.include?("`INTERRUPTED`") && delegation_skill.include?("records the task and plan node as `blocked`") && delegation_skill.include?("`resolution: blocked`"), "delegation skill must map interrupted results to blocked terminal state") assert(communication_skill.include?("matrix:!roomid:domain"), "communication skill must support legacy Matrix requester routing") assert(communication_skill.include?("Matrix DM requester reports") && communication_skill.include?("targetSession"), "communication skill must document Matrix DM reply routes") assert(communication_skill.include?("requester report") && communication_skill.include?("mandatory"), "communication skill must require requester reports after accepted state changes") @@ -254,6 +288,13 @@ def skill_frontmatter(path) assert(normalized(communication_skill).include?("recorded requester route is exactly"), "communication skill must own requester route exclusion rules") assert(execution_skill.include?("Do not use this skill or taskflow"), "execution skill must exclude direct checks") assert(execution_skill.include?("meta.json"), "execution skill must use CoPaw meta.json state") +assert(execution_skill.include?("submission_id") && execution_skill.include?("submitted_at") && execution_skill.include?("result_digest") && execution_skill.include?("continuation"), "execution skill must document the durable submission contract") +assert(normalized(execution_skill).include?("retry with exactly the same status, summary, and ordered deliverables") && normalized(execution_skill).include?("retry conflicts"), "execution skill must document immutable idempotent submit retries") +assert(execution_skill.include?("opaque fence") && execution_skill.include?("do not parse it"), "execution skill must treat submission ids as opaque") +assert(execution_skill.include?("does not mean") && execution_skill.include?("Matrix wake"), "execution skill must not claim a pending continuation was delivered") +assert(execution_skill.include?("cannot accept, reject, cancel, or resolve") && execution_skill.include?("cannot\noverride your Worker runtime identity"), "execution skill must forbid Worker terminal decisions") +assert(execution_skill.include?("legacy task") && execution_skill.include?("exactly matches the persisted result") && execution_skill.include?("Do not invent\nan identity"), "execution skill must document fail-closed legacy adoption") +assert(execution_skill.include?("`INTERRUPTED`") && execution_skill.include?("records the task and plan\nnode as `blocked`") && execution_skill.include?("`resolution: blocked`"), "execution skill must map interrupted results to blocked terminal state") server = manifest.fetch("mcp").fetch("servers").fetch(0) assert(server.fetch("id") == "teamharness", "MCP server id must be teamharness")