Files
2026-07-13 13:00:08 +08:00

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")
}
}