Skip to content

Commit ece0db5

Browse files
authored
Merge pull request #280 from LeXwDeX/fix/goal-turn-scope
fix(goal): bound goal-driven turns and map ESC to goal pause
2 parents 12eeae3 + f2d5ed2 commit ece0db5

5 files changed

Lines changed: 298 additions & 5 deletions

File tree

packages/opencode/src/goal/goal.ts

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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

143171
export 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
)

packages/opencode/src/goal/loop.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,11 @@ const serviceLayer = Layer.effect(
225225
const evaluatedRevisions = new Map<SessionID, number>()
226226

227227
const afterIdle = Effect.fn("GoalLoop.afterIdle")(function* (sessionID: SessionID, scanResume?: boolean) {
228+
// GOAL-TURN-SCOPE: the goal-driven turn that produced this idle has
229+
// ended. Clear the mark up front; the continuation branch below re-marks
230+
// when it dispatches the next turn. This also retires a stale mark when
231+
// the idle came from an unrelated (non-goal) turn.
232+
yield* goal.clearTurnDriven(sessionID)
228233
const goalState = yield* goal.load(sessionID)
229234
if (!goalState || goalState.status !== "active") return
230235
// D-4 entry gate (scan path only): the boot snapshot may have gone
@@ -451,11 +456,19 @@ const serviceLayer = Layer.effect(
451456
const continuationLease = Option.getOrUndefined(yield* automation.claim(sessionID, goalOwner))
452457
if (!continuationLease) return
453458
yield* Effect.gen(function* () {
459+
// GOAL-TURN-SCOPE: mark BEFORE admission so the goal-turn provenance is
460+
// already visible the instant the continuation can start — no window
461+
// where an httpapi cancel slips between admit and mark. If admission
462+
// is refused (session not idle), clear the speculative mark.
463+
yield* goal.markTurnDriven(sessionID)
454464
const admitted = yield* SessionPrompt.admitIfIdle(promptSvc, automation, continuationLease, {
455465
sessionID,
456466
parts: [{ type: "text", text: continuationText }],
457467
})
458-
if (Option.isNone(admitted)) return
468+
if (Option.isNone(admitted)) {
469+
yield* goal.clearTurnDriven(sessionID)
470+
return
471+
}
459472
yield* admitted.value
460473
}).pipe(
461474
Effect.catchCause((cause) =>

packages/opencode/src/goal/prompts.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ export const JUDGE_RESPONSE_SNIPPET_CHARS = 4000
1818
// message, is treated as orphaned and auto-paused so the user can recover via
1919
// /goal resume instead of the goal sitting silently "active" forever.
2020
export const FRESHNESS_THRESHOLD = 120_000
21+
// Step ceiling applied to every goal-driven turn (kick, judge continuation,
22+
// resume-kick). Without it a goal turn has NO step bound (agent.steps defaults
23+
// to Infinity for build), so a long-running task keeps the session busy
24+
// forever — the judge only runs on idle, and turns_used stays frozen at 0/20
25+
// with the goal permanently "active". This ceiling guarantees every goal turn
26+
// reaches an idle boundary where the judge and the turn budget can engage.
27+
// Applied as min(agent.steps ?? Infinity, GOAL_TURN_MAX_STEPS) so a stricter
28+
// user-configured agent.steps is always respected.
29+
export const GOAL_TURN_MAX_STEPS = 50
2130

2231
export const JUDGE_SYSTEM_PROMPT = `You are an autonomous-goal completion judge.
2332
You will receive:

packages/opencode/src/session/prompt.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,15 @@ export const layer = Layer.effect(
175175

176176
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
177177
yield* Effect.logInfo("cancel", { "session.id": sessionID })
178+
// GOAL-TURN-SCOPE (ESC semantics): ESC on a goal-driven turn means "stop
179+
// working on the goal", not just "stop this turn". Without this, the idle
180+
// event after cancel re-enters afterIdle, shouldPreempt returns false (the
181+
// last user message is the /goal command itself, necessarily older than
182+
// this turn's assistant output), and the judge auto-resurrects the goal
183+
// with a continuation the user just tried to abort. Pause instead.
184+
if (goal && (yield* goal.isTurnDriven(sessionID))) {
185+
yield* goal.pauseForUserCancel(sessionID, "用户中断(ESC)— /goal resume 继续").pipe(Effect.ignore)
186+
}
178187
yield* state.cancel(sessionID)
179188
})
180189

@@ -1663,7 +1672,12 @@ export const layer = Layer.effect(
16631672
yield* events.publish(Session.Event.Error, { sessionID, error: error.toObject() })
16641673
throw error
16651674
}
1666-
const maxSteps = agent.steps ?? Infinity
1675+
// GOAL-TURN-SCOPE: cap goal-driven turns (kick / continuation /
1676+
// resume-kick) so every goal turn reaches an idle boundary where the
1677+
// judge and the turn budget can engage. min() keeps a stricter
1678+
// user-configured agent.steps authoritative.
1679+
const goalMax = goal ? yield* goal.goalTurnMaxSteps(sessionID) : undefined
1680+
const maxSteps = Math.min(agent.steps ?? Infinity, goalMax ?? Infinity)
16671681
const isLastStep = step >= maxSteps
16681682
msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe(
16691683
Effect.provideService(RuntimeFlags.Service, flags),
@@ -1996,6 +2010,10 @@ export const layer = Layer.effect(
19962010
yield* sessions.updatePart(responsePart)
19972011
yield* sessions.touch(input.sessionID)
19982012
if (dispatchResult.type === "kick" && input.command === "goal") {
2013+
// GOAL-TURN-SCOPE: this loop() is a goal-driven turn (kick or
2014+
// resume-kick) — mark it so the step ceiling applies and ESC maps to
2015+
// a goal pause.
2016+
yield* goal?.markTurnDriven(input.sessionID)
19992017
// Drain SessionStart hook contexts before loop
20002018
if (startContext) {
20012019
const contexts = yield* startContext.consume(input.sessionID)

0 commit comments

Comments
 (0)