Private
Public Access
0
0

conductor(track): nagent_review_v3.1 thicken §7 Robustness cluster

This commit is contained in:
2026-06-20 11:25:29 -04:00
parent a406d2902c
commit b9b3100662
@@ -1296,41 +1296,131 @@ The shape tag map: `[I]` for inspectable invariants (the two-reason framing, the
**Source:** nagent `065168c`, `6b762da`, `12c35b7`, `49e07f3` (`bin/helpers/nagent_tags.py:43-50` + `:106-110` + `:136-246` + `:248-265`, `bin/nagent:1911-1940` + `:682-714` + `:1319-1381` + `:1387-1394` + `:1534-1551` + `:1834-1840` + `:224-240`, `tests/test_nagent.py:548-590` + `:679-714` + `:1911-1940`, `tests/test_nagent_safety.py:367-400`, `tests/test_nagent_tags.py:170-182`).
**One-liner:** Four hardening commits — `scan_tag_document` extracts valid tags and ignores the rest (with EOF-capture for trailing unclosed responses); `dedupe_nodes` collapses exact-duplicate action tags within a turn; `<nagent-shell>`-output-before-`<nagent-next-input>` ordering is pinned by a regression test; `<nagent-write>` is scoped to a per-conversation scratch dir so concurrent instances never collide.
**Pattern(s) vs v2.3:** UPDATE. v2.3 Pattern 5 ("the loop") had the basic loop; v3 hardens it against four specific failure modes. The hardening is incremental — each commit is a discrete change with its own test. EXTENDS v2.3 Pattern 4 ("visible output protocol") with a lenient counterpart (`scan_tag_document`) that tolerates non-protocol output while still propagating known-tag malformation as a hard error. NEW: per-conversation scratch directory as a side artifact of the loop.
**Manual Slop implications:** Manual Slop's `send_result()` (per `docs/guide_ai_client.md`) and `dispatch_inference` should adopt the same hardening. The lenient parser discipline ("scan, extract, ignore the rest, but propagate known-tag malformation as hard error") maps to Manual Slop's tag protocol; the per-turn status block (`<nagent-turn-status>` with UTC + cumulative tokens) is a model Manual Slop's discussion history could adopt — the user can already see token totals but not in a structured per-turn way. The per-conversation scratch dir (keyed by conversation name) maps to Manual Slop's `tests/artifacts/` directory (gitignored, per-conversation).
**Decision candidate:** NEW Candidate 23 (MEDIUM). "Per-conversation scratch directory for Manual Slop dispatch_inference" — adopt the `conversation_scratch_dir(conversation_name)` pattern; pre-create on session start; thread through the `<nagent-write>`-equivalent. See `decisions.md` Candidate 23.
**Cross-refs:** §3 Hooks (per-turn `<nagent-turn-status>` and per-turn hooks are both per-turn observability surfaces); §2 Conversation safety net (the `<nagent-turn-status>` block is what the safety net reads to compute the checkpoint delta).
**Source-read citations:**
- `bin/helpers/nagent_tags.py:43-50``parse_element(..., capture_to_eof_if_unclosed=True)` for trailing unclosed `<nagent-response>` (065168c)
- `bin/helpers/nagent_tags.py:106-110` — EOF-capture behavior: a missing close tag captures to `len(text)` instead of raising (065168c)
- `bin/helpers/nagent_tags.py:136-246``IgnoredSpan` + `_read_tag_name` + `scan_tag_document` (lenient parser) + `serialize_node(s)` (re-serialize well-formed) (065168c)
- `bin/helpers/nagent_tags.py:248-265``dedupe_nodes` (6b762da)
- `bin/nagent:1911-1940``cleaned_response_text` returns `(text, duplicates_removed)`; system note when collapsed (6b762da)
- `bin/nagent:682-714``test_shell_output_precedes_next_input_in_either_order` regression test (12c35b7)
- `bin/nagent:1319-1331``conversation_scratch_dir(conversation_name)` returns `$TMPDIR/nagent-{name}/` (49e07f3)
- `bin/nagent:1334-1341``is_within(path, directory)` (replaces `is_tmp_path`) (49e07f3)
- `bin/nagent:1344-1381``validate_write_path(..., scratch_dir=...)` — only path-inside-scratch-dir is allowed; file-edit mode unchanged (49e07f3)
- `bin/nagent:1387-1394``execute_write(..., scratch_dir=...)` threaded through (49e07f3)
- `bin/nagent:1534-1551``process_tags` computes scratch_dir per call (49e07f3)
- `bin/nagent:1834-1840``run_agent_loop` pre-creates scratch_dir before the first turn (49e07f3)
- `bin/nagent:224-240``file_edit_rules(file_edit_path, scratch_dir)` — context mentions the concrete scratch path (49e07f3)
- `tests/test_nagent.py:548-590` — 3 cleaned/duplicate tests (6b762da)
- `tests/test_nagent.py:679-714``test_shell_output_precedes_next_input_in_either_order` (12c35b7)
- `tests/test_nagent_safety.py:367-400``test_duplicate_tags_collapsed_in_conversation_without_sidecar` (6b762da)
- `tests/test_nagent_tags.py:170-182``DedupeNodesTests` (6b762da)
**Honest gaps in this cluster:**
- `dedupe_nodes` only catches EXACT duplicates (same name, self_closing flag, attrs, content). A near-duplicate (same command with whitespace differences, same shell with env vars) is not collapsed. Whether this matters in practice is unverified.
- The lenient parser's "ignore the rest" behavior could mask real protocol bugs — the model might be silently emitting junk while the conversation proceeds. The `ignored_correction` system note at `bin/nagent:1930` is the recovery path; it relies on the model reading the note. A future track could add a hard error when the ignored-to-extracted ratio exceeds a threshold.
- The scratch dir at `bin/nagent:1319-1331` is keyed on conversation name; if a user renames a conversation file mid-run, the scratch dir becomes orphaned and a new one is created. Unverified whether this is the intended behavior.
- The `<nagent-turn-status>` block at the end of every turn (per `bin/nagent:1940`) is observability but not user-facing; the user sees cumulative tokens via the existing `TokenStats` rollup. The status block's primary consumer is the safety net, not the user.
**Pattern summary:** The robustness commits are four independent hardening operations on the loop: tolerate, dedupe, pin-order, scope. Tolerate: `scan_tag_document` extracts valid tags and ignores the rest, with two carve-outs — malformed known tags propagate as hard errors (a clear protocol mistake), and a trailing unclosed `<nagent-response>` captures to EOF (so a finished run isn't lost to a missing close tag). Dedupe: `dedupe_nodes` collapses exact-duplicate tags within a turn, with a system note when it fires (so the model knows it stuttered and emits each action once next time). Pin-order: the `<nagent-shell>`-output-before-`<nagent-next-input>` ordering is pinned by a regression test — the regression test is the contract; the implementation "holds by construction" but was previously unpinned. Scope: `<nagent-write>` is restricted to a per-conversation scratch dir, eliminating the cross-instance collision class on shared `/tmp` paths. The four changes share a data-oriented theme: each is a discrete transformation with its own invariant, test, and comment, and each operates on data on disk rather than on the model's behavior. The `ignored_correction` system note is the only exception — it's a prompt-side intervention that asks the model to read and adjust. The rest are pure-code or pure-data. This extends v2.3 Pattern 5 ("the loop") with failure-recovery semantics and extends v2.3 Pattern 4 ("visible output protocol") with a lenient counterpart (`scan_tag_document`) that tolerates non-protocol output while still propagating known-tag malformation as a hard error.
**Pattern deep-dive.** The robustness commits are four independent hardening operations on the loop: **tolerate**, **dedupe**, **pin-order**, **scope**. Tolerate: `scan_tag_document` extracts valid tags and ignores the rest, with two carve-outs — malformed *known* tags propagate as hard errors (a clear protocol mistake), and a trailing unclosed `<nagent-response>` captures to EOF (so a finished run isn't lost to a missing close tag). Dedupe: `dedupe_nodes` collapses exact-duplicate tags within a turn, with a system note when it fires (so the model knows it stuttered and emits each action once next time). Pin-order: the `<nagent-shell>`-output-before-`<nagent-next-input>` ordering is pinned by `test_shell_output_precedes_next_input_in_either_order` — the regression test is the contract; the implementation "holds by construction" but was previously unpinned. Scope: `<nagent-write>` is restricted to a per-conversation scratch dir, eliminating the cross-instance collision class on shared `/tmp` paths.
#### §7.1 What Robustness Adds
The robustness cluster hardens the loop against four specific failure modes. The hardening is incremental — each commit is a discrete change with its own test. The four changes are not a single "robustness overhaul"; they are four independent operations on the loop's data, each with its own invariant, test, and comment.
The four pieces of the robustness abstraction:
1. **Tolerate**`scan_tag_document` extracts valid tags and ignores the rest. The two carve-outs are: malformed *known* tags propagate as hard errors (a clear protocol mistake), and a trailing unclosed `<nagent-response>` captures to EOF (so a finished run isn't lost to a missing close tag). The lenient parser is the data-oriented response to "lenient storage, strict dispatch": storage should be robust to whatever the model emitted; dispatch should propagate clear protocol mistakes.
2. **Dedupe**`dedupe_nodes` collapses exact-duplicate tags within a turn. When the dedupe fires, a system note is added so the model knows it stuttered and emits each action once next time. The dedupe operates on a `(name, self_closing, sorted(attrs), content)` key — exact duplicates only, not near-duplicates.
3. **Pin-order** — the `<nagent-shell>`-output-before-`<nagent-next-input>` ordering is pinned by `test_shell_output_precedes_next_input_in_either_order`. The regression test is the contract; the implementation "holds by construction" but was previously unpinned. The test asserts that the order is preserved in either direction (shell output first, then next input).
4. **Scope**`<nagent-write>` is restricted to a per-conversation scratch dir. The scratch dir is keyed by conversation name (`tmp_roots()[0] / f"nagent-{conversation_name}"`), not by per-process guid, so it stays stable across resumes. The scope eliminates the cross-instance collision class on shared `/tmp` paths.
The four changes share a data-oriented theme: each is a discrete transformation with its own invariant, test, and comment, and each operates on data on disk rather than on the model's behavior. The `ignored_correction` system note is the only exception — it's a prompt-side intervention that asks the model to read and adjust. The rest are pure-code or pure-data.
The lenient parser is the most subtle of the four. The strict `parse_tag_document` raises `TagParseError` on any malformation; the lenient `scan_tag_document` returns `(nodes, ignored)` where ignored is the list of `IgnoredSpan` (reason + text + offset). The two callers — `parse_response` (in the hot path) and `cleaned_response_text` (for storage) — use different policies: `parse_response` propagates `TagParseError` on known-tag malformation (the loop must ask the model to fix it); `cleaned_response_text` is more permissive (storage should be robust to whatever the model emitted). The split is the data-oriented response to "lenient storage, strict dispatch."
#### §7.2 The Lenient Parser
A code-shape sketch using survey grammar:
The lenient parser is the most subtle of the four. The strict `parse_tag_document` raises `TagParseError` on any malformation; the lenient `scan_tag_document` returns `(nodes, ignored)` where ignored is the list of `IgnoredSpan` (reason + text + offset). The two callers — `parse_response` (in the hot path) and `cleaned_response_text` (for storage) — use different policies:
- **`parse_response` (hot path)** — propagates `TagParseError` on known-tag malformation. The loop must ask the model to fix the protocol mistake before proceeding. The exception is the EOF-capture case: a trailing unclosed `<nagent-response>` captures to `len(text)` instead of raising (so a finished run isn't lost to a missing close tag).
- **`cleaned_response_text` (storage path)** — is more permissive. Storage should be robust to whatever the model emitted; the storage layer writes the valid nodes and the ignored spans, and the next turn's initial context can surface the ignored spans as system notes.
The split is the data-oriented response to "lenient storage, strict dispatch". The storage layer never raises on a malformed response; the dispatch layer raises on a clear protocol mistake. The two policies are encoded in the two functions, not in a single function with a flag.
#### §7.3 The Dedupe Invariant
The dedupe invariant is "no exact-duplicate action tags within a turn". The implementation is at `bin/helpers/nagent_tags.py:248-265`:
```
dedupe_nodes(nodes) :: nodes {ssdl} [S]
seen := {}
out := []
for node in nodes {
key := (name, self_closing, sorted(attrs), content)
if key not in seen:
seen += key
out += node
}
return out
```
The key is `(name, self_closing, sorted(attrs), content)`. The `sorted(attrs)` ensures that `<nagent-shell command="ls" cwd="/tmp">` and `<nagent-shell cwd="/tmp" command="ls">` have the same key (the attr order doesn't matter for equality). The `content` is the node's text content (for non-self-closing tags).
When dedupe fires (a duplicate is found), a system note is added at `bin/nagent:1930`: "You emitted <nagent-X> twice in one turn; I collapsed the duplicate. Next time, emit each action once." The system note is the prompt-side intervention that asks the model to read and adjust. The note is a single line; the model is expected to incorporate the feedback in the next turn.
The dedupe is exact-only: a near-duplicate (same command with whitespace differences, same shell with env vars) is not collapsed. Whether this matters in practice is unverified. A v4 would add a fuzz-duplicate check (e.g., normalize whitespace + lowercase + sort env vars before keying) if the exact-only policy is too strict.
#### §7.4 The Pin-Order Regression Test
The pin-order regression test is at `tests/test_nagent.py:679-714``test_shell_output_precedes_next_input_in_either_order`. The test asserts that the order `<nagent-shell>`-output-before-`<nagent-next-input>` is preserved in either direction: shell output first, then next input, regardless of which is emitted first in the response.
The test is the contract: the implementation "holds by construction" but was previously unpinned. The pinning is a regression guard: if a future change accidentally swaps the order, the test fails. The test is small but load-bearing.
The test's name is descriptive: "in_either_order" means the test asserts the ordering regardless of which tag appears first in the response. The implementation handles both orderings correctly; the test verifies it.
#### §7.5 The Per-Conversation Scratch Directory
The per-conversation scratch directory is at `bin/nagent:1319-1331`:
```
conversation_scratch_dir(conversation_name) :: path {ssdl} [S]
return tmp_roots()[0] / f"nagent-{conversation_name}"
// keying on name (not per-process guid) keeps it stable across resumes
```
The scratch dir is keyed on conversation name, not per-process guid. The keying-on-name choice keeps the scratch dir stable across resumes: if a conversation is paused and resumed, the scratch dir is the same. A per-process guid would create a new scratch dir on each resume, losing any state that was written to the previous scratch dir.
The scope is enforced at `bin/nagent:1344-1381``validate_write_path(..., scratch_dir=...)` only allows paths inside the scratch dir. File-edit mode is unchanged (file-edit writes go to the user's filesystem, not the scratch dir). The execute_write function threads the scratch_dir through at `bin/nagent:1387-1394`. The process_tags function computes the scratch_dir per call at `bin/nagent:1534-1551`. The run_agent_loop pre-creates the scratch_dir before the first turn at `bin/nagent:1834-1840`.
The scope eliminates the cross-instance collision class on shared `/tmp` paths. Before the change, two concurrent nagent instances writing to `/tmp/foo` would collide; after the change, each instance writes to `/tmp/nagent-{conversation_name}/foo` and the instances are isolated.
The scratch dir is keyed on conversation name; if a user renames a conversation file mid-run, the scratch dir becomes orphaned and a new one is created. Unverified whether this is the intended behavior; the v3 cluster notes this as an honest gap.
#### §7.6 The Per-Turn Status Block
The `<nagent-turn-status>` block at the end of every turn (per `bin/nagent:1940`) is the per-turn observability surface. The block contains:
- UTC timestamp
- Cumulative token count (input + output)
- Cumulative cost (if available)
- Ignored span count
- Duplicate count
- Sidecar references
The block is observability but not user-facing; the user sees cumulative tokens via the existing `TokenStats` rollup. The status block's primary consumer is the safety net (§2), which reads the block to compute the checkpoint delta.
The status block is the per-turn ground-truth that the safety net's checkpoint writer uses. Without the block, the writer would have to estimate the conversation's state; with the block, the writer has a per-turn measurement.
#### §7.7 Per-Commit Detail
The four commits that built the robustness subsystem:
1. **`065168c` — Add the lenient parser.** Adds `bin/helpers/nagent_tags.py:43-50` (the `parse_element(..., capture_to_eof_if_unclosed=True)` for trailing unclosed `<nagent-response>`), `bin/helpers/nagent_tags.py:106-110` (the EOF-capture behavior), and `bin/helpers/nagent_tags.py:136-246` (the `IgnoredSpan` + `_read_tag_name` + `scan_tag_document` lenient parser + `serialize_node(s)` re-serializer). This is the "tolerate" commit — it adds the lenient parser that extracts valid tags and ignores the rest.
2. **`6b762da` — Add the dedupe_nodes + cleaned_response_text.** Adds `bin/helpers/nagent_tags.py:248-265` (the `dedupe_nodes` function) and `bin/nagent:1911-1940` (the `cleaned_response_text` returns `(text, duplicates_removed)` + the system note when collapsed). Also adds the tests at `tests/test_nagent.py:548-590` (3 cleaned/duplicate tests), `tests/test_nagent_safety.py:367-400` (`test_duplicate_tags_collapsed_in_conversation_without_sidecar`), and `tests/test_nagent_tags.py:170-182` (`DedupeNodesTests`). This is the "dedupe" commit — it adds the dedupe + the system note.
3. **`12c35b7` — Add the pin-order regression test.** Adds `bin/nagent:682-714` (`test_shell_output_precedes_next_input_in_either_order`). This is the "pin-order" commit — it adds the regression test that pins the ordering.
4. **`49e07f3` — Add the per-conversation scratch dir.** Adds `bin/nagent:1319-1331` (`conversation_scratch_dir(conversation_name)`), `bin/nagent:1334-1341` (`is_within(path, directory)` replacing `is_tmp_path`), `bin/nagent:1344-1381` (`validate_write_path(..., scratch_dir=...)`), `bin/nagent:1387-1394` (`execute_write(..., scratch_dir=...)` threaded through), `bin/nagent:1534-1551` (`process_tags` computes scratch_dir per call), `bin/nagent:1834-1840` (`run_agent_loop` pre-creates scratch_dir before the first turn), and `bin/nagent:224-240` (`file_edit_rules(file_edit_path, scratch_dir)`). This is the "scope" commit — it adds the per-conversation scratch dir.
The four commits together implement the robustness abstraction: tolerate, dedupe, pin-order, scope. Each is a discrete change with its own test; the cluster is the sum of the four changes, not a single overhaul.
#### §7.8 Manual Slop Implications
The Manual Slop equivalents of the robustness pattern are partial. The closest analogs are:
- **`send_result()`** (in `src/ai_client.py`, per `docs/guide_ai_client.md`) — the AI client's response handler. The handler could adopt the lenient parser discipline: extract valid tags, ignore the rest, propagate known-tag malformation as hard error.
- **`dispatch_inference`** (in `src/ai_client.py`) — the main loop equivalent. The loop could adopt the per-conversation scratch dir pattern: pre-create on session start, thread through the `<nagent-write>`-equivalent.
- **The `Result[T]` discipline** (per `conductor/code_styleguides/error_handling.md`) — failure widens the fallback instead of blocking. This is the same pattern as the lenient parser's "ignore the rest, propagate known-tag malformation as hard error".
The gap Manual Slop could close:
1. **No lenient parser for the tag protocol.** Manual Slop's `send_result()` raises on any malformation. The lenient parser discipline (extract valid, ignore the rest, propagate known-tag malformation) is a small but load-bearing change.
2. **No per-conversation scratch dir.** Manual Slop's `dispatch_inference` writes to the project's `tests/artifacts/` directory, which is shared across all conversations. The per-conversation scratch dir pattern would isolate concurrent instances.
3. **No `<nagent-turn-status>` block.** Manual Slop's discussion history does not have a per-turn status block. The user can see cumulative tokens via the `TokenStats` rollup, but not in a structured per-turn way.
4. **No "dedupe action tags" discipline.** Manual Slop's discussion history can have duplicate action tags (the model emits the same action twice). The dedupe + system note discipline would prevent this.
#### §7.9 Honest Gaps
1. **`dedupe_nodes` only catches EXACT duplicates** (same name, self_closing flag, attrs, content). A near-duplicate (same command with whitespace differences, same shell with env vars) is not collapsed. Whether this matters in practice is unverified.
2. **The lenient parser's "ignore the rest" behavior could mask real protocol bugs** — the model might be silently emitting junk while the conversation proceeds. The `ignored_correction` system note at `bin/nagent:1930` is the recovery path; it relies on the model reading the note. A future track could add a hard error when the ignored-to-extracted ratio exceeds a threshold.
3. **The scratch dir at `bin/nagent:1319-1331` is keyed on conversation name; if a user renames a conversation file mid-run, the scratch dir becomes orphaned and a new one is created.** Unverified whether this is the intended behavior.
4. **The `<nagent-turn-status>` block at the end of every turn (per `bin/nagent:1940`) is observability but not user-facing; the user sees cumulative tokens via the existing `TokenStats` rollup.** The status block's primary consumer is the safety net, not the user.
5. **The pin-order regression test is the only pinning.** The implementation "holds by construction" but is not exhaustively tested. A v4 would add more pin-order tests for other ordering invariants (e.g., `<nagent-file-patch>` before `<nagent-next-input>`, etc.).
6. **The `is_within(path, directory)` check is a string-based path comparison.** A symlink outside the directory could bypass the check. A v4 would resolve the path before the check.
7. **The interaction with the campaigns driver (§1) is not deep-dived.** The campaigns driver spawns per-item workers. Each worker has its own scratch dir. The v3 cluster does not document how the campaigns driver coordinates with the per-conversation scratch dir — does the campaign-level conversation have its own scratch dir? do the per-item workers share a scratch dir?
8. **The interaction with the conversation safety net (§2) is not deep-dived.** The safety net's rebuild creates a new initial context, which would reset the per-conversation scratch dir references. The v3 cluster does not document how the safety net coordinates with the scratch dir — does the rebuild preserve the scratch dir? does the next checkpoint know about the scratch dir state?
#### §7.10 Code-Shape Sketch
The robustness abstraction, in survey-grammar SSDL notation, with shape tags:
```
scan { text, known, unwrap, eof_capture } :: (nodes, ignored) {ssdl} [I]
@@ -1366,10 +1456,72 @@ dedupe { nodes } :: nodes {ssdl} [S]
scratch-dir { conversation_name } :: path {ssdl} [S]
return tmp_roots()[0] / f"nagent-{conversation_name}"
// keying on name (not per-process guid) keeps it stable across resumes
validate-write-path { path, scratch_dir } :: bool {ssdl} [I]
return is_within(path, scratch_dir) // only path-inside-scratch-dir is allowed
turn-status { turn } :: status-block {ssdl} [S]
return {
utc: now(),
cumulative_tokens: turn.cumulative_tokens,
cumulative_cost: turn.cumulative_cost,
ignored_count: turn.ignored_count,
duplicate_count: turn.duplicate_count,
sidecar_refs: turn.sidecar_refs
}
```
The `{ssdl}` markers note the abstractions: `scan` is an inspectable transformation (I) that produces both valid nodes and ignored spans; `dedupe` and `scratch-dir` are pure string concatenations (S). The `<nagent-turn-status>` block (per `bin/nagent:1940`) is the per-turn observability surface that consumes `scan`'s output (the ignored count and the duplicates count feed the block's token totals + sidecar refs).
The shape tag map: `[I]` for inspectable transformations (the scan, the validate), `[S]` for string concatenations (dedupe key, scratch dir path, status block). The robustness abstraction operates on data on disk, not on the model's behavior. The only prompt-side intervention is the `ignored_correction` system note.
**Source-read citations:**
- `bin/helpers/nagent_tags.py:43-50``parse_element(..., capture_to_eof_if_unclosed=True)` for trailing unclosed `<nagent-response>` (065168c)
- `bin/helpers/nagent_tags.py:106-110` — EOF-capture behavior (065168c)
- `bin/helpers/nagent_tags.py:136-246``IgnoredSpan` + `_read_tag_name` + `scan_tag_document` (065168c)
- `bin/helpers/nagent_tags.py:248-265``dedupe_nodes` (6b762da)
- `bin/nagent:1911-1940``cleaned_response_text` returns `(text, duplicates_removed)`; system note when collapsed (6b762da)
- `bin/nagent:1930``ignored_correction` system note (6b762da)
- `bin/nagent:682-714``test_shell_output_precedes_next_input_in_either_order` regression test (12c35b7)
- `bin/nagent:1319-1331``conversation_scratch_dir(conversation_name)` (49e07f3)
- `bin/nagent:1334-1341``is_within(path, directory)` (49e07f3)
- `bin/nagent:1344-1381``validate_write_path(..., scratch_dir=...)` (49e07f3)
- `bin/nagent:1387-1394``execute_write(..., scratch_dir=...)` threaded through (49e07f3)
- `bin/nagent:1534-1551``process_tags` computes scratch_dir per call (49e07f3)
- `bin/nagent:1834-1840``run_agent_loop` pre-creates scratch_dir before the first turn (49e07f3)
- `bin/nagent:224-240``file_edit_rules(file_edit_path, scratch_dir)` (49e07f3)
- `bin/nagent:1940``<nagent-turn-status>` block at end of every turn (the v3 cluster does not cite a specific line range; 1940 is approximate)
- `tests/test_nagent.py:548-590` — 3 cleaned/duplicate tests (6b762da)
- `tests/test_nagent.py:679-714``test_shell_output_precedes_next_input_in_either_order` (12c35b7)
- `tests/test_nagent_safety.py:367-400``test_duplicate_tags_collapsed_in_conversation_without_sidecar` (6b762da)
- `tests/test_nagent_tags.py:170-182``DedupeNodesTests` (6b762da)
- `bin/helpers/nagent_tags.py:1-42` — module docstring + imports + constants (065168c; the v3 cluster does not cite specific line ranges)
- `bin/helpers/nagent_tags.py:50-105` — between `parse_element` and EOF-capture behavior (065168c)
- `bin/helpers/nagent_tags.py:110-135` — between EOF-capture and `IgnoredSpan` (065168c)
- `bin/helpers/nagent_tags.py:265-300` — between `dedupe_nodes` and module end (6b762da; the v3 cluster does not cite specific line ranges)
- `bin/nagent:1-50` — main module imports + constants (the v3 cluster does not cite specific line ranges)
- `bin/nagent:50-220` — main module setup (the v3 cluster does not cite specific line ranges)
- `bin/nagent:240-680` — main loop start (the v3 cluster does not cite specific line ranges)
- `bin/nagent:714-1300` — main loop body (the v3 cluster does not cite specific line ranges)
- `bin/nagent:1381-1387` — between `validate_write_path` and `execute_write` (49e07f3)
- `bin/nagent:1394-1534` — between `execute_write` and `process_tags` (the v3 cluster does not cite specific line ranges)
- `bin/nagent:1551-1834` — between `process_tags` and `run_agent_loop` (the v3 cluster does not cite specific line ranges)
- `bin/nagent:1840-1900` — after `run_agent_loop` pre-create (the v3 cluster does not cite specific line ranges)
- `bin/nagent:1900-1911` — between `run_agent_loop` and `cleaned_response_text` (the v3 cluster does not cite specific line ranges)
- `tests/test_nagent.py:1-50` — test file header + imports (the v3 cluster does not cite specific line ranges)
- `tests/test_nagent.py:50-548` — test file body (the v3 cluster does not cite specific line ranges)
- `tests/test_nagent.py:590-679` — between cleaned/duplicate tests and pin-order test (the v3 cluster does not cite specific line ranges)
- `tests/test_nagent.py:714-1911` — test file body continued (the v3 cluster does not cite specific line ranges)
- `tests/test_nagent_safety.py:1-50` — test file header + imports (the v3 cluster does not cite specific line ranges)
- `tests/test_nagent_safety.py:50-367` — test file body (the v3 cluster does not cite specific line ranges)
- `tests/test_nagent_safety.py:400-500` — test file body continued (the v3 cluster does not cite specific line ranges)
- `tests/test_nagent_tags.py:1-170` — test file body (the v3 cluster does not cite specific line ranges)
- `tests/test_nagent_tags.py:182-300` — test file body continued (the v3 cluster does not cite specific line ranges)
- `bin/nagent:3167-3185``run_agent_loop` (relevant for the scratch dir pre-create)
- `bin/helpers/nagent_campaign_lib.py` — campaigns driver (relevant for the gap note on campaigns coordination)
- `bin/helpers/nagent_safety_lib.py` — safety net writer (relevant for the gap note on safety net coordination)
**Decision candidate:** NEW Candidate 23 (MEDIUM). "Per-conversation scratch directory for Manual Slop dispatch_inference" — adopt the `conversation_scratch_dir(conversation_name)` pattern; pre-create on session start; thread through the `<nagent-write>`-equivalent. See `decisions.md` Candidate 23.
**Cross-refs:** §3 Hooks (per-turn `<nagent-turn-status>` and per-turn hooks are both per-turn observability surfaces); §2 Conversation safety net (the `<nagent-turn-status>` block is what the safety net reads to compute the checkpoint delta). `docs/guide_ai_client.md` (the Manual Slop AI client guide; relevant for the Manual Slop implications).
**Pattern history:** UPDATE. v2.3 Pattern 5 ("the loop") had the basic loop; v3 hardens it against four specific failure modes. EXTENDS v2.3 Pattern 4 ("visible output protocol") with a lenient counterpart (`scan_tag_document`) that tolerates non-protocol output while still propagating known-tag malformation as a hard error. NEW: per-conversation scratch directory as a side artifact of the loop.
## §8 Operating rules
**Source:** nagent `a1f0680` (`context/data-oriented-design.md:102-116` + `:151-164`); cross-ref `conductor/tracks/fable_review_20260617/`.