Skip to content

story-091: One command files a story - #89

Merged
jerodw merged 1 commit into
mainfrom
story/story-091
Aug 29, 2026
Merged

story-091: One command files a story#89
jerodw merged 1 commit into
mainfrom
story/story-091

Conversation

@jerodw

@jerodw jerodw commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Story

One command files a story.

The outbox had a queue and a drain but nothing that could file an entry. This adds the transport: one command, invoked once per entry, through which the harness knows nothing else about the tracker. No provider abstraction, no client library, no tracker vocabulary anywhere in harness source.

The contract

  • stdin — the entry as one JSON document, carrying key, identity, state and payload.
  • L5_SYNC_KEY — the entry's key, taken from the same field as the copy on stdin so the two cannot disagree.
  • stdout — the reference is the last non-empty line, recorded and never parsed, so a reference that is not a URL lands exactly as one that is.
  • stderr — carried back as the entry's last_error, bounded to a tail, so a failed or pending entry says why in its own file.
  • exit 0 lands it; exit 75 leaves it pending with the attempt counted; any other non-zero code fails it terminally and no later sync invokes the command for it again.

A zero exit naming no reference is read as transient, not as success, because it establishes nothing about whether the request arrived.

75 is EX_TEMPFAIL, and the choice is recorded rather than arbitrary: 1 and 2 are ordinary failures, 126 and 127 mean the command could not be executed, 128+N is a signal. "A small number would collide with all of those; this one collides with nothing."

The timeout is not optional

sync_timeout_seconds defaults to 60 rather than 0, and the reason is written where a reader meets it: unlike max_pause_wait_seconds, a zero here would mean no timeout, which is the failure the bound exists to prevent. So the one duration written in harness source is a real one and every path through the transport is bounded in time.

The kill reaches the command's process group — the command is spawned with start_new_session=True and killed with os.killpg — so a command that spawned children of its own leaves none behind. A command killed this way leaves its entry pending rather than failed, because running past a timeout established nothing about whether the request arrived.

The reference read from stdout is length-bounded too: it is written into a durable file and validated against the entry schema, so an unbounded one would be an unbounded write of somebody else's stdout into the queue.

Idempotency is the command's promise, and the harness says it cannot check it

The command must search the tracker for the key before it creates anything, which is what makes the ambiguous write — created, response lost — safe to retry. The harness cannot enforce this, and the schema says so rather than implying a check that does not exist. Same for a sync command must not commit: it writes, and a human or a run commits.

templates/sync/github.sh is a reference implementation of the whole contract, installed by l5-init into .harness/sync/ and executable. It writes the key into the issue body as a marker and creates only when a search for that marker finds nothing; a search that fails is transient rather than terminal, because creating on a failed search is exactly the duplicate the mechanism exists to avoid. Its board step is opt-in and its own business — the harness knows nothing about it.

Evidence

  • Suite on the working tree: 4102 passed, 1 skipped (suite-run-result.json, exit 0)
  • Suite in a fresh clone with the story committed: 4102 passed, 1 skipped (clean-clone-result.json, exit 0)
  • Verification: passed, no blocking issues
  • Revert-check nomination: tests/test_outbox.py::test_no_module_a_run_executes_reaches_the_outbox — exit 1 reverted, exit 0 applied
  • 34 tests written; new module tests/test_command_transport.py
  • 0 retries, 0 self-routes, no correction pass

Tested with fixture commands and never a network: one exiting 0 with a canned reference, one transient, one terminal, one hanging past the timeout, one not executable, one invoked twice with the same key.

Still no producer, deliberately

test_no_module_a_run_executes_reaches_the_outbox holds story_coordinator.py to reaching the queue not at all — the only module that reaches it is command_transport.py, the drain side — with a planted-call-site control and an assertion that the single exemption has not gone stale. l5-sync remains the only drain site and says why in its own docstring: nothing enqueues anything yet, so there is no queue accumulating unattended, and the story that adds a producer is the first point at which an automatic sweep earns its call site.

Running l5-sync on this repository today reports landed 0, pending 0, failed 0, poisoned 0.

Notes for review

This repository configures sync_command for itself, so the script the harness hands to every target is the script this repository is exercised by. The template ships both keys commented out, and test_a_freshly_initialized_target_sets_neither_new_key asserts a fresh target opts in to filing rather than discovering that it files — with this repo's own config as the control, so "unset" is a fact about the template rather than about a reader finding nothing.

On the two copies of github.sh: they are byte-identical, and a test pins them so. templates/sync/github.sh is mode 100644 in git while .harness/sync/github.sh is 100755, which does not matter — l5-init does an explicit chmod(0o755) after copying rather than relying on git's mode bit, and test_l5_init_installs_the_reference_command_executable asserts the installed copy is executable.

The verifier's second unverified is resolved by evidence it did not have. It could not confirm the new sync scripts would survive into a clean clone, because they were untracked when it read the tree. They are in the story commit, and the clean-clone suite passed afterwards.

Two operational gaps worth knowing before a producer lands, neither a defect in this story:

  1. The reference script defaults to --label l5. gh issue create fails if that label does not exist, and the script maps that to exit 75 — so entries would pend and retry indefinitely rather than failing loudly. Create the label, or set L5_SYNC_LABEL, before the first producer merges.
  2. Board placement is add-only. gh project item-add puts an item on a project and sets no field; choosing a column needs an item-edit carrying project, field and option ids, which the script neither resolves nor accepts. Per-target settings — board, label, repository — come from ambient shell environment today and are recorded nowhere in the repo. Worth a story, sequenced after the producer so it can be verified against a real entry.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HkujNrgMcsRBFWNEKWYnmA

Implemented by the l5 harness story workflow.
@jerodw
jerodw merged commit ed30cb0 into main Aug 29, 2026
3 checks passed
@jerodw
jerodw deleted the story/story-091 branch August 29, 2026 20:19
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.

1 participant