Conversation
defb9db to
26e222b
Compare
26e222b to
5d4bfce
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Idle timer callback can restart spinner after Stop
- Added r.lastStatus = "" in stopLocked() to prevent the idle timer callback from restarting the spinner after Stop() is called.
- ✅ Fixed: Field
autoSelectedis set but never read- Removed the unused autoSelected field from logTarget struct and its assignment in resolveLogTarget function.
Or push these changes by commenting:
@cursor push e035df330b
Preview (e035df330b)
diff --git a/pkg/cmd/ci/logs.go b/pkg/cmd/ci/logs.go
--- a/pkg/cmd/ci/logs.go
+++ b/pkg/cmd/ci/logs.go
@@ -182,7 +182,6 @@
jobStatus string
workflowPath string
noLogsMessage string
- autoSelected bool
hasAlternates bool
alternateLabel string
}
@@ -291,6 +290,7 @@
}
r.spinner.Stop()
r.spinner = nil
+ r.lastStatus = ""
}
type followLogWriter struct {
@@ -575,7 +575,6 @@
jobKey: targetJob.JobKey,
jobStatus: targetJob.Status,
workflowPath: workflowPath,
- autoSelected: jobKey == "",
hasAlternates: len(targetJob.Attempts) > 1,
alternateLabel: alternateLabel,
}, nilYou can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Backoff not reset for status-only stream messages
- Moved backoff reset to immediately after stream.Receive() succeeds, ensuring status-only messages also reset the reconnection delay.
Or push these changes by commenting:
@cursor push 5ec2a86d7f
Preview (5ec2a86d7f)
diff --git a/pkg/api/ci.go b/pkg/api/ci.go
--- a/pkg/api/ci.go
+++ b/pkg/api/ci.go
@@ -117,6 +117,7 @@
for stream.Receive() {
msg := stream.Msg()
+ backoff = ciStreamInitialBackoff
if status := msg.GetAttemptStatus(); status != "" && onStatus != nil {
onStatus(status)
}
@@ -137,7 +138,6 @@
cursor = msg.GetNextCursor()
}
}
- backoff = ciStreamInitialBackoff
}
err = stream.Err()You can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Context cancellation produces misleading unresolved ID error
- Added check for context.Canceled/DeadlineExceeded in streamUnresolvedLogsWithFollowUX to return context error directly instead of wrapping in unresolvedLogStreamError.
- ✅ Fixed: Dead production code kept alive only by tests
- Removed resolveAttempt function and updated tests to call resolveLogTarget directly.
Or push these changes by commenting:
@cursor push f7b84f439d
Preview (f7b84f439d)
diff --git a/pkg/cmd/ci/logs.go b/pkg/cmd/ci/logs.go
--- a/pkg/cmd/ci/logs.go
+++ b/pkg/cmd/ci/logs.go
@@ -367,6 +367,10 @@
return nil
}
+ if errors.Is(jobErr, context.Canceled) || errors.Is(jobErr, context.DeadlineExceeded) {
+ return jobErr
+ }
+
attemptErr := streamLogTargetWithFollowUX(
ctx,
tokenVal,
@@ -512,20 +516,6 @@
return names
}
-// resolveAttempt finds the target attempt from a run status response.
-// It selects a job (by workflow job key flag, by job ID match, or auto-select), then
-// picks the latest attempt and prints informational messages about what was chosen.
-func resolveAttempt(resp *civ1.GetRunStatusResponse, originalID, jobKey, workflowFilter string) (string, error) {
- target, err := resolveLogTarget(resp, originalID, jobKey, workflowFilter)
- if err != nil {
- return "", err
- }
- if target.attemptID == "" {
- return "", errors.New(target.noLogsMessage)
- }
- return target.attemptID, nil
-}
-
func resolveLogTarget(resp *civ1.GetRunStatusResponse, originalID, jobKey, workflowFilter string) (logTarget, error) {
targetJob, workflowPath, err := findLogsJob(resp, originalID, jobKey, workflowFilter)
if err != nil {
diff --git a/pkg/cmd/ci/logs_test.go b/pkg/cmd/ci/logs_test.go
--- a/pkg/cmd/ci/logs_test.go
+++ b/pkg/cmd/ci/logs_test.go
@@ -232,12 +232,12 @@
},
}
- attemptID, err := resolveAttempt(resp, "run-1", "build", "")
+ target, err := resolveLogTarget(resp, "run-1", "build", "")
if err != nil {
t.Fatal(err)
}
- if attemptID != "att-2" {
- t.Fatalf("expected attempt ID %q, got %q", "att-2", attemptID)
+ if target.attemptID != "att-2" {
+ t.Fatalf("expected attempt ID %q, got %q", "att-2", target.attemptID)
}
}
@@ -254,7 +254,7 @@
},
}
- _, err := resolveAttempt(resp, "run-1", "", "")
+ _, err := resolveLogTarget(resp, "run-1", "", "")
if err == nil {
t.Fatal("expected error for job with no attempts")
}
@@ -529,6 +529,33 @@
}
}
+func TestStreamUnresolvedLogsWithFollowUXReturnsContextCancellation(t *testing.T) {
+ original := ciStreamJobAttemptLogs
+ t.Cleanup(func() { ciStreamJobAttemptLogs = original })
+
+ ciStreamJobAttemptLogs = func(
+ _ context.Context,
+ _, _ string,
+ _ api.CILogStreamTarget,
+ _ io.Writer,
+ _ func(string),
+ ) error {
+ return context.Canceled
+ }
+
+ err := streamUnresolvedLogsWithFollowUX(
+ context.Background(),
+ "token-123",
+ "org-123",
+ "id-123",
+ io.Discard,
+ newLogFollowReporter(io.Discard, false),
+ )
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("expected context.Canceled, got %v", err)
+ }
+}
+
func TestFindLogsJob_SuffixMatch(t *testing.T) {
resp := &civ1.GetRunStatusResponse{
RunId: "run-1",You can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Missing
context.DeadlineExceededcheck in transient error detection- Added context.DeadlineExceeded check to isTransientConnectError to prevent client-side deadline expirations from being treated as transient server errors.
Or push these changes by commenting:
@cursor push 0745c9cc9b
Preview (0745c9cc9b)
diff --git a/pkg/api/ci.go b/pkg/api/ci.go
--- a/pkg/api/ci.go
+++ b/pkg/api/ci.go
@@ -175,6 +175,9 @@
if errors.Is(err, context.Canceled) {
return false
}
+ if errors.Is(err, context.DeadlineExceeded) {
+ return false
+ }
switch connect.CodeOf(err) {
case connect.CodeUnavailable, connect.CodeDeadlineExceeded, connect.CodeAborted:
return trueYou can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit eeb98e3. Configure here.


Summary
depot ci logs --follownow behaves like a live terminal tail for Depot CI logs instead of looking blank while the backend is waiting for work to materialize.The command streams one attempt's persisted logs, reconnects with the server cursor when the stream is interrupted, suppresses replayed rows, and keeps all human status updates on stderr so stdout remains only log content.
Linear: https://linear.app/depot/issue/DEP-4262/m4-add-live-log-streaming-parity
What was happening
depot ci logsonly fetched stored logs. That was fine after an attempt finished, but it left active CI runs without the basic terminal workflow users expect: start a run, follow the job, watch new lines arrive.The roughest cases were the in-between states: a fresh run can exist before jobs are created, a selected job can be queued before an attempt exists, and an active attempt can have no logs yet. In those states, the old behavior either exited too early or looked stuck.
Direct attempt IDs were especially opaque because the CLI did not have run/job status context before opening the stream.
What happens now
depot ci logs --followuses the newStreamJobAttemptLogsRPC. Attempt IDs stream directly. Positional job IDs are sent asjob_idso the API resolves the latest attempt server-side. Run IDs still resolve throughGetRunStatus; if a run has multiple jobs,--jobremains a workflow job-key selector for that run.While
--followis waiting, users now see state-specific progress on stderr: waiting for jobs to be created, waiting for a queued job to start, following a selected job/attempt, waiting for logs with the current attempt status, and no-log stream-ended messages. Interactive terminals get a spinner during waits; non-interactive terminals get plain status lines.For direct attempt or job IDs, the API sends attempt status updates over the stream, so the CLI can show messages like
Waiting for logs from attempt g39s51gj45 (status: failed)...instead of a status-free spinner.Terminal no-log cases are explicit instead of silent. For example, skipped jobs report that no logs were produced, and empty completed streams say the stream ended without receiving logs. Normal log bytes still go to stdout, so piping and redirecting logs stays clean.
The normal non-follow path still uses
GetJobAttemptLogs, so stored log fetching remains the same shape while gaining clearer empty-log messages.Anything else?
This depends on the API streaming RPC in https://github.com/depot/api/pull/3613.
Timestamps, parallel-step grouping, and JSON formatting stay out of this slice. The stream prints the same body-only lines as the existing logs command so this PR gives users live parity first without changing the display contract.
Note
Medium Risk
Adds a new CI log streaming RPC and significantly changes
depot ci logsbehavior to reconnect, dedupe, and retry while runs/jobs are pending, which could affect log output and error handling for active runs.Overview
depot ci logsgains a--followmode that streams live job/attempt logs (and status updates) while keeping log content on stdout and progress/status UX on stderr (spinner in interactive terminals).This introduces a new
StreamJobAttemptLogsRPC and client wrapper with transient-error reconnection, cursor resume, exponential backoff, and duplicate-line suppression, plus new run/job resolution logic that can retry while jobs/attempts are still pending and emits explicit “no logs produced” messages for terminal/skipped cases.Reviewed by Cursor Bugbot for commit bba90ab. Bugbot is set up for automated code reviews on this repo. Configure here.