Skip to content

Commit 3b3272b

Browse files
committed
Suppress gob run output on success, dump on failure
gob run now waits silently instead of streaming output. On success, it shows a summary with helper commands to inspect output. On failure (including signal kills), it dumps full stdout/stderr then the summary. This reduces noise for AI agents that only need output when diagnosing failures.
1 parent e2057c7 commit 3b3272b

File tree

5 files changed

+156
-25
lines changed

5 files changed

+156
-25
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- `gob run` now suppresses command output on success; on failure, full output is dumped after completion
13+
1014
### Removed
1115

1216
- `await-any` command

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,9 @@ Do NOT use `gob` for:
179179

180180
- `gob add <cmd>` - Start command in background, returns job ID
181181
- `gob add --description "context" <cmd>` - Start with description for context
182-
- `gob run <cmd>` - Run and wait for completion (equivalent to `gob add` + `gob await`)
182+
- `gob run <cmd>` - Run and wait for completion (output on failure only)
183183
- `gob run --description "context" <cmd>` - Run with description for context
184-
- `gob await <job_id>` - Wait for job to finish, stream output
184+
- `gob await <job_id>` - Wait for job to finish, stream output in real-time
185185
- `gob list` - List jobs with IDs, status, and descriptions
186186
- `gob logs <job_id>` - View stdout and stderr (stdout→stdout, stderr→stderr)
187187
- `gob stdout <job_id>` - View current stdout (useful if job may be stuck)

cmd/follow.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,90 @@ func followJob(jobID string, pid int, stdoutPath string, avgDurationMs int64) (F
126126
return result, nil
127127
}
128128

129+
// waitForJob waits for a job to complete without streaming output.
130+
// It monitors for completion, stuck condition, or interruption using file mod times
131+
// for stuck detection instead of a Follower.
132+
func waitForJob(pid int, stdoutPath string, avgDurationMs int64) (FollowResult, error) {
133+
// Derive stderr path from stdout path
134+
stderrPath := strings.Replace(stdoutPath, ".stdout.log", ".stderr.log", 1)
135+
136+
// Wait for log files to exist
137+
for i := 0; i < 50; i++ {
138+
_, errStdout := os.Stat(stdoutPath)
139+
_, errStderr := os.Stat(stderrPath)
140+
if errStdout == nil && errStderr == nil {
141+
break
142+
}
143+
time.Sleep(10 * time.Millisecond)
144+
}
145+
146+
// Check if log files exist
147+
if _, err := os.Stat(stdoutPath); os.IsNotExist(err) {
148+
return FollowResult{}, fmt.Errorf("stdout log file not found: %s", stdoutPath)
149+
}
150+
if _, err := os.Stat(stderrPath); os.IsNotExist(err) {
151+
return FollowResult{}, fmt.Errorf("stderr log file not found: %s", stderrPath)
152+
}
153+
154+
// Calculate stuck detection threshold
155+
var stuckTimeoutMs int64
156+
if avgDurationMs == 0 {
157+
stuckTimeoutMs = DefaultStuckTimeoutMs
158+
} else {
159+
stuckTimeoutMs = avgDurationMs + NoOutputWindowMs
160+
}
161+
162+
stuckTimeout := time.Duration(stuckTimeoutMs) * time.Millisecond
163+
noOutputWindow := time.Duration(NoOutputWindowMs) * time.Millisecond
164+
165+
// Set up signal handling - on Ctrl+C, just exit (job continues in background)
166+
sigCh := make(chan os.Signal, 1)
167+
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
168+
defer signal.Stop(sigCh)
169+
170+
result := FollowResult{}
171+
startTime := time.Now()
172+
173+
ticker := time.NewTicker(100 * time.Millisecond)
174+
defer ticker.Stop()
175+
176+
for {
177+
select {
178+
case <-sigCh:
179+
// Interrupted — job continues in background
180+
return result, nil
181+
case <-ticker.C:
182+
// Check if process completed
183+
if !process.IsProcessRunning(pid) {
184+
result.Completed = true
185+
return result, nil
186+
}
187+
188+
// Check for stuck condition using file mod times
189+
elapsed := time.Since(startTime)
190+
if elapsed > stuckTimeout {
191+
lastOutput := lastFileModTime(stdoutPath, stderrPath)
192+
if time.Since(lastOutput) > noOutputWindow {
193+
result.PossiblyStuck = true
194+
return result, nil
195+
}
196+
}
197+
}
198+
}
199+
}
200+
201+
// lastFileModTime returns the most recent modification time of the given files.
202+
func lastFileModTime(paths ...string) time.Time {
203+
var latest time.Time
204+
for _, p := range paths {
205+
info, err := os.Stat(p)
206+
if err == nil && info.ModTime().After(latest) {
207+
latest = info.ModTime()
208+
}
209+
}
210+
return latest
211+
}
212+
129213
// CalculateStuckTimeout returns the stuck detection timeout based on average duration
130214
// No data: 5 minutes
131215
// Has data: avg + 1 minute

cmd/run.go

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@ var runCmd = &cobra.Command{
1515
Use: "run [--description <desc>] [--] <command> [args...]",
1616
Short: "Add a job and wait for it to complete",
1717
DisableFlagParsing: true,
18-
Long: `Add a new background job and immediately wait for it to complete.
18+
Long: `Add a new background job and wait for it to complete.
1919
20-
This is equivalent to running 'gob add' followed by 'gob await'.
20+
The job is started as a detached process. Output is suppressed on success
21+
and dumped on failure. Use 'gob add' + 'gob await' for real-time streaming.
2122
22-
The job is started as a detached process and its output is streamed in real-time.
23-
The command exits with the job's exit code when it completes.
23+
On success: prints a summary with helper commands to inspect output.
24+
On failure: prints the full stdout and stderr, then a summary.
2425
2526
Examples:
2627
# Run a build and wait for it
@@ -40,7 +41,9 @@ Examples:
4041
gob run -d "Run tests" -- npm test
4142
4243
Output:
43-
Shows job statistics (if available), then streams the job's output.
44+
Shows job statistics (if available), then waits silently.
45+
On success: summary with commands to view output.
46+
On failure: full stdout/stderr followed by summary.
4447
4548
Exit codes:
4649
Exits with the job's exit code (0 if successful, non-zero otherwise).
@@ -165,21 +168,21 @@ Exit codes:
165168
fmt.Printf(" Stuck detection: timeout after %s\n", formatDuration(stuckTimeout))
166169
}
167170

168-
// Follow the output until completion
169-
followResult, err := followJob(result.Job.ID, result.Job.PID, result.Job.StdoutPath, avgDurationMs)
171+
// Wait for job to complete (without streaming output)
172+
waitResult, err := waitForJob(result.Job.PID, result.Job.StdoutPath, avgDurationMs)
170173
if err != nil {
171174
return err
172175
}
173176

174-
if followResult.PossiblyStuck {
177+
if waitResult.PossiblyStuck {
175178
fmt.Printf("\nJob %s possibly stuck (no output for 1m)\n", result.Job.ID)
176179
fmt.Printf(" gob stdout %s # check current output\n", result.Job.ID)
177180
fmt.Printf(" gob await %s # continue waiting with output\n", result.Job.ID)
178181
fmt.Printf(" gob stop %s # stop the job\n", result.Job.ID)
179182
return nil
180183
}
181184

182-
if !followResult.Completed {
185+
if !waitResult.Completed {
183186
fmt.Printf("\nJob %s continues running in background\n", result.Job.ID)
184187
fmt.Printf(" gob await %s # wait for completion with live output\n", result.Job.ID)
185188
fmt.Printf(" gob stop %s # stop the job\n", result.Job.ID)
@@ -192,9 +195,23 @@ Exit codes:
192195
return err
193196
}
194197

198+
// On failure (non-zero or killed by signal), dump stdout/stderr
199+
if job.ExitCode == nil || *job.ExitCode != 0 {
200+
if err := printJobOutput(job); err != nil {
201+
return err
202+
}
203+
}
204+
195205
// Show summary
196206
printJobSummary(job)
197207

208+
// On success, show helper commands for inspecting output
209+
if job.ExitCode != nil && *job.ExitCode == 0 {
210+
fmt.Printf(" gob stdout %s # view stdout\n", result.Job.ID)
211+
fmt.Printf(" gob stderr %s # view stderr\n", result.Job.ID)
212+
fmt.Printf(" gob logs %s # view both\n", result.Job.ID)
213+
}
214+
198215
// Exit with job's exit code
199216
if job.ExitCode != nil && *job.ExitCode != 0 {
200217
os.Exit(*job.ExitCode)

test/run.bats

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,18 @@ load 'test_helper'
1818
run "$JOB_CLI" run echo "hello world"
1919
assert_success
2020
assert_output --partial "Running job"
21-
assert_output --partial "hello world"
2221
assert_output --partial "completed"
22+
assert_output --partial "Exit code: 0"
2323
}
2424

25-
@test "run command shows job output" {
26-
run "$JOB_CLI" run -- sh -c "echo 'test output'; echo 'more output'"
25+
@test "run command suppresses output on success" {
26+
# Use arithmetic so the output ("val_23", "val_34") differs from the command text
27+
run "$JOB_CLI" run -- sh -c 'echo val_$((20+3)); echo val_$((30+4))'
2728
assert_success
28-
assert_output --partial "test output"
29-
assert_output --partial "more output"
29+
refute_output --partial "val_23"
30+
refute_output --partial "val_34"
31+
assert_output --partial "gob stdout"
32+
assert_output --partial "gob logs"
3033
}
3134

3235
@test "run command shows summary with command" {
@@ -87,13 +90,13 @@ load 'test_helper'
8790
run "$JOB_CLI" run "echo hello world"
8891
assert_success
8992
assert_output --partial "Running job"
90-
assert_output --partial "hello world"
9193
}
9294

93-
@test "run command shows stderr output" {
94-
run "$JOB_CLI" run -- sh -c "echo 'stderr message' >&2"
95+
@test "run command suppresses stderr on success" {
96+
# Use arithmetic so the output ("err_45") differs from the command text
97+
run "$JOB_CLI" run -- sh -c 'echo err_$((40+5)) >&2'
9598
assert_success
96-
assert_output --partial "stderr message"
99+
refute_output --partial "err_45"
97100
}
98101

99102
@test "run command attaches to already running job" {
@@ -161,15 +164,38 @@ load 'test_helper'
161164
assert_output --partial "Expected duration if failure:"
162165
}
163166

164-
@test "run command streams output in real-time" {
165-
# Start a job that outputs something and completes
166-
run "$JOB_CLI" run -- sh -c "echo 'first'; sleep 0.2; echo 'second'"
167+
@test "run command does not show output on success" {
168+
# Use arithmetic so the output ("out_56", "out_67") differs from the command text
169+
run "$JOB_CLI" run -- sh -c 'echo out_$((50+6)); sleep 0.2; echo out_$((60+7))'
167170
assert_success
168-
assert_output --partial "first"
169-
assert_output --partial "second"
171+
refute_output --partial "out_56"
172+
refute_output --partial "out_67"
170173
assert_output --partial "completed"
171174
}
172175

176+
@test "run command shows output on failure" {
177+
run "$JOB_CLI" run -- sh -c "echo 'failure stdout'; echo 'failure stderr' >&2; exit 1"
178+
assert_failure 1
179+
assert_output --partial "failure stdout"
180+
assert_output --partial "failure stderr"
181+
assert_output --partial "Exit code: 1"
182+
}
183+
184+
@test "run command shows helper commands on success" {
185+
run "$JOB_CLI" run true
186+
assert_success
187+
assert_output --partial "gob stdout"
188+
assert_output --partial "gob stderr"
189+
assert_output --partial "gob logs"
190+
}
191+
192+
@test "run command does not show helper commands on failure" {
193+
run "$JOB_CLI" run false
194+
assert_failure
195+
refute_output --partial "gob stdout"
196+
refute_output --partial "gob logs"
197+
}
198+
173199
@test "run command with --description stores description" {
174200
run "$JOB_CLI" run --description "Run test description" true
175201
assert_success

0 commit comments

Comments
 (0)