Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 101 additions & 3 deletions packages/opencode/src/goal/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,34 @@ export interface Interface {
* fiber AND publishes the paused event.
*/
readonly pauseAndPublish: (sessionID: SessionID, reason: string) => Effect.Effect<GoalState.Info | undefined>
/**
* Goal-turn provenance + step ceiling (GOAL-TURN-SCOPE). Marks the session's
* CURRENT turn as goal-driven (kick / judge continuation / resume-kick) so
* (a) the prompt loop can cap its steps via goalTurnMaxSteps, and
* (b) SessionPrompt.cancel can map a user ESC on a goal turn into a goal
* pause instead of letting the idle event auto-resurrect the goal.
* The mark is process-local InstanceState: cleared when the turn ends
* (afterIdle entry, pause, clear, markDone) and safe to overwrite.
*/
readonly markTurnDriven: (sessionID: SessionID) => Effect.Effect<void>
/** Clears the goal-turn mark. No-op when not marked. */
readonly clearTurnDriven: (sessionID: SessionID) => Effect.Effect<void>
/**
* ESC-on-goal-turn transition: pauses the goal (durable row + event) AND
* releases the automation registration AND clears the turn mark — one
* seam so SessionPrompt.cancel stays free of lease plumbing. No-op (returns
* undefined) when the goal is not active. Never fails: pause failures are
* logged and swallowed so a cancel path can always proceed.
*/
readonly pauseForUserCancel: (sessionID: SessionID, reason: string) => Effect.Effect<GoalState.Info | undefined>
/** True when the session's current turn is goal-driven. */
readonly isTurnDriven: (sessionID: SessionID) => Effect.Effect<boolean>
/**
* Step ceiling for a goal-driven turn: min of GOAL_TURN_MAX_STEPS and the
* current goal's identity. Returns undefined when the turn is NOT
* goal-driven (the prompt loop then falls back to agent.steps unchanged).
*/
readonly goalTurnMaxSteps: (sessionID: SessionID) => Effect.Effect<number | undefined>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/Goal") {}
Expand Down Expand Up @@ -167,6 +195,58 @@ const serviceLayer = Layer.effect(

const fibers = new Map<SessionID, Fiber.Fiber<unknown, unknown>>()

// GOAL-TURN-SCOPE: process-local provenance of the CURRENT goal-driven
// turn. Keyed by session; set at every goal dispatch (kick in prompt.ts,
// continuation in loop.ts), cleared at turn end (afterIdle entry) and at
// every terminal transition (pause/clear/markDone) plus ESC-cancel. A stale
// mark is harmless: goalTurnMaxSteps re-validates against the durable goal
// row before reporting a ceiling.
const turnDriven = new Set<SessionID>()

const markTurnDriven = Effect.fnUntraced(function* (sessionID: SessionID) {
turnDriven.add(sessionID)
})

const clearTurnDriven = Effect.fnUntraced(function* (sessionID: SessionID) {
turnDriven.delete(sessionID)
})

const isTurnDriven = Effect.fnUntraced(function* (sessionID: SessionID) {
return turnDriven.has(sessionID)
})

const goalTurnMaxSteps = Effect.fnUntraced(function* (sessionID: SessionID) {
if (!turnDriven.has(sessionID)) return undefined
// Re-validate against the durable row: a mark left over from a turn that
// ended with the goal cleared/paused must not cap an unrelated turn.
const state = yield* loadState(sessionID)
if (!state || state.status !== "active") {
turnDriven.delete(sessionID)
return undefined
}
return GoalPrompts.GOAL_TURN_MAX_STEPS
})

// ESC-on-goal-turn: durable pause + lease release + mark clear, failure-
// absorbed. Called from SessionPrompt.cancel so a user ESC on a goal turn
// pauses the goal instead of letting the post-cancel idle event resurrect
// it with an unwanted continuation.
const pauseForUserCancel = Effect.fnUntraced(function* (sessionID: SessionID, reason: string) {
const paused = yield* pauseAndPublish(sessionID, reason).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("goal pause on cancel failed", { sessionID, cause: String(cause) }).pipe(
Effect.as(undefined),
),
),
)
if (paused)
yield* automation.unregister(sessionID, { kind: "goal", id: paused.goal_id ?? "legacy" }).pipe(
Effect.ignore,
)
turnDriven.delete(sessionID)
return paused
})

const registerFiber = Effect.fnUntraced(function* (
sessionID: SessionID,
fiber: Fiber.Fiber<unknown, unknown>,
Expand Down Expand Up @@ -431,6 +511,7 @@ const serviceLayer = Layer.effect(
if (!updated) return undefined
yield* automation.unregister(sessionID, { kind: "goal", id: updated.goal_id ?? "legacy" })
yield* clearFiber(sessionID)
turnDriven.delete(sessionID)
return updated
})

Expand Down Expand Up @@ -476,6 +557,7 @@ const serviceLayer = Layer.effect(
if (cleared)
yield* automation.unregister(sessionID, { kind: "goal", id: cleared.goal_id ?? "legacy" })
yield* clearFiber(sessionID)
turnDriven.delete(sessionID)
})

// GOAL-FP-01-05/-16: session-deletion cleanup. `clear` keeps the
Expand All @@ -491,6 +573,7 @@ const serviceLayer = Layer.effect(
if (cleared)
yield* automation.unregister(sessionID, { kind: "goal", id: cleared.goal_id ?? "legacy" })
yield* clearFiber(sessionID)
turnDriven.delete(sessionID)
})

const markDone = Effect.fn("Goal.markDone")(function* (sessionID: SessionID, reason: string) {
Expand All @@ -505,6 +588,7 @@ const serviceLayer = Layer.effect(
const completed = yield* deleteAndPublishDone(sessionID, reason)
if (completed)
yield* automation.unregister(sessionID, { kind: "goal", id: completed.goal_id ?? "legacy" })
turnDriven.delete(sessionID)
return completed
})

Expand Down Expand Up @@ -551,8 +635,17 @@ const serviceLayer = Layer.effect(
if (!state) return undefined
const subgoals = state.subgoals ?? []
const sub = subgoals.length > 0 ? `,${subgoals.length} 个子目标` : ""
if (state.status === "active")
if (state.status === "active") {
// GOAL-TURN-SCOPE observability: a goal-driven turn in flight reads as
// "执行中" so a long first turn (turns_used still 0/N) does not look
// like a dead 0/N counter.
if (turnDriven.has(sessionID)) {
const busy = yield* sessionStatus.get(sessionID)
if (busy.type === "busy")
return `⊙ 目标(执行中,${state.turns_used}/${state.max_turns} 轮${sub}):${state.goal}`
}
return `⊙ 目标(进行中,${state.turns_used}/${state.max_turns} 轮${sub}):${state.goal}`
}
if (state.status === "paused") {
const reason = state.paused_reason ? ` — ${state.paused_reason}` : ""
return `⏸ 目标(已暂停,${state.turns_used}/${state.max_turns} 轮${reason}):${state.goal}`
Expand Down Expand Up @@ -619,7 +712,7 @@ const serviceLayer = Layer.effect(
newParseFailures >= GoalPrompts.MAX_CONSECUTIVE_PARSE_FAILURES
? "judge 模型未返回有效 JSON 判定。请检查模型配置或换用更可靠的模型,然后 /goal resume。"
: turnsUsed >= state.max_turns
? `已用 ${turnsUsed}/${state.max_turns} 轮。使用 /goal resume 继续,或 /goal clear 停止。`
? `已用 ${turnsUsed}/${state.max_turns} 轮预算。/goal clear 停止;/goal resume 会重启一整轮执行(本轮内完成才会计为达成,否则仍会被再次暂停)。`
: undefined
const updated = GoalState.advance(state, {
status: pauseReason ? "paused" : "active",
Expand Down Expand Up @@ -708,7 +801,7 @@ const serviceLayer = Layer.effect(
// text a second later, which looks like resume didn't work.
const announceMsg =
result.turns_used >= result.max_turns
? `⚠ 目标已恢复,但预算已耗尽(${result.turns_used}/${result.max_turns} 轮)。下一轮 judge 会立刻再次判定超预算暂停。建议 /goal clear 后重新 /goal <text>,或在 /goal set 时传更大的 maxTurns。`
? `⚠ 目标已恢复,但轮预算已耗尽(${result.turns_used}/${result.max_turns} 轮)。resume 会重启一整轮执行:本轮内任务完成才会计为达成,否则 judge 会再次暂停。建议 /goal clear 后用更大的 maxTurns 重新设定。`
: undefined
return {
type: "kick" as const,
Expand Down Expand Up @@ -834,6 +927,11 @@ const serviceLayer = Layer.effect(
clearLoopFiberIf: clearFiberIf,
deleteAndPublishDone,
pauseAndPublish,
markTurnDriven,
clearTurnDriven,
isTurnDriven,
goalTurnMaxSteps,
pauseForUserCancel,
})
}),
)
Expand Down
15 changes: 14 additions & 1 deletion packages/opencode/src/goal/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,11 @@ const serviceLayer = Layer.effect(
const evaluatedRevisions = new Map<SessionID, number>()

const afterIdle = Effect.fn("GoalLoop.afterIdle")(function* (sessionID: SessionID, scanResume?: boolean) {
// GOAL-TURN-SCOPE: the goal-driven turn that produced this idle has
// ended. Clear the mark up front; the continuation branch below re-marks
// when it dispatches the next turn. This also retires a stale mark when
// the idle came from an unrelated (non-goal) turn.
yield* goal.clearTurnDriven(sessionID)
const goalState = yield* goal.load(sessionID)
if (!goalState || goalState.status !== "active") return
// D-4 entry gate (scan path only): the boot snapshot may have gone
Expand Down Expand Up @@ -451,11 +456,19 @@ const serviceLayer = Layer.effect(
const continuationLease = Option.getOrUndefined(yield* automation.claim(sessionID, goalOwner))
if (!continuationLease) return
yield* Effect.gen(function* () {
// GOAL-TURN-SCOPE: mark BEFORE admission so the goal-turn provenance is
// already visible the instant the continuation can start — no window
// where an httpapi cancel slips between admit and mark. If admission
// is refused (session not idle), clear the speculative mark.
yield* goal.markTurnDriven(sessionID)
const admitted = yield* SessionPrompt.admitIfIdle(promptSvc, automation, continuationLease, {
sessionID,
parts: [{ type: "text", text: continuationText }],
})
if (Option.isNone(admitted)) return
if (Option.isNone(admitted)) {
yield* goal.clearTurnDriven(sessionID)
return
}
yield* admitted.value
}).pipe(
Effect.catchCause((cause) =>
Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/goal/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ export const JUDGE_RESPONSE_SNIPPET_CHARS = 4000
// message, is treated as orphaned and auto-paused so the user can recover via
// /goal resume instead of the goal sitting silently "active" forever.
export const FRESHNESS_THRESHOLD = 120_000
// Step ceiling applied to every goal-driven turn (kick, judge continuation,
// resume-kick). Without it a goal turn has NO step bound (agent.steps defaults
// to Infinity for build), so a long-running task keeps the session busy
// forever — the judge only runs on idle, and turns_used stays frozen at 0/20
// with the goal permanently "active". This ceiling guarantees every goal turn
// reaches an idle boundary where the judge and the turn budget can engage.
// Applied as min(agent.steps ?? Infinity, GOAL_TURN_MAX_STEPS) so a stricter
// user-configured agent.steps is always respected.
export const GOAL_TURN_MAX_STEPS = 50

export const JUDGE_SYSTEM_PROMPT = `You are an autonomous-goal completion judge.
You will receive:
Expand Down
20 changes: 19 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,15 @@ export const layer = Layer.effect(

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

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