Add X-Session-Id and X-Session-Affinity headers to all outbound LLM chat
completion requests so LLM gateways can sticky-route consecutive requests
from the same conversation and observability tools (Langfuse, Arize) can
group stateless API calls into a single multi-turn trace.
The headers carry a stable identifier: the task id when available (stable
across pause/resume), otherwise the pi session id. The implementation wraps
modelRegistry.getApiKeyAndHeaders -- the single chokepoint pi-coding-agent
uses for both the main stream and compaction -- merging routing headers into
the resolved output. This covers all HTTP-based providers (built-in, custom,
and HTTP-streaming extensions) without disturbing auth resolution.
Also propagates taskId to four secondary executor sessions (retry,
verification-fix, workflow-step, child-agent) that previously fell back to
a per-instance pi id, fragmenting per-task observability grouping.
Closes#1675
- Await async spawned child session disposal
- Use own-key iteration for structured tool result previews
- Add FNXC requirement comments for new regression assertions
Wrap the fatal-path acquisition observability writes (logEntry + audit.git)
in safeObserve so a store/audit throw can't replace the original
acquisition error, keeping WorkspaceRepoAcquireBusyError instanceof checks
reliable upstream.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the follow-up review on the foundation fixes:
- Re-read the task via store.getTask immediately before merging the per-repo
entry, so a concurrent sibling-repo acquisition that landed since the initial
read isn't clobbered by updateTask's wholesale map replace (narrows the
read-modify-write window to the store lock; a fully atomic per-repo store-level
merge remains a follow-up).
- Normalize the inline FNXC comment to the FNXC:Area yyyy-MM-dd-hh:mm: convention.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- base-commit-capture.ts: shell-quote integration branch with a real
single-quoted POSIX literal instead of JSON.stringify (not shell-safe).
- TaskCard.tsx: memo compares full workspaceWorktrees values, not just key
sets, so a same-key worktreePath/branch change re-renders.
- TaskDetailModal.tsx: gate/render workspace summary off hydrated workingTask.
- worktree-acquisition.ts: null the singular worktree/branch columns in the
workspaceWorktrees write so isWorkspaceTask stays true; wrap non-fatal
post-acquire observability so logEntry/audit can't re-escalate to fatal.
- agent-tools.ts: register sub-repo worktree via onAcquired unconditionally
(idempotent) so a resumed/already-acquired path is tracked after restart.
- executor.ts: DB liveness fallback also checks task.workspaceWorktrees paths.
- executor-workspace.test.ts: root non-git assertion runs in fx.rootDir (".").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- base-commit-capture: POSIX single-quote integration branch refs instead of
JSON.stringify (double quotes are subject to $-expansion in the shell)
- executor: add per-repo no_commits guard to the workspace verifyWorktreeInvariants
branch (parity with the singular path), gated by the same task-wide no-commit
eligibility
- executor: reviewWorkspacePerRepo failure message now states the per-repo verdict
list is partial (evaluation stops at first failure)
- worktree-acquisition: defensively wrap non-fatal/outer-catch logEntry/audit so a
logging throw cannot promote a non-fatal error to fatal or mask the original error
- docs/plans: add code-fence language tags and fix MD028 blank-line-in-blockquote
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolves the actionable CodeRabbit threads on the workspace-mode foundation:
- project-resolver: defer saveWorkspaceConfig until after the user confirms init
and store.init() succeeds (no partial .fusion/ on a declined/non-interactive run).
- git-repository: validate each candidate with a real `git rev-parse` work-tree
probe before counting it (no false-positive repos from stray .git markers);
loadWorkspaceConfig now rejects absolute paths, `..` escapes, and non-string
entries so a corrupt/malicious config can't resolve outside the workspace root.
- executor: gate workspace mode on repos.length > 0 at all three sites so an
empty { repos: [] } can't bypass the git-repo guard or enable an empty workspace.
- worktree-acquisition: thread the configured-command runner through the workspace
acquire path (sub-repos run their init setup); validate repoRelPath as an in-root
relative path before joining; liveness-check a remembered worktree before
reporting it ready (pruned paths fall through to re-acquire); clear the singular
task.worktree/branch after persisting per-repo state (per-repo state lives only
in workspaceWorktrees).
- agent-tools: forward runContext into acquireWorkspaceRepoWorktree for log attribution.
The executor-workspace test's mock-the-subject pattern is left for the
session-scoping follow-up that rewrites it with a real two-repo fixture (FN-5048).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The foundation imports acquireWorkspaceRepoWorktree in executor.ts but
deliberately stops before wiring it into the executor lifecycle, so the import
is unused and fails @typescript-eslint/no-unused-vars (the sole Lint failure on
this PR). Remove the dead import; it is reintroduced with real usage in the
session-scoping follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ce-code-review (4 personas) on Phase B. No P0; the review conjunction was
confirmed safe (no false-done — empty map and per-repo throws both route to
UNAVAILABLE, which blocks). Applied:
P1: the fn_task_done scope-leak guard now fails CLOSED in workspace mode — a
per-repo capture throw blocks completion ("refusing as a precaution") instead of
the outer .catch returning {blocked:false} and letting an incomplete check pass.
A scoped task that acquired ZERO sub-repo worktrees is now blocked rather than
silently passing scope enforcement.
P2: reviewWorkspacePerRepo breaks on the first non-APPROVE repo so a later repo's
throw can't discard an already-determined REVISE (callers were seeing UNAVAILABLE
instead). captureWorkspaceModifiedFiles isolates each per-repo capture in
try/catch so one repo's throw can't skip the modifiedFiles write. The .changeset
always-allowed carve-out is honored in workspace mode: the scope-leak branch now
filters repo-LOCAL paths via the (previously dead) workspace-paths.ts
deriveRepoScopeSubset helper through the same filter as the singular path, so a
sub-repo .changeset/* no longer falsely blocks fn_task_done. All four per-repo
loops iterate sorted keys for deterministic offending-repo reporting; the dead
repoRel callback param and the duplicate path-normalizer are removed.
Verified safe (no change): the reviewer semaphore releases on throw (try/finally),
and per-repo reviewers inherit the task abort via session disposal.
Deferred to Phase C: extracting a workspace-executor.ts module (before the merge
loop lands). Gate green: typecheck, lint, build, test:gate (649+58).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In workspace mode both review entry points and the completion guards now iterate
every acquired sub-repo. A shared reviewWorkspacePerRepo loops task.workspaceWorktrees
and invokes the existing single-cwd reviewStep once per repo (cwd = the sub-repo —
the reviewer agent runs its own git diff there), aggregating repo-tagged verdicts
as a conjunction: the task is reviewed only if every repo APPROVEs; the first
non-APPROVE repo's verdict becomes the aggregate. Both call sites loop — the
in-session fn_review_step tool AND the step-inversion seam (createReviewStepTool
and the stepReview workflow seam) — so no review surface silently scopes to the
non-git root (FN-5893). reviewStep itself stays single-cwd; the callers loop.
fn_task_done completion verification iterates per repo: verifyWorktreeInvariants
(from U1) already covers all worktrees, and evaluateTaskDoneScopeLeak now loops
each sub-repo (cwd + repo.baseCommitSha, repo-prefixed touched files vs the
repo-prefixed declared File Scope), blocking on the first repo with off-scope
files and naming it. Both return shapes preserved (ReviewResult; {blocked,message}).
New workspace-paths.ts repo-prefix helper (deriveRepoForPath/splitRepoScopedPath/
deriveRepoScopeSubset; segment-wise longest-prefix match, unscoped fallback) —
master U5 reuses it. Singular non-workspace path unchanged. 16 new fixture tests.
Gate green: typecheck, lint, build, test:gate (649+58).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In workspace mode the executor now captures changes and verifies worktree
invariants per acquired sub-repo instead of degrading to empty against the
non-git root. Post-session capture (:7898) gains a workspace branch that loops
task.workspaceWorktrees and reuses captureModifiedFiles(repo.worktreePath,
repo.baseCommitSha, …) per repo — inheriting resolveDiffBaseRef's merge-base
fallback (repo baseCommitSha may be undefined) and the filterFilesToOwnTaskCommits
contamination/divergence audit — then prefixes each repo's files with the repo
path into task.modifiedFiles. Branch attribution runs per sub-repo (cwd), never
against the root. The no-op assertCleanBranchAtBase is not iterated.
verifyWorktreeInvariants is un-stubbed for workspace mode: it iterates every
workspaceWorktrees entry asserting toplevel match + HEAD on fusion/<id>, and
returns the FIRST failing repo while preserving the exact discriminated union
{ok:true} | {ok:false; reason:'wrong_toplevel'|'wrong_branch'|'no_commits';
observed; expected} (the :10889 consumer switches on reason for requeue/handoff)
— the new repo field is additive. Singular non-workspace path unchanged.
Real two-repo fixture tests (capture A+B repo-prefixed vs own base, undefined-base
fallback, foreign-commit contamination audit, wrong_branch verify failure,
single-repo regression). Gate green: typecheck, lint, test:gate (649+58).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ce-code-review (5 personas) on Phase A. No P0; the workspace-root-removal path
was ruled out and the contract changes verified additive. Applied:
P1: fn_acquire_repo_worktree now catches WorkspaceRepoAcquireBusyError (and
generic failures) and returns a sanitized retryable isError instead of an
uncaught throw into the agent loop; runContext is forwarded so audit/log keep
attribution. Per-repo acquired worktree paths are now registered into the
executor's activeWorktrees Set (via an onAcquired callback) — previously the Set
only held the browse-only root, making the U1 per-repo liveness invariant hollow.
Post-acquire identity-guard install and base-SHA capture are now non-fatal
(log-and-continue): a hook/branch failure no longer strands the on-disk worktree
(the worktree is usable without the guard; an undefined baseCommitSha is already
an accepted state).
P2: the KTD3 settings-strip also strips settings.baseBranch (resolveFromSettings
falls back integrationBranch → baseBranch, so a shared baseBranch leaked); the
workspaceWorktrees write re-reads the task fresh before merging to avoid a
sibling-repo clobber on sequential acquires (store-level atomic merge deferred to
Phase B); the busy-path logging is wrapped so it can't mask the busy error; the
TaskCard memo compares key-sets not counts; the stuck-kill no-op for workspace
tasks is now logged; the exclusivity check-then-act synchrony is documented.
Residuals (Phase B): per-repo worktree teardown, orphan-scan coverage, reaper
dedup, store-level atomic merge.
Gate green: typecheck, lint, build, test:gate (649+58), affected (25 + TaskCard 251).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
acquireWorkspaceRepoWorktree now hardens each sub-repo worktree at acquisition:
(1) installs the identity guard with the executor's settings args
(commitMsgHookEnabled/taskPrefix/taskAttributionTrailerName) for single-repo
parity — it was installing no guard before; (2) captures a per-repo
baseCommitSha local-first against the repo's resolved integration branch via
resolveIntegrationBranch(repoAbsPath, {...settings, integrationBranch: undefined})
— stripping the shared override so each sub-repo falls through to its own
origin/HEAD, not a project-wide branch; (3) persists baseCommitSha into the
workspaceWorktrees[repo] entry (Task type extended); (4) registers same-sub-repo
exclusivity on the sub-repo path via activeSessionRegistry under a distinct
"workspace-repo-acquire" kind (released in finally), so two concurrent workspace
tasks contending for the same sub-repo are serialized (throws
WorkspaceRepoAcquireBusyError). Idempotent re-acquire short-circuits.
resolveCapturedBaseCommitSha gains an optional trailing integrationBranch param
defaulting to "main", so existing single-repo callers + base-commit-capture
real-git tests stay green. New audit events worktree:workspace-repo-acquire-busy
/-failed. 6 new real-fixture tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In workspace mode (loadWorkspaceConfig present), the executor now skips the
root acquireTaskWorktree({rootDir}) and every intervening rootDir git preflight
(base-commit capture, contamination, identity-guard, verifyWorktreeInvariants),
runs the agent session rooted at the non-git workspace root (cwd=rootDir,
browse-only; task.worktree never set), and tracks activeWorktrees as a per-task
Set<path>. scopePromptToWorktree is a no-op in workspace mode. The non-workspace
path is unchanged (every change branches on this.workspaceConfig; a single-repo
task holds a one-element Set).
Converted every activeWorktrees consumer to membership semantics (feasibility-
verified list): findActiveWorktreeOwner, hasActiveWorktreeBinding, the FN-6736
phantom-binding reclaim, listWorktreeHolders (flat-maps a Set into N holder rows
— verified the FN-6782 reaper keys off taskId only, so slot accounting is
unaffected), the conflict-set iteration, the three deleteActive* unregister
resolvers (loop every path), cleanup, getWorktreePath (undefined for a
multi-worktree workspace task), and the verifyWorktreeInvariants singular
resolution (gated off in workspace mode — per-repo verify returns in Phase B).
Rewrote executor-workspace.test.ts from vi.mock-the-subject to a real two-repo
git fixture harness (_workspace-fixture.ts, shared with later units), 13 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Reject failure-condition edges inside optional-group templates (the single-pass
walk surfaces template failures as the group's outcome, so an internal failure
edge was silently dead) — Greptile P2.
- flowToIr: a container/group node (foreach/loop/optional-group) is v2-only — its
presence now forces v2 serialization (an inserted optional-group on a plain
workflow no longer serializes as invalid v1) — CodeRabbit.
- Disabled optional-group bypass routes a plain success with no distinguishing
value, so an outcome:* edge can't preempt success routing (inertness) — CodeRabbit.
- Downgrade heuristic: presence of a legacy optionalSteps key (incl. []) keeps v2.
- Resolver docblock corrected (config-less groups resolve to a fallback entry).
- Strengthen tests: assert both inserted groups + v2 round-trip; failure-edge
rejection case.
- Changeset: bump to major (removed exported WorkflowOptionalStep type).
- Plan: record U7a as delivered in this cohort; only the workflow-step seam
infra removal remains deferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Post-fix verification review (correctness + adversarial + reliability, unanimous
P0) found that the earlier retry-burn fix introduced an infinite loop: parking a
WorkspaceTaskMergeError task with status:null + mergeRetries:0 passes every
auto-merge eligibility gate (canMergeTask short-circuits only on status==='failed'),
so the cooldown sweep re-enqueues it every tick → guard re-throws → re-park, forever.
- Park with status:'failed' (keep mergeRetries:0). canMergeTask now blocks the
auto-sweep; a human's manual merge still works because it flows through the
manual-resolver branch (rejectMergeResolvers), which bypasses canMergeTask — so
'failed' does not block manual retry (the original comment's worry was wrong).
- Detect the error via `err instanceof Error && err.name === "WorkspaceTaskMergeError"`,
matching the VerificationError/MergeAbortedError convention and bundle-safe across
the @fusion/core→@fusion/engine boundary (drops the now-unused class import).
- Document that the dispatch door guard is a fast-fail only; the unconditional
chokepoint guard inside runAiMerge is the authoritative enforcement.
- Add a regression test asserting the auto-merge park sets status:'failed' (not null).
Gate green: lint, typecheck, build, test:gate (649+58), project-engine (81).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>