Skip to content

Commit 8d592c9

Browse files
rhenley1958claude
andcommitted
feat(ft-recursive-002): the supervised retrain controller + operator-confirm promote (Epic 5)
ftloop.Controller: a supervised loop (default-off behind FT_LOOP_ENABLED) that picks up a triggered cycle from the ledger and walks it curate->train->gate as ctx-cancellable Python subprocesses, holding the compute lease + quiescing RSIC, updating the ledger + the 6a ft-loop:<stage> jobhealth surface at each step. FAIL -> archived + one ft-loop alert; lease-expiry/disk-floor -> class-4 high alert-and-halt; PASS -> promote_pending (halts; promotion is operator-gated). Started via StartSupervisedBackground (after the supervisor is set). The gate now OPENS a cycle on a trigger decision (the executor logs, no alert — the ledger is the controller's out-of-band signal, SPEC fork 7). mdemg ft-loop promote --cycle-id [--reject] records the operator decision to the ledger. AMD-1 epoch cap passed to train_ft. New config: FT_LOOP_POLL_INTERVAL_SEC, FT_LOOP_LEASE_MAX_HOURS/PATH, FT_LOOP_MIN_FREE_DISK_GB, FT_LOOP_PYTHON_BIN, FT_LOOP_MODEL_VERSION, FT_LORA_EPOCHS_CAP, FT_EARLY_STOP_VAL_LOSS_FACTOR. Controller state-machine tests (stub runStage): all-stages, stage-failure halt, disk-floor. Config guard 744/744. Subprocess arg sets validated live in Epic 6. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8f37f10 commit 8d592c9

9 files changed

Lines changed: 548 additions & 14 deletions

File tree

internal/ape/task_dispatch.go

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1175,15 +1175,13 @@ func (d *Dispatcher) executeTriggerTrainingPipeline(ctx context.Context, spec RS
11751175
slog.Debug("RSIC trigger gate: suppressed", "decision", decision, "space", spec.TargetSpace)
11761176
return map[string]any{"action": spec.ActionType, "suppressed": true, "decision": decision}, nil
11771177
}
1178-
// decision == trigger: actuator enabled + fresh + clear. Until Epic 5's
1179-
// controller owns this path, surface the actionable signal once (the gate's
1180-
// interval/open-cycle checks already prevent spam).
1181-
out, aerr := d.executeAlertLog(ctx, spec, "Training data ready — launching retrain cycle")
1182-
if out != nil {
1183-
out["decision"] = decision
1184-
out["suppressed"] = false
1185-
}
1186-
return out, aerr
1178+
// decision == trigger: the gate has opened a cycle in the ledger; the
1179+
// supervised controller (a separate loop) consumes it out-of-band and
1180+
// alerts on the outcome (promote_pending / failed). No alert here — the
1181+
// ledger is the signal, and alerting on every trigger would re-introduce
1182+
// spam.
1183+
slog.Info("RSIC trigger gate: retrain cycle opened", "space", spec.TargetSpace)
1184+
return map[string]any{"action": spec.ActionType, "suppressed": false, "decision": decision, "cycle_opened": true}, nil
11871185
}
11881186

11891187
// executeAlertLog is a generic alert/log handler for RSIC-DATA and gap-fix actions.

internal/ape/task_dispatch_test.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,14 +177,19 @@ func TestExecuteTriggerTrainingPipeline_SF2(t *testing.T) {
177177
t.Errorf("expected suppressed deliverables, got %v", out)
178178
}
179179

180-
// Trigger → alert fires (under the distinct rsic-<action> service from SF-3).
180+
// Trigger → the gate opened a cycle; the executor does NOT alert (the
181+
// controller owns outcome alerts) — cycle_opened=true, no spam.
181182
capTrig := &captureAlertDispatcher{}
182183
d2 := &Dispatcher{alertDispatcher: capTrig, trainingTriggerGate: fakeTriggerGate{decision: "trigger", suppressed: false}}
183-
if _, err := d2.executeTriggerTrainingPipeline(context.Background(), spec); err != nil {
184+
out2, err := d2.executeTriggerTrainingPipeline(context.Background(), spec)
185+
if err != nil {
184186
t.Fatal(err)
185187
}
186-
if len(capTrig.services) != 1 || capTrig.services[0] != "rsic-trigger_training_pipeline" {
187-
t.Errorf("trigger should alert under rsic-trigger_training_pipeline, got %v", capTrig.services)
188+
if len(capTrig.services) != 0 {
189+
t.Errorf("trigger must NOT alert (ledger is the signal), got %v", capTrig.services)
190+
}
191+
if out2["cycle_opened"] != true {
192+
t.Errorf("trigger should report cycle_opened, got %v", out2)
188193
}
189194

190195
// Nil gate → legacy alert (backward compat).

internal/api/server.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2129,6 +2129,34 @@ func (s *Server) StartSupervisedBackground() {
21292129
s.tsdbBackupScheduler.SetSupervise(s.superviseFn)
21302130
s.tsdbBackupScheduler.Start()
21312131
}
2132+
// FT-RECURSIVE-002 Epic 5: the recursive-retrain controller (supervised,
2133+
// default-off behind FT_LOOP_ENABLED — Run returns immediately when off).
2134+
if s.tsdbClient != nil {
2135+
repoDir, _ := os.Getwd()
2136+
leasePath := s.cfg.FtLoopLeasePath
2137+
if leasePath == "" {
2138+
if home, err := os.UserHomeDir(); err == nil {
2139+
leasePath = filepath.Join(home, ".mdemg", "ft-loop.lease")
2140+
} else {
2141+
leasePath = filepath.Join(os.TempDir(), "mdemg-ft-loop.lease")
2142+
}
2143+
}
2144+
ctrl := ftloop.NewController(s.tsdbClient.Pool(), s.orchestrationPolicy, s.alertDispatcher,
2145+
ftloop.ControllerConfig{
2146+
Enabled: s.cfg.FtLoopEnabled,
2147+
PollInterval: time.Duration(s.cfg.FtLoopPollIntervalSec) * time.Second,
2148+
LeasePath: leasePath,
2149+
LeaseMax: time.Duration(s.cfg.FtLoopLeaseMaxHours) * time.Hour,
2150+
MinFreeDiskGB: s.cfg.FtLoopMinFreeDiskGB,
2151+
PythonBin: s.cfg.FtLoopPythonBin,
2152+
ModelVersion: s.cfg.FtLoopModelVersion,
2153+
EpochsCap: s.cfg.FtLoraEpochsCap,
2154+
EarlyStopFactor: s.cfg.FtEarlyStopValLossFactor,
2155+
RepoDir: repoDir,
2156+
InstanceID: s.cfg.InstanceID,
2157+
})
2158+
s.goSupervised("ft-loop-controller", ctrl.Run)
2159+
}
21322160
}
21332161

21342162
// StartPeriodicConsolidation starts a background goroutine that consolidates conversation memory

internal/cli/ft_loop.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,84 @@ high-severity alert under the distinct "ft-loop" service). The autonomous
4545
actuator that calls these is a later phase (FT-RECURSIVE-002).`,
4646
}
4747
cmd.AddCommand(newFtLoopReportStageCmd())
48+
cmd.AddCommand(newFtLoopPromoteCmd())
49+
return cmd
50+
}
51+
52+
func newFtLoopPromoteCmd() *cobra.Command {
53+
var (
54+
cycleID string
55+
reject bool
56+
reason string
57+
)
58+
cmd := &cobra.Command{
59+
Use: "promote",
60+
Short: "Operator-confirm (or reject) a promote_pending retrain cycle",
61+
Long: `Confirm or reject a cycle the controller left at promote_pending.
62+
63+
The controller halts every cycle at promote_pending — promotion is operator-
64+
gated in Phase 6b (auto-promote + canary are Phase 7). --confirm records the
65+
cycle promoted; --reject records it rolled_back (with --reason). The actual
66+
GGUF symlink swap + llama-server restart is performed in the live promotion
67+
flow; this records the operator decision in the ft_training_cycles ledger.`,
68+
RunE: func(_ *cobra.Command, _ []string) error {
69+
if cycleID == "" {
70+
return fmt.Errorf("--cycle-id is required")
71+
}
72+
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
73+
defer cancel()
74+
75+
client, err := tsdb.NewClient(ctx, tsdbConfigFromEnv())
76+
if err != nil {
77+
return fmt.Errorf("connect TSDB: %w", err)
78+
}
79+
defer client.Close()
80+
pool := client.Pool()
81+
82+
status, found, err := tsdb.CycleStatus(ctx, pool, cycleID)
83+
if err != nil {
84+
return fmt.Errorf("query cycle: %w", err)
85+
}
86+
if !found {
87+
return fmt.Errorf("cycle %q not found in the ledger", cycleID)
88+
}
89+
if status != tsdb.FtCyclePromotePending {
90+
return fmt.Errorf("cycle %q is %q, not promote_pending — nothing to confirm/reject", cycleID, status)
91+
}
92+
93+
modelVersion := strings.TrimSpace(os.Getenv("FT_LOOP_MODEL_VERSION"))
94+
if modelVersion == "" {
95+
modelVersion = "mdemg-llm-v1"
96+
}
97+
ev := tsdb.FtCycleEvent{
98+
CycleID: cycleID,
99+
ModelVersion: modelVersion,
100+
EvalResults: map[string]any{"operator_decision": "confirm", "reason": reason},
101+
}
102+
if reject {
103+
ev.Status = tsdb.FtCycleRolledBack
104+
ev.Stage = "operator_reject"
105+
ev.Error = reason
106+
ev.EvalResults["operator_decision"] = "reject"
107+
} else {
108+
ev.Status = tsdb.FtCyclePromoted
109+
ev.Stage = "operator_confirm"
110+
}
111+
if err := tsdb.RecordCycleEvent(ctx, pool, ev); err != nil {
112+
return fmt.Errorf("record decision: %w", err)
113+
}
114+
if reject {
115+
fmt.Printf("cycle %s rejected (rolled_back). reason: %s\n", cycleID, reason)
116+
} else {
117+
fmt.Printf("cycle %s confirmed (promoted). Perform the GGUF symlink swap + llama-server restart to serve the new model.\n", cycleID)
118+
}
119+
return nil
120+
},
121+
}
122+
cmd.Flags().StringVar(&cycleID, "cycle-id", "", "the promote_pending cycle to act on")
123+
cmd.Flags().BoolVar(&reject, "reject", false, "reject the cycle (rolled_back) instead of confirming")
124+
cmd.Flags().StringVar(&reason, "reason", "", "operator reasoning (recorded; required-ish for --reject)")
125+
_ = cmd.MarkFlagRequired("cycle-id")
48126
return cmd
49127
}
50128

internal/config/config.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,6 +1228,16 @@ type Config struct {
12281228
FtLoopMinRetrainIntervalHours int // FT_LOOP_MIN_RETRAIN_INTERVAL_HOURS — suppress a new trigger if a cycle started within this window (single-flight at retrain scale, not the RSIC seconds cooldown) (default: 168 = 7 days)
12291229
FtLoopMinFreshFraction float64 // FT_LOOP_MIN_FRESH_FRACTION — minimum fraction of interactions newer than the last cycle before retraining (retrain on new signal, never the same corpus) (default: 0.30)
12301230

1231+
// FT recursive-retrain controller (FT-RECURSIVE-002 Phase 6b, Epic 5)
1232+
FtLoopPollIntervalSec int // FT_LOOP_POLL_INTERVAL_SEC — controller poll cadence for a triggered cycle (default: 60)
1233+
FtLoopLeaseMaxHours int // FT_LOOP_LEASE_MAX_HOURS — compute-lease expiry (≈1.5× the ~9h actual) so a crashed trainer can't wedge RSIC (default: 14)
1234+
FtLoopLeasePath string // FT_LOOP_LEASE_PATH — compute-lease lockfile path (default: $HOME/.mdemg/ft-loop.lease)
1235+
FtLoopMinFreeDiskGB float64 // FT_LOOP_MIN_FREE_DISK_GB — disk-floor preflight before TRAIN (~85GB transient) (default: 100)
1236+
FtLoopPythonBin string // FT_LOOP_PYTHON_BIN — python interpreter for the training subprocesses (default: python3)
1237+
FtLoopModelVersion string // FT_LOOP_MODEL_VERSION — model_version recorded on cycles (default: mdemg-llm-v1)
1238+
FtLoraEpochsCap int // FT_LORA_EPOCHS_CAP — hard epoch cap passed to train_ft.py (AMD-1; the `auto` rejection is never weakened) (default: 3)
1239+
FtEarlyStopValLossFactor float64 // FT_EARLY_STOP_VAL_LOSS_FACTOR — SFT early-stop trips when val_loss > best × this (AMD-1) (default: 1.05)
1240+
12311241
// MAINT-LIVE-001 — maintenance liveness (only-ever-dry-runs detection).
12321242
MaintLiveAlertEnabled bool // MAINT_LIVE_ALERT_ENABLED — enable the maintenance_no_live_run rule (default: true)
12331243
MaintLiveLookbackDays int // MAINT_LIVE_LOOKBACK_DAYS — window in which at least one live (dry_run=false) maintenance run must appear when any maintenance runs exist (default: 8)
@@ -4650,6 +4660,44 @@ func FromEnv() (Config, error) {
46504660
if ftLoopMinFreshFraction < 0 || ftLoopMinFreshFraction > 1 {
46514661
return Config{}, errors.New("FT_LOOP_MIN_FRESH_FRACTION must be in [0,1]")
46524662
}
4663+
ftLoopPollIntervalSec, err := atoi("FT_LOOP_POLL_INTERVAL_SEC", 60)
4664+
if err != nil {
4665+
return Config{}, err
4666+
}
4667+
if ftLoopPollIntervalSec < 1 {
4668+
return Config{}, errors.New("FT_LOOP_POLL_INTERVAL_SEC must be >= 1")
4669+
}
4670+
ftLoopLeaseMaxHours, err := atoi("FT_LOOP_LEASE_MAX_HOURS", 14)
4671+
if err != nil {
4672+
return Config{}, err
4673+
}
4674+
if ftLoopLeaseMaxHours < 1 {
4675+
return Config{}, errors.New("FT_LOOP_LEASE_MAX_HOURS must be >= 1")
4676+
}
4677+
ftLoopLeasePath := strings.TrimSpace(os.Getenv("FT_LOOP_LEASE_PATH"))
4678+
ftLoopMinFreeDiskGB, err := atof("FT_LOOP_MIN_FREE_DISK_GB", 100)
4679+
if err != nil {
4680+
return Config{}, err
4681+
}
4682+
if ftLoopMinFreeDiskGB < 0 {
4683+
return Config{}, errors.New("FT_LOOP_MIN_FREE_DISK_GB must be >= 0")
4684+
}
4685+
ftLoopPythonBin := get("FT_LOOP_PYTHON_BIN", "python3")
4686+
ftLoopModelVersion := get("FT_LOOP_MODEL_VERSION", "mdemg-llm-v1")
4687+
ftLoraEpochsCap, err := atoi("FT_LORA_EPOCHS_CAP", 3)
4688+
if err != nil {
4689+
return Config{}, err
4690+
}
4691+
if ftLoraEpochsCap < 1 {
4692+
return Config{}, errors.New("FT_LORA_EPOCHS_CAP must be >= 1")
4693+
}
4694+
ftEarlyStopValLossFactor, err := atof("FT_EARLY_STOP_VAL_LOSS_FACTOR", 1.05)
4695+
if err != nil {
4696+
return Config{}, err
4697+
}
4698+
if ftEarlyStopValLossFactor <= 1.0 {
4699+
return Config{}, errors.New("FT_EARLY_STOP_VAL_LOSS_FACTOR must be > 1.0")
4700+
}
46534701
maintLiveAlertEnabled := getBool("MAINT_LIVE_ALERT_ENABLED", true)
46544702
maintLiveLookbackDays, err := atoi("MAINT_LIVE_LOOKBACK_DAYS", 8)
46554703
if err != nil {
@@ -5490,6 +5538,14 @@ func FromEnv() (Config, error) {
54905538
FtLoopEnabled: ftLoopEnabled,
54915539
FtLoopMinRetrainIntervalHours: ftLoopMinRetrainIntervalHours,
54925540
FtLoopMinFreshFraction: ftLoopMinFreshFraction,
5541+
FtLoopPollIntervalSec: ftLoopPollIntervalSec,
5542+
FtLoopLeaseMaxHours: ftLoopLeaseMaxHours,
5543+
FtLoopLeasePath: ftLoopLeasePath,
5544+
FtLoopMinFreeDiskGB: ftLoopMinFreeDiskGB,
5545+
FtLoopPythonBin: ftLoopPythonBin,
5546+
FtLoopModelVersion: ftLoopModelVersion,
5547+
FtLoraEpochsCap: ftLoraEpochsCap,
5548+
FtEarlyStopValLossFactor: ftEarlyStopValLossFactor,
54935549
MaintLiveAlertEnabled: maintLiveAlertEnabled,
54945550
MaintLiveLookbackDays: maintLiveLookbackDays,
54955551
JobBackupStalenessHours: jobBackupStalenessHours,

0 commit comments

Comments
 (0)