Merge remote-tracking branch 'origin/main' into latest3-1717

This commit is contained in:
gsxdsm
2026-06-23 19:58:12 -07:00
3 changed files with 75 additions and 2 deletions

View File

@@ -155,7 +155,7 @@ The surface-enumeration spine (FN-5893). Every row is a single-worktree / `cwd:r
Additive only — no migration to existing single-repo tasks:
```
```ts
Task.workspaceWorktrees: Record<repoRelPath, {
worktreePath: string;
branch: string;

View File

@@ -10699,6 +10699,26 @@ export class TaskExecutor {
// Phase A returned a flat {ok:true} stub here (no root worktree to verify against the non-git root). Phase B iterates every `task.workspaceWorktrees` entry, asserting (a) the sub-repo worktree's git toplevel matches the recorded repo.worktreePath and (b) its HEAD is on the recorded `fusion/<id>` branch (repo.branch). The result union is PRESERVED EXACTLY — `{ok:true} | {ok:false; reason:'wrong_toplevel'|'wrong_branch'|'no_commits'; observed; expected}` — because the :10889 consumer switches on `reason` to drive requeue/handoff (:10894-10936). We ADD an optional `repo` field to the failure shape (purely additive; the consumer only reads reason/observed/expected) and return the FIRST failing repo. A zero-acquire workspace task (empty map) verifies vacuously → {ok:true}, matching Phase A so fn_task_done does not requeue it.
if (this.workspaceConfig) {
const workspaceWorktrees = task.workspaceWorktrees ?? {};
// FNXC:Workspace 2026-06-22-00:00: KTD2 — resolve the SAME task-wide no-commit eligibility the singular path
// uses (getNoCommitEligibilityReason / no-op-completion sentinel / prompt-derived), once, before the per-repo
// loop. When eligible (Plan-Only, verified no-op, etc.) the per-repo no_commits guard below is skipped so an
// intentionally commit-free workspace task is not blocked from completion.
const workspacePromptContent = (task as Task & { prompt?: unknown }).prompt;
const workspacePromptEligibility = evaluatePromptDerivedNoCommitEligibility(
task,
typeof workspacePromptContent === "string" ? workspacePromptContent : "",
);
const workspaceNoCommitEligibilityReason =
getNoCommitEligibilityReason(task) ??
(options?.noOpCompletion
? options.noOpCompletionReason ?? "verified no-op/duplicate completion sentinel"
: null) ??
(workspacePromptEligibility.eligible
? workspacePromptEligibility.reason ?? "prompt-derived no-commit eligibility"
: null);
if (workspaceNoCommitEligibilityReason) {
executorLog.log(`${task.id}: workspace fn_task_done no_commits guard skipped (${workspaceNoCommitEligibilityReason})`);
}
// FNXC:Workspace 2026-06-21-15:00: F6 — iterate sorted repo keys so the FIRST failing repo
// returned here is deterministic across runs/rehydrate (the value is surfaced to the operator).
for (const repoRel of Object.keys(workspaceWorktrees).sort()) {
@@ -10776,6 +10796,48 @@ export class TaskExecutor {
expected: expectedBranch,
};
}
// FNXC:Workspace 2026-06-22-00:00: KTD2 — per-repo no_commits guard (parity with the singular path at :10821).
// Phase B originally returned {ok:true} after the toplevel/branch checks, so a workspace task could call
// fn_task_done having committed NOTHING in any sub-repo (scope-leak sees zero touched files, branch names match)
// and still advance to in-review. Enforce the same `git rev-list --count <base>..HEAD > 0` invariant per repo,
// gated by the SAME task-wide no-commit eligibility below so Plan-Only / no-op-sentinel tasks stay exempt.
// The first sub-repo with zero commits fails with reason:'no_commits' (consumer-stable union).
if (!workspaceNoCommitEligibilityReason) {
const repoBaseRef = await this.resolveDiffBaseRef(repo.worktreePath, repo.baseCommitSha);
if (repoBaseRef) {
try {
const { stdout } = await execAsync(`git rev-list --count ${repoBaseRef}..HEAD`, {
cwd: repo.worktreePath,
encoding: "utf-8",
timeout: 10_000,
maxBuffer: 1024 * 1024,
});
const trimmedCount = stdout.trim();
if (trimmedCount) {
const count = Number.parseInt(trimmedCount, 10);
if (!Number.isFinite(count) || count <= 0) {
return {
ok: false,
reason: "no_commits",
repo: repoRel,
observed: Number.isFinite(count) ? String(count) : trimmedCount,
expected: "> 0",
};
}
}
} catch (error) {
return {
ok: false,
reason: "no_commits",
repo: repoRel,
observed: error instanceof Error ? error.message : String(error),
expected: `git rev-list --count ${repoBaseRef}..HEAD > 0`,
};
}
} else {
executorLog.warn(`${task.id}: unable to resolve diff base for ${repoRel} no_commits guard; skipping for this sub-repo`);
}
}
}
return { ok: true };
}
@@ -12730,7 +12792,10 @@ ${failureFeedback}
// verdict→edge mapping is identical to single-cwd), with the full repo-tagged review body.
return {
verdict: firstFailing.result.verdict,
review: `Workspace review failed in sub-repo \`${firstFailing.repo}\` (verdict ${firstFailing.result.verdict}). Per-repo verdicts:\n\n${reviewSections.join("\n\n")}`,
// FNXC:Workspace 2026-06-22-00:00: the conjunction BREAKS on the first non-APPROVE repo,
// so reviewSections holds only the repos evaluated up to (and including) the failure — not
// every sub-repo. Label it honestly so operators don't read a partial list as exhaustive.
review: `Workspace review failed in sub-repo \`${firstFailing.repo}\` (verdict ${firstFailing.result.verdict}). Per-repo verdicts (evaluation stopped at first failure; later repos not reviewed):\n\n${reviewSections.join("\n\n")}`,
summary: `${firstFailing.repo}: ${firstFailing.result.verdict} — ${summarySections.join(" | ")}`,
};
}

View File

@@ -867,6 +867,9 @@ export async function acquireWorkspaceRepoWorktree(
});
} catch (guardErr) {
// FNXC:Workspace 2026-06-21-22:30: F3 — identity-guard install is non-fatal; worktree is usable without it.
// FNXC:Workspace 2026-06-22-00:00: the non-fatal logEntry/audit are themselves best-effort — if either throws
// (e.g. a DB write hiccup) it must NOT promote this non-fatal guard failure into a fatal acquisition failure.
// Swallow logging errors so acquisition continues (matching the F6 busy-path defensive wrap above).
const message = guardErr instanceof Error ? guardErr.message : String(guardErr);
logger?.warn(`${task.id}: identity-guard install failed for sub-repo ${repoRelPath} (non-fatal): ${message}`);
// FNXC:Workspace 2026-06-22-09:00: the observability writes (store.logEntry / audit.git)
@@ -904,6 +907,8 @@ export async function acquireWorkspaceRepoWorktree(
baseCommitSha = await resolveCapturedBaseCommitSha(result.worktreePath, logger, integrationBranch);
} catch (baseErr) {
// FNXC:Workspace 2026-06-21-22:30: F3 — base-SHA capture is non-fatal; an undefined baseCommitSha is an accepted state.
// FNXC:Workspace 2026-06-22-00:00: guard the best-effort logEntry/audit so a logging throw cannot promote this
// non-fatal capture failure into a fatal acquisition failure (parity with the F6 busy-path defensive wrap).
const message = baseErr instanceof Error ? baseErr.message : String(baseErr);
logger?.warn(`${task.id}: base-SHA capture failed for sub-repo ${repoRelPath} (non-fatal): ${message}`);
// FNXC:Workspace 2026-06-22-09:00: same non-fatal contract as the identity-guard catch —
@@ -957,6 +962,9 @@ export async function acquireWorkspaceRepoWorktree(
sub-repo.
*/
if (!(err instanceof WorkspaceRepoAcquireBusyError)) {
// FNXC:Workspace 2026-06-22-00:00: wrap the failure logEntry/audit so a throw here cannot replace the ORIGINAL
// acquisition `err` the caller must observe — losing it would mask the real cause and the re-throw below would
// surface a logging error instead. Best-effort observability; `err` is always re-thrown.
const message = err instanceof Error ? err.message : String(err);
logger?.error?.(`${task.id}: workspace sub-repo acquisition failed for ${repoRelPath}: ${message}`);
// FNXC:Workspace 2026-06-22-09:30: the fatal-path observability writes must use safeObserve