Feedback loops
Understand how a failed check sends work back to an earlier step, and how to keep these retries safe and limited.
A feedback loop runs part of a job again when a check fails. For example, an agent step changes the code. A later step runs the tests. If the tests fail, the job goes back to the agent step.
How a loop runs
This complete workflow shows a loop. The project needs an npm test command and
default agent settings.
# yaml-language-server: $schema=https://www.shipfox.io/docs/workflow.schema.json
name: Fix failing tests
runner: shipfox
triggers:
manual:
source: manual
jobs:
fix:
steps:
- key: fix
prompt: Fix the failing tests in this repository.
- key: test
run: npm test
gate:
success: step.exit_code == 0
on_failure:
restart_from: fixThis is what happens:
- The
fixstep runs. The agent changes the code. - The
teststep runsnpm test. - If the tests fail, the job goes back to the
fixstep. The files that the agent changed are still there, so the agent can continue from them. - The
fixandteststeps run again. - When the tests pass, the job continues.
The Gate fields reference lists the exact fields.
Check and restart are separate
A gate has two parts. You write each part separately:
successis the check. It decides if the step passed.on_failureis the restart rule. It names the step that runs again when the check fails.
Because the two parts are separate, a failed check has two possible results:
- Stop the job. Write only
success. Do this when it is not safe to run the steps again. - Go back to the step that can fix the problem. Add
on_failureand name that step. The job does not start again from its first step.
Restart or fail
Some failures are worth another try. Some are not. An earlier step can fix a merge conflict, so the job should restart. No step can fix a missing credential, so the job should fail.
To choose, give on_failure a list of rules. Each rule has an if condition.
Shipfox checks the rules in order and uses the first rule where if is true.
steps:
- key: select_branch
run: ./scripts/select-branch.sh
- key: prepare
run: ./scripts/prepare.sh
- key: publish
run: ./scripts/publish.sh
outputs:
failure_kind:
type: string
from_file: failure-kind.txt
gate:
on_failure:
- if: has(step.outputs.failure_kind) && step.outputs.failure_kind == "foreign-commits"
restart_from: select_branch
- if: has(step.outputs.failure_kind) && step.outputs.failure_kind in ["stale-head", "transient"]
restart_from: prepare
max_attempts: 2The publish script writes the kind of failure to failure-kind.txt. When
publish fails:
- For
foreign-commits, the job goes back toselect_branch. - For
stale-headortransient, the job goes back toprepare. - For any other kind, no rule applies. The job fails, and
publishkeeps its own error.
When no rule applies, the job fails. A failure that you did not plan for does not restart by accident.
Keep these points in mind:
ifcan readstep.exit_code,step.status, andstep.outputs.- A step can fail before it sets an output. Use
has()before you read an output. If Shipfox cannot read the value, the job fails. - A rule with no
ifalways applies. Put it last. - A gate with
successset totruepasses the step, and Shipfox reads no rule. To choose only with rules, leavesuccessout. The status of the step then decides if the step passed.
Use a separate check
Do not ask the agent that did the work to also check the work. An agent that made a mistake often does not see that mistake when it checks.
Use a check that gives the same answer for the same code, whatever the agent says. Good checks are:
- A test command.
- A type check.
- A linter.
- A review by a different agent step.
Route several loops by their checking step
One job can contain more than one feedback loop. In this example, the job has
two checks, test and lint. Each check has its own fix step:
steps:
- key: fix_tests
if: '${{ has(step.restart) && has(step.restart.from.key) && step.restart.from.key == "test" }}'
prompt: Fix the failing tests.
- key: test
run: npm test
gate:
success: step.exit_code == 0
on_failure:
restart_from: fix_tests
- key: fix_lint
if: '${{ has(step.restart) && has(step.restart.from.key) && step.restart.from.key == "lint" }}'
prompt: Fix the lint errors.
- key: lint
run: npm run lint
gate:
success: step.exit_code == 0
on_failure:
restart_from: fix_lintstep.restart.from.key is the key of the check that failed. Each fix step
tests this value in its if condition. The two has() tests make sure that
the value exists before the condition reads it:
- When
testfails, the job goes back tofix_tests.fix_testsruns. - When
lintfails, the job goes back tofix_lint.fix_lintruns. - On the first attempt, no check has failed. Both fix steps are skipped.
Build a feedback loop shows the complete workflow.
Tell the next attempt what failed
When a gate restarts an agent step, Shipfox tells the agent what failed.
To give the agent more details, add feedback to the gate:
steps:
- key: fix
prompt: Fix the failing tests.
- key: test
run: npm test
gate:
success: step.exit_code == 0
on_failure:
restart_from: fix
feedback: Fix the code, not the tests.Loop bounds
Two limits stop a loop that does not pass its check:
# yaml-language-server: $schema=https://www.shipfox.io/docs/workflow.schema.json
name: Fix failing tests
runner: shipfox
triggers:
manual:
source: manual
jobs:
fix:
execution_timeout: "30m"
steps:
- key: fix
prompt: Fix the failing tests.
- key: test
run: npm test
gate:
success: step.exit_code == 0
on_failure:
restart_from: fix
max_attempts: 8max_attemptsis the number of times theteststep can run. The first run counts. Here, iftestfails 8 times, the job fails withrestart_exhausted. The default is 5. You cannot remove the limit.execution_timeoutis the time that the job can run. The time includes every attempt. Here, if the job is not complete after 30 minutes, it fails withtimed_out.
With a list of rules, each rule has its own max_attempts. The number counts
every run of the step. It does not count only the runs that this rule started.
steps:
- key: fix
prompt: Fix the problem.
- key: test
run: ./scripts/test.sh
gate:
on_failure:
- if: step.exit_code == 2
restart_from: fix
- if: step.exit_code == 3
restart_from: fix
max_attempts: 2This is what happens:
testexits with code 2. The first rule applies, and the job restarts fromfix.testruns a second time and exits with code 3.- The second rule applies. It has
max_attempts: 2, andtesthas already run 2 times. - The job does not restart. It fails with
restart_exhausted.
The limit that the job reaches first stops the loop.
Limits lists the exact values.
Avoid repeated writes
When a gate fails, the job runs again every step from the restart_from step
to the step with the gate. A step in that range that pushes, deploys, or
comments does it again on each attempt.
Put such a step after the gate. It then runs one time, after the check passes:
steps:
- key: fix
prompt: Fix the failing tests.
- key: test
run: npm test
gate:
success: step.exit_code == 0
on_failure:
restart_from: fix
- key: push
run: git push origin HEADHere, a restart runs fix and test again. push is after the gate, so it
runs only when test passes.
A tool step can send the job back to an earlier step only when it reads data. If the gate of a tool step that writes data fails, the job fails. For an example, see Wait for a change to apply.
If a step that writes must stay in the loop, make it safe to run again. For example, a push step must use the branch that an earlier attempt created, and not fail because the branch exists. Turn a Sentry issue into a verified pull request shows a push step that does this.
To add a feedback loop to a workflow, see Build an agent feedback loop.