Skip to content

Commit 49ffdad

Browse files
luohahaclaude
andcommitted
fix(orchestrator): make suggestions.md a mailbox, drop the byte cursor
The async human-feedback channel tracked delivery with a byte offset (.goaloop/suggestions.cursor) into an append-only suggestions.md. That assumed the file only ever grows: if the human edited or deleted earlier notes, the offset drifted — a shorter rewrite could clamp the cursor to end-of-file and silently swallow freshly added guidance. Replace it with a mailbox model and no cursor at all. suggestions.md now holds undelivered guidance; each fresh attempt atomically CLAIMS its contents (os.rename aside), injects them into the brief, archives them to .goaloop/suggestions.delivered.md (stamped per attempt), and clears the file. The atomic rename is the whole trick: a note appended while a claim is in flight lands either in that batch or in a fresh file the next attempt picks up — never lost, never delivered twice, with nothing to drift when the human edits or deletes earlier notes. The inflight file persists until the archive write succeeds, so a crash mid-claim is recovered (at worst delivered twice — harmless — never dropped) on the next call. Bonus: each note now reaches exactly one attempt and history moves to the archive, instead of stacking up in the live file (which, in a long run, left the Runner guessing which of several blocks was current). Docs (README, design.md, goal-run skill) updated to describe the mailbox. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CHb1FkoMGfDfwmeJR5hBG6
1 parent 7c2e40b commit 49ffdad

4 files changed

Lines changed: 64 additions & 48 deletions

File tree

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,10 +182,12 @@ and waits for your approval before the next one; release it with
182182
`goaloop continue <name>`. (`pass`/`blocked`/`error` are terminal, and
183183
`in_progress` resumes automatically — only `advanced` waits.)
184184

185-
`suggestions.md` is an optional async channel: append a one-off note and
186-
the next fresh attempt sees the text added since it was last read, once.
187-
Use `goal.md` for permanent/structural changes, `suggestions.md` for
188-
transient nudges (e.g. left while AFK).
185+
`suggestions.md` is an optional async channel — a mailbox: append a one-off
186+
note and the next fresh attempt claims it (exactly once), archiving it to
187+
`.goaloop/suggestions.delivered.md` and clearing the file. Edit or delete
188+
freely before it's claimed; the claim is atomic, so nothing is lost or
189+
double-delivered. Use `goal.md` for permanent/structural changes,
190+
`suggestions.md` for transient nudges (e.g. left while AFK).
189191

190192
## Workspace contents
191193

docs/design.md

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -244,10 +244,10 @@ tokens.
244244
│ ├── 002.md
245245
│ └── ... # Append-only: each file is written once, never modified
246246
└── .goaloop/ # Orchestrator-private state (not part of the goal record)
247-
├── state.json # Checkpoint: active session id for crash/quota resume
247+
├── state.json # Checkpoint: active session id + cumulative counters/cost for resume
248248
├── status.txt # Current one-line orchestrator status (read by /goal-run)
249-
├── attempt_complete.json # Last completed attempt's {attempt, status, cost_usd}
250-
├── suggestions.cursor # Byte offset into suggestions.md already shown
249+
├── attempt_complete.json # Last completed attempt's {attempt, status, cost_usd, total_cost_usd}
250+
├── suggestions.delivered.md # Archive of consumed suggestions.md notes, stamped per attempt
251251
├── continue.json # copilot-mode approval token (written by `goaloop continue`)
252252
├── orchestrator.log # Per-attempt log: Runner messages, tool calls, results
253253
└── pipeline.pid # PID of the running orchestrator (for status/stop)
@@ -534,13 +534,17 @@ just the starting configuration.
534534
**`suggestions.md` — transient / per-attempt.**
535535

536536
For a one-off note that does not belong in the goal spec (e.g. something left
537-
while AFK — "try lock granularity next"), the human appends a line to
538-
`<workspace>/suggestions.md`. On each FRESH attempt the orchestrator injects
539-
the text added since a stored cursor (`.goaloop/suggestions.cursor`) into the
540-
Runner's brief as a "Human guidance (NEW)" section, then advances the cursor
541-
— so each note is shown to exactly one attempt and not repeated. Use
542-
`goal.md` for changes that should persist; use `suggestions.md` for transient
543-
nudges.
537+
while AFK — "try lock granularity next"), the human appends to
538+
`<workspace>/suggestions.md`. The file is a **mailbox, not a log**: whatever it
539+
holds is undelivered. On each FRESH attempt the orchestrator atomically
540+
*claims* its contents (renaming it aside), injects them into the Runner's brief
541+
as a "Human guidance (NEW)" section, archives them to
542+
`.goaloop/suggestions.delivered.md`, and clears the file — so each note reaches
543+
exactly one attempt. The atomic rename is what makes this race-free: a note
544+
appended while a claim is in flight lands in either that batch or a fresh file
545+
the next attempt picks up — never lost, never delivered twice, with no byte
546+
cursor to drift when the human edits or deletes earlier notes. Use `goal.md`
547+
for changes that should persist; use `suggestions.md` for transient nudges.
544548

545549
**The Manager distinguishes messages for itself vs. goal edits.**
546550

goaloop/orchestrator.py

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,15 @@ def __init__(
8787
self.status_path = self.state_dir / "status.txt"
8888
self.complete_path = self.state_dir / "attempt_complete.json"
8989
self.continue_path = self.state_dir / "continue.json"
90-
# Async human feedback channel (optional). The human appends notes to
91-
# suggestions.md; we inject anything past the cursor as NEW into the
92-
# next fresh attempt's brief, then advance the cursor.
90+
# Async human feedback channel (optional). suggestions.md is a MAILBOX,
91+
# not a log: whatever it holds is undelivered guidance. Each fresh
92+
# attempt atomically CLAIMS its contents (rename aside), injects them as
93+
# NEW into the brief, archives them, and clears the file — so there is
94+
# no byte cursor to drift when the human edits/deletes (the old model).
9395
self.suggestions_path = self.ws / "suggestions.md"
94-
self.cursor_path = self.state_dir / "suggestions.cursor"
96+
# Atomic-claim staging + permanent archive of delivered notes.
97+
self.suggestions_inflight = self.state_dir / "suggestions.inflight"
98+
self.suggestions_archive = self.state_dir / "suggestions.delivered.md"
9599

96100
self.adapter = ClaudeAdapter(
97101
cwd=str(self.ws),
@@ -189,34 +193,39 @@ def _end_error(self, attempt: int, reason: str) -> None:
189193
self._mark_complete(attempt, "error", None)
190194
self._clear_active()
191195

192-
def _suggestions_section(self) -> str:
193-
"""Build the NEW-since-cursor block from suggestions.md, then advance
194-
the cursor (these notes are now delivered into a session).
195-
196-
Returns "" when there's no suggestions.md or nothing new. Only NEW
197-
text is injected — older notes stay in the file for the human; the
198-
Runner can read it directly if it wants history. goal.md remains the
199-
channel for permanent/structural guidance; suggestions.md is for
200-
transient per-attempt notes (e.g. left while AFK).
196+
def _consume_suggestions(self, n: int) -> str:
197+
"""Claim any pending human notes from suggestions.md (the mailbox) and
198+
return them as a brief section, or "" if none.
199+
200+
suggestions.md holds undelivered guidance; we CLAIM it by atomically
201+
renaming it aside. That rename is the whole trick: a human append that
202+
races the claim lands either in the batch we just took or in a fresh
203+
file the next attempt picks up — never lost, never delivered twice, and
204+
with no byte cursor to drift when the human edits or deletes earlier
205+
notes. The claimed text is appended to suggestions.delivered.md
206+
(stamped with the attempt) so history survives the cleared live file.
207+
goal.md stays the channel for permanent/structural guidance.
208+
209+
Crash-safety: the inflight file persists until the archive write
210+
succeeds, so a crash mid-claim leaves it for the next call to recover
211+
(at worst delivered twice — harmless — never dropped).
201212
"""
202-
if not self.suggestions_path.exists():
203-
return ""
204-
content = self.suggestions_path.read_text()
205-
if not content.strip():
206-
return ""
207-
cursor = 0
208-
if self.cursor_path.exists():
213+
self.state_dir.mkdir(parents=True, exist_ok=True)
214+
if not self.suggestions_inflight.exists():
215+
if not self.suggestions_path.exists():
216+
return ""
209217
try:
210-
cursor = int(self.cursor_path.read_text().strip())
211-
except ValueError:
212-
cursor = 0
213-
cursor = max(0, min(cursor, len(content)))
214-
new = content[cursor:].strip()
215-
if not new:
218+
self.suggestions_path.rename(self.suggestions_inflight)
219+
except OSError:
220+
return ""
221+
text = self.suggestions_inflight.read_text().strip()
222+
if not text:
223+
self.suggestions_inflight.unlink(missing_ok=True)
216224
return ""
217-
self.state_dir.mkdir(parents=True, exist_ok=True)
218-
self.cursor_path.write_text(str(len(content))) # delivered — advance
219-
return f"\n## Human guidance (NEW — address this attempt)\n\n{new}\n"
225+
with self.suggestions_archive.open("a") as fh:
226+
fh.write(f"\n## Delivered to attempt {n:03d}\n\n{text}\n")
227+
self.suggestions_inflight.unlink(missing_ok=True)
228+
return f"\n## Human guidance (NEW — address this attempt)\n\n{text}\n"
220229

221230
def _wait_for_continue(self, n: int) -> None:
222231
"""Copilot mode: block until the human approves the next attempt.
@@ -235,7 +244,7 @@ def _wait_for_continue(self, n: int) -> None:
235244
self.log("[orchestrator] approval received — continuing")
236245

237246
def _build_brief(self, n: int) -> str:
238-
guidance = self._suggestions_section()
247+
guidance = self._consume_suggestions(n)
239248
return f"""Workspace: {self.ws}
240249
This is attempt {n:03d}; write your attempt record to attempts/{n:03d}.md.
241250
{guidance}

skills/goal-run/SKILL.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,11 @@ durable guidance channels, by intent serving different purposes.
153153
reads the updated spec naturally — no relay needed. Propose the edit,
154154
make it on the user's confirmation; no restart required.
155155
- **Transient per-attempt note**: append a line to
156-
`~/.goaloop/<name>/suggestions.md`. The next fresh attempt sees the
157-
text added since it was last read, once (then it's not repeated). Use
158-
this for one-off nudges (e.g. dropped while AFK) rather than changes
159-
that should persist — those belong in `goal.md`.
156+
`~/.goaloop/<name>/suggestions.md` (a mailbox). The next fresh attempt
157+
claims it exactly once — injected into the brief, archived to
158+
`.goaloop/suggestions.delivered.md`, and the file cleared. Use this for
159+
one-off nudges (e.g. dropped while AFK) rather than changes that should
160+
persist — those belong in `goal.md`.
160161
- **Stop the orchestrator**: `goaloop stop <name>` (sends SIGTERM; it
161162
exits after the in-flight attempt's process settles).
162163

0 commit comments

Comments
 (0)