PR #3478
Sections
Review

chore(office): delete unwired GC and fix stale scheduler doc

main ← chore/wo-26-delete-office-gc 5 files +4 −954 PR #3478 ↗

Deletes the unwired office GarbageCollector and its tests, drops the GC field from Services, and updates the scheduler package doc to match current run processing and routing duties.

Why this change

The office GarbageCollector sweeps worktrees and containers but nothing wires it. The code stays dead, adds maintenance cost, and the scheduler doc still describes an old tick loop.

What it does

Architecture, end to end

Office domain before and after. GC lived in infra but had no caller. Services no longer holds it. Scheduler doc now matches real duties.

flowchart LR
  Services[office.Services] --> Reconciler[infra.Reconciler]
  Services -.-> GC[infra.GarbageCollector - removed]
  Scheduler[scheduler.SchedulerService] --> Svc[service.Service]
  Scheduler --> Routing[routing.Resolver]
  GC --> Worktree[WorktreeInventory]
  GC --> Docker[DockerClient]
  GC --> Repo[(sqlite.Repository)]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

Delete GarbageCollector implementationapps/backend/internal/office/infra/gc.go ↗
type GarbageCollector struct
Click for details →

Removes the unwired sweeper that deleted orphan worktrees and stale containers.

Deleted file
deleted file mode 100644
// Package infra provides infrastructure-level background jobs for the office
// domain, including garbage collection and reconciliation.
package infra

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"time"

	"go.uber.org/zap"

	"github.com/kandev/kandev/internal/common/logger"
	"github.com/kandev/kandev/internal/office/repository/sqlite"
)

// DefaultGCInterval is the default interval between GC sweeps.
const DefaultGCInterval = 3 * time.Hour

// WorktreeGracePeriod is the minimum age a directory must have before the
// worktree sweep will consider it for deletion.
const WorktreeGracePeriod = 24 * time.Hour

type GarbageCollector struct {
	repo         *sqlite.Repository
	worktreeInv  WorktreeInventory
	worktreeBase string
	dockerClient DockerClient
	interval     time.Duration
	logger       *logger.Logger
}

func NewGarbageCollector(repo *sqlite.Repository, worktreeInv WorktreeInventory, log *logger.Logger, worktreeBase string, dockerClient DockerClient, interval time.Duration) *GarbageCollector {
	if interval <= 0 {
		interval = DefaultGCInterval
	}
	return &GarbageCollector{repo: repo, worktreeInv: worktreeInv, worktreeBase: worktreeBase, dockerClient: dockerClient, interval: interval, logger: log.WithFields(zap.String("component", "office-gc"))}
}

func (gc *GarbageCollector) Start(ctx context.Context) {
	gc.logger.Info("GC sweep loop starting", zap.Duration("interval", gc.interval))
	result := gc.Sweep(ctx)
	gc.logResult(result)
	ticker := time.NewTicker(gc.interval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			gc.logger.Info("GC sweep loop stopping")
			return
		case <-ticker.C:
			result := gc.Sweep(ctx)
			gc.logResult(result)
		}
	}
}

func (gc *GarbageCollector) Sweep(ctx context.Context) GCSweepResult {
	var result GCSweepResult
	wtResult := gc.sweepWorktrees(ctx)
	ctrResult := gc.sweepContainers(ctx)
	return result
}
func TestGC_WorktreeSweep_DeletesOldOrphan(t *testing.T)
Click for details →

Removes the 596-line test suite that covered the deleted sweeper.

Deleted tests
deleted file mode 100644
package infra_test

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/jmoiron/sqlx"
	_ "github.com/mattn/go-sqlite3"

	"github.com/kandev/kandev/internal/common/logger"
	"github.com/kandev/kandev/internal/office/infra"
	"github.com/kandev/kandev/internal/office/repository/sqlite"
)

type mockDockerClient struct {
	containers []infra.GCContainerInfo
	removed    []string
	removeErr  error
}

func (m *mockDockerClient) ListContainers(_ context.Context, _ map[string]string) ([]infra.GCContainerInfo, error) {
	return m.containers, nil
}

type fakeInventory struct {
	paths []string
	err   error
}

func TestGC_WorktreeSweep_DeletesOldOrphan(t *testing.T) {
	base := t.TempDir()
	orphan := filepath.Join(base, "orphan_xyz")
	if err := os.MkdirAll(orphan, 0o755); err != nil {
		t.Fatal(err)
	}
	old := time.Now().Add(-25 * time.Hour)
	if err := os.Chtimes(orphan, old, old); err != nil {
		t.Fatal(err)
	}
	gc, _ := newTestGC(t, base, nil, &fakeInventory{})
	result := gc.Sweep(context.Background())
	if _, err := os.Stat(orphan); !os.IsNotExist(err) {
		t.Errorf("expected orphan dir removed; stat err = %v", err)
	}
}

func TestGC_OrphanContainerRemoved(t *testing.T) {
	mock := &mockDockerClient{containers: []infra.GCContainerInfo{{ID: "ctr-orphan-1", State: "exited", Labels: map[string]string{"kandev.task_id": "no-such-task"}}}}
	gc, _ := newTestGC(t, "", mock, nil)
	result := gc.Sweep(context.Background())
	if result.ContainersRemoved != 1 {
		t.Errorf("containers_removed = %d, want 1", result.ContainersRemoved)
	}
}
Remove GC from Services wiringapps/backend/internal/office/services.go ↗
type Services struct
Click for details →

Drops the GC field so Services no longer references dead code.

Services struct
package office

import (
	"github.com/kandev/kandev/internal/office/agents"
	"github.com/kandev/kandev/internal/office/approvals"
	"github.com/kandev/kandev/internal/office/channels"
	"github.com/kandev/kandev/internal/office/config"
	"github.com/kandev/kandev/internal/office/configloader"
	"github.com/kandev/kandev/internal/office/configsync"
	"github.com/kandev/kandev/internal/office/costs"
	"github.com/kandev/kandev/internal/office/dashboard"
	"github.com/kandev/kandev/internal/office/infra"
	"github.com/kandev/kandev/internal/office/labels"
	"github.com/kandev/kandev/internal/office/onboarding"
	"github.com/kandev/kandev/internal/office/projects"
	"github.com/kandev/kandev/internal/office/repository/sqlite"
	"github.com/kandev/kandev/internal/office/routines"
	"github.com/kandev/kandev/internal/office/scheduler"
	officeservice "github.com/kandev/kandev/internal/office/service"
	"github.com/kandev/kandev/internal/office/skills"
	taskservice "github.com/kandev/kandev/internal/task/service"
)

// Services holds references to all feature services in the office domain.
// It is the central wiring point for HTTP handlers and background jobs.
type Services struct {
	Agents       *agents.AgentService
	Skills       *skills.SkillService
	Projects     *projects.ProjectService
	Costs        *costs.CostService
	Routines     *routines.RoutineService
	Approvals    *approvals.ApprovalService
	Channels     *channels.ChannelService
	Config       *config.ConfigService
	ConfigSync   *configsync.Service
	Dashboard    *dashboard.DashboardService
	Labels       *labels.LabelService
	Onboarding   *onboarding.OnboardingService
	Scheduler    *scheduler.SchedulerService
	TreeControls *officeservice.Service
	Workspaces   *officeservice.Service
	Documents    *taskservice.DocumentService
	GC           *infra.GarbageCollector
	Reconciler   *infra.Reconciler
	Repo         *sqlite.Repository
	GitManager   *configloader.GitManager
	// KandevHome is the base storage directory for attachment files.
	KandevHome string
}
Fix stale scheduler and infra docsapps/backend/internal/office/scheduler/run.go ↗
// Package scheduler orchestrates run processing
Click for details →

Updates the package doc to describe current duties, not the old tick loop.

Scheduler doc
// Package scheduler orchestrates run processing for the office domain.
// It wraps service.Service and owns the tick loop, retry logic, event
// subscribers, and idle-timeout management.
// It wraps service.Service and owns run processing, dispatch/tier routing,
// retry logic, reactivity, and mentions.
package scheduler
Reconciler doc
// Package infra provides infrastructure-level background jobs for the office domain.
package infra
Read the changes as a list

Delete GarbageCollector implementation

apps/backend/internal/office/infra/gc.go

Removes the unwired sweeper that deleted orphan worktrees and stale containers.

Deleted file
deleted file mode 100644
// Package infra provides infrastructure-level background jobs for the office
// domain, including garbage collection and reconciliation.
package infra

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"time"

	"go.uber.org/zap"

	"github.com/kandev/kandev/internal/common/logger"
	"github.com/kandev/kandev/internal/office/repository/sqlite"
)

// DefaultGCInterval is the default interval between GC sweeps.
const DefaultGCInterval = 3 * time.Hour

// WorktreeGracePeriod is the minimum age a directory must have before the
// worktree sweep will consider it for deletion.
const WorktreeGracePeriod = 24 * time.Hour

type GarbageCollector struct {
	repo         *sqlite.Repository
	worktreeInv  WorktreeInventory
	worktreeBase string
	dockerClient DockerClient
	interval     time.Duration
	logger       *logger.Logger
}

func NewGarbageCollector(repo *sqlite.Repository, worktreeInv WorktreeInventory, log *logger.Logger, worktreeBase string, dockerClient DockerClient, interval time.Duration) *GarbageCollector {
	if interval <= 0 {
		interval = DefaultGCInterval
	}
	return &GarbageCollector{repo: repo, worktreeInv: worktreeInv, worktreeBase: worktreeBase, dockerClient: dockerClient, interval: interval, logger: log.WithFields(zap.String("component", "office-gc"))}
}

func (gc *GarbageCollector) Start(ctx context.Context) {
	gc.logger.Info("GC sweep loop starting", zap.Duration("interval", gc.interval))
	result := gc.Sweep(ctx)
	gc.logResult(result)
	ticker := time.NewTicker(gc.interval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			gc.logger.Info("GC sweep loop stopping")
			return
		case <-ticker.C:
			result := gc.Sweep(ctx)
			gc.logResult(result)
		}
	}
}

func (gc *GarbageCollector) Sweep(ctx context.Context) GCSweepResult {
	var result GCSweepResult
	wtResult := gc.sweepWorktrees(ctx)
	ctrResult := gc.sweepContainers(ctx)
	return result
}

Delete GC test suite

apps/backend/internal/office/infra/gc_test.go

Removes the 596-line test suite that covered the deleted sweeper.

Deleted tests
deleted file mode 100644
package infra_test

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/jmoiron/sqlx"
	_ "github.com/mattn/go-sqlite3"

	"github.com/kandev/kandev/internal/common/logger"
	"github.com/kandev/kandev/internal/office/infra"
	"github.com/kandev/kandev/internal/office/repository/sqlite"
)

type mockDockerClient struct {
	containers []infra.GCContainerInfo
	removed    []string
	removeErr  error
}

func (m *mockDockerClient) ListContainers(_ context.Context, _ map[string]string) ([]infra.GCContainerInfo, error) {
	return m.containers, nil
}

type fakeInventory struct {
	paths []string
	err   error
}

func TestGC_WorktreeSweep_DeletesOldOrphan(t *testing.T) {
	base := t.TempDir()
	orphan := filepath.Join(base, "orphan_xyz")
	if err := os.MkdirAll(orphan, 0o755); err != nil {
		t.Fatal(err)
	}
	old := time.Now().Add(-25 * time.Hour)
	if err := os.Chtimes(orphan, old, old); err != nil {
		t.Fatal(err)
	}
	gc, _ := newTestGC(t, base, nil, &fakeInventory{})
	result := gc.Sweep(context.Background())
	if _, err := os.Stat(orphan); !os.IsNotExist(err) {
		t.Errorf("expected orphan dir removed; stat err = %v", err)
	}
}

func TestGC_OrphanContainerRemoved(t *testing.T) {
	mock := &mockDockerClient{containers: []infra.GCContainerInfo{{ID: "ctr-orphan-1", State: "exited", Labels: map[string]string{"kandev.task_id": "no-such-task"}}}}
	gc, _ := newTestGC(t, "", mock, nil)
	result := gc.Sweep(context.Background())
	if result.ContainersRemoved != 1 {
		t.Errorf("containers_removed = %d, want 1", result.ContainersRemoved)
	}
}

Remove GC from Services wiring

apps/backend/internal/office/services.go

Drops the GC field so Services no longer references dead code.

Services struct
package office

import (
	"github.com/kandev/kandev/internal/office/agents"
	"github.com/kandev/kandev/internal/office/approvals"
	"github.com/kandev/kandev/internal/office/channels"
	"github.com/kandev/kandev/internal/office/config"
	"github.com/kandev/kandev/internal/office/configloader"
	"github.com/kandev/kandev/internal/office/configsync"
	"github.com/kandev/kandev/internal/office/costs"
	"github.com/kandev/kandev/internal/office/dashboard"
	"github.com/kandev/kandev/internal/office/infra"
	"github.com/kandev/kandev/internal/office/labels"
	"github.com/kandev/kandev/internal/office/onboarding"
	"github.com/kandev/kandev/internal/office/projects"
	"github.com/kandev/kandev/internal/office/repository/sqlite"
	"github.com/kandev/kandev/internal/office/routines"
	"github.com/kandev/kandev/internal/office/scheduler"
	officeservice "github.com/kandev/kandev/internal/office/service"
	"github.com/kandev/kandev/internal/office/skills"
	taskservice "github.com/kandev/kandev/internal/task/service"
)

// Services holds references to all feature services in the office domain.
// It is the central wiring point for HTTP handlers and background jobs.
type Services struct {
	Agents       *agents.AgentService
	Skills       *skills.SkillService
	Projects     *projects.ProjectService
	Costs        *costs.CostService
	Routines     *routines.RoutineService
	Approvals    *approvals.ApprovalService
	Channels     *channels.ChannelService
	Config       *config.ConfigService
	ConfigSync   *configsync.Service
	Dashboard    *dashboard.DashboardService
	Labels       *labels.LabelService
	Onboarding   *onboarding.OnboardingService
	Scheduler    *scheduler.SchedulerService
	TreeControls *officeservice.Service
	Workspaces   *officeservice.Service
	Documents    *taskservice.DocumentService
	GC           *infra.GarbageCollector
	Reconciler   *infra.Reconciler
	Repo         *sqlite.Repository
	GitManager   *configloader.GitManager
	// KandevHome is the base storage directory for attachment files.
	KandevHome string
}

Fix stale scheduler and infra docs

apps/backend/internal/office/scheduler/run.go

Updates the package doc to describe current duties, not the old tick loop.

Scheduler doc
// Package scheduler orchestrates run processing for the office domain.
// It wraps service.Service and owns the tick loop, retry logic, event
// subscribers, and idle-timeout management.
// It wraps service.Service and owns run processing, dispatch/tier routing,
// retry logic, reactivity, and mentions.
package scheduler
Reconciler doc
// Package infra provides infrastructure-level background jobs for the office domain.
package infra

Risk

2 / 10 Low
1 low5 medium10 high

Why this score

  • Deletes dead code with no callers, so no runtime path changes.
  • No data migration or config change; only struct field removal.
  • Doc-only edits in scheduler and reconciler carry no behavior risk.

Trade-offs and review notes

Where to look first

  1. Confirm GC has no callers outside the deleted files.
  2. Check Services wiring no longer expects a GC instance.
  3. Verify scheduler doc now matches the real scheduler duties.