Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1400e91
fix(workspace): prevent invalid IDs from hanging creation
skevetter Oct 20, 2026
13d7f38
fix(workspace): keep detached recovery retryable on Windows
skevetter Oct 20, 2026
17cdd82
fix(desktop): isolate workspace status delivery failures
skevetter Oct 20, 2026
bbd0e12
fix(workspace): log retryable task reconciliation errors
skevetter Oct 20, 2026
20d76ec
fix(desktop): preserve source names and log confirmation
skevetter Oct 20, 2026
089c401
fix(workspace): recover interrupted detached launches
skevetter Oct 21, 2026
3de19a7
fix(task): preserve launch cleanup across quiescing and state reads
skevetter Oct 21, 2026
ef40901
fix(task): replace Windows state while readers remain open
skevetter Oct 21, 2026
69c5ae6
Merge branch 'main' into fix/1471-uppercase-workspace-task-hang
skevetter Oct 21, 2026
b257211
fix(task): synchronize state reads and recognize exited workers
skevetter Oct 21, 2026
b7caed6
fix(desktop): preserve log-save warnings across status refreshes
skevetter Oct 22, 2026
d32de4a
chore: merge current main into workspace recovery fix
skevetter Oct 22, 2026
50d2e61
chore(ci): fix actionlint subprocess deadlock on macOS
skevetter Oct 22, 2026
c9f3ffb
fix(task): reject incomplete task inventories before cleanup
skevetter Oct 22, 2026
109c6fb
chore: reconcile Windows lifecycle coverage with CI test tiers
skevetter Oct 22, 2026
f334a7c
test(ssh): capture bounded proxy command diagnostics
skevetter Oct 22, 2026
3370ae6
fix(desktop): preserve context edits during option loading
skevetter Oct 22, 2026
dbe74f1
fix(desktop): report current context option load failures
skevetter Oct 22, 2026
4592f5c
fix(task): bind detached worker admission to process identity
skevetter Oct 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ jobs:
matrix: ${{ fromJSON(needs.plan.outputs.unit) }}
runs-on: ${{ matrix.runner }}
timeout-minutes: ${{ matrix.timeout_minutes }}
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

Expand Down
33 changes: 23 additions & 10 deletions cmd/provider/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"io"
"os"
"strings"
"testing"

"github.com/devsy-org/devsy/pkg/config"
Expand Down Expand Up @@ -90,24 +91,36 @@ type providerListOutput struct {
err error
}

func captureProviderListOutput(t *testing.T, render func() error) providerListOutput {
func captureProviderListOutput(t *testing.T, render func() error) (output providerListOutput) {
t.Helper()
read, write, err := os.Pipe()
capture, err := os.CreateTemp(t.TempDir(), "stdout")
require.NoError(t, err)
previous := os.Stdout
os.Stdout = write
os.Stdout = capture
defer func() {
os.Stdout = previous
_ = write.Close()
_ = read.Close()
output.err = errors.Join(output.err, capture.Close())
}()
renderErr := render()
os.Stdout = previous
closeErr := write.Close()
data, readErr := io.ReadAll(read)
readCloseErr := read.Close()
_, seekErr := capture.Seek(0, io.SeekStart)
data, readErr := io.ReadAll(capture)
return providerListOutput{
text: string(data),
err: errors.Join(renderErr, closeErr, readErr, readCloseErr),
err: errors.Join(renderErr, seekErr, readErr),
}
}

func TestCaptureProviderListOutputLargeOutput(t *testing.T) {
want := strings.Repeat("0123456789abcdef", 1<<16)
renderErr := errors.New("render failed")
previous := os.Stdout
output := captureProviderListOutput(t, func() error {
n, err := io.WriteString(os.Stdout, want)
require.NoError(t, err)
require.Equal(t, len(want), n)
return renderErr
})
require.Equal(t, want, output.text)
require.ErrorIs(t, output.err, renderErr)
require.Same(t, previous, os.Stdout)
}
32 changes: 24 additions & 8 deletions cmd/secrets/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import (
"encoding/json"
"io"
"os"
"strings"
"testing"
"time"

"github.com/devsy-org/devsy/pkg/config"
devsysecrets "github.com/devsy-org/devsy/pkg/secrets"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)

Expand Down Expand Up @@ -141,23 +143,37 @@ func (s *ListTestSuite) TestListEntriesReportsAvailabilityInJSONAndPlainOutput()

func captureListOutput(t *testing.T, render func()) string {
t.Helper()
read, write, err := os.Pipe()
capture, err := os.CreateTemp(t.TempDir(), "stdout")
if err != nil {
t.Fatal(err)
}
previous := os.Stdout
os.Stdout = write
defer func() { os.Stdout = previous }()
os.Stdout = capture
defer func() {
os.Stdout = previous
if err := capture.Close(); err != nil {
t.Error(err)
}
}()
render()
if err := write.Close(); err != nil {
if _, err := capture.Seek(0, io.SeekStart); err != nil {
t.Fatal(err)
}
output, err := io.ReadAll(read)
output, err := io.ReadAll(capture)
if err != nil {
t.Fatal(err)
}
if err := read.Close(); err != nil {
t.Fatal(err)
}
return string(output)
}

func TestCaptureListOutputLargeOutput(t *testing.T) {
want := strings.Repeat("0123456789abcdef", 1<<16)
previous := os.Stdout
output := captureListOutput(t, func() {
n, err := io.WriteString(os.Stdout, want)
require.NoError(t, err)
require.Equal(t, len(want), n)
})
require.Equal(t, want, output)
require.Same(t, previous, os.Stdout)
}
33 changes: 25 additions & 8 deletions cmd/workspace/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"io"
"os"
"strings"
"testing"

"github.com/devsy-org/devsy/pkg/client"
Expand Down Expand Up @@ -57,20 +58,36 @@ func (f *fakeWorkspaceClient) WorkspaceConfig() *provider.Workspace { return f.c
func (f *fakeWorkspaceClient) Lock(context.Context) error { return nil }
func (f *fakeWorkspaceClient) Unlock() {}

// captureStdout runs fn while redirecting os.Stdout to a pipe and returns
// everything written.
// captureStdout runs fn while redirecting os.Stdout to a temporary file and
// returns everything written.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
orig := os.Stdout
r, w, err := os.Pipe()
capture, err := os.CreateTemp(t.TempDir(), "stdout")
require.NoError(t, err)
os.Stdout = w
defer func() { os.Stdout = orig }()
orig := os.Stdout
os.Stdout = capture
defer func() {
os.Stdout = orig
require.NoError(t, capture.Close())
}()

fn()

require.NoError(t, w.Close())
out, err := io.ReadAll(r)
_, err = capture.Seek(0, io.SeekStart)
require.NoError(t, err)
out, err := io.ReadAll(capture)
require.NoError(t, err)
return string(out)
}

func TestCaptureStdoutLargeOutput(t *testing.T) {
want := strings.Repeat("0123456789abcdef", 1<<16)
orig := os.Stdout
got := captureStdout(t, func() {
n, err := io.WriteString(os.Stdout, want)
require.NoError(t, err)
require.Equal(t, len(want), n)
})
require.Equal(t, want, got)
require.Same(t, orig, os.Stdout)
}
34 changes: 32 additions & 2 deletions cmd/workspace/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
config2 "github.com/devsy-org/devsy/pkg/devcontainer/config"
cliflags "github.com/devsy-org/devsy/pkg/flags"
"github.com/devsy-org/devsy/pkg/flags/names"
"github.com/devsy-org/devsy/pkg/log"
"github.com/devsy-org/devsy/pkg/output"
"github.com/devsy-org/devsy/pkg/status"
"github.com/devsy-org/devsy/pkg/table"
Expand Down Expand Up @@ -181,8 +182,14 @@ type followTaskOptions struct {
emitJSON bool
}

func followTask(ctx context.Context, store *task.Store, opts followTaskOptions) error {
type taskFollowerStore interface {
ReconcileTask(context.Context, string) (*task.State, error)
Get(string) (*task.State, error)
}

func followTask(ctx context.Context, store taskFollowerStore, opts followTaskOptions) error {
var last *task.State
var lastReconcileError error
ticker := time.NewTicker(opts.interval)
defer ticker.Stop()

Expand All @@ -195,7 +202,7 @@ func followTask(ctx context.Context, store *task.Store, opts followTaskOptions)
for {
tailer.poll(os.Stderr)

state, err := store.Get(opts.id)
state, err := readTaskForFollower(ctx, store, opts.id, &lastReconcileError)
if err != nil {
return err
}
Expand All @@ -216,6 +223,29 @@ func followTask(ctx context.Context, store *task.Store, opts followTaskOptions)
}
}

func readTaskForFollower(
ctx context.Context,
store taskFollowerStore,
id string,
lastReconcileError *error,
) (*task.State, error) {
state, err := store.ReconcileTask(ctx, id)
if err == nil {
*lastReconcileError = nil
return state, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
message := err.Error()
if *lastReconcileError == nil || message != (*lastReconcileError).Error() {
log.Debugf("Reconcile task %s: %s", id, message)
*lastReconcileError = err
}
// Cleanup can be retried; only persisted terminal state ends the follower.
return store.Get(id)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func emitTaskTransition(last, current *task.State, emitJSON bool) {
if last != nil && last.Phase == current.Phase && last.Step == current.Step &&
last.Status == current.Status && last.OperationID == current.OperationID &&
Expand Down
Loading
Loading