Skip to content

Commit afa3fda

Browse files
author
ralphstodomingo
committed
fix: re-raise the offer hourly past the latch window; scope the attached-run line to its session
The attach-side dedupe expired seven days after the offer was raised, but the TUI latch runs from "Not now", which can come later — a long-lived session could wait a second full window. Past the window the offer is now re-raised every `OFFER_RECHECK_MS` and the TUI suppresses it until its latch ends. The offer command now carries the session it was raised for, so an attached headless run prints only its own offer, not another session's in the directory.
1 parent 63ba0a1 commit afa3fda

6 files changed

Lines changed: 54 additions & 17 deletions

File tree

packages/opencode/src/altimate/workspace/engine-offer.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ export const OFFER_COMMAND = "altimate.workspace.engineInstallOffer"
3232
* the per-session announce dedupe both key on this, so a session that
3333
* outlives the latch sees the offer again instead of waiting for a new one. */
3434
export const OFFER_SKIP_TTL_MS = 7 * 24 * 60 * 60 * 1000
35+
/** Once a session's offer is older than the latch, how often it is raised
36+
* again while the verdict stands. The TUI's latch starts when "Not now" is
37+
* chosen, not when the offer was raised, so the attach side cannot know when
38+
* it ends: it re-raises at this cadence and the TUI suppresses until then. */
39+
export const OFFER_RECHECK_MS = 60 * 60 * 1000
3540

3641
/** A "no usable engine" state, described well enough for an interactive
3742
* surface to act on it without re-deriving anything. */
@@ -156,12 +161,16 @@ export async function installEngine(): Promise<InstallResult> {
156161
}
157162
}
158163

159-
/** Ask the TUI to raise the offer. False when the bus is unavailable. */
160-
async function publishOffer(): Promise<boolean> {
161-
if (syncInternals.publishOffer) return syncInternals.publishOffer()
164+
/** Ask the TUI to raise the offer. False when the bus is unavailable. The
165+
* session is carried so an attached headless run, which reads the same event
166+
* stream, prints the offer raised for its own session only. */
167+
async function publishOffer(sessionID: string): Promise<boolean> {
168+
if (syncInternals.publishOffer) return syncInternals.publishOffer(sessionID)
162169
try {
163170
await AppRuntime.runPromise(
164-
EventV2Bridge.Service.use((events) => events.publish(TuiEvent.CommandExecute, { command: OFFER_COMMAND })),
171+
EventV2Bridge.Service.use((events) =>
172+
events.publish(TuiEvent.CommandExecute, { command: OFFER_COMMAND, sessionID }),
173+
),
165174
)
166175
return true
167176
} catch (err) {
@@ -192,12 +201,12 @@ export function describeOfferLine(offer: EngineOffer): string {
192201

193202
/** Offer via the dialog surface when there is one; otherwise print (headless)
194203
* or toast (bus unavailable). Exactly one of these happens. */
195-
export async function offerOrNotify(offer: EngineOffer, toast: Toast): Promise<void> {
204+
export async function offerOrNotify(offer: EngineOffer, toast: Toast, sessionID: string): Promise<void> {
196205
if (isHeadless()) {
197206
printLine(describeOfferLine(offer))
198207
return
199208
}
200209
if (offerInstall(offer)) return
201-
if (await publishOffer()) return
210+
if (await publishOffer(sessionID)) return
202211
await notify(toast)
203212
}

packages/opencode/src/altimate/workspace/engine-overlay.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { MCP } from "@/mcp"
2828
import { Config } from "@/config/config"
2929
import { currentDirectory, isEnabled, isHeadless, isServe, log, syncInternals } from "./engine-seams"
3030
import { declaredBounded, fingerprint, notify, printLine, resolveBinding, versionOf, which } from "./engine-probes"
31-
import { OFFER_SKIP_TTL_MS, installCommand, offerOrNotify, type EngineOffer } from "./engine-offer"
31+
import { OFFER_RECHECK_MS, OFFER_SKIP_TTL_MS, installCommand, offerOrNotify, type EngineOffer } from "./engine-offer"
3232
import {
3333
ENGINE_BINARY,
3434
INSTALL_HELPS,
@@ -530,7 +530,11 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS
530530
*
531531
* The offer route's "once" expires with the "Not now" latch: a session that
532532
* stays open past `OFFER_SKIP_TTL_MS` is offered again, so the latch (which
533-
* the TUI checks on every offer) decides, not the age of the session. */
533+
* the TUI checks on every offer) decides, not the age of the session. The
534+
* latch is measured from the user's "Not now", which can come well after the
535+
* offer was raised, so after the first expiry the offer is re-raised every
536+
* `OFFER_RECHECK_MS` rather than once per further window — the TUI keeps
537+
* suppressing it until its latch really ends. */
534538
export async function announceRefusal(
535539
sessionID: string,
536540
outcome: Outcome,
@@ -542,14 +546,16 @@ export async function announceRefusal(
542546
const signature = `${outcome.kind}:${detail}:${toast.title}`
543547
const offering = !!offer && INSTALL_HELPS[outcome.kind]
544548
const at = now()
549+
let repeat = false
545550
if (rec.announced === signature) {
546551
const expired = offering && rec.announcedAt !== undefined && at - rec.announcedAt >= OFFER_SKIP_TTL_MS
547552
if (!expired) return
553+
repeat = true
548554
}
549555
rec.announced = signature
550-
rec.announcedAt = at
556+
rec.announcedAt = repeat ? at - OFFER_SKIP_TTL_MS + OFFER_RECHECK_MS : at
551557
if (offering) {
552-
await offerOrNotify(offer, toast)
558+
await offerOrNotify(offer, toast, sessionID)
553559
return
554560
}
555561
if (isHeadless()) {

packages/opencode/src/altimate/workspace/engine-seams.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export const syncInternals: {
2222
printLine?: (line: string) => void
2323
/** Install-offer seams (see engine-offer.ts). */
2424
offer?: (offer: EngineOffer) => boolean
25-
publishOffer?: () => Promise<boolean>
25+
publishOffer?: (sessionID: string) => Promise<boolean>
2626
nodeMajor?: () => Promise<number | null>
2727
npmAvailable?: () => boolean
2828
install?: (spec: string) => Promise<InstallResult>

packages/opencode/src/cli/cmd/run.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -761,9 +761,12 @@ You are speaking to a non-technical business executive. Follow these rules stric
761761
// which has no handler for it, is the only thing the user is looking at.
762762
// Placed before the idle break: the loop stops on idle, so a handler
763763
// after it never runs for an offer that arrives in the same batch.
764+
// The stream carries every session's events for this directory; only
765+
// the offer raised for this run's session is this run's to print.
764766
if (
765767
event.type === "tui.command.execute" &&
766-
(event.properties as { command?: string }).command === OFFER_COMMAND
768+
(event.properties as { command?: string }).command === OFFER_COMMAND &&
769+
(event.properties as { sessionID?: string }).sessionID === sessionID
767770
) {
768771
// stderr: stdout is raw JSON events under --format json.
769772
process.stderr.write(

packages/opencode/src/server/tui-event.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ export const TuiEvent = {
3131
]),
3232
Schema.String,
3333
]),
34+
// altimate_change start — the workspace engine install offer is published
35+
// as a command for the TUI plugin, and an attached headless run reads the
36+
// same stream: the session it was raised for lets that run print only its
37+
// own offer, not another session's in the same directory.
38+
sessionID: Schema.optional(Schema.String),
39+
// altimate_change end
3440
},
3541
}),
3642
ToastShow: EventV2.define({

packages/opencode/test/altimate/workspace/engine-install-offer.test.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
type EngineOffer,
2121
type Toast,
2222
} from "../../../src/altimate/workspace/engine-overlay"
23-
import { OFFER_SKIP_TTL_MS } from "../../../src/altimate/workspace/engine-offer"
23+
import { OFFER_RECHECK_MS, OFFER_SKIP_TTL_MS } from "../../../src/altimate/workspace/engine-offer"
2424
import { Process } from "../../../src/util/process"
2525
import type { CachedBinding } from "../../../src/altimate/workspace/state"
2626

@@ -36,7 +36,7 @@ const binding: CachedBinding = {
3636
linkedAt: 0,
3737
} as CachedBinding
3838

39-
type Harness = { offers: EngineOffer[]; toasts: Toast[]; printed: string[]; published: number }
39+
type Harness = { offers: EngineOffer[]; toasts: Toast[]; printed: string[]; published: number; publishedFor: string[] }
4040

4141
/** No engine on PATH (or an old one) plus captured surfaces. */
4242
function install(opts: {
@@ -48,7 +48,7 @@ function install(opts: {
4848
surface?: boolean
4949
bound?: boolean
5050
}): Harness {
51-
const h: Harness = { offers: [], toasts: [], printed: [], published: 0 }
51+
const h: Harness = { offers: [], toasts: [], printed: [], published: 0, publishedFor: [] }
5252
process.env.ALTIMATE_WORKSPACE = "1"
5353
syncInternals.serve = () => false
5454
syncInternals.headless = () => opts.headless === true
@@ -66,9 +66,10 @@ function install(opts: {
6666
syncInternals.printLine = (line) => {
6767
h.printed.push(line)
6868
}
69-
syncInternals.publishOffer = async () => {
69+
syncInternals.publishOffer = async (sessionID) => {
7070
if (opts.bus === false) return false
7171
h.published += 1
72+
h.publishedFor.push(sessionID)
7273
return true
7374
}
7475
if (opts.surface) {
@@ -178,13 +179,16 @@ describe("offer routing — engine missing", () => {
178179
await beforeTurn("s1")
179180
expect(h.printed[0]).toContain("1 integration tool need")
180181
})
181-
test("the offer is raised once per session per verdict", async () => {
182+
test("the offer is raised once per session per verdict, naming the session it is for", async () => {
182183
const h = install({})
183184
await beforeTurn("s1")
184185
await beforeTurn("s1")
185186
expect(h.published).toBe(1)
186187
await beforeTurn("s2")
187188
expect(h.published).toBe(2)
189+
// An attached headless run reads every session's events for the directory
190+
// and prints only the offer raised for its own session.
191+
expect(h.publishedFor).toEqual(["s1", "s2"])
188192
})
189193
test("a session that outlives the Not-now latch is offered again", async () => {
190194
// The TUI re-checks its 7-day latch on every offer; the dedupe here must
@@ -202,6 +206,15 @@ describe("offer routing — engine missing", () => {
202206
expect(h.published).toBe(2)
203207
await beforeTurn("s1")
204208
expect(h.published).toBe(2)
209+
// The latch runs from "Not now", which may come long after the offer was
210+
// raised, so once the window has passed the offer is re-raised hourly —
211+
// never held for another full window.
212+
clock += OFFER_RECHECK_MS - 1
213+
await beforeTurn("s1")
214+
expect(h.published).toBe(2)
215+
clock += 1
216+
await beforeTurn("s1")
217+
expect(h.published).toBe(3)
205218
})
206219
})
207220

0 commit comments

Comments
 (0)