Sourced via Codex scan of github/spec-kit and deepseek-ai/deepseek-harness (scan reports in granja/_temp/codex-logs/), then authored by Codex against this repo's exact SKILL.md format/density, calibrated against bastille-jail-provisioning/writing-implementation-plans/tdd. Spot-checked two directly (write-feature-specification, harden-async-lifecycle-code) -- concrete, code-example-backed procedures, not generic advice. From spec-kit: write-feature-specification, clarify-feature-specification, audit-requirements-quality, analyze-spec-plan-task-consistency, converge-implementation-to-spec. From deepseek-harness: harden-async-lifecycle-code, test-real-entry-paths, snapshot-agent-behavior, maintain-decision-records, remove-reasoning-transcript-prose. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
7.0 KiB
name, description
| name | description |
|---|---|
| harden-async-lifecycle-code | Use when implementing or reviewing asynchronous lifecycle, callback dispatch, subprocess, cancellation, temporary spill, or teardown code where races, orphaned work, exception leakage, secret exposure, or unsafe link cleanup are possible. |
Harden Async Lifecycle Code
Treat lifecycle correctness as a set of independently observable contracts. Each rule below prevents a distinct shipped failure class; do not collapse them into a single success boolean or a generic cleanup block.
1. Draw the lifecycle before editing
For every owned resource, record:
resource | creator | start signal | completion signal | cancellation request | quiescence signal | disposer
Include processes, streams, readers, timers, callbacks, registries, temp files, and background promises. Mark shared signals versus per-operation signals. If ownership or quiescence cannot be named, the design is not ready.
Model states explicitly, for example:
created → starting → running → stopping → quiescent → disposed
↘ failed ─────────────↗
Define legal repeated calls. Usually cancellation and disposal are idempotent; starting twice is rejected.
2. Report orthogonal outcomes independently
A process may time out and later exit zero because it handled the termination signal. A request may be aborted and also produce a provider error. Do not nest one fact beneath another:
type Outcome = {
timedOut: boolean
aborted: boolean
exitCode: number | null
signal: string | null
error: Error | null
}
Bad:
if (exitCode !== 0) return { timedOut }
return { ok: true } // loses the fact that the deadline cut the run short
This rule prevents cut-short work from being reported as a clean success. Define precedence only for a final display label; preserve every underlying fact.
3. Normalize public async contracts
Inventory every way an implementation can signal termination: throw, reject, terminal event, EOF, abort event, or status field. Choose one public contract and normalize at the boundary.
Example: provider failures may arrive as throws or error-finish events, while consumer callback defects should remain thrown. Convert only provider-originated forms to the documented terminal result; do not swallow middleware or caller bugs into the same bucket.
Test each source form through a real consumer. This prevents callers from guessing whether a caught exception came from the provider, a wrapper, logging, or their own assembly.
4. Do not mistake shared state for operation completion
Global idle, running, reader close, or queue-empty signals may cover several operations. They cannot prove which message completed, whether it started, or which output belongs to it.
For an owned operation, define an interval with durable boundaries, such as:
operation receipt persisted → all work attributable to the owned run settled → system quiescent
If the API lacks per-operation completion, describe captured output as interval-wide, not causally attributed. Handle the “nothing started, therefore no transition will occur” branch explicitly or the wait can hang forever.
This prevents wrapper jobs from reporting completion merely because background work was started or a shared agent later became idle.
5. Dispose to quiescence
Disposal means work has stopped, not that a stop request was issued.
Use this order unless resource dependencies require a documented variation:
- mark the owner as disposing so no new work is accepted;
- close callback/listener/notification registries;
- request cancellation or send termination to children;
- await every child’s definitive completion signal;
- escalate termination after a bounded grace period when appropriate;
- await exit after escalation;
- close streams, handles, and files;
- remove owned temporary paths;
- mark disposed.
async function dispose(): Promise<void> {
if (disposePromise) return disposePromise
disposePromise = (async () => {
accepting = false
listeners.clear()
child.kill('SIGTERM')
await withTimeout(childDone, graceMs, () => child.kill('SIGKILL'))
await childDone
await removeOwnedTemp()
})()
return disposePromise
}
Returning after kill() creates orphaned processes and late callbacks into torn-down state.
6. Isolate callback failures
Take a stable snapshot if listeners may add/remove listeners during dispatch. Catch per listener so one bad subscriber cannot reject the lifecycle promise or starve later subscribers:
for (const listener of [...listeners]) {
try {
await listener(event)
} catch (error) {
logger.error({ error }, 'listener failed')
}
}
Define whether dispatch is sequential or concurrent. Sequential preserves order; concurrent must collect all settlements. Never leave floating promises.
7. Scrub child environments
Do not pass the ambient environment to an untrusted command or tool output. Build an allowlist where feasible. Otherwise clone and delete keys whose case-insensitive names contain KEY, SECRET, TOKEN, PASSWORD, CREDENTIAL, or project-specific secret names.
Preserve only variables required for execution, such as a controlled PATH, locale, and explicitly approved runtime settings. Test that canary secrets are absent from child env output and spill artifacts. This prevents harness or CI credentials from leaking through subprocess output.
8. Create private unpredictable spill paths
Create a random private directory with mode 0700, then files with exclusive creation and mode 0600:
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'worker-'))
await fs.chmod(dir, 0o700)
const handle = await fs.open(path.join(dir, randomUUID()), 'wx', 0o600)
Never use a predictable shared filename, open without exclusivity, or depend on a permissive default umask. Those allow pre-created symlink races and cross-user disclosure.
9. Unlink link-shaped paths safely
For a path that may be a symlink or Windows junction, inspect with lstat, not stat. If it is link-shaped, call unlink; do not recursively remove it. unlink deletes the link and refuses a real directory. Recursive removal may follow a junction or otherwise endanger its target.
Reserve recursive deletion for a path proven to be a real directory created and owned by this operation. Validate the resolved target remains inside the owned private parent before deletion.
10. Test the failure classes
- timeout plus zero exit preserves both facts;
- cancellation before start does not hang;
- disposal awaits real process exit and emits no late callback;
- one throwing listener does not block the next;
- repeated disposal returns the same settlement;
- child environment omits canary secrets;
- spill creation rejects a pre-existing name;
- cleanup unlinks a symlink/junction without touching its target;
- provider errors normalize while consumer defects still throw.
Use fake clocks only for time nondeterminism; keep the real queue, dispatcher, process wrapper, and cleanup owner whenever possible.