---
phase: 06-structured-handoff-and-client-continuation
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
  - src/types/protocol.rs
  - src/server/core.rs
  - src/server/tasks.rs
  - crates/pmcp-tasks/src/router.rs
  - crates/pmcp-tasks/src/types/params.rs
  - crates/pmcp-tasks/src/types/workflow.rs
autonomous: true
requirements: [CONT-01, CONT-02, CONT-03]

must_haves:
  truths:
    - "A follow-up tools/call request with _task_id in _meta executes the tool normally AND records the result in the workflow's task variables"
    - "When the tool matches a remaining workflow step, the result is stored under _workflow.result.<step_name> and the step status is updated in _workflow.progress"
    - "When the tool does NOT match any workflow step, the result is stored under _workflow.extra.<tool_name>"
    - "The continuation recording is fire-and-forget: tool result is returned to the client regardless of whether recording succeeds"
    - "tasks/cancel with a result field completes the task (not cancels it)"
    - "tasks/result returns standard task data including _workflow.* variables for workflow status polling"
    - "Retries: last result wins -- calling the same step tool twice overwrites the previous result"
  artifacts:
    - path: "src/types/protocol.rs"
      provides: "_task_id field on RequestMeta"
      contains: "_task_id"
    - path: "src/server/tasks.rs"
      provides: "handle_workflow_continuation method on TaskRouter trait"
      contains: "handle_workflow_continuation"
    - path: "src/server/core.rs"
      provides: "_task_id intercept in tool call path"
      contains: "_task_id"
    - path: "crates/pmcp-tasks/src/router.rs"
      provides: "TaskRouterImpl implementation of handle_workflow_continuation and cancel-with-result"
      contains: "handle_workflow_continuation"
    - path: "crates/pmcp-tasks/src/types/params.rs"
      provides: "Extended TaskCancelParams with optional result field"
      contains: "result"
    - path: "crates/pmcp-tasks/src/types/workflow.rs"
      provides: "WORKFLOW_EXTRA_PREFIX constant"
      contains: "WORKFLOW_EXTRA_PREFIX"
  key_links:
    - from: "ServerCore::handle_request_internal (CallTool branch)"
      to: "TaskRouter::handle_workflow_continuation"
      via: "fire-and-forget call after normal tool execution when _task_id present"
      pattern: "handle_workflow_continuation"
    - from: "TaskRouterImpl::handle_workflow_continuation"
      to: "TaskStore::set_variable / get_variable"
      via: "loads _workflow.progress, matches tool, updates variables"
      pattern: "_workflow\\.progress"
    - from: "TaskRouterImpl::handle_tasks_cancel"
      to: "TaskStore::complete_with_result"
      via: "branches on presence of result field in TaskCancelParams"
      pattern: "complete_with_result"
---

<objective>
Add tool-to-task reconnection and client completion to enable workflow continuation.

Purpose: After the handoff (Plan 01), LLM clients need to execute remaining steps and reconnect results to the workflow task. This plan adds: (1) `_task_id` field on `RequestMeta` so tool calls can reference a workflow task, (2) fire-and-forget intercept in `ServerCore` that records tool results against the workflow after normal execution, (3) `handle_workflow_continuation` on `TaskRouter` for step matching and variable updates, and (4) completion-via-cancel so clients can signal workflow completion through the existing `tasks/cancel` endpoint.

Output: Modified `protocol.rs`, `core.rs`, `tasks.rs`, `router.rs`, `params.rs`, and `workflow.rs` with comprehensive tests.
</objective>

<execution_context>
@./.claude/get-shit-done/workflows/execute-plan.md
@./.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-structured-handoff-and-client-continuation/06-CONTEXT.md
@.planning/phases/06-structured-handoff-and-client-continuation/06-RESEARCH.md
@.planning/phases/05-partial-execution-engine/05-02-SUMMARY.md
@src/types/protocol.rs
@src/server/core.rs
@src/server/tasks.rs
@crates/pmcp-tasks/src/router.rs
@crates/pmcp-tasks/src/types/params.rs
@crates/pmcp-tasks/src/types/workflow.rs
</context>

<tasks>

<task type="auto">
  <name>Task 1: RequestMeta _task_id field, TaskRouter continuation trait method, and WORKFLOW_EXTRA_PREFIX constant</name>
  <files>src/types/protocol.rs, src/server/tasks.rs, crates/pmcp-tasks/src/types/workflow.rs</files>
  <action>
**1. Add `_task_id` to `RequestMeta` (src/types/protocol.rs, line ~976):**

Add an optional `_task_id` field to the existing `RequestMeta` struct:

```rust
pub struct RequestMeta {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub progress_token: Option<ProgressToken>,

    /// Task ID for workflow continuation (PMCP extension).
    ///
    /// When present on a `tools/call` request, the server records the tool
    /// result against the referenced workflow task after normal execution.
    #[serde(skip_serializing_if = "Option::is_none")]
    #[allow(clippy::pub_underscore_fields)]
    pub _task_id: Option<String>,
}
```

The `#[allow(clippy::pub_underscore_fields)]` attribute is needed (same pattern as `_meta` on `CallToolRequest`). The field uses `rename_all = "camelCase"` from the struct-level attribute, which is fine since `_task_id` with camelCase becomes `_taskId` -- but since this is a PMCP extension field with underscore prefix, we want it to serialize as `_task_id`. Add `#[serde(rename = "_task_id")]` to keep the underscore-separated name in JSON rather than camelCase.

**2. Add `handle_workflow_continuation` to `TaskRouter` trait (src/server/tasks.rs):**

Add a new default method after `complete_workflow_task`:

```rust
/// Record a tool call result against a workflow task.
///
/// Called by `ServerCore` when a `tools/call` includes `_task_id` in `_meta`.
/// The implementation matches the tool name to a remaining workflow step
/// and updates task variables with the step result and updated progress.
///
/// Best-effort: if the tool does not match any step, the result is stored
/// under `_workflow.extra.<tool_name>` for observability.
///
/// # Default
///
/// Returns `Ok(())` -- no-op for routers that don't support workflow continuation.
async fn handle_workflow_continuation(
    &self,
    _task_id: &str,
    _tool_name: &str,
    _tool_result: Value,
    _owner_id: &str,
) -> Result<()> {
    Ok(())
}
```

**3. Add `WORKFLOW_EXTRA_PREFIX` constant (crates/pmcp-tasks/src/types/workflow.rs):**

Add after the existing `WORKFLOW_RESULT_PREFIX` constant:

```rust
/// Prefix for extra tool call variable keys (unmatched tools).
///
/// When a tool call with `_task_id` does not match any workflow step,
/// the result is stored under `_workflow.extra.<tool_name>` for observability.
///
/// # Examples
///
/// ```
/// use pmcp_tasks::types::workflow::WORKFLOW_EXTRA_PREFIX;
///
/// assert_eq!(WORKFLOW_EXTRA_PREFIX, "_workflow.extra.");
/// ```
pub const WORKFLOW_EXTRA_PREFIX: &str = "_workflow.extra.";

/// Builds the task variable key for an unmatched tool call result.
///
/// # Examples
///
/// ```
/// use pmcp_tasks::types::workflow::workflow_extra_key;
///
/// assert_eq!(workflow_extra_key("debug_tool"), "_workflow.extra.debug_tool");
/// ```
pub fn workflow_extra_key(tool_name: &str) -> String {
    format!("{WORKFLOW_EXTRA_PREFIX}{tool_name}")
}
```

Add a unit test in the existing `mod tests`:
- `workflow_extra_prefix_constant` - asserts `WORKFLOW_EXTRA_PREFIX == "_workflow.extra."`
- `workflow_extra_key_format` - asserts `workflow_extra_key("my_tool") == "_workflow.extra.my_tool"`

Also add a unit test for `_task_id` serialization in protocol.rs tests (if test module exists, otherwise add inline):
- Verify `RequestMeta { progress_token: None, _task_id: Some("abc".into()) }` serializes to `{"_task_id": "abc"}` (not `_taskId` -- the serde rename attribute must produce underscore-separated name)
  </action>
  <verify>
Run `cargo test --package pmcp --lib types::protocol` and `cargo test --package pmcp-tasks --lib types::workflow` -- all pass. Run `cargo check --package pmcp` -- compiles without error. Run `cargo clippy --package pmcp -- -D warnings` and `cargo clippy --package pmcp-tasks -- -D warnings` -- zero warnings.
  </verify>
  <done>
`RequestMeta` has `_task_id: Option<String>` that serializes as `_task_id` in JSON. `TaskRouter` trait has `handle_workflow_continuation` with a default no-op. `WORKFLOW_EXTRA_PREFIX` and `workflow_extra_key` are defined in pmcp-tasks workflow types.
  </done>
</task>

<task type="auto">
  <name>Task 2: ServerCore intercept, TaskRouterImpl continuation implementation, and cancel-with-result</name>
  <files>src/server/core.rs, crates/pmcp-tasks/src/router.rs, crates/pmcp-tasks/src/types/params.rs</files>
  <action>
**1. Add _task_id intercept in ServerCore (src/server/core.rs):**

In `handle_request_internal`, in the `ClientRequest::CallTool(req)` branch, modify the **normal tool call path** (line ~763-769, the path AFTER the task-augmented check). The current code is:

```rust
// Normal tool call path (no task augmentation)
match self.handle_call_tool(req, auth_context.clone()).await {
    Ok(result) => {
        Self::success_response(id, serde_json::to_value(result).unwrap())
    },
    Err(e) => Self::error_response(id, -32603, e.to_string()),
}
```

Change to:

```rust
// Normal tool call path (no task augmentation)
match self.handle_call_tool(req, auth_context.clone()).await {
    Ok(result) => {
        // Check for _task_id in _meta for workflow continuation
        #[cfg(not(target_arch = "wasm32"))]
        if let Some(ref task_router) = self.task_router {
            if let Some(task_id) = req._meta.as_ref().and_then(|m| m._task_id.as_ref()) {
                let owner_id = self
                    .resolve_task_owner(&auth_context)
                    .unwrap_or_else(|| "local".to_string());
                let tool_result_value = serde_json::to_value(&result)
                    .unwrap_or_default();
                // Fire-and-forget: log warning on failure, never fail the tool call
                if let Err(e) = task_router
                    .handle_workflow_continuation(
                        task_id,
                        &req.name,
                        tool_result_value,
                        &owner_id,
                    )
                    .await
                {
                    tracing::warn!(
                        "Workflow continuation recording failed for task {}: {}",
                        task_id,
                        e
                    );
                }
            }
        }
        Self::success_response(id, serde_json::to_value(result).unwrap())
    },
    Err(e) => Self::error_response(id, -32603, e.to_string()),
}
```

IMPORTANT: The `req` variable is moved into `handle_call_tool`. You need to extract _task_id and tool_name BEFORE the call. Restructure as:

```rust
// Normal tool call path (no task augmentation)
// Extract continuation context before moving req
#[cfg(not(target_arch = "wasm32"))]
let continuation_ctx = req._meta.as_ref()
    .and_then(|m| m._task_id.clone())
    .map(|task_id| (task_id, req.name.clone()));

match self.handle_call_tool(req, auth_context.clone()).await {
    Ok(result) => {
        #[cfg(not(target_arch = "wasm32"))]
        if let (Some((task_id, tool_name)), Some(ref task_router)) =
            (continuation_ctx, &self.task_router)
        {
            let owner_id = self
                .resolve_task_owner(&auth_context)
                .unwrap_or_else(|| "local".to_string());
            let tool_result_value = serde_json::to_value(&result)
                .unwrap_or_default();
            if let Err(e) = task_router
                .handle_workflow_continuation(
                    &task_id,
                    &tool_name,
                    tool_result_value,
                    &owner_id,
                )
                .await
            {
                tracing::warn!(
                    "Workflow continuation recording failed for task {}: {}",
                    task_id,
                    e
                );
            }
        }
        Self::success_response(id, serde_json::to_value(result).unwrap())
    },
    Err(e) => Self::error_response(id, -32603, e.to_string()),
}
```

Note: `req` is a reference (`&CallToolRequest`) in the `ClientRequest::CallTool(req)` pattern -- check the actual pattern. If `req` is owned, extract before the match. If `req` is a reference (it's passed as `req` which is the enum variant data), study the existing code pattern to understand ownership. The existing task-augmented path clones `req.arguments` and `req.task`, suggesting `req` is borrowed. Adapt accordingly -- the key point is extracting `_task_id` and `name` before the handler consumes the request.

**2. Implement `handle_workflow_continuation` on `TaskRouterImpl` (crates/pmcp-tasks/src/router.rs):**

Add the implementation in the `#[async_trait] impl TaskRouter for TaskRouterImpl` block:

```rust
async fn handle_workflow_continuation(
    &self,
    task_id: &str,
    tool_name: &str,
    tool_result: Value,
    owner_id: &str,
) -> PmcpResult<()> {
    use crate::types::workflow::{
        workflow_extra_key, workflow_result_key, WORKFLOW_PROGRESS_KEY,
    };

    // Load the task to verify it exists and is Working
    let record = self.store
        .get(task_id, owner_id)
        .await
        .map_err(task_error_to_pmcp)?;

    // Only continue if task is in Working status
    if record.status != crate::types::task::TaskStatus::Working {
        return Err(PmcpError::invalid_params(format!(
            "task {} is not in Working status (current: {:?})",
            task_id, record.status
        )));
    }

    // Load _workflow.progress from task variables
    let progress_value = record.variables
        .get(WORKFLOW_PROGRESS_KEY)
        .cloned();

    let mut variables_to_set: HashMap<String, Value> = HashMap::new();

    // Try to match tool_name against remaining step tool fields
    let mut matched = false;
    if let Some(progress) = &progress_value {
        if let Some(steps) = progress.get("steps").and_then(|s| s.as_array()) {
            for step in steps {
                let step_tool = step.get("tool").and_then(|t| t.as_str()).unwrap_or("");
                let step_status = step.get("status").and_then(|s| s.as_str()).unwrap_or("");
                let step_name = step.get("name").and_then(|n| n.as_str()).unwrap_or("");

                // Match: tool name matches AND step is pending or failed (retryable)
                if step_tool == tool_name
                    && (step_status == "pending" || step_status == "failed")
                {
                    // Store result under _workflow.result.<step_name>
                    variables_to_set.insert(
                        workflow_result_key(step_name),
                        tool_result.clone(),
                    );
                    matched = true;
                    break; // First match wins
                }
            }
        }
    }

    if !matched {
        // Store under _workflow.extra.<tool_name> for observability
        variables_to_set.insert(
            workflow_extra_key(tool_name),
            tool_result,
        );
    }

    // Update progress: mark matched step as completed
    if matched {
        if let Some(mut progress) = progress_value.clone() {
            if let Some(steps) = progress.get_mut("steps").and_then(|s| s.as_array_mut()) {
                for step in steps.iter_mut() {
                    let step_tool = step.get("tool").and_then(|t| t.as_str()).unwrap_or("");
                    let step_status = step.get("status").and_then(|s| s.as_str()).unwrap_or("");

                    if step_tool == tool_name
                        && (step_status == "pending" || step_status == "failed")
                    {
                        if let Some(obj) = step.as_object_mut() {
                            obj.insert(
                                "status".to_string(),
                                Value::String("completed".to_string()),
                            );
                        }
                        break;
                    }
                }
            }
            variables_to_set.insert(WORKFLOW_PROGRESS_KEY.to_string(), progress);
        }
    }

    // Clear pause_reason since the client is making progress
    // (Use the constant from the workflow module)
    use crate::types::workflow::WORKFLOW_PAUSE_REASON_KEY;
    variables_to_set.insert(WORKFLOW_PAUSE_REASON_KEY.to_string(), Value::Null);

    // Batch write all variables
    let variables_value = serde_json::to_value(&variables_to_set)
        .map_err(|e| PmcpError::internal(format!("failed to serialize variables: {e}")))?;

    self.set_task_variables(task_id, owner_id, variables_value).await
}
```

**3. Extend `TaskCancelParams` with optional `result` field (crates/pmcp-tasks/src/types/params.rs):**

```rust
pub struct TaskCancelParams {
    /// The task ID to cancel or complete.
    pub task_id: String,
    /// Optional result value for workflow completion.
    ///
    /// When present, completes the task (transitions to `Completed` status)
    /// instead of cancelling it. Used by workflow clients to signal explicit
    /// completion after executing remaining steps.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub result: Option<Value>,
}
```

Update the doctest for `TaskCancelParams` to show both cancel and complete usage.

**4. Update `handle_tasks_cancel` in `TaskRouterImpl` to branch on result (crates/pmcp-tasks/src/router.rs):**

Modify the existing `handle_tasks_cancel` method:

```rust
async fn handle_tasks_cancel(&self, params: Value, owner_id: &str) -> PmcpResult<Value> {
    let cancel_params: TaskCancelParams = serde_json::from_value(params)
        .map_err(|e| PmcpError::invalid_params(format!("invalid tasks/cancel params: {e}")))?;

    let record = if let Some(result) = cancel_params.result {
        // Completion path: complete the task with the provided result
        self.store
            .complete_with_result(
                &cancel_params.task_id,
                owner_id,
                crate::types::task::TaskStatus::Completed,
                None, // No content items
                Some(result),
            )
            .await
            .map_err(task_error_to_pmcp)?
    } else {
        // Standard cancel path
        self.store
            .cancel(&cancel_params.task_id, owner_id)
            .await
            .map_err(task_error_to_pmcp)?
    };

    let wire_task = record.to_wire_task_with_variables();
    serde_json::to_value(wire_task)
        .map_err(|e| PmcpError::internal(format!("failed to serialize CancelTaskResult: {e}")))
}
```

Note: Check that `TaskStore::complete_with_result` exists and accepts these parameters. If the method signature differs (e.g., it's `complete` not `complete_with_result`), adapt accordingly. The key behavior: when `result` is present, transition to `Completed` status (not `Cancelled`).

**Testing (crates/pmcp-tasks/src/router.rs):**

Add these tests to the existing `mod tests`:

1. `handle_workflow_continuation_matches_step` - Create a task with workflow progress containing a pending step with tool "deploy". Call `handle_workflow_continuation` with tool_name "deploy". Verify `_workflow.result.deploy` is set and `_workflow.progress` shows the step as "completed".

2. `handle_workflow_continuation_unmatched_tool` - Create a task with workflow progress. Call with tool_name "debug_tool" (not in any step). Verify `_workflow.extra.debug_tool` is set and progress steps are unchanged.

3. `handle_workflow_continuation_last_result_wins` - Call continuation twice for the same step tool (simulate retry). Verify the second result overwrites the first in `_workflow.result.<step>`.

4. `handle_workflow_continuation_rejects_non_working_task` - Complete a task first, then try continuation. Verify it returns an error.

5. `handle_tasks_cancel_with_result_completes` - Call `handle_tasks_cancel` with params containing a `result` field. Verify the task transitions to `Completed` (not `Cancelled`) and the result is stored.

6. `handle_tasks_cancel_without_result_cancels` - Call `handle_tasks_cancel` without result field. Verify the task transitions to `Cancelled` (existing behavior preserved).

**Testing (crates/pmcp-tasks/src/types/params.rs):**

Add test:
1. `task_cancel_params_with_result` - Verify `TaskCancelParams` with result field serializes/deserializes correctly, and without result field is backward compatible.
  </action>
  <verify>
Run `cargo test --package pmcp-tasks` -- all existing + 7 new tests pass. Run `cargo test --package pmcp --lib` -- all tests pass. Run `cargo clippy --package pmcp -- -D warnings` and `cargo clippy --package pmcp-tasks -- -D warnings` -- zero warnings. Run `cargo fmt --check` -- clean formatting.
  </verify>
  <done>
Follow-up tool calls with `_task_id` in `_meta` execute the tool normally AND record results against the workflow task. Matching tools update `_workflow.result.<step>` and progress. Non-matching tools store under `_workflow.extra.<tool>`. The recording is fire-and-forget. `tasks/cancel` with `result` field completes the task. `tasks/result` returns standard task variables including `_workflow.*` data. All existing tests pass unchanged (backward compatibility).
  </done>
</task>

</tasks>

<verification>
1. `cargo test --package pmcp-tasks` -- all tests pass (existing + 7 new)
2. `cargo test --package pmcp --lib` -- all tests pass (existing + new)
3. `cargo clippy --package pmcp -- -D warnings` -- zero warnings
4. `cargo clippy --package pmcp-tasks -- -D warnings` -- zero warnings
5. `cargo test --workspace` -- full workspace compilation and test pass
6. Verify `_task_id` serialization in RequestMeta produces `_task_id` (not `_taskId`) in JSON
7. Verify `tasks/cancel` without result still cancels (backward compatibility)
</verification>

<success_criteria>
- RequestMeta has _task_id field that serializes as "_task_id" in JSON
- ServerCore intercepts _task_id on normal tool calls and fires continuation recording
- Continuation recording is fire-and-forget (never fails the tool call)
- TaskRouterImpl matches tool name to remaining workflow step (first match wins)
- Matched results stored under _workflow.result.<step_name>, status updated to completed
- Unmatched results stored under _workflow.extra.<tool_name>
- Last result wins on retry (overwrite semantics)
- tasks/cancel with result field transitions task to Completed
- tasks/cancel without result field transitions task to Cancelled (backward compatible)
- tasks/result returns standard task data with _workflow.* variables
- All existing tests pass unchanged
</success_criteria>

<output>
After completion, create `.planning/phases/06-structured-handoff-and-client-continuation/06-02-SUMMARY.md`
</output>
