diff --git a/pkg/codingcontext/context.go b/pkg/codingcontext/context.go index 7895406c..a086de85 100644 --- a/pkg/codingcontext/context.go +++ b/pkg/codingcontext/context.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/go-getter/v2" "github.com/kitproj/coding-context-cli/pkg/codingcontext/markdown" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/memoryusage" "github.com/kitproj/coding-context-cli/pkg/codingcontext/selectors" "github.com/kitproj/coding-context-cli/pkg/codingcontext/skills" "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" @@ -52,25 +53,25 @@ const ( // Context holds the configuration and state for assembling coding context. type Context struct { - params taskparser.Params - includes selectors.Selectors - manifestURL string - searchPaths []string - downloadedPaths []string - task markdown.Markdown[markdown.TaskFrontMatter] // Parsed task - rules []markdown.Markdown[markdown.RuleFrontMatter] // Collected rule files - skills skills.AvailableSkills // Discovered skills (metadata only) - totalTokens int - logger *slog.Logger - cmdRunner func(cmd *exec.Cmd) error + params taskparser.Params + includes selectors.Selectors + manifestURL string + searchPaths []string + downloadedPaths []string + task markdown.Markdown[markdown.TaskFrontMatter] // Parsed task + rules []markdown.Markdown[markdown.RuleFrontMatter] // Collected rule files + skills skills.AvailableSkills // Discovered skills (metadata only) + totalTokens int + logger *slog.Logger + cmdRunner func(cmd *exec.Cmd) error resume bool doBootstrap bool // Controls whether to discover rules, skills, and run bootstrap scripts includeByDefault bool // Controls whether unmatched rules/skills are included by default - agent Agent - namespace string // Active namespace derived from task name (e.g. "myteam" from "myteam/fix-bug") - userPrompt string // User-provided prompt to append to task - lintMode bool - lintCollector *lintCollector + agent Agent + namespace string // Active namespace derived from task name (e.g. "myteam" from "myteam/fix-bug") + userPrompt string // User-provided prompt to append to task + lintMode bool + lintCollector *lintCollector } // parseNamespacedTaskName splits a task name into its optional namespace and base name. @@ -102,11 +103,11 @@ func parseNamespacedTaskName(taskName string) (string, string, error) { // New creates a new Context with the given options. func New(opts ...Option) *Context { c := &Context{ - params: make(taskparser.Params), - includes: make(selectors.Selectors), - rules: make([]markdown.Markdown[markdown.RuleFrontMatter], 0), - skills: skills.AvailableSkills{Skills: make([]skills.Skill, 0)}, - logger: slog.New(slog.NewTextHandler(os.Stderr, nil)), + params: make(taskparser.Params), + includes: make(selectors.Selectors), + rules: make([]markdown.Markdown[markdown.RuleFrontMatter], 0), + skills: skills.AvailableSkills{Skills: make([]skills.Skill, 0)}, + logger: slog.New(slog.NewTextHandler(os.Stderr, nil)), doBootstrap: true, // Default to true for backward compatibility includeByDefault: true, // Default to true for backward compatibility cmdRunner: func(cmd *exec.Cmd) error { @@ -175,6 +176,11 @@ func (cc *Context) Run(ctx context.Context, taskName string) (*Result, error) { // Estimate tokens for task cc.logger.Info("Total estimated tokens", "tokens", cc.totalTokens) + // Log current cgroup v2 memory usage if available (e.g. in containerized environments) + if memBytes, err := memoryusage.ReadCurrent(); err == nil { + cc.logger.Info("Current memory usage", "bytes", memBytes) + } + // Build the combined prompt from all rules and task content var promptBuilder strings.Builder for _, rule := range cc.rules { diff --git a/pkg/codingcontext/enumerate_test.go b/pkg/codingcontext/enumerate_test.go index 3b18fb87..723a660d 100644 --- a/pkg/codingcontext/enumerate_test.go +++ b/pkg/codingcontext/enumerate_test.go @@ -485,4 +485,3 @@ func TestDiscoveredTask_Fields(t *testing.T) { t.Errorf("namespaced Path = %q, want *ns-task.md", namespaced.Path) } } - diff --git a/pkg/codingcontext/memoryusage/memoryusage.go b/pkg/codingcontext/memoryusage/memoryusage.go new file mode 100644 index 00000000..166d37e0 --- /dev/null +++ b/pkg/codingcontext/memoryusage/memoryusage.go @@ -0,0 +1,35 @@ +// Package memoryusage provides memory usage reading from cgroup v2. +package memoryusage + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +const cgroupV2MemoryCurrentPath = "/sys/fs/cgroup/memory.current" + +// ReadCurrent reads the current memory usage in bytes from the cgroup v2 +// memory.current file at the default path. Returns an error if the file +// cannot be read or parsed. +func ReadCurrent() (int64, error) { + return ReadCurrentFromPath(cgroupV2MemoryCurrentPath) +} + +// ReadCurrentFromPath reads the current memory usage in bytes from the +// provided cgroup v2 memory.current file path. Returns an error if the +// file cannot be read or parsed. +func ReadCurrentFromPath(path string) (int64, error) { + data, err := os.ReadFile(path) + if err != nil { + return 0, fmt.Errorf("reading %s: %w", path, err) + } + + val, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64) + if err != nil { + return 0, fmt.Errorf("parsing %s: %w", path, err) + } + + return val, nil +} diff --git a/pkg/codingcontext/memoryusage/memoryusage_test.go b/pkg/codingcontext/memoryusage/memoryusage_test.go new file mode 100644 index 00000000..bff60fdc --- /dev/null +++ b/pkg/codingcontext/memoryusage/memoryusage_test.go @@ -0,0 +1,95 @@ +package memoryusage_test + +import ( + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/memoryusage" +) + +func TestReadCurrent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fileContent string + wantBytes int64 + wantErr bool + }{ + { + name: "valid memory value", + fileContent: "12345678\n", + wantBytes: 12345678, + }, + { + name: "valid value without newline", + fileContent: "999999", + wantBytes: 999999, + }, + { + name: "zero value", + fileContent: "0\n", + wantBytes: 0, + }, + { + name: "invalid non-numeric content", + fileContent: "abc\n", + wantErr: true, + }, + { + name: "empty content", + fileContent: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + memFile := filepath.Join(tmpDir, "memory.current") + + if err := os.WriteFile(memFile, []byte(tt.fileContent), 0o600); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + + got, err := memoryusage.ReadCurrentFromPath(memFile) + if (err != nil) != tt.wantErr { + t.Errorf("ReadCurrentFromPath() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr && got != tt.wantBytes { + t.Errorf("ReadCurrentFromPath() = %d, want %d", got, tt.wantBytes) + } + }) + } +} + +func TestReadCurrent_FileNotFound(t *testing.T) { + t.Parallel() + + _, err := memoryusage.ReadCurrentFromPath("/nonexistent/path/memory.current") + if err == nil { + t.Error("ReadCurrentFromPath() expected error for missing file, got nil") + } +} + +func TestReadCurrent_LiveCgroup(t *testing.T) { + t.Parallel() + + bytes, err := memoryusage.ReadCurrent() + if err != nil { + // On systems without cgroup v2 memory.current this is expected. + t.Skipf("cgroup v2 memory.current not available: %v", err) + } + + if bytes < 0 { + t.Errorf("ReadCurrent() returned negative value %d", bytes) + } + + t.Logf("current cgroup memory usage: %s bytes", strconv.FormatInt(bytes, 10)) +}