@@ -138,6 +138,34 @@ export interface Interface {
138138 * fiber AND publishes the paused event.
139139 */
140140 readonly pauseAndPublish : ( sessionID : SessionID , reason : string ) => Effect . Effect < GoalState . Info | undefined >
141+ /**
142+ * Goal-turn provenance + step ceiling (GOAL-TURN-SCOPE). Marks the session's
143+ * CURRENT turn as goal-driven (kick / judge continuation / resume-kick) so
144+ * (a) the prompt loop can cap its steps via goalTurnMaxSteps, and
145+ * (b) SessionPrompt.cancel can map a user ESC on a goal turn into a goal
146+ * pause instead of letting the idle event auto-resurrect the goal.
147+ * The mark is process-local InstanceState: cleared when the turn ends
148+ * (afterIdle entry, pause, clear, markDone) and safe to overwrite.
149+ */
150+ readonly markTurnDriven : ( sessionID : SessionID ) => Effect . Effect < void >
151+ /** Clears the goal-turn mark. No-op when not marked. */
152+ readonly clearTurnDriven : ( sessionID : SessionID ) => Effect . Effect < void >
153+ /**
154+ * ESC-on-goal-turn transition: pauses the goal (durable row + event) AND
155+ * releases the automation registration AND clears the turn mark — one
156+ * seam so SessionPrompt.cancel stays free of lease plumbing. No-op (returns
157+ * undefined) when the goal is not active. Never fails: pause failures are
158+ * logged and swallowed so a cancel path can always proceed.
159+ */
160+ readonly pauseForUserCancel : ( sessionID : SessionID , reason : string ) => Effect . Effect < GoalState . Info | undefined >
161+ /** True when the session's current turn is goal-driven. */
162+ readonly isTurnDriven : ( sessionID : SessionID ) => Effect . Effect < boolean >
163+ /**
164+ * Step ceiling for a goal-driven turn: min of GOAL_TURN_MAX_STEPS and the
165+ * current goal's identity. Returns undefined when the turn is NOT
166+ * goal-driven (the prompt loop then falls back to agent.steps unchanged).
167+ */
168+ readonly goalTurnMaxSteps : ( sessionID : SessionID ) => Effect . Effect < number | undefined >
141169 }
142170
143171export class Service extends Context . Service < Service , Interface > ( ) ( "@opencode/Goal" ) { }
@@ -167,6 +195,58 @@ const serviceLayer = Layer.effect(
167195
168196 const fibers = new Map < SessionID , Fiber . Fiber < unknown , unknown > > ( )
169197
198+ // GOAL-TURN-SCOPE: process-local provenance of the CURRENT goal-driven
199+ // turn. Keyed by session; set at every goal dispatch (kick in prompt.ts,
200+ // continuation in loop.ts), cleared at turn end (afterIdle entry) and at
201+ // every terminal transition (pause/clear/markDone) plus ESC-cancel. A stale
202+ // mark is harmless: goalTurnMaxSteps re-validates against the durable goal
203+ // row before reporting a ceiling.
204+ const turnDriven = new Set < SessionID > ( )
205+
206+ const markTurnDriven = Effect . fnUntraced ( function * ( sessionID : SessionID ) {
207+ turnDriven . add ( sessionID )
208+ } )
209+
210+ const clearTurnDriven = Effect . fnUntraced ( function * ( sessionID : SessionID ) {
211+ turnDriven . delete ( sessionID )
212+ } )
213+
214+ const isTurnDriven = Effect . fnUntraced ( function * ( sessionID : SessionID ) {
215+ return turnDriven . has ( sessionID )
216+ } )
217+
218+ const goalTurnMaxSteps = Effect . fnUntraced ( function * ( sessionID : SessionID ) {
219+ if ( ! turnDriven . has ( sessionID ) ) return undefined
220+ // Re-validate against the durable row: a mark left over from a turn that
221+ // ended with the goal cleared/paused must not cap an unrelated turn.
222+ const state = yield * loadState ( sessionID )
223+ if ( ! state || state . status !== "active" ) {
224+ turnDriven . delete ( sessionID )
225+ return undefined
226+ }
227+ return GoalPrompts . GOAL_TURN_MAX_STEPS
228+ } )
229+
230+ // ESC-on-goal-turn: durable pause + lease release + mark clear, failure-
231+ // absorbed. Called from SessionPrompt.cancel so a user ESC on a goal turn
232+ // pauses the goal instead of letting the post-cancel idle event resurrect
233+ // it with an unwanted continuation.
234+ const pauseForUserCancel = Effect . fnUntraced ( function * ( sessionID : SessionID , reason : string ) {
235+ const paused = yield * pauseAndPublish ( sessionID , reason ) . pipe (
236+ Effect . catchCause ( ( cause ) =>
237+ Effect . logWarning ( "goal pause on cancel failed" , { sessionID, cause : String ( cause ) } ) . pipe (
238+ Effect . as ( undefined ) ,
239+ ) ,
240+ ) ,
241+ )
242+ if ( paused )
243+ yield * automation . unregister ( sessionID , { kind : "goal" , id : paused . goal_id ?? "legacy" } ) . pipe (
244+ Effect . ignore ,
245+ )
246+ turnDriven . delete ( sessionID )
247+ return paused
248+ } )
249+
170250 const registerFiber = Effect . fnUntraced ( function * (
171251 sessionID : SessionID ,
172252 fiber : Fiber . Fiber < unknown , unknown > ,
@@ -431,6 +511,7 @@ const serviceLayer = Layer.effect(
431511 if ( ! updated ) return undefined
432512 yield * automation . unregister ( sessionID , { kind : "goal" , id : updated . goal_id ?? "legacy" } )
433513 yield * clearFiber ( sessionID )
514+ turnDriven . delete ( sessionID )
434515 return updated
435516 } )
436517
@@ -476,6 +557,7 @@ const serviceLayer = Layer.effect(
476557 if ( cleared )
477558 yield * automation . unregister ( sessionID , { kind : "goal" , id : cleared . goal_id ?? "legacy" } )
478559 yield * clearFiber ( sessionID )
560+ turnDriven . delete ( sessionID )
479561 } )
480562
481563 // GOAL-FP-01-05/-16: session-deletion cleanup. `clear` keeps the
@@ -491,6 +573,7 @@ const serviceLayer = Layer.effect(
491573 if ( cleared )
492574 yield * automation . unregister ( sessionID , { kind : "goal" , id : cleared . goal_id ?? "legacy" } )
493575 yield * clearFiber ( sessionID )
576+ turnDriven . delete ( sessionID )
494577 } )
495578
496579 const markDone = Effect . fn ( "Goal.markDone" ) ( function * ( sessionID : SessionID , reason : string ) {
@@ -505,6 +588,7 @@ const serviceLayer = Layer.effect(
505588 const completed = yield * deleteAndPublishDone ( sessionID , reason )
506589 if ( completed )
507590 yield * automation . unregister ( sessionID , { kind : "goal" , id : completed . goal_id ?? "legacy" } )
591+ turnDriven . delete ( sessionID )
508592 return completed
509593 } )
510594
@@ -551,8 +635,17 @@ const serviceLayer = Layer.effect(
551635 if ( ! state ) return undefined
552636 const subgoals = state . subgoals ?? [ ]
553637 const sub = subgoals . length > 0 ? `,${ subgoals . length } 个子目标` : ""
554- if ( state . status === "active" )
638+ if ( state . status === "active" ) {
639+ // GOAL-TURN-SCOPE observability: a goal-driven turn in flight reads as
640+ // "执行中" so a long first turn (turns_used still 0/N) does not look
641+ // like a dead 0/N counter.
642+ if ( turnDriven . has ( sessionID ) ) {
643+ const busy = yield * sessionStatus . get ( sessionID )
644+ if ( busy . type === "busy" )
645+ return `⊙ 目标(执行中,${ state . turns_used } /${ state . max_turns } 轮${ sub } ):${ state . goal } `
646+ }
555647 return `⊙ 目标(进行中,${ state . turns_used } /${ state . max_turns } 轮${ sub } ):${ state . goal } `
648+ }
556649 if ( state . status === "paused" ) {
557650 const reason = state . paused_reason ? ` — ${ state . paused_reason } ` : ""
558651 return `⏸ 目标(已暂停,${ state . turns_used } /${ state . max_turns } 轮${ reason } ):${ state . goal } `
@@ -619,7 +712,7 @@ const serviceLayer = Layer.effect(
619712 newParseFailures >= GoalPrompts . MAX_CONSECUTIVE_PARSE_FAILURES
620713 ? "judge 模型未返回有效 JSON 判定。请检查模型配置或换用更可靠的模型,然后 /goal resume。"
621714 : turnsUsed >= state . max_turns
622- ? `已用 ${ turnsUsed } /${ state . max_turns } 轮。使用 /goal resume 继续,或 /goal clear 停止 。`
715+ ? `已用 ${ turnsUsed } /${ state . max_turns } 轮预算。 /goal clear 停止; /goal resume 会重启一整轮执行(本轮内完成才会计为达成,否则仍会被再次暂停) 。`
623716 : undefined
624717 const updated = GoalState . advance ( state , {
625718 status : pauseReason ? "paused" : "active" ,
@@ -708,7 +801,7 @@ const serviceLayer = Layer.effect(
708801 // text a second later, which looks like resume didn't work.
709802 const announceMsg =
710803 result . turns_used >= result . max_turns
711- ? `⚠ 目标已恢复,但预算已耗尽 (${ result . turns_used } /${ result . max_turns } 轮)。下一轮 judge 会立刻再次判定超预算暂停 。建议 /goal clear 后重新 /goal <text>,或在 /goal set 时传更大的 maxTurns。`
804+ ? `⚠ 目标已恢复,但轮预算已耗尽 (${ result . turns_used } /${ result . max_turns } 轮)。resume 会重启一整轮执行:本轮内任务完成才会计为达成,否则 judge 会再次暂停 。建议 /goal clear 后用更大的 maxTurns 重新设定 。`
712805 : undefined
713806 return {
714807 type : "kick" as const ,
@@ -834,6 +927,11 @@ const serviceLayer = Layer.effect(
834927 clearLoopFiberIf : clearFiberIf ,
835928 deleteAndPublishDone,
836929 pauseAndPublish,
930+ markTurnDriven,
931+ clearTurnDriven,
932+ isTurnDriven,
933+ goalTurnMaxSteps,
934+ pauseForUserCancel,
837935 } )
838936 } ) ,
839937)
0 commit comments