TL;DR: To repair an AI-built app, start with one failing user workflow: reproduce the defect, define the expected behavior and check a bounded fix. Then decide whether to keep the design, replace a component or rebuild. The runnable retry example demonstrates a sequential in-memory repair; production storage and concurrency need separate checks.
Imagine a small team preparing an AI-built app for its first customer pilot. The demo works: create a customer, save it, see it in the list. Then a save returns an error. The user retries, and Avery Chen appears twice. The pilot now depends on answering a basic question: can one intended action create more than one record?
The tempting response is to ask a coding agent to "clean up the codebase." But a tidier project would leave the team with the same unanswered question. Before another round of changes, they need to reproduce the duplicate and understand what a successful repair would prove.
That is the useful meaning of "unslop" here: make an AI-assisted project behave correctly and become easier to change. For this team, it starts with a save they can trust.
Start with a user action, not a cleanup prompt
The team narrows the problem to creating a customer record. Its expected outcome is simple: one intended create action produces one record, including when its response is lost and the action is retried. That gives the repair an acceptance condition. Renaming files or extracting helpers would not satisfy it.
You can start without reading the code. Write down what the user did, what you expected, what happened, and whether you can repeat it. In this scenario, the useful report is "a save returned an error; retrying left two records," rather than "the generated code looks messy." Ask whoever changes the app to reproduce that failure, then show the same check passing after the fix.
Before editing, keep the current revision and a reproducible starting state. This matters just as much if you inherited the app from a contractor. Record the relevant input, observed result, and app version. A screenshot can show the duplicate; a repeatable check tells you whether a change removes it.
The team also records checks for useful behavior around the save: field validation and the customer list. Those checks give the repair something to preserve. Copying every current behavior into a test would preserve the duplicate too, so someone who understands the product must decide which results are correct.
The missing reply: a small failure with a visible consequence
The error message alone does not tell the team whether the save failed. The record may already exist even though its confirmation never reached the caller. To isolate that possibility, reduce the create path to a small example and deliberately lose its reply.
This local JavaScript example deliberately discards a reply after creating a record. It runs real in-memory create operations, without a network or model. It demonstrates a software failure and a bounded correction; it does not establish that AI caused the defect.
In the example, the first create stores Avery Chen, but the caller never receives its result and retries. Without request identity, the retry creates a second record. This gives the team a repeatable explanation to check against the app: the first write succeeds, but the retry has no way to recognize it.
Hiding the retry button might reduce repeated clicks, but an automatic retry or another caller could still repeat the write. The create operation needs to recognize a repeat of the same intended action.
| Step | Naive create | Corrected local create |
|---|---|---|
| First create, then reply loss | 1 record; caller sees an error | 1 record; caller sees an error |
| Retry the same intended action | 2 records | 1 record; original result returned |
| Reuse the request ID with a changed name | Request identity is ignored | Rejected; no new record |
| Create with a new request ID | Another create operation | A second legitimate record |
For the correction, the caller creates a request ID once for the intended save and reuses it on every retry. The store remembers the name and result: the same ID and name return the earlier result; a changed name under that ID is rejected. A genuinely new action gets a new ID, even if its contents match an earlier one.
This is an example of idempotency: repeating the same request adds no further effect. AWS's Making retries safe with idempotent APIs explains request identifiers, parameter mismatches, and the production storage decisions behind this pattern.
You can follow the repair decision from the table without reading JavaScript. The complete code is optional; it lets an engineer check the behavior directly.
Run the complete retry example locally
Copy the following code into retry.mjs, then run node retry.mjs with a current Node.js installation. It uses only Node's standard library. No packages, credentials, model calls, or repository access are needed.
// Sequential in-memory teaching example. No network or durable storage.
import { realpathSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
export class InjectedReplyLossError extends Error {}
export function createNaiveStore() {
const records = [];
return {
create(name) {
const record = { id: records.length + 1, name };
records.push(record);
return { ...record };
},
snapshot: () => records.map((record) => ({ ...record })),
};
}
export function createIdempotentStore() {
const records = [];
const requests = new Map();
return {
create(requestId, name) {
if (typeof requestId !== 'string' || requestId === '') throw new TypeError('Request ID is required');
if (typeof name !== 'string' || name === '') throw new TypeError('Name is required');
const previous = requests.get(requestId);
if (previous) {
if (previous.name !== name) throw new Error('Request ID reused with a different name');
return { ...previous };
}
const record = { id: records.length + 1, name };
records.push(record);
requests.set(requestId, record);
return { ...record };
},
snapshot: () => records.map((record) => ({ ...record })),
};
}
// Run the mutation first, then deliberately discard its reply.
export function loseReplyAfterMutation(operation) {
operation();
throw new InjectedReplyLossError('Deliberately lost reply after mutation');
}
export function retryAfterLostReply(create, request) {
try {
loseReplyAfterMutation(() => create(request.requestId, request.name));
} catch (error) {
if (!(error instanceof InjectedReplyLossError)) throw error;
}
return create(request.requestId, request.name);
}
function assert(condition, message) {
if (!condition) throw new Error(`Example assertion failed: ${message}`);
}
export function runExample() {
const request = { requestId: 'req-001', name: 'Avery Chen' };
const naive = createNaiveStore();
retryAfterLostReply((_requestId, name) => naive.create(name), request);
const naiveCount = naive.snapshot().length;
assert(naiveCount === 2, 'naive retry creates a duplicate');
const corrected = createIdempotentStore();
retryAfterLostReply(corrected.create, request);
const retryCount = corrected.snapshot().length;
assert(retryCount === 1, 'stable request ID reuses the result');
let changedNameRejected = false;
try {
corrected.create(request.requestId, 'Jordan Lee');
} catch (error) {
changedNameRejected = error.message === 'Request ID reused with a different name';
}
assert(changedNameRejected, 'changed name under the same ID is rejected');
corrected.create('req-002', 'Jordan Lee');
const finalCount = corrected.snapshot().length;
assert(finalCount === 2, 'a different request ID creates another record');
return {
fault: 'deliberately injected reply loss after the first create mutation',
naive: { intendedRecords: 1, observedRecords: naiveCount },
corrected: { observedRecordsAfterRetry: retryCount, changedNameRejected, observedRecordsAfterDifferentRequest: finalCount },
scope: 'sequential in-memory demo only; no concurrency, restart, process-crash, atomic-storage, or expiry guarantees',
};
}
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
console.log(JSON.stringify(runExample(), null, 2));
}
The script checks its own observations before printing this report:
{
"fault": "deliberately injected reply loss after the first create mutation",
"naive": { "intendedRecords": 1, "observedRecords": 2 },
"corrected": {
"observedRecordsAfterRetry": 1,
"changedNameRejected": true,
"observedRecordsAfterDifferentRequest": 2
},
"scope": "sequential in-memory demo only; no concurrency, restart, process-crash, atomic-storage, or expiry guarantees"
}When applying this pattern to a real store, protect the record and request history together, scope identity to the appropriate caller, and define how long repeat requests are recognized. A response cache alone does not settle those storage decisions. The AWS article linked above explains these production concerns.
The corrected local check passes, but its request history disappears when the process exits, and it does not test overlapping requests or crashes between writes. The team has a repair approach to investigate in the real app, with more work needed before calling the pilot ready.
The retry check passes. Is the pilot ready?
The team now has a more useful assignment than "clean up the codebase": ask the person changing the app to reproduce the failure against its actual store, then show the repair passing the same checks. Include two retries arriving at once and a restart. These can reveal whether the store remembers an action beyond the simple sequence demonstrated above.
That investigation may expose other problems. The team ranks them against the pilot's users, data, and actions before authorizing more changes. The duplicate matters because it changes customer records. An unfamiliar folder structure needs a more specific reason to delay the pilot.
- Block the affected path when its essential behavior is wrong. The duplicate is an observed failure. The team needs to fix it or disable the path; whether unaffected work can continue is a separate release decision.
- Investigate consequential unknowns. An untested permission boundary or recovery path is missing evidence, not an observed incident. Choose checks that answer those questions before expanding exposure.
- Repair structure where it obstructs the workflow. If two components apply the create rule differently, consolidating that rule may be part of the fix. A refactor earns its place by making the agreed behavior clearer or easier to verify.
- Schedule discretionary cleanup separately. Formatting, names, and unused abstractions can help maintainers, but they do not answer whether the same create request can write twice.
Does this failure justify a rewrite?
The team starts with the create path. If the lost-reply explanation holds in the actual app, a targeted repair may be enough. If its storage design cannot preserve both the record and request history correctly, rework or replace that component. The duplicate alone does not justify rebuilding unrelated parts of the app.
A wider review could change that decision, especially if assessed repair effort makes replacement the better option. A broad rewrite also replaces working behavior, so the team compares its acceptance cases and migration cost before choosing it. Whether an agent wrote the code does not settle the question.
| Decision | Evidence to look for | Next step |
|---|---|---|
| Repair in place | The failure has an identifiable cause and the surrounding path remains useful. | Reproduce it, make a targeted change, and check affected behavior. |
| Replace one component | One adapter or module repeatedly defeats the required behavior and has a separable interface. | Keep the interface and acceptance cases; replace the implementation behind them. |
| Rebuild a larger boundary | A bounded component change cannot meet the required behavior, or assessed repair effort makes replacement the better option. | Compare the acceptance cases and migration cost; define what moves, how to switch, and how to reverse. |
| Stop or narrow the product | The intended user value does not justify the remaining work and operating burden. | Test a smaller outcome or stop before funding a better-built unwanted app. |
If the repair becomes a migration, Patterns of Legacy Displacement explains how smaller replacements can leave useful parts of an existing system in service.
Give the next coding agent a testable repair task
The team can now give its engineer or coding agent a clearer task. The local example supplies the core acceptance cases; the actual store adds the checks still missing. AI assistance can remain part of the repair with a specific outcome and boundaries:
Outcome: one intended customer create action produces one record.
Failure: lose the reply after creation, then retry the same request.
Expected: return the original record; the record count remains one.
Also check: changed input under the same ID is rejected;
a new intended action can create another record.
Preserve: current field validation and the record-list behavior.
Scope: the create path and its tests; explain any wider change first.
Actual store: verify overlapping retries and restart behavior;
show how the record and request history commit together.
Deadline: name the date and the minimum outcome that must work.
Handoff: changed files, checks run, results, and remaining unknowns.The workflow owner should agree on those expected results before accepting generated tests. Inspect the relevant implementation as well as the check results. Another agent's agreement can help with review, but it does not show what the app actually does. GitHub's responsible-use guidance for Copilot agents likewise calls for reviewing and testing generated code before merging.
If this work exposes an unrelated defect, record it and decide whether it changes the pilot's release boundary. Adding it silently to the same repair would make it harder to see which change solved which problem.
The next release decision should use results from the real app, with any remaining limits visible. Keep the tested revision and acceptance cases for the next maintainer. For more on keeping that discipline while working with agents, read How I Use Coding Agents Without Giving Up Architectural Control.