Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 30 additions & 4 deletions cli/control.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,32 @@ func (r runner) fork(args []string) error {
}

func (r runner) continueSession(args []string) error {
if len(args) != 1 {
return errors.New("usage: enowx-app continue SESSION [--host H]")
var ref string
if len(args) == 0 {
list, err := r.c.Sessions()
if err != nil {
return err
}
// Find latest exited or hibernated session to recover
for i := len(list) - 1; i >= 0; i-- {
if list[i].Exited || list[i].Hibernated {
ref = list[i].ID
break
}
}
if ref == "" {
return errors.New("continue: no exited or broken session found to continue")
}
} else if len(args) == 1 {
ref = strings.TrimSpace(args[0])
if ref == "" || ref == "null" || ref == "undefined" {
return errors.New("continue: session ID cannot be empty, null, or undefined")
}
} else {
return errors.New("usage: enowx-app continue [SESSION] [--host H]")
}
s, err := r.session(args[0])

s, err := r.session(ref)
if err != nil {
return err
}
Expand All @@ -247,7 +269,11 @@ func (r runner) continueSession(args []string) error {
default:
return fmt.Errorf("continue: %s is %s, not ended", s.ID, State(s))
}
return err
if err != nil {
return err
}
fmt.Fprintf(r.out, "Continued session %s (%s)\n", s.ID, s.Agent)
return nil
}

func (r runner) rename(args []string) error {
Expand Down
3 changes: 3 additions & 0 deletions core/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ func TestArgv(t *testing.T) {
{"omp", Launch{SkipPermissions: true, Continue: true}, []string{"omp", "--continue", "--auto-approve"}},
{"omp", Launch{Prompt: "test", Resume: "sess-123"}, []string{"omp", "--resume", "sess-123"}},
{"omp", Launch{Continue: true, SessionDir: "/s/1"}, []string{"omp", "--continue", "--session-dir", "/s/1"}},
{"antigravity", Launch{Prompt: "hi", SkipPermissions: true}, []string{"agy", "--dangerously-skip-permissions", "--prompt-interactive", "hi"}},
{"antigravity", Launch{SkipPermissions: true, Continue: true}, []string{"agy", "--continue", "--dangerously-skip-permissions"}},
{"antigravity", Launch{Prompt: "test", Resume: "uuid-123"}, []string{"agy", "--conversation", "uuid-123"}},
// An agent without a folder of its own for them keeps its own.
{"pi", Launch{SessionDir: "/s/1"}, []string{"pi"}},
}
Expand Down
6 changes: 4 additions & 2 deletions core/agent/inject_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,10 @@ func TestProjectBlockKeepsTheUsersTextAndDoesntDouble(t *testing.T) {
if strings.Count(string(data), projectBlockStart) != 1 || strings.Contains(string(data), "older words") || !strings.HasSuffix(string(data), mine) {
t.Fatalf("%q", data)
}
if info, _ := os.Stat(path); info.Mode().Perm() != 0o640 {
t.Fatalf("mode %v", info.Mode())
if runtime.GOOS != "windows" {
if info, _ := os.Stat(path); info.Mode().Perm() != 0o640 {
t.Fatalf("mode %v", info.Mode())
}
}
// Off: as it was before.
if err := RemoveProjectBlock(dir); err != nil {
Expand Down
2 changes: 2 additions & 0 deletions core/agent/presets.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ var Presets = []Preset{
ID: "antigravity", Name: "Antigravity", Command: "agy",
PromptFlag: []string{"--prompt-interactive"},
SkipPermissions: []string{"--dangerously-skip-permissions"},
Continue: []string{"--continue"},
Resume: []string{"--conversation"},
Homepage: "https://antigravity.google/docs/cli-overview",
},
{
Expand Down
97 changes: 97 additions & 0 deletions core/recovery/checkpoint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package recovery

import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
)

const checkpointFileName = "checkpoint.json"

// ComputeStateHash calculates the SHA-256 hash of a file at snapshotPath.
func ComputeStateHash(snapshotPath string) (string, error) {
if snapshotPath == "" {
return "", nil
}
f, err := os.Open(snapshotPath)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
defer f.Close()

hasher := sha256.New()
if _, err := io.Copy(hasher, f); err != nil {
return "", err
}
return hex.EncodeToString(hasher.Sum(nil)), nil
}

// SaveCheckpoint writes the session state to checkpoint.json atomically.
func SaveCheckpoint(stateDir string, state *SessionState) error {
if state == nil {
return fmt.Errorf("state cannot be nil")
}
if err := os.MkdirAll(stateDir, 0o700); err != nil {
return fmt.Errorf("failed to create state directory: %w", err)
}

// Compute snapshot hash if ref exists
if state.HistorySnapshotRef != "" {
hash, err := ComputeStateHash(state.HistorySnapshotRef)
if err == nil && hash != "" {
state.Checkpoint.StateHash = hash
}
}

// Sanitize failure context if present
if state.FailureContext != nil {
state.FailureContext = SanitizeFailureContext(state.FailureContext)
}

data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal checkpoint: %w", err)
}

targetPath := filepath.Join(stateDir, checkpointFileName)
tmpPath := targetPath + ".tmp"

// Write to temporary journal file
if err := os.WriteFile(tmpPath, data, 0o600); err != nil {
return fmt.Errorf("failed to write checkpoint temp file: %w", err)
}

// Atomic rename replaces the existing checkpoint safely
if err := os.Rename(tmpPath, targetPath); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to atomically rename checkpoint file: %w", err)
}

return nil
}

// LoadCheckpoint reads and unmarshals checkpoint.json from stateDir.
func LoadCheckpoint(stateDir string) (*SessionState, error) {
targetPath := filepath.Join(stateDir, checkpointFileName)
data, err := os.ReadFile(targetPath)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrSessionNotFound
}
return nil, fmt.Errorf("%w: read error: %v", ErrSessionUnrecoverable, err)
}

var state SessionState
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("%w: json decode error: %v", ErrSessionUnrecoverable, err)
}

return &state, nil
}
121 changes: 121 additions & 0 deletions core/recovery/compact.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package recovery

import (
"bufio"
"encoding/json"
"fmt"
"os"
)

// HistoryStep represents a generic step entry in a session transcript/history.
type HistoryStep struct {
StepIndex int `json:"step_index"`
Source string `json:"source,omitempty"`
Type string `json:"type,omitempty"`
Status string `json:"status,omitempty"`
Content string `json:"content,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
Raw map[string]interface{} `json:"-"`
}

// CompactHistory compacts a JSONL history file to prevent context overflow.
// It summarizes older steps and retains the most recent retainSteps steps intact.
func CompactHistory(filePath string, retainSteps int) (bool, error) {
f, err := os.Open(filePath)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("compact: open history file: %w", err)
}

var allLines []map[string]interface{}
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 64<<10), 16<<20)

for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var entry map[string]interface{}
if err := json.Unmarshal(line, &entry); err == nil {
allLines = append(allLines, entry)
}
}
scanErr := scanner.Err()
_ = f.Close()
if scanErr != nil {
return false, fmt.Errorf("compact: read history: %w", scanErr)
}

if len(allLines) <= retainSteps {
return false, nil // No compaction needed
}

cutoff := len(allLines) - retainSteps
compactedCount := cutoff

// Create summary of compacted history
summaryContent := fmt.Sprintf("[Compacted context: %d earlier interaction steps summarized to fit context budget]", compactedCount)
summaryStep := map[string]interface{}{
"step_index": 0,
"type": "compact_summary",
"source": "SYSTEM",
"status": "DONE",
"content": summaryContent,
}

newLines := make([]map[string]interface{}, 0, retainSteps+1)
newLines = append(newLines, summaryStep)
newLines = append(newLines, allLines[cutoff:]...)

// Atomic write back
tmpPath := filePath + ".compact.tmp"
tmpFile, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return false, fmt.Errorf("compact: create tmp file: %w", err)
}

writer := bufio.NewWriter(tmpFile)
for _, l := range newLines {
data, err := json.Marshal(l)
if err != nil {
tmpFile.Close()
os.Remove(tmpPath)
return false, fmt.Errorf("compact: marshal line: %w", err)
}
if _, err := writer.Write(data); err != nil {
tmpFile.Close()
os.Remove(tmpPath)
return false, fmt.Errorf("compact: write line: %w", err)
}
if err := writer.WriteByte('\n'); err != nil {
tmpFile.Close()
os.Remove(tmpPath)
return false, fmt.Errorf("compact: write newline: %w", err)
}
}
if err := writer.Flush(); err != nil {
tmpFile.Close()
os.Remove(tmpPath)
return false, fmt.Errorf("compact: flush: %w", err)
}
if err := tmpFile.Sync(); err != nil {
tmpFile.Close()
os.Remove(tmpPath)
return false, fmt.Errorf("compact: sync: %w", err)
}
tmpFile.Close()

if err := os.Rename(tmpPath, filePath); err != nil {
// Fallback for Windows if target exists
_ = os.Remove(filePath)
if rerr := os.Rename(tmpPath, filePath); rerr != nil {
os.Remove(tmpPath)
return false, fmt.Errorf("compact: rename tmp file: %w", rerr)
}
}

return true, nil
}
Loading
Loading