Skip to content

fix(capture): an undelivered capture leaves no artifact behind (#737, slice 1) - #907

Merged
cdeust merged 5 commits into
mainfrom
fix/737-no-orphan-capture-artifact
Nov 6, 2026
Merged

cdeust merged 5 commits into
mainfrom
fix/737-no-orphan-capture-artifact

Conversation

@cdeust

@cdeust cdeust commented Nov 6, 2026

Copy link
Copy Markdown
Owner

Summary

First slice of #737 (proposal 2, "no orphan artifacts"). post_tool_capture writes the full-output artifact before it hands the memory to capture_dispatch.dispatch. When dispatch declines the payload (it returns False after an OSError, ValueError or CaptureTransportError) or raises, the artifact stayed under ~/.claude/methodology/artifacts/ with no memory pointing at it: recall cannot surface it and forget cannot reach it.

Root cause: the hook did not know whether the memory was handed over, and the artifact store did not say whether the file was new. Fix:

  • artifact_store.write_artifact publishes the file exclusively (gated_files.write_text(..., exclusive=True)) and returns (path, created). store_artifact keeps its signature and delegates to it.
  • _store_memory returns the result of dispatch. When the memory is not handed over (declined or raised), the hook removes the artifact only if this capture created it. A file that already existed is kept, because content addressing may share it with another memory (ADR-0503).

Refs #737 (slice 1 of several; the remaining slices are listed in the issue comment).

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Test plan

  • New: test_declined_dispatch_removes_the_artifact_it_created, test_failing_dispatch_removes_the_artifact_it_created, test_declined_dispatch_keeps_an_artifact_it_did_not_create (tests_py/hooks/test_post_tool_capture.py); test_write_artifact_reports_whether_it_created_the_file, test_discard_created_artifact_removes_it_and_tolerates_absence (tests_py/infrastructure/test_artifact_store.py). Mutations caught: dropping the cleanup, cleaning up on success, or deleting a pre-existing file.
  • test_artifact_store_failure_falls_back_to_full_content now patches write_artifact, the function the hook calls.
  • Local run (SQLite, no PostgreSQL, torch not installed because its download host is blocked here): pytest tests_py/hooks/ tests_py/architecture tests_py/infrastructure/test_artifact_store.py test_private_storage_modes.py test_redaction_gate_storage.py tests_py/handlers/test_forget.py test_redaction_once_per_path.py test_redaction_gate_entry_points.py test_ble001_sweep_handlers.py: 976 passed, 40 skipped, 2 failed. The 2 failures (test_entry_auto_dispatch.py::test_engine_selected_auto_backend_reaches_hook[False-*]) fail identically on origin/main without this change in this environment.
  • ruff check and ruff format --check on the 4 changed files: clean. python scripts/check_craftsmanship.py: OK.

Audit notes

  • Not verified: PostgreSQL-gated tests, the mutation gate, pyright, and the full suite (CI will run them).
  • Known limit, kept for a later slice: a memory handed over to the worker but later refused by the write gate still leaves its artifact. Covering it needs the sweep for unreferenced artifacts (proposal 2b). A rare race also remains: if two hook processes capture identical output at the same moment and the creator's dispatch fails while the other's succeeds, the shared file is removed.

Coding-standards compliance

  • §2.2 Layer dependency direction preserved (hook → infrastructure, lazily imported as before).
  • §4.2 No function > 40 lines: process_event was split (_capture).
  • post_tool_capture.py was already over the 300-line cap (baselined); it grows by about 30 lines.

Breaking changes

None. store_artifact keeps its signature and behaviour.


Generated by Claude Code

… slice 1)

post_tool_capture wrote the full-output artifact before handing the memory
to dispatch. When dispatch declined the payload (it returns False on
OSError/ValueError/CaptureTransportError) or raised, the artifact stayed on
disk with no memory pointing at it: recall cannot surface it and forget
cannot reach it.

write_artifact now publishes the file exclusively and reports whether this
call created it; the hook removes only an artifact it created when the
memory was not handed over. A file that already existed is kept, since
content addressing may share it with another memory (ADR-0503).

Refs #737

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018kyZYtm1c2azpFFMFMaRsy
…stats run start

The `unit` check of run 42187994087 failed in the mutation gate (ADR-1122):

- post_tool_capture.py: "failed to collect stats. runner returned 1".
  test_cascade_counter_processes starts a `python -S` child from the
  mutants/ copy, whose mutated modules import mutmut at load time, which a
  -S child cannot reach. As #889 did for the hook subprocesses, the child
  drops MUTANT_UNDER_TEST and runs the original checkout; the in-process
  tests judge the mutants.
- artifact_store.py: 6 survivors in write_artifact. Tests now cover the
  exclusive-write race (another writer publishes first) and the UTC month
  of the shard.

The changed functions of post_tool_capture then had 32 survivors, most in
lines the split of process_event moved: the new wiring tests pin what the
event hands on (tool name, input, cwd, tags, pointer) and each log line.
Five mutants that cannot change behaviour are registered as equivalents.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018kyZYtm1c2azpFFMFMaRsy

cdeust commented Nov 6, 2026

Copy link
Copy Markdown
Owner Author

CI autopilot : correction du check unit (gate de mutation, run 42187994087), commit e89a585.

Les tests passaient (11 851). C'est le gate de mutation (ADR-1122) qui a échoué, sur les deux fichiers du diff.

  • post_tool_capture.py : failed to collect stats. test_cascade_counter_processes lance un enfant python -S depuis la copie mutants/. Ses modules mutés importent mutmut au chargement, ce qu'un enfant -S ne peut pas faire. Comme dans test(hooks): hook subprocesses do not inherit the parent's MUTANT_UNDER_TEST (ADR-1122) #889, l'enfant retire MUTANT_UNDER_TEST et exécute le checkout d'origine ; les tests en processus jugent les mutants.
  • artifact_store.py : 6 survivants dans write_artifact. Ce sont la course de l'écriture exclusive (exclusive=True → False/None, et le retour de FileExistsError) et le mois UTC du shard. Deux tests les tuent maintenant.
  • Une fois les stats rétablies, les 5 fonctions modifiées du hook avaient 32 survivants, surtout dans les lignes déplacées par le découpage de process_event. Un nouveau fichier, tests_py/hooks/test_post_tool_capture_wiring.py, fixe les valeurs transmises (nom d'outil, entrée, cwd, tags, pointeur) et chaque ligne de log. 5 mutants sans effet observable sont enregistrés comme équivalents dans memory/mutation-equivalents.json, chacun avec sa justification.

Reproduit puis vérifié localement : GITHUB_BASE_REF=main python scripts/ci_mutation_diff.py donnait 2 file(s), 2 failed avant le correctif et donne 2 file(s), 0 failed, 0 untested après. pytest tests_py/hooks tests_py/infrastructure/test_artifact_store.py tests_py/scripts : 2775 passés, 47 ignorés. Ruff et le gate craftsmanship sont propres.

Non vérifié localement : les tests qui exigent PostgreSQL, qui tournent dans le gate CI et pas ici. Le CI de e89a585 est en cours, aucun check n'est encore vert.


Generated by Claude Code

@cdeust
cdeust marked this pull request as ready for review November 6, 2026 16:22
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-06T17:07:02.949050Z dbb60b8 New commits
🔒 Security Review ✅ Completed 2026-11-06T17:09:08.683720Z dbb60b8 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Claude finished @cdeust's task in 1m 57s —— View job


PR #907 review (1 of at most 3)

No Blockers. The race where two captures of the same output run at once is already listed in the PR body as a known limit, so it is a Note, not a Blocker. Two smaller Notes cover a log assertion that is too loose and the narrow except OSError. I did not change any code.

Verdict: approve

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e89a58554d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mcp_server/hooks/post_tool_capture.py Outdated
Comment on lines +412 to +413
if not delivered:
_discard_undelivered_artifact(created)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve artifacts when dispatch has already durably spooled the payload

On platforms using the spool fallback, capture_dispatch._spool() writes the payload before calling spawn_drainer(). If spawning raises OSError, dispatch() returns False even though the complete payload remains pending for a later drainer. This cleanup then deletes its artifact, so the recovered memory contains a broken pointer and the full output is lost. Executing the reviewed versions with a failing drainer spawn reproduced one pending payload and zero artifacts. Distinguish failure before durable handoff from failure to launch the drainer, and retain the artifact after the payload has been spooled.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in e4245d4. _spool writes the payload before it starts the drainer, so a spawn failure made dispatch return False for a capture that is stored later. The same was true of a lost acknowledgement on the worker path.

dispatch now returns a Handoff: ADMITTED, NOT_HANDED_OVER or UNCERTAIN. capture_transport.send raises CaptureHandoffUncertainError once sending may have started; a refusal stays certain. A drainer that does not start after the payload is spooled is reported as capture_drainer_spawn_failed and the capture counts as admitted. The hook removes the artifact only on NOT_HANDED_OVER, and keeps it when _store_memory raises. Your scenario is now test_a_spooled_capture_whose_drainer_fails_keeps_its_artifact: one pending payload, one artifact.


Generated by Claude Code

Pre: ``path`` was returned with ``created`` True by this process, and the
memory that would have referred to it was never handed over (issue #737).
An already absent file is not an error."""
path.unlink(missing_ok=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Coordinate cleanup with concurrent users of the same artifact

When two overlapping captures have identical output, the first creates the artifact and the second reuses it with created=False. If the second successfully dispatches before the first dispatch fails, this unconditional unlink removes the successful capture's full output. Exclusive publication prevents two creators but does not establish exclusive ownership for deletion. Interleaving these two captures against the reviewed code reproduced a delivered memory with no remaining artifact. Coordinate publication and cleanup with other users of the content-addressed path, or defer removal until outstanding and persisted references can be checked.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not fixed in this PR. This is the race the PR body lists under "Known limit", and the Claude review raised it as a Note. e4245d4 states it in the write_artifact docstring: created does not make a removal safe on its own.

Closing it needs the referrer count, which only the store can give (artifact_gc.count_artifact_references). The hook does not open the store: that import is what its latency boundary avoids. The sweep of unreferenced artifacts, the next slice listed on #737, removes files only after checking that no memory refers to them, and is meant to replace removal at capture. The race needs two captures of byte-identical large output in the same month, overlapping, with the creator's dispatch failing. I leave the call to the owner: keep this slice with the limit stated, or hold it for the sweep.


Generated by Claude Code

@claude

claude Bot commented Nov 6, 2026

Copy link
Copy Markdown

Review 1/3: PR #907 (e89a585)

Five reviewers ran (code quality, performance, test coverage, documentation, security). None found a Blocker.

What I checked

  • gated_files.write_text(..., exclusive=True) publishes the file with os.link, which raises FileExistsError when the target exists. created is True for exactly one writer, and the file keeps its 0600 mode under the private roots (ADR-1112).
  • discard_created_artifact only receives the path write_artifact built from ARTIFACTS_DIR, the month shard and the hash. Nothing from the event reaches it, so the unlink cannot be aimed at another file.
  • All 5 new entries in memory/mutation-equivalents.json match the code: _ = cwd, _should_capture never reads tool_input, None and False are both falsy, codec names are normalised, and "XXXX" stays under the 50-character floor.
  • The hook gains no import on its hot path. The only extra I/O is one unlink, and only on a failed dispatch. Layer direction and the 40-line limit hold.

Notes, no action before merge

  • mcp_server/infrastructure/artifact_store.py:44-47: the docstring implies created makes removal safe. Capture B can take (path, False) and deliver a pointer while capture A's dispatch is still pending; A then unlinks the file B's memory points to. The PR lists this race. Worth stating in the docstring until the sweep (2b) or delivery-before-artifact lands.
  • tests_py/hooks/test_post_tool_capture_wiring.py:122-123: " removed" also matches "could not be removed". Assert f"artifact {path} removed" so the log check tells the two outcomes apart.
  • mcp_server/hooks/post_tool_capture.py:184: _discard_undelivered_artifact catches only OSError. That is fine for unlink, but an unexpected exception from the lazy import would escape the hook after the capture was already lost.

Verdict: approve

Review of #907 (Codex, P2): dispatch() returned False for any failure, and
the hook took False to mean "not handed over". On the spool path the payload
is written before the drainer starts, so a drainer spawn failure returned
False while the spooled memory, with its artifact pointer, is stored later:
the hook deleted the artifact and left that memory pointing nowhere. A lost
acknowledgement on the worker path was the same case.

dispatch now returns a Handoff: ADMITTED, NOT_HANDED_OVER or UNCERTAIN.
capture_transport.send raises CaptureHandoffUncertainError for a failure
after sending may have started; a refusal stays certain. A drainer that does
not start after the payload is spooled is reported as
capture_drainer_spawn_failed, not as a skipped capture. The hook removes the
artifact only on NOT_HANDED_OVER, and keeps it when _store_memory raises.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018kyZYtm1c2azpFFMFMaRsy
@claude

claude Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Claude finished @cdeust's task in 2m 7s —— View job


PR #907 review (2 of at most 3)

No Blockers. Codex's P2 is fixed: when the payload is in the spool but its drainer fails to start, dispatch returns ADMITTED and the artifact is kept. When the send or its ack fails, the result is UNCERTAIN and the artifact is also kept. The review lists three Notes:

  • After the spool write, only OSError is caught.
  • UNCERTAIN is still counted as capture_skipped.
  • Some test stubs make _store_memory return None, so those tests also run the removal path.

I did not change any code, and I did not run the test suite.

Verdict: approve

@claude

claude Bot commented Nov 6, 2026

Copy link
Copy Markdown

Review 2/3: PR #907 (e4245d4)

Review 1 (e89a585) had no Blockers. This review covers only e4245d4. Five reviewers ran (code quality, performance, test coverage, documentation, security). None found a Blocker.

What I checked

  • Codex P2 is fixed. If the drainer fails to start after the payload is spooled, _spool now reports capture_drainer_spawn_failed and dispatch returns ADMITTED. The spool write publishes with os.replace as its last step (shared/gated_files.py:66), so a failed spool write leaves no file. In that case NOT_HANDED_OVER is correct.
  • send classifies failures correctly. A deadline already past before sendall raises TimeoutError outside the try: nothing was sent, so the result is NOT_HANDED_OVER. A failure in sendall or while reading the ack is CaptureHandoffUncertainError, so the result is UNCERTAIN and the artifact is kept. An explicit refusal stays a plain CaptureTransportError. The new class subclasses CaptureTransportError, so dispatch catches the same exceptions as before.
  • When _store_memory raises, the artifact is kept. No caller still reads dispatch as a bool. The only one, _store_memory, compares with is.
  • The hot path is unchanged. enum is from the standard library. Error messages carry no payload bytes. Deletion now happens in fewer cases than before.

Notes, no action before merge

  • mcp_server/hooks/capture_dispatch.py:101-103: only OSError is caught after capture_spool.write. A ValueError from Popen would reach dispatch, which returns NOT_HANDED_OVER and removes the artifact while the payload is already in the spool. This is unlikely with fixed arguments.
  • mcp_server/hooks/capture_dispatch.py:149-154: UNCERTAIN is still reported as capture_skipped with skipped=True, and the report_failure docstring calls that "a declined capture". The skip count can include captures the worker kept.
  • Some _store_memory stubs return None, which the hook now reads as "not handed over", so those tests also run the removal path: test_redaction_once_per_path.py:66, test_redaction_gate_entry_points.py:129, test_cascade_counter_processes.py:31, test_capture_mode_processes.py:42.

Verdict: approve

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e4245d4369

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +153 to +155
if isinstance(exc, CaptureHandoffUncertainError):
return Handoff.UNCERTAIN
return Handoff.NOT_HANDED_OVER

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make worker refusals prove the payload was not queued

If the worker queues a capture but then fails while preparing or sending its acknowledgement, CaptureServer._receive() catches that failure and attempts to send a refusal without removing the queued payload (capture_server.py:56–67). If that refusal reaches the hook, this branch reports NOT_HANDED_OVER, causing _capture() to delete the artifact even though the worker can still persist its pointer. Exercising the server with a deadline failure immediately after enqueue reproduced a plain CaptureTransportError on the client with the payload still pending. Ensure the server cannot send a refusal after enqueue, or treat that response as uncertain until the protocol guarantees it means no admission.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in a52deec. CaptureServer._receive sends a refusal from its except even when the failure (deadline or acknowledgement) comes after self.pending.put, so a refusal does not prove the payload was dropped. send now raises CaptureHandoffUncertainError for a refusal as well. dispatch returns UNCERTAIN and the hook keeps the artifact. On the worker path, only a failure before the first byte (unreachable worker, deadline already past, oversize frame, invalid payload) is NOT_HANDED_OVER now.

The server-side cause, a refusal sent after enqueue that is also counted as capture_skipped, predates this PR and is left out of it. A follow-up can make the server refuse only before put, so that a refusal becomes a proof again.


Generated by Claude Code

@claude

claude Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Claude finished @cdeust's task in 2m 17s —— View job


PR #907 review (3 of at most 3)

No Blockers. a52deec answers Codex's P2. The worker can queue a payload, fail its acknowledgement, and still send a refusal, so a worker refusal is now treated as UNCERTAIN and the hook keeps the artifact. When the deadline has already passed before anything is sent, the result is still NOT_HANDED_OVER. No test expects the old behaviour.

The review lists three Notes:

  • The dispatch docstring and the capture-worker design doc still describe a refusal as certain.
  • Refusals where the payload was never queued now also keep their artifact. A flag on the server side could make a refusal certain again.
  • No test covers the server side of the queue-then-refuse race.

Accepted: the whole PR, all 4 commits. No file or commit carries an open Blocker.

I did not run the tests or ruff: installing the environment needs approval in this job, so CI will run them. I did not change any code.

Verdict: approve

@claude

claude Bot commented Nov 6, 2026

Copy link
Copy Markdown

Review 3/3: PR #907 (a52deec)

Reviews 1 (e89a585) and 2 (e4245d4) had no Blockers. This review covers only a52deec, the one commit after e4245d4. It answers Codex's P2: CaptureServer._receive can queue the payload, fail its acknowledgement, and then send a refusal. Five reviewers ran (code quality, performance, test coverage, documentation, security). None found a Blocker.

What I checked

  • The new classification is correct. capture_server.py:56-67 can refuse after pending.put succeeded, so a refusal does not prove the capture was dropped. send now raises CaptureHandoffUncertainError for a refusal (capture_transport.py:99-100), dispatch returns UNCERTAIN, and the hook keeps the artifact.
  • "Nothing was sent" is still certain. If the deadline has already passed, remaining() raises TimeoutError at capture_transport.py:91, outside the try. That reaches dispatch as an OSError, so the result is NOT_HANDED_OVER.
  • CI: no test expects a worker refusal to be certain. test_negative_ack_and_eof_do_not_trigger_replay still passes because the new class subclasses CaptureTransportError. The NOT_HANDED_OVER cases in test_capture_dispatch.py and test_capture_unsupported_platform.py come from spawn or validation failures and never reach send. The new assertRaisesRegex(CaptureHandoffUncertainError, "^...$") kills the class-swap and message mutants of send.
  • Security and performance: no hot-path cost, and error messages carry no payload bytes. Deletion now happens in fewer cases.
  • Not run: I could not run the tests or ruff locally, because installing the environment needs approval in this job. CI will run them.

Notes, no action before merge

  • mcp_server/hooks/capture_dispatch.py:137-138: the dispatch docstring still lists "refused" under NOT_HANDED_OVER. A worker refusal is now UNCERTAIN; only a refusal by validation stays certain. capture_transport.py:31-34 (class docstring) and docs/provenance/capture-worker-design.md:40,78 also still describe a refusal as proof the capture was not admitted.
  • Every worker refusal now keeps its artifact, including refusals where the payload was never queued (queue.Full on the one-slot queue, failed authenticate, a bad frame). Those files have no memory pointing at them, and the sweep that would remove them (2b) is not there yet. A follow-up could restore a certain refusal on the server side: set a flag after pending.put succeeds and close without acknowledge(False) once it is set.
  • No test covers the server side of this race: put succeeds, acknowledge(True) raises, the client receives b"\x00" and the payload is still in server.pending. test_capture_server_failure.py only covers a failure inside receive.

Accepted: the whole PR, all 4 commits (60d8b7b, e89a585, e4245d4, a52deec). No file or commit carries an open Blocker. The Notes above can go to the next #737 slice.

Verdict: approve

…ture-artifact

# Conflicts:
#	memory/mutation-equivalents.json
@cdeust
cdeust merged commit 9156d8f into main Nov 6, 2026
18 checks passed
@cdeust
cdeust deleted the fix/737-no-orphan-capture-artifact branch November 6, 2026 18:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants