Files
agent-skills/skills/harden-async-lifecycle-code/SKILL.md
T
MalinandClaude Sonnet 5 051688e196 feat: 10 new skills scouted from spec-kit and deepseek-harness
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>
2026-08-15 14:28:54 +02:00

7.0 KiB
Raw Blame History

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:

  1. mark the owner as disposing so no new work is accepted;
  2. close callback/listener/notification registries;
  3. request cancellation or send termination to children;
  4. await every childs definitive completion signal;
  5. escalate termination after a bounded grace period when appropriate;
  6. await exit after escalation;
  7. close streams, handles, and files;
  8. remove owned temporary paths;
  9. 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.

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.