2026-03-16 11:10:33 +00:00
|
|
|
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
|
|
|
|
|
|
package agentic
|
|
|
|
|
|
|
|
|
|
import (
|
refactor: migrate core/agent to Core primitives — reference implementation
Phase 1: go-io/go-log → core.Fs{}, core.E(), core.Error/Info/Warn
Phase 2: strings/fmt → core.Contains, core.Sprintf, core.Split etc
Phase 3: embed.FS → core.Mount/core.Embed, core.Extract
Phase 4: cmd/main.go → core.Command(), c.Cli().Run(), no cli package
All packages migrated:
- pkg/lib (Codex): core.Mount, core.Extract, Result returns, AX comments
- pkg/setup (Codex): core.Fs, core.E, fixed missing lib helpers
- pkg/brain (Codex): Core primitives, AX comments
- pkg/monitor (Codex): Core string/logging primitives
- pkg/agentic (Codex): 20 files, Core primitives throughout
- cmd/main.go: pure Core CLI, no fmt/log/filepath/strings/cli
Remaining stdlib: path/filepath (Core doesn't wrap OS paths),
fmt.Sscanf/strings.Map (no Core equivalent).
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-22 06:13:41 +00:00
|
|
|
"strconv"
|
2026-03-16 11:10:33 +00:00
|
|
|
"time"
|
|
|
|
|
|
refactor: migrate core/agent to Core primitives — reference implementation
Phase 1: go-io/go-log → core.Fs{}, core.E(), core.Error/Info/Warn
Phase 2: strings/fmt → core.Contains, core.Sprintf, core.Split etc
Phase 3: embed.FS → core.Mount/core.Embed, core.Extract
Phase 4: cmd/main.go → core.Command(), c.Cli().Run(), no cli package
All packages migrated:
- pkg/lib (Codex): core.Mount, core.Extract, Result returns, AX comments
- pkg/setup (Codex): core.Fs, core.E, fixed missing lib helpers
- pkg/brain (Codex): Core primitives, AX comments
- pkg/monitor (Codex): Core string/logging primitives
- pkg/agentic (Codex): 20 files, Core primitives throughout
- cmd/main.go: pure Core CLI, no fmt/log/filepath/strings/cli
Remaining stdlib: path/filepath (Core doesn't wrap OS paths),
fmt.Sscanf/strings.Map (no Core equivalent).
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-22 06:13:41 +00:00
|
|
|
core "dappco.re/go/core"
|
2026-03-16 11:10:33 +00:00
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-30 22:30:05 +00:00
|
|
|
// config := agentic.DispatchConfig{DefaultAgent: "claude", DefaultTemplate: "coding"}
|
2026-03-16 11:10:33 +00:00
|
|
|
type DispatchConfig struct {
|
|
|
|
|
DefaultAgent string `yaml:"default_agent"`
|
|
|
|
|
DefaultTemplate string `yaml:"default_template"`
|
|
|
|
|
WorkspaceRoot string `yaml:"workspace_root"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 05:20:50 +00:00
|
|
|
// rate := agentic.RateConfig{ResetUTC: "06:00", DailyLimit: 200, MinDelay: 15, SustainedDelay: 120, BurstWindow: 2, BurstDelay: 15}
|
2026-03-16 11:10:33 +00:00
|
|
|
type RateConfig struct {
|
2026-03-31 05:20:50 +00:00
|
|
|
ResetUTC string `yaml:"reset_utc"`
|
|
|
|
|
DailyLimit int `yaml:"daily_limit"`
|
|
|
|
|
MinDelay int `yaml:"min_delay"`
|
|
|
|
|
SustainedDelay int `yaml:"sustained_delay"`
|
|
|
|
|
BurstWindow int `yaml:"burst_window"`
|
|
|
|
|
BurstDelay int `yaml:"burst_delay"`
|
2026-03-16 11:10:33 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-30 22:30:05 +00:00
|
|
|
// claude: 1 → Total=1, Models=nil
|
|
|
|
|
// codex: → Total=2, Models={"gpt-5.4": 1, "gpt-5.3-codex-spark": 1}
|
2026-03-24 13:05:41 +00:00
|
|
|
//
|
2026-03-30 22:30:05 +00:00
|
|
|
// total: 2
|
|
|
|
|
// gpt-5.4: 1
|
|
|
|
|
// gpt-5.3-codex-spark: 1
|
2026-03-24 13:05:41 +00:00
|
|
|
type ConcurrencyLimit struct {
|
|
|
|
|
Total int
|
|
|
|
|
Models map[string]int
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 22:04:36 +00:00
|
|
|
// var limit ConcurrencyLimit
|
|
|
|
|
// _ = yaml.Unmarshal([]byte("total: 2\ngpt-5.4: 1\n"), &limit)
|
2026-03-24 13:05:41 +00:00
|
|
|
func (c *ConcurrencyLimit) UnmarshalYAML(value *yaml.Node) error {
|
|
|
|
|
var n int
|
|
|
|
|
if err := value.Decode(&n); err == nil {
|
|
|
|
|
c.Total = n
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
var m map[string]int
|
|
|
|
|
if err := value.Decode(&m); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
c.Total = m["total"]
|
|
|
|
|
c.Models = make(map[string]int)
|
|
|
|
|
for k, v := range m {
|
|
|
|
|
if k != "total" {
|
|
|
|
|
c.Models[k] = v
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 22:30:05 +00:00
|
|
|
// config := agentic.AgentsConfig{Version: 1, Dispatch: agentic.DispatchConfig{DefaultAgent: "claude"}}
|
2026-03-16 11:10:33 +00:00
|
|
|
type AgentsConfig struct {
|
2026-03-29 21:19:37 +00:00
|
|
|
Version int `yaml:"version"`
|
|
|
|
|
Dispatch DispatchConfig `yaml:"dispatch"`
|
|
|
|
|
Concurrency map[string]ConcurrencyLimit `yaml:"concurrency"`
|
|
|
|
|
Rates map[string]RateConfig `yaml:"rates"`
|
2026-03-16 11:10:33 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-30 22:04:36 +00:00
|
|
|
// config := s.loadAgentsConfig()
|
2026-03-16 11:10:33 +00:00
|
|
|
func (s *PrepSubsystem) loadAgentsConfig() *AgentsConfig {
|
|
|
|
|
paths := []string{
|
refactor: migrate core/agent to Core primitives — reference implementation
Phase 1: go-io/go-log → core.Fs{}, core.E(), core.Error/Info/Warn
Phase 2: strings/fmt → core.Contains, core.Sprintf, core.Split etc
Phase 3: embed.FS → core.Mount/core.Embed, core.Extract
Phase 4: cmd/main.go → core.Command(), c.Cli().Run(), no cli package
All packages migrated:
- pkg/lib (Codex): core.Mount, core.Extract, Result returns, AX comments
- pkg/setup (Codex): core.Fs, core.E, fixed missing lib helpers
- pkg/brain (Codex): Core primitives, AX comments
- pkg/monitor (Codex): Core string/logging primitives
- pkg/agentic (Codex): 20 files, Core primitives throughout
- cmd/main.go: pure Core CLI, no fmt/log/filepath/strings/cli
Remaining stdlib: path/filepath (Core doesn't wrap OS paths),
fmt.Sscanf/strings.Map (no Core equivalent).
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-22 06:13:41 +00:00
|
|
|
core.JoinPath(CoreRoot(), "agents.yaml"),
|
|
|
|
|
core.JoinPath(s.codePath, "core", "agent", "config", "agents.yaml"),
|
2026-03-16 11:10:33 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, path := range paths {
|
2026-03-30 21:11:06 +00:00
|
|
|
readResult := fs.Read(path)
|
|
|
|
|
if !readResult.OK {
|
2026-03-16 11:10:33 +00:00
|
|
|
continue
|
|
|
|
|
}
|
2026-03-30 20:53:36 +00:00
|
|
|
var config AgentsConfig
|
2026-03-30 21:11:06 +00:00
|
|
|
if err := yaml.Unmarshal([]byte(readResult.Value.(string)), &config); err != nil {
|
2026-03-16 11:10:33 +00:00
|
|
|
continue
|
|
|
|
|
}
|
2026-03-30 20:53:36 +00:00
|
|
|
return &config
|
2026-03-16 11:10:33 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &AgentsConfig{
|
|
|
|
|
Dispatch: DispatchConfig{
|
|
|
|
|
DefaultAgent: "claude",
|
|
|
|
|
DefaultTemplate: "coding",
|
|
|
|
|
},
|
2026-03-24 13:05:41 +00:00
|
|
|
Concurrency: map[string]ConcurrencyLimit{
|
|
|
|
|
"claude": {Total: 1},
|
|
|
|
|
"gemini": {Total: 3},
|
2026-03-16 11:10:33 +00:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 22:04:36 +00:00
|
|
|
// delay := s.delayForAgent("codex:gpt-5.4")
|
2026-03-16 11:10:33 +00:00
|
|
|
func (s *PrepSubsystem) delayForAgent(agent string) time.Duration {
|
2026-03-26 06:38:02 +00:00
|
|
|
var rates map[string]RateConfig
|
|
|
|
|
if s.ServiceRuntime != nil {
|
|
|
|
|
rates, _ = s.Core().Config().Get("agents.rates").Value.(map[string]RateConfig)
|
|
|
|
|
}
|
|
|
|
|
if rates == nil {
|
2026-03-30 20:53:36 +00:00
|
|
|
config := s.loadAgentsConfig()
|
|
|
|
|
rates = config.Rates
|
2026-03-26 06:38:02 +00:00
|
|
|
}
|
refactor: migrate core/agent to Core primitives — reference implementation
Phase 1: go-io/go-log → core.Fs{}, core.E(), core.Error/Info/Warn
Phase 2: strings/fmt → core.Contains, core.Sprintf, core.Split etc
Phase 3: embed.FS → core.Mount/core.Embed, core.Extract
Phase 4: cmd/main.go → core.Command(), c.Cli().Run(), no cli package
All packages migrated:
- pkg/lib (Codex): core.Mount, core.Extract, Result returns, AX comments
- pkg/setup (Codex): core.Fs, core.E, fixed missing lib helpers
- pkg/brain (Codex): Core primitives, AX comments
- pkg/monitor (Codex): Core string/logging primitives
- pkg/agentic (Codex): 20 files, Core primitives throughout
- cmd/main.go: pure Core CLI, no fmt/log/filepath/strings/cli
Remaining stdlib: path/filepath (Core doesn't wrap OS paths),
fmt.Sscanf/strings.Map (no Core equivalent).
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-22 06:13:41 +00:00
|
|
|
base := baseAgent(agent)
|
2026-03-26 06:38:02 +00:00
|
|
|
rate, ok := rates[base]
|
2026-03-16 11:10:33 +00:00
|
|
|
if !ok || rate.SustainedDelay == 0 {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resetHour, resetMin := 6, 0
|
refactor: migrate core/agent to Core primitives — reference implementation
Phase 1: go-io/go-log → core.Fs{}, core.E(), core.Error/Info/Warn
Phase 2: strings/fmt → core.Contains, core.Sprintf, core.Split etc
Phase 3: embed.FS → core.Mount/core.Embed, core.Extract
Phase 4: cmd/main.go → core.Command(), c.Cli().Run(), no cli package
All packages migrated:
- pkg/lib (Codex): core.Mount, core.Extract, Result returns, AX comments
- pkg/setup (Codex): core.Fs, core.E, fixed missing lib helpers
- pkg/brain (Codex): Core primitives, AX comments
- pkg/monitor (Codex): Core string/logging primitives
- pkg/agentic (Codex): 20 files, Core primitives throughout
- cmd/main.go: pure Core CLI, no fmt/log/filepath/strings/cli
Remaining stdlib: path/filepath (Core doesn't wrap OS paths),
fmt.Sscanf/strings.Map (no Core equivalent).
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-22 06:13:41 +00:00
|
|
|
parts := core.Split(rate.ResetUTC, ":")
|
|
|
|
|
if len(parts) >= 2 {
|
|
|
|
|
if hour, err := strconv.Atoi(core.Trim(parts[0])); err == nil {
|
|
|
|
|
resetHour = hour
|
|
|
|
|
}
|
|
|
|
|
if min, err := strconv.Atoi(core.Trim(parts[1])); err == nil {
|
|
|
|
|
resetMin = min
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-16 11:10:33 +00:00
|
|
|
|
|
|
|
|
now := time.Now().UTC()
|
|
|
|
|
resetToday := time.Date(now.Year(), now.Month(), now.Day(), resetHour, resetMin, 0, 0, time.UTC)
|
|
|
|
|
if now.Before(resetToday) {
|
|
|
|
|
resetToday = resetToday.AddDate(0, 0, -1)
|
|
|
|
|
}
|
|
|
|
|
nextReset := resetToday.AddDate(0, 0, 1)
|
|
|
|
|
hoursUntilReset := nextReset.Sub(now).Hours()
|
|
|
|
|
|
2026-04-01 19:14:54 +00:00
|
|
|
delay := time.Duration(rate.SustainedDelay) * time.Second
|
2026-03-16 11:10:33 +00:00
|
|
|
if rate.BurstWindow > 0 && hoursUntilReset <= float64(rate.BurstWindow) {
|
2026-04-01 19:14:54 +00:00
|
|
|
delay = time.Duration(rate.BurstDelay) * time.Second
|
2026-03-16 11:10:33 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 19:14:54 +00:00
|
|
|
minDelay := time.Duration(rate.MinDelay) * time.Second
|
|
|
|
|
if minDelay > delay {
|
|
|
|
|
delay = minDelay
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return delay
|
2026-03-16 11:10:33 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-30 22:54:19 +00:00
|
|
|
// n := s.countRunningByAgent("codex")
|
2026-03-16 11:10:33 +00:00
|
|
|
func (s *PrepSubsystem) countRunningByAgent(agent string) int {
|
2026-03-30 16:01:32 +00:00
|
|
|
var runtime *core.Core
|
|
|
|
|
if s.ServiceRuntime != nil {
|
|
|
|
|
runtime = s.Core()
|
|
|
|
|
}
|
feat(runner): extract dispatch runner into independent Core service
Moves concurrency, queue drain, workspace lifecycle, and frozen state
from agentic/prep into pkg/runner/ — a standalone Core service that
communicates via IPC Actions only.
- runner.Register wires Actions: dispatch, status, start, stop, kill, poke
- runner.HandleIPCEvents catches AgentCompleted → ChannelPush + queue poke
- Agentic dispatch asks runner for permission via c.Action("runner.dispatch")
- Dispatch mutex moved to struct-level sync.Mutex (fixes core.Lock init race)
- Registry-based concurrency counting replaces disk scanning
- TrackWorkspace called on both queued and running status writes
- SpawnQueued message added for runner→agentic spawn requests
- ChannelPush message in core/mcp enables any service to push channel events
- 51 new tests covering runner service, queue, and config parsing
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 11:00:47 +00:00
|
|
|
if s.workspaces != nil && s.workspaces.Len() > 0 {
|
|
|
|
|
count := 0
|
2026-03-30 21:11:06 +00:00
|
|
|
s.workspaces.Each(func(_ string, workspaceStatus *WorkspaceStatus) {
|
|
|
|
|
if workspaceStatus.Status == "running" && baseAgent(workspaceStatus.Agent) == agent && ProcessAlive(runtime, workspaceStatus.ProcessID, workspaceStatus.PID) {
|
2026-03-30 00:28:11 +00:00
|
|
|
count++
|
feat(runner): extract dispatch runner into independent Core service
Moves concurrency, queue drain, workspace lifecycle, and frozen state
from agentic/prep into pkg/runner/ — a standalone Core service that
communicates via IPC Actions only.
- runner.Register wires Actions: dispatch, status, start, stop, kill, poke
- runner.HandleIPCEvents catches AgentCompleted → ChannelPush + queue poke
- Agentic dispatch asks runner for permission via c.Action("runner.dispatch")
- Dispatch mutex moved to struct-level sync.Mutex (fixes core.Lock init race)
- Registry-based concurrency counting replaces disk scanning
- TrackWorkspace called on both queued and running status writes
- SpawnQueued message added for runner→agentic spawn requests
- ChannelPush message in core/mcp enables any service to push channel events
- 51 new tests covering runner service, queue, and config parsing
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 11:00:47 +00:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
return count
|
|
|
|
|
}
|
2026-03-16 11:10:33 +00:00
|
|
|
|
2026-03-30 16:01:32 +00:00
|
|
|
return s.countRunningByAgentDisk(runtime, agent)
|
feat(runner): extract dispatch runner into independent Core service
Moves concurrency, queue drain, workspace lifecycle, and frozen state
from agentic/prep into pkg/runner/ — a standalone Core service that
communicates via IPC Actions only.
- runner.Register wires Actions: dispatch, status, start, stop, kill, poke
- runner.HandleIPCEvents catches AgentCompleted → ChannelPush + queue poke
- Agentic dispatch asks runner for permission via c.Action("runner.dispatch")
- Dispatch mutex moved to struct-level sync.Mutex (fixes core.Lock init race)
- Registry-based concurrency counting replaces disk scanning
- TrackWorkspace called on both queued and running status writes
- SpawnQueued message added for runner→agentic spawn requests
- ChannelPush message in core/mcp enables any service to push channel events
- 51 new tests covering runner service, queue, and config parsing
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 11:00:47 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-30 16:01:32 +00:00
|
|
|
func (s *PrepSubsystem) countRunningByAgentDisk(runtime *core.Core, agent string) int {
|
2026-03-16 11:10:33 +00:00
|
|
|
count := 0
|
2026-03-29 21:19:37 +00:00
|
|
|
for _, statusPath := range WorkspaceStatusPaths() {
|
2026-03-30 19:40:02 +00:00
|
|
|
result := ReadStatusResult(core.PathDir(statusPath))
|
2026-03-30 21:11:06 +00:00
|
|
|
workspaceStatus, ok := workspaceStatusValue(result)
|
|
|
|
|
if !ok || workspaceStatus.Status != "running" {
|
2026-03-16 11:10:33 +00:00
|
|
|
continue
|
|
|
|
|
}
|
2026-03-30 21:11:06 +00:00
|
|
|
if baseAgent(workspaceStatus.Agent) != agent {
|
2026-03-16 11:10:33 +00:00
|
|
|
continue
|
|
|
|
|
}
|
2026-03-30 21:11:06 +00:00
|
|
|
if ProcessAlive(runtime, workspaceStatus.ProcessID, workspaceStatus.PID) {
|
2026-03-17 19:35:15 +00:00
|
|
|
count++
|
2026-03-16 11:10:33 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return count
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 22:54:19 +00:00
|
|
|
// n := s.countRunningByModel("codex:gpt-5.4")
|
2026-03-24 13:05:41 +00:00
|
|
|
func (s *PrepSubsystem) countRunningByModel(agent string) int {
|
2026-03-30 16:01:32 +00:00
|
|
|
var runtime *core.Core
|
|
|
|
|
if s.ServiceRuntime != nil {
|
|
|
|
|
runtime = s.Core()
|
|
|
|
|
}
|
feat(runner): extract dispatch runner into independent Core service
Moves concurrency, queue drain, workspace lifecycle, and frozen state
from agentic/prep into pkg/runner/ — a standalone Core service that
communicates via IPC Actions only.
- runner.Register wires Actions: dispatch, status, start, stop, kill, poke
- runner.HandleIPCEvents catches AgentCompleted → ChannelPush + queue poke
- Agentic dispatch asks runner for permission via c.Action("runner.dispatch")
- Dispatch mutex moved to struct-level sync.Mutex (fixes core.Lock init race)
- Registry-based concurrency counting replaces disk scanning
- TrackWorkspace called on both queued and running status writes
- SpawnQueued message added for runner→agentic spawn requests
- ChannelPush message in core/mcp enables any service to push channel events
- 51 new tests covering runner service, queue, and config parsing
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 11:00:47 +00:00
|
|
|
if s.workspaces != nil && s.workspaces.Len() > 0 {
|
|
|
|
|
count := 0
|
2026-03-30 21:11:06 +00:00
|
|
|
s.workspaces.Each(func(_ string, workspaceStatus *WorkspaceStatus) {
|
|
|
|
|
if workspaceStatus.Status == "running" && workspaceStatus.Agent == agent && ProcessAlive(runtime, workspaceStatus.ProcessID, workspaceStatus.PID) {
|
2026-03-30 00:28:11 +00:00
|
|
|
count++
|
feat(runner): extract dispatch runner into independent Core service
Moves concurrency, queue drain, workspace lifecycle, and frozen state
from agentic/prep into pkg/runner/ — a standalone Core service that
communicates via IPC Actions only.
- runner.Register wires Actions: dispatch, status, start, stop, kill, poke
- runner.HandleIPCEvents catches AgentCompleted → ChannelPush + queue poke
- Agentic dispatch asks runner for permission via c.Action("runner.dispatch")
- Dispatch mutex moved to struct-level sync.Mutex (fixes core.Lock init race)
- Registry-based concurrency counting replaces disk scanning
- TrackWorkspace called on both queued and running status writes
- SpawnQueued message added for runner→agentic spawn requests
- ChannelPush message in core/mcp enables any service to push channel events
- 51 new tests covering runner service, queue, and config parsing
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 11:00:47 +00:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
return count
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 16:01:32 +00:00
|
|
|
return s.countRunningByModelDisk(runtime, agent)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *PrepSubsystem) countRunningByModelDisk(runtime *core.Core, agent string) int {
|
2026-03-24 13:05:41 +00:00
|
|
|
count := 0
|
2026-03-29 21:19:37 +00:00
|
|
|
for _, statusPath := range WorkspaceStatusPaths() {
|
2026-03-30 19:40:02 +00:00
|
|
|
result := ReadStatusResult(core.PathDir(statusPath))
|
2026-03-30 21:11:06 +00:00
|
|
|
workspaceStatus, ok := workspaceStatusValue(result)
|
|
|
|
|
if !ok || workspaceStatus.Status != "running" {
|
2026-03-24 13:05:41 +00:00
|
|
|
continue
|
|
|
|
|
}
|
2026-03-30 21:11:06 +00:00
|
|
|
if workspaceStatus.Agent != agent {
|
2026-03-24 13:05:41 +00:00
|
|
|
continue
|
|
|
|
|
}
|
2026-03-30 21:11:06 +00:00
|
|
|
if ProcessAlive(runtime, workspaceStatus.ProcessID, workspaceStatus.PID) {
|
2026-03-24 13:05:41 +00:00
|
|
|
count++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return count
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 22:04:36 +00:00
|
|
|
// base := baseAgent("gemini:flash") // "gemini"
|
2026-03-16 11:10:33 +00:00
|
|
|
func baseAgent(agent string) string {
|
refactor: migrate core/agent to Core primitives — reference implementation
Phase 1: go-io/go-log → core.Fs{}, core.E(), core.Error/Info/Warn
Phase 2: strings/fmt → core.Contains, core.Sprintf, core.Split etc
Phase 3: embed.FS → core.Mount/core.Embed, core.Extract
Phase 4: cmd/main.go → core.Command(), c.Cli().Run(), no cli package
All packages migrated:
- pkg/lib (Codex): core.Mount, core.Extract, Result returns, AX comments
- pkg/setup (Codex): core.Fs, core.E, fixed missing lib helpers
- pkg/brain (Codex): Core primitives, AX comments
- pkg/monitor (Codex): Core string/logging primitives
- pkg/agentic (Codex): 20 files, Core primitives throughout
- cmd/main.go: pure Core CLI, no fmt/log/filepath/strings/cli
Remaining stdlib: path/filepath (Core doesn't wrap OS paths),
fmt.Sscanf/strings.Map (no Core equivalent).
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-22 06:13:41 +00:00
|
|
|
return core.SplitN(agent, ":", 2)[0]
|
2026-03-16 11:10:33 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-30 22:30:05 +00:00
|
|
|
// codex: {total: 2, models: {gpt-5.4: 1}} → max 2 codex total, max 1 gpt-5.4
|
2026-03-16 11:10:33 +00:00
|
|
|
func (s *PrepSubsystem) canDispatchAgent(agent string) bool {
|
2026-03-24 16:44:19 +00:00
|
|
|
var concurrency map[string]ConcurrencyLimit
|
feat(v0.8.0): full AX migration — ServiceRuntime, Actions, quality gates, transport
go-process:
- Register factory, Result lifecycle, 5 named Action handlers
- Start/Run/StartWithOptions/RunWithOptions all return core.Result
- core.ID() replaces fmt.Sprintf, core.As replaces errors.As
core/agent:
- PrepSubsystem + monitor.Subsystem + setup.Service embed ServiceRuntime[T]
- 22 named Actions + agent.completion Task pipeline in OnStartup
- ChannelNotifier removed — all IPC via c.ACTION(messages.X{})
- proc.go: all methods via s.Core().Process(), returns core.Result
- status.go: WriteAtomic + JSONMarshalString
- paths.go: Fs.NewUnrestricted() replaces unsafe.Pointer
- transport.go: ONE net/http file — HTTPGet/HTTPPost/HTTPDo/MCP transport
- All disallowed imports eliminated from source files (13 quality gates)
- String concat eliminated — core.Concat() throughout
- 1:1 _test.go + _example_test.go for every source file
- Reference docs synced from core/go v0.8.0
- RFC-025 updated with net/http, net/url, io/fs quality gates
- lib.go: io/fs eliminated via Data.ListNames, Array[T].Deduplicate
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 01:27:46 +00:00
|
|
|
if s.ServiceRuntime != nil {
|
2026-03-30 21:11:06 +00:00
|
|
|
configurationResult := s.Core().Config().Get("agents.concurrency")
|
|
|
|
|
if configurationResult.OK {
|
|
|
|
|
concurrency, _ = configurationResult.Value.(map[string]ConcurrencyLimit)
|
2026-03-26 11:30:38 +00:00
|
|
|
}
|
2026-03-24 16:44:19 +00:00
|
|
|
}
|
|
|
|
|
if concurrency == nil {
|
2026-03-30 20:53:36 +00:00
|
|
|
config := s.loadAgentsConfig()
|
|
|
|
|
concurrency = config.Concurrency
|
2026-03-24 16:44:19 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-16 11:10:33 +00:00
|
|
|
base := baseAgent(agent)
|
2026-03-24 16:44:19 +00:00
|
|
|
limit, ok := concurrency[base]
|
2026-03-24 13:05:41 +00:00
|
|
|
if !ok || limit.Total <= 0 {
|
2026-04-01 19:14:54 +00:00
|
|
|
if blocked, until := s.dailyRateLimitBackoff(agent); blocked {
|
|
|
|
|
if s.backoff == nil {
|
|
|
|
|
s.backoff = make(map[string]time.Time)
|
|
|
|
|
}
|
|
|
|
|
s.backoff[baseAgent(agent)] = until
|
|
|
|
|
s.persistRuntimeState()
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-03-16 11:10:33 +00:00
|
|
|
return true
|
|
|
|
|
}
|
2026-03-24 13:05:41 +00:00
|
|
|
|
|
|
|
|
if s.countRunningByAgent(base) >= limit.Total {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if limit.Models != nil {
|
|
|
|
|
model := modelVariant(agent)
|
|
|
|
|
if model != "" {
|
|
|
|
|
if modelLimit, has := limit.Models[model]; has && modelLimit > 0 {
|
|
|
|
|
if s.countRunningByModel(agent) >= modelLimit {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 19:14:54 +00:00
|
|
|
if blocked, until := s.dailyRateLimitBackoff(agent); blocked {
|
|
|
|
|
if s.backoff == nil {
|
|
|
|
|
s.backoff = make(map[string]time.Time)
|
|
|
|
|
}
|
|
|
|
|
s.backoff[base] = until
|
|
|
|
|
s.persistRuntimeState()
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 13:05:41 +00:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 19:14:54 +00:00
|
|
|
func (s *PrepSubsystem) dailyRateLimitBackoff(agent string) (bool, time.Time) {
|
|
|
|
|
rates := s.loadAgentsConfig().Rates
|
|
|
|
|
rate, ok := rates[baseAgent(agent)]
|
|
|
|
|
if !ok || rate.DailyLimit <= 0 {
|
|
|
|
|
return false, time.Time{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if s.dailyDispatchCount(agent) < rate.DailyLimit {
|
|
|
|
|
return false, time.Time{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resetHour, resetMin := 6, 0
|
|
|
|
|
parts := core.Split(rate.ResetUTC, ":")
|
|
|
|
|
if len(parts) >= 2 {
|
|
|
|
|
if hour, err := strconv.Atoi(core.Trim(parts[0])); err == nil {
|
|
|
|
|
resetHour = hour
|
|
|
|
|
}
|
|
|
|
|
if min, err := strconv.Atoi(core.Trim(parts[1])); err == nil {
|
|
|
|
|
resetMin = min
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
now := time.Now().UTC()
|
|
|
|
|
resetToday := time.Date(now.Year(), now.Month(), now.Day(), resetHour, resetMin, 0, 0, time.UTC)
|
|
|
|
|
if now.Before(resetToday) {
|
|
|
|
|
resetToday = resetToday.AddDate(0, 0, -1)
|
|
|
|
|
}
|
|
|
|
|
nextReset := resetToday.AddDate(0, 0, 1)
|
|
|
|
|
if nextReset.Before(now) {
|
|
|
|
|
nextReset = now
|
|
|
|
|
}
|
|
|
|
|
return true, nextReset
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *PrepSubsystem) dailyDispatchCount(agent string) int {
|
|
|
|
|
eventsPath := core.JoinPath(WorkspaceRoot(), "events.jsonl")
|
|
|
|
|
result := fs.Read(eventsPath)
|
|
|
|
|
if !result.OK {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
targetDay := time.Now().UTC().Format("2006-01-02")
|
|
|
|
|
base := baseAgent(agent)
|
|
|
|
|
count := 0
|
|
|
|
|
|
|
|
|
|
for _, line := range core.Split(result.Value.(string), "\n") {
|
|
|
|
|
line = core.Trim(line)
|
|
|
|
|
if line == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var event CompletionEvent
|
|
|
|
|
if parseResult := core.JSONUnmarshalString(line, &event); !parseResult.OK {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if event.Type != "agent_started" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if baseAgent(event.Agent) != base {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
timestamp, err := time.Parse(time.RFC3339, event.Timestamp)
|
|
|
|
|
if err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if timestamp.UTC().Format("2006-01-02") != targetDay {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
count++
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return count
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 22:54:19 +00:00
|
|
|
// model := modelVariant("codex:gpt-5.4")
|
2026-03-31 05:20:50 +00:00
|
|
|
// core.Println(model) // "gpt-5.4"
|
2026-03-24 13:05:41 +00:00
|
|
|
func modelVariant(agent string) string {
|
|
|
|
|
parts := core.SplitN(agent, ":", 2)
|
|
|
|
|
if len(parts) < 2 {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return parts[1]
|
2026-03-16 11:10:33 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-30 22:04:36 +00:00
|
|
|
// s.drainQueue()
|
2026-03-16 11:10:33 +00:00
|
|
|
func (s *PrepSubsystem) drainQueue() {
|
2026-03-23 16:08:08 +00:00
|
|
|
if s.frozen {
|
|
|
|
|
return
|
|
|
|
|
}
|
feat(v0.8.0): full AX migration — ServiceRuntime, Actions, quality gates, transport
go-process:
- Register factory, Result lifecycle, 5 named Action handlers
- Start/Run/StartWithOptions/RunWithOptions all return core.Result
- core.ID() replaces fmt.Sprintf, core.As replaces errors.As
core/agent:
- PrepSubsystem + monitor.Subsystem + setup.Service embed ServiceRuntime[T]
- 22 named Actions + agent.completion Task pipeline in OnStartup
- ChannelNotifier removed — all IPC via c.ACTION(messages.X{})
- proc.go: all methods via s.Core().Process(), returns core.Result
- status.go: WriteAtomic + JSONMarshalString
- paths.go: Fs.NewUnrestricted() replaces unsafe.Pointer
- transport.go: ONE net/http file — HTTPGet/HTTPPost/HTTPDo/MCP transport
- All disallowed imports eliminated from source files (13 quality gates)
- String concat eliminated — core.Concat() throughout
- 1:1 _test.go + _example_test.go for every source file
- Reference docs synced from core/go v0.8.0
- RFC-025 updated with net/http, net/url, io/fs quality gates
- lib.go: io/fs eliminated via Data.ListNames, Array[T].Deduplicate
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 01:27:46 +00:00
|
|
|
if s.ServiceRuntime != nil {
|
|
|
|
|
s.Core().Lock("drain").Mutex.Lock()
|
|
|
|
|
defer s.Core().Lock("drain").Mutex.Unlock()
|
2026-03-24 16:44:19 +00:00
|
|
|
} else {
|
|
|
|
|
s.drainMu.Lock()
|
|
|
|
|
defer s.drainMu.Unlock()
|
|
|
|
|
}
|
2026-03-21 17:10:43 +00:00
|
|
|
|
2026-03-23 12:53:33 +00:00
|
|
|
for s.drainOne() {
|
2026-03-16 11:10:33 +00:00
|
|
|
}
|
2026-03-23 12:53:33 +00:00
|
|
|
}
|
2026-03-16 11:10:33 +00:00
|
|
|
|
2026-03-30 22:04:36 +00:00
|
|
|
// spawned := s.drainOne()
|
2026-03-23 12:53:33 +00:00
|
|
|
func (s *PrepSubsystem) drainOne() bool {
|
2026-03-29 21:19:37 +00:00
|
|
|
for _, statusPath := range WorkspaceStatusPaths() {
|
2026-03-30 21:22:54 +00:00
|
|
|
workspaceDir := core.PathDir(statusPath)
|
|
|
|
|
result := ReadStatusResult(workspaceDir)
|
2026-03-30 21:11:06 +00:00
|
|
|
workspaceStatus, ok := workspaceStatusValue(result)
|
|
|
|
|
if !ok || workspaceStatus.Status != "queued" {
|
2026-03-16 11:10:33 +00:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 21:11:06 +00:00
|
|
|
if !s.canDispatchAgent(workspaceStatus.Agent) {
|
2026-03-16 11:10:33 +00:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 21:11:06 +00:00
|
|
|
pool := baseAgent(workspaceStatus.Agent)
|
2026-03-23 16:08:08 +00:00
|
|
|
if until, ok := s.backoff[pool]; ok && time.Now().Before(until) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 21:11:06 +00:00
|
|
|
delay := s.delayForAgent(workspaceStatus.Agent)
|
2026-03-16 11:10:33 +00:00
|
|
|
if delay > 0 {
|
|
|
|
|
time.Sleep(delay)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 21:11:06 +00:00
|
|
|
if !s.canDispatchAgent(workspaceStatus.Agent) {
|
2026-03-16 11:10:33 +00:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 21:11:06 +00:00
|
|
|
prompt := core.Concat("TASK: ", workspaceStatus.Task, "\n\nResume from where you left off. Read CODEX.md for conventions. Commit when done.")
|
2026-03-16 11:10:33 +00:00
|
|
|
|
2026-03-30 21:22:54 +00:00
|
|
|
pid, processID, _, err := s.spawnAgent(workspaceStatus.Agent, prompt, workspaceDir)
|
2026-03-16 11:10:33 +00:00
|
|
|
if err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 21:11:06 +00:00
|
|
|
workspaceStatus.Status = "running"
|
|
|
|
|
workspaceStatus.PID = pid
|
|
|
|
|
workspaceStatus.ProcessID = processID
|
|
|
|
|
workspaceStatus.Runs++
|
2026-03-30 21:22:54 +00:00
|
|
|
writeStatusResult(workspaceDir, workspaceStatus)
|
|
|
|
|
s.TrackWorkspace(WorkspaceName(workspaceDir), workspaceStatus)
|
2026-03-16 11:10:33 +00:00
|
|
|
|
2026-03-23 12:53:33 +00:00
|
|
|
return true
|
2026-03-16 11:10:33 +00:00
|
|
|
}
|
2026-03-23 12:53:33 +00:00
|
|
|
|
|
|
|
|
return false
|
2026-03-16 11:10:33 +00:00
|
|
|
}
|