436 lines
17 KiB
Markdown
436 lines
17 KiB
Markdown
# FN-4819 — Distributed Multi-Node Task Coordination Gap Specification
|
|
|
|
## 1. Baseline: Single-Node SQLite WAL Correctness Model
|
|
|
|
Fusion's current correctness baseline is single-node.
|
|
Within one project runtime, structured task metadata is stored in one per-project SQLite database at `.fusion/fusion.db`.
|
|
That database runs in WAL mode.
|
|
Large blobs stay on disk under `.fusion/tasks/{ID}/`.
|
|
|
|
From `AGENTS.md` (Storage Model):
|
|
|
|
> Fusion uses a hybrid storage architecture: structured metadata lives in SQLite (`.fusion/fusion.db`) while large blob files (PROMPT.md, attachments) remain on the filesystem under `.fusion/tasks/{ID}/`. The database runs in WAL mode for concurrent access.
|
|
|
|
From `AGENTS.md` (Checkout Leasing):
|
|
|
|
> Checkout conflicts return **409 Conflict** when another agent already holds the lease.
|
|
> Clients **must not retry 409 automatically** — this is ownership contention, not a transient failure.
|
|
|
|
From `docs/storage.md`:
|
|
|
|
- Project authoritative task row storage is `.fusion/fusion.db` (`docs/storage.md:70`).
|
|
- SQLite connections enforce `journal_mode = WAL` and `busy_timeout` (`docs/storage.md:92-101`).
|
|
|
|
Within one process, ownership is enforced by `AgentStore.checkoutTask(...)`.
|
|
The in-process path checks `task.checkedOutBy`, throws `CheckoutConflictError` if held by another agent, and writes lease fields on success (`packages/core/src/agent-store.ts:1378-1426`).
|
|
|
|
Durable lease-related fields already present on task rows are:
|
|
|
|
- `checkedOutBy`
|
|
- `checkoutNodeId`
|
|
- `checkoutRunId`
|
|
- `checkoutLeaseEpoch`
|
|
- `checkoutLeaseRenewedAt`
|
|
|
|
These are defined on task records in `packages/core/src/store.ts:155-160` and persisted through the task row write path (`packages/core/src/store.ts:1571-1576`, `1599-1624`).
|
|
|
|
### Gap statement
|
|
|
|
Under multi-node operation, each node can maintain a locally consistent read/write path for the same logical task row, but there is no documented atomic cross-node compare-and-set ownership contract.
|
|
Two nodes can each evaluate the local single-row precondition as true before either write is globally fenced, causing double-checkout risk.
|
|
SQLite WAL guarantees concurrency semantics for a database file, not distributed consensus across multiple node runtimes.
|
|
Therefore, the single-node WAL safety model does not, by itself, define cross-node lease correctness.
|
|
|
|
## 2. Distributed Checkout Mutex (Independent, Testable)
|
|
|
|
This section is intentionally independent from §3.
|
|
It is testable without invoking unavailable-node handoff logic.
|
|
|
|
### 2.1 Contract
|
|
|
|
A lease claim attempt by claimant tuple `(nodeId, agentId, runId)` against task `(projectId, taskId)` MUST:
|
|
|
|
1. Atomically read ownership precondition over:
|
|
- `checkedOutBy`
|
|
- `checkoutNodeId`
|
|
- `checkoutLeaseEpoch`
|
|
2. Either:
|
|
- Acquire ownership and bump epoch (`checkoutLeaseEpoch = priorEpoch + 1`) for owner changes, or
|
|
- Return `CheckoutConflictError` populated with current owner details.
|
|
3. Renewal by the existing owner MUST update renewal timestamp but MUST NOT bump epoch.
|
|
|
|
### 2.2 Authority placement decision
|
|
|
|
**Decision:** Use an authoritative central claim row in `~/.fusion/fusion-central.db`, keyed by `(projectId, taskId)`.
|
|
|
|
Why this option:
|
|
|
|
- Least new infrastructure relative to existing architecture.
|
|
- Reuses existing central registry database already used for cross-project/node coordination (`docs/multi-project.md:19`, `packages/core/src/central-core.ts:7`, `2626`).
|
|
- Avoids introducing external consensus services.
|
|
- Keeps per-project `.fusion/fusion.db` semantics intact for local task state while adding one cross-node authority point.
|
|
|
|
Rejected options (for FN-4819 scope):
|
|
|
|
- Pure per-project DB mutex only:
|
|
- insufficient when ownership arbitration spans nodes and runtimes.
|
|
- Full distributed consensus layer:
|
|
- larger infra jump than required for the defined gap.
|
|
|
|
### 2.3 Data model (minimum)
|
|
|
|
Authoritative central table (conceptual): `taskClaims`
|
|
|
|
Primary key:
|
|
|
|
- `(projectId, taskId)`
|
|
|
|
Required columns:
|
|
|
|
- `projectId TEXT NOT NULL`
|
|
- `taskId TEXT NOT NULL`
|
|
- `ownerNodeId TEXT NOT NULL`
|
|
- `ownerAgentId TEXT NOT NULL`
|
|
- `ownerRunId TEXT NULL`
|
|
- `leaseEpoch INTEGER NOT NULL`
|
|
- `leaseRenewedAt TEXT NOT NULL`
|
|
- `createdAt TEXT NOT NULL`
|
|
- `updatedAt TEXT NOT NULL`
|
|
|
|
Optional but recommended columns:
|
|
|
|
- `ownerSessionId TEXT NULL`
|
|
- `metadataJson TEXT NULL`
|
|
|
|
Task row remains source for local executor checks and backward compatibility:
|
|
|
|
- `checkedOutBy`
|
|
- `checkoutNodeId`
|
|
- `checkoutRunId`
|
|
- `checkoutLeaseEpoch`
|
|
- `checkoutLeaseRenewedAt`
|
|
|
|
### 2.4 Acquisition semantics
|
|
|
|
Acquisition is one atomic write transaction in central DB.
|
|
Use single-writer semantics with `INSERT ... ON CONFLICT(projectId, taskId) DO UPDATE ... WHERE <precondition>`.
|
|
|
|
Precondition for claimant change:
|
|
|
|
- Existing claim is absent, OR
|
|
- Existing claim matches same owner tuple for renewal path, OR
|
|
- Existing claim explicitly released.
|
|
|
|
Owner-change claim success path:
|
|
|
|
- `leaseEpoch = existingEpoch + 1` (or `1` on first claim)
|
|
- Set owner tuple to claimant.
|
|
- Set `leaseRenewedAt = now`.
|
|
|
|
Renewal success path (same owner tuple):
|
|
|
|
- Keep `leaseEpoch` unchanged.
|
|
- Update `leaseRenewedAt = now`.
|
|
|
|
Loser path:
|
|
|
|
- No mutation.
|
|
- Return `CheckoutConflictError` with current holder.
|
|
- Caller MUST NOT auto-retry immediately (matches existing 409 semantics from `AGENTS.md` Checkout Leasing).
|
|
|
|
### 2.5 Per-project row synchronization
|
|
|
|
On successful central claim transaction, update per-project task row lease fields in the same logical operation boundary:
|
|
|
|
- Write `checkedOutBy = ownerAgentId`
|
|
- Write `checkoutNodeId = ownerNodeId`
|
|
- Write `checkoutRunId = ownerRunId`
|
|
- Write `checkoutLeaseEpoch = central.leaseEpoch`
|
|
- Write `checkoutLeaseRenewedAt = central.leaseRenewedAt`
|
|
|
|
On release:
|
|
|
|
- Clear owner fields on both central row and task row.
|
|
- Preserve monotonic epoch progression in central row history (or tombstone).
|
|
|
|
If a split-write occurs (central success, task-row write fail), reconciliation policy is:
|
|
|
|
- Treat central row as authority.
|
|
- Repair task row via reconciliation worker before next dispatch.
|
|
|
|
### 2.6 Heartbeat semantics
|
|
|
|
Heartbeat renewal from active owner:
|
|
|
|
- Re-assert same owner tuple.
|
|
- Update `leaseRenewedAt`.
|
|
- Do not bump epoch.
|
|
|
|
Heartbeat from stale owner (epoch mismatch or owner mismatch):
|
|
|
|
- Must fail as conflict.
|
|
- Worker aborts run before any write/merge path.
|
|
|
|
### 2.7 API-level behavior contract
|
|
|
|
`AgentStore.checkoutTask(...)` remains the integration point.
|
|
Behavior evolves from single-row local check to:
|
|
|
|
1. central authoritative claim mutation
|
|
2. task-row synchronization write
|
|
3. return claimed task
|
|
|
|
Conflict mapping remains `CheckoutConflictError` / 409 for external callers.
|
|
No automatic retry loops in caller.
|
|
|
|
### 2.8 Testability surface
|
|
|
|
Required unit-level contract test:
|
|
|
|
- Given pre-seeded central claim row with owner A
|
|
- When owner B attempts claim
|
|
- Then claim fails with `CheckoutConflictError`
|
|
- And error contains `currentHolder = owner A`
|
|
|
|
Required race integration test:
|
|
|
|
- Simulate two nodes racing same `(projectId, taskId)` claim.
|
|
- Use barrier/latch to release both attempts simultaneously.
|
|
- Assert exactly one winner.
|
|
- Assert loser receives `CheckoutConflictError`.
|
|
- Assert post-state owner fields match winner tuple `(nodeId, agentId)`.
|
|
- Assert epoch bumped exactly once from initial value.
|
|
|
|
## 3. Unavailable-Node Handoff Policy (Independent, Testable)
|
|
|
|
This section is intentionally independent from §2.
|
|
It is testable without invoking mutex acquisition by pre-seeding task ownership rows and node health transitions.
|
|
|
|
### 3.1 Scope and intent
|
|
|
|
Define deterministic handling for node availability transitions affecting existing lease owners.
|
|
No live-process migration is introduced.
|
|
Scheduler failover is out of scope.
|
|
|
|
### 3.2 Transition A: pre-dispatch unavailable
|
|
|
|
Keep current dispatch semantics from `applyUnavailableNodePolicy(...)`:
|
|
|
|
- `fallback-local` allows local fallback (`packages/engine/src/node-routing-policy.ts:11-37`).
|
|
- `block` refuses dispatch with reason (`packages/engine/src/node-routing-policy.ts:39-42`).
|
|
|
|
Current scheduler call site remains dispatch-time policy gate (`packages/engine/src/scheduler.ts:1005`).
|
|
|
|
### 3.3 Transition B: owner unreachable mid-task
|
|
|
|
Behavior contract:
|
|
|
|
1. In-flight task remains owned until lease expiry window is reached.
|
|
2. After lease expiry, if `NodeHealthMonitor` reports owner status `offline` or `error` (`packages/engine/src/node-health-monitor.ts:132-148`), owner is considered unreachable.
|
|
3. Recovery action:
|
|
- release claim
|
|
- move task to `todo`
|
|
- preserve step progress (`preserveProgress = true` when progress exists)
|
|
- do not preserve old worktree on picking node (`preserveWorktree = false`)
|
|
4. Emit run-audit event: `task:auto-recover-node-unreachable`.
|
|
|
|
`MeshLeaseManager.isLeaseRecoverable(...)` and `recoverAbandonedLease(...)` provide current recovery seam (`packages/engine/src/mesh-lease-manager.ts:23-107`), extended by policy checks above.
|
|
|
|
No executor/session process handoff occurs.
|
|
Restarted work is fresh execution from preserved progress metadata.
|
|
|
|
### 3.4 Transition C: owner returns after handoff
|
|
|
|
A previously owning node may come back online after another node advanced epoch.
|
|
Returning node behavior:
|
|
|
|
- On next heartbeat, validate lease ownership.
|
|
- If central claim owner/epoch no longer matches local run, abort cleanly.
|
|
- Do not continue writes, merges, or `fn_task_done`.
|
|
|
|
Existing detection seam:
|
|
|
|
- `HeartbeatMonitor.executeHeartbeat()` already validates `task.checkedOutBy` and exits with checkout-conflict semantics (`AGENTS.md` Checkout Leasing section).
|
|
|
|
Minimal extension needed:
|
|
|
|
- include central epoch/owner validation (or synchronized local epoch validation) in the same heartbeat preflight so stale owner runs fence themselves before side effects.
|
|
|
|
### 3.5 Policy enum
|
|
|
|
Define policy enum for unavailable-owner reassignment behavior:
|
|
|
|
- `block`
|
|
- `reassign-to-local`
|
|
- `reassign-any-healthy`
|
|
|
|
Default recommendation:
|
|
|
|
- Keep default behavior equivalent to today's fallback/local-block pattern unless explicitly configured.
|
|
- For compatibility, map existing `fallback-local` to `reassign-to-local` in transition layer.
|
|
|
|
### 3.6 Testability surface
|
|
|
|
Unit policy tests:
|
|
|
|
- For each enum value, assert expected decision for `{owner online, owner offline, owner error, owner unknown}`.
|
|
|
|
Scheduler integration tests:
|
|
|
|
1. Online-owner protection:
|
|
- pre-seed task owned by online node A
|
|
- scheduler on node B must not dispatch it
|
|
2. `reassign-to-local` behavior:
|
|
- pre-seed task owned by offline node A
|
|
- after lease expiry and policy evaluation, row is released
|
|
- task becomes dispatch-eligible for local node B
|
|
|
|
Independence harness note:
|
|
|
|
- These tests can bypass §2 by directly seeding ownership row + lease timestamps and toggling health monitor outputs.
|
|
|
|
## 4. Single-Project → Multi-Project / Multi-Node Isolation & Ownership Transition Path
|
|
|
|
### 4.1 Supported state machine
|
|
|
|
Supported operational states:
|
|
|
|
- `single-project/in-process`
|
|
- `multi-project/in-process`
|
|
- `multi-project/child-process`
|
|
- `multi-project/in-process + remote runtime placement`
|
|
|
|
Transition trigger is runtime reconfiguration requiring restart-bound re-evaluation.
|
|
|
|
### 4.2 Preconditions
|
|
|
|
Transition allowed when:
|
|
|
|
- Node is idle (`0` tasks in `in-progress`), OR
|
|
- Caller passes explicit `force` flag.
|
|
|
|
If `force=true`:
|
|
|
|
- In-flight tasks are paused through engine-initiated rebound path.
|
|
- Do not mark tasks as `userPaused`.
|
|
- Resume under new runtime after restart and ownership re-validation.
|
|
|
|
### 4.3 Runtime selection behavior on transition
|
|
|
|
On startup after transition:
|
|
|
|
- Re-evaluate `shouldUseHybridExecutor(...)` gate (`packages/engine/src/hybrid-executor-gate.ts:20`).
|
|
- Re-evaluate runtime type selection in `ProjectManager.addProject(...)` based on `isolationMode` (`packages/engine/src/project-manager.ts:168-170`).
|
|
- Continue to use `CentralCore` registration as source for project placement and mode (`packages/core/src/central-core.ts:252`, `449`).
|
|
|
|
Per-project SQLite DBs are not migrated or rewritten by this transition.
|
|
They remain at `.fusion/fusion.db` per project.
|
|
|
|
### 4.4 Central claim bootstrap
|
|
|
|
On first startup in multi-node-capable mode:
|
|
|
|
- Apply idempotent migration to create central `taskClaims` table (from §2).
|
|
- Migration must be safe to rerun.
|
|
- No data loss on repeated startups.
|
|
|
|
### 4.5 Rollback rules
|
|
|
|
Rollback to single-project mode is only supported when node is sole registered node in `CentralCore`.
|
|
If multiple nodes are registered, reject rollback with actionable error.
|
|
|
|
Rationale:
|
|
|
|
- prevents silently orphaning cross-node claim ownership semantics.
|
|
|
|
### 4.6 Durable trail requirements
|
|
|
|
Every transition writes durable activity/audit records:
|
|
|
|
- Activity entry: `project:isolation-transition`
|
|
- Event entry: `project:runtime-restarted` with payload:
|
|
- `projectId`
|
|
- `isolationMode`
|
|
- `reason`
|
|
|
|
### 4.7 Explicit non-hot-swap scope
|
|
|
|
Hot-swap isolation transitions without restart are out of scope.
|
|
All supported transitions are restart-bound.
|
|
|
|
## 5. Non-Goals (Explicit Exclusions)
|
|
|
|
The following are explicitly out of scope for FN-4819 and MUST NOT be pulled into this brief's implementation follow-up:
|
|
|
|
1. **Scheduler failover**
|
|
- Exclusion: a peer taking over the live scheduler tick loop for another node's project runtime.
|
|
- Rationale: requires runtime leadership election and scheduler state transfer not needed to close lease-ownership gap.
|
|
2. **Live-process state migration**
|
|
- Exclusion: transferring in-memory executor/session process state mid-task across nodes.
|
|
- Rationale: requires session serialization/resume semantics beyond lease fencing and handoff policy.
|
|
3. **Cross-node consensus for engine settings mutations**
|
|
- Exclusion: globally coordinated settings write quorum.
|
|
- Rationale: orthogonal control-plane consistency problem.
|
|
4. **Multi-master concurrent writes to the same per-project `.fusion/fusion.db`**
|
|
- Exclusion: active simultaneous writers from multiple nodes to one project DB file.
|
|
- Rationale: storage topology constraint outside this task's lease contract.
|
|
5. **Automatic node promotion**
|
|
- Exclusion: autonomous promotion/demotion workflows for node roles.
|
|
- Rationale: operational governance feature, not checkout correctness.
|
|
|
|
No follow-up task is to be filed under this brief for these exclusions.
|
|
|
|
## 6. Acceptance Mapping
|
|
|
|
- **Acceptance: "four concerns called out as separate sections"**
|
|
- Satisfied by:
|
|
- §1 Baseline correctness model
|
|
- §2 Distributed checkout mutex
|
|
- §3 Unavailable-node handoff policy
|
|
- §4 Isolation/ownership transition path
|
|
- **Acceptance: "distributed checkout mutex and unavailable-node handoff policy explicitly independent and testable"**
|
|
- Satisfied by:
|
|
- §2 opening independence statement + §2.8 testability surface
|
|
- §3 opening independence statement + §3.6 testability surface
|
|
- **Acceptance: "WAL-based single-node baseline referenced in the protocol/docs section"**
|
|
- Satisfied by:
|
|
- §1 references to `AGENTS.md` Storage Model and `docs/storage.md` WAL details
|
|
- **Acceptance: "non-goal section explicitly excludes scheduler failover/live-process migration"**
|
|
- Satisfied by:
|
|
- §5 items 1 and 2
|
|
|
|
## 7. Code & Doc References
|
|
|
|
All references below were path-verified during FN-4819 preflight.
|
|
|
|
- `AGENTS.md:81-85` — Storage Model canonical wording
|
|
- `AGENTS.md:472-486` — Checkout Leasing conflict semantics and heartbeat enforcement notes
|
|
- `docs/storage.md:70` — per-project DB row source (`.fusion/fusion.db`)
|
|
- `docs/storage.md:92-101` — WAL/busy-timeout and concurrent write notes
|
|
- `docs/multi-project.md:19` — central DB path `~/.fusion/fusion-central.db`
|
|
- `docs/multi-project.md:108-113` — isolation mode definitions
|
|
- `docs/multi-project.md:158-166` — ProjectManager runtime-mode mapping narrative
|
|
- `docs/shared-mesh-protocol.md:16-19` — existing protocol non-goals including scheduler failover/live-process migration
|
|
- `docs/architecture.md` — engine composition and run-audit reference baseline
|
|
- `packages/core/src/store.ts:155-160` — task lease field definitions
|
|
- `packages/core/src/store.ts:1571-1576` — persisted lease field write mapping
|
|
- `packages/core/src/store.ts:1599-1624` — insert/upsert columns include lease fields
|
|
- `packages/core/src/agent-store.ts:1378-1426` — `checkoutTask` current single-process lease behavior
|
|
- `packages/core/src/agent-store.ts:47` — `CheckoutConflictError` usage import site
|
|
- `packages/core/src/central-core.ts:7` — central DB location note
|
|
- `packages/core/src/central-core.ts:252-255` — `registerProject` with `isolationMode`
|
|
- `packages/core/src/central-core.ts:449` — `updateProject` mutation path
|
|
- `packages/core/src/central-core.ts:2626` — central DB path getter
|
|
- `packages/core/src/types.ts:3369` — `IsolationMode = "in-process" | "child-process"`
|
|
- `packages/engine/src/project-manager.ts:168-170` — `isolationMode` runtime branching
|
|
- `packages/engine/src/node-routing-policy.ts:4-42` — `PolicyDecision` and `applyUnavailableNodePolicy`
|
|
- `packages/engine/src/mesh-lease-manager.ts:23-107` — lease recoverability and recovery path
|
|
- `packages/engine/src/node-health-monitor.ts:132-148` — offline/error transition handling
|
|
- `packages/engine/src/scheduler.ts:1005` — dispatch-time unavailable-node policy call site
|
|
- `packages/engine/src/hybrid-executor-gate.ts:20` — `shouldUseHybridExecutor` decision seam
|
|
|
|
## 8. Related Work (Optional Footnote)
|
|
|
|
No sibling FN design brief is required to interpret this document.
|
|
If adjacent work (for example another FN-48xx mesh coordination brief) exists, treat it as supplemental context only; FN-4819 remains self-contained and normative for this specific coordination-gap specification.
|