Skip to content

Commit 00ad31d

Browse files
committed
v0.56.0 — Eliminate post-response hang: run learn loop and episode extraction asynchronously
After the agent delivers its response, the process was making blocking LLM calls for skill learning (ExtractSkillsFromConversation + GenerateSkillWithLLM) and episode extraction (OnSessionEnd), causing a 2-5 second hang before the process terminated. Fix: wrap all four post-response LLM operations in goroutines so the process can exit immediately after delivering the response. These are best-effort operations — skill auto-save and episode summarization still happen, but they no longer block termination. Locations changed: - cmd/odek/main.go (run): learn loop + session end → goroutines - cmd/odek/main.go (continueCmd): session end → goroutine - cmd/odek/repl.go: session end → goroutine - cmd/odek/main_test.go: sync learn loop tests to async behavior
1 parent 22cc8f9 commit 00ad31d

3 files changed

Lines changed: 45 additions & 22 deletions

File tree

cmd/odek/main.go

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,23 +1071,29 @@ func run(args []string) error {
10711071
}
10721072

10731073
// ── Learn loop: run self-improvement heuristics ──
1074+
// Run asynchronously so the process can exit immediately after
1075+
// the response is delivered. Skill learning is best-effort
1076+
// post-processing that should not block termination.
10741077
if resolved.Skills.Learn && sm != nil {
1075-
// Create LLM client for skill enhancement
1076-
skillsLLM := llm.New(resolved.BaseURL, resolved.APIKey, resolved.Model, "", 30*time.Second)
1077-
runLearnLoop(allMessages, f.Task, sm, skillsLLM, resolved.Skills)
1078+
go func() {
1079+
skillsLLM := llm.New(resolved.BaseURL, resolved.APIKey, resolved.Model, "", 30*time.Second)
1080+
runLearnLoop(allMessages, f.Task, sm, skillsLLM, resolved.Skills)
1081+
}()
10781082
}
10791083

10801084
// ── Session end — extract episode if enough turns ──
1085+
// Run asynchronously so episode extraction does not delay process exit.
10811086
if mm := agent.Memory(); mm != nil && f.Session != nil && *f.Session {
1082-
// We need the session for OnSessionEnd. Re-create it from the stored data.
1083-
sess, err := session.NewStore()
1084-
if err == nil {
1085-
latest, err := sess.Latest()
1087+
go func() {
1088+
sess, err := session.NewStore()
10861089
if err == nil {
1087-
msgStrs := makeSessionMessageStrings(latest)
1088-
mm.OnSessionEnd(latest.ID, latest.Turns, msgStrs)
1090+
latest, err := sess.Latest()
1091+
if err == nil {
1092+
msgStrs := makeSessionMessageStrings(latest)
1093+
mm.OnSessionEnd(latest.ID, latest.Turns, msgStrs)
1094+
}
10891095
}
1090-
}
1096+
}()
10911097
}
10921098

10931099
// ── Delivery: send result to default channel ──
@@ -2017,9 +2023,12 @@ func continueCmd(args []string) error {
20172023
fmt.Fprintf(os.Stderr, "odek: session %s saved (%d turns)\n", sess.ID, sess.Turns+1)
20182024

20192025
// ── Session end — extract episode ──
2026+
// Run asynchronously so episode extraction does not delay process exit.
20202027
if mm := agent.Memory(); mm != nil {
2021-
msgStrs := makeSessionMessageStrings(sess)
2022-
mm.OnSessionEnd(sess.ID, sess.Turns+1, msgStrs)
2028+
go func() {
2029+
msgStrs := makeSessionMessageStrings(sess)
2030+
mm.OnSessionEnd(sess.ID, sess.Turns+1, msgStrs)
2031+
}()
20232032
}
20242033

20252034
return nil

cmd/odek/main_test.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"path/filepath"
1313
"strings"
1414
"testing"
15+
"time"
1516

1617
"github.com/BackendStack21/odek"
1718
"github.com/BackendStack21/odek/internal/config"
@@ -1394,6 +1395,10 @@ func TestRunLearn_MultiStepProcedure(t *testing.T) {
13941395
defer func() { os.Stderr = oldStderr }()
13951396

13961397
err := run([]string{"--learn", "--base-url", server.URL, "multi step task"})
1398+
// Learn loop now runs asynchronously; give it a moment to complete.
1399+
// With llm_learn=false the heuristics are instant, but we still
1400+
// need to let the goroutine write to stderr before closing the pipe.
1401+
time.Sleep(200 * time.Millisecond)
13971402
errW.Close()
13981403
errOutput, _ := io.ReadAll(errR)
13991404

@@ -1412,12 +1417,16 @@ func TestRunLearn_MultiStepProcedure(t *testing.T) {
14121417
t.Error("expected 'multi-step' heuristic in output")
14131418
}
14141419

1415-
// Skill file written to disk
1420+
// Skill file written to disk — poll since the goroutine may still be writing.
14161421
skillDir := filepath.Join(homeDir, ".odek", "skills", "procedure-echo")
14171422
skillFile := filepath.Join(skillDir, "SKILL.md")
1418-
if _, err := os.Stat(skillFile); os.IsNotExist(err) {
1419-
t.Errorf("expected skill file at %s", skillFile)
1423+
for i := 0; i < 10; i++ {
1424+
if _, err := os.Stat(skillFile); err == nil {
1425+
return // found
1426+
}
1427+
time.Sleep(50 * time.Millisecond)
14201428
}
1429+
t.Errorf("expected skill file at %s", skillFile)
14211430
}
14221431

14231432
// TestRunLearn_InteractiveReject verifies that when auto-save is disabled,
@@ -1459,6 +1468,8 @@ func TestRunLearn_InteractiveReject(t *testing.T) {
14591468
defer func() { os.Stderr = oldStderr }()
14601469

14611470
err := run([]string{"--learn", "--base-url", server.URL, "multi step task"})
1471+
// Learn loop runs asynchronously; give it a moment to write stderr.
1472+
time.Sleep(200 * time.Millisecond)
14621473
errW.Close()
14631474
errOutput, _ := io.ReadAll(errR)
14641475

cmd/odek/repl.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -270,14 +270,17 @@ func replCmd(args []string) error {
270270
fmt.Fprintln(os.Stderr)
271271
}
272272

273-
// Session end — extract episode if enough turns
273+
// Session end — extract episode if enough turns.
274+
// Run asynchronously so episode extraction does not delay process exit.
274275
if mm := agent.Memory(); mm != nil {
275-
messages := sess.GetMessages()
276-
msgStrs := make([]string, 0, len(messages))
277-
for _, m := range messages {
278-
msgStrs = append(msgStrs, m.Role+": "+m.Content)
279-
}
280-
mm.OnSessionEnd(sess.ID, sess.Turns, msgStrs)
276+
go func() {
277+
messages := sess.GetMessages()
278+
msgStrs := make([]string, 0, len(messages))
279+
for _, m := range messages {
280+
msgStrs = append(msgStrs, m.Role+": "+m.Content)
281+
}
282+
mm.OnSessionEnd(sess.ID, sess.Turns, msgStrs)
283+
}()
281284
}
282285

283286
return nil

0 commit comments

Comments
 (0)