Skip to content

feat(teamharness): persist durable task continuation state - #1183

Open
jesseedcp wants to merge 7 commits into
agentscope-ai:mainfrom
jesseedcp:feat/teamharness-durable-continuation-1177
Open

feat(teamharness): persist durable task continuation state#1183
jesseedcp wants to merge 7 commits into
agentscope-ai:mainfrom
jesseedcp:feat/teamharness-durable-continuation-1177

Conversation

@jesseedcp

@jesseedcp jesseedcp commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Why

submit_task writes the Worker result before the Worker sends its separate completion message. If the process stops in between, the result survives but nothing reliably identifies that submission or records that the Leader still needs to review it.

This PR adds that durable state contract to the CoPaw-native tools and the standalone TeamHarness MCP server. It also updates the Controller cancel endpoint from #1172 so a human cancellation cannot leave a submitted task with continuation.status=pending.

What changed

On first submit, TaskMeta records an immutable submission_id, submitted_at, canonical result digest, and deterministic pending continuation ID. An identical retry reuses the first submission; a different result is rejected. Late Worker calls cannot move a terminal task back to in_progress or submitted.

Leader accept/cancel decisions are fenced by the current submissionId. Repeating the same decision is idempotent, while stale or conflicting decisions fail before state is changed. CoPaw and the standalone MCP path use the same result-status and continuation-resolution rules.

The Controller's project cancel API now follows the same cancellation contract:

  • a submitted TaskMeta requires the exact current submissionId;
  • successful cancellation preserves the delivery ID and resolves the continuation as cancelled;
  • ProjectMeta stores a small cancellation decision envelope before TaskMeta is written, so a failed second write can be retried without losing the original reason, replacement task, submission ID, or timestamp;
  • a replan cannot drop, reopen, or remove-and-reuse a task ID that carries a committed cancellation decision;
  • TaskMeta ownership is checked against both the route task ID and project ID before any write.

GET .../workflow?includeTasks=true now returns tasks_detail[].submission_id. The CLI exposes the full path without guessing the current generation:

agt get projects demo-project-001 --include-tasks -o json
agt project cancel demo-project-001 demo-project-001-01 \
  --reason "no longer needed" \
  --submission-id submission-123 \
  --team biz-team

Local state files use same-directory atomic replacement. Result files and deliverables are uploaded and verified before remote TaskMeta is committed. Partial project/task writes return retryable state, and a matching retry repairs the missing projection without rotating the submission identity or repeating the terminal transition.

Compatibility

Existing CoPaw submit_task() callers still receive TaskMeta, and the legacy positional event_id slot is unchanged.

Legacy tasks without a submission identity remain cancellable without submissionId. Once TaskMeta has an identity, both TeamHarness and Controller cancellation require the exact value. The Controller API therefore treats submissionId as conditionally required rather than globally required.

The standalone MCP used to accept FAILED and PARTIAL in submit_task, then fail later because neither status had an acceptance mapping. It now rejects both at submission time. This PR also adds INTERRUPTED, matching CoPaw's result-status set. A caller that still sends FAILED or PARTIAL gets unsupported result status before task or project state is changed.

Tests

Local checks:

  • go test ./internal/server ./cmd/agt
  • go vet ./internal/server ./cmd/agt
  • 81 standalone continuation/project-pull tests
  • 99 CoPaw taskflow/domain tests
  • TeamHarness Ruby contract checks
  • TeamHarness Ruby taskflow integration
  • Python compile checks and git diff --check

The branch is rebased on current main, including #1169 and #1172.

Follow-up

This PR does not add the periodic scanner, wake a sleeping Team Leader, or send a recovery event through Matrix. It also does not automatically accept a Worker result.

The follow-up for #1177 is the Controller side: a leader-elected scanner over the canonical pending submissions, Leader lifecycle wake-up, and structured retryable Matrix delivery. TeamHarness remains responsible for eligibility, fencing, and terminal decisions.

Related to #1177.

@jesseedcp
jesseedcp force-pushed the feat/teamharness-durable-continuation-1177 branch from a7dc93c to 74a3aa3 Compare August 17, 2026 17:01
@jesseedcp
jesseedcp marked this pull request as ready for review August 17, 2026 17:02
@jesseedcp

Copy link
Copy Markdown
Contributor Author

I moved this out of draft and rebased it onto the current main.

While checking the merged #1172 path, I found that Controller-side cancellation could leave a submitted TaskMeta as status=cancelled but keep its continuation pending. The latest commit brings that endpoint under the same submission fence as the TeamHarness tools. It also keeps a small cancellation decision on the project node so a ProjectMeta-success/TaskMeta-failure retry cannot silently change the original reason or replacement task.

I added the corresponding replan fences as well: a task with a committed cancellation cannot be reopened, dropped, or removed and re-added under the same ID. tasks_detail now exposes the opaque submission ID, and the CLI has --include-tasks, --submission-id, and --team so the HTTP fence is actually usable.

The focused Go, Python, and Ruby suites are green locally. CI is running again after the rebase and force-push.

@shiyiyue1102, when you have time, could you take a look at this state-contract slice? The periodic scanner and Matrix wake-up are still intentionally left for the follow-up to #1177.

@jesseedcp
jesseedcp force-pushed the feat/teamharness-durable-continuation-1177 branch from 0bd1c13 to 31ede24 Compare August 23, 2026 16:34
@jesseedcp jesseedcp closed this Aug 23, 2026
@jesseedcp jesseedcp reopened this Aug 23, 2026
@jesseedcp
jesseedcp force-pushed the feat/teamharness-durable-continuation-1177 branch from 31ede24 to 8d7594c Compare September 3, 2026 12:25
@jesseedcp

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (ac22c887) and force-pushed. The rebase was clean, and the six commits are patch-equivalent to the previous series. The new CI run is green, including the TeamHarness contracts, Controller tests, image builds, and all five integration shards.

@shiyiyue1102, could you take another look when you have time? This PR is still only the state-contract slice; the Controller scanner, Leader wake-up, and Matrix retry will stay in a separate follow-up for #1177.

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This PR adds durable task continuation state across the Controller (Go), CoPaw (Python), and MCP server (Python), introducing opaque submission identity, submission-fenced accept/cancel decisions, idempotent retry with CAS-style repair, atomic file writes, canonical result digests, and publish-last shared-storage commit ordering. The design is solid and the test coverage is extensive, covering idempotency, legacy adoption, concurrent races, cross-project rejection, and retry-after-failure scenarios. However, three areas warrant attention before merge: (1) _resolve_task_continuation in server.py unconditionally overwrites resolved_at on every call, which could clobber the original decision timestamp when a leader re-accepts the same submission; (2) the _TASK_MUTATION_LOCKS registry in task.py grows without bound as tasks are created, leaking memory over long-running worker processes; (3) the tightened isPlainToken in project_handler.go now rejects tokens starting with non-alphanumeric characters, which is a backward-incompatible validation change that could break existing project IDs or tokens in production. These are not critical correctness bugs but should be addressed or explicitly acknowledged.


Automated review by github-manager-bot

continuation = task.get("continuation") if isinstance(task.get("continuation"), dict) else {}
if not continuation:
return
continuation["status"] = "resolved"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_resolve_task_continuation unconditionally overwrites resolved_at with _utc_timestamp() on every call, unlike CoPaw's _commit_task_decision which uses continuation.get("resolved_at") or _now() to preserve the original timestamp. Today the callers guard against calling this for already-resolved continuations at a higher level, but the function itself is not defensive. If a future code path calls it for an already-resolved continuation (e.g. during a repair that re-runs after resolved_at was already set), it would silently rotate the timestamp, violating the design contract that resolved_at records the first resolution time and retries must not alter it. Consider using continuation.setdefault("resolved_at", _utc_timestamp()) or an early return when continuation.get("status") == "resolved".

}
EFFECTIVE_RESULT_STATUSES = {"SUCCESS", "SUCCESS_WITH_NOTES"}
TERMINAL_TASK_STATUSES = {"completed", "revision", "blocked", "cancelled"}
_TASK_MUTATION_LOCKS: dict[str, threading.RLock] = {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_TASK_MUTATION_LOCKS is a module-level dict that accumulates one threading.RLock per unique task meta.json path and is never pruned. In a long-running CoPaw process that handles many distinct tasks over its lifetime, this grows without bound. Each entry is small (string key + RLock), so this is unlikely to cause practical memory pressure, but it is a resource leak by design. Consider using a bounded cache (e.g. an OrderedDict capped at a few hundred entries with LRU eviction) or keying by (workspace_dir, task_id) with periodic cleanup.

return false
}
for i, r := range s {
alphaNumeric := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tightened isPlainToken now requires the first character to be alphanumeric ([A-Za-z0-9]), rejecting identifiers that start with -, _, or .. This is a backward-incompatible change for the replacementTaskId parameter in POST /api/v1/projects/{id}/tasks/{taskId}/cancel. If any existing caller (human CLI scripts, automated tooling, or other runtime integrations) passes replacement IDs starting with _ or -, those requests will start returning 400 after upgrade. The test TestCancelTask_InvalidReplacementID400 explicitly validates that "_task" and "-task" are now rejected. Consider whether this tightening is necessary for the cancellation feature or if the old more-permissive check should be preserved for backward compatibility.

"submit_task",
{"project_id": task.get("project_id", "")},
task,
summary=f"submit_task: {task_id} ({status})",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the submit_task retry path, the condition if not task.get("result_digest") or task.get("resultDigest") is misleading. After _load_task runs _ensure_console_task_meta, camelCase keys (including resultDigest) are popped from the task dict, so task.get("resultDigest") is always None/falsy at this point. The condition effectively simplifies to if not task.get("result_digest"). The dead or clause could confuse future maintainers into thinking camelCase migration is still needed here. Consider simplifying to if not task.get("result_digest"):.

}
if str(taskData["task_id"]) != taskID || str(taskData["project_id"]) != projectID {
writeError(w, http.StatusNotFound, "task meta not found")
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new ownership check str(taskData["task_id"]) != taskID || str(taskData["project_id"]) != projectID correctly prevents cross-project task cancellation. However, if taskData["project_id"] is nil (absent from the stored JSON), str(nil) returns "", and the check "" != projectID would reject the cancellation with 404 even for a legitimately owned task. All tasks created by TeamHarness runtimes set project_id, so this should not affect normal workflows, but legacy or manually-created TaskMeta files without project_id would become un-cancellable through this API endpoint.

@@ -3071,28 +3242,159 @@ def _payload_bool_field(payload: dict[str, Any], names: tuple[str, ...], default


Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _accept_task_result function now requires role == "leader" via _role(arguments), and _role was changed to prioritize the runtime environment role (AGENTTEAMS_AGENT_ROLE) over the payload role argument. This is correct security hardening that prevents Workers from forging Leader operations. Verify that all production Leader deployment configurations set AGENTTEAMS_AGENT_ROLE=leader, since the argument-level role override is now ignored when a runtime role is present. Existing test infrastructure was updated accordingly.

@jesseedcp

jesseedcp commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Picked up two concrete review notes in 6bc6f1fe. _resolve_task_continuation now keeps the first resolved_at when an idempotent accept repairs a missing TaskMeta fence; the regression test starts from an already-resolved continuation and checks that its original timestamp survives. I also removed the dead camelCase resultDigest condition, since persisted TaskMeta uses result_digest.

Locally, the continuation, project-pull, and CoPaw taskflow suites pass (66 + 15 + 99), along with the Ruby contract/taskflow checks. The new GitHub run is fully green as well: TeamHarness, Controller, all image builds, and all five integration shards passed.

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of the incremental commit pushed since the last review (8d7594c..6bc6f1f): fix(teamharness): preserve first continuation resolution.

The change is correct and safe: preserving an existing resolved_at keeps repair/re-accept paths from overwriting the original resolution timestamp, and dropping the redundant resultDigest fallback is safe because _first_text() already prefers result_digest and _write_task normalizes camelCase keys before persisting. The new test directly exercises the preservation path on the repair flow.

LGTM. Trivial change, looks good.


Automated review by github-manager-bot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants