426 lines
14 KiB
Go
426 lines
14 KiB
Go
package jobs
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"reasonix/internal/event"
|
|
)
|
|
|
|
func TestCompletedJobPersistsOutputAndReleasesMemory(t *testing.T) {
|
|
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
|
|
m := NewManager(event.Discard)
|
|
defer m.Close()
|
|
m.SetActiveSessionPath("session", sessionPath)
|
|
|
|
j := m.StartForSession("session", "bash", "persist", func(_ context.Context, out io.Writer) (string, error) {
|
|
_, _ = io.WriteString(out, strings.Repeat("x", defaultTailBytes+1024))
|
|
return "", nil
|
|
})
|
|
<-j.done
|
|
|
|
j.mu.Lock()
|
|
tailLen := len(j.tail)
|
|
result := j.result
|
|
artifactPath := j.artifactPath
|
|
j.mu.Unlock()
|
|
|
|
if tailLen != 0 {
|
|
t.Fatalf("completed artifact-backed job kept %d tail bytes, want 0", tailLen)
|
|
}
|
|
if result != "" {
|
|
t.Fatalf("completed artifact-backed job kept result %q, want empty", result)
|
|
}
|
|
if artifactPath == "" {
|
|
t.Fatal("artifact path should be set")
|
|
}
|
|
|
|
res := m.WaitForSession(context.Background(), "session", []string{j.ID}, 1)
|
|
if len(res) != 1 || len(res[0].Output) != defaultTailBytes+1024 {
|
|
t.Fatalf("wait output len = %d, want %d", len(res[0].Output), defaultTailBytes+1024)
|
|
}
|
|
}
|
|
|
|
func TestJobArtifactRedactsSecrets(t *testing.T) {
|
|
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
|
|
m := NewManager(event.Discard)
|
|
defer m.Close()
|
|
m.SetActiveSessionPath("session", sessionPath)
|
|
secret := "sk-real-secret-value-123456"
|
|
|
|
j := m.StartForSession("session", "bash", "persist secret", func(_ context.Context, out io.Writer) (string, error) {
|
|
_, _ = io.WriteString(out, "DEEPSEEK_API_KEY="+secret+"\n")
|
|
return "Authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz", nil
|
|
})
|
|
<-j.done
|
|
|
|
res := m.WaitForSession(context.Background(), "session", []string{j.ID}, 1)
|
|
if len(res) != 1 {
|
|
t.Fatalf("wait result = %+v", res)
|
|
}
|
|
if strings.Contains(res[0].Output, secret) || strings.Contains(res[0].Output, "ghp_abcdefghijklmnopqrstuvwxyz") {
|
|
t.Fatalf("wait output leaked secret:\n%s", res[0].Output)
|
|
}
|
|
|
|
data, err := os.ReadFile(filepath.Join(ArtifactDir(sessionPath), j.ID+jobLogExt))
|
|
if err != nil {
|
|
t.Fatalf("read artifact: %v", err)
|
|
}
|
|
if strings.Contains(string(data), secret) || strings.Contains(string(data), "ghp_abcdefghijklmnopqrstuvwxyz") {
|
|
t.Fatalf("artifact leaked secret:\n%s", data)
|
|
}
|
|
}
|
|
|
|
func TestJobArtifactMetadataRedactsLabel(t *testing.T) {
|
|
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
|
|
m := NewManager(event.Discard)
|
|
defer m.Close()
|
|
m.SetActiveSessionPath("session", sessionPath)
|
|
const secret = "sk-real-secret-value-123456"
|
|
|
|
j := m.StartForSession("session", "bash", "echo DEEPSEEK_API_KEY="+secret, func(context.Context, io.Writer) (string, error) {
|
|
return "", nil
|
|
})
|
|
<-j.done
|
|
|
|
data, err := os.ReadFile(filepath.Join(ArtifactDir(sessionPath), j.ID+jobMetaExt))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(data), secret) {
|
|
t.Fatalf("job metadata leaked label secret:\n%s", data)
|
|
}
|
|
}
|
|
|
|
func TestRestoreSessionArtifactsAndAdvanceSequence(t *testing.T) {
|
|
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
|
|
first := NewManager(event.Discard)
|
|
first.SetActiveSessionPath("session", sessionPath)
|
|
j := first.StartForSession("session", "task", "answer", func(context.Context, io.Writer) (string, error) {
|
|
return "persisted answer", nil
|
|
})
|
|
<-j.done
|
|
first.Close()
|
|
|
|
second := NewManager(event.Discard)
|
|
defer second.Close()
|
|
second.SetActiveSessionPath("session", sessionPath)
|
|
|
|
res := second.WaitForSession(context.Background(), "session", []string{j.ID}, 1)
|
|
if len(res) != 1 || !strings.Contains(res[0].Output, "persisted answer") {
|
|
t.Fatalf("restored wait = %+v, want persisted answer", res)
|
|
}
|
|
if got := second.WaitForSession(context.Background(), "session", nil, 1); len(got) != 0 {
|
|
t.Fatalf("wait without ids should ignore restored completed artifacts, got %+v", got)
|
|
}
|
|
|
|
next := second.StartForSession("session", "bash", "next", func(context.Context, io.Writer) (string, error) {
|
|
return "", nil
|
|
})
|
|
<-next.done
|
|
if next.ID == j.ID {
|
|
t.Fatalf("new job reused restored id %q", next.ID)
|
|
}
|
|
}
|
|
|
|
func TestFinishDestroySessionPurgesOwnedJobs(t *testing.T) {
|
|
m := NewManager(event.Discard)
|
|
defer m.Close()
|
|
|
|
j := m.StartForSession("session", "task", "done", func(context.Context, io.Writer) (string, error) {
|
|
return "answer", nil
|
|
})
|
|
<-j.done
|
|
|
|
done := m.DestroySession("session")
|
|
if len(done) != 0 {
|
|
t.Fatalf("finished job should not need destroy wait, got %d handles", len(done))
|
|
}
|
|
m.FinishDestroySession("session")
|
|
|
|
if _, _, ok := m.OutputForSession("session", j.ID); ok {
|
|
t.Fatalf("destroyed session job %s should be purged", j.ID)
|
|
}
|
|
}
|
|
|
|
func TestSetActiveSessionPathMigratesRunningJobArtifacts(t *testing.T) {
|
|
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
|
|
m := NewManager(event.Discard)
|
|
defer m.Close()
|
|
|
|
wroteBefore := make(chan struct{})
|
|
release := make(chan struct{})
|
|
var releaseOnce sync.Once
|
|
defer releaseOnce.Do(func() { close(release) })
|
|
j := m.StartForSession("session", "bash", "migrate", func(_ context.Context, out io.Writer) (string, error) {
|
|
_, _ = io.WriteString(out, "before\n")
|
|
close(wroteBefore)
|
|
<-release
|
|
_, _ = io.WriteString(out, "after\n")
|
|
return "", nil
|
|
})
|
|
<-wroteBefore
|
|
j.mu.Lock()
|
|
oldPath := j.artifactPath
|
|
j.mu.Unlock()
|
|
|
|
m.SetActiveSessionPath("session", sessionPath)
|
|
j.mu.Lock()
|
|
gotPath := j.artifactPath
|
|
j.mu.Unlock()
|
|
if gotPath != oldPath {
|
|
t.Fatalf("running artifact path = %q, want unchanged %q before completion", gotPath, oldPath)
|
|
}
|
|
|
|
releaseOnce.Do(func() { close(release) })
|
|
<-j.done
|
|
j.mu.Lock()
|
|
donePath := j.artifactPath
|
|
j.mu.Unlock()
|
|
if !strings.HasPrefix(donePath, ArtifactDir(sessionPath)+string(filepath.Separator)) {
|
|
t.Fatalf("completed artifact path = %q, want under %q", donePath, ArtifactDir(sessionPath))
|
|
}
|
|
res := m.WaitForSession(context.Background(), "session", []string{j.ID}, 1)
|
|
if len(res) != 1 || !strings.Contains(res[0].Output, "before\n") || !strings.Contains(res[0].Output, "after\n") {
|
|
t.Fatalf("wait after migration = %+v, want before and after output", res)
|
|
}
|
|
}
|
|
|
|
func TestArtifactFailureDoesNotFailSuccessfulJob(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "session.jsonl")
|
|
if err := os.WriteFile(ArtifactDir(sessionPath), []byte("not a dir"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m := NewManager(event.Discard)
|
|
defer m.Close()
|
|
m.SetActiveSessionPath("session", sessionPath)
|
|
|
|
j := m.StartForSession("session", "task", "artifact fail", func(context.Context, io.Writer) (string, error) {
|
|
return "successful result", nil
|
|
})
|
|
<-j.done
|
|
|
|
res := m.WaitForSession(context.Background(), "session", []string{j.ID}, 1)
|
|
if len(res) != 1 || res[0].Status != Done {
|
|
t.Fatalf("wait = %+v, want one done result", res)
|
|
}
|
|
if !strings.Contains(res[0].Output, "successful result") || !strings.Contains(res[0].Output, "job artifact incomplete:") {
|
|
t.Fatalf("output = %q, want result and artifact warning", res[0].Output)
|
|
}
|
|
}
|
|
|
|
func TestMigrateArtifactDirFallsBackToCopyWhenRenameFails(t *testing.T) {
|
|
root := t.TempDir()
|
|
src := filepath.Join(root, "src")
|
|
dst := filepath.Join(root, "dst")
|
|
if err := os.MkdirAll(src, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(src, "bash-1.log"), []byte("persisted output"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
oldRename := renamePath
|
|
renamePath = func(_, _ string) error {
|
|
return errors.New("forced rename failure")
|
|
}
|
|
t.Cleanup(func() { renamePath = oldRename })
|
|
|
|
if err := migrateArtifactDir(src, dst); err != nil {
|
|
t.Fatalf("migrateArtifactDir: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(dst, "bash-1.log"))
|
|
if err != nil {
|
|
t.Fatalf("read migrated artifact: %v", err)
|
|
}
|
|
if string(got) != "persisted output" {
|
|
t.Fatalf("migrated artifact = %q, want persisted output", got)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(src, "bash-1.log")); !os.IsNotExist(err) {
|
|
t.Fatalf("source artifact should be removed after copy fallback, stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSetActiveSessionPathAdoptsUnscopedTemporaryJobs(t *testing.T) {
|
|
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
|
|
m := NewManager(event.Discard)
|
|
defer m.Close()
|
|
|
|
j := m.StartForSession("", "task", "temporary", func(context.Context, io.Writer) (string, error) {
|
|
return "temporary answer", nil
|
|
})
|
|
<-j.done
|
|
|
|
m.SetActiveSessionPath("session", sessionPath)
|
|
res := m.WaitForSession(context.Background(), "session", []string{j.ID}, 1)
|
|
if len(res) != 1 || !strings.Contains(res[0].Output, "temporary answer") {
|
|
t.Fatalf("adopted wait = %+v, want temporary answer", res)
|
|
}
|
|
if _, _, ok := m.OutputForSession("", j.ID); !ok {
|
|
t.Fatalf("legacy unscoped lookup should still find adopted job %s", j.ID)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(ArtifactDir(sessionPath), j.ID+jobLogExt)); err != nil {
|
|
t.Fatalf("adopted artifact should be under persistent sidecar: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSetActiveSessionPathAdoptsUnscopedJobsOnMigrationFailure(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "session.jsonl")
|
|
if err := os.WriteFile(ArtifactDir(sessionPath), []byte("not a dir"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m := NewManager(event.Discard)
|
|
defer m.Close()
|
|
|
|
j := m.StartForSession("", "task", "temporary", func(context.Context, io.Writer) (string, error) {
|
|
return "temporary answer", nil
|
|
})
|
|
<-j.done
|
|
|
|
m.SetActiveSessionPath("session", sessionPath)
|
|
|
|
out, status, ok := m.OutputForSession("session", j.ID)
|
|
if !ok || status != Done {
|
|
t.Fatalf("scoped output ok/status = %v/%s, want true/done", ok, status)
|
|
}
|
|
if !strings.Contains(out, "temporary answer") || !strings.Contains(out, "job artifact incomplete: migration:") {
|
|
t.Fatalf("scoped output = %q, want answer and migration error", out)
|
|
}
|
|
res := m.WaitForSession(context.Background(), "session", []string{j.ID}, 1)
|
|
if len(res) != 1 || !strings.Contains(res[0].Output, "job artifact incomplete: migration:") {
|
|
t.Fatalf("scoped wait = %+v, want migration error", res)
|
|
}
|
|
if note := m.DrainCompletedNoteForSession("session"); !strings.Contains(note, j.ID) {
|
|
t.Fatalf("adopted completion note = %q, want job id %s", note, j.ID)
|
|
}
|
|
}
|
|
|
|
func TestSetActiveSessionPathReportsMigrationFailure(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "session.jsonl")
|
|
if err := os.WriteFile(ArtifactDir(sessionPath), []byte("not a dir"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var notices []event.Event
|
|
m := NewManager(event.FuncSink(func(e event.Event) {
|
|
if e.Kind == event.Notice {
|
|
notices = append(notices, e)
|
|
}
|
|
}))
|
|
defer m.Close()
|
|
|
|
j := m.StartForSession("session", "task", "migrate fail", func(context.Context, io.Writer) (string, error) {
|
|
return "answer", nil
|
|
})
|
|
m.SetActiveSessionPath("session", sessionPath)
|
|
<-j.done
|
|
|
|
res := m.WaitForSession(context.Background(), "session", []string{j.ID}, 1)
|
|
if len(res) != 1 || !strings.Contains(res[0].Output, "job artifact incomplete: migration:") {
|
|
t.Fatalf("wait after migration failure = %+v, want artifact error", res)
|
|
}
|
|
found := false
|
|
for _, notice := range notices {
|
|
if notice.Text == "Job artifact migration failed." && strings.Contains(notice.Detail, "job artifact migration failed") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("migration failure notice not emitted, got %+v", notices)
|
|
}
|
|
}
|
|
|
|
func TestOutputReadsArtifactFromOffset(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "bash-1.log")
|
|
prefix := strings.Repeat("x", 2*defaultTailBytes)
|
|
suffix := "new output\n"
|
|
if err := os.WriteFile(path, []byte(prefix+suffix), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
m := NewManager(event.Discard)
|
|
defer m.Close()
|
|
j := &Job{
|
|
ID: "bash-1",
|
|
Kind: "bash",
|
|
SessionID: "session",
|
|
status: Running,
|
|
readOffset: int64(len(prefix)),
|
|
artifactPath: path,
|
|
done: make(chan struct{}),
|
|
}
|
|
m.jobs[jobKey("session", j.ID)] = j
|
|
m.order = append(m.order, jobKey("session", j.ID))
|
|
|
|
text, status, ok := m.OutputForSession("session", j.ID)
|
|
if !ok || status != Running {
|
|
t.Fatalf("OutputForSession ok/status = %v/%s, want true/running", ok, status)
|
|
}
|
|
if text != suffix {
|
|
t.Fatalf("OutputForSession text = %q, want %q", text, suffix)
|
|
}
|
|
if j.readOffset != int64(len(prefix)+len(suffix)) {
|
|
t.Fatalf("readOffset = %d, want %d", j.readOffset, len(prefix)+len(suffix))
|
|
}
|
|
}
|
|
|
|
func TestRestoredArtifactsAreScopedBySession(t *testing.T) {
|
|
root := t.TempDir()
|
|
pathA := filepath.Join(root, "a.jsonl")
|
|
pathB := filepath.Join(root, "b.jsonl")
|
|
|
|
first := NewManager(event.Discard)
|
|
first.SetActiveSessionPath("session-a", pathA)
|
|
jobA := first.StartForSession("session-a", "bash", "a", func(_ context.Context, out io.Writer) (string, error) {
|
|
_, _ = io.WriteString(out, "from-a")
|
|
return "", nil
|
|
})
|
|
<-jobA.done
|
|
first.Close()
|
|
|
|
second := NewManager(event.Discard)
|
|
second.SetActiveSessionPath("session-b", pathB)
|
|
jobB := second.StartForSession("session-b", "bash", "b", func(_ context.Context, out io.Writer) (string, error) {
|
|
_, _ = io.WriteString(out, "from-b")
|
|
return "", nil
|
|
})
|
|
<-jobB.done
|
|
second.Close()
|
|
|
|
if jobA.ID != "bash-1" || jobB.ID != "bash-1" {
|
|
t.Fatalf("test setup expected duplicate ids, got %s and %s", jobA.ID, jobB.ID)
|
|
}
|
|
|
|
m := NewManager(event.Discard)
|
|
defer m.Close()
|
|
m.SetActiveSessionPath("session-a", pathA)
|
|
m.SetActiveSessionPath("session-b", pathB)
|
|
|
|
resA := m.WaitForSession(context.Background(), "session-a", []string{"bash-1"}, 1)
|
|
if len(resA) != 1 || resA[0].Output != "from-a" {
|
|
t.Fatalf("session-a wait = %+v, want from-a", resA)
|
|
}
|
|
resB := m.WaitForSession(context.Background(), "session-b", []string{"bash-1"}, 1)
|
|
if len(resB) != 1 || resB[0].Output != "from-b" {
|
|
t.Fatalf("session-b wait = %+v, want from-b", resB)
|
|
}
|
|
|
|
next := m.StartForSession("session-b", "bash", "next", func(context.Context, io.Writer) (string, error) {
|
|
return "", nil
|
|
})
|
|
<-next.done
|
|
if next.ID == "bash-1" {
|
|
t.Fatal("new job should not reuse restored bash-1")
|
|
}
|
|
}
|