moeru-ai/airi · error
Extension import plan is missing or was already used.
Error message
Extension import plan is missing or was already used.
What it means
commitPreparedPlan looks up the import plan by planId in an in-memory map and throws when it is absent. Plans are consumed exactly once: commit deletes the plan after a successful commit, and plans live only in process memory, so restarts or double-commits make the id invalid. This is a one-time-token state check.
Solutions
- Call prepare (folder re-selection/preview) again to obtain a fresh planId, then commit once with it.
- Track commit state in the caller so commit is invoked at most once per planId.
- If the app restarted, redo the whole import flow: prepare -> review -> commit; in-memory plans do not survive restarts.
- Verify the renderer is passing the planId returned by prepare unchanged.
Example fix
// before
await commit(preparedPlanId)
await commit(preparedPlanId) // second call throws
// after
if (!committedPlanIds.has(preparedPlanId)) {
await commit(preparedPlanId)
committedPlanIds.add(preparedPlanId)
} Defensive patterns
Strategy: try-catch
Validate before calling
if (committedPlanIds.has(planId)) {
return // already committed; skip duplicate call
} Try / catch
try {
await importService.commit(planId)
} catch (error) {
if (error.message.includes('missing or was already used')) {
// re-run prepare to get a fresh planId before committing again
}
} Prevention
- Commit each planId exactly once; guard retry logic with a committed set.
- Do not persist planIds across app restarts; redo prepare -> review -> commit after a restart.
- Pass the planId returned by prepare through unchanged.
When it happens
Trigger: Calling commit (which routes to commitPreparedPlan) with a planId that was never prepared; calling commit twice with the same planId (second call fails because plans.delete ran on success); restarting the Electron main process between prepare and commit, clearing the in-memory plans map.
Common situations: Renderer retry logic re-sending a commit after a timeout when the first commit actually succeeded; user closing/restarting the app between reviewing the folder and confirming the import; corrupted or fabricated planId from the renderer.
Understand the failure class
Background: Record Not Found Errors: "not found", RecordNotFound, and "was not found" — what they mean and how to fix them — this error's family across 28 libraries.
Related errors
- Extension copy does not match the reviewed package.
- Extension directory importer is disposed.
- Extension is already installed
- Extension package changed during copy. Select the folder…
- Extension source changed after review. Select the folder…
AI-assisted analysis of moeru-ai/airi@438a067dde (2026-09-17).
Data as JSON: /api/errors/60d3665bd95beb26.
Report an issue: GitHub.
Appendix: source
Thrown at apps/stage-tamagotchi/src/main/services/airi/plugins/host/directory-import.ts:480
try {
return await operation()
}
finally {
stopAccessing()
}
}
private async assertDestinationAvailable(extensionId: string): Promise<void> {
const destination = join(this.extensionsRoot, extensionId)
if (await this.isExtensionInstalled(extensionId) || await pathExists(destination)) {
throw new Error(`Extension is already installed: ${extensionId}`)
}
}
private async commitPreparedPlan(planId: string): Promise<ManifestEntry> {
const storedPlan = this.plans.get(planId)
if (!storedPlan) {
throw new Error('Extension import plan is missing or was already used.')
}
return await this.withSecurityScopedAccess(storedPlan.securityScopedBookmark, async () => {
const inspected = await inspectExtensionDirectory(storedPlan.sourcePath)
if (inspected.manifest.id !== storedPlan.preview.extensionId || inspected.fingerprint !== storedPlan.fingerprint) {
throw new Error('Extension source changed after review. Select the folder again.')
}
await this.assertDestinationAvailable(inspected.manifest.id)
await mkdir(this.extensionsRoot, { recursive: true })
const stagingRoot = join(this.extensionsRoot, '.imports')
await mkdir(stagingRoot, { recursive: true })
const stagingPath = join(stagingRoot, planId)
const destination = join(this.extensionsRoot, inspected.manifest.id)
try {
await copyExtensionDirectory(inspected.sourcePath, stagingPath)
const staged = await inspectExtensionDirectory(stagingPath)
if (staged.manifest.id !== inspected.manifest.id || staged.fingerprint !== inspected.fingerprint) {View on GitHub (pinned to 438a067dde)