detached 2766cd4

Merge pull request #130 from bnema/fix/goal-continuation-loop

fix(goals): prevent unbounded continuations
commit
2766cd4bc00e4bb39cebf53130c28a7a1ae42c97
author
bnema · · 2026-08-14 06:41 UTC
committer
GitHub · · 2026-08-14 06:41 UTC
merge of
949637b + dd0b211 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
16 files · +968 -30
M README.md +13 -1
@@ -335,7 +335,19 @@ Extension-registered commands appear under a divider at the bottom of the popup,
335335
336336 ### `/goal`
337337
338-In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts a hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. The manager can use `update_goal` to settle the current goal or set a bounded next goal within the same mission. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it.
338+In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. The manager can use `update_goal` to settle the current goal or set a bounded next goal within the same mission. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session.
339+
340+Goals are unlimited by default. Zut records token use for each goal but only enforces a budget when it is explicitly configured in `config.json`:
341+
342+```json
343+{
344+ "goals": {
345+ "max_token_budget": 1000000
346+ }
347+}
348+```
349+
350+When set, `max_token_budget` is applied to newly created goals. A goal that reaches it becomes `budget_limited`; omitting the setting creates a goal with no token limit. This is separate from the continuation safety controller: a turn that ends without a concrete tool action receives one corrective continuation, then becomes `stalled` rather than repeatedly sending the same prompt. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it.
339351
340352 A branch that copies the complete transcript inherits the goal. Forking from an earlier turn clears it because the later objective did not necessarily exist at that point. Manual `/compact` leaves an active goal recorded but does not start a hidden follow-up; run `/goal resume` to continue it.
341353
@@ -335,7 +335,19 @@ Extension-registered commands appear under a divider at the bottom of the popup,
335 335
336 ### `/goal`336 ### `/goal`
337 337
338-In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts a hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. The manager can use `update_goal` to settle the current goal or set a bounded next goal within the same mission. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it.338+In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. The manager can use `update_goal` to settle the current goal or set a bounded next goal within the same mission. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session.
339+
340+Goals are unlimited by default. Zut records token use for each goal but only enforces a budget when it is explicitly configured in `config.json`:
341+
342+```json
343+{
344+ "goals": {
345+ "max_token_budget": 1000000
346+ }
347+}
348+```
349+
350+When set, `max_token_budget` is applied to newly created goals. A goal that reaches it becomes `budget_limited`; omitting the setting creates a goal with no token limit. This is separate from the continuation safety controller: a turn that ends without a concrete tool action receives one corrective continuation, then becomes `stalled` rather than repeatedly sending the same prompt. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it.
339 351
340 A branch that copies the complete transcript inherits the goal. Forking from an earlier turn clears it because the later objective did not necessarily exist at that point. Manual `/compact` leaves an active goal recorded but does not start a hidden follow-up; run `/goal resume` to continue it.352 A branch that copies the complete transcript inherits the goal. Forking from an earlier turn clears it because the later objective did not necessarily exist at that point. Manual `/compact` leaves an active goal recorded but does not start a hidden follow-up; run `/goal resume` to continue it.
341 353
M packages/agent/cli.go +71 -10
@@ -693,8 +693,19 @@ func copyExtensionStates(states map[string]json.RawMessage) map[string]json.RawM
693693 return out
694694 }
695695
696-func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result core.ToolResult) error {
696+var (
697+ errGoalActiveReplacement = errors.New("active goal replacement")
698+ errGoalMissionMissing = errors.New("goal mission missing")
699+ errGoalMissionMismatch = errors.New("goal mission mismatch")
700+ errGoalStorageUnavailable = errors.New("goal storage unavailable")
701+ errGoalStateWrite = errors.New("goal state write")
702+)
703+
704+func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result core.ToolResult, goalMaxTokenBudget ...*uint64) error {
697705 if sess == nil {
706+ if _, ok := tools.GoalUpdateFromResult(result); ok {
707+ return goalCommitError(errGoalStorageUnavailable)
708+ }
698709 return nil
699710 }
700711 if details, ok := result.Details.(extensions.ToolResultDetails); ok && details.Extension != "" && len(details.State) > 0 {
@@ -705,13 +716,34 @@ func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result
705716 mgr.UpdateSessionState(details.Extension, details.State)
706717 }
707718 }
708- if err := persistGoalToolResult(sess, result); err != nil {
709- return fmt.Errorf("goal state: %w", err)
719+ if err := persistGoalToolResult(sess, result, goalMaxTokenBudget...); err != nil {
720+ return goalCommitError(err)
710721 }
711722 return nil
712723 }
713724
714-func persistGoalToolResult(sess *core.Session, result core.ToolResult) error {
725+func goalCommitError(err error) error {
726+ return &core.ToolResultCommitError{Message: safeGoalCommitMessage(err), Err: fmt.Errorf("goal state: %w", err)}
727+}
728+
729+func safeGoalCommitMessage(err error) string {
730+ switch {
731+ case errors.Is(err, errGoalActiveReplacement):
732+ return "goal transition rejected: the current goal is still active"
733+ case errors.Is(err, errGoalMissionMissing):
734+ return "goal transition rejected: no active mission"
735+ case errors.Is(err, errGoalMissionMismatch):
736+ return "goal transition rejected: it does not belong to the active mission"
737+ case errors.Is(err, errGoalStorageUnavailable):
738+ return "goal state unavailable: session persistence is disabled"
739+ case errors.Is(err, errGoalStateWrite):
740+ return "goal state could not be saved"
741+ default:
742+ return "goal update could not be persisted"
743+ }
744+}
745+
746+func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTokenBudget ...*uint64) error {
715747 if sess == nil {
716748 return nil
717749 }
@@ -721,12 +753,23 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult) error {
721753 }
722754 if update.Status == core.GoalActive {
723755 if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive {
724- return fmt.Errorf("cannot replace an active goal")
756+ return errGoalActiveReplacement
757+ }
758+ if sess.Meta.Mission == nil {
759+ return errGoalMissionMissing
760+ }
761+ if update.MissionID == "" || update.MissionID != sess.Meta.Mission.ID {
762+ return errGoalMissionMismatch
763+ }
764+ goal := &core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager}
765+ if len(goalMaxTokenBudget) > 0 && goalMaxTokenBudget[0] != nil && *goalMaxTokenBudget[0] > 0 {
766+ budget := *goalMaxTokenBudget[0]
767+ goal.TokenBudget = &budget
725768 }
726- if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID {
727- return fmt.Errorf("goal update does not belong to the active mission")
769+ if err := sess.UpdateGoal(goal); err != nil {
770+ return fmt.Errorf("%w: %w", errGoalStateWrite, err)
728771 }
729- return sess.UpdateGoal(&core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager})
772+ return nil
730773 }
731774 if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive {
732775 return nil
@@ -734,7 +777,10 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult) error {
734777 goal := *sess.Meta.Goal
735778 goal.Status = update.Status
736779 goal.Reason = update.Reason
737- return sess.UpdateGoal(&goal)
780+ if err := sess.UpdateGoal(&goal); err != nil {
781+ return fmt.Errorf("%w: %w", errGoalStateWrite, err)
782+ }
783+ return nil
738784 }
739785
740786 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
@@ -1416,6 +1462,9 @@ func reloadResourcesAfterStartupPreWithRegistry(ctx context.Context, args Args,
14161462
14171463 func runInteractive(ctx context.Context, args Args, version string) (runErr error) {
14181464 initialCfg, _ := LoadConfig()
1465+ if initialCfg.Goals.MaxTokenBudget != nil && *initialCfg.Goals.MaxTokenBudget == 0 {
1466+ return errors.New("goals.max_token_budget must be greater than zero")
1467+ }
14191468 // Resolve WITHOUT requiring credentials.
14201469 r, err := Resolve(args, false)
14211470 if err != nil {
@@ -1849,6 +1898,16 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
18491898 }
18501899 return sess.UpdateGoal(goal)
18511900 }
1901+ persistGoalRuntime := func(goal *core.SessionGoal) error {
1902+ sessionTransitionMu.RLock()
1903+ defer sessionTransitionMu.RUnlock()
1904+ persistMu.Lock()
1905+ defer persistMu.Unlock()
1906+ if sess == nil {
1907+ return errors.New("session persistence is disabled")
1908+ }
1909+ return sess.UpdateGoalRuntime(goal)
1910+ }
18521911 ensureMission := func(objective string) error {
18531912 sessionTransitionMu.RLock()
18541913 defer sessionTransitionMu.RUnlock()
@@ -1883,7 +1942,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
18831942 a.CommitToolResult = func(_ string, result core.ToolResult) error {
18841943 persistMu.Lock()
18851944 defer persistMu.Unlock()
1886- return persistToolResultState(extMgr, boundSession, result)
1945+ return persistToolResultState(extMgr, boundSession, result, initialCfg.Goals.MaxTokenBudget)
18871946 }
18881947 }
18891948 wireAgentPersist := func(a *core.Agent) *core.Agent {
@@ -2361,6 +2420,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
23612420 LSPEnabled: initialCfg.LSPEnabled,
23622421 SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
23632422 AutoCompactThreshold: initialCfg.AutoCompactThreshold,
2423+ GoalMaxTokenBudget: initialCfg.Goals.MaxTokenBudget,
23642424 JailByDefault: initialCfg.JailByDefault,
23652425 QuickModelShortcuts: quickModelShortcuts,
23662426 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
@@ -2483,6 +2543,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
24832543 PersistCompactHandoff: persistCompactHandoff,
24842544 CurrentCompactHandoff: currentCompactHandoff,
24852545 PersistGoal: persistGoal,
2546+ PersistGoalRuntime: persistGoalRuntime,
24862547 CurrentGoal: currentGoal,
24872548 CurrentGoalHistory: currentGoalHistory,
24882549 EnsureMission: ensureMission,
@@ -693,8 +693,19 @@ func copyExtensionStates(states map[string]json.RawMessage) map[string]json.RawM
693 return out693 return out
694 }694 }
695 695
696-func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result core.ToolResult) error {696+var (
697+ errGoalActiveReplacement = errors.New("active goal replacement")
698+ errGoalMissionMissing = errors.New("goal mission missing")
699+ errGoalMissionMismatch = errors.New("goal mission mismatch")
700+ errGoalStorageUnavailable = errors.New("goal storage unavailable")
701+ errGoalStateWrite = errors.New("goal state write")
702+)
703+
704+func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result core.ToolResult, goalMaxTokenBudget ...*uint64) error {
697 if sess == nil {705 if sess == nil {
706+ if _, ok := tools.GoalUpdateFromResult(result); ok {
707+ return goalCommitError(errGoalStorageUnavailable)
708+ }
698 return nil709 return nil
699 }710 }
700 if details, ok := result.Details.(extensions.ToolResultDetails); ok && details.Extension != "" && len(details.State) > 0 {711 if details, ok := result.Details.(extensions.ToolResultDetails); ok && details.Extension != "" && len(details.State) > 0 {
@@ -705,13 +716,34 @@ func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result
705 mgr.UpdateSessionState(details.Extension, details.State)716 mgr.UpdateSessionState(details.Extension, details.State)
706 }717 }
707 }718 }
708- if err := persistGoalToolResult(sess, result); err != nil {719+ if err := persistGoalToolResult(sess, result, goalMaxTokenBudget...); err != nil {
709- return fmt.Errorf("goal state: %w", err)720+ return goalCommitError(err)
710 }721 }
711 return nil722 return nil
712 }723 }
713 724
714-func persistGoalToolResult(sess *core.Session, result core.ToolResult) error {725+func goalCommitError(err error) error {
726+ return &core.ToolResultCommitError{Message: safeGoalCommitMessage(err), Err: fmt.Errorf("goal state: %w", err)}
727+}
728+
729+func safeGoalCommitMessage(err error) string {
730+ switch {
731+ case errors.Is(err, errGoalActiveReplacement):
732+ return "goal transition rejected: the current goal is still active"
733+ case errors.Is(err, errGoalMissionMissing):
734+ return "goal transition rejected: no active mission"
735+ case errors.Is(err, errGoalMissionMismatch):
736+ return "goal transition rejected: it does not belong to the active mission"
737+ case errors.Is(err, errGoalStorageUnavailable):
738+ return "goal state unavailable: session persistence is disabled"
739+ case errors.Is(err, errGoalStateWrite):
740+ return "goal state could not be saved"
741+ default:
742+ return "goal update could not be persisted"
743+ }
744+}
745+
746+func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTokenBudget ...*uint64) error {
715 if sess == nil {747 if sess == nil {
716 return nil748 return nil
717 }749 }
@@ -721,12 +753,23 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult) error {
721 }753 }
722 if update.Status == core.GoalActive {754 if update.Status == core.GoalActive {
723 if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive {755 if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive {
724- return fmt.Errorf("cannot replace an active goal")756+ return errGoalActiveReplacement
757+ }
758+ if sess.Meta.Mission == nil {
759+ return errGoalMissionMissing
760+ }
761+ if update.MissionID == "" || update.MissionID != sess.Meta.Mission.ID {
762+ return errGoalMissionMismatch
763+ }
764+ goal := &core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager}
765+ if len(goalMaxTokenBudget) > 0 && goalMaxTokenBudget[0] != nil && *goalMaxTokenBudget[0] > 0 {
766+ budget := *goalMaxTokenBudget[0]
767+ goal.TokenBudget = &budget
725 }768 }
726- if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID {769+ if err := sess.UpdateGoal(goal); err != nil {
727- return fmt.Errorf("goal update does not belong to the active mission")770+ return fmt.Errorf("%w: %w", errGoalStateWrite, err)
728 }771 }
729- return sess.UpdateGoal(&core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager})772+ return nil
730 }773 }
731 if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive {774 if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive {
732 return nil775 return nil
@@ -734,7 +777,10 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult) error {
734 goal := *sess.Meta.Goal777 goal := *sess.Meta.Goal
735 goal.Status = update.Status778 goal.Status = update.Status
736 goal.Reason = update.Reason779 goal.Reason = update.Reason
737- return sess.UpdateGoal(&goal)780+ if err := sess.UpdateGoal(&goal); err != nil {
781+ return fmt.Errorf("%w: %w", errGoalStateWrite, err)
782+ }
783+ return nil
738 }784 }
739 785
740 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {786 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
@@ -1416,6 +1462,9 @@ func reloadResourcesAfterStartupPreWithRegistry(ctx context.Context, args Args,
1416 1462
1417 func runInteractive(ctx context.Context, args Args, version string) (runErr error) {1463 func runInteractive(ctx context.Context, args Args, version string) (runErr error) {
1418 initialCfg, _ := LoadConfig()1464 initialCfg, _ := LoadConfig()
1465+ if initialCfg.Goals.MaxTokenBudget != nil && *initialCfg.Goals.MaxTokenBudget == 0 {
1466+ return errors.New("goals.max_token_budget must be greater than zero")
1467+ }
1419 // Resolve WITHOUT requiring credentials.1468 // Resolve WITHOUT requiring credentials.
1420 r, err := Resolve(args, false)1469 r, err := Resolve(args, false)
1421 if err != nil {1470 if err != nil {
@@ -1849,6 +1898,16 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1849 }1898 }
1850 return sess.UpdateGoal(goal)1899 return sess.UpdateGoal(goal)
1851 }1900 }
1901+ persistGoalRuntime := func(goal *core.SessionGoal) error {
1902+ sessionTransitionMu.RLock()
1903+ defer sessionTransitionMu.RUnlock()
1904+ persistMu.Lock()
1905+ defer persistMu.Unlock()
1906+ if sess == nil {
1907+ return errors.New("session persistence is disabled")
1908+ }
1909+ return sess.UpdateGoalRuntime(goal)
1910+ }
1852 ensureMission := func(objective string) error {1911 ensureMission := func(objective string) error {
1853 sessionTransitionMu.RLock()1912 sessionTransitionMu.RLock()
1854 defer sessionTransitionMu.RUnlock()1913 defer sessionTransitionMu.RUnlock()
@@ -1883,7 +1942,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1883 a.CommitToolResult = func(_ string, result core.ToolResult) error {1942 a.CommitToolResult = func(_ string, result core.ToolResult) error {
1884 persistMu.Lock()1943 persistMu.Lock()
1885 defer persistMu.Unlock()1944 defer persistMu.Unlock()
1886- return persistToolResultState(extMgr, boundSession, result)1945+ return persistToolResultState(extMgr, boundSession, result, initialCfg.Goals.MaxTokenBudget)
1887 }1946 }
1888 }1947 }
1889 wireAgentPersist := func(a *core.Agent) *core.Agent {1948 wireAgentPersist := func(a *core.Agent) *core.Agent {
@@ -2361,6 +2420,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2361 LSPEnabled: initialCfg.LSPEnabled,2420 LSPEnabled: initialCfg.LSPEnabled,
2362 SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,2421 SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
2363 AutoCompactThreshold: initialCfg.AutoCompactThreshold,2422 AutoCompactThreshold: initialCfg.AutoCompactThreshold,
2423+ GoalMaxTokenBudget: initialCfg.Goals.MaxTokenBudget,
2364 JailByDefault: initialCfg.JailByDefault,2424 JailByDefault: initialCfg.JailByDefault,
2365 QuickModelShortcuts: quickModelShortcuts,2425 QuickModelShortcuts: quickModelShortcuts,
2366 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,2426 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
@@ -2483,6 +2543,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2483 PersistCompactHandoff: persistCompactHandoff,2543 PersistCompactHandoff: persistCompactHandoff,
2484 CurrentCompactHandoff: currentCompactHandoff,2544 CurrentCompactHandoff: currentCompactHandoff,
2485 PersistGoal: persistGoal,2545 PersistGoal: persistGoal,
2546+ PersistGoalRuntime: persistGoalRuntime,
2486 CurrentGoal: currentGoal,2547 CurrentGoal: currentGoal,
2487 CurrentGoalHistory: currentGoalHistory,2548 CurrentGoalHistory: currentGoalHistory,
2488 EnsureMission: ensureMission,2549 EnsureMission: ensureMission,
M packages/agent/cli_session_test.go +140 -1
@@ -111,14 +111,133 @@ func TestToolResultPersistenceUsesBoundSession(t *testing.T) {
111111 }
112112 }
113113
114+func TestPersistToolResultStateUsesSafeGoalPersistenceCategories(t *testing.T) {
115+ const privateGoal = "private goal text"
116+ const privateMission = "private-mission-id"
117+ const privateReason = "private active reason"
118+ const privateSessionContent = "private session content"
119+ var writeFailureGoal core.SessionGoal
120+
121+ newSession := func(t *testing.T) *core.Session {
122+ t.Helper()
123+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
124+ if err != nil {
125+ t.Fatal(err)
126+ }
127+ if err := sess.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: privateSessionContent}}}); err != nil {
128+ t.Fatal(err)
129+ }
130+ t.Cleanup(func() { _ = sess.Close() })
131+ return sess
132+ }
133+
134+ tests := []struct {
135+ name string
136+ setup func(*testing.T) *core.Session
137+ update tools.GoalUpdate
138+ message string
139+ assertUnchanged func(*testing.T, *core.Session)
140+ }{
141+ {
142+ name: "active goal",
143+ setup: func(t *testing.T) *core.Session {
144+ sess := newSession(t)
145+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: privateGoal, Status: core.GoalActive}); err != nil {
146+ t.Fatal(err)
147+ }
148+ return sess
149+ },
150+ update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal},
151+ message: "goal transition rejected: the current goal is still active",
152+ },
153+ {
154+ name: "missing mission",
155+ setup: newSession,
156+ update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal, MissionID: privateMission},
157+ message: "goal transition rejected: no active mission",
158+ },
159+ {
160+ name: "mismatched mission",
161+ setup: func(t *testing.T) *core.Session {
162+ sess := newSession(t)
163+ if err := sess.EnsureMission(privateGoal, core.MissionSourceUser); err != nil {
164+ t.Fatal(err)
165+ }
166+ return sess
167+ },
168+ update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal, MissionID: privateMission},
169+ message: "goal transition rejected: it does not belong to the active mission",
170+ },
171+ {
172+ name: "storage unavailable",
173+ setup: func(*testing.T) *core.Session { return nil },
174+ update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal, MissionID: privateMission},
175+ message: "goal state unavailable: session persistence is disabled",
176+ },
177+ {
178+ name: "write failure",
179+ setup: func(t *testing.T) *core.Session {
180+ sess := newSession(t)
181+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: privateGoal, Status: core.GoalActive, Reason: privateReason}); err != nil {
182+ t.Fatal(err)
183+ }
184+ writeFailureGoal = *sess.Meta.Goal
185+ if err := sess.Close(); err != nil {
186+ t.Fatal(err)
187+ }
188+ return sess
189+ },
190+ update: tools.GoalUpdate{Status: core.GoalDone},
191+ message: "goal state could not be saved",
192+ assertUnchanged: func(t *testing.T, sess *core.Session) {
193+ t.Helper()
194+ assertGoal := func(source string, goal *core.SessionGoal) {
195+ t.Helper()
196+ if goal == nil || goal.Status != writeFailureGoal.Status || goal.Objective != writeFailureGoal.Objective || goal.Reason != writeFailureGoal.Reason {
197+ t.Fatalf("%s goal after failed update = %#v, want status=%q objective=%q reason=%q", source, goal, writeFailureGoal.Status, writeFailureGoal.Objective, writeFailureGoal.Reason)
198+ }
199+ }
200+ assertGoal("in-memory", sess.Meta.Goal)
201+ snapshot, err := core.ReadSessionSnapshot(sess.Path)
202+ if err != nil {
203+ t.Fatal(err)
204+ }
205+ assertGoal("persisted", snapshot.Meta.Goal)
206+ },
207+ },
208+ }
209+ for _, test := range tests {
210+ t.Run(test.name, func(t *testing.T) {
211+ sess := test.setup(t)
212+ err := persistToolResultState(nil, sess, core.ToolResult{Details: test.update})
213+ var safe *core.ToolResultCommitError
214+ if !errors.As(err, &safe) {
215+ t.Fatalf("persist error = %T %v, want safe tool result commit error", err, err)
216+ }
217+ if safe.Message != test.message {
218+ t.Fatalf("safe message = %q, want %q", safe.Message, test.message)
219+ }
220+ if strings.Contains(safe.Message, privateGoal) || strings.Contains(safe.Message, privateMission) || strings.Contains(safe.Message, privateSessionContent) || (sess != nil && strings.Contains(safe.Message, sess.Path)) {
221+ t.Fatalf("safe message leaked private state: %q", safe.Message)
222+ }
223+ if test.assertUnchanged != nil {
224+ test.assertUnchanged(t, sess)
225+ }
226+ })
227+ }
228+}
229+
114230 func TestPersistGoalToolResultCreatesActiveGoal(t *testing.T) {
115231 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
116232 if err != nil {
117233 t.Fatal(err)
118234 }
119235 defer sess.Close()
236+ if err := sess.EnsureMission("reproduce the reported failure", core.MissionSourceUser); err != nil {
237+ t.Fatal(err)
238+ }
120239
121- result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure"}}
240+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure", MissionID: sess.Meta.Mission.ID}}
122241 if err := persistGoalToolResult(sess, result); err != nil {
123242 t.Fatal(err)
124243 }
@@ -127,6 +246,26 @@ func TestPersistGoalToolResultCreatesActiveGoal(t *testing.T) {
127246 }
128247 }
129248
249+func TestPersistGoalToolResultAppliesConfiguredBudget(t *testing.T) {
250+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
251+ if err != nil {
252+ t.Fatal(err)
253+ }
254+ defer sess.Close()
255+ if err := sess.EnsureMission("reproduce the reported failure", core.MissionSourceUser); err != nil {
256+ t.Fatal(err)
257+ }
258+
259+ budget := uint64(1_000_000)
260+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure", MissionID: sess.Meta.Mission.ID}}
261+ if err := persistGoalToolResult(sess, result, &budget); err != nil {
262+ t.Fatal(err)
263+ }
264+ if goal := sess.Meta.Goal; goal == nil || goal.TokenBudget == nil || *goal.TokenBudget != budget {
265+ t.Fatalf("persisted goal budget = %#v", goal)
266+ }
267+}
268+
130269 func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) {
131270 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
132271 if err != nil {
@@ -111,14 +111,133 @@ func TestToolResultPersistenceUsesBoundSession(t *testing.T) {
111 }111 }
112 }112 }
113 113
114+func TestPersistToolResultStateUsesSafeGoalPersistenceCategories(t *testing.T) {
115+ const privateGoal = "private goal text"
116+ const privateMission = "private-mission-id"
117+ const privateReason = "private active reason"
118+ const privateSessionContent = "private session content"
119+ var writeFailureGoal core.SessionGoal
120+
121+ newSession := func(t *testing.T) *core.Session {
122+ t.Helper()
123+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
124+ if err != nil {
125+ t.Fatal(err)
126+ }
127+ if err := sess.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: privateSessionContent}}}); err != nil {
128+ t.Fatal(err)
129+ }
130+ t.Cleanup(func() { _ = sess.Close() })
131+ return sess
132+ }
133+
134+ tests := []struct {
135+ name string
136+ setup func(*testing.T) *core.Session
137+ update tools.GoalUpdate
138+ message string
139+ assertUnchanged func(*testing.T, *core.Session)
140+ }{
141+ {
142+ name: "active goal",
143+ setup: func(t *testing.T) *core.Session {
144+ sess := newSession(t)
145+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: privateGoal, Status: core.GoalActive}); err != nil {
146+ t.Fatal(err)
147+ }
148+ return sess
149+ },
150+ update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal},
151+ message: "goal transition rejected: the current goal is still active",
152+ },
153+ {
154+ name: "missing mission",
155+ setup: newSession,
156+ update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal, MissionID: privateMission},
157+ message: "goal transition rejected: no active mission",
158+ },
159+ {
160+ name: "mismatched mission",
161+ setup: func(t *testing.T) *core.Session {
162+ sess := newSession(t)
163+ if err := sess.EnsureMission(privateGoal, core.MissionSourceUser); err != nil {
164+ t.Fatal(err)
165+ }
166+ return sess
167+ },
168+ update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal, MissionID: privateMission},
169+ message: "goal transition rejected: it does not belong to the active mission",
170+ },
171+ {
172+ name: "storage unavailable",
173+ setup: func(*testing.T) *core.Session { return nil },
174+ update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal, MissionID: privateMission},
175+ message: "goal state unavailable: session persistence is disabled",
176+ },
177+ {
178+ name: "write failure",
179+ setup: func(t *testing.T) *core.Session {
180+ sess := newSession(t)
181+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: privateGoal, Status: core.GoalActive, Reason: privateReason}); err != nil {
182+ t.Fatal(err)
183+ }
184+ writeFailureGoal = *sess.Meta.Goal
185+ if err := sess.Close(); err != nil {
186+ t.Fatal(err)
187+ }
188+ return sess
189+ },
190+ update: tools.GoalUpdate{Status: core.GoalDone},
191+ message: "goal state could not be saved",
192+ assertUnchanged: func(t *testing.T, sess *core.Session) {
193+ t.Helper()
194+ assertGoal := func(source string, goal *core.SessionGoal) {
195+ t.Helper()
196+ if goal == nil || goal.Status != writeFailureGoal.Status || goal.Objective != writeFailureGoal.Objective || goal.Reason != writeFailureGoal.Reason {
197+ t.Fatalf("%s goal after failed update = %#v, want status=%q objective=%q reason=%q", source, goal, writeFailureGoal.Status, writeFailureGoal.Objective, writeFailureGoal.Reason)
198+ }
199+ }
200+ assertGoal("in-memory", sess.Meta.Goal)
201+ snapshot, err := core.ReadSessionSnapshot(sess.Path)
202+ if err != nil {
203+ t.Fatal(err)
204+ }
205+ assertGoal("persisted", snapshot.Meta.Goal)
206+ },
207+ },
208+ }
209+ for _, test := range tests {
210+ t.Run(test.name, func(t *testing.T) {
211+ sess := test.setup(t)
212+ err := persistToolResultState(nil, sess, core.ToolResult{Details: test.update})
213+ var safe *core.ToolResultCommitError
214+ if !errors.As(err, &safe) {
215+ t.Fatalf("persist error = %T %v, want safe tool result commit error", err, err)
216+ }
217+ if safe.Message != test.message {
218+ t.Fatalf("safe message = %q, want %q", safe.Message, test.message)
219+ }
220+ if strings.Contains(safe.Message, privateGoal) || strings.Contains(safe.Message, privateMission) || strings.Contains(safe.Message, privateSessionContent) || (sess != nil && strings.Contains(safe.Message, sess.Path)) {
221+ t.Fatalf("safe message leaked private state: %q", safe.Message)
222+ }
223+ if test.assertUnchanged != nil {
224+ test.assertUnchanged(t, sess)
225+ }
226+ })
227+ }
228+}
229+
114 func TestPersistGoalToolResultCreatesActiveGoal(t *testing.T) {230 func TestPersistGoalToolResultCreatesActiveGoal(t *testing.T) {
115 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")231 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
116 if err != nil {232 if err != nil {
117 t.Fatal(err)233 t.Fatal(err)
118 }234 }
119 defer sess.Close()235 defer sess.Close()
236+ if err := sess.EnsureMission("reproduce the reported failure", core.MissionSourceUser); err != nil {
237+ t.Fatal(err)
238+ }
120 239
121- result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure"}}240+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure", MissionID: sess.Meta.Mission.ID}}
122 if err := persistGoalToolResult(sess, result); err != nil {241 if err := persistGoalToolResult(sess, result); err != nil {
123 t.Fatal(err)242 t.Fatal(err)
124 }243 }
@@ -127,6 +246,26 @@ func TestPersistGoalToolResultCreatesActiveGoal(t *testing.T) {
127 }246 }
128 }247 }
129 248
249+func TestPersistGoalToolResultAppliesConfiguredBudget(t *testing.T) {
250+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
251+ if err != nil {
252+ t.Fatal(err)
253+ }
254+ defer sess.Close()
255+ if err := sess.EnsureMission("reproduce the reported failure", core.MissionSourceUser); err != nil {
256+ t.Fatal(err)
257+ }
258+
259+ budget := uint64(1_000_000)
260+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure", MissionID: sess.Meta.Mission.ID}}
261+ if err := persistGoalToolResult(sess, result, &budget); err != nil {
262+ t.Fatal(err)
263+ }
264+ if goal := sess.Meta.Goal; goal == nil || goal.TokenBudget == nil || *goal.TokenBudget != budget {
265+ t.Fatalf("persisted goal budget = %#v", goal)
266+ }
267+}
268+
130 func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) {269 func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) {
131 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")270 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
132 if err != nil {271 if err != nil {
M packages/agent/config.go +11 -0
@@ -33,6 +33,13 @@ type SubagentsConfig struct {
3333 AllowedRoots []string `json:"allowed_roots,omitempty"`
3434 }
3535
36+// GoalsConfig controls optional resource limits for autonomous goals. A nil
37+// MaxTokenBudget leaves goals unlimited; when set, it is the host-configured
38+// cap applied to newly created goals.
39+type GoalsConfig struct {
40+ MaxTokenBudget *uint64 `json:"max_token_budget,omitempty"`
41+}
42+
3643 // Config is the persisted user configuration.
3744 type Config struct {
3845 Provider string `json:"provider"`
@@ -82,6 +89,10 @@ type Config struct {
8289 // Subagents controls manager-owned child lifecycle and resource policy.
8390 Subagents SubagentsConfig `json:"subagents,omitempty"`
8491
92+ // Goals configures optional resource limits for autonomous goals. Missing
93+ // configuration preserves unlimited autonomous execution.
94+ Goals GoalsConfig `json:"goals,omitempty"`
95+
8596 // AutoSubagentsEnabled lets the main agent spawn background sub-agents
8697 // and query their live state via built-in auto-subagent tools. Off by
8798 // default; nil/missing means disabled. Toggle from /settings.
@@ -33,6 +33,13 @@ type SubagentsConfig struct {
33 AllowedRoots []string `json:"allowed_roots,omitempty"`33 AllowedRoots []string `json:"allowed_roots,omitempty"`
34 }34 }
35 35
36+// GoalsConfig controls optional resource limits for autonomous goals. A nil
37+// MaxTokenBudget leaves goals unlimited; when set, it is the host-configured
38+// cap applied to newly created goals.
39+type GoalsConfig struct {
40+ MaxTokenBudget *uint64 `json:"max_token_budget,omitempty"`
41+}
42+
36 // Config is the persisted user configuration.43 // Config is the persisted user configuration.
37 type Config struct {44 type Config struct {
38 Provider string `json:"provider"`45 Provider string `json:"provider"`
@@ -82,6 +89,10 @@ type Config struct {
82 // Subagents controls manager-owned child lifecycle and resource policy.89 // Subagents controls manager-owned child lifecycle and resource policy.
83 Subagents SubagentsConfig `json:"subagents,omitempty"`90 Subagents SubagentsConfig `json:"subagents,omitempty"`
84 91
92+ // Goals configures optional resource limits for autonomous goals. Missing
93+ // configuration preserves unlimited autonomous execution.
94+ Goals GoalsConfig `json:"goals,omitempty"`
95+
85 // AutoSubagentsEnabled lets the main agent spawn background sub-agents96 // AutoSubagentsEnabled lets the main agent spawn background sub-agents
86 // and query their live state via built-in auto-subagent tools. Off by97 // and query their live state via built-in auto-subagent tools. Off by
87 // default; nil/missing means disabled. Toggle from /settings.98 // default; nil/missing means disabled. Toggle from /settings.
A packages/agent/config_goal_test.go +24 -0
@@ -0,0 +1,24 @@
1+package agent
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+)
7+
8+func TestGoalsConfigTokenBudgetIsOptional(t *testing.T) {
9+ var unlimited Config
10+ if err := json.Unmarshal([]byte(`{}`), &unlimited); err != nil {
11+ t.Fatal(err)
12+ }
13+ if unlimited.Goals.MaxTokenBudget != nil {
14+ t.Fatalf("missing budget = %v, want nil", *unlimited.Goals.MaxTokenBudget)
15+ }
16+
17+ var limited Config
18+ if err := json.Unmarshal([]byte(`{"goals":{"max_token_budget":1000000}}`), &limited); err != nil {
19+ t.Fatal(err)
20+ }
21+ if limited.Goals.MaxTokenBudget == nil || *limited.Goals.MaxTokenBudget != 1_000_000 {
22+ t.Fatalf("configured budget = %#v", limited.Goals.MaxTokenBudget)
23+ }
24+}
@@ -0,0 +1,24 @@
1+package agent
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+)
7+
8+func TestGoalsConfigTokenBudgetIsOptional(t *testing.T) {
9+ var unlimited Config
10+ if err := json.Unmarshal([]byte(`{}`), &unlimited); err != nil {
11+ t.Fatal(err)
12+ }
13+ if unlimited.Goals.MaxTokenBudget != nil {
14+ t.Fatalf("missing budget = %v, want nil", *unlimited.Goals.MaxTokenBudget)
15+ }
16+
17+ var limited Config
18+ if err := json.Unmarshal([]byte(`{"goals":{"max_token_budget":1000000}}`), &limited); err != nil {
19+ t.Fatal(err)
20+ }
21+ if limited.Goals.MaxTokenBudget == nil || *limited.Goals.MaxTokenBudget != 1_000_000 {
22+ t.Fatalf("configured budget = %#v", limited.Goals.MaxTokenBudget)
23+ }
24+}
M packages/agent/modes/goal.go +40 -3
@@ -31,6 +31,9 @@ func (i *Interactive) goalContinuationMessage() (provider.Message, bool) {
3131 }
3232 text := goal.Objective +
3333 "\n\nContinue working autonomously toward this active goal. Do not stop at a progress update. Continue until the goal is complete or blocked, then call update_goal."
34+ if goal.ConsecutiveNoProgressTurns > 0 {
35+ text += " You ended the prior continuation without taking a concrete action. Inspect the current state and take the next action now; do not merely say that you will continue."
36+ }
3437 if goal.MissionID != "" {
3538 text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission."
3639 }
@@ -47,7 +50,11 @@ func (i *Interactive) goalIdleLocked() bool {
4750
4851 func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool {
4952 message, ok := i.goalContinuationMessage()
50- if !ok || i.coordinatorHasPendingWorkers() {
53+ if !ok || i.coordinatorHasPendingWorkers() || i.cfg.CurrentGoal == nil {
54+ return false
55+ }
56+ goal := copySessionGoal(i.cfg.CurrentGoal())
57+ if goal == nil || i.limitGoalBeforeRun(goal) {
5158 return false
5259 }
5360 i.mu.Lock()
@@ -57,11 +64,25 @@ func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool
5764 }
5865 i.busy = true
5966 i.mu.Unlock()
60- i.startGoalContinuation(parent, message)
67+ run, err := i.startGoalRun(goal)
68+ if err != nil {
69+ i.mu.Lock()
70+ i.busy = false
71+ i.mu.Unlock()
72+ i.ReportError(err)
73+ return false
74+ }
75+ if run == nil {
76+ i.mu.Lock()
77+ i.busy = false
78+ i.mu.Unlock()
79+ return false
80+ }
81+ i.startGoalContinuation(parent, message, run)
6182 return true
6283 }
6384
64-func (i *Interactive) startGoalContinuation(parent context.Context, message provider.Message) {
85+func (i *Interactive) startGoalContinuation(parent context.Context, message provider.Message, run *goalContinuationRun) {
6586 goalMessage, goalActive := i.goalContinuationMessage()
6687 i.mu.Lock()
6788 ag := i.agent
@@ -70,6 +91,7 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov
7091 pendingIdleWork := i.takePendingIdleWorkLocked()
7192 i.mu.Unlock()
7293 runPendingIdleWork(pendingIdleWork)
94+ i.abandonGoalRun(run)
7395 i.invalidate()
7496 return
7597 }
@@ -77,11 +99,13 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov
7799 next := i.queued[0]
78100 i.queued = i.queued[1:]
79101 i.mu.Unlock()
102+ i.abandonGoalRun(run)
80103 i.startTurn(parent, next)
81104 return
82105 }
83106 if ag.QueuedMessageCount() > 0 {
84107 i.mu.Unlock()
108+ i.abandonGoalRun(run)
85109 i.startTurnRequest(parent, "", nil, true, false)
86110 return
87111 }
@@ -93,6 +117,7 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov
93117 // The objective may have been replaced while the prior turn was
94118 // finishing. Re-evaluate it now instead of waiting for another event;
95119 // the idle gate still gives queued user work priority.
120+ i.abandonGoalRun(run)
96121 i.requestGoalContinuationIfIdle(parent)
97122 i.invalidate()
98123 return
@@ -108,6 +133,7 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov
108133 next := i.queued[0]
109134 i.queued = i.queued[1:]
110135 i.mu.Unlock()
136+ i.abandonGoalRun(run)
111137 i.startTurn(parent, next)
112138 return
113139 }
@@ -182,6 +208,11 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st
182208 owner = core.GoalOwnerUser
183209 }
184210 status := fmt.Sprintf("goal %s (%s): %s", current.Status, owner, current.Objective)
211+ if current.TokenBudget != nil {
212+ status += fmt.Sprintf(" (%d / %d tokens)", current.TokensUsed, *current.TokenBudget)
213+ } else if current.TokensUsed > 0 {
214+ status += fmt.Sprintf(" (%d tokens)", current.TokensUsed)
215+ }
185216 if current.Reason != "" {
186217 status += " (" + current.Reason + ")"
187218 }
@@ -226,6 +257,12 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st
226257 return
227258 }
228259 current = &core.SessionGoal{Objective: objective, Status: core.GoalActive, Owner: core.GoalOwnerUser}
260+ if i.cfg.GoalMaxTokenBudget != nil {
261+ budget := *i.cfg.GoalMaxTokenBudget
262+ if budget > 0 {
263+ current.TokenBudget = &budget
264+ }
265+ }
229266 startIfIdle = true
230267 startTitle = true
231268 }
@@ -31,6 +31,9 @@ func (i *Interactive) goalContinuationMessage() (provider.Message, bool) {
31 }31 }
32 text := goal.Objective +32 text := goal.Objective +
33 "\n\nContinue working autonomously toward this active goal. Do not stop at a progress update. Continue until the goal is complete or blocked, then call update_goal."33 "\n\nContinue working autonomously toward this active goal. Do not stop at a progress update. Continue until the goal is complete or blocked, then call update_goal."
34+ if goal.ConsecutiveNoProgressTurns > 0 {
35+ text += " You ended the prior continuation without taking a concrete action. Inspect the current state and take the next action now; do not merely say that you will continue."
36+ }
34 if goal.MissionID != "" {37 if goal.MissionID != "" {
35 text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission."38 text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission."
36 }39 }
@@ -47,7 +50,11 @@ func (i *Interactive) goalIdleLocked() bool {
47 50
48 func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool {51 func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool {
49 message, ok := i.goalContinuationMessage()52 message, ok := i.goalContinuationMessage()
50- if !ok || i.coordinatorHasPendingWorkers() {53+ if !ok || i.coordinatorHasPendingWorkers() || i.cfg.CurrentGoal == nil {
54+ return false
55+ }
56+ goal := copySessionGoal(i.cfg.CurrentGoal())
57+ if goal == nil || i.limitGoalBeforeRun(goal) {
51 return false58 return false
52 }59 }
53 i.mu.Lock()60 i.mu.Lock()
@@ -57,11 +64,25 @@ func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool
57 }64 }
58 i.busy = true65 i.busy = true
59 i.mu.Unlock()66 i.mu.Unlock()
60- i.startGoalContinuation(parent, message)67+ run, err := i.startGoalRun(goal)
68+ if err != nil {
69+ i.mu.Lock()
70+ i.busy = false
71+ i.mu.Unlock()
72+ i.ReportError(err)
73+ return false
74+ }
75+ if run == nil {
76+ i.mu.Lock()
77+ i.busy = false
78+ i.mu.Unlock()
79+ return false
80+ }
81+ i.startGoalContinuation(parent, message, run)
61 return true82 return true
62 }83 }
63 84
64-func (i *Interactive) startGoalContinuation(parent context.Context, message provider.Message) {85+func (i *Interactive) startGoalContinuation(parent context.Context, message provider.Message, run *goalContinuationRun) {
65 goalMessage, goalActive := i.goalContinuationMessage()86 goalMessage, goalActive := i.goalContinuationMessage()
66 i.mu.Lock()87 i.mu.Lock()
67 ag := i.agent88 ag := i.agent
@@ -70,6 +91,7 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov
70 pendingIdleWork := i.takePendingIdleWorkLocked()91 pendingIdleWork := i.takePendingIdleWorkLocked()
71 i.mu.Unlock()92 i.mu.Unlock()
72 runPendingIdleWork(pendingIdleWork)93 runPendingIdleWork(pendingIdleWork)
94+ i.abandonGoalRun(run)
73 i.invalidate()95 i.invalidate()
74 return96 return
75 }97 }
@@ -77,11 +99,13 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov
77 next := i.queued[0]99 next := i.queued[0]
78 i.queued = i.queued[1:]100 i.queued = i.queued[1:]
79 i.mu.Unlock()101 i.mu.Unlock()
102+ i.abandonGoalRun(run)
80 i.startTurn(parent, next)103 i.startTurn(parent, next)
81 return104 return
82 }105 }
83 if ag.QueuedMessageCount() > 0 {106 if ag.QueuedMessageCount() > 0 {
84 i.mu.Unlock()107 i.mu.Unlock()
108+ i.abandonGoalRun(run)
85 i.startTurnRequest(parent, "", nil, true, false)109 i.startTurnRequest(parent, "", nil, true, false)
86 return110 return
87 }111 }
@@ -93,6 +117,7 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov
93 // The objective may have been replaced while the prior turn was117 // The objective may have been replaced while the prior turn was
94 // finishing. Re-evaluate it now instead of waiting for another event;118 // finishing. Re-evaluate it now instead of waiting for another event;
95 // the idle gate still gives queued user work priority.119 // the idle gate still gives queued user work priority.
120+ i.abandonGoalRun(run)
96 i.requestGoalContinuationIfIdle(parent)121 i.requestGoalContinuationIfIdle(parent)
97 i.invalidate()122 i.invalidate()
98 return123 return
@@ -108,6 +133,7 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov
108 next := i.queued[0]133 next := i.queued[0]
109 i.queued = i.queued[1:]134 i.queued = i.queued[1:]
110 i.mu.Unlock()135 i.mu.Unlock()
136+ i.abandonGoalRun(run)
111 i.startTurn(parent, next)137 i.startTurn(parent, next)
112 return138 return
113 }139 }
@@ -182,6 +208,11 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st
182 owner = core.GoalOwnerUser208 owner = core.GoalOwnerUser
183 }209 }
184 status := fmt.Sprintf("goal %s (%s): %s", current.Status, owner, current.Objective)210 status := fmt.Sprintf("goal %s (%s): %s", current.Status, owner, current.Objective)
211+ if current.TokenBudget != nil {
212+ status += fmt.Sprintf(" (%d / %d tokens)", current.TokensUsed, *current.TokenBudget)
213+ } else if current.TokensUsed > 0 {
214+ status += fmt.Sprintf(" (%d tokens)", current.TokensUsed)
215+ }
185 if current.Reason != "" {216 if current.Reason != "" {
186 status += " (" + current.Reason + ")"217 status += " (" + current.Reason + ")"
187 }218 }
@@ -226,6 +257,12 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st
226 return257 return
227 }258 }
228 current = &core.SessionGoal{Objective: objective, Status: core.GoalActive, Owner: core.GoalOwnerUser}259 current = &core.SessionGoal{Objective: objective, Status: core.GoalActive, Owner: core.GoalOwnerUser}
260+ if i.cfg.GoalMaxTokenBudget != nil {
261+ budget := *i.cfg.GoalMaxTokenBudget
262+ if budget > 0 {
263+ current.TokenBudget = &budget
264+ }
265+ }
229 startIfIdle = true266 startIfIdle = true
230 startTitle = true267 startTitle = true
231 }268 }
A packages/agent/modes/goal_runtime.go +210 -0
@@ -0,0 +1,210 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ agenttools "github.com/bnema/zut/packages/agent/tools"
8+ "github.com/bnema/zut/packages/core"
9+ "github.com/bnema/zut/packages/provider"
10+ "github.com/google/uuid"
11+)
12+
13+// A second empty continuation is enough to distinguish a model that briefly
14+// paused to summarize from one that is only acknowledging the continuation
15+// prompt. This is a progress invariant, not a resource limit: productive
16+// autonomous goals remain unbounded unless the user configures a budget.
17+const maxConsecutiveGoalNoProgressTurns = 2
18+
19+type goalContinuationRun struct {
20+ id string
21+ goalID string
22+ tokens uint64
23+ hadTool bool
24+}
25+
26+func goalTokenCount(usage provider.Usage) uint64 {
27+ // OutputTokens already includes provider-reported reasoning tokens. Cache
28+ // usage is separately billable and therefore included in the goal total.
29+ counts := []int{usage.InputTokens, usage.OutputTokens, usage.CacheReadTokens, usage.CacheWriteTokens}
30+ var total uint64
31+ for _, count := range counts {
32+ if count > 0 {
33+ total += uint64(count)
34+ }
35+ }
36+ return total
37+}
38+
39+func (i *Interactive) limitGoalBeforeRun(goal *core.SessionGoal) bool {
40+ if goal == nil || goal.Status != core.GoalActive || goal.TokenBudget == nil || goal.TokensUsed < *goal.TokenBudget {
41+ return false
42+ }
43+ goal = copySessionGoal(goal)
44+ goal.Status = core.GoalBudgetLimited
45+ goal.Reason = "configured token budget exhausted"
46+ goal.ContinuationID = ""
47+ if i.cfg.PersistGoal == nil {
48+ i.ReportError(fmt.Errorf("goal persistence is unavailable"))
49+ return true
50+ }
51+ if err := i.cfg.PersistGoal(goal); err != nil {
52+ i.ReportError(fmt.Errorf("persist goal budget limit: %w", err))
53+ return true
54+ }
55+ i.setGoalStatus(goal)
56+ return true
57+}
58+
59+func (i *Interactive) startGoalRun(goal *core.SessionGoal) (*goalContinuationRun, error) {
60+ if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" {
61+ return nil, nil
62+ }
63+ i.mu.Lock()
64+ if i.goalRun != nil {
65+ i.mu.Unlock()
66+ return nil, nil
67+ }
68+ run := &goalContinuationRun{id: uuid.NewString(), goalID: goal.ID}
69+ i.goalRun = run
70+ i.mu.Unlock()
71+
72+ goal = copySessionGoal(goal)
73+ goal.ContinuationID = run.id
74+ if i.cfg.PersistGoalRuntime == nil {
75+ return run, nil
76+ }
77+ if err := i.cfg.PersistGoalRuntime(goal); err != nil {
78+ i.clearGoalRun(run.id)
79+ return nil, fmt.Errorf("persist goal continuation: %w", err)
80+ }
81+ return run, nil
82+}
83+
84+func (i *Interactive) clearGoalRun(runID string) {
85+ i.mu.Lock()
86+ if i.goalRun != nil && i.goalRun.id == runID {
87+ i.goalRun = nil
88+ }
89+ i.mu.Unlock()
90+}
91+
92+func (i *Interactive) abandonGoalRun(run *goalContinuationRun) {
93+ if run == nil {
94+ return
95+ }
96+ i.clearGoalRun(run.id)
97+ if i.cfg.CurrentGoal == nil || i.cfg.PersistGoalRuntime == nil {
98+ return
99+ }
100+ goal := copySessionGoal(i.cfg.CurrentGoal())
101+ if goal == nil || goal.ID != run.goalID || goal.ContinuationID != run.id {
102+ return
103+ }
104+ goal.ContinuationID = ""
105+ if err := i.cfg.PersistGoalRuntime(goal); err != nil {
106+ i.ReportError(fmt.Errorf("clear goal continuation: %w", err))
107+ }
108+}
109+
110+func (i *Interactive) observeGoalRun(ev core.AgentEvent) {
111+ i.mu.Lock()
112+ defer i.mu.Unlock()
113+ if i.goalRun == nil {
114+ return
115+ }
116+ switch e := ev.(type) {
117+ case core.EvUsage:
118+ i.goalRun.tokens += goalTokenCount(e.Usage)
119+ case core.EvToolCall:
120+ if e.Name != agenttools.UpdateGoalToolName {
121+ i.goalRun.hadTool = true
122+ }
123+ }
124+}
125+
126+// finishGoalRun records accounting exactly once and returns whether the
127+// controller should attempt another autonomous continuation. A stale run is
128+// discarded without changing a newer goal.
129+func (i *Interactive) finishGoalRun() bool {
130+ i.mu.Lock()
131+ run := i.goalRun
132+ i.goalRun = nil
133+ i.mu.Unlock()
134+ if run == nil || i.cfg.CurrentGoal == nil {
135+ return false
136+ }
137+ goal := copySessionGoal(i.cfg.CurrentGoal())
138+ if goal == nil || goal.ID != run.goalID {
139+ return false
140+ }
141+ goal.ContinuationID = ""
142+ goal.TokensUsed += run.tokens
143+
144+ persistRuntime := func() bool {
145+ if i.cfg.PersistGoalRuntime == nil {
146+ return true
147+ }
148+ if err := i.cfg.PersistGoalRuntime(goal); err != nil {
149+ i.ReportError(fmt.Errorf("persist goal runtime: %w", err))
150+ return false
151+ }
152+ i.setGoalStatus(goal)
153+ return true
154+ }
155+ if goal.Status != core.GoalActive {
156+ persistRuntime()
157+ return false
158+ }
159+ if goal.TokenBudget != nil && goal.TokensUsed >= *goal.TokenBudget {
160+ goal.Status = core.GoalBudgetLimited
161+ goal.Reason = "configured token budget exhausted"
162+ if i.cfg.PersistGoal == nil {
163+ i.ReportError(fmt.Errorf("goal persistence is unavailable"))
164+ return false
165+ }
166+ if err := i.cfg.PersistGoal(goal); err != nil {
167+ i.ReportError(fmt.Errorf("persist goal budget limit: %w", err))
168+ return false
169+ }
170+ i.setGoalStatus(goal)
171+ return false
172+ }
173+ if run.hadTool {
174+ goal.ConsecutiveNoProgressTurns = 0
175+ } else {
176+ goal.ConsecutiveNoProgressTurns++
177+ if goal.ConsecutiveNoProgressTurns >= maxConsecutiveGoalNoProgressTurns {
178+ goal.Status = core.GoalStalled
179+ goal.Reason = "agent ended consecutive continuations without a concrete action"
180+ if i.cfg.PersistGoal == nil {
181+ i.ReportError(fmt.Errorf("goal persistence is unavailable"))
182+ return false
183+ }
184+ if err := i.cfg.PersistGoal(goal); err != nil {
185+ i.ReportError(fmt.Errorf("persist stalled goal: %w", err))
186+ return false
187+ }
188+ i.setGoalStatus(goal)
189+ return false
190+ }
191+ }
192+ return persistRuntime()
193+}
194+
195+// recoverGoalRun clears a lease left by a previous process. The new process
196+// has no provider turn associated with it, so retaining that lease would make
197+// the active goal permanently unschedulable.
198+func (i *Interactive) recoverGoalRun() {
199+ if i.cfg.CurrentGoal == nil || i.cfg.PersistGoalRuntime == nil {
200+ return
201+ }
202+ goal := copySessionGoal(i.cfg.CurrentGoal())
203+ if goal == nil || goal.Status != core.GoalActive || goal.ContinuationID == "" {
204+ return
205+ }
206+ goal.ContinuationID = ""
207+ if err := i.cfg.PersistGoalRuntime(goal); err != nil {
208+ i.ReportError(fmt.Errorf("recover goal continuation: %w", err))
209+ }
210+}
@@ -0,0 +1,210 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ agenttools "github.com/bnema/zut/packages/agent/tools"
8+ "github.com/bnema/zut/packages/core"
9+ "github.com/bnema/zut/packages/provider"
10+ "github.com/google/uuid"
11+)
12+
13+// A second empty continuation is enough to distinguish a model that briefly
14+// paused to summarize from one that is only acknowledging the continuation
15+// prompt. This is a progress invariant, not a resource limit: productive
16+// autonomous goals remain unbounded unless the user configures a budget.
17+const maxConsecutiveGoalNoProgressTurns = 2
18+
19+type goalContinuationRun struct {
20+ id string
21+ goalID string
22+ tokens uint64
23+ hadTool bool
24+}
25+
26+func goalTokenCount(usage provider.Usage) uint64 {
27+ // OutputTokens already includes provider-reported reasoning tokens. Cache
28+ // usage is separately billable and therefore included in the goal total.
29+ counts := []int{usage.InputTokens, usage.OutputTokens, usage.CacheReadTokens, usage.CacheWriteTokens}
30+ var total uint64
31+ for _, count := range counts {
32+ if count > 0 {
33+ total += uint64(count)
34+ }
35+ }
36+ return total
37+}
38+
39+func (i *Interactive) limitGoalBeforeRun(goal *core.SessionGoal) bool {
40+ if goal == nil || goal.Status != core.GoalActive || goal.TokenBudget == nil || goal.TokensUsed < *goal.TokenBudget {
41+ return false
42+ }
43+ goal = copySessionGoal(goal)
44+ goal.Status = core.GoalBudgetLimited
45+ goal.Reason = "configured token budget exhausted"
46+ goal.ContinuationID = ""
47+ if i.cfg.PersistGoal == nil {
48+ i.ReportError(fmt.Errorf("goal persistence is unavailable"))
49+ return true
50+ }
51+ if err := i.cfg.PersistGoal(goal); err != nil {
52+ i.ReportError(fmt.Errorf("persist goal budget limit: %w", err))
53+ return true
54+ }
55+ i.setGoalStatus(goal)
56+ return true
57+}
58+
59+func (i *Interactive) startGoalRun(goal *core.SessionGoal) (*goalContinuationRun, error) {
60+ if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" {
61+ return nil, nil
62+ }
63+ i.mu.Lock()
64+ if i.goalRun != nil {
65+ i.mu.Unlock()
66+ return nil, nil
67+ }
68+ run := &goalContinuationRun{id: uuid.NewString(), goalID: goal.ID}
69+ i.goalRun = run
70+ i.mu.Unlock()
71+
72+ goal = copySessionGoal(goal)
73+ goal.ContinuationID = run.id
74+ if i.cfg.PersistGoalRuntime == nil {
75+ return run, nil
76+ }
77+ if err := i.cfg.PersistGoalRuntime(goal); err != nil {
78+ i.clearGoalRun(run.id)
79+ return nil, fmt.Errorf("persist goal continuation: %w", err)
80+ }
81+ return run, nil
82+}
83+
84+func (i *Interactive) clearGoalRun(runID string) {
85+ i.mu.Lock()
86+ if i.goalRun != nil && i.goalRun.id == runID {
87+ i.goalRun = nil
88+ }
89+ i.mu.Unlock()
90+}
91+
92+func (i *Interactive) abandonGoalRun(run *goalContinuationRun) {
93+ if run == nil {
94+ return
95+ }
96+ i.clearGoalRun(run.id)
97+ if i.cfg.CurrentGoal == nil || i.cfg.PersistGoalRuntime == nil {
98+ return
99+ }
100+ goal := copySessionGoal(i.cfg.CurrentGoal())
101+ if goal == nil || goal.ID != run.goalID || goal.ContinuationID != run.id {
102+ return
103+ }
104+ goal.ContinuationID = ""
105+ if err := i.cfg.PersistGoalRuntime(goal); err != nil {
106+ i.ReportError(fmt.Errorf("clear goal continuation: %w", err))
107+ }
108+}
109+
110+func (i *Interactive) observeGoalRun(ev core.AgentEvent) {
111+ i.mu.Lock()
112+ defer i.mu.Unlock()
113+ if i.goalRun == nil {
114+ return
115+ }
116+ switch e := ev.(type) {
117+ case core.EvUsage:
118+ i.goalRun.tokens += goalTokenCount(e.Usage)
119+ case core.EvToolCall:
120+ if e.Name != agenttools.UpdateGoalToolName {
121+ i.goalRun.hadTool = true
122+ }
123+ }
124+}
125+
126+// finishGoalRun records accounting exactly once and returns whether the
127+// controller should attempt another autonomous continuation. A stale run is
128+// discarded without changing a newer goal.
129+func (i *Interactive) finishGoalRun() bool {
130+ i.mu.Lock()
131+ run := i.goalRun
132+ i.goalRun = nil
133+ i.mu.Unlock()
134+ if run == nil || i.cfg.CurrentGoal == nil {
135+ return false
136+ }
137+ goal := copySessionGoal(i.cfg.CurrentGoal())
138+ if goal == nil || goal.ID != run.goalID {
139+ return false
140+ }
141+ goal.ContinuationID = ""
142+ goal.TokensUsed += run.tokens
143+
144+ persistRuntime := func() bool {
145+ if i.cfg.PersistGoalRuntime == nil {
146+ return true
147+ }
148+ if err := i.cfg.PersistGoalRuntime(goal); err != nil {
149+ i.ReportError(fmt.Errorf("persist goal runtime: %w", err))
150+ return false
151+ }
152+ i.setGoalStatus(goal)
153+ return true
154+ }
155+ if goal.Status != core.GoalActive {
156+ persistRuntime()
157+ return false
158+ }
159+ if goal.TokenBudget != nil && goal.TokensUsed >= *goal.TokenBudget {
160+ goal.Status = core.GoalBudgetLimited
161+ goal.Reason = "configured token budget exhausted"
162+ if i.cfg.PersistGoal == nil {
163+ i.ReportError(fmt.Errorf("goal persistence is unavailable"))
164+ return false
165+ }
166+ if err := i.cfg.PersistGoal(goal); err != nil {
167+ i.ReportError(fmt.Errorf("persist goal budget limit: %w", err))
168+ return false
169+ }
170+ i.setGoalStatus(goal)
171+ return false
172+ }
173+ if run.hadTool {
174+ goal.ConsecutiveNoProgressTurns = 0
175+ } else {
176+ goal.ConsecutiveNoProgressTurns++
177+ if goal.ConsecutiveNoProgressTurns >= maxConsecutiveGoalNoProgressTurns {
178+ goal.Status = core.GoalStalled
179+ goal.Reason = "agent ended consecutive continuations without a concrete action"
180+ if i.cfg.PersistGoal == nil {
181+ i.ReportError(fmt.Errorf("goal persistence is unavailable"))
182+ return false
183+ }
184+ if err := i.cfg.PersistGoal(goal); err != nil {
185+ i.ReportError(fmt.Errorf("persist stalled goal: %w", err))
186+ return false
187+ }
188+ i.setGoalStatus(goal)
189+ return false
190+ }
191+ }
192+ return persistRuntime()
193+}
194+
195+// recoverGoalRun clears a lease left by a previous process. The new process
196+// has no provider turn associated with it, so retaining that lease would make
197+// the active goal permanently unschedulable.
198+func (i *Interactive) recoverGoalRun() {
199+ if i.cfg.CurrentGoal == nil || i.cfg.PersistGoalRuntime == nil {
200+ return
201+ }
202+ goal := copySessionGoal(i.cfg.CurrentGoal())
203+ if goal == nil || goal.Status != core.GoalActive || goal.ContinuationID == "" {
204+ return
205+ }
206+ goal.ContinuationID = ""
207+ if err := i.cfg.PersistGoalRuntime(goal); err != nil {
208+ i.ReportError(fmt.Errorf("recover goal continuation: %w", err))
209+ }
210+}
A packages/agent/modes/goal_runtime_test.go +250 -0
@@ -0,0 +1,250 @@
1+package modes
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/zut/packages/agent/tools"
10+ "github.com/bnema/zut/packages/core"
11+ "github.com/bnema/zut/packages/provider"
12+)
13+
14+type noProgressGoalClient struct {
15+ mu sync.Mutex
16+ calls int
17+ hits chan int
18+}
19+
20+func (c *noProgressGoalClient) Name() string { return "goal-no-progress-test" }
21+
22+func (c *noProgressGoalClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) {
23+ c.mu.Lock()
24+ c.calls++
25+ call := c.calls
26+ c.mu.Unlock()
27+ c.hits <- call
28+ out := make(chan provider.Event, 1)
29+ out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
30+ Role: provider.RoleAssistant,
31+ Content: []provider.Content{provider.TextBlock{Text: "Continuing autonomously."}},
32+ }}
33+ close(out)
34+ return out, nil
35+}
36+
37+func (c *noProgressGoalClient) count() int {
38+ c.mu.Lock()
39+ defer c.mu.Unlock()
40+ return c.calls
41+}
42+
43+func TestGoalContinuationDoesNotResendNoProgressMessagesIndefinitely(t *testing.T) {
44+ var mu sync.Mutex
45+ var goal *core.SessionGoal
46+ current := func() *core.SessionGoal {
47+ mu.Lock()
48+ defer mu.Unlock()
49+ return cloneSessionGoal(goal)
50+ }
51+ persist := func(next *core.SessionGoal) error {
52+ mu.Lock()
53+ goal = cloneSessionGoal(next)
54+ mu.Unlock()
55+ return nil
56+ }
57+ client := &noProgressGoalClient{hits: make(chan int, 3)}
58+ agent := core.NewAgent(client, "test", "", core.Registry{tools.UpdateGoalToolName: &tools.UpdateGoalTool{}})
59+ interactive := NewInteractive(InteractiveConfig{
60+ Agent: agent,
61+ CurrentGoal: current,
62+ PersistGoal: persist,
63+ PersistGoalRuntime: persist,
64+ })
65+ interactive.runCtx = context.Background()
66+ interactive.runGoalCommand(context.Background(), "/goal finish the work", []string{"/goal", "finish", "the", "work"})
67+
68+ for want := 1; want <= maxConsecutiveGoalNoProgressTurns; want++ {
69+ select {
70+ case got := <-client.hits:
71+ if got != want {
72+ t.Fatalf("provider call = %d, want %d", got, want)
73+ }
74+ case <-time.After(2 * time.Second):
75+ t.Fatalf("provider call %d did not start", want)
76+ }
77+ }
78+ waitInteractiveIdle(t, interactive)
79+ if got := current(); got == nil || got.Status != core.GoalStalled {
80+ t.Fatalf("goal after repeated no-progress responses = %#v", got)
81+ }
82+ if got := client.count(); got != maxConsecutiveGoalNoProgressTurns {
83+ t.Fatalf("provider call count = %d, want %d", got, maxConsecutiveGoalNoProgressTurns)
84+ }
85+}
86+
87+func TestGoalRuntimeStallsAfterRepeatedNoProgress(t *testing.T) {
88+ var mu sync.Mutex
89+ goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive}
90+ current := func() *core.SessionGoal {
91+ mu.Lock()
92+ defer mu.Unlock()
93+ return cloneSessionGoal(goal)
94+ }
95+ persistRuntime := func(next *core.SessionGoal) error {
96+ mu.Lock()
97+ goal = cloneSessionGoal(next)
98+ mu.Unlock()
99+ return nil
100+ }
101+ interactive := NewInteractive(InteractiveConfig{
102+ CurrentGoal: current,
103+ PersistGoal: persistRuntime,
104+ PersistGoalRuntime: persistRuntime,
105+ })
106+
107+ first, err := interactive.startGoalRun(current())
108+ if err != nil || first == nil {
109+ t.Fatalf("start first goal run = (%v, %v), want run without error", first, err)
110+ }
111+ if !interactive.finishGoalRun() {
112+ t.Fatal("first non-progress run should receive one corrective continuation")
113+ }
114+ if got := current(); got.ConsecutiveNoProgressTurns != 1 || got.Status != core.GoalActive || got.ContinuationID != "" {
115+ t.Fatalf("first run state = %#v", got)
116+ }
117+
118+ second, err := interactive.startGoalRun(current())
119+ if err != nil || second == nil {
120+ t.Fatalf("start second goal run = (%v, %v), want run without error", second, err)
121+ }
122+ if interactive.finishGoalRun() {
123+ t.Fatal("second non-progress run scheduled another continuation")
124+ }
125+ got := current()
126+ if got.Status != core.GoalStalled || got.ConsecutiveNoProgressTurns != maxConsecutiveGoalNoProgressTurns {
127+ t.Fatalf("stalled goal = %#v", got)
128+ }
129+ if got.Reason == "" || got.ContinuationID != "" {
130+ t.Fatalf("stalled goal diagnostics = %#v", got)
131+ }
132+}
133+
134+func TestGoalRuntimeBudgetIsOptionalAndEnforcedWhenSet(t *testing.T) {
135+ for _, test := range []struct {
136+ name string
137+ budget *uint64
138+ wantStatus core.GoalStatus
139+ wantNext bool
140+ }{
141+ {name: "unlimited", wantStatus: core.GoalActive, wantNext: true},
142+ {name: "limited", budget: uint64ptr(100), wantStatus: core.GoalBudgetLimited},
143+ } {
144+ t.Run(test.name, func(t *testing.T) {
145+ var mu sync.Mutex
146+ goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, TokenBudget: test.budget}
147+ current := func() *core.SessionGoal {
148+ mu.Lock()
149+ defer mu.Unlock()
150+ return cloneSessionGoal(goal)
151+ }
152+ persist := func(next *core.SessionGoal) error {
153+ mu.Lock()
154+ goal = cloneSessionGoal(next)
155+ mu.Unlock()
156+ return nil
157+ }
158+ interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist, PersistGoalRuntime: persist})
159+ run, err := interactive.startGoalRun(current())
160+ if err != nil || run == nil {
161+ t.Fatalf("start goal run = (%v, %v)", run, err)
162+ }
163+ interactive.observeGoalRun(core.EvToolCall{Name: "bash"})
164+ interactive.observeGoalRun(core.EvUsage{Usage: provider.Usage{InputTokens: 40, OutputTokens: 60}})
165+ if got := interactive.finishGoalRun(); got != test.wantNext {
166+ t.Fatalf("finish goal run = %t, want %t", got, test.wantNext)
167+ }
168+ got := current()
169+ if got.Status != test.wantStatus || got.TokensUsed != 100 {
170+ t.Fatalf("goal = %#v", got)
171+ }
172+ })
173+ }
174+}
175+
176+func TestGoalRuntimeLimitsPersistedExhaustedBudgetBeforeRun(t *testing.T) {
177+ budget := uint64(100)
178+ var mu sync.Mutex
179+ goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, TokenBudget: &budget, TokensUsed: budget}
180+ current := func() *core.SessionGoal {
181+ mu.Lock()
182+ defer mu.Unlock()
183+ return cloneSessionGoal(goal)
184+ }
185+ persist := func(next *core.SessionGoal) error {
186+ mu.Lock()
187+ goal = cloneSessionGoal(next)
188+ mu.Unlock()
189+ return nil
190+ }
191+ interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist})
192+ if !interactive.limitGoalBeforeRun(current()) {
193+ t.Fatal("exhausted budget was not limited before starting a new provider turn")
194+ }
195+ if got := current(); got.Status != core.GoalBudgetLimited || got.ContinuationID != "" {
196+ t.Fatalf("limited goal = %#v", got)
197+ }
198+}
199+
200+func TestGoalRuntimeRejectsStaleRun(t *testing.T) {
201+ var mu sync.Mutex
202+ goal := &core.SessionGoal{ID: "goal-1", Objective: "first goal", Status: core.GoalActive}
203+ current := func() *core.SessionGoal {
204+ mu.Lock()
205+ defer mu.Unlock()
206+ return cloneSessionGoal(goal)
207+ }
208+ persist := func(next *core.SessionGoal) error {
209+ mu.Lock()
210+ goal = cloneSessionGoal(next)
211+ mu.Unlock()
212+ return nil
213+ }
214+ interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist, PersistGoalRuntime: persist})
215+ run, err := interactive.startGoalRun(current())
216+ if err != nil || run == nil {
217+ t.Fatalf("start goal run = (%v, %v)", run, err)
218+ }
219+ if err := persist(&core.SessionGoal{ID: "goal-2", Objective: "new goal", Status: core.GoalActive}); err != nil {
220+ t.Fatal(err)
221+ }
222+ if interactive.finishGoalRun() {
223+ t.Fatal("stale run scheduled a continuation")
224+ }
225+ if got := current(); got.ID != "goal-2" || got.ContinuationID != "" || got.TokensUsed != 0 {
226+ t.Fatalf("stale run changed current goal: %#v", got)
227+ }
228+}
229+
230+func TestGoalRuntimeRecoversAbandonedLease(t *testing.T) {
231+ var mu sync.Mutex
232+ goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, ContinuationID: "abandoned"}
233+ current := func() *core.SessionGoal {
234+ mu.Lock()
235+ defer mu.Unlock()
236+ return cloneSessionGoal(goal)
237+ }
238+ persist := func(next *core.SessionGoal) error {
239+ mu.Lock()
240+ goal = cloneSessionGoal(next)
241+ mu.Unlock()
242+ return nil
243+ }
244+ _ = NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoalRuntime: persist})
245+ if got := current(); got.ContinuationID != "" {
246+ t.Fatalf("abandoned lease was not cleared: %#v", got)
247+ }
248+}
249+
250+func uint64ptr(value uint64) *uint64 { return &value }
@@ -0,0 +1,250 @@
1+package modes
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/zut/packages/agent/tools"
10+ "github.com/bnema/zut/packages/core"
11+ "github.com/bnema/zut/packages/provider"
12+)
13+
14+type noProgressGoalClient struct {
15+ mu sync.Mutex
16+ calls int
17+ hits chan int
18+}
19+
20+func (c *noProgressGoalClient) Name() string { return "goal-no-progress-test" }
21+
22+func (c *noProgressGoalClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) {
23+ c.mu.Lock()
24+ c.calls++
25+ call := c.calls
26+ c.mu.Unlock()
27+ c.hits <- call
28+ out := make(chan provider.Event, 1)
29+ out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
30+ Role: provider.RoleAssistant,
31+ Content: []provider.Content{provider.TextBlock{Text: "Continuing autonomously."}},
32+ }}
33+ close(out)
34+ return out, nil
35+}
36+
37+func (c *noProgressGoalClient) count() int {
38+ c.mu.Lock()
39+ defer c.mu.Unlock()
40+ return c.calls
41+}
42+
43+func TestGoalContinuationDoesNotResendNoProgressMessagesIndefinitely(t *testing.T) {
44+ var mu sync.Mutex
45+ var goal *core.SessionGoal
46+ current := func() *core.SessionGoal {
47+ mu.Lock()
48+ defer mu.Unlock()
49+ return cloneSessionGoal(goal)
50+ }
51+ persist := func(next *core.SessionGoal) error {
52+ mu.Lock()
53+ goal = cloneSessionGoal(next)
54+ mu.Unlock()
55+ return nil
56+ }
57+ client := &noProgressGoalClient{hits: make(chan int, 3)}
58+ agent := core.NewAgent(client, "test", "", core.Registry{tools.UpdateGoalToolName: &tools.UpdateGoalTool{}})
59+ interactive := NewInteractive(InteractiveConfig{
60+ Agent: agent,
61+ CurrentGoal: current,
62+ PersistGoal: persist,
63+ PersistGoalRuntime: persist,
64+ })
65+ interactive.runCtx = context.Background()
66+ interactive.runGoalCommand(context.Background(), "/goal finish the work", []string{"/goal", "finish", "the", "work"})
67+
68+ for want := 1; want <= maxConsecutiveGoalNoProgressTurns; want++ {
69+ select {
70+ case got := <-client.hits:
71+ if got != want {
72+ t.Fatalf("provider call = %d, want %d", got, want)
73+ }
74+ case <-time.After(2 * time.Second):
75+ t.Fatalf("provider call %d did not start", want)
76+ }
77+ }
78+ waitInteractiveIdle(t, interactive)
79+ if got := current(); got == nil || got.Status != core.GoalStalled {
80+ t.Fatalf("goal after repeated no-progress responses = %#v", got)
81+ }
82+ if got := client.count(); got != maxConsecutiveGoalNoProgressTurns {
83+ t.Fatalf("provider call count = %d, want %d", got, maxConsecutiveGoalNoProgressTurns)
84+ }
85+}
86+
87+func TestGoalRuntimeStallsAfterRepeatedNoProgress(t *testing.T) {
88+ var mu sync.Mutex
89+ goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive}
90+ current := func() *core.SessionGoal {
91+ mu.Lock()
92+ defer mu.Unlock()
93+ return cloneSessionGoal(goal)
94+ }
95+ persistRuntime := func(next *core.SessionGoal) error {
96+ mu.Lock()
97+ goal = cloneSessionGoal(next)
98+ mu.Unlock()
99+ return nil
100+ }
101+ interactive := NewInteractive(InteractiveConfig{
102+ CurrentGoal: current,
103+ PersistGoal: persistRuntime,
104+ PersistGoalRuntime: persistRuntime,
105+ })
106+
107+ first, err := interactive.startGoalRun(current())
108+ if err != nil || first == nil {
109+ t.Fatalf("start first goal run = (%v, %v), want run without error", first, err)
110+ }
111+ if !interactive.finishGoalRun() {
112+ t.Fatal("first non-progress run should receive one corrective continuation")
113+ }
114+ if got := current(); got.ConsecutiveNoProgressTurns != 1 || got.Status != core.GoalActive || got.ContinuationID != "" {
115+ t.Fatalf("first run state = %#v", got)
116+ }
117+
118+ second, err := interactive.startGoalRun(current())
119+ if err != nil || second == nil {
120+ t.Fatalf("start second goal run = (%v, %v), want run without error", second, err)
121+ }
122+ if interactive.finishGoalRun() {
123+ t.Fatal("second non-progress run scheduled another continuation")
124+ }
125+ got := current()
126+ if got.Status != core.GoalStalled || got.ConsecutiveNoProgressTurns != maxConsecutiveGoalNoProgressTurns {
127+ t.Fatalf("stalled goal = %#v", got)
128+ }
129+ if got.Reason == "" || got.ContinuationID != "" {
130+ t.Fatalf("stalled goal diagnostics = %#v", got)
131+ }
132+}
133+
134+func TestGoalRuntimeBudgetIsOptionalAndEnforcedWhenSet(t *testing.T) {
135+ for _, test := range []struct {
136+ name string
137+ budget *uint64
138+ wantStatus core.GoalStatus
139+ wantNext bool
140+ }{
141+ {name: "unlimited", wantStatus: core.GoalActive, wantNext: true},
142+ {name: "limited", budget: uint64ptr(100), wantStatus: core.GoalBudgetLimited},
143+ } {
144+ t.Run(test.name, func(t *testing.T) {
145+ var mu sync.Mutex
146+ goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, TokenBudget: test.budget}
147+ current := func() *core.SessionGoal {
148+ mu.Lock()
149+ defer mu.Unlock()
150+ return cloneSessionGoal(goal)
151+ }
152+ persist := func(next *core.SessionGoal) error {
153+ mu.Lock()
154+ goal = cloneSessionGoal(next)
155+ mu.Unlock()
156+ return nil
157+ }
158+ interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist, PersistGoalRuntime: persist})
159+ run, err := interactive.startGoalRun(current())
160+ if err != nil || run == nil {
161+ t.Fatalf("start goal run = (%v, %v)", run, err)
162+ }
163+ interactive.observeGoalRun(core.EvToolCall{Name: "bash"})
164+ interactive.observeGoalRun(core.EvUsage{Usage: provider.Usage{InputTokens: 40, OutputTokens: 60}})
165+ if got := interactive.finishGoalRun(); got != test.wantNext {
166+ t.Fatalf("finish goal run = %t, want %t", got, test.wantNext)
167+ }
168+ got := current()
169+ if got.Status != test.wantStatus || got.TokensUsed != 100 {
170+ t.Fatalf("goal = %#v", got)
171+ }
172+ })
173+ }
174+}
175+
176+func TestGoalRuntimeLimitsPersistedExhaustedBudgetBeforeRun(t *testing.T) {
177+ budget := uint64(100)
178+ var mu sync.Mutex
179+ goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, TokenBudget: &budget, TokensUsed: budget}
180+ current := func() *core.SessionGoal {
181+ mu.Lock()
182+ defer mu.Unlock()
183+ return cloneSessionGoal(goal)
184+ }
185+ persist := func(next *core.SessionGoal) error {
186+ mu.Lock()
187+ goal = cloneSessionGoal(next)
188+ mu.Unlock()
189+ return nil
190+ }
191+ interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist})
192+ if !interactive.limitGoalBeforeRun(current()) {
193+ t.Fatal("exhausted budget was not limited before starting a new provider turn")
194+ }
195+ if got := current(); got.Status != core.GoalBudgetLimited || got.ContinuationID != "" {
196+ t.Fatalf("limited goal = %#v", got)
197+ }
198+}
199+
200+func TestGoalRuntimeRejectsStaleRun(t *testing.T) {
201+ var mu sync.Mutex
202+ goal := &core.SessionGoal{ID: "goal-1", Objective: "first goal", Status: core.GoalActive}
203+ current := func() *core.SessionGoal {
204+ mu.Lock()
205+ defer mu.Unlock()
206+ return cloneSessionGoal(goal)
207+ }
208+ persist := func(next *core.SessionGoal) error {
209+ mu.Lock()
210+ goal = cloneSessionGoal(next)
211+ mu.Unlock()
212+ return nil
213+ }
214+ interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist, PersistGoalRuntime: persist})
215+ run, err := interactive.startGoalRun(current())
216+ if err != nil || run == nil {
217+ t.Fatalf("start goal run = (%v, %v)", run, err)
218+ }
219+ if err := persist(&core.SessionGoal{ID: "goal-2", Objective: "new goal", Status: core.GoalActive}); err != nil {
220+ t.Fatal(err)
221+ }
222+ if interactive.finishGoalRun() {
223+ t.Fatal("stale run scheduled a continuation")
224+ }
225+ if got := current(); got.ID != "goal-2" || got.ContinuationID != "" || got.TokensUsed != 0 {
226+ t.Fatalf("stale run changed current goal: %#v", got)
227+ }
228+}
229+
230+func TestGoalRuntimeRecoversAbandonedLease(t *testing.T) {
231+ var mu sync.Mutex
232+ goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, ContinuationID: "abandoned"}
233+ current := func() *core.SessionGoal {
234+ mu.Lock()
235+ defer mu.Unlock()
236+ return cloneSessionGoal(goal)
237+ }
238+ persist := func(next *core.SessionGoal) error {
239+ mu.Lock()
240+ goal = cloneSessionGoal(next)
241+ mu.Unlock()
242+ return nil
243+ }
244+ _ = NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoalRuntime: persist})
245+ if got := current(); got.ContinuationID != "" {
246+ t.Fatalf("abandoned lease was not cleared: %#v", got)
247+ }
248+}
249+
250+func uint64ptr(value uint64) *uint64 { return &value }
M packages/agent/modes/interactive.go +7 -0
@@ -370,6 +370,11 @@ type InteractiveConfig struct {
370370 CurrentGoalHistory func() []core.SessionGoal
371371 EnsureMission func(objective string) error
372372 PersistGoal func(goal *core.SessionGoal) error
373+ // PersistGoalRuntime records execution bookkeeping without adding a mission
374+ // transition to goal history.
375+ PersistGoalRuntime func(goal *core.SessionGoal) error
376+ // GoalMaxTokenBudget is optional. Nil means autonomous goals are unlimited.
377+ GoalMaxTokenBudget *uint64
373378
374379 OnAssistant func(m provider.Message)
375380
@@ -665,6 +670,7 @@ type Interactive struct {
665670 statusErr string
666671 statusOK string
667672 goalStatus core.GoalStatus
673+ goalRun *goalContinuationRun
668674 reloadStatusSeq uint64
669675 extStatuses map[string]map[string]extensionStatus
670676 extWidgets map[string]map[string]extensionWidget
@@ -1037,6 +1043,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
10371043 i.titleGenerationStarted = i.titleRealPromptSeen
10381044 }
10391045 i.applyAutoSubagentsTool()
1046+ i.recoverGoalRun()
10401047 return i
10411048 }
10421049
@@ -370,6 +370,11 @@ type InteractiveConfig struct {
370 CurrentGoalHistory func() []core.SessionGoal370 CurrentGoalHistory func() []core.SessionGoal
371 EnsureMission func(objective string) error371 EnsureMission func(objective string) error
372 PersistGoal func(goal *core.SessionGoal) error372 PersistGoal func(goal *core.SessionGoal) error
373+ // PersistGoalRuntime records execution bookkeeping without adding a mission
374+ // transition to goal history.
375+ PersistGoalRuntime func(goal *core.SessionGoal) error
376+ // GoalMaxTokenBudget is optional. Nil means autonomous goals are unlimited.
377+ GoalMaxTokenBudget *uint64
373 378
374 OnAssistant func(m provider.Message)379 OnAssistant func(m provider.Message)
375 380
@@ -665,6 +670,7 @@ type Interactive struct {
665 statusErr string670 statusErr string
666 statusOK string671 statusOK string
667 goalStatus core.GoalStatus672 goalStatus core.GoalStatus
673+ goalRun *goalContinuationRun
668 reloadStatusSeq uint64674 reloadStatusSeq uint64
669 extStatuses map[string]map[string]extensionStatus675 extStatuses map[string]map[string]extensionStatus
670 extWidgets map[string]map[string]extensionWidget676 extWidgets map[string]map[string]extensionWidget
@@ -1037,6 +1043,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
1037 i.titleGenerationStarted = i.titleRealPromptSeen1043 i.titleGenerationStarted = i.titleRealPromptSeen
1038 }1044 }
1039 i.applyAutoSubagentsTool()1045 i.applyAutoSubagentsTool()
1046+ i.recoverGoalRun()
1040 return i1047 return i
1041 }1048 }
1042 1049
M packages/agent/modes/interactive_compaction.go +17 -1
@@ -393,7 +393,23 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
393393 case continueAutomatically:
394394 i.startAutoCompactContinuation(p)
395395 case continueGoal:
396- i.startGoalContinuation(p, goalMessage)
396+ var run *goalContinuationRun
397+ if i.cfg.CurrentGoal != nil {
398+ goal := copySessionGoal(i.cfg.CurrentGoal())
399+ if !i.limitGoalBeforeRun(goal) {
400+ run, err = i.startGoalRun(goal)
401+ }
402+ }
403+ if err != nil || run == nil {
404+ i.mu.Lock()
405+ i.busy = false
406+ i.mu.Unlock()
407+ if err != nil {
408+ i.ReportError(err)
409+ }
410+ return
411+ }
412+ i.startGoalContinuation(p, goalMessage, run)
397413 }
398414 }
399415 }()
@@ -393,7 +393,23 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
393 case continueAutomatically:393 case continueAutomatically:
394 i.startAutoCompactContinuation(p)394 i.startAutoCompactContinuation(p)
395 case continueGoal:395 case continueGoal:
396- i.startGoalContinuation(p, goalMessage)396+ var run *goalContinuationRun
397+ if i.cfg.CurrentGoal != nil {
398+ goal := copySessionGoal(i.cfg.CurrentGoal())
399+ if !i.limitGoalBeforeRun(goal) {
400+ run, err = i.startGoalRun(goal)
401+ }
402+ }
403+ if err != nil || run == nil {
404+ i.mu.Lock()
405+ i.busy = false
406+ i.mu.Unlock()
407+ if err != nil {
408+ i.ReportError(err)
409+ }
410+ return
411+ }
412+ i.startGoalContinuation(p, goalMessage, run)
397 }413 }
398 }414 }
399 }()415 }()
M packages/agent/modes/interactive_turn.go +5 -1
@@ -236,6 +236,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
236236 lastStop = e.Stop
237237 lastTurnErr = e.Err
238238 }
239+ i.observeGoalRun(ev)
239240 i.handleEventForPresentation(ev)
240241 }
241242
@@ -322,6 +323,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
322323 if terminalGoalError {
323324 i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")
324325 }
326+ continueGoal := i.finishGoalRun()
325327 i.mu.Lock()
326328 awaitingPre := i.awaitingStartupPre
327329 // A newer explicit prompt may have cleared the handoff while the
@@ -379,7 +381,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
379381 if recoverContextOverflow || shouldAutoCompact {
380382 i.resetStreamingStateLocked()
381383 }
382- alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
384+ alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact)
383385 i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
384386 i.mu.Unlock()
385387 if persistHandoff {
@@ -415,6 +417,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
415417 lastStop: lastStop,
416418 turnError: lastTurnErr,
417419 })
420+ case continueGoal:
421+ i.requestGoalContinuationIfIdle(parent)
418422 }
419423 }()
420424 }
@@ -236,6 +236,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
236 lastStop = e.Stop236 lastStop = e.Stop
237 lastTurnErr = e.Err237 lastTurnErr = e.Err
238 }238 }
239+ i.observeGoalRun(ev)
239 i.handleEventForPresentation(ev)240 i.handleEventForPresentation(ev)
240 }241 }
241 242
@@ -322,6 +323,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
322 if terminalGoalError {323 if terminalGoalError {
323 i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")324 i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")
324 }325 }
326+ continueGoal := i.finishGoalRun()
325 i.mu.Lock()327 i.mu.Lock()
326 awaitingPre := i.awaitingStartupPre328 awaitingPre := i.awaitingStartupPre
327 // A newer explicit prompt may have cleared the handoff while the329 // A newer explicit prompt may have cleared the handoff while the
@@ -379,7 +381,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
379 if recoverContextOverflow || shouldAutoCompact {381 if recoverContextOverflow || shouldAutoCompact {
380 i.resetStreamingStateLocked()382 i.resetStreamingStateLocked()
381 }383 }
382- alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)384+ alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact)
383 i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact385 i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
384 i.mu.Unlock()386 i.mu.Unlock()
385 if persistHandoff {387 if persistHandoff {
@@ -415,6 +417,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
415 lastStop: lastStop,417 lastStop: lastStop,
416 turnError: lastTurnErr,418 turnError: lastTurnErr,
417 })419 })
420+ case continueGoal:
421+ i.requestGoalContinuationIfIdle(parent)
418 }422 }
419 }()423 }()
420 }424 }
M packages/core/agent.go +31 -1
@@ -1153,6 +1153,36 @@ func drainProviderStream(stream <-chan provider.Event) {
11531153 }
11541154 }
11551155
1156+// ToolResultCommitError supplies a user-safe explanation when a host rejects
1157+// a tool result during durable-state commit. Internal storage errors remain in
1158+// Err; callers without this type retain the generic failure message.
1159+type ToolResultCommitError struct {
1160+ Message string
1161+ Err error
1162+}
1163+
1164+func (e *ToolResultCommitError) Error() string {
1165+ if e == nil || e.Err == nil {
1166+ return "tool result commit failed"
1167+ }
1168+ return e.Err.Error()
1169+}
1170+
1171+func (e *ToolResultCommitError) Unwrap() error {
1172+ if e == nil {
1173+ return nil
1174+ }
1175+ return e.Err
1176+}
1177+
1178+func toolResultCommitMessage(err error) string {
1179+ var safe *ToolResultCommitError
1180+ if errors.As(err, &safe) && strings.TrimSpace(safe.Message) != "" {
1181+ return safe.Message
1182+ }
1183+ return "tool result state could not be persisted"
1184+}
1185+
11561186 // executeTools runs every tool call in the assistant message and returns
11571187 // a single tool-role message carrying all results.
11581188 func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) {
@@ -1170,7 +1200,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
11701200 if a.CommitToolResult != nil {
11711201 if err := a.CommitToolResult(tc.ID, res); err != nil {
11721202 res = ToolResult{
1173- Content: []provider.Content{provider.TextBlock{Text: "tool result state could not be persisted"}},
1203+ Content: []provider.Content{provider.TextBlock{Text: toolResultCommitMessage(err)}},
11741204 IsError: true,
11751205 Timing: res.Timing,
11761206 }
@@ -1153,6 +1153,36 @@ func drainProviderStream(stream <-chan provider.Event) {
1153 }1153 }
1154 }1154 }
1155 1155
1156+// ToolResultCommitError supplies a user-safe explanation when a host rejects
1157+// a tool result during durable-state commit. Internal storage errors remain in
1158+// Err; callers without this type retain the generic failure message.
1159+type ToolResultCommitError struct {
1160+ Message string
1161+ Err error
1162+}
1163+
1164+func (e *ToolResultCommitError) Error() string {
1165+ if e == nil || e.Err == nil {
1166+ return "tool result commit failed"
1167+ }
1168+ return e.Err.Error()
1169+}
1170+
1171+func (e *ToolResultCommitError) Unwrap() error {
1172+ if e == nil {
1173+ return nil
1174+ }
1175+ return e.Err
1176+}
1177+
1178+func toolResultCommitMessage(err error) string {
1179+ var safe *ToolResultCommitError
1180+ if errors.As(err, &safe) && strings.TrimSpace(safe.Message) != "" {
1181+ return safe.Message
1182+ }
1183+ return "tool result state could not be persisted"
1184+}
1185+
1156 // executeTools runs every tool call in the assistant message and returns1186 // executeTools runs every tool call in the assistant message and returns
1157 // a single tool-role message carrying all results.1187 // a single tool-role message carrying all results.
1158 func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) {1188 func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) {
@@ -1170,7 +1200,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
1170 if a.CommitToolResult != nil {1200 if a.CommitToolResult != nil {
1171 if err := a.CommitToolResult(tc.ID, res); err != nil {1201 if err := a.CommitToolResult(tc.ID, res); err != nil {
1172 res = ToolResult{1202 res = ToolResult{
1173- Content: []provider.Content{provider.TextBlock{Text: "tool result state could not be persisted"}},1203+ Content: []provider.Content{provider.TextBlock{Text: toolResultCommitMessage(err)}},
1174 IsError: true,1204 IsError: true,
1175 Timing: res.Timing,1205 Timing: res.Timing,
1176 }1206 }
M packages/core/core_test.go +68 -0
@@ -486,6 +486,74 @@ func TestSessionGoalRoundTripAndClear(t *testing.T) {
486486 }
487487 }
488488
489+func TestSessionGoalRuntimeRoundTripDoesNotAddHistory(t *testing.T) {
490+ sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
491+ if err != nil {
492+ t.Fatal(err)
493+ }
494+ budget := uint64(1_000_000)
495+ if err := sess.UpdateGoal(&SessionGoal{Objective: "finish the requested change", Status: GoalActive, TokenBudget: &budget}); err != nil {
496+ t.Fatal(err)
497+ }
498+ goal := cloneSessionGoal(sess.Meta.Goal)
499+ goal.TokensUsed = 1234
500+ goal.ContinuationID = "run-1"
501+ goal.ConsecutiveNoProgressTurns = 1
502+ if err := sess.UpdateGoalRuntime(goal); err != nil {
503+ t.Fatal(err)
504+ }
505+ if len(sess.Meta.GoalHistory) != 0 {
506+ t.Fatalf("runtime update added history: %#v", sess.Meta.GoalHistory)
507+ }
508+ if err := sess.Close(); err != nil {
509+ t.Fatal(err)
510+ }
511+ reopened, _, err := OpenSession(sess.Path)
512+ if err != nil {
513+ t.Fatal(err)
514+ }
515+ defer reopened.Close()
516+ if goal := reopened.Meta.Goal; goal == nil || goal.TokenBudget == nil || *goal.TokenBudget != budget || goal.TokensUsed != 1234 || goal.ContinuationID != "run-1" || goal.ConsecutiveNoProgressTurns != 1 {
517+ t.Fatalf("reopened runtime goal = %#v", goal)
518+ }
519+ if len(reopened.Meta.GoalHistory) != 0 {
520+ t.Fatalf("reopened runtime history = %#v", reopened.Meta.GoalHistory)
521+ }
522+}
523+
524+func TestSessionGoalTerminalStatusSettlesMission(t *testing.T) {
525+ for _, test := range []struct {
526+ goalStatus GoalStatus
527+ missionStatus MissionStatus
528+ }{
529+ {GoalDone, MissionCompleted},
530+ {GoalPaused, MissionPaused},
531+ {GoalBlocked, MissionBlocked},
532+ {GoalBudgetLimited, MissionBlocked},
533+ {GoalStalled, MissionBlocked},
534+ } {
535+ t.Run(string(test.goalStatus), func(t *testing.T) {
536+ sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
537+ if err != nil {
538+ t.Fatal(err)
539+ }
540+ defer sess.Close()
541+ if err := sess.UpdateGoal(&SessionGoal{Objective: "finish", Status: GoalActive}); err != nil {
542+ t.Fatal(err)
543+ }
544+ goal := cloneSessionGoal(sess.Meta.Goal)
545+ goal.Status = test.goalStatus
546+ goal.Reason = "stopped"
547+ if err := sess.UpdateGoal(goal); err != nil {
548+ t.Fatal(err)
549+ }
550+ if sess.Meta.Mission == nil || sess.Meta.Mission.Status != test.missionStatus || sess.Meta.Mission.ActiveGoalID != "" {
551+ t.Fatalf("mission = %#v", sess.Meta.Mission)
552+ }
553+ })
554+ }
555+}
556+
489557 func TestCostAdd(t *testing.T) {
490558 var c CostTracker
491559 c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01})
@@ -486,6 +486,74 @@ func TestSessionGoalRoundTripAndClear(t *testing.T) {
486 }486 }
487 }487 }
488 488
489+func TestSessionGoalRuntimeRoundTripDoesNotAddHistory(t *testing.T) {
490+ sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
491+ if err != nil {
492+ t.Fatal(err)
493+ }
494+ budget := uint64(1_000_000)
495+ if err := sess.UpdateGoal(&SessionGoal{Objective: "finish the requested change", Status: GoalActive, TokenBudget: &budget}); err != nil {
496+ t.Fatal(err)
497+ }
498+ goal := cloneSessionGoal(sess.Meta.Goal)
499+ goal.TokensUsed = 1234
500+ goal.ContinuationID = "run-1"
501+ goal.ConsecutiveNoProgressTurns = 1
502+ if err := sess.UpdateGoalRuntime(goal); err != nil {
503+ t.Fatal(err)
504+ }
505+ if len(sess.Meta.GoalHistory) != 0 {
506+ t.Fatalf("runtime update added history: %#v", sess.Meta.GoalHistory)
507+ }
508+ if err := sess.Close(); err != nil {
509+ t.Fatal(err)
510+ }
511+ reopened, _, err := OpenSession(sess.Path)
512+ if err != nil {
513+ t.Fatal(err)
514+ }
515+ defer reopened.Close()
516+ if goal := reopened.Meta.Goal; goal == nil || goal.TokenBudget == nil || *goal.TokenBudget != budget || goal.TokensUsed != 1234 || goal.ContinuationID != "run-1" || goal.ConsecutiveNoProgressTurns != 1 {
517+ t.Fatalf("reopened runtime goal = %#v", goal)
518+ }
519+ if len(reopened.Meta.GoalHistory) != 0 {
520+ t.Fatalf("reopened runtime history = %#v", reopened.Meta.GoalHistory)
521+ }
522+}
523+
524+func TestSessionGoalTerminalStatusSettlesMission(t *testing.T) {
525+ for _, test := range []struct {
526+ goalStatus GoalStatus
527+ missionStatus MissionStatus
528+ }{
529+ {GoalDone, MissionCompleted},
530+ {GoalPaused, MissionPaused},
531+ {GoalBlocked, MissionBlocked},
532+ {GoalBudgetLimited, MissionBlocked},
533+ {GoalStalled, MissionBlocked},
534+ } {
535+ t.Run(string(test.goalStatus), func(t *testing.T) {
536+ sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
537+ if err != nil {
538+ t.Fatal(err)
539+ }
540+ defer sess.Close()
541+ if err := sess.UpdateGoal(&SessionGoal{Objective: "finish", Status: GoalActive}); err != nil {
542+ t.Fatal(err)
543+ }
544+ goal := cloneSessionGoal(sess.Meta.Goal)
545+ goal.Status = test.goalStatus
546+ goal.Reason = "stopped"
547+ if err := sess.UpdateGoal(goal); err != nil {
548+ t.Fatal(err)
549+ }
550+ if sess.Meta.Mission == nil || sess.Meta.Mission.Status != test.missionStatus || sess.Meta.Mission.ActiveGoalID != "" {
551+ t.Fatalf("mission = %#v", sess.Meta.Mission)
552+ }
553+ })
554+ }
555+}
556+
489 func TestCostAdd(t *testing.T) {557 func TestCostAdd(t *testing.T) {
490 var c CostTracker558 var c CostTracker
491 c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01})559 c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01})
M packages/core/intercept_test.go +18 -0
@@ -80,6 +80,24 @@ func TestToolResultPersistenceFailureBecomesToolError(t *testing.T) {
8080 }
8181 }
8282
83+func TestToolResultCommitErrorUsesSafeMessage(t *testing.T) {
84+ agent := NewAgent(nil, "test", "", Registry{
85+ "result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "done"}}}},
86+ })
87+ agent.CommitToolResult = func(string, ToolResult) error {
88+ return &ToolResultCommitError{Message: "goal transition rejected: the current goal is still active", Err: errors.New("database path /private/session is unavailable")}
89+ }
90+ message := provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "result", Arguments: json.RawMessage(`{}`)}}}
91+ toolMessage, hadError := agent.executeTools(context.Background(), message, func(AgentEvent) {})
92+ if !hadError {
93+ t.Fatal("commit error did not mark the tool batch failed")
94+ }
95+ block := toolMessage.Content[0].(provider.ToolResultBlock)
96+ if got := block.Content[0].(provider.TextBlock).Text; got != "goal transition rejected: the current goal is still active" {
97+ t.Fatalf("tool error text = %q", got)
98+ }
99+}
100+
83101 // TestBeforeToolExecuteModifiesArgs verifies that a non-nil
84102 // modifiedArgs returned from BeforeToolExecute is what the tool
85103 // actually sees.
@@ -80,6 +80,24 @@ func TestToolResultPersistenceFailureBecomesToolError(t *testing.T) {
80 }80 }
81 }81 }
82 82
83+func TestToolResultCommitErrorUsesSafeMessage(t *testing.T) {
84+ agent := NewAgent(nil, "test", "", Registry{
85+ "result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "done"}}}},
86+ })
87+ agent.CommitToolResult = func(string, ToolResult) error {
88+ return &ToolResultCommitError{Message: "goal transition rejected: the current goal is still active", Err: errors.New("database path /private/session is unavailable")}
89+ }
90+ message := provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "result", Arguments: json.RawMessage(`{}`)}}}
91+ toolMessage, hadError := agent.executeTools(context.Background(), message, func(AgentEvent) {})
92+ if !hadError {
93+ t.Fatal("commit error did not mark the tool batch failed")
94+ }
95+ block := toolMessage.Content[0].(provider.ToolResultBlock)
96+ if got := block.Content[0].(provider.TextBlock).Text; got != "goal transition rejected: the current goal is still active" {
97+ t.Fatalf("tool error text = %q", got)
98+ }
99+}
100+
83 // TestBeforeToolExecuteModifiesArgs verifies that a non-nil101 // TestBeforeToolExecuteModifiesArgs verifies that a non-nil
84 // modifiedArgs returned from BeforeToolExecute is what the tool102 // modifiedArgs returned from BeforeToolExecute is what the tool
85 // actually sees.103 // actually sees.
M packages/core/session.go +51 -11
@@ -55,10 +55,12 @@ type Session struct {
5555 type GoalStatus string
5656
5757 const (
58- GoalActive GoalStatus = "active"
59- GoalPaused GoalStatus = "paused"
60- GoalBlocked GoalStatus = "blocked"
61- GoalDone GoalStatus = "done"
58+ GoalActive GoalStatus = "active"
59+ GoalPaused GoalStatus = "paused"
60+ GoalBlocked GoalStatus = "blocked"
61+ GoalDone GoalStatus = "done"
62+ GoalBudgetLimited GoalStatus = "budget_limited"
63+ GoalStalled GoalStatus = "stalled"
6264 )
6365
6466 // GoalOwner identifies who established a session goal. Missing owners in
@@ -102,13 +104,17 @@ type SessionMission struct {
102104
103105 // SessionGoal is the concise autonomous objective attached to one session.
104106 type SessionGoal struct {
105- ID string `json:"id,omitempty"`
106- MissionID string `json:"mission_id,omitempty"`
107- Objective string `json:"objective"`
108- Status GoalStatus `json:"status"`
109- Owner GoalOwner `json:"owner,omitempty"`
110- Ordinal int `json:"ordinal,omitempty"`
111- Reason string `json:"reason,omitempty"`
107+ ID string `json:"id,omitempty"`
108+ MissionID string `json:"mission_id,omitempty"`
109+ Objective string `json:"objective"`
110+ Status GoalStatus `json:"status"`
111+ Owner GoalOwner `json:"owner,omitempty"`
112+ Ordinal int `json:"ordinal,omitempty"`
113+ Reason string `json:"reason,omitempty"`
114+ TokenBudget *uint64 `json:"token_budget,omitempty"`
115+ TokensUsed uint64 `json:"tokens_used,omitempty"`
116+ ContinuationID string `json:"continuation_id,omitempty"`
117+ ConsecutiveNoProgressTurns int `json:"consecutive_no_progress_turns,omitempty"`
112118 }
113119
114120 // SessionMeta is written as the first line of every session file.
@@ -1439,6 +1445,29 @@ func (s *Session) UpdateGoal(goal *SessionGoal) error {
14391445 return nil
14401446 }
14411447
1448+// UpdateGoalRuntime persists mutable execution state without treating each
1449+// continuation as a mission transition. The goal ID must match the current
1450+// goal so stale workers cannot overwrite a newer user or manager goal.
1451+func (s *Session) UpdateGoalRuntime(goal *SessionGoal) error {
1452+ if s == nil {
1453+ return nil
1454+ }
1455+ if goal == nil || s.Meta.Goal == nil || goal.ID == "" || goal.ID != s.Meta.Goal.ID {
1456+ return errors.New("goal runtime update does not match the current goal")
1457+ }
1458+ previous := cloneSessionGoal(s.Meta.Goal)
1459+ copyGoal := *s.Meta.Goal
1460+ copyGoal.TokensUsed = goal.TokensUsed
1461+ copyGoal.ContinuationID = goal.ContinuationID
1462+ copyGoal.ConsecutiveNoProgressTurns = goal.ConsecutiveNoProgressTurns
1463+ s.Meta.Goal = &copyGoal
1464+ if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
1465+ s.Meta.Goal = previous
1466+ return fmt.Errorf("update goal runtime: %w", err)
1467+ }
1468+ return nil
1469+}
1470+
14421471 func (s *Session) assignGoalToMission(goal, previous *SessionGoal) error {
14431472 if s.Meta.Mission == nil {
14441473 source := MissionSourceUser
@@ -1476,6 +1505,17 @@ func (s *Session) assignGoalToMission(goal, previous *SessionGoal) error {
14761505 mission.Reason = ""
14771506 } else if mission.ActiveGoalID == goal.ID {
14781507 mission.ActiveGoalID = ""
1508+ switch goal.Status {
1509+ case GoalDone:
1510+ mission.Status = MissionCompleted
1511+ mission.Reason = ""
1512+ case GoalPaused:
1513+ mission.Status = MissionPaused
1514+ mission.Reason = ""
1515+ case GoalBlocked, GoalBudgetLimited, GoalStalled:
1516+ mission.Status = MissionBlocked
1517+ mission.Reason = goal.Reason
1518+ }
14791519 }
14801520 return nil
14811521 }
@@ -55,10 +55,12 @@ type Session struct {
55 type GoalStatus string55 type GoalStatus string
56 56
57 const (57 const (
58- GoalActive GoalStatus = "active"58+ GoalActive GoalStatus = "active"
59- GoalPaused GoalStatus = "paused"59+ GoalPaused GoalStatus = "paused"
60- GoalBlocked GoalStatus = "blocked"60+ GoalBlocked GoalStatus = "blocked"
61- GoalDone GoalStatus = "done"61+ GoalDone GoalStatus = "done"
62+ GoalBudgetLimited GoalStatus = "budget_limited"
63+ GoalStalled GoalStatus = "stalled"
62 )64 )
63 65
64 // GoalOwner identifies who established a session goal. Missing owners in66 // GoalOwner identifies who established a session goal. Missing owners in
@@ -102,13 +104,17 @@ type SessionMission struct {
102 104
103 // SessionGoal is the concise autonomous objective attached to one session.105 // SessionGoal is the concise autonomous objective attached to one session.
104 type SessionGoal struct {106 type SessionGoal struct {
105- ID string `json:"id,omitempty"`107+ ID string `json:"id,omitempty"`
106- MissionID string `json:"mission_id,omitempty"`108+ MissionID string `json:"mission_id,omitempty"`
107- Objective string `json:"objective"`109+ Objective string `json:"objective"`
108- Status GoalStatus `json:"status"`110+ Status GoalStatus `json:"status"`
109- Owner GoalOwner `json:"owner,omitempty"`111+ Owner GoalOwner `json:"owner,omitempty"`
110- Ordinal int `json:"ordinal,omitempty"`112+ Ordinal int `json:"ordinal,omitempty"`
111- Reason string `json:"reason,omitempty"`113+ Reason string `json:"reason,omitempty"`
114+ TokenBudget *uint64 `json:"token_budget,omitempty"`
115+ TokensUsed uint64 `json:"tokens_used,omitempty"`
116+ ContinuationID string `json:"continuation_id,omitempty"`
117+ ConsecutiveNoProgressTurns int `json:"consecutive_no_progress_turns,omitempty"`
112 }118 }
113 119
114 // SessionMeta is written as the first line of every session file.120 // SessionMeta is written as the first line of every session file.
@@ -1439,6 +1445,29 @@ func (s *Session) UpdateGoal(goal *SessionGoal) error {
1439 return nil1445 return nil
1440 }1446 }
1441 1447
1448+// UpdateGoalRuntime persists mutable execution state without treating each
1449+// continuation as a mission transition. The goal ID must match the current
1450+// goal so stale workers cannot overwrite a newer user or manager goal.
1451+func (s *Session) UpdateGoalRuntime(goal *SessionGoal) error {
1452+ if s == nil {
1453+ return nil
1454+ }
1455+ if goal == nil || s.Meta.Goal == nil || goal.ID == "" || goal.ID != s.Meta.Goal.ID {
1456+ return errors.New("goal runtime update does not match the current goal")
1457+ }
1458+ previous := cloneSessionGoal(s.Meta.Goal)
1459+ copyGoal := *s.Meta.Goal
1460+ copyGoal.TokensUsed = goal.TokensUsed
1461+ copyGoal.ContinuationID = goal.ContinuationID
1462+ copyGoal.ConsecutiveNoProgressTurns = goal.ConsecutiveNoProgressTurns
1463+ s.Meta.Goal = &copyGoal
1464+ if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
1465+ s.Meta.Goal = previous
1466+ return fmt.Errorf("update goal runtime: %w", err)
1467+ }
1468+ return nil
1469+}
1470+
1442 func (s *Session) assignGoalToMission(goal, previous *SessionGoal) error {1471 func (s *Session) assignGoalToMission(goal, previous *SessionGoal) error {
1443 if s.Meta.Mission == nil {1472 if s.Meta.Mission == nil {
1444 source := MissionSourceUser1473 source := MissionSourceUser
@@ -1476,6 +1505,17 @@ func (s *Session) assignGoalToMission(goal, previous *SessionGoal) error {
1476 mission.Reason = ""1505 mission.Reason = ""
1477 } else if mission.ActiveGoalID == goal.ID {1506 } else if mission.ActiveGoalID == goal.ID {
1478 mission.ActiveGoalID = ""1507 mission.ActiveGoalID = ""
1508+ switch goal.Status {
1509+ case GoalDone:
1510+ mission.Status = MissionCompleted
1511+ mission.Reason = ""
1512+ case GoalPaused:
1513+ mission.Status = MissionPaused
1514+ mission.Reason = ""
1515+ case GoalBlocked, GoalBudgetLimited, GoalStalled:
1516+ mission.Status = MissionBlocked
1517+ mission.Reason = goal.Reason
1518+ }
1479 }1519 }
1480 return nil1520 return nil
1481 }1521 }
M packages/core/session_portable.go +12 -1
@@ -460,11 +460,22 @@ func cloneSessionGoal(goal *SessionGoal) *SessionGoal {
460460 return nil
461461 }
462462 clone := *goal
463+ if goal.TokenBudget != nil {
464+ budget := *goal.TokenBudget
465+ clone.TokenBudget = &budget
466+ }
463467 return &clone
464468 }
465469
466470 func cloneGoalHistory(history []SessionGoal) []SessionGoal {
467- return append([]SessionGoal(nil), history...)
471+ if len(history) == 0 {
472+ return nil
473+ }
474+ clone := make([]SessionGoal, len(history))
475+ for index := range history {
476+ clone[index] = *cloneSessionGoal(&history[index])
477+ }
478+ return clone
468479 }
469480
470481 func goalHistoryAtBranch(parent SessionMeta, messages []provider.Message, limit int) []SessionGoal {
@@ -460,11 +460,22 @@ func cloneSessionGoal(goal *SessionGoal) *SessionGoal {
460 return nil460 return nil
461 }461 }
462 clone := *goal462 clone := *goal
463+ if goal.TokenBudget != nil {
464+ budget := *goal.TokenBudget
465+ clone.TokenBudget = &budget
466+ }
463 return &clone467 return &clone
464 }468 }
465 469
466 func cloneGoalHistory(history []SessionGoal) []SessionGoal {470 func cloneGoalHistory(history []SessionGoal) []SessionGoal {
467- return append([]SessionGoal(nil), history...)471+ if len(history) == 0 {
472+ return nil
473+ }
474+ clone := make([]SessionGoal, len(history))
475+ for index := range history {
476+ clone[index] = *cloneSessionGoal(&history[index])
477+ }
478+ return clone
468 }479 }
469 480
470 func goalHistoryAtBranch(parent SessionMeta, messages []provider.Message, limit int) []SessionGoal {481 func goalHistoryAtBranch(parent SessionMeta, messages []provider.Message, limit int) []SessionGoal {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close