-
Notifications
You must be signed in to change notification settings - Fork 0
fix(spec): stop announcing a review that never started (BUG-081, #989) #994
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ import ( | |
| "os" | ||
| "os/exec" | ||
| "path/filepath" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/spf13/cobra" | ||
|
|
@@ -38,6 +39,15 @@ var lookPath = exec.LookPath | |
|
|
||
| // runCommand is a seam for the launch itself, so the command's wiring is | ||
| // testable without spawning a reviewer that would take half an hour. | ||
| // sessionAlive reports whether a detached tmux session still exists, and | ||
| // sleepFor paces the probe. Both are seams so tests can simulate a launch that | ||
| // died without needing tmux or real time. | ||
| var sessionAlive = func(session string) bool { | ||
| return exec.Command("tmux", "has-session", "-t", session).Run() == nil | ||
| } | ||
|
|
||
| var sleepFor = time.Sleep | ||
|
|
||
| var runCommand = func(dir string, argv []string) error { | ||
| c := exec.Command(argv[0], argv[1:]...) | ||
| c.Dir = dir | ||
|
|
@@ -192,6 +202,9 @@ is the only record of how.`, | |
| if err := runCommand(repoRoot, launch); err != nil { | ||
| return fmt.Errorf("starting the tmux session: %w", err) | ||
| } | ||
| if err := confirmLaunched(session, transcript); err != nil { | ||
| return err | ||
| } | ||
| cmd.Printf("[OK] Review running detached. Watch it with:\n\n tmux attach -t %s\n\n", session) | ||
| cmd.Printf("When it finishes, %s carries the verdict and archive reads it.\n", spec.ReviewFile) | ||
| return nil | ||
|
|
@@ -210,6 +223,63 @@ is the only record of how.`, | |
| return cmd | ||
| } | ||
|
|
||
| // confirmLaunched turns "the session was created" into "the reviewer survived | ||
| // startup" — two claims the launcher used to conflate (#989). | ||
| // | ||
| // `tmux new-session -d` exits 0 the moment the session exists; the process | ||
| // inside it can be dead a fraction of a second later. Measured on the failure | ||
| // that prompted this: the session lasted 0.54s and the launcher reported a | ||
| // running review over a 0-byte transcript, so the archive gate's much later | ||
| // "no review.md" read as "you forgot to run it" rather than "it died". | ||
| // | ||
| // The window is deliberately modest. It proves the reviewer got past startup, | ||
| // which is where a bad credential, a missing binary or an unreachable model all | ||
| // fail; it does NOT promise the run will finish. A death at minute three is | ||
| // inherently unwatched in detached mode, and `spec archive` refusing without a | ||
| // review.md stays the backstop for that. | ||
| func confirmLaunched(session, transcript string) error { | ||
| const ( | ||
| window = 3 * time.Second // ~6x the slowest observed startup failure | ||
| step = 250 * time.Millisecond // cheap enough to poll, coarse enough not to spin | ||
| ) | ||
| for waited := time.Duration(0); waited < window; waited += step { | ||
| sleepFor(step) | ||
| if sessionAlive(session) { | ||
| continue | ||
| } | ||
| return fmt.Errorf("the review died on startup — tmux session %q is already gone.\n%s\n"+ | ||
| "Nothing was reviewed and %s was not written; re-run with --foreground to watch it fail live", | ||
| session, reviewerLastWords(transcript), spec.ReviewFile) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // reviewerLastWords quotes what the dead reviewer actually said. An error that | ||
| // reproduces the output it received beats one that only reports that parsing or | ||
| // launching failed — the original defect discarded the reason entirely, which is | ||
| // why nobody could tell a broken credential from a missing binary. | ||
| func reviewerLastWords(transcript string) string { | ||
| for _, src := range []struct{ label, path string }{ | ||
| {"stderr", spec.StderrPath(transcript)}, | ||
| {"transcript", transcript}, | ||
| } { | ||
| b, err := os.ReadFile(src.path) | ||
| if err != nil { | ||
| continue | ||
| } | ||
| out := strings.TrimSpace(string(b)) | ||
| if out == "" { | ||
| continue | ||
| } | ||
| const max = 800 | ||
| if len(out) > max { | ||
| out = out[:max] + fmt.Sprintf("\n… (truncated; full %s at %s)", src.label, src.path) | ||
|
Comment on lines
+274
to
+276
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Verify that truncation does not use byte indexing.
rg -n -C 4 'func reviewerLastWords|len\(out\)|out\[:max\]|\[\]rune\(out\)' cli/internal/cmd/spec.goRepository: mlorentedev/dotfiles Length of output: 1084 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- implementation and nearby tests ---'
sed -n '240,290p' cli/internal/cmd/spec.go
rg -n -C 3 'reviewerLastWords|truncated|800|StderrPath' cli --glob '*_test.go' --glob '*.go'
printf '%s\n' '--- UTF-8 boundary behavior of the current expression ---'
python3 - <<'PY'
out = "é" * 800
max_len = 800
try:
truncated = out[:max_len]
truncated.encode("utf-8")
print("ASCII-safe boundary: yes")
except UnicodeEncodeError as exc:
print(f"UTF-8 boundary error: {exc}")
for sample in ("é" * 801, "🙂" * 801, "a" * 799 + "é" * 2):
prefix = sample[:max_len]
print({
"input_runes": len(sample),
"input_bytes": len(sample.encode()),
"prefix_runes": len(prefix),
"prefix_bytes": len(prefix.encode()),
"prefix_ends_on_utf8_boundary": prefix.encode().decode("utf-8") == prefix,
})
PYRepository: mlorentedev/dotfiles Length of output: 13446 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
# Model Go string slicing: index bytes, then decode the resulting bytes.
samples = {
"ASCII plus 2-byte rune at boundary": "a" * 799 + "é" + "z",
"ASCII plus 3-byte rune at boundary": "a" * 799 + "€" + "z",
"ASCII plus 4-byte rune at boundary": "a" * 799 + "🙂" + "z",
"mixed diagnostic": "é" * 399 + "🙂" + "tail",
}
for name, text in samples.items():
data = text.encode("utf-8")
prefix = data[:800]
try:
prefix.decode("utf-8")
valid = True
except UnicodeDecodeError:
valid = False
print({
"case": name,
"input_bytes": len(data),
"go_condition_len_gt_800": len(data) > 800,
"go_prefix_bytes": len(prefix),
"go_prefix_valid_utf8": valid,
"go_prefix_runes_if_valid": len(prefix.decode("utf-8")) if valid else None,
})
PYRepository: mlorentedev/dotfiles Length of output: 896 Truncate diagnostic text without splitting UTF-8 When 🤖 Prompt for AI Agents |
||
| } | ||
| return "It wrote, on " + src.label + ":\n" + out | ||
| } | ||
| return "It wrote nothing to either the transcript or its stderr, so the failure happened before the reviewer produced output at all." | ||
| } | ||
|
|
||
| func newSpecInitCmd() *cobra.Command { | ||
| var ( | ||
| issueNum int | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift
Use a
Depsstruct for the new external seams.sessionAlivecalls tmux andsleepForcontrols time through mutable package globals. Pass these dependencies through aDepsstruct to the command constructor or handler. This keeps the detached launch path isolated between tests.As per coding guidelines:
cli/**/*.gomust inject external surfaces through aDepsstruct.🤖 Prompt for AI Agents
Source: Coding guidelines