fix(engine): defend autostash drop against TOCTOU race
dropAutostashBySha resolved SHA→stash@{N} then ran git stash drop ${ref}
non-atomically. Any other process (interactive shell, parallel merger,
fix-agent) pushing a stash between resolve and drop shifted the index, so
we silently dropped the wrong entry while leaving ours behind. The task
log then claimed "Restored pre-merge autostash X cleanly" even though the
stash was still in the list — observed on FN-3558 (e81e922) and others.
Verify the ref still resolves to our SHA via git rev-parse before dropping;
on mismatch, re-resolve and retry up to 5x. Return success/failure so the
caller can record honest status to the task feed instead of unconditionally
logging "cleanly".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1150,6 +1150,16 @@ Read-only board/task card projection plugin for Even Realities companion flows.
|
|||||||
- Demonstrates: plugin routes protected with API-key auth, store reads via `ctx.taskStore`, and card-deck projection helpers
|
- Demonstrates: plugin routes protected with API-key auth, store reads via `ctx.taskStore`, and card-deck projection helpers
|
||||||
- Features: `GET /board/cards`, `GET /board`, and `GET /tasks/:id/cards` endpoints with compact card payloads
|
- Features: `GET /board/cards`, `GET /board`, and `GET /tasks/:id/cards` endpoints with compact card payloads
|
||||||
|
|
||||||
|
### [Even Realities Glasses Plugin](../../plugins/fusion-plugin-even-realities-glasses/)
|
||||||
|
|
||||||
|
Task-focused card bridge plugin for Even Realities glasses companion flows.
|
||||||
|
|
||||||
|
- Features: quick capture text into new tasks via the plugin route
|
||||||
|
- Features: polling-based task transition notifications on configured columns (default `in-review`)
|
||||||
|
- Features: agent actions for start work (`in-progress`) and request review (`in-review`), gated by `enableAgentActions`
|
||||||
|
- Demonstrates: settings schema for `fusionApiBaseUrl`, `fusionApiToken`, `glassesDeviceId`, `pollingIntervalSeconds`, `notifyOnColumns`, `quickCaptureDefaultColumn`, and `enableAgentActions`
|
||||||
|
- Demonstrates FN-3737-aligned display limits: `EVEN_CARD_MAX_CHARS_PER_LINE = 28`, `EVEN_CARD_MAX_LINES_PER_CARD = 8`, `EVEN_CARD_MAX_DECK_SIZE = 12`
|
||||||
|
|
||||||
### Installing Example Plugins from Settings
|
### Installing Example Plugins from Settings
|
||||||
|
|
||||||
All example plugins can be installed via the dashboard Settings → Plugins UI:
|
All example plugins can be installed via the dashboard Settings → Plugins UI:
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ For a full walkthrough (installation, onboarding, first task, and daily workflow
|
|||||||
|---|---|
|
|---|---|
|
||||||
| [Plugin Management](./plugin-management.md) | End-user guide for discovering, installing, enabling, configuring, updating, uninstalling, and troubleshooting Fusion plugins |
|
| [Plugin Management](./plugin-management.md) | End-user guide for discovering, installing, enabling, configuring, updating, uninstalling, and troubleshooting Fusion plugins |
|
||||||
| [Plugin Authoring](./PLUGIN_AUTHORING.md) | Developer guide for building Fusion plugins (manifest, SDK hooks, routes, UI/runtime contributions) |
|
| [Plugin Authoring](./PLUGIN_AUTHORING.md) | Developer guide for building Fusion plugins (manifest, SDK hooks, routes, UI/runtime contributions) |
|
||||||
|
| [Even Realities Glasses Plugin](../plugins/fusion-plugin-even-realities-glasses/README.md) | Task-focused Even Realities glasses bridge with quick capture, polling notifications, and agent actions |
|
||||||
| [Memory Plugin Contract](./memory-plugin-contract.md) | Pluggable memory backend architecture, interface contract, and migration strategy |
|
| [Memory Plugin Contract](./memory-plugin-contract.md) | Pluggable memory backend architecture, interface contract, and migration strategy |
|
||||||
|
|
||||||
### Audit Reports
|
### Audit Reports
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ async function captureTools(settingsOverride?: Record<string, unknown>): Promise
|
|||||||
{ name: "Preflight", status: "done" },
|
{ name: "Preflight", status: "done" },
|
||||||
{ name: "Implement", status: "in-progress" },
|
{ name: "Implement", status: "in-progress" },
|
||||||
{ name: "Testing", status: "pending" },
|
{ name: "Testing", status: "pending" },
|
||||||
|
{ name: "Docs", status: "pending" },
|
||||||
];
|
];
|
||||||
store.updateStep.mockImplementation(async (_taskId: string, stepIndex: number, status: string) => {
|
store.updateStep.mockImplementation(async (_taskId: string, stepIndex: number, status: string) => {
|
||||||
const current = stepStates[stepIndex];
|
const current = stepStates[stepIndex];
|
||||||
|
|||||||
@@ -1531,20 +1531,60 @@ async function findStashRefBySha(rootDir: string, sha: string): Promise<string |
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Best-effort drop of an autostash by SHA. Resolves SHA → stash@{N} →
|
/** Drop an autostash by SHA, defending against the TOCTOU race where another
|
||||||
* drops. Logs but never throws on failure. */
|
* process pushes a stash between our `findStashRefBySha` and the actual
|
||||||
async function dropAutostashBySha(rootDir: string, taskId: string, sha: string): Promise<void> {
|
* `git stash drop stash@{N}` (drop only takes positional refs, so the index
|
||||||
const ref = await findStashRefBySha(rootDir, sha);
|
* is what git uses — not our SHA). Without this guard we silently drop
|
||||||
if (!ref) {
|
* someone else's stash while leaving ours behind, and the task log lies
|
||||||
mergerLog.log(`${taskId}: autostash ${sha.slice(0, 7)} no longer in stash list (already dropped)`);
|
* about a clean restore.
|
||||||
return;
|
*
|
||||||
}
|
* Strategy: re-resolve ref → SHA, verify the ref still points at our SHA
|
||||||
try {
|
* with `git rev-parse`, then drop. If the SHA at the ref drifted (race),
|
||||||
await execAsync(`git stash drop ${ref}`, { cwd: rootDir });
|
* retry up to 5x. Returns whether the drop landed cleanly so callers can
|
||||||
} catch (err: unknown) {
|
* surface failure to the task feed. */
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
async function dropAutostashBySha(
|
||||||
mergerLog.warn(`${taskId}: failed to drop autostash ${ref} (${msg}) — harmless, will linger in stash list`);
|
rootDir: string,
|
||||||
|
taskId: string,
|
||||||
|
sha: string,
|
||||||
|
): Promise<{ dropped: boolean; reason?: string }> {
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
const ref = await findStashRefBySha(rootDir, sha);
|
||||||
|
if (!ref) {
|
||||||
|
mergerLog.log(`${taskId}: autostash ${sha.slice(0, 7)} no longer in stash list (already dropped)`);
|
||||||
|
return { dropped: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defend against the index-shift race: confirm the ref still resolves to
|
||||||
|
// our SHA before dropping. If another process pushed a stash, ref now
|
||||||
|
// points at theirs — back off and re-resolve.
|
||||||
|
let refSha = "";
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync(`git rev-parse ${ref}`, { cwd: rootDir, encoding: "utf-8" });
|
||||||
|
refSha = String(stdout).trim();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
mergerLog.warn(`${taskId}: rev-parse ${ref} failed (${msg}) on drop attempt ${attempt + 1} — retrying`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (refSha !== sha) {
|
||||||
|
mergerLog.log(`${taskId}: autostash ${sha.slice(0, 7)} shifted off ${ref} (now ${refSha.slice(0, 7)}); re-resolving`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execAsync(`git stash drop ${ref}`, { cwd: rootDir });
|
||||||
|
return { dropped: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
// Final attempt: surface the failure. Earlier attempts get retried.
|
||||||
|
if (attempt === 4) {
|
||||||
|
mergerLog.warn(`${taskId}: failed to drop autostash ${ref} after ${attempt + 1} attempts (${msg}) — stash will linger in stash list`);
|
||||||
|
return { dropped: false, reason: msg };
|
||||||
|
}
|
||||||
|
mergerLog.warn(`${taskId}: drop ${ref} attempt ${attempt + 1} failed (${msg}) — retrying`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return { dropped: false, reason: "exhausted retry attempts" };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1800,13 +1840,26 @@ async function restoreUnrelatedRootDirChanges(
|
|||||||
if (!applyConflicted) {
|
if (!applyConflicted) {
|
||||||
// Clean apply — drop the stash and we're done.
|
// Clean apply — drop the stash and we're done.
|
||||||
mergerLog.log(`${taskId}: restored autostash ${sha.slice(0, 7)} cleanly`);
|
mergerLog.log(`${taskId}: restored autostash ${sha.slice(0, 7)} cleanly`);
|
||||||
await dropAutostashBySha(rootDir, taskId, sha);
|
const dropResult = await dropAutostashBySha(rootDir, taskId, sha);
|
||||||
await ctx.store
|
if (dropResult.dropped) {
|
||||||
.logEntry(
|
await ctx.store
|
||||||
taskId,
|
.logEntry(
|
||||||
`Restored pre-merge autostash ${sha.slice(0, 7)} cleanly`,
|
taskId,
|
||||||
)
|
`Restored pre-merge autostash ${sha.slice(0, 7)} cleanly`,
|
||||||
.catch(() => undefined);
|
)
|
||||||
|
.catch(() => undefined);
|
||||||
|
} else {
|
||||||
|
// Apply succeeded but drop failed — the working tree has the dev's
|
||||||
|
// changes but the stash is still in the list. Surface honestly so the
|
||||||
|
// operator can `git stash drop` it manually.
|
||||||
|
await ctx.store
|
||||||
|
.logEntry(
|
||||||
|
taskId,
|
||||||
|
`Restored pre-merge autostash ${sha.slice(0, 7)} (apply clean), but stash entry failed to drop and is still in the list`,
|
||||||
|
`Drop failure: ${dropResult.reason ?? "unknown"}\n\nClean up manually with:\n cd ${rootDir} && git stash list | grep ${sha.slice(0, 7)} && git stash drop <ref>`,
|
||||||
|
)
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
return { status: "restored", stashSha: sha };
|
return { status: "restored", stashSha: sha };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1883,12 +1936,20 @@ async function restoreUnrelatedRootDirChanges(
|
|||||||
mergerLog.log(
|
mergerLog.log(
|
||||||
`${taskId}: AI-resolved autostash conflict in ${conflictedFiles.length} file(s); dropping stash ${sha.slice(0, 7)}`,
|
`${taskId}: AI-resolved autostash conflict in ${conflictedFiles.length} file(s); dropping stash ${sha.slice(0, 7)}`,
|
||||||
);
|
);
|
||||||
await ctx.store.logEntry(
|
const aiDropResult = await dropAutostashBySha(rootDir, taskId, sha);
|
||||||
taskId,
|
if (aiDropResult.dropped) {
|
||||||
`Autostash conflict resolved by AI in ${conflictedFiles.length} file(s)`,
|
await ctx.store.logEntry(
|
||||||
conflictedFiles.join("\n"),
|
taskId,
|
||||||
);
|
`Autostash conflict resolved by AI in ${conflictedFiles.length} file(s)`,
|
||||||
await dropAutostashBySha(rootDir, taskId, sha);
|
conflictedFiles.join("\n"),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await ctx.store.logEntry(
|
||||||
|
taskId,
|
||||||
|
`Autostash conflict resolved by AI in ${conflictedFiles.length} file(s), but stash entry failed to drop`,
|
||||||
|
`Resolved files:\n${conflictedFiles.join("\n")}\n\nDrop failure: ${aiDropResult.reason ?? "unknown"}\n\nClean up manually with:\n cd ${rootDir} && git stash list | grep ${sha.slice(0, 7)} && git stash drop <ref>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: "ai-resolved",
|
status: "ai-resolved",
|
||||||
|
|||||||
Reference in New Issue
Block a user