Skip to content
Merged
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
11 changes: 10 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ permissions:
contents: read

env:
GO_VERSION: '1.26.3'
GO_VERSION: '1.26.8'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pin a Go patch release that actually exists

As of this commit's 2026-09-05 date, Go 1.26.8 has not been released, so actions/setup-go cannot resolve this exact version. Every job in .github/workflows/ci.yml fails during toolchain setup, and the identical pin in .github/workflows/release.yml also prevents releases; use the current available 1.26 patch release instead.

Useful? React with 👍 / 👎.

GOLANGCI_LINT_VERSION: v2.9.0
GOVULNCHECK_VERSION: v1.3.0
GORELEASER_VERSION: v2.9.0
Expand Down Expand Up @@ -132,6 +132,15 @@ jobs:
run: ${{ matrix.binary }} --help
- name: Smoke headless run help
run: ${{ matrix.binary }} run --help
# The checks above prove the binary starts. This one drives the real
# agent loop against a stdlib-only stub provider on loopback: credential
# resolution, an approved write, the fail-closed approval path, the
# dotenv secret refusal, and the compound-command deny floor. It needs no
# credentials and no network. bash is explicit because the Windows runner
# would otherwise use PowerShell.
- name: End-to-end smoke
shell: bash
run: bash smoke.sh

# The release pipeline, exercised on every push rather than first exercised
# by tagging.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ permissions:
attestations: write

env:
GO_VERSION: '1.26.3'
GO_VERSION: '1.26.8'
GORELEASER_VERSION: v2.9.0
COSIGN_VERSION: v2.4.1

Expand Down
7 changes: 4 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ smoke: build
# provider (tools/smokestub) with an isolated home, asserting credential
# resolution, an approved write, the fail-closed approval path, the dotenv
# secret refusal, and the compound-command deny floor. No credentials, no
# network, no new dependencies. Deliberately not part of `ci` yet; add it there
# once it has run green on all three runners.
# network, no new dependencies. The CI smoke job runs the same script on
# ubuntu, macos and windows, and the script exits nonzero if any assertion
# fails, so it gates rather than reports.
smoke-e2e:
bash smoke.sh

Expand All @@ -76,7 +77,7 @@ tui-golden-check: build
run: build
./$(BINARY)

ci: verify lint test vulncheck build smoke goreleaser-check release-dry-run install-test
ci: verify lint test vulncheck build smoke smoke-e2e goreleaser-check release-dry-run install-test

clean:
rm -rf bin/ dist/
42 changes: 27 additions & 15 deletions build_qa_workbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -720,22 +720,34 @@ def bug_log() -> list:

def day31() -> list:
rows = [
("K-01", "Move CI to Go 1.26.6: one line in ci.yml and release.yml. Clears nine "
"reachable stdlib advisories with no code change."),
("K-02", "Decide U-03. If yes, apply docs/audit/patches/P10b-*.patch and update the "
"Go version in README.md and HANDOFF.md."),
("K-03", "Add smoke-e2e to the ci target and to ci.yml. Watch the first macOS and "
"Linux runs; it has only been exercised on Windows."),
("K-04", "Answer U-01 and U-02. Each closes an open medium finding or produces a "
"small patch."),
("K-05", "Prune backups on startup. $PC/backups grows without bound and "
"BackupManager.Cleanup has no production caller."),
("K-06", "Decide F-11: collect_agent_results either prompts or is read-only. "
"One line either way."),
("K-07", "Route code intelligence through LocalBackend.Resolve and retire "
("K-01", "DONE, and measured. ci.yml and release.yml pin Go 1.26.8, not the 1.26.6 "
"this list first named. On main at 1.26.3 govulncheck reports 15 reachable "
"vulnerabilities, 8 of them stdlib; on the branch at 1.26.8 it reports 7, "
"none stdlib. The pin cleared 8 stdlib advisories, not 9, with no code "
"change."),
("K-02", "OPEN, and the blocker for a green CI. All 7 remaining reachable "
"advisories are in x/crypto v0.43.0, reached through "
"internal/computers/ssh_backend.go, so vulncheck fails on main too. Five "
"of them (GO-2026-5013, 5017, 5018, 5019, 5020) are fixed in v0.52.0, "
"which needs go 1.25.0. The other two (GO-2026-6354, 6355) need v0.56.0, "
"which needs go 1.26.0: that is what P10b does. Raising the language floor "
"decides who can build the project."),
("K-03", "DONE. smoke-e2e is in the ci target, and the CI smoke job runs smoke.sh "
"on ubuntu, macos and windows. The macOS and Linux runs are new; before "
"this it had only been exercised on Windows."),
("K-04", "PARTLY DONE. U-02 is answered: no provider base_url is set anywhere, so "
"there is no plain-http endpoint to find. U-01 is still open."),
("K-05", "DONE. session.PruneBackups removes backup trees no run can reach, once "
"per start, configured by backup_retention_days and "
"backup_prune_disabled."),
("K-06", "DONE. collect_agent_results is read-only on both sides: RequiresApproval "
"returns false and no longer contradicts permissions.readOnlyTool. No "
"profile changes its decision."),
("K-07", "OPEN. Route code intelligence through LocalBackend.Resolve and retire "
"internal/tools/safefs.go. Security boundary: needs its own review."),
("K-08", "Do not start Streamable HTTP MCP or the Packet Computers daemon in a "
"low-capability window. Both have written contracts to build against later."),
("K-08", "OPEN. Do not start Streamable HTTP MCP or the Packet Computers daemon in "
"a low-capability window. Both have written contracts to build against "
"later. A warning, not a task."),
]
return [
para("Day 31 backlog", H1),
Expand Down
31 changes: 31 additions & 0 deletions cmd/packetcode/backup_retention_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package main

import (
"testing"
"time"

"github.com/packetcode/packetcode/internal/config"
"github.com/packetcode/packetcode/internal/session"
)

func TestBackupRetention(t *testing.T) {
tests := []struct {
name string
behavior config.BehaviorConfig
want time.Duration
}{
{"unset uses the built-in default", config.BehaviorConfig{}, session.DefaultBackupMaxAge},
{"a configured window is honoured", config.BehaviorConfig{BackupRetentionDays: 3}, 72 * time.Hour},
{"a negative count falls back like every other cap", config.BehaviorConfig{BackupRetentionDays: -5}, session.DefaultBackupMaxAge},
{"the disable switch beats a configured window", config.BehaviorConfig{BackupRetentionDays: 3, BackupPruneDisabled: true}, 0},
{"the disable switch beats an unset window", config.BehaviorConfig{BackupPruneDisabled: true}, 0},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := backupRetention(tc.behavior); got != tc.want {
t.Errorf("backupRetention() = %s, want %s", got, tc.want)
}
})
}
}
21 changes: 21 additions & 0 deletions cmd/packetcode/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,10 @@ func buildPacketRuntime(ctx context.Context, opts packetRuntimeConfig) (_ *packe
}

rt.Backups = session.NewBackupManager(opts.BackupsDir, rt.SessionID)
// Undo cannot reach a previous run's backups, so they are dead weight on
// disk. Reclaim the stale ones once per start, before anything writes new
// ones, and never touch the tree this session is about to use.
session.PruneBackups(opts.BackupsDir, rt.SessionID, backupRetention(opts.Config.Behavior))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve backups belonging to other live runtimes

When more than one runtime is alive, age does not establish that a backup tree is unreachable. For example, an ACP server retains multiple independent runtimes in Server.sessions; if session A made a backup and remains open beyond the configured retention period, creating session B calls this pruning path and deletes A's tree because only B's ID is protected. A subsequent /undo in A then loses its still-live backup, so pruning needs to exclude all active sessions or use a process/activity marker rather than treating every non-current tree as exited.

Useful? React with 👍 / 👎.

rt.Skills = skills.Load(opts.Root)
// Spilled tool output is per-session state, like the todo store: a
// background job must not be able to read or evict what the foreground
Expand Down Expand Up @@ -474,3 +478,20 @@ func (rt *packetRuntime) Close() error {
}
return errors.Join(errs...)
}

// backupRetention resolves the configured backup window into a duration.
//
// Zero or negative days selects the built-in default, which is how every
// other [behavior] cap reads a nonsensical value, and the explicit disable
// switch is what turns pruning off. That split matches
// loop_detection_disabled and post_edit_diagnostics_disabled rather than
// overloading the sign of a count.
func backupRetention(b config.BehaviorConfig) time.Duration {
if b.BackupPruneDisabled {
return 0
}
if b.BackupRetentionDays <= 0 {
return session.DefaultBackupMaxAge
}
return time.Duration(b.BackupRetentionDays) * 24 * time.Hour
}
9 changes: 9 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ background_token_budget = 0
workflow_token_budget = 0
provider_max_retries = 3
provider_stall_timeout = 60
backup_retention_days = 14
backup_prune_disabled = false

[packet_computers]
enabled = true # set false to disable registry loading, SSH, and remote placement
Expand Down Expand Up @@ -214,6 +216,13 @@ Provider resilience settings:
- `provider_max_retries` — how many times to retry a failed provider request (default 3).
- `provider_stall_timeout` — abort a provider stream that goes silent for this many seconds (default 60).

Undo backup retention:

- `backup_retention_days` — how long a previous session's undo backups stay under `~/.packetcode/backups/` (default 14). Stale trees are removed once at startup. The session that is starting is never pruned, however old it is, so resuming an old session keeps its tree.
- `backup_prune_disabled` — set true to keep every backup tree forever. A negative `backup_retention_days` is treated as unset and reported by `packetcode doctor`; this switch is the supported way to turn pruning off.

Undo only reaches backups made during the current run: the undo stack is held in memory and reset on start. Trees left by earlier runs cannot be restored from and are only occupying disk, which is what pruning reclaims.

Write-capable background agents create git worktrees under `~/.packetcode/worktrees/<repo-key>/<job-id>` using branch `packetcode-job-<job-id>` and the current `HEAD` commit as the base. This state directory is internal; there is no config key for it yet. Read-only jobs do not create worktrees.

Background job snapshots under `~/.packetcode/jobs/` also persist compact artifact metadata. Artifact previews are bounded and intended for manifests, not raw log or diff storage.
Expand Down
8 changes: 8 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,14 @@ type BehaviorConfig struct {
// operator who finds it noisy must be able to silence it without a
// rebuild.
PostEditDiagnosticsDisabled bool `toml:"post_edit_diagnostics_disabled"`

// BackupRetentionDays bounds how long a previous session's undo backups
// stay on disk. The undo stack is in-memory and reset per run, so a tree
// from an exited session can never be undone again and is pure disk cost.
// Zero uses the built-in default of fourteen days, like every other cap
// here; BackupPruneDisabled is the switch for keeping everything.
BackupRetentionDays int `toml:"backup_retention_days"`
BackupPruneDisabled bool `toml:"backup_prune_disabled"`
}

// PermissionConfig controls the approval policy applied to tool calls.
Expand Down
3 changes: 3 additions & 0 deletions internal/config/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ func (c *Config) ValidationProblems() []string {
if b.ProviderStallTimeout < 0 {
problems = append(problems, fmt.Sprintf("[behavior] provider_stall_timeout %d is negative; the default of 60 is used", b.ProviderStallTimeout))
}
if b.BackupRetentionDays < 0 {
problems = append(problems, fmt.Sprintf("[behavior] backup_retention_days %d is negative; the default of 14 is used. Set backup_prune_disabled to keep backups forever", b.BackupRetentionDays))
}
for _, cap := range []struct {
name string
value int
Expand Down
18 changes: 18 additions & 0 deletions internal/config/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,21 @@ func TestValidationProblems_BehaviorRanges(t *testing.T) {
assert.Contains(t, problems, "provider_max_retries -1")
assert.Contains(t, problems, "background_max_depth -2")
}

func TestValidationProblems_BackupRetention(t *testing.T) {
cfg := Default()
cfg.Behavior.BackupRetentionDays = -1
problems := strings.Join(cfg.ValidationProblems(), "\n")
assert.Contains(t, problems, "backup_retention_days")
assert.Contains(t, problems, "backup_prune_disabled")

// Turning pruning off is a legitimate choice, not something to warn about.
disabled := Default()
disabled.Behavior.BackupPruneDisabled = true
assert.Empty(t, disabled.ValidationProblems())

// So is a positive window.
configured := Default()
configured.Behavior.BackupRetentionDays = 30
assert.Empty(t, configured.ValidationProblems())
}
83 changes: 83 additions & 0 deletions internal/session/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -282,3 +283,85 @@ func pathWithinDir(base, target string) bool {
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && !filepath.IsAbs(rel))
}

// DefaultBackupMaxAge is how long a session backup tree is kept when the
// operator has not configured a retention window.
const DefaultBackupMaxAge = 14 * 24 * time.Hour

// PruneBackups removes session backup trees under backupsDir that have not
// been written to for maxAge, and reports how many trees it removed. A
// maxAge of zero or less prunes nothing.
//
// A backup exists only to serve /undo, and the undo stack lives in memory and
// is reset whenever a BackupManager is constructed (see the type comment).
// A tree left by a run that has already exited is therefore unreachable by any
// future undo: it is disk cost with no recovery value. Age is the whole of the
// policy because nothing else reads these files.
//
// keepSessionID is never removed whatever its age. It is the tree the caller
// is about to write into, and a resumed session can easily be older than the
// window.
//
// Pruning is best effort and silent. A tree that cannot be read or removed is
// left in place, because failing to reclaim disk is not a reason to fail a
// startup. Only names that pass the session-id check are joined onto the base,
// so a candidate path can never escape the backups directory.
func PruneBackups(backupsDir, keepSessionID string, maxAge time.Duration) int {
if maxAge <= 0 {
return 0
}
base := cleanBackupBase(backupsDir)
listing, err := os.ReadDir(base)
if err != nil {
return 0
}
cutoff := time.Now().Add(-maxAge)
removed := 0
for _, item := range listing {
if !item.IsDir() || item.Name() == keepSessionID {
continue
}
root, err := backupRoot(base, item.Name())
if err != nil {
continue
}
newest, ok := newestModTime(root)
if !ok || !newest.Before(cutoff) {
continue
}
if os.RemoveAll(root) == nil {
removed++
}
}
return removed
}

// newestModTime reports the most recent modification time anywhere under dir,
// including dir itself.
//
// Taking the maximum over the whole tree is what makes the age check honest:
// backups are written into a mirror of the original source tree, so a write
// several levels down does not necessarily touch the session directory's own
// mtime. A tree that cannot be walked reports no time, which keeps it.
func newestModTime(dir string) (time.Time, bool) {
var newest time.Time
found := false
err := filepath.WalkDir(dir, func(_ string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
info, err := entry.Info()
if err != nil {
return err
}
if mod := info.ModTime(); mod.After(newest) {
newest = mod
}
found = true
return nil
})
if err != nil {
return time.Time{}, false
}
return newest, found
}
Loading
Loading