fix(work): emit completion signal to stop sandcastle agent loops

Sandcastle re-invokes agents up to maxIterations even when the work is
already done — the decomposer was looping 4x re-writing the same epic
on every dispatch. Two halves to the fix:

- Pass completionSignal: "<promise>COMPLETE</promise>" explicitly on
  all three run() calls (decompose, implementer, reviewer). Makes the
  contract visible alongside maxIterations instead of relying on
  sandcastle's default.
- Append a "Signal completion (required)" section to each prompt
  telling the agent to emit the literal marker as its final line when
  the work is genuinely done, plus a "do NOT emit if..." list to
  discourage premature signaling.
This commit is contained in:
2026-05-13 19:11:44 +02:00
parent d6bf2f638f
commit eadbb7ebd9
5 changed files with 47 additions and 0 deletions

View File

@@ -73,3 +73,15 @@ When done, tell the human the epic folder path and offer them a chance to review
- If the PRD's status is not `approved`, refuse to decompose and tell the human to flip it first.
- **Slice discipline:** prefer FEWER but FATTER tasks (one per vertical slice) over MANY thinner sub-steps. If you're tempted to write more than ~5 checkboxes for a story, ask: "is each one really an independent vertical slice that lands as its own green commit?" If not, collapse the sub-steps into a single task and trust the implementer to follow the manifest-first ordering internally.
- **Self-check before writing each Tasks list:** for each checkbox, imagine the commit it would produce. Would `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm coverage:diff` all pass on that commit alone? If no, the checkbox isn't a slice — merge it with its neighbours.
## Signal completion (required)
When the epic folder + story files are written and committed (or you have determined the work is truly done — including the case where you decided not to write anything and reported the reason), emit the literal string `<promise>COMPLETE</promise>` as the final line of your response.
Sandcastle uses this marker to stop the iteration loop. Without it, the orchestrator will re-invoke you up to `maxIterations` times even when the work is already done — every redundant iteration costs subscription quota and time.
Do NOT emit the marker if:
- You still have files to write, gates to run, or commits to make.
- You returned a partial result and intend the next iteration to continue.
- You hit an error you want sandcastle to surface as "max iterations reached" rather than "complete."

View File

@@ -98,3 +98,15 @@ When done, return structured JSON:
```
Do NOT modify the task markdown or `_state.json` yourself — the orchestrator handles state writes.
## Signal completion (required)
After you have committed the slice (or returned a terminal `blocked` / `needs-clarification` status), emit the literal string `<promise>COMPLETE</promise>` as the final line of your response.
Sandcastle uses this marker to stop the iteration loop. Without it, the orchestrator will re-invoke you up to `maxIterations` times even when the work is already done — every redundant iteration costs subscription quota and time.
Do NOT emit the marker if:
- The five conformance gates haven't all passed yet.
- You still have files to write, fixes to apply, or commits to make.
- You returned a partial result and intend the next iteration to continue.

View File

@@ -79,3 +79,11 @@ Return structured JSON:
```
If you reject, the orchestrator passes your notes back to the implementer for a fix-up cycle (up to the task's `max-attempts`, default 3).
## Signal completion (required)
After you have returned the structured JSON decision, emit the literal string `<promise>COMPLETE</promise>` as the final line of your response.
Sandcastle uses this marker to stop the iteration loop. Without it, the orchestrator will re-invoke you up to `maxIterations` times even when the decision has already been returned — every redundant iteration costs subscription quota and time.
Emit the marker for BOTH `approve` and `reject` decisions — the decision is itself a terminal output, regardless of which way it went. Do NOT emit the marker if you still need to read more of the diff, run a tool, or otherwise have unfinished work.

View File

@@ -166,6 +166,11 @@ export async function executeDecompose(prdId, prdPath, prdText) {
// context, write epic + stories, commit); 10 iterations is enough
// room. Tune via env SANDCASTLE_DECOMPOSE_ITERATIONS.
maxIterations: Number(process.env.SANDCASTLE_DECOMPOSE_ITERATIONS ?? 10),
// Stop iterating the moment the agent emits this marker. Without it,
// sandcastle re-invokes the model up to maxIterations even when the
// work is already done — the prompt instructs the agent to emit
// <promise>COMPLETE</promise> on its final line.
completionSignal: "<promise>COMPLETE</promise>",
});
} catch (e) {
console.error("✗ Decomposer dispatch failed:", e.message);

View File

@@ -237,6 +237,11 @@ async function executeDispatch() {
maxIterations: Number(
process.env.SANDCASTLE_IMPLEMENTER_ITERATIONS ?? 30,
),
// Stop iterating the moment the agent emits this marker. Without it,
// sandcastle re-invokes the model up to maxIterations even when the
// work is already done — the prompt instructs the agent to emit
// <promise>COMPLETE</promise> on its final line.
completionSignal: "<promise>COMPLETE</promise>",
});
} catch (e) {
console.error("✗ Implementer dispatch failed:", e.message);
@@ -290,6 +295,11 @@ async function executeDispatch() {
// Smaller surface than the implementer; 10 iterations is plenty.
// Tune via env SANDCASTLE_REVIEWER_ITERATIONS.
maxIterations: Number(process.env.SANDCASTLE_REVIEWER_ITERATIONS ?? 10),
// Stop iterating the moment the agent emits this marker. Without it,
// sandcastle re-invokes the model up to maxIterations even when the
// decision has already been returned — the prompt instructs the agent
// to emit <promise>COMPLETE</promise> on its final line.
completionSignal: "<promise>COMPLETE</promise>",
});
console.log(`Reviewer returned. stdout follows:\n${reviewResult.stdout}`);