diff --git a/packages/opencode/src/goal/goal.ts b/packages/opencode/src/goal/goal.ts index 29d56c7707..106d0be3cc 100644 --- a/packages/opencode/src/goal/goal.ts +++ b/packages/opencode/src/goal/goal.ts @@ -138,6 +138,34 @@ export interface Interface { * fiber AND publishes the paused event. */ readonly pauseAndPublish: (sessionID: SessionID, reason: string) => Effect.Effect + /** + * 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 + /** Clears the goal-turn mark. No-op when not marked. */ + readonly clearTurnDriven: (sessionID: SessionID) => Effect.Effect + /** + * 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 + /** True when the session's current turn is goal-driven. */ + readonly isTurnDriven: (sessionID: SessionID) => Effect.Effect + /** + * 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 } export class Service extends Context.Service()("@opencode/Goal") {} @@ -167,6 +195,58 @@ const serviceLayer = Layer.effect( const fibers = new Map>() + // 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() + + 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, @@ -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 }) @@ -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 @@ -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) { @@ -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 }) @@ -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}` @@ -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", @@ -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 ,或在 /goal set 时传更大的 maxTurns。` + ? `⚠ 目标已恢复,但轮预算已耗尽(${result.turns_used}/${result.max_turns} 轮)。resume 会重启一整轮执行:本轮内任务完成才会计为达成,否则 judge 会再次暂停。建议 /goal clear 后用更大的 maxTurns 重新设定。` : undefined return { type: "kick" as const, @@ -834,6 +927,11 @@ const serviceLayer = Layer.effect( clearLoopFiberIf: clearFiberIf, deleteAndPublishDone, pauseAndPublish, + markTurnDriven, + clearTurnDriven, + isTurnDriven, + goalTurnMaxSteps, + pauseForUserCancel, }) }), ) diff --git a/packages/opencode/src/goal/loop.ts b/packages/opencode/src/goal/loop.ts index bb72356df5..fac694df93 100644 --- a/packages/opencode/src/goal/loop.ts +++ b/packages/opencode/src/goal/loop.ts @@ -225,6 +225,11 @@ const serviceLayer = Layer.effect( const evaluatedRevisions = new Map() 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 @@ -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) => diff --git a/packages/opencode/src/goal/prompts.ts b/packages/opencode/src/goal/prompts.ts index 188d44b977..f40949fb32 100644 --- a/packages/opencode/src/goal/prompts.ts +++ b/packages/opencode/src/goal/prompts.ts @@ -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: diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 11b6f95be8..e6d30bfe03 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -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) }) @@ -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), @@ -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) diff --git a/packages/opencode/test/goal/turn-scope.test.ts b/packages/opencode/test/goal/turn-scope.test.ts new file mode 100644 index 0000000000..99dde397d5 --- /dev/null +++ b/packages/opencode/test/goal/turn-scope.test.ts @@ -0,0 +1,155 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Goal } from "@/goal/goal" +import { GoalPrompts } from "@/goal/prompts" +import { EventV2Bridge } from "@/event-v2-bridge" +import { SessionStatus } from "@/session/status" +import { Database } from "@opencode-ai/core/database/database" +import { SessionID } from "@/session/schema" +import { testEffect } from "../lib/effect" + +// GOAL-TURN-SCOPE regression tests: the turn-provenance mark (kick / +// continuation / resume-kick) drives (a) the goal-turn step ceiling surfaced by +// goalTurnMaxSteps, (b) ESC-on-goal-turn mapping to a durable pause, and (c) +// mark lifecycle across terminal transitions. Uses the real Goal layer (same +// shape as goal.test.ts) so the durable row, the event bus, and the +// process-local mark are all exercised. + +const testLayer = Goal.layer.pipe( + // provideMerge (not provide): the statusLine test body yields + // SessionStatus.Service to set busy/idle — it must see the SAME instance the + // Goal service reads. + Layer.provideMerge(EventV2Bridge.defaultLayer), + Layer.provideMerge(SessionStatus.defaultLayer), + Layer.provide(Database.defaultLayer), +) + +const it = testEffect(testLayer) + +describe("Goal turn-scope — markTurnDriven / goalTurnMaxSteps", () => { + it.live("unmarked session reports no ceiling", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sid = SessionID.descending() + yield* goal.set(sid, "test goal", 5) + expect(yield* goal.goalTurnMaxSteps(sid)).toBeUndefined() + }), + ) + + it.live("marked + active goal reports GOAL_TURN_MAX_STEPS", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sid = SessionID.descending() + yield* goal.set(sid, "test goal", 5) + yield* goal.markTurnDriven(sid) + expect(yield* goal.isTurnDriven(sid)).toBe(true) + expect(yield* goal.goalTurnMaxSteps(sid)).toBe(GoalPrompts.GOAL_TURN_MAX_STEPS) + }), + ) + + it.live("stale mark (goal cleared) self-retires and reports no ceiling", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sid = SessionID.descending() + yield* goal.set(sid, "test goal", 5) + yield* goal.markTurnDriven(sid) + yield* goal.clear(sid) + // The durable row is gone: the next goalTurnMaxSteps probe must drop the + // mark instead of capping an unrelated turn. + expect(yield* goal.goalTurnMaxSteps(sid)).toBeUndefined() + expect(yield* goal.isTurnDriven(sid)).toBe(false) + }), + ) + + it.live("stale mark (goal paused) self-retires and reports no ceiling", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sid = SessionID.descending() + yield* goal.set(sid, "test goal", 5) + yield* goal.markTurnDriven(sid) + yield* goal.pause(sid, "user-paused") + expect(yield* goal.goalTurnMaxSteps(sid)).toBeUndefined() + }), + ) +}) + +describe("Goal turn-scope — pauseForUserCancel (ESC semantics)", () => { + it.live("ESC on a marked turn pauses the goal durably and clears the mark", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sid = SessionID.descending() + yield* goal.set(sid, "test goal", 5) + yield* goal.markTurnDriven(sid) + + const paused = yield* goal.pauseForUserCancel(sid, "用户中断(ESC)— /goal resume 继续") + expect(paused?.status).toBe("paused") + + const state = yield* goal.load(sid) + expect(state?.status).toBe("paused") + expect(state?.paused_reason).toBe("用户中断(ESC)— /goal resume 继续") + expect(yield* goal.isTurnDriven(sid)).toBe(false) + // A paused goal reports no step ceiling even if the mark somehow leaked. + expect(yield* goal.goalTurnMaxSteps(sid)).toBeUndefined() + }), + ) + + it.live("ESC-like cancel without an active goal is a no-op", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sid = SessionID.descending() + const paused = yield* goal.pauseForUserCancel(sid, "ESC") + expect(paused).toBeUndefined() + }), + ) + + it.live("terminal transitions clear the mark (markDone)", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sid = SessionID.descending() + yield* goal.set(sid, "test goal", 5) + yield* goal.markTurnDriven(sid) + yield* goal.markDone(sid, "/goal done") + expect(yield* goal.isTurnDriven(sid)).toBe(false) + expect(yield* goal.load(sid)).toBeUndefined() + }), + ) + + it.live("terminal transitions clear the mark (purgeSession)", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sid = SessionID.descending() + yield* goal.set(sid, "test goal", 5) + yield* goal.markTurnDriven(sid) + yield* goal.purgeSession(sid) + expect(yield* goal.isTurnDriven(sid)).toBe(false) + }), + ) +}) + +describe("Goal turn-scope — statusLine executing indicator", () => { + // status.set needs an instance context (InstanceRef) — use it.instance so the + // test runs with a scoped temp instance. + it.instance("marked + busy session shows 执行中; idle shows 进行中", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const status = yield* SessionStatus.Service + const sid = SessionID.descending() + yield* goal.set(sid, "ship it", 5) + + const idleLine = yield* goal.statusLine(sid) + expect(idleLine).toContain("进行中") + expect(idleLine).not.toContain("执行中") + + yield* goal.markTurnDriven(sid) + yield* status.set(sid, { type: "busy" }) + const busyLine = yield* goal.statusLine(sid) + expect(busyLine).toContain("执行中") + expect(busyLine).toContain("0/5") + + // Marked but idle (turn ended, mark not yet retired) falls back to 进行中. + yield* status.set(sid, { type: "idle" }) + const backIdle = yield* goal.statusLine(sid) + expect(backIdle).toContain("进行中") + }), + ) +})