diff --git a/.changeset/multiworkspace-worktree-persistence.md b/.changeset/multiworkspace-worktree-persistence.md
new file mode 100644
index 0000000000..2179e7a766
--- /dev/null
+++ b/.changeset/multiworkspace-worktree-persistence.md
@@ -0,0 +1,5 @@
+---
+"@runfusion/fusion": patch
+---
+
+Fix multiworkspace tasks failing to complete. `task.workspaceWorktrees` is now durably persisted (it previously had no SQLite column, so `fn_acquire_repo_worktree`'s write was dropped on every persist and `fn_task_done` always reported "acquired no sub-repo worktrees"). Concurrent workspace tasks no longer collide on the shared browse-root active-session path — each task gets a task-scoped session key, so a second workspace task no longer fails with "active-session path … is held by …".
diff --git a/CONCEPTS.md b/CONCEPTS.md
index 2afdad7e97..9b952ac8b2 100644
--- a/CONCEPTS.md
+++ b/CONCEPTS.md
@@ -96,6 +96,9 @@ The authoritative task lifecycle runtime. It resolves a Task to workflow IR, wal
### Engine Singleton Lock
A per-machine mutual-exclusion guard ensuring only one fusion process runs the engine for a given project, combining a lockfile in the project's `.fusion/` directory with a per-project loopback socket. Failure to acquire it (`EngineAlreadyRunningError`) is **positive proof an engine is already running** for that project elsewhere on the machine — not an error to swallow and not "no engine." A process refused the lock keeps that as a fact: it reports the engine as available (so UI surfaces don't claim it's down) while reconciliation keeps retrying, so it takes over if the current owner exits.
+### Active-session lease
+A path-keyed, in-memory claim that a given worktree path is held by a specific Task's running session (executor, step, workflow-step, AI-merge, or a workspace sub-repo acquire/land). It serves two jobs at once: mutual exclusion (a second Task may not register a path already held by a different Task — the foreign-task guard) and liveness (self-healing treats a held path as proof the Task is actively running and must not be rebounded). The key is the path, so the registry is only as correct as the path chosen: a path uniquely owned by one Task gives real exclusivity, but a path shared across Tasks (e.g. a workspace's browse-only root) must be made Task-scoped before registration or the guard will reject every concurrent sibling. Re-registration by the same Task is idempotent; cleanup must unregister the exact key that was registered.
+
### ACP Ask Path
A one-turn read-only model ask routed through the ACP runtime rather than a CLI print mode. The runner accumulates streamed prose, may recover a trailing JSON object for structured seams, and treats abnormal ACP stop reasons as incomplete answers for validator use.
diff --git a/docs/solutions/database-issues/task-field-silently-dropped-without-sqlite-column-mapping.md b/docs/solutions/database-issues/task-field-silently-dropped-without-sqlite-column-mapping.md
new file mode 100644
index 0000000000..bbf425e56d
--- /dev/null
+++ b/docs/solutions/database-issues/task-field-silently-dropped-without-sqlite-column-mapping.md
@@ -0,0 +1,72 @@
+---
+title: A Task field is silently dropped on persist unless it has a SQLite column + rowToTask mapping
+date: 2026-06-24
+category: database-issues
+module: core-task-store
+problem_type: database_issue
+component: database
+symptoms:
+ - "fn_task_done on a workspace task fails: workspace task declares File Scope but acquired no sub-repo worktrees — cannot verify scope"
+ - "A field set via store.updateTask(...) is present on the returned task but undefined on the next store.getTask(...)"
+ - "task.json on disk never contains the field even though the update succeeded"
+root_cause: incomplete_setup
+resolution_type: code_fix
+severity: high
+tags: [task-store, sqlite, persistence, rowtotask, workspaceworktrees, silent-data-loss, workspace, multiworkspace]
+related_components: [engine-executor, active-session-registry]
+---
+
+# A Task field is silently dropped on persist unless it has a SQLite column + rowToTask mapping
+
+## Problem
+Adding a field to the `Task` TypeScript type and mutating it in `TaskStore.updateTask` is **not** enough to persist it. If the field has no matching SQLite column, no `defineTaskColumn` descriptor, and no `rowToTask` deserialization, the value is silently dropped on the very next read — with no error. This broke all multiworkspace task completion: `task.workspaceWorktrees` (the per-sub-repo worktree map written by `fn_acquire_repo_worktree`) was such a phantom field.
+
+## Symptoms
+- `fn_task_done` on a workspace task always blocked with: *"workspace task declares File Scope but acquired no sub-repo worktrees — cannot verify scope"* (`executor.ts` scope verifier reading `task.workspaceWorktrees ?? {}` → `{}`).
+- A peer workspace task separately failed with *"active-session path … is held by task … may not overwrite it"* (a second, independent bug fixed alongside — see Related).
+- Ground truth: the live failing tasks' `task.json` files contained **zero** occurrences of `workspaceWorktrees`, even though the agent logs showed the sub-repo worktree was acquired and the acquire tool returned its path.
+
+## What Didn't Work
+- Treating it as a race / stale-read between the acquire write and the `fn_task_done` read. The field was not racing — it was never persisted at all, so no retry or ordering change would help.
+- Inspecting only the executor/scope-verifier side. The verifier read the field correctly; the value was already gone before it ran. The bug was one layer down in the store.
+
+## Solution
+Persist the field by mirroring an existing JSON-object column (`mergeDetails` is the canonical example). All of these are required — adding only some leaves the field still broken:
+
+1. **SCHEMA_SQL** — add the column to `CREATE TABLE tasks` in `db.ts` (this feeds `getSchemaCompatibilityTableSchemas()`, so existing DBs get backfilled by `ensureSchemaCompatibility()` at boot).
+2. **Versioned migration** — `addColumnIfMissing("tasks", "
", "TEXT")` in a new `if (version < N)` block, and bump `SCHEMA_VERSION` to `N`.
+3. **db-migrate.ts** — add the column to the legacy `task.json → SQLite` rebuild INSERT (column list, one `?`, and the `toJsonNullable(task.)` value). Keep column/placeholder/arg counts equal.
+4. **store.ts descriptor** — `defineTaskColumn("", (task) => toJsonNullable(task.))`. This is what `getChangedTaskColumns` uses to detect the field changed and emit it in the UPDATE.
+5. **store.ts TaskRow** — add `: string | null;` to the `TaskRow` interface.
+6. **store.ts rowToTask** — deserialize: `: fromJson<...>(row.)`.
+
+```ts
+// store.ts — descriptor (drives both the write AND change-detection)
+defineTaskColumn("workspaceWorktrees", (task) => toJsonNullable(task.workspaceWorktrees)),
+
+// store.ts — rowToTask (the read side that was missing → undefined on every getTask)
+workspaceWorktrees: (() => {
+ const w = fromJson(row.workspaceWorktrees);
+ return w && Object.keys(w).length > 0 ? w : undefined;
+})(),
+```
+
+## Why This Works
+The trap is in the persist path. `TaskStore.updateTask` mutates the in-memory task, but `applyTaskPatch` writes **`result.current`** to `task.json` — and `result.current` comes from `readTaskFromDb()` → `rowToTask()`, i.e. a fresh round-trip *through SQLite*. A field with no column never makes it into the row, so `rowToTask` reconstructs the task **without** it, and that stripped object is what gets written back to `task.json`. The in-memory mutation is overwritten by the DB's view on the same call. Every later `getTask` reads from SQLite and returns `undefined`. SQLite — not `task.json` — is the source of truth; `task.json` is a debug mirror that is itself rebuilt from the DB round-trip.
+
+## Prevention
+- When adding a persisted `Task` field, treat the six edit sites above as one atomic change. The `Task` type compiling is **not** evidence the field persists — TypeScript never sees the SQLite layer.
+- Always write a round-trip regression test that asserts the field survives `getTask`, `listTasks`, **and** a full store reopen (`reopenDiskBackedStore` in `store-test-helpers.ts`). An in-memory-only assertion would pass even with the bug, because the bug lives in the SQLite round-trip:
+
+```ts
+const updated = await store.updateTask(id, { workspaceWorktrees: map });
+expect(updated.workspaceWorktrees).toEqual(map); // passes even when broken
+const detail = await store.getTask(id);
+expect(detail.workspaceWorktrees).toEqual(map); // FAILS when broken — the real check
+```
+
+- The `architecture-schema-compat` test enforces that fresh-from-SCHEMA_SQL and migrated DBs converge, so the SCHEMA_SQL column and the migration must both be added (see Related).
+
+## Related Issues
+- `docs/solutions/database-issues/schema-version-constant-must-equal-highest-migration.md` — the companion rule for the `SCHEMA_VERSION` bump that accompanies any new migration.
+- Same fix (PR #1747) also resolved a second multiworkspace bug: concurrent workspace tasks collided on the shared browse-only workspace root in the path-keyed `activeSessionRegistry` (every task registered `this.rootDir` as its executor session, so the foreign-task guard rejected the second). Fixed by giving each workspace task a task-scoped synthetic session key (`sessionRegistryPath` in `executor.ts`), applied symmetrically at all register/unregister sites.
diff --git a/packages/cli/src/project-resolver.ts b/packages/cli/src/project-resolver.ts
index 044a718867..cd171d135a 100644
--- a/packages/cli/src/project-resolver.ts
+++ b/packages/cli/src/project-resolver.ts
@@ -680,14 +680,17 @@ export async function registerProjectInteractive(
// Initialize the project (create .fusion/)
const { TaskStore } = await import("@fusion/core");
const store = new TaskStore(absPath);
- await store.init();
- if (detectedSubRepos) {
- await saveWorkspaceConfig(absPath, { repos: detectedSubRepos });
- // Persist workspaceMode in config.json so it's visible/toggleable in the dashboard
- await store.updateSettings({ workspaceMode: true });
+ try {
+ await store.init();
+ if (detectedSubRepos) {
+ await saveWorkspaceConfig(absPath, { repos: detectedSubRepos });
+ // Persist workspaceMode in config.json so it's visible/toggleable in the dashboard
+ await store.updateSettings({ workspaceMode: true });
+ }
+ console.log(` ✓ Initialized fn at ${absPath}`);
+ } finally {
+ await store.close();
}
- await store.close();
- console.log(` ✓ Initialized fn at ${absPath}`);
} else {
throw new ProjectResolutionError(
"Cannot register project without .fusion/ directory. Run `fn init` first.",
@@ -747,29 +750,32 @@ export async function registerProjectInteractive(
/*
FNXC:Onboarding 2026-06-24-18:00:
- After registration, prompt the user to confirm a task prefix and default workflow.
- The prefix defaults to the first 2-4 chars of the project name so each project gets
- recognizable task IDs (e.g., "MYPR" for "my-project"). The workflow defaults to coding.
- Both are persisted to config.json via the TaskStore.
+ After registration, set a task prefix and default workflow. The prefix defaults to
+ the first 2-4 chars of the project name so each project gets recognizable task IDs
+ (e.g., "MYPR" for "my-project"). The workflow defaults to coding. Both are persisted
+ to config.json via the TaskStore.
*/
- if (interactive) {
+ {
const { TaskStore } = await import("@fusion/core");
const store = new TaskStore(absPath);
- await store.init();
-
- const suggestedPrefix = suggestTaskPrefix(name);
- const rl = createInterface({ input: process.stdin, output: process.stdout });
- const prefixInput = await rl.question(`\n Task prefix [${suggestedPrefix}]: `);
- rl.close();
- const rawPrefix = prefixInput.trim().toUpperCase().replace(/[^A-Z]/g, "");
- const prefix = rawPrefix.length >= 2 && rawPrefix.length <= 5 ? rawPrefix : suggestedPrefix;
-
- await store.updateSettings({
- taskPrefix: prefix,
- defaultWorkflowId: "builtin:coding",
- });
- await store.close();
- console.log(` ✓ Task prefix set to "${prefix}", default workflow: coding`);
+ try {
+ await store.init();
+ let prefix = suggestTaskPrefix(name);
+ if (interactive) {
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
+ const prefixInput = await rl.question(`\n Task prefix [${prefix}]: `);
+ rl.close();
+ const rawPrefix = prefixInput.trim().toUpperCase().replace(/[^A-Z]/g, "");
+ if (rawPrefix.length >= 1 && rawPrefix.length <= 5) prefix = rawPrefix;
+ }
+ await store.updateSettings({
+ taskPrefix: prefix,
+ defaultWorkflowId: "builtin:coding",
+ });
+ console.log(` ✓ Task prefix set to "${prefix}", default workflow: coding`);
+ } finally {
+ await store.close();
+ }
}
return createResolvedProject(project);
diff --git a/packages/core/src/__tests__/store-persistence.test.ts b/packages/core/src/__tests__/store-persistence.test.ts
index 54f4670338..c54be7ee39 100644
--- a/packages/core/src/__tests__/store-persistence.test.ts
+++ b/packages/core/src/__tests__/store-persistence.test.ts
@@ -59,6 +59,78 @@ describe("TaskStore", () => {
});
});
+ // FNXC:Workspace 2026-06-24-15:30 (multiworkspace fn_task_done regression):
+ // task.workspaceWorktrees previously had NO SQLite column / no rowToTask mapping, so
+ // fn_acquire_repo_worktree's updateTask({workspaceWorktrees}) set it only in memory and the very
+ // next getTask (SQLite round-trip) dropped it. fn_task_done's scope verifier then read `{}` and
+ // refused with "acquired no sub-repo worktrees", and every isWorkspaceTask() consumer misfired.
+ // The invariant: the per-sub-repo worktree map survives write→read across ALL surfaces —
+ // getTask, listTasks, AND a full store reopen (SQLite + task.json + reconcile).
+ describe("workspaceWorktrees persistence", () => {
+ const sampleMap = {
+ swarmclaw: { worktreePath: "/ws/swarmclaw/.worktrees/ivory-raven", branch: "fusion/mult-002", baseCommitSha: "a327402" },
+ OpenVide: { worktreePath: "/ws/OpenVide/.worktrees/light-ember", branch: "fusion/mult-001" },
+ };
+
+ it("round-trips the per-sub-repo worktree map through write and getTask", async () => {
+ const task = await harness.store().createTask({ description: "Workspace task" });
+
+ const updated = await harness.store().updateTask(task.id, { workspaceWorktrees: sampleMap });
+ expect(updated.workspaceWorktrees).toEqual(sampleMap);
+
+ // The smoking-gun assertion: getTask reads back from SQLite (rowToTask), not the in-memory
+ // mutation. Before the fix this returned undefined because no column persisted the map.
+ const detail = await harness.store().getTask(task.id);
+ expect(detail.workspaceWorktrees).toEqual(sampleMap);
+ });
+
+ it("returns the map from listTasks", async () => {
+ const task = await harness.store().createTask({ description: "Workspace task in list" });
+ await harness.store().updateTask(task.id, { workspaceWorktrees: sampleMap });
+
+ const listed = (await harness.store().listTasks()).find((t) => t.id === task.id);
+ expect(listed?.workspaceWorktrees).toEqual(sampleMap);
+ });
+
+ it("survives a full store reopen (SQLite + task.json + reconcile)", async () => {
+ const task = await harness.store().createTask({ description: "Workspace task across reopen" });
+ await harness.store().updateTask(task.id, { workspaceWorktrees: sampleMap });
+
+ await harness.reopenDiskBackedStore();
+
+ const detail = await harness.store().getTask(task.id);
+ expect(detail.workspaceWorktrees).toEqual(sampleMap);
+ });
+
+ // Surface enumeration (PR #1747 review): rowToTask reads row.workspaceWorktrees, but the
+ // explicit slim and activity-log-limited SELECT lists are separate from `*` — if the column is
+ // omitted there, slim/limited reads silently drop the field even though getTask("*") works.
+ it("survives the activity-log-limited read (explicit limited SELECT clause)", async () => {
+ const task = await harness.store().createTask({ description: "Workspace task limited read" });
+ await harness.store().updateTask(task.id, { workspaceWorktrees: sampleMap });
+
+ const detail = await harness.store().getTask(task.id, { activityLogLimit: 1 });
+ expect(detail.workspaceWorktrees).toEqual(sampleMap);
+ });
+
+ it("survives the slim search read (explicit slim SELECT clause)", async () => {
+ const task = await harness.store().createTask({ description: "Workspace slimsearchmarker task" });
+ await harness.store().updateTask(task.id, { workspaceWorktrees: sampleMap });
+
+ const found = (await harness.store().searchTasks("slimsearchmarker", { slim: true })).find((t) => t.id === task.id);
+ expect(found?.workspaceWorktrees).toEqual(sampleMap);
+ });
+
+ it("normalizes an empty map to undefined so isWorkspaceTask stays false", async () => {
+ const task = await harness.store().createTask({ description: "Empty workspace map" });
+ const updated = await harness.store().updateTask(task.id, { workspaceWorktrees: {} });
+ expect(updated.workspaceWorktrees ?? {}).toEqual({});
+
+ const detail = await harness.store().getTask(task.id);
+ expect(detail.workspaceWorktrees).toBeUndefined();
+ });
+ });
+
describe("tokenUsage persistence", () => {
it("round-trips per-model token buckets through write and read", async () => {
const task = await harness.store().createTask({ description: "Per-model token task" });
diff --git a/packages/core/src/db-migrate.ts b/packages/core/src/db-migrate.ts
index 30d10cab8e..6322a87e22 100644
--- a/packages/core/src/db-migrate.ts
+++ b/packages/core/src/db-migrate.ts
@@ -226,10 +226,11 @@ async function migrateTasks(fusionDir: string, db: Database): Promise {
columnMovedAt, dependencies, steps, log, attachments, steeringComments,
comments, workflowStepResults, prInfo, issueInfo,
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt,
- mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, sliceId
+ mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, sliceId,
+ workspaceWorktrees
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
- ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
`);
@@ -300,6 +301,9 @@ async function migrateTasks(fusionDir: string, db: Database): Promise {
toJson(task.enabledWorkflowSteps || []),
toJson(task.modifiedFiles || []),
task.sliceId ?? null,
+ // FNXC:Workspace 2026-06-24-15:30: carry the per-sub-repo worktree map through the legacy
+ // task.json→SQLite rebuild so a workspace task migrated from disk keeps its acquired worktrees.
+ toJsonNullable(task.workspaceWorktrees),
);
migrated++;
} catch (err) {
diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts
index 2ef6ca39b4..f84d0c1e59 100644
--- a/packages/core/src/db.ts
+++ b/packages/core/src/db.ts
@@ -162,7 +162,7 @@ export function isFts5CorruptionError(error: unknown): boolean {
// ── Schema Definition ────────────────────────────────────────────────
-const SCHEMA_VERSION = 128;
+const SCHEMA_VERSION = 129;
const TASKS_FTS_AUTOMERGE = 8;
const TASKS_FTS_CRISISMERGE = 16;
@@ -347,7 +347,11 @@ CREATE TABLE IF NOT EXISTS tasks (
deletedAt TEXT,
allowResurrection INTEGER DEFAULT 0,
transitionPending TEXT,
- customFields TEXT DEFAULT '{}'
+ customFields TEXT DEFAULT '{}',
+ -- FNXC:Workspace 2026-06-24-15:30: per-sub-repo worktree map (JSON) for workspace-mode tasks.
+ -- Source of truth for getSchemaCompatibilityTableSchemas(), so existing DBs are backfilled by
+ -- ensureSchemaCompatibility() at boot and fresh DBs get it here. See store.ts TaskRow note.
+ workspaceWorktrees TEXT
);
-- Config table (single row with project settings)
@@ -5289,6 +5293,16 @@ export class Database {
});
}
+ if (version < 129) {
+ // FNXC:Workspace 2026-06-24-15:30: add the workspaceWorktrees column so workspace-mode tasks
+ // can durably persist their per-sub-repo worktree map. Backfill is also covered by
+ // ensureSchemaCompatibility() (SCHEMA_SQL is its source of truth); this versioned migration keeps
+ // migrated and fresh-from-SCHEMA_SQL DBs converged.
+ this.applyMigration(129, () => {
+ this.addColumnIfMissing("tasks", "workspaceWorktrees", "TEXT");
+ });
+ }
+
}
/**
diff --git a/packages/core/src/distributed-task-id.ts b/packages/core/src/distributed-task-id.ts
index 04ba930b00..98a5aed311 100644
--- a/packages/core/src/distributed-task-id.ts
+++ b/packages/core/src/distributed-task-id.ts
@@ -77,16 +77,16 @@ function getConfiguredPrefixAndLegacyNextId(db: Database): { prefix: string; nex
.prepare("SELECT nextId, settings FROM config WHERE id = 1")
.get() as { nextId: number | null; settings: string | null } | undefined;
if (!row) {
- return { prefix: "KB", nextId: null };
+ return { prefix: "FN", nextId: null };
}
const settings = row.settings ? (JSON.parse(row.settings) as { taskPrefix?: string }) : null;
return {
- prefix: (settings?.taskPrefix ?? "KB").trim().toUpperCase(),
+ prefix: (settings?.taskPrefix ?? "FN").trim().toUpperCase(),
nextId: typeof row.nextId === "number" ? row.nextId : null,
};
} catch {
- return { prefix: "KB", nextId: null };
+ return { prefix: "FN", nextId: null };
}
}
diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts
index b5e85e2411..64d26fca25 100644
--- a/packages/core/src/store.ts
+++ b/packages/core/src/store.ts
@@ -277,6 +277,13 @@ interface TaskRow {
sourceIssueUrl: string | null;
sourceIssueClosedAt: string | null;
mergeDetails: string | null;
+ // FNXC:Workspace 2026-06-24-15:30 (FN-multiworkspace persistence): the per-sub-repo worktree
+ // map MUST have its own SQLite column. Before this it was a Task field with NO column/rowToTask
+ // mapping, so updateTask set it only in-memory and applyTaskPatch wrote the DB-round-tripped
+ // task (without it) back to task.json — the map was silently dropped on every persist. That made
+ // fn_task_done's scope verifier always read `{}` ("acquired no sub-repo worktrees") and broke
+ // every isWorkspaceTask() consumer. Stored as JSON text, same shape as mergeDetails.
+ workspaceWorktrees: string | null;
breakIntoSubtasks: number | null;
noCommitsExpected: number | null;
enabledWorkflowSteps: string | null;
@@ -429,6 +436,10 @@ const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [
defineTaskColumn("sourceIssueUrl", (task) => task.sourceIssue?.url ?? null),
defineTaskColumn("sourceIssueClosedAt", (task) => task.sourceIssue?.closedAt ?? null),
defineTaskColumn("mergeDetails", (task) => toJsonNullable(task.mergeDetails)),
+ // FNXC:Workspace 2026-06-24-15:30: persist the per-sub-repo worktree map so fn_acquire_repo_worktree's
+ // write survives the SQLite round-trip getChangedTaskColumns/rowToTask use. Without this descriptor the
+ // column diff never sees a change and the field never reaches the DB.
+ defineTaskColumn("workspaceWorktrees", (task) => toJsonNullable(task.workspaceWorktrees)),
defineTaskColumn("breakIntoSubtasks", (task) => task.breakIntoSubtasks ? 1 : 0),
defineTaskColumn("noCommitsExpected", (task) => task.noCommitsExpected ? 1 : 0),
defineTaskColumn("enabledWorkflowSteps", (task) => toJson(task.enabledWorkflowSteps || [])),
@@ -2150,6 +2161,13 @@ export class TaskStore extends EventEmitter {
};
})(),
mergeDetails: fromJson(row.mergeDetails),
+ // FNXC:Workspace 2026-06-24-15:30: deserialize the per-sub-repo worktree map. An empty/null map
+ // normalizes to undefined so isWorkspaceTask() (keys-length>0) and the scope verifier behave the
+ // same as a task that never acquired a sub-repo.
+ workspaceWorktrees: (() => {
+ const w = fromJson(row.workspaceWorktrees);
+ return w && Object.keys(w).length > 0 ? w : undefined;
+ })(),
breakIntoSubtasks: row.breakIntoSubtasks ? true : undefined,
noCommitsExpected: row.noCommitsExpected ? true : undefined,
enabledWorkflowSteps: (() => { const e = fromJson(row.enabledWorkflowSteps); return e && e.length > 0 ? e : undefined; })(),
@@ -2589,7 +2607,7 @@ export class TaskStore extends EventEmitter {
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt",
"dependencies", "steps", "customFields", "comments", "review", "reviewState", "workflowStepResults", "steeringComments",
- "attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails",
+ "attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "workspaceWorktrees",
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
@@ -2638,7 +2656,7 @@ export class TaskStore extends EventEmitter {
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt",
"dependencies", "steps", "customFields", "attachments", "steeringComments",
- "comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails",
+ "comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "workspaceWorktrees",
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts
index b4dfa9d33a..08d78d6d76 100644
--- a/packages/dashboard/app/api/legacy.ts
+++ b/packages/dashboard/app/api/legacy.ts
@@ -2502,8 +2502,8 @@ export interface GitRemote {
}
/** Fetch GitHub remotes from the current git repository */
-export function fetchGitRemotes(projectId?: string): Promise {
- return api(withProjectId("/git/remotes", projectId));
+export function fetchGitRemotes(projectId?: string, repoPath?: string): Promise {
+ return api(withRepoPath(withProjectId("/git/remotes", projectId), repoPath));
}
/** Detailed git remote info with fetch and push URLs */
@@ -2514,36 +2514,36 @@ export interface GitRemoteDetailed {
}
/** Fetch all git remotes with their fetch and push URLs */
-export function fetchGitRemotesDetailed(projectId?: string): Promise {
- return api(withProjectId("/git/remotes/detailed", projectId));
+export function fetchGitRemotesDetailed(projectId?: string, repoPath?: string): Promise {
+ return api(withRepoPath(withProjectId("/git/remotes/detailed", projectId), repoPath));
}
/** Add a new git remote */
-export function addGitRemote(name: string, url: string, projectId?: string): Promise {
- return api(withProjectId("/git/remotes", projectId), {
+export function addGitRemote(name: string, url: string, projectId?: string, repoPath?: string): Promise {
+ return api(withRepoPath(withProjectId("/git/remotes", projectId), repoPath), {
method: "POST",
body: JSON.stringify({ name, url }),
});
}
/** Remove a git remote */
-export function removeGitRemote(name: string, projectId?: string): Promise {
- return api(withProjectId(`/git/remotes/${encodeURIComponent(name)}`, projectId), {
+export function removeGitRemote(name: string, projectId?: string, repoPath?: string): Promise {
+ return api(withRepoPath(withProjectId(`/git/remotes/${encodeURIComponent(name)}`, projectId), repoPath), {
method: "DELETE",
});
}
/** Rename a git remote */
-export function renameGitRemote(name: string, newName: string, projectId?: string): Promise {
- return api(withProjectId(`/git/remotes/${encodeURIComponent(name)}`, projectId), {
+export function renameGitRemote(name: string, newName: string, projectId?: string, repoPath?: string): Promise {
+ return api(withRepoPath(withProjectId(`/git/remotes/${encodeURIComponent(name)}`, projectId), repoPath), {
method: "PATCH",
body: JSON.stringify({ newName }),
});
}
/** Update the URL for a git remote */
-export function updateGitRemoteUrl(name: string, url: string, projectId?: string): Promise {
- return api(withProjectId(`/git/remotes/${encodeURIComponent(name)}/url`, projectId), {
+export function updateGitRemoteUrl(name: string, url: string, projectId?: string, repoPath?: string): Promise {
+ return api(withRepoPath(withProjectId(`/git/remotes/${encodeURIComponent(name)}/url`, projectId), repoPath), {
method: "PUT",
body: JSON.stringify({ url }),
});
@@ -3041,104 +3041,111 @@ export interface GitPushResult {
* resolution, ahead/behind vs both local and origin integration tip, dirty
* breakdown, stash count, index-stale detection, and recent merge-advance
* audit events for the project-root worktree. */
-export function fetchGitStatus(projectId?: string, opts?: { extended?: boolean }): Promise {
- const base = withProjectId("/git/status", projectId);
+export function fetchGitStatus(projectId?: string, opts?: { extended?: boolean }, repoPath?: string): Promise {
+ const base = withRepoPath(withProjectId("/git/status", projectId), repoPath);
if (!opts?.extended) return api(base);
const sep = base.includes("?") ? "&" : "?";
return api(`${base}${sep}extended=1`);
}
/** Fetch recent commits */
-export function fetchGitCommits(limit?: number, projectId?: string): Promise {
+export function fetchGitCommits(limit?: number, projectId?: string, repoPath?: string): Promise {
const query = limit ? `?limit=${limit}` : "";
- return api(withProjectId(`/git/commits${query}`, projectId));
+ return api(withRepoPath(withProjectId(`/git/commits${query}`, projectId), repoPath));
}
/** Fetch diff for a specific commit */
-export function fetchCommitDiff(hash: string, projectId?: string): Promise<{ stat: string; patch: string }> {
- return api<{ stat: string; patch: string }>(withProjectId(`/git/commits/${hash}/diff`, projectId));
+export function fetchCommitDiff(hash: string, projectId?: string, repoPath?: string): Promise<{ stat: string; patch: string }> {
+ return api<{ stat: string; patch: string }>(withRepoPath(withProjectId(`/git/commits/${hash}/diff`, projectId), repoPath));
}
/** Fetch local commits ahead of the upstream tracking branch (commits to push) */
-export function fetchAheadCommits(projectId?: string): Promise {
- return api(withProjectId("/git/commits/ahead", projectId));
+export function fetchAheadCommits(projectId?: string, repoPath?: string): Promise {
+ return api(withRepoPath(withProjectId("/git/commits/ahead", projectId), repoPath));
}
/** Fetch recent commits for a specific remote */
-export function fetchRemoteCommits(remote: string, ref?: string, limit?: number, projectId?: string): Promise {
+export function fetchRemoteCommits(remote: string, ref?: string, limit?: number, projectId?: string, repoPath?: string): Promise {
const params = new URLSearchParams();
if (ref) params.set("ref", ref);
if (limit) params.set("limit", String(limit));
const query = params.size > 0 ? `?${params.toString()}` : "";
- return api(withProjectId(`/git/remotes/${encodeURIComponent(remote)}/commits${query}`, projectId));
+ return api(withRepoPath(withProjectId(`/git/remotes/${encodeURIComponent(remote)}/commits${query}`, projectId), repoPath));
}
/** Fetch all local branches */
-export function fetchGitBranches(projectId?: string): Promise {
- return api(withProjectId("/git/branches", projectId));
+export function fetchGitBranches(projectId?: string, repoPath?: string): Promise {
+ return api(withRepoPath(withProjectId("/git/branches", projectId), repoPath));
}
/** Fetch recent commits for a specific branch */
-export function fetchBranchCommits(branchName: string, limit?: number, projectId?: string): Promise {
+export function fetchBranchCommits(branchName: string, limit?: number, projectId?: string, repoPath?: string): Promise {
const query = limit ? `?limit=${limit}` : "";
- return api(withProjectId(`/git/branches/${encodeURIComponent(branchName)}/commits${query}`, projectId));
+ return api(withRepoPath(withProjectId(`/git/branches/${encodeURIComponent(branchName)}/commits${query}`, projectId), repoPath));
}
/** Fetch all worktrees */
-export function fetchGitWorktrees(projectId?: string): Promise {
- return api(withProjectId("/git/worktrees", projectId));
+export function fetchGitWorktrees(projectId?: string, repoPath?: string): Promise {
+ return api(withRepoPath(withProjectId("/git/worktrees", projectId), repoPath));
}
/** Create a new branch */
-export function createBranch(name: string, base?: string, projectId?: string): Promise {
- return api(withProjectId("/git/branches", projectId), {
+export function createBranch(name: string, base?: string, projectId?: string, repoPath?: string): Promise {
+ return api(withRepoPath(withProjectId("/git/branches", projectId), repoPath), {
method: "POST",
body: JSON.stringify({ name, base }),
});
}
/** Checkout an existing branch */
-export function checkoutBranch(name: string, projectId?: string): Promise {
- return api(withProjectId(`/git/branches/${encodeURIComponent(name)}/checkout`, projectId), {
+export function checkoutBranch(name: string, projectId?: string, repoPath?: string): Promise {
+ return api(withRepoPath(withProjectId(`/git/branches/${encodeURIComponent(name)}/checkout`, projectId), repoPath), {
method: "POST",
});
}
/** Delete a branch */
-export function deleteBranch(name: string, force?: boolean, projectId?: string): Promise {
+export function deleteBranch(name: string, force?: boolean, projectId?: string, repoPath?: string): Promise {
const query = force ? "?force=true" : "";
- return api(withProjectId(`/git/branches/${encodeURIComponent(name)}${query}`, projectId), {
+ return api(withRepoPath(withProjectId(`/git/branches/${encodeURIComponent(name)}${query}`, projectId), repoPath), {
method: "DELETE",
});
}
/** Fetch from remote */
-export function fetchRemote(remote?: string, projectId?: string): Promise {
- return api(withProjectId("/git/fetch", projectId), {
+export function fetchRemote(remote?: string, projectId?: string, repoPath?: string): Promise {
+ return api(withRepoPath(withProjectId("/git/fetch", projectId), repoPath), {
method: "POST",
body: JSON.stringify({ remote }),
});
}
/** Pull current branch */
-export function pullBranch(options?: { rebase?: boolean }, projectId?: string): Promise;
-export function pullBranch(projectId?: string): Promise;
+export function pullBranch(options?: { rebase?: boolean }, projectId?: string, repoPath?: string): Promise;
+export function pullBranch(projectId?: string, repoPath?: string): Promise;
export function pullBranch(
optionsOrProjectId?: { rebase?: boolean } | string,
projectId?: string,
+ repoPath?: string,
): Promise {
- const options = typeof optionsOrProjectId === "string" ? undefined : optionsOrProjectId;
- const resolvedProjectId = typeof optionsOrProjectId === "string" ? optionsOrProjectId : projectId;
+ // FNXC:DashboardGitApi 2026-06-24-00:00:
+ // pullBranch has two overloads. In the string-arg style pullBranch(projectId, repoPath),
+ // the second positional carries repoPath (not the 3rd parameter), so resolve it from `projectId`
+ // to avoid dropping repoPath; otherwise multi-repo workspace pulls hit the wrong repo.
+ const isStringForm = typeof optionsOrProjectId === "string";
+ const options = isStringForm ? undefined : optionsOrProjectId;
+ const resolvedProjectId = isStringForm ? optionsOrProjectId : projectId;
+ const resolvedRepoPath = isStringForm ? projectId : repoPath;
- return api(withProjectId("/git/pull", resolvedProjectId), {
+ return api(withRepoPath(withProjectId("/git/pull", resolvedProjectId), resolvedRepoPath), {
method: "POST",
body: JSON.stringify({ rebase: options?.rebase ?? false }),
});
}
/** Push current branch */
-export function pushBranch(projectId?: string): Promise {
- return api(withProjectId("/git/push", projectId), {
+export function pushBranch(projectId?: string, repoPath?: string): Promise {
+ return api(withRepoPath(withProjectId("/git/push", projectId), repoPath), {
method: "POST",
});
}
@@ -3160,83 +3167,83 @@ export interface GitFileChange {
}
/** Fetch stash list */
-export function fetchGitStashList(projectId?: string): Promise {
- return api(withProjectId("/git/stashes", projectId));
+export function fetchGitStashList(projectId?: string, repoPath?: string): Promise {
+ return api(withRepoPath(withProjectId("/git/stashes", projectId), repoPath));
}
/** Create a new stash */
-export function createStash(message?: string, projectId?: string): Promise<{ message: string }> {
- return api<{ message: string }>(withProjectId("/git/stashes", projectId), {
+export function createStash(message?: string, projectId?: string, repoPath?: string): Promise<{ message: string }> {
+ return api<{ message: string }>(withRepoPath(withProjectId("/git/stashes", projectId), repoPath), {
method: "POST",
body: JSON.stringify({ message }),
});
}
/** Apply a stash entry */
-export function applyStash(index: number, drop?: boolean, projectId?: string): Promise<{ message: string }> {
- return api<{ message: string }>(withProjectId(`/git/stashes/${index}/apply`, projectId), {
+export function applyStash(index: number, drop?: boolean, projectId?: string, repoPath?: string): Promise<{ message: string }> {
+ return api<{ message: string }>(withRepoPath(withProjectId(`/git/stashes/${index}/apply`, projectId), repoPath), {
method: "POST",
body: JSON.stringify({ drop }),
});
}
/** Drop a stash entry */
-export function dropStash(index: number, projectId?: string): Promise<{ message: string }> {
- return api<{ message: string }>(withProjectId(`/git/stashes/${index}`, projectId), {
+export function dropStash(index: number, projectId?: string, repoPath?: string): Promise<{ message: string }> {
+ return api<{ message: string }>(withRepoPath(withProjectId(`/git/stashes/${index}`, projectId), repoPath), {
method: "DELETE",
});
}
/** Fetch stash diff (stat + patch) */
-export function fetchStashDiff(index: number, projectId?: string): Promise<{ stat: string; patch: string }> {
- return api<{ stat: string; patch: string }>(withProjectId(`/git/stashes/${index}/diff`, projectId));
+export function fetchStashDiff(index: number, projectId?: string, repoPath?: string): Promise<{ stat: string; patch: string }> {
+ return api<{ stat: string; patch: string }>(withRepoPath(withProjectId(`/git/stashes/${index}/diff`, projectId), repoPath));
}
/** Fetch unstaged diff (working directory changes) */
-export function fetchUnstagedDiff(projectId?: string): Promise<{ stat: string; patch: string }> {
- return api<{ stat: string; patch: string }>(withProjectId("/git/diff", projectId));
+export function fetchUnstagedDiff(projectId?: string, repoPath?: string): Promise<{ stat: string; patch: string }> {
+ return api<{ stat: string; patch: string }>(withRepoPath(withProjectId("/git/diff", projectId), repoPath));
}
/** Fetch diff for a specific file in staged or unstaged mode */
-export function fetchGitFileDiff(path: string, staged: boolean, projectId?: string): Promise<{ stat: string; patch: string }> {
+export function fetchGitFileDiff(path: string, staged: boolean, projectId?: string, repoPath?: string): Promise<{ stat: string; patch: string }> {
const params = new URLSearchParams();
params.set("path", path);
params.set("staged", String(staged));
- return api<{ stat: string; patch: string }>(withProjectId(`/git/diff/file?${params.toString()}`, projectId));
+ return api<{ stat: string; patch: string }>(withRepoPath(withProjectId(`/git/diff/file?${params.toString()}`, projectId), repoPath));
}
/** Fetch file changes (staged and unstaged) */
-export function fetchFileChanges(projectId?: string): Promise {
- return api(withProjectId("/git/changes", projectId));
+export function fetchFileChanges(projectId?: string, repoPath?: string): Promise {
+ return api(withRepoPath(withProjectId("/git/changes", projectId), repoPath));
}
/** Stage specific files */
-export function stageFiles(files: string[], projectId?: string): Promise<{ staged: string[] }> {
- return api<{ staged: string[] }>(withProjectId("/git/stage", projectId), {
+export function stageFiles(files: string[], projectId?: string, repoPath?: string): Promise<{ staged: string[] }> {
+ return api<{ staged: string[] }>(withRepoPath(withProjectId("/git/stage", projectId), repoPath), {
method: "POST",
body: JSON.stringify({ files }),
});
}
/** Unstage specific files */
-export function unstageFiles(files: string[], projectId?: string): Promise<{ unstaged: string[] }> {
- return api<{ unstaged: string[] }>(withProjectId("/git/unstage", projectId), {
+export function unstageFiles(files: string[], projectId?: string, repoPath?: string): Promise<{ unstaged: string[] }> {
+ return api<{ unstaged: string[] }>(withRepoPath(withProjectId("/git/unstage", projectId), repoPath), {
method: "POST",
body: JSON.stringify({ files }),
});
}
/** Create a commit */
-export function createCommit(message: string, projectId?: string): Promise<{ hash: string; message: string }> {
- return api<{ hash: string; message: string }>(withProjectId("/git/commit", projectId), {
+export function createCommit(message: string, projectId?: string, repoPath?: string): Promise<{ hash: string; message: string }> {
+ return api<{ hash: string; message: string }>(withRepoPath(withProjectId("/git/commit", projectId), repoPath), {
method: "POST",
body: JSON.stringify({ message }),
});
}
/** Discard changes in working directory for specific files */
-export function discardChanges(files: string[], projectId?: string): Promise<{ discarded: string[] }> {
- return api<{ discarded: string[] }>(withProjectId("/git/discard", projectId), {
+export function discardChanges(files: string[], projectId?: string, repoPath?: string): Promise<{ discarded: string[] }> {
+ return api<{ discarded: string[] }>(withRepoPath(withProjectId("/git/discard", projectId), repoPath), {
method: "POST",
body: JSON.stringify({ files }),
});
@@ -5881,6 +5888,18 @@ function withProjectId(path: string, projectId?: string): string {
return `${path}${separator}projectId=${encodeURIComponent(projectId)}`;
}
+/** Append repoPath query param for workspace-mode sub-repo targeting */
+function withRepoPath(path: string, repoPath?: string): string {
+ if (!repoPath) return path;
+ const separator = path.includes("?") ? "&" : "?";
+ return `${path}${separator}repoPath=${encodeURIComponent(repoPath)}`;
+}
+
+/** Fetch workspace sub-repos for a project */
+export function fetchWorkspaceRepos(projectId?: string): Promise<{ repos: string[] }> {
+ return api<{ repos: string[] }>(withProjectId("/git/workspace-repos", projectId));
+}
+
/**
* Rewrite a path to route through the node proxy when viewing a remote node.
* When nodeId is provided and differs from localNodeId (i.e., it's a remote node),
@@ -6770,6 +6789,8 @@ export interface ProjectCreateInput {
isolationMode?: "in-process" | "child-process";
nodeId?: string;
cloneUrl?: string;
+ workspaceMode?: boolean;
+ taskPrefix?: string;
}
export type DockerNodeConfigInfo = DockerNodeConfig;
@@ -7236,6 +7257,13 @@ export function registerProject(input: ProjectCreateInput): Promise
body: JSON.stringify(input),
});
}
+/** Detect git sub-repos in a directory (workspace mode detection) */
+export function detectWorkspace(path: string): Promise<{ repos: string[]; isWorkspace: boolean }> {
+ return api<{ repos: string[]; isWorkspace: boolean }>("/projects/detect-workspace", {
+ method: "POST",
+ body: JSON.stringify({ path }),
+ });
+}
/** Unregister a project */
export function unregisterProject(id: string): Promise {
diff --git a/packages/dashboard/app/components/GitManagerModal.tsx b/packages/dashboard/app/components/GitManagerModal.tsx
index eb2e62a5ee..e50f21bb78 100644
--- a/packages/dashboard/app/components/GitManagerModal.tsx
+++ b/packages/dashboard/app/components/GitManagerModal.tsx
@@ -58,6 +58,7 @@ import {
fetchAheadCommits,
fetchRemoteCommits,
fetchBranchCommits,
+ fetchWorkspaceRepos,
} from "../api";
import { StashRecoveryView } from "./StashRecoveryView";
import {
@@ -256,6 +257,17 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
const [rootDir, setRootDir] = useState(null);
+ // ── Workspace repo selector state
+ /*
+ FNXC:Workspace 2026-06-24-21:00:
+ In workspace mode (multi-repo), the git manager shows a repo selector so the
+ user can pick which sub-repo to inspect. selectedRepo is the relative path
+ (e.g. "openvide"); gitRepoPath is passed as repoPath to all git API calls.
+ */
+ const [workspaceRepos, setWorkspaceRepos] = useState([]);
+ const [selectedRepo, setSelectedRepo] = useState(null);
+ const gitRepoPath = selectedRepo ?? undefined;
+
// ── Changes state
const [fileChanges, setFileChanges] = useState([]);
const [selectedFiles, setSelectedFiles] = useState>(new Set());
@@ -313,12 +325,12 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
try {
switch (activeSection) {
case "status": {
- const statusData = await fetchGitStatus(projectId, { extended: true });
+ const statusData = await fetchGitStatus(projectId, { extended: true }, gitRepoPath);
setStatus(statusData);
break;
}
case "changes": {
- const [statusData, changes] = await Promise.all([fetchGitStatus(projectId, { extended: true }), fetchFileChanges(projectId)]);
+ const [statusData, changes] = await Promise.all([fetchGitStatus(projectId, { extended: true }, gitRepoPath), fetchFileChanges(projectId, gitRepoPath)]);
setStatus(statusData);
setFileChanges(changes);
setSelectedFiles(new Set());
@@ -328,23 +340,23 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
break;
}
case "commits": {
- const commitsData = await fetchGitCommits(commitsLimit, projectId);
+ const commitsData = await fetchGitCommits(commitsLimit, projectId, gitRepoPath);
setCommits(commitsData);
break;
}
case "branches": {
- const [branchesData, statusForBranch] = await Promise.all([fetchGitBranches(projectId), fetchGitStatus(projectId, { extended: true })]);
+ const [branchesData, statusForBranch] = await Promise.all([fetchGitBranches(projectId, gitRepoPath), fetchGitStatus(projectId, { extended: true }, gitRepoPath)]);
setBranches(branchesData);
setStatus(statusForBranch);
break;
}
case "worktrees": {
- const worktreesData = await fetchGitWorktrees(projectId);
+ const worktreesData = await fetchGitWorktrees(projectId, gitRepoPath);
setWorktrees(worktreesData);
break;
}
case "stashes": {
- const stashesData = await fetchGitStashList(projectId);
+ const stashesData = await fetchGitStashList(projectId, gitRepoPath);
setStashes(stashesData);
setExpandedStashIndex(null);
setStashDiff(null);
@@ -357,7 +369,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
break;
}
case "remotes": {
- const remoteStatus = await fetchGitStatus(projectId, { extended: true });
+ const remoteStatus = await fetchGitStatus(projectId, { extended: true }, gitRepoPath);
setStatus(remoteStatus);
break;
}
@@ -368,7 +380,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
} finally {
setLoading(false);
}
- }, [activeSection, isOpen, commitsLimit, addToast, projectId]);
+ }, [activeSection, isOpen, commitsLimit, addToast, projectId, gitRepoPath]);
useEffect(() => {
if (isOpen) {
@@ -405,9 +417,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
const handleStageFiles = useCallback(async (files: string[]) => {
try {
- await stageFiles(files, projectId);
+ await stageFiles(files, projectId, gitRepoPath);
addToast(t("git.stagedFiles", "Staged {{count}} file(s)", { count: files.length }), "success");
- const changes = await fetchFileChanges(projectId);
+ const changes = await fetchFileChanges(projectId, gitRepoPath);
setFileChanges(changes);
setSelectedFiles(new Set());
setSelectedDiffTarget(null);
@@ -420,9 +432,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
const handleUnstageFiles = useCallback(async (files: string[]) => {
try {
- await unstageFiles(files, projectId);
+ await unstageFiles(files, projectId, gitRepoPath);
addToast(t("git.unstagedFiles", "Unstaged {{count}} file(s)", { count: files.length }), "success");
- const changes = await fetchFileChanges(projectId);
+ const changes = await fetchFileChanges(projectId, gitRepoPath);
setFileChanges(changes);
setSelectedFiles(new Set());
setSelectedDiffTarget(null);
@@ -441,9 +453,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
});
if (!shouldDiscard) return;
try {
- await discardChanges(files, projectId);
+ await discardChanges(files, projectId, gitRepoPath);
addToast(t("git.discardedFiles", "Discarded changes to {{count}} file(s)", { count: files.length }), "success");
- const [changes, statusData] = await Promise.all([fetchFileChanges(projectId), fetchGitStatus(projectId, { extended: true })]);
+ const [changes, statusData] = await Promise.all([fetchFileChanges(projectId, gitRepoPath), fetchGitStatus(projectId, { extended: true }, gitRepoPath)]);
setFileChanges(changes);
setStatus(statusData);
setSelectedFiles(new Set());
@@ -460,11 +472,11 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
if (!commitMessage.trim()) return;
setCommitting(true);
try {
- const result = await createCommit(commitMessage.trim(), projectId);
+ const result = await createCommit(commitMessage.trim(), projectId, gitRepoPath);
addToast(t("git.committedHash", "Committed: {{hash}}", { hash: result.hash }), "success");
setCommitMessage("");
// Refresh changes and status
- const [changes, statusData] = await Promise.all([fetchFileChanges(projectId), fetchGitStatus(projectId, { extended: true })]);
+ const [changes, statusData] = await Promise.all([fetchFileChanges(projectId, gitRepoPath), fetchGitStatus(projectId, { extended: true }, gitRepoPath)]);
setFileChanges(changes);
setStatus(statusData);
setSelectedDiffTarget(null);
@@ -483,12 +495,12 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
try {
const unstaged = fileChanges.filter((f) => !f.staged).map((f) => f.file);
if (unstaged.length > 0) {
- await stageFiles(unstaged, projectId);
+ await stageFiles(unstaged, projectId, gitRepoPath);
}
- const result = await createCommit(commitMessage.trim(), projectId);
+ const result = await createCommit(commitMessage.trim(), projectId, gitRepoPath);
addToast(t("git.committedHash", "Committed: {{hash}}", { hash: result.hash }), "success");
setCommitMessage("");
- const [changes, statusData] = await Promise.all([fetchFileChanges(projectId), fetchGitStatus(projectId, { extended: true })]);
+ const [changes, statusData] = await Promise.all([fetchFileChanges(projectId, gitRepoPath), fetchGitStatus(projectId, { extended: true }, gitRepoPath)]);
setFileChanges(changes);
setStatus(statusData);
setSelectedDiffTarget(null);
@@ -509,7 +521,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
changeDiffRequestIdRef.current = requestId;
try {
- const diff = await fetchGitFileDiff(file, staged, projectId);
+ const diff = await fetchGitFileDiff(file, staged, projectId, gitRepoPath);
if (changeDiffRequestIdRef.current !== requestId) {
return;
}
@@ -552,7 +564,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
setSelectedCommit(hash);
setLoadingDiff(true);
try {
- const diff = await fetchCommitDiff(hash, projectId);
+ const diff = await fetchCommitDiff(hash, projectId, gitRepoPath);
setCommitDiff(diff);
} catch (err) {
addToast(getErrorMessage(err) || t("git.failedToLoadDiff", "Failed to load diff"), "error");
@@ -584,11 +596,11 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
if (!newBranchName.trim()) return;
setLoading(true);
try {
- await createBranch(newBranchName.trim(), branchBase.trim() || undefined, projectId);
+ await createBranch(newBranchName.trim(), branchBase.trim() || undefined, projectId, gitRepoPath);
addToast(t("git.createdBranch", "Created branch {{name}}", { name: newBranchName }), "success");
setNewBranchName("");
setBranchBase("");
- const branchesData = await fetchGitBranches(projectId);
+ const branchesData = await fetchGitBranches(projectId, gitRepoPath);
setBranches(branchesData);
} catch (err) {
addToast(getErrorMessage(err) || t("git.failedToCreateBranch", "Failed to create branch"), "error");
@@ -600,9 +612,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
const handleCheckoutBranch = useCallback(async (name: string) => {
setLoading(true);
try {
- await checkoutBranch(name, projectId);
+ await checkoutBranch(name, projectId, gitRepoPath);
addToast(t("git.switchedToBranch", "Switched to {{name}}", { name }), "success");
- const [statusData, branchesData] = await Promise.all([fetchGitStatus(projectId, { extended: true }), fetchGitBranches(projectId)]);
+ const [statusData, branchesData] = await Promise.all([fetchGitStatus(projectId, { extended: true }, gitRepoPath), fetchGitBranches(projectId, gitRepoPath)]);
setStatus(statusData);
setBranches(branchesData);
} catch (err) {
@@ -621,9 +633,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
if (!shouldDelete) return;
setLoading(true);
try {
- await deleteBranch(name, undefined, projectId);
+ await deleteBranch(name, undefined, projectId, gitRepoPath);
addToast(t("git.deletedBranch", "Deleted branch {{name}}", { name }), "success");
- const branchesData = await fetchGitBranches(projectId);
+ const branchesData = await fetchGitBranches(projectId, gitRepoPath);
setBranches(branchesData);
} catch (err) {
if (getErrorMessage(err).includes("not fully merged")) {
@@ -634,9 +646,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
});
if (shouldForceDelete) {
try {
- await deleteBranch(name, true, projectId);
+ await deleteBranch(name, true, projectId, gitRepoPath);
addToast(t("git.forceDeletedBranch", "Force deleted branch {{name}}", { name }), "success");
- const branchesData = await fetchGitBranches(projectId);
+ const branchesData = await fetchGitBranches(projectId, gitRepoPath);
setBranches(branchesData);
} catch (forceErr) {
addToast(getErrorMessage(forceErr) || t("git.failedToDeleteBranch", "Failed to delete branch"), "error");
@@ -674,7 +686,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
setBranchCommitDiff(null);
setLoadingBranchCommits(true);
try {
- const data = await fetchBranchCommits(name, 10, projectId);
+ const data = await fetchBranchCommits(name, 10, projectId, gitRepoPath);
setBranchCommits(data);
} catch {
setBranchCommits([]);
@@ -694,7 +706,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
setBranchCommitDiff(null);
setLoadingBranchCommitDiff(true);
try {
- const diff = await fetchCommitDiff(hash, projectId);
+ const diff = await fetchCommitDiff(hash, projectId, gitRepoPath);
setBranchCommitDiff(diff);
} catch {
setBranchCommitDiff(null);
@@ -726,10 +738,10 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
setStashLoading("create");
resetStashDiffState();
try {
- await createStash(stashMessage.trim() || undefined, projectId);
+ await createStash(stashMessage.trim() || undefined, projectId, gitRepoPath);
addToast(t("git.changesStashed", "Changes stashed"), "success");
setStashMessage("");
- const stashesData = await fetchGitStashList(projectId);
+ const stashesData = await fetchGitStashList(projectId, gitRepoPath);
setStashes(stashesData);
} catch (err) {
addToast(getErrorMessage(err) || t("git.failedToStashChanges", "Failed to stash changes"), "error");
@@ -742,9 +754,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
setStashLoading(`apply-${index}`);
resetStashDiffState();
try {
- await applyStash(index, drop, projectId);
+ await applyStash(index, drop, projectId, gitRepoPath);
addToast(drop ? t("git.stashPopped", "Stash popped") : t("git.stashApplied", "Stash applied"), "success");
- const stashesData = await fetchGitStashList(projectId);
+ const stashesData = await fetchGitStashList(projectId, gitRepoPath);
setStashes(stashesData);
} catch (err) {
addToast(getErrorMessage(err) || t("git.failedToApplyStash", "Failed to apply stash"), "error");
@@ -763,9 +775,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
setStashLoading(`drop-${index}`);
resetStashDiffState();
try {
- await dropStash(index, projectId);
+ await dropStash(index, projectId, gitRepoPath);
addToast(t("git.stashDropped", "Stash dropped"), "success");
- const stashesData = await fetchGitStashList(projectId);
+ const stashesData = await fetchGitStashList(projectId, gitRepoPath);
setStashes(stashesData);
} catch (err) {
addToast(getErrorMessage(err) || t("git.failedToDropStash", "Failed to drop stash"), "error");
@@ -787,7 +799,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
setStashDiffError(null);
setLoadingStashDiff(true);
try {
- const diff = await fetchStashDiff(index, projectId);
+ const diff = await fetchStashDiff(index, projectId, gitRepoPath);
if (stashDiffRequestIdRef.current !== requestId) {
return;
}
@@ -810,10 +822,10 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
const handleFetch = useCallback(async () => {
setRemoteLoading("fetch");
try {
- const result = await fetchRemote(undefined, projectId);
+ const result = await fetchRemote(undefined, projectId, gitRepoPath);
setLastRemoteResult(result);
addToast(result.message || t("git.fetchCompleted", "Fetch completed"), result.fetched ? "success" : "info");
- const statusData = await fetchGitStatus(projectId, { extended: true });
+ const statusData = await fetchGitStatus(projectId, { extended: true }, gitRepoPath);
setStatus(statusData);
} catch (err) {
addToast(getErrorMessage(err) || t("git.fetchFailed", "Fetch failed"), "error");
@@ -825,7 +837,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
const handlePull = useCallback(async (options?: { rebase?: boolean }) => {
setRemoteLoading("pull");
try {
- const result = await pullBranch(options, projectId);
+ const result = await pullBranch(options, projectId, gitRepoPath);
setLastRemoteResult(result);
if (result.conflict) {
addToast(t("git.mergeConflictDetected", "Merge conflict detected. Resolve manually."), "error");
@@ -833,7 +845,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
const fallbackMessage = options?.rebase ? t("git.pullRebaseCompleted", "Pull --rebase completed") : t("git.pullCompleted", "Pull completed");
addToast(result.message || fallbackMessage, "success");
}
- const statusData = await fetchGitStatus(projectId, { extended: true });
+ const statusData = await fetchGitStatus(projectId, { extended: true }, gitRepoPath);
setStatus(statusData);
} catch (err) {
addToast(getErrorMessage(err) || t("git.pullFailed", "Pull failed"), "error");
@@ -845,10 +857,10 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
const handlePush = useCallback(async () => {
setRemoteLoading("push");
try {
- const result = await pushBranch(projectId);
+ const result = await pushBranch(projectId, gitRepoPath);
setLastRemoteResult(result);
addToast(result.message || t("git.pushCompleted", "Push completed"), "success");
- const statusData = await fetchGitStatus(projectId, { extended: true });
+ const statusData = await fetchGitStatus(projectId, { extended: true }, gitRepoPath);
setStatus(statusData);
} catch (err) {
addToast(getErrorMessage(err) || t("git.pushFailed", "Push failed"), "error");
@@ -860,17 +872,17 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
const handleSyncWithOrigin = useCallback(async () => {
setRemoteLoading("sync");
try {
- const pullResult = await pullBranch({ rebase: true }, projectId);
+ const pullResult = await pullBranch({ rebase: true }, projectId, gitRepoPath);
setLastRemoteResult(pullResult);
if (pullResult.conflict) {
addToast(t("git.mergeConflictDetected", "Merge conflict detected. Resolve manually."), "error");
return;
}
- const pushResult = await pushBranch(projectId);
+ const pushResult = await pushBranch(projectId, gitRepoPath);
setLastRemoteResult(pushResult);
addToast(t("git.syncedWithOrigin", "Synced with origin (pull --rebase + push)"), "success");
- const statusData = await fetchGitStatus(projectId, { extended: true });
+ const statusData = await fetchGitStatus(projectId, { extended: true }, gitRepoPath);
setStatus(statusData);
} catch (err) {
addToast(getErrorMessage(err) || t("git.syncWithOriginFailed", "Sync with origin failed"), "error");
@@ -885,6 +897,32 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
fetchConfig(projectId).then((cfg) => setRootDir(cfg.rootDir)).catch(() => setRootDir(null));
}, [projectId]);
+ // Fetch workspace repos on mount to determine if this is a multi-repo project.
+ /*
+ FNXC:Workspace 2026-06-24-21:30:
+ Revalidate selectedRepo against the freshly fetched repo list. When projectId
+ changes (or a project has no workspace repos), a stale selection from the prior
+ project would otherwise persist and keep sending a stale repoPath to git
+ endpoints. Keep the current selection only if it still exists in the new list;
+ otherwise fall back to repos[0], or clear to null when the list is empty (and on
+ fetch error). The functional updater lets us revalidate without depending on
+ selectedRepo in the effect deps, preserving the projectId-keyed intent.
+ */
+ useEffect(() => {
+ fetchWorkspaceRepos(projectId)
+ .then((result) => {
+ const repos = result.repos;
+ setWorkspaceRepos(repos);
+ setSelectedRepo((current) =>
+ current && repos.includes(current) ? current : (repos[0] ?? null),
+ );
+ })
+ .catch(() => {
+ setWorkspaceRepos([]);
+ setSelectedRepo(null);
+ });
+ }, [projectId]); // keyed on projectId; selectedRepo is revalidated via the functional updater
+
const handleSyncIntegrationTip = useCallback(async () => {
if (!status?.integrationBranch || status.isOnIntegrationBranch === false) return;
const worktreePath = rootDir;
@@ -909,7 +947,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
}),
});
addToast(t("git.syncedWorktreeToIntegrationTip", "Synced worktree to local integration tip"), "success");
- const statusData = await fetchGitStatus(projectId, { extended: true });
+ const statusData = await fetchGitStatus(projectId, { extended: true }, gitRepoPath);
setStatus(statusData);
} catch (err) {
addToast(getErrorMessage(err) || t("git.syncFailed", "Sync failed"), "error");
@@ -932,6 +970,29 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
<>
{/* Sidebar Navigation */}