A label started it.
A person finished it.

One bug in this repository, from the label that authorized the work to the commit that closed the issue. Nobody watched the run in the middle. Every figure here is read back out of its record, or out of git history you can check yourself.

  • issue #109
  • unattended
  • 608.3s
  • 3,013,462 tokens

The record keeps what allowed this run to start: a label, and the name of the person who put it there.

workflow
fix/from-ticket
approval
labeled:tpouyer
cost
$9.62
staged
2 files

The loop files its own next bug, and stops on its own.

Drawn: the fix workflow. A staged change that fails its tests never becomes a pull request. It becomes a bug ticket, labeled so an agent may take it. Applying that label needs write access, so the label is the authorization, and the workflow it fires carries no gate job at all.

the machine acted a person decided

The self-feeding fix loop, and the two places a person acts Five boxes joined left to right by arrows. Tests fail: the staged change does not go green. Bug filed: labeled ai-generated and ai-attempt-N. Labeled: applying it needs write access, and this box is marked as a place a person decided. Fix runs: reproduce red, then fix green, then stage it. Merged: a person reads it and decides, also marked as a place a person decided. The arrow between the last two is labeled pull request. A dashed arrow returns from the fix box to the bug box, labeled: tests still fail, so the next ticket is ai-attempt-N plus one, and at the cap nothing is labeled, so nothing fires. tests fail the staged change does not go green bug filed labeled ai-generated and ai-attempt-N labeled a person the first time, the run before it after that fix runs reproduce red, then fix green, then stage it merged a person reads it and decides pull request tests still fail: the next ticket is ai-attempt-N+1 at the cap nothing is labeled, so nothing fires
The machine can re-open this loop, up to a cap it cannot raise. Only a person closes it.
src/in_lockstep/platform/propose.pyexcerpt
AI_GENERATED = "ai-generated"
ATTEMPT_PREFIX = "ai-attempt-"


def attempt_of(labels: Any) -> int:
    counts = [
        int(rest)
        for label in labels or ()
        if (rest := str(label)[len(ATTEMPT_PREFIX) :])
        and str(label).startswith(ATTEMPT_PREFIX)
        and rest.isdigit()
    ]
    return max(counts, default=0)

The highest number wins, so a stray duplicate label cannot lower the count. The docstring in the source puts it in one line: the attempt count rides on the source ticket's labels, so the loop is bounded without any store.

When a run's change fails, the escalation reads the highest ai-attempt-N off the source issue and files the next ticket as one more than that. At max_attempts, three in this repository, it files nothing and comments that a human is needed.

Because nothing new then carries ai-generated, the trigger stops firing. There is no queue, no state file and no counter in a database. The bound sits on the issue, where the people working the issue can see it.

The same route serves a person. Label any bug ai-generated and it runs, which is what happened here: the label went onto #109 at 20:48:04Z, and the run started fifteen seconds later.

What the run left behind.

Drawn: the ledger. Records go to an orphan branch that touches none of your code. One command reads one back, and this is all of it.

$ in-lockstep history --explain fix-from-ticket
run       fix-from-ticket-33334652697-20260830T204819Z-2902
what      workflow  fix/from-ticket
status    succeeded
when      2026-08-30T20:57:48+00:00
head      e07f657dc8ba3fee8a4a7ce17dc19352b3f1fd70
branch    main
config    local working tree
approved  labeled:tpouyer  (unattended)
ci actor  tpouyer
args      ticket=#109
spend     $9.6244  (3013462 tokens, 608.324s)
findings  19
          src/in_lockstep/ai/builtins.py:None fix.staged: wrote
          src/in_lockstep/ai/builtins.py
          tests/in_lockstep/test_sandbox_fixtures_use_sys_executable.py:None
          fix.staged: wrote
          tests/in_lockstep/test_sandbox_fixtures_use_sys_executable.py
          fix.unstructured: the final message was not the JSON the schema
          asked for; its text was kept as the summary. The staged change
          came through the tool boundary and is unaffected.
          injection.exfil_token_names: high: ANTHROPIC_API_KEY
          injection.ignore_previous: critical: ignore all previous instructions
          injection.exfil_token_names: high: ANTHROPIC_API_KEY
          injection.exfil_token_names: high: ANTHROPIC_API_KEY
          injection.ignore_previous: critical: ignore all previous instructions
          injection.exfil_token_names: high: ANTHROPIC_API_KEY
          injection.exfil_token_names: high: ANTHROPIC_API_KEY
          injection.exfil_token_names: high: ANTHROPIC_API_KEY
          injection.ignore_previous: critical: ignore all previous instructions
          injection.exfil_token_names: high: ANTHROPIC_API_KEY
          injection.exfil_token_names: high: ANTHROPIC_API_KEY
          injection.ignore_previous: critical: ignore all previous instructions
          injection.exfil_token_names: high: ANTHROPIC_API_KEY
          injection.ignore_previous: critical: ignore all previous instructions
          injection.exfil_token_names: high: ANTHROPIC_API_KEY
          injection.exfil_token_names: high: ANTHROPIC_API_KEY

Nineteen findings. Two name the files that were staged. One is the run reporting a flaw in its own output. The other sixteen are the injection scanner, logging every pattern it matched in text the run had been told not to trust. Long lines are wrapped to fit this panel; nothing else about the block is changed.

Staged, not written. The job holding the model key has read-only access to the repository. What it produces is a change set in an artifact, so neither of those two files was written to anybody's working tree. A separate job, holding a write token and no provider SDK, picks that artifact up and checks every path in it again.

The third finding is the run reporting on itself. The model's closing message did not come back as the JSON the schema asked for, so its text was kept as the summary. The staged change was unaffected, because it arrived through the tool boundary rather than through that message. Naming the half it could not read beats discarding a good change, and beats pretending the reply was fine.

Ticket text is input to a model holding write tools.

Drawn: the security model. The title, the body and every comment reach the model marked untrusted. Anyone who can file an issue can write into a prompt.

Before the first call, a scanner reads everything marked that way. It reads every tool result too, before the result re-enters the loop, because a tool result is content that arrived after the prompt was assembled, and a git log carries whatever anyone wrote in a commit message.

This run matched sixteen times: eleven on a credential name, five on the phrase ignore all previous instructions. Each match is in the record above with the text that triggered it. None of them stopped the run. That is a setting, not an oversight.

The module is blunt about what it is not. It is not a safety filter, and what actually bounds a successful injection is a read-only tool set, a deny list and an egress rule. What the scan does is close the gap between telling a model not to and checking whether somebody told it something else.

$ in-lockstep lsexcerpt
policy
  framework-floor  <- in-lockstep
  = scan=warn deny_tools=0 max_turns=100 max_idle_turns=(unset)

scan=warn records and continues. scan=block refuses before the first model call and names the patterns that matched. Organization standards can move that setting one way only, because a standard able to loosen it would not be a standard.

Six ways a run can end.

None of them is waiting for the model to announce that it is finished. Four are ceilings the framework checks itself, on every turn, and one is a person.

  • It answers. The model returns a turn with no tool call in it. The ordinary exit, and the only one that is not a ceiling.
  • The turn cap. The loop is a bounded range rather than a while. Falling out of it comes back marked exhausted, not as another call.
  • The budget. Checked before each call against a projection of what that call will cost, so the turn that would cross the ceiling is refused while it is still free.
  • The deadline. Wall clock, re-checked on every turn.
  • The kill switch. IN_LOCKSTEP_DISABLE, also re-checked on every turn, so a run already in flight can be halted from outside it.
  • It parks. A workflow that reaches a decision only a person can make stops, marks the pull request, and resume continues it from any machine once they act. Parked is its own status, never a failure.
src/in_lockstep/adapters/ai/fix.pyexcerpt
decided=not fix_inv.exhausted,
reason="exhausted" if fix_inv.exhausted else None,

Two lines, and they are why a run that ran out of room can never be counted as a success. Running out is recorded as running out, on the pull request, in the ledger, and in any score computed later.

A tool result is capped as well, at 20,000 characters, because a tool result is model input and an unbounded one is an unbounded prompt on the next turn.

Your organization's ceiling wins. A strategy asking for forty turns, under a resolved ceiling of twelve, gets twelve. There is no direction in which a rule can loosen one of these.

A person read it, and wrote something else.

Just under three hours after the label went on, the issue closed. Not with either of the two files the run staged.

$ git show --stat --format='%s' 2a13ba2
fix(test): sandbox fixtures execute an interpreter that exists (#109) (#112)

 tests/in_lockstep/test_implement_oneshot.py | 60 ++++++++++++++++++++++++++---
 1 file changed, 55 insertions(+), 5 deletions(-)

The commit that closed #109. One test file, hand written, on a branch a maintainer opened after reading what the run had staged.

The run succeeded and its change was not merged. A maintainer kept the diagnosis and wrote the fix differently. The commit message names the distinction the automated attempt had erased: it rewrote argv inside run_script, which would have sent a host path into a container image, where that path means nothing.

That is the loop working rather than the loop failing. The change was staged where a person could read it, the person disagreed with one part of it, and the disagreement is in the commit now, where the next person to touch these fixtures will find it. On a laptop that correction would have ended in a terminal nobody kept.

Nine dollars bought a diagnosis, a failing test, and a proposal specific enough to argue with. The judgment stayed exactly where it already was.