Skip to content

Commit eafe2be

Browse files
mchwangeastagiletrackerclaude
authored
Probe task storage path identity at runner startup (#162) (#180)
* Probe checkout path identity at runner startup Runner startup now probes case sensitivity and Unicode normalization on the filesystem that holds the task checkouts and refuses to start when the configured pathIdentity disagrees. The demo uses the same shared probe. * Probe task storage, not the host folder, for path identity (#162) The agent edits, and the runner inspects, a tmpfs volume in the Docker daemon's Linux kernel. The host attempts folder holds only the staging clone. On a macOS host the earlier probe reported APFS's folding and refused the setting that is correct for task storage. Startup now runs the probe with the built image, in a hardened --rm container on an anonymous volume made with task storage's own tmpfs options, and refuses on a mismatch. The host probe stays for the demo. The staging clone's own case collapse is #179. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Narrow the task storage claim and test the probe script on the host Independent review of #180: the runner's host checkouts (the staging clone and the rebase worktree) are not task storage, so the comment and docs now say only what the probe covers, and point at #179. The probe script takes its directory, so a test runs it on a host folder and compares it with the host probe: a script that stopped measuring fails on a host that folds names. A test comment no longer claims an order the test does not check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Remove a failed path identity probe by name Independent review of #180, round 2: --rm removes the probe only once its container has started. A client killed between create and start (its deadline, on a slow daemon) left an unnamed container and its volume that nothing would ever remove. The probe now names its container and, on any failure, removes it and its volume by that name, asking through the settle window when the client did not finish. If that does not settle, the error names the container to remove. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Confirm a failed probe's removal by inspect, as Docker answers Independent review of #180, round 3: `docker rm --force` exits 0 for a name that does not exist (Docker 27), so the first removal ended the settle window and a create that landed after the killed client stayed behind. Removal is now confirmed by `container inspect` reporting the name absent after the window. A docker that never started sent nothing, so it is neither removed nor waited for. The test's fake docker now answers rm and inspect as Docker does, and can land a late create. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test the probe's retries, its give-up and an any-host negative control Independent review of #180, round 4. The script is now run on a folder that already holds CASE and the NFD name, which must read as folded on any host, so a script that printed a constant fails on Linux CI too. The cleanup's retry while Docker refuses a removal, its give-up with the name to remove, and a client refused before it started each have a test; a give-up after Docker still reports the container says so. The comment no longer claims every failed probe is removed: a create that lands after the window, or a runner that dies first, can leave one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pin the path identity probe's volume options and hardening in a test Independent review of #180, round 5: any Linux volume answers `true none`, so only the probe's arguments show it measures a volume made like task storage, in a container hardened like the keeper. A fake-docker test now checks the exact run arguments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Confirm a failed probe's removal by inspect whatever rm answers Independent review of #180, round 6: older Docker CLIs exit non-zero for `rm --force` on a missing name, and the cleanup then never asked inspect, so it waited out its whole window and said a container may remain when none did. Every removal is now followed by an inspect, as allocation's own removal does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Point the probe's leftover-container note at #181 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Require test fakes to answer as the real tool does Review lesson from #180: a fake `docker` answered `rm --force` on a missing name with exit 1, while Docker 27 exits 0, so a cleanup that trusted that exit code passed its tests while it would leak against the real daemon. Added to the mirrored "Review readiness" section of AGENTS.md and CLAUDE.md in the same commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hold this branch's fakes to the new rule The probe fake names the Docker version its answers were checked against and gives the older CLI's rm answer for a missing name; the CLI test's fake says it models only a successful startup's calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Word the fakes rule by the versions the code may meet No repository document defines a supported Docker, git or gh range, so "supported versions" left a fake's completeness undecidable. Changed in both mirrored files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Make the fakes answer exactly as Docker 27.4.0 does The probe fake's inspect of a missing name now prints [] on stdout and its refused run uses Docker's own wording; the CLI test's fake names the version its success-path answers were checked against. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Give the fakes Docker's full answers and list every modelled call Independent review of #180, round 13: a refused run's stderr has a second line on Docker 27.4.0, now in the fake and asserted in full; the CLI test's fake lists the network listing and build among its calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Name every call the fakes answer, image inspect and build included Independent review of #180, round 14. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Bound the probe cleanup per call, test volume removal, document the step Independent review of #180, round 15. Each cleanup call now gets the time left before giving up (at least 1 s), so a daemon that stops answering cannot stretch the bound, and the give-up reports the last answer Docker gave. A real-Docker test shows a never-started container made with the probe's exact mount loses its volume to the cleanup's own rm. The runner lifecycle doc and README name the startup check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Start no cleanup call after giving up is due, and report its real cause Independent review of #180, round 16. After the first ask, the probe cleanup checks the give-up time before each call, so a daemon that stops answering partway adds at most one call's floor and stop grace. On give-up it reports the latest outcome of either call (an unanswered rm included), or that Docker still reported the container. The fake no longer has states Docker never answers: a removal in progress applies only to a container that exists, and a hanging daemon starts at a set time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Simplify the probe cleanup's give-up, and settle unless the daemon refused Independent review of #180, round 17. One helper now makes every cleanup call and checks the give-up time before each, and the give-up says only that Docker did not confirm the container is gone, with Docker's last outcome as the cause. A client-side exit 125 (a lost connection after the create was sent) no longer skips the settle window: only a refusal from the daemon itself does. The fake notes which rm and inspect output the code reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Accept either last answer in the give-up test; use Docker's real 125 text Independent review of #180, round 18: when giving up falls due during the rm, the last answer is the rm's refusal, not the inspect, so the test accepts both. The unreachable-daemon fake prints what Docker 27.4.0 prints. The cleanup comment names the 10 s cap on each call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test the probe's strict parse; model an unreachable daemon fully Independent review of #180, round 19: a probe that exits 0 with output other than one identity line is refused, now under test. The unreachable fake answers rm and inspect as Docker 27.4.0 does without a daemon, and the old-CLI note cites the 20.10.0 release notes it rests on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pin the script the probe container runs, and cite the probe's evidence Independent review of #180, round 20: the argv test now checks the -c argument is the measuring script itself, so a constant printed in its place fails; the plan-library alignment row cites the probe's code and tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test each field the probe reports, and the 10 s cap per cleanup call Independent review of #180, round 21: every fake answered tmpfs's true none, so a probe that ignored the container's answer passed; the fake can now answer any identity and each field is checked on its own. A hung-daemon test shows the first cleanup call is cut at 10 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test the probe's 1 s floor, the parse's start anchor and its writes Independent review of #180, round 22: a slow daemon with almost no time left is still heard; output with a line before the identity is refused; the script's directory listing is checked on any host; and the tests that need a create to land assert that it did, with a 2 s client deadline to keep that margin under load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Land the late create after the first inspect, and assert that order Independent review of #180, round 23: the late-create fake landed before the first rm, so the test never exercised a create that lands after the cleanup has found nothing. It now lands after the first inspect, and the test checks that the first rm found nothing and a later one removed it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Assert the absent-then-silent order, and say what the old-CLI fake leaves unchecked Independent review of #180, round 24. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Judge the probe cleanup's settle window by when each inspect was asked Independent review of #180, round 25: an absent answer to an inspect asked inside the window, arriving after it while a create lands, was taken as settled and left the container. The window now uses the time the inspect was asked; a fake with a slow, mid-landing inspect tests it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Assert the slow inspect answered, and test the default 30 s give-up Independent review of #180, round 26. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pin the default give-up from both sides Independent review of #180, round 27: the default-bound test accepted up to 50 s, so a longer settle window or give-up passed; it now allows 3 s over the 40 s it states. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pin the cleanup call deadline's floor and cap exactly Independent review of #180, round 28: timing tests caught only some changes to the 1 s floor and 10 s cap. The deadline is now a small exported function with exact values tested in both directions; the timing tests still show the cleanup uses it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Name and pin the probe cleanup's settle and give-up values; reattach a doc Independent review of #180, round 29: changes under about 3 s to the default give-up or the settle window passed the timing test. Both are now named exports with their exact values tested; the timing test shows they are the ones in effect. The removeProbeContainer doc sits above its function again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Check that the probe's settle window starts when the client is stopped Independent review of #180, round 30. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test the never-started check's synchronous spawn failure (E2BIG) Independent review of #180, round 32. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Scope the real-Docker volume checks to the probe's own volumes Independent review of #180, round 33: both tests compared every volume on the daemon, so another run on it could fail them. They now count only volumes with the probe's tmpfs options, or the container's own volume by name. The per-call deadline comment states the tolerance the timing tests give. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * State the real-Docker probe test's one-probe-per-daemon precondition Independent review of #180, round 34. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Say which constant script each script test catches, and where Independent review of #180, round 36. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Word the fakes rule by the parts of an answer the code reads Independent review of #180, round 37: as written, the rule required a fake to reproduce output the code never reads (a removed container's name, full inspect JSON, build logs). Changed in both mirrored files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pin the probe cleanup's give-up start and the settle window's end Independent review of #180, round 37: giving up counted from the start of docker run, or a 15 s window, passed every test. The give-up test now needs the client deadline, the window and the cleanup time; the late-create test bounds the window from above. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * State the cleanup bound's pause and the unconfirmed volume removal Independent review of #180, round 38. The overshoot bound includes one 250 ms pause. The volume's removal is Docker's and is not confirmed; the maintainer accepted that as part of #181. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Seed one name per field in the script tests; assert the busy refusals Independent review of #180, round 42: with CASE and the NFD name seeded together, a script whose case check read the NFD name (or the reverse) passed everywhere; one seeded name per field catches it on Linux CI. The busy test now shows both refusals were answered before a later rm removed the container. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tighten the hung-daemon test so a floor near 4 s fails Independent review of #180, round 43: a 3.7 s per-call floor at the call site passed under the 4 s ceiling; the ceiling is now 2.5 s and the stated floor tolerance roughly 0.4-2.5 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: eastagiletracker <310448263+eastagiletracker@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7cd81a8 commit eafe2be

15 files changed

Lines changed: 625 additions & 35 deletions

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ A race regression must assert that the interleaving it names actually happened (
5959
- Each self-review and independent-agent review pass examines the full current PR diff against the base, including changed tests, configuration and documentation, and rereads every changed function in full. Do not review only the fixes or lines changed since the previous round. Code unchanged since the first commit of the PR still gets reviewed in every pass.
6060
- Treat every behavioural claim the change makes, in code comments, the PR body or docs (for example "pauses every 1,000 entries", "settles only after exit", "never throws", "bounded by N seconds"), as something to verify. Trace each claim through every path that can break it, including nested loops, callbacks, error paths and early returns, and give it a test that fails if the claim is false.
6161
- A test's setup must leave the state the production path would: if production never runs a step (such as a commit that refreshes Git's index), the test must not run it before the behaviour under test either.
62+
- A fake of an external tool (a stand-in `docker`, `git` or `gh` on `PATH`, or a stubbed client) must give, for every call the code reads, each part of the answer the code reads (exit status, stdout, stderr) as the real tool gives it, error cases included. Where it leaves out output the code does not read, say so beside the fake. Check those answers against the real tool and name the version checked beside the fake. Where versions the code may meet answer differently (for example `docker rm --force` on a missing name), the code must not depend on that answer: confirm the outcome with a call whose answer is stable, and give the fake each version's answer.
6263
- Independent-agent review is mandatory before every Copilot review request, including the first request and every re-request, for all changes, including documentation-only changes. The author's self-review is not a substitute. Use a reviewer that did not implement the change and does not share the author's working context: `/codex review`, a separate review agent, or `/code-review` at `high` effort or above.
6364
- Repeat independent-agent review -> address findings -> re-run relevant validation -> independent-agent review until a complete pass on the latest head reports no new issues and no earlier valid findings remain unresolved. A pass that found issues is not clean merely because they were fixed afterward; the fixes require another independent full-diff pass. Do not stop after a fixed number of rounds.
6465
- Only after that independent loop is clean may Copilot review be requested on the same reviewed and validated head. If Copilot finds issues, including summary-only concerns, address them and re-run relevant validation, then repeat the independent-agent review/fix loop until clean before requesting another Copilot review. Repeat this sequence until Copilot also completes a review with no new issues and no earlier valid findings remain unresolved. Never request Copilot in parallel with an unfinished independent-review cycle.

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ In QA mode, flag any code that doesn't match DESIGN.md.
2929
- Each self-review and independent-agent review pass examines the full current PR diff against the base, including changed tests, configuration and documentation, and rereads every changed function in full. Do not review only the fixes or lines changed since the previous round. Code unchanged since the first commit of the PR still gets reviewed in every pass.
3030
- Treat every behavioural claim the change makes, in code comments, the PR body or docs (for example "pauses every 1,000 entries", "settles only after exit", "never throws", "bounded by N seconds"), as something to verify. Trace each claim through every path that can break it, including nested loops, callbacks, error paths and early returns, and give it a test that fails if the claim is false.
3131
- A test's setup must leave the state the production path would: if production never runs a step (such as a commit that refreshes Git's index), the test must not run it before the behaviour under test either.
32+
- A fake of an external tool (a stand-in `docker`, `git` or `gh` on `PATH`, or a stubbed client) must give, for every call the code reads, each part of the answer the code reads (exit status, stdout, stderr) as the real tool gives it, error cases included. Where it leaves out output the code does not read, say so beside the fake. Check those answers against the real tool and name the version checked beside the fake. Where versions the code may meet answer differently (for example `docker rm --force` on a missing name), the code must not depend on that answer: confirm the outcome with a call whose answer is stable, and give the fake each version's answer.
3233
- Independent-agent review is mandatory before every Copilot review request, including the first request and every re-request, for all changes, including documentation-only changes. The author's self-review is not a substitute. Use a reviewer that did not implement the change and does not share the author's working context: `/codex review`, a separate review agent, or `/code-review` at `high` effort or above.
3334
- Repeat independent-agent review -> address findings -> re-run relevant validation -> independent-agent review until a complete pass on the latest head reports no new issues and no earlier valid findings remain unresolved. A pass that found issues is not clean merely because they were fixed afterward; the fixes require another independent full-diff pass. Do not stop after a fixed number of rounds.
3435
- Only after that independent loop is clean may Copilot review be requested on the same reviewed and validated head. If Copilot finds issues, including summary-only concerns, address them and re-run relevant validation, then repeat the independent-agent review/fix loop until clean before requesting another Copilot review. Repeat this sequence until Copilot also completes a review with no new issues and no earlier valid findings remain unresolved. Never request Copilot in parallel with an unfinished independent-review cycle.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ A configuration with a `github` block may also add a `runner` block. The runner
5151
- `diagnosticsCapBytes`: the size retention trims that directory back to. It is a target, not a hard limit: the file just saved is always kept, even when it alone passes the cap. The default is 256 MiB.
5252
- `limits`: task storage limits (`workBytes`, `workInodes`, `metadataBytes`, `metadataInodes`).
5353

54-
The runner needs Docker and `CLAUDE_CODE_OAUTH_TOKEN`. At start, before the server opens, codeboost recovers what an earlier run left and builds the agent image; a recovery it cannot finish safely stops startup with what to do. `POST /api/runner` with `start` or `resume` runs the plan; the review screen has no runner buttons yet. A task paused for an out-of-scope change stays paused until the plan is amended, every changed path is declared, the remaining items validate against the runner's audited tree, and a person sends `approve-continuation` with the current `expectedStateVersion`, `expectedReviewVersion`, and an action ID. Then `resume` starts at the first unfinished item. `GET /api/runner` reports the checkpoint and whether the continuation is approved. When a run has completed every item, codeboost pushes the task head to a `codeboost/…` branch with your `gh` credentials and opens a ready pull request into `baseBranch`; a task that needs a person gets a draft pull request with its problems. Before merge, `prepare-merge` refreshes the published PR's exact base/head, rebases onto a moved base, refreshes attribution and approvals, and runs allowed `cmd:` checks in a credential-free read-only container. The check result counts only for that exact head and command list. A publish that was refused (for example, the branch holds a commit codeboost did not make) is retried with the `publish` action, and `GET /api/runner` shows the last outcome under `publish`. When a task is cancelled, codeboost closes the open pull requests it opened for it (the branch is kept). A pull request a person moved is left open and reported, and `close-pull-requests` retries a close that failed. Merge PR then merges that pull request, so `github.pullRequest` can be left out; if it is set, it must name the task's pull request, or merging is blocked. Demos never publish.
54+
The runner needs Docker and `CLAUDE_CODE_OAUTH_TOKEN`. At start, before the server opens, codeboost recovers what an earlier run left and builds the agent image; a recovery it cannot finish safely stops startup with what to do. It then checks `pathIdentity` against the task storage the agent edits, a Linux tmpfs (`caseSensitive: true`, `unicodeNormalization: "none"`), and refuses to start when they differ. `POST /api/runner` with `start` or `resume` runs the plan; the review screen has no runner buttons yet. A task paused for an out-of-scope change stays paused until the plan is amended, every changed path is declared, the remaining items validate against the runner's audited tree, and a person sends `approve-continuation` with the current `expectedStateVersion`, `expectedReviewVersion`, and an action ID. Then `resume` starts at the first unfinished item. `GET /api/runner` reports the checkpoint and whether the continuation is approved. When a run has completed every item, codeboost pushes the task head to a `codeboost/…` branch with your `gh` credentials and opens a ready pull request into `baseBranch`; a task that needs a person gets a draft pull request with its problems. Before merge, `prepare-merge` refreshes the published PR's exact base/head, rebases onto a moved base, refreshes attribution and approvals, and runs allowed `cmd:` checks in a credential-free read-only container. The check result counts only for that exact head and command list. A publish that was refused (for example, the branch holds a commit codeboost did not make) is retried with the `publish` action, and `GET /api/runner` shows the last outcome under `publish`. When a task is cancelled, codeboost closes the open pull requests it opened for it (the branch is kept). A pull request a person moved is left open and reported, and `close-pull-requests` retries a close that failed. Merge PR then merges that pull request, so `github.pullRequest` can be left out; if it is set, it must name the task's pull request, or merging is blocked. Demos never publish.
5555

5656
## Library
5757

‎agents/container/storage.ts‎

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import { randomUUID } from 'node:crypto';
33
import { lstatSync } from 'node:fs';
44
import { join } from 'node:path';
55
import type { TaskClone } from '../contract.ts';
6+
import type { PathIdentity } from '../../core/path-identity.ts';
67
import { assertTaskClone } from '../../git/clone.ts';
78
import { assertBuiltAgentImage } from './image.ts';
89
import { createOutcomeUnknown, DOCKER_ID } from '../client-outcome.ts';
9-
import { DockerError, pause, runDocker, type DockerOutcome } from '../docker.ts';
10+
import { DockerError, NOT_STARTED, pause, runDocker, type DockerOutcome } from '../docker.ts';
1011
import { MAXIMUM_TIMER_MS, runInProcessGroup, type ProcessGroup } from '../process-group.ts';
1112
import { runTrackedProcess, type ProcessGroupLifecycle } from '../tracked-docker.ts';
1213
import { MAXIMUM_DECLARED_LINKS, MAXIMUM_NAME_BYTES, TREE_SCRIPT } from './tree-script.ts';
@@ -265,6 +266,10 @@ function* allocationObjects(allocationId: string, expected: number, remaining: (
265266
}
266267
}
267268

269+
/** The local-driver options of every task storage volume: a size- and inode-limited tmpfs owned by the storage user. */
270+
const TASK_VOLUME_OPTIONS = (bytes: number, inodes: number): readonly string[] =>
271+
['type=tmpfs', 'device=tmpfs', `o=size=${bytes},nr_inodes=${inodes},uid=10001,gid=10001,mode=0755,nosuid,nodev`];
272+
268273
/** The allocation, as a sequence of Docker calls. Both variants run these same steps; only how each call runs differs. */
269274
function* allocation(clone: TaskClone, limits: TaskStorageLimits, imageId: string, owner: ResourceOwner,
270275
timeoutMs: number): Steps<TaskFilesystems> {
@@ -306,8 +311,7 @@ function* allocation(clone: TaskClone, limits: TaskStorageLimits, imageId: strin
306311
try {
307312
for (const [kind, name, bytes, inodes] of [['work', workVolume, limits.workBytes, limits.workInodes],
308313
['metadata', metadataVolume, limits.metadataBytes, limits.metadataInodes]] as const) {
309-
yield* allocate(name, ['volume', 'create', '--driver', 'local', '--opt', 'type=tmpfs', '--opt', 'device=tmpfs',
310-
'--opt', `o=size=${bytes},nr_inodes=${inodes},uid=10001,gid=10001,mode=0755,nosuid,nodev`,
314+
yield* allocate(name, ['volume', 'create', '--driver', 'local', ...TASK_VOLUME_OPTIONS(bytes, inodes).flatMap(option => ['--opt', option]),
311315
'--label', `io.codeboost.task-storage=${kind}`, ...labels, name]);
312316
// The check above and this create are not atomic across processes. Once our first object exists, it must be
313317
// the only one carrying the ID: of two racing allocations, the later check sees both and backs out.
@@ -400,6 +404,90 @@ export async function prepareTaskFilesystemsAsync(clone: TaskClone, limits: Task
400404
}
401405
}
402406

407+
// In the directory given as $1, writes `case` and NFC `caf\u00e9`, then asks whether `CASE` and NFD `cafe\u0301` name
408+
// the same entries. printf writes the UTF-8 bytes, so the script itself stays ASCII. Exported so a test can run it on a
409+
// host folder whose answer is known.
410+
export const PATH_IDENTITY_SCRIPT = ['set -eu', 'cd "$1"', ': > case', ': > "$(printf \'caf\\303\\251\')"',
411+
'if [ -e CASE ]; then c=false; else c=true; fi', 'if [ -e "$(printf \'cafe\\314\\201\')" ]; then u=NFC; else u=none; fi',
412+
'printf \'codeboost-path-identity %s %s\\n\' "$c" "$u"'].join('; ');
413+
/** The probe's `/work`: an anonymous local volume with task storage's tmpfs options, as one `--mount` value. */
414+
export const PROBE_VOLUME_MOUNT = ['type=volume', 'target=/work', 'volume-driver=local',
415+
...TASK_VOLUME_OPTIONS(1024 * 1024, 64).map(option => `volume-opt=${option}`)]
416+
// A field with a comma in it is quoted, as Docker's CSV parser reads --mount.
417+
.map(field => field.includes(',') ? `"${field}"` : field).join(',');
418+
/**
419+
* The path identity of task storage (#162): how the filesystem the agent edits, where the runner inspects task changes, tells two
420+
* spellings of a path apart. It is measured, not assumed, on a volume made with the same options as task storage, in a
421+
* container hardened like the keeper. The volume is anonymous and the container `--rm`, so once the container has
422+
* started the daemon removes both when it exits, even if this process does not live to see it. A client stopped before
423+
* then (its deadline) may leave the container created but never started, which `--rm` does not remove, so a failed
424+
* probe removes its container, and that volume, by the unique name it gave it. Not covered: a create that lands after
425+
* the settle window, or a runner that dies before the removal; the probe carries no owner labels, so recovery does not
426+
* find that container (`docker ps -a --filter name=codeboost-path-identity-` does). Nor is the volume's removal
427+
* confirmed: `--rm` and `rm --volumes` remove it, but Docker only logs a failure to. A recovery sweep is #181.
428+
*/
429+
export async function probeTaskStoragePathIdentity(imageId: string, timeoutMs = 60_000, cleanupMs = PROBE_CLEANUP_MS): Promise<PathIdentity> {
430+
if (!/^sha256:[0-9a-f]{64}$/.test(imageId)) throw new Error('The path identity probe requires the immutable built image ID.');
431+
assertBuiltAgentImage(imageId);
432+
const name = `codeboost-path-identity-${randomUUID()}`;
433+
const args = ['run', '--rm', '--name', name, '--read-only', '--user', '10001:10001', '--network=none', '--cap-drop=ALL',
434+
'--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--runtime=runc', '--pids-limit=32', '--memory=128m',
435+
'--cpus=.25', '--mount', PROBE_VOLUME_MOUNT, '--entrypoint', 'sh', imageId, '-c', PATH_IDENTITY_SCRIPT, 'probe', '/work'];
436+
const outcome = await runInProcessGroup('docker', args, { env: dockerEnvironment(), timeoutMs });
437+
if (outcome.status !== 0) {
438+
const failure = new DockerError(args, outcome);
439+
// A client that never started sent nothing to the daemon, so there is nothing to remove.
440+
const error = outcome.error as NodeJS.ErrnoException | undefined;
441+
if (outcome.status === null && (error?.code === NOT_STARTED || error?.syscall?.startsWith('spawn'))) throw failure;
442+
// Only a refusal from the daemon itself settles at once. Any other outcome (a killed client, a connection lost after
443+
// the create was sent, exit 125 from the client) leaves the daemon's outcome unknown: a create may still land, so
444+
// only absence after the settle window counts.
445+
const refused = outcome.status !== null && /Error response from daemon/.test(outcome.stderr);
446+
try { await removeProbeContainer(name, refused ? 0 : PROBE_SETTLE_MS, cleanupMs); }
447+
catch (error) {
448+
throw new Error(`${failure.message} The probe container ${name} may remain; remove it with docker rm --force --volumes ${name}.`,
449+
{ cause: new AggregateError([failure, error]) });
450+
}
451+
throw failure;
452+
}
453+
const answer = /^codeboost-path-identity (true|false) (none|NFC)$/.exec(outcome.stdout.trim());
454+
if (!answer) throw new Error('The path identity probe did not report an identity.');
455+
return { caseSensitive: answer[1] === 'true', unicodeNormalization: answer[2] as PathIdentity['unicodeNormalization'] };
456+
}
457+
458+
/** One cleanup call's deadline: the time left before giving up, capped at 10 s and at least 1 s. */
459+
export const probeCleanupCallMs = (leftMs: number): number => Math.min(10_000, Math.max(1_000, Math.ceil(leftMs)));
460+
/** How long a failed probe's cleanup waits for an outcome Docker did not report (a killed client's create may land). */
461+
export const PROBE_SETTLE_MS = CREATE_SETTLE_MS;
462+
/** How long a failed probe's cleanup keeps asking after its settle window before it gives up. */
463+
export const PROBE_CLEANUP_MS = 30_000;
464+
/**
465+
* Remove a failed probe's container and its anonymous volume by name. `rm --force` exits 0 for a name that does not
466+
* exist, so only an inspect that reports the name absent, after the settle window, proves the probe is gone.
467+
*/
468+
async function removeProbeContainer(name: string, settleMs: number, cleanupMs: number): Promise<void> {
469+
const settleBy = performance.now() + settleMs, giveUpAt = settleBy + cleanupMs;
470+
let last: DockerOutcome | undefined;
471+
// Each call gets the time left before giving up, capped at 10 s and at least 1 s (so the last ask can still be
472+
// answered), and after the first call none starts once that time has passed: a daemon that stops answering
473+
// stretches the bound by at most one call's floor, its stop grace and one 250 ms pause.
474+
const ask = async (args: readonly string[]) => {
475+
if (last && performance.now() >= giveUpAt)
476+
throw new Error(`Docker did not confirm that ${name} is gone.`, { cause: last });
477+
return last = await runInProcessGroup('docker', args, { env: dockerEnvironment(),
478+
timeoutMs: probeCleanupCallMs(giveUpAt - performance.now()) });
479+
};
480+
for (;;) {
481+
// Whatever rm answered (older CLIs exit non-zero for a missing name, newer ones 0), the inspect decides.
482+
await ask(['rm', '--force', '--volumes', name]);
483+
// The window is judged by when the inspect was asked: a slow answer to an ask made inside it proves nothing.
484+
const asked = performance.now();
485+
if (absent(await ask(['container', 'inspect', name])) && asked >= settleBy) return;
486+
// Still there (a removal already in progress), not yet landed, or Docker did not answer: ask again.
487+
await pause(250);
488+
}
489+
}
490+
403491
/**
404492
* Task storage that `recoverLeftovers` found after a restart, when the `TaskFilesystems` value that allocated it is
405493
* gone. A crash during allocation can leave any subset of the parts, so each is optional. D issues a handle only after

0 commit comments

Comments
 (0)