Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
4a65a3b
fix(runtime): release cancellation compensations before re-raising Ca…
jamespud Aug 23, 2026
8514999
fix(runtime): close remaining cancellation cleanup races
jamespud Aug 24, 2026
6b652d3
fix(runtime): protect durable claim handoff from cancellation
jamespud Aug 24, 2026
8cc0ed6
refactor(runtime): add bounded cancellation task wait
jamespud Aug 24, 2026
17fcb40
test(runtime): pin cancellation wait deadline
jamespud Aug 24, 2026
71e5e64
test(runtime): exercise real repeated cancellation
jamespud Aug 24, 2026
b688965
fix(runtime): supervise MCP cancellation compensation
jamespud Aug 24, 2026
ac3ac4c
refactor(runtime): harden compensation supervision
jamespud Aug 24, 2026
4d2fb7d
fix(runtime): bound MCP cancellation handoffs
jamespud Aug 24, 2026
099cd8c
fix(runtime): observe cancelled batch releases
jamespud Aug 24, 2026
96236c9
fix(runtime): bound MCP task service stop
jamespud Aug 24, 2026
9955790
fix(runtime): share MCP stop deadline
jamespud Aug 24, 2026
e36857b
fix(runtime): detach stalled journal writes on cancellation
jamespud Aug 24, 2026
a153850
fix(runtime): serialize journal write handoffs
jamespud Aug 24, 2026
9a0d37d
fix(runtime): supervise timed-out run cleanup
jamespud Aug 24, 2026
dc73389
fix(runtime): enforce run shutdown deadline
jamespud Aug 24, 2026
1d54fd7
fix(runtime): track run cleanup producers
jamespud Aug 24, 2026
a9c944d
fix(runtime): close run shutdown deadline gaps
jamespud Aug 24, 2026
0c39f05
fix(runtime): observe runs after shutdown lock timeout
jamespud Aug 24, 2026
be1d846
fix(runtime): close cancellation audit gaps
jamespud Aug 24, 2026
20c11aa
fix(runtime): supervise cancelled MCP batches
jamespud Aug 24, 2026
1967285
fix(runtime): hand off MCP retry releases
jamespud Aug 24, 2026
4aba0e2
fix(runtime): isolate cancelled MCP retry releases
jamespud Aug 24, 2026
cb08bd1
fix(runtime): isolate cancelled notification releases
jamespud Aug 24, 2026
e91d014
fix(runtime): isolate cancelled MCP claims
jamespud Aug 24, 2026
6528a86
docs(runtime): document bounded cancellation drains
jamespud Aug 24, 2026
f6c2eab
docs(runtime): clarify cancellation ownership boundaries
jamespud Aug 24, 2026
8fe86c6
docs(runtime): align cancellation guarantees with behavior
jamespud Aug 24, 2026
3016a90
docs(runtime): streamline cancellation guidance
jamespud Aug 24, 2026
da5edf8
docs(runtime): correct cancellation guide routing
jamespud Aug 24, 2026
13898f8
fix(runtime): log cancelled batch child failures
jamespud Aug 24, 2026
1cd4ceb
fix(runtime): avoid counting cancelled polls as failures
jamespud Aug 24, 2026
6722111
fix(runtime): fence MCP task releases by per-claim lease tokens
jamespud Aug 27, 2026
c674399
fix(runtime): bound journal flushes against hung writes
jamespud Aug 27, 2026
15141e0
fix(runtime): bound MCP task claim and release waits on uncancelled p…
jamespud Aug 27, 2026
6101511
fix(runtime): serialize concurrent journal flushes
jamespud Aug 27, 2026
8a2a191
fix(runtime): bound journal progress snapshot waits
jamespud Aug 27, 2026
24e59d7
fix(runtime): close cancellation finalization gaps
jamespud Aug 28, 2026
7eca7a5
Merge upstream/main into fix/cancellation-safety
jamespud Aug 30, 2026
498ff03
fix(runtime): address final cancellation review findings
jamespud Aug 30, 2026
a77d8a4
fix(runtime): reconcile late delivery receipt failures
jamespud Aug 30, 2026
72bcfcd
fix(runtime): reconcile late delivery failures after terminal writes
jamespud Aug 30, 2026
c6b9d02
fix(runtime): reconcile exceptional finalization failures
jamespud Aug 30, 2026
562710e
fix(runtime): bound late finalization reconciliation
jamespud Aug 31, 2026
7fd25e7
fix(runtime): close cancellation ownership gaps
jamespud Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ OpenViking memory backend; it does not replace automatic turn capture or recall.

The Gateway can adapt an MCP server's ordinary `submit` / `status` / `cancel` tools into durable background tasks. The Agent sees only the configured submit tool and a DeerFlow-local task ID; remote IDs are persisted before the submit call returns, while status and cancel stay internal to the runtime. Polling uses cross-worker leases, exponential retry backoff, scoped MCP sessions, bounded result storage, and restart recovery. A status-tool `isError` is retained as a bounded diagnostic and retried; servers report a permanent remote-task outcome through a normal structured result with `status: "failed"`. Remote poll hints are finite positive numbers capped at 24 hours, artifact-reference JSON is limited to 64 KiB, and task/server identifiers are validated against their durable SQL column limits before persistence. Input-required and terminal updates wake the current chat through idempotent Agent runs, while `list_background_tasks` and `cancel_background_task` let the Agent manage tasks without asking users for remote handles. Current-thread tasks are available through `GET /api/threads/{thread_id}/mcp-tasks`, its detail endpoint, and `POST /api/threads/{thread_id}/mcp-tasks/{task_id}/cancel`; when the task runtime actually starts, the Web UI exposes the same safe local view from the chat header with live status refresh, cancellation, and on-demand result, artifact, input-request, status-error, and cancellation-retry details. Default-disabled and memory-backend deployments hide that UI and do not poll the task endpoints. A failed remote cancellation remains queued with backoff, and its latest bounded error and attempt count stay visible in the expanded task card. Enable `mcp_tasks` in `config.yaml`, configure `task_toolsets` with exact raw tool names in `extensions_config.json`, and use a SQL database backend (`sqlite` or `postgres`). Task-enabled server connection, authentication, interceptor, timeout, or binding changes require a Gateway restart so Agent tool discovery and background calls cannot use different configuration versions. `input_required` is notification-only for now: DeerFlow can display the request but cannot yet submit the user's answer back to the remote task.

Notification launch and failed Agent-run deliveries use capped exponential backoff with a visible attempt count and stop after five failed attempts. A permanently rejected target such as a deleted chat is dead-lettered immediately instead of retried forever or recreated. Cancellation endpoints return after durably recording the request; the background service owns the potentially slow remote MCP call and its retry schedule.
Notification launch and failed Agent-run deliveries use capped exponential backoff with a visible attempt count and stop after five failed attempts. When a bounded ordinary release exceeds its drain deadline, the service retains ownership until it settles; best-effort progress snapshots request cancellation at the drain boundary and retain their reference until they settle, with delayed replacements keeping independent scheduling state. A permanently rejected target such as a deleted chat is dead-lettered immediately instead of retried forever or recreated. Cancellation endpoints return after durably recording the request; the background service owns the potentially slow remote MCP call and its retry schedule.

Notification runs keep their trusted delivery instruction separate from the framed, untrusted remote event payload. The process-started task runtime—not a hot config read—controls whether the task-management tools are exposed, so changing `mcp_tasks` requires a Gateway restart. When a skill's `allowed-tools` policy is active, `list_background_tasks` and `cancel_background_task` must be declared explicitly like other business tools.
See the [MCP Server Guide](backend/docs/MCP_SERVER.md) for detailed instructions.
Expand Down
25 changes: 23 additions & 2 deletions backend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,29 @@ DeerFlow is a LangGraph-based AI super agent system with a full-stack architectu
- Background subagent identity is deliberately split: the provider `tool_call_id` remains the correlation key for `ToolMessage`, `task_*` SSE events, persisted lifecycle events, frontend cards, and the public `ExtensionData.scope_id` contract (stored as `SubagentResult.external_task_id`), while `SubagentExecutor.execute_async()` generates a full server-side `execution_id` for `SubagentResult.task_id`, the process-wide registry, polling, cancellation, timeout handling, and cleanup. Provider IDs are not globally unique across parent runs, so they must never become registry ownership keys; scheduler closures retain their own `SubagentResult` rather than resolving ownership again through the mutable registry. Terminal subagent token usage travels in the current run's `ToolMessage.additional_kwargs` and is attributed from message state, never through a process-global provider-ID cache.
- Scheduled-task executions must reuse that same Gateway run lifecycle. The scheduler may decide *when* work runs, but it must dispatch through the existing run path rather than introducing a parallel execution stack. Scheduled launches pass `scheduler.recursion_limit` (default 1000, matching the web UI's `recursion_limit: 1000`, clamped by `max_recursion_limit`) via `launch_scheduled_thread_run`; the value is read from `get_app_config()` at dispatch.
- The background scheduler is single-instance by default. `scheduler.multi_instance=true` opts into lease-aware recovery across Gateway instances and requires shared Postgres, `run_ownership.heartbeat_enabled=true`, and `run_events.backend=db`; otherwise startup rejects the configuration. Live scheduled runs are preserved when a peer starts; expired launch claims return to the durable queue, expired run leases are atomically taken over, stale launch writes are fenced by lease ownership, and the Postgres advisory-locked budget makes `max_concurrent_runs` a shared global cap for `launching`/`running` rows.
- Long-running MCP work uses a separate durable task runtime rather than keeping remote task IDs or status polling inside the Agent loop. Explicit `task_toolsets` bind raw submit/status/cancel names; only submit remains Agent-visible, and its wrapper persists the remote handle before returning a local ID. `McpTaskService` claims due rows with leases, resolves a protocol-specific `McpTaskDriver`, and writes normalized snapshots back to `mcp_tasks`; expired leases are the restart-recovery mechanism, and a result returned after expiry or after a cancel request must be discarded even when the owner token still matches. The first cancel request fences an in-flight poll lease, while repeats preserve an active cancellation lease so they cannot issue concurrent remote cancels; cancellation backoff starts when the remote attempt finishes, so a slow timeout cannot consume the retry delay. Cancellation, polling, and notification batches isolate per-task exceptions; an unexpected cancellation/poll failure leaves that record's lease to expire, while notification failures release only the affected lease for retry. Input-required and terminal event snapshots are delivered by idempotent Agent runs and marked delivered only after run success; the trusted notification instruction stays outside the input boundary while the serialized remote event is framed as untrusted data. A busy-thread conflict is normalized back to the service boundary so the queued snapshot coalesces to the latest task event. A missing dispatched run becomes a failed delivery attempt, while transient run-store hydration errors stay distinguishable and retry the same lookup. The database is the source of truth; `ThreadState` receives only a bounded current-thread projection, and display names are neutralized at that model-state boundary. The installed process-local submitter is the source of truth for management-tool exposure; hot `mcp_tasks` edits take effect only after restart, and active skills must explicitly declare the list/cancel business tools.
- MCP notification failures use a consecutive counter separate from the idempotency-key `dispatch_attempt`, capped exponential backoff, latest-event rebuilding before a run launches, and a five-attempt budget before `dead_letter`. A permanently missing/mismatched target thread is dead-lettered immediately instead of being recreated or reclaimed. HTTP and Agent cancellation requests return after the durable cancel fence; the background loop alone owns the potentially slow remote call and retry schedule. The HTTP cancel endpoint rejects requests with 503 when the loop is not running (`mcp_tasks_available` false, e.g. `mcp_tasks.enabled=false` with SQL persistence), so a cancellation is never acknowledged without a worker to perform it. The bounded notification error/count/status join poll and cancellation diagnostics in the task detail API and expanded card.
- Long-running MCP work uses the durable task runtime in `app/mcp_tasks/service.py`:
the database is authoritative, leases provide recovery, and results after lease
expiry or a cancel fence are discarded. HTTP and Agent cancellation return after
the durable fence; the worker owns remote cancellation and retry. Notification
delivery is idempotent and bounded by retry/dead-letter policy, with delivered
state written only after the Agent run succeeds. Detailed claim/release, batch,
delivery, and tool-exposure rules live in the [MCP guide](packages/harness/deerflow/mcp/AGENTS.md);
cancellation, batch, journal, and run-ownership rules live in the [runtime guide](packages/harness/deerflow/runtime/AGENTS.md).
- Cancellation ownership crosses MCP, RunManager, and journal boundaries. Each
cancellation drain and `McpTaskService.stop()` currently use the module-local
`_CANCELLATION_DRAIN_TIMEOUT_SECONDS = 5.0`. When
caller-cancellation draining times out:
the exact asyncio operation task remains retained by the subsystem-owned registry after timeout.
Caller cancellation is re-raised, while normal `McpTaskService.stop()` and
`RunManager.shutdown()` record/log the deadline and return as retained work
continues. `RunManager.shutdown(timeout=5.0)` keeps a caller-provided hard
total budget (the default is overrideable), establishes one absolute deadline,
and gives nested waits only the remaining time. RunManager fencing is local;
durable terminalization may be reconciled by peers/orphans. See the [runtime guide](packages/harness/deerflow/runtime/AGENTS.md)
for the boundary-specific rules and source map.
Batch ordinary-release tasks are transferred to the MCP service compensation
registry on timeout; journal progress snapshots are best-effort, request
cancellation at their bounded observation, and retain ownership until settle.
- Scheduled-task dispatch enforces at most one non-terminal occurrence per task through `uq_scheduled_task_run_active` (`task_id WHERE status IN ('queued','launching','running')`). `queued` is durable and survives restart; `launching` carries a short owner/expiry lease and is the only state that may call the normal Gateway launch path; `running` is associated with the durable run. Each occurrence also supplies a stable run-admission idempotency key, so a recovered launch retry reuses the same durable run. A reused-thread `ConflictError` moves `launching` back to `queued`, while non-conflict launch errors become terminal `failed`. Waiting rows do not consume `max_concurrent_runs`; the atomic queue claim enforces the budget. Repeated triggers coalesce on the one active row, and same-thread FIFO treats older `queued`, `launching`, and `running` rows as blockers. The task definition stays immutable for all three active states because queue admission, PATCH/resume, pause, and delete serialize on the parent task row before touching the occurrence row. Pause/delete atomically interrupt existing `queued` rows and reject `launching`/`running` rows; PATCH/resume reject every active state, and mutation errors advertise pause cancellation only for `queued` work. A manual trigger may queue and run while the parent schedule remains paused. Recovery and multi-instance reconciliation lock task/run pairs in deterministic task-id/run-id order and must reconstruct `run_id`, `started_at`, and the live error state before releasing the short launch claim. Launch/failure/timeout bookkeeping changes the occurrence and its parent task in one parent-first transaction so a peer cannot claim the released task between those writes. Queue timeout marks the occurrence failed and advances a scheduled occurrence so it cannot immediately requeue forever; repository write boundaries coerce serialized task timestamps before binding SQL `DateTime` fields.
- `extensions_config.json` is written at runtime by the Gateway (`PUT`/`PATCH /api/mcp/config`, the MCP enable switch, skill updates), so the production compose mounts it read-write while `config.yaml` stays `:ro`; Helm copies its ConfigMap seed into a writable home-volume directory before Gateway starts. Every read-modify-write holds both `extensions_config_write_lock` and the sidecar advisory `extensions_config_file_lock`, because the process-local lock alone loses updates across workers. Docker mounts the compose file as its own mount point, and Linux refuses `rename()` over a mount point with `EBUSY` even when the mount is writable — so `atomic_write_extensions_config` keeps the temp-file-plus-rename path and falls back to an in-place overwrite only on `EBUSY`. That fallback is deliberately non-atomic (a crash mid-write truncates the file); it exists because the alternative is a write that can never succeed, and only its first occurrence per target is logged at warning level. Any other `errno` still propagates. Pinned by `tests/test_compose_extensions_config_writable.py`, `tests/test_extensions_config_atomic_write.py`, and `tests/test_helm_extensions_config_writable.py`.

Expand Down
Loading