Repository navigation
fix(cli): thread --wait returns once background work the agent waits on has ended #34696
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f872efb
16c4739
b752e2f
67c5386
5819dda
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ import { | |
| ThreadId, | ||
| TrimmedNonEmptyString, | ||
| type OrchestratorMcpThreadListItem, | ||
| type OrchestratorMcpThreadRun, | ||
| type OrchestratorMcpThreadStatus, | ||
| type OrchestratorMcpThreadTimelineItem, | ||
| } from "@t3tools/contracts"; | ||
|
|
@@ -39,6 +40,15 @@ import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; | |
| * soon, since `t3_thread_wait` only returns on a terminal run. | ||
| */ | ||
| const WAIT_SLICE_MS = 15_000; | ||
| const WAKE_GRACE_MS = 10_000; | ||
| const WAKE_POLL_MS = 500; | ||
| const TERMINAL_RUN_STATUSES = new Set<OrchestratorMcpThreadRun["status"]>([ | ||
| "completed", | ||
| "failed", | ||
| "cancelled", | ||
| "interrupted", | ||
| "rolled_back", | ||
| ]); | ||
| const READ_PAGE_LIMIT = 100; | ||
| const TITLE_MAX_CHARS = 80; | ||
|
|
||
|
|
@@ -498,35 +508,131 @@ type WaitOutcome = | |
| } | ||
| | { readonly type: "needs_input" }; | ||
|
|
||
| /** | ||
| * Whether the thread has nothing left to do: no active run and no delegated | ||
| * or background work. Only then is the work a later wait settles on this | ||
| * send's own. | ||
| */ | ||
| const threadIsIdle = Effect.fn("cli.thread.threadIsIdle")(function* ( | ||
| tools: ThreadTools, | ||
| threadId: ThreadId, | ||
| ) { | ||
| const waited = yield* tools.call( | ||
| "t3_thread_wait", | ||
| { threadId, timeoutMs: 1, untilDelegatedWorkSettled: true }, | ||
| OrchestratorMcpThreadWaitResult, | ||
| ); | ||
| return !waited.timedOut; | ||
| }); | ||
|
|
||
| /** | ||
| * The runs after `runId`, and whether one holds a message someone sent. | ||
| * Background work wakes a run with a notification, never a user message, so | ||
| * such a run is another request and its reply is not this one's. | ||
| */ | ||
| const runsAfter = Effect.fn("cli.thread.runsAfter")(function* ( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Move later-run classification into the thread service. The new As per coding guidelines, “A transport handler does three things: decode the request, call one service method, and map the service's typed errors to the transport's error. Nothing else.” As per path instructions, “multi-step dispatch and other domain work belong in the service.”
|
||
| tools: ThreadTools, | ||
| threadId: ThreadId, | ||
| runId: RunId, | ||
| ) { | ||
| const { recentRuns } = yield* tools.call( | ||
| "t3_thread_read", | ||
| { threadId, limit: 1, runLimit: 50 }, | ||
| OrchestratorMcpThreadReadResult, | ||
| ); | ||
| const own = recentRuns.find((run) => run.runId === runId); | ||
| if (own === undefined) return { later: [], sent: true }; | ||
| const later = recentRuns.filter((run) => run.ordinal > own.ordinal); | ||
| if (later.length === 0) return { later, sent: false }; | ||
| const { items } = yield* readTimeline(tools, threadId, "messages"); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid reading the complete timeline on each follow-up check. When a later run exists,
|
||
| const sent = items.some( | ||
| (item) => item.type === "user_message" && later.some((run) => run.runId === item.runId), | ||
| ); | ||
| return { later, sent }; | ||
| }); | ||
|
|
||
| /** | ||
| * Waits for this exact run, following a restart to its continuation, so a | ||
| * message another client sends meanwhile cannot stand in for its result. | ||
| * With `followWakes`, a completed run is followed further to the runs its | ||
| * background work wakes, as long as no one else sends to the thread. | ||
| */ | ||
| export const waitForRun = Effect.fn("cli.thread.waitForRun")(function* ( | ||
| tools: ThreadTools, | ||
| threadId: ThreadId, | ||
| runId: RunId, | ||
| followWakes = false, | ||
| ) { | ||
| const waitSlice = (timeoutMs: number) => | ||
| tools.call("t3_thread_wait", { threadId, runId, timeoutMs }, OrchestratorMcpThreadWaitResult); | ||
| const waitSlice = (timeoutMs: number, untilDelegatedWorkSettled?: true) => | ||
| tools.call( | ||
| "t3_thread_wait", | ||
| { | ||
| threadId, | ||
| runId, | ||
| timeoutMs, | ||
| ...(untilDelegatedWorkSettled ? { untilDelegatedWorkSettled } : {}), | ||
| }, | ||
| OrchestratorMcpThreadWaitResult, | ||
| ); | ||
| const ended = (waited: OrchestratorMcpThreadWaitResult) => | ||
| ({ type: "ended", runId: waited.runId, status: waited.status }) satisfies WaitOutcome; | ||
| const ownRun = Effect.gen(function* () { | ||
| while (true) { | ||
| const waited = yield* waitSlice(WAIT_SLICE_MS); | ||
| if (!waited.timedOut) return ended(waited); | ||
| // A pending request on this run, or on an earlier run this one is queued | ||
| // behind, blocks it until someone answers. | ||
| const { thread } = yield* tools.call( | ||
| "t3_thread_read", | ||
| { threadId, limit: 1, runLimit: 1 }, | ||
| OrchestratorMcpThreadReadResult, | ||
| ); | ||
| if (thread.pendingRequestCount > 0) { | ||
| // The count is thread-wide: if this run ended since the slice, the | ||
| // request belongs to a later run. | ||
| const recheck = yield* waitSlice(1); | ||
| return recheck.timedOut ? ({ type: "needs_input" } satisfies WaitOutcome) : ended(recheck); | ||
| } | ||
| } | ||
| }); | ||
| const outcome = yield* ownRun; | ||
| if (!followWakes || outcome.type !== "ended" || outcome.status !== "completed") return outcome; | ||
| const finalRunId = outcome.runId ?? runId; | ||
| // A provider records the run that background work wakes only after that | ||
| // work has ended, so the thread briefly looks settled in between. | ||
| const wakeRunRecorded = Effect.gen(function* () { | ||
| for (let waited = 0; waited < WAKE_GRACE_MS; waited += WAKE_POLL_MS) { | ||
| yield* Effect.sleep(WAKE_POLL_MS); | ||
| const after = yield* runsAfter(tools, threadId, finalRunId); | ||
| if (after.sent) return false; | ||
| if (after.later.length > 0) return true; | ||
| } | ||
| return false; | ||
| }); | ||
| let workOutlivedRun = false; | ||
| while (true) { | ||
| const waited = yield* waitSlice(WAIT_SLICE_MS); | ||
| if (!waited.timedOut) return ended(waited); | ||
| // A pending request on this run, or on an earlier run this one is queued | ||
| // behind, blocks it until someone answers. | ||
| const waited = yield* waitSlice(WAIT_SLICE_MS, true); | ||
| // `resultRun` is the thread's latest finished run, whoever sent it. | ||
| const result = waited.timedOut ? undefined : waited.resultRun; | ||
| const after = yield* runsAfter(tools, threadId, finalRunId); | ||
| if (after.sent) return outcome; | ||
| if (!waited.timedOut) { | ||
| if (result !== undefined && after.later.some((run) => run.runId === result.runId)) { | ||
| return { type: "ended", runId: result.runId, status: result.status } satisfies WaitOutcome; | ||
| } | ||
| if (!workOutlivedRun) return outcome; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High When background work clears its roster and records its wake run after a - if (!workOutlivedRun) return outcome;
+ if (!workOutlivedRun) {
+ if (after.later.length === 0 && (yield* wakeRunRecorded)) continue;
+ return outcome;
+ }🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle the wake-recording gap without requiring a timeout. If delegated work finishes within the first 15-second wait slice, Handle this settlement gap even when no wait slice timed out. Extend the delayed-wake test in
|
||
| if (after.later.some((run) => !TERMINAL_RUN_STATUSES.has(run.status))) continue; | ||
| if (after.later.length === 0 && (yield* wakeRunRecorded)) continue; | ||
| return outcome; | ||
| } | ||
| workOutlivedRun = true; | ||
| const { thread } = yield* tools.call( | ||
| "t3_thread_read", | ||
| { threadId, limit: 1, runLimit: 1 }, | ||
| OrchestratorMcpThreadReadResult, | ||
| ); | ||
| if (thread.pendingRequestCount > 0) { | ||
| // The count is thread-wide: if this run ended since the slice, the | ||
| // request belongs to a later run. | ||
| const recheck = yield* waitSlice(1); | ||
| return recheck.timedOut ? ({ type: "needs_input" } satisfies WaitOutcome) : ended(recheck); | ||
| } | ||
| // Earlier runs have ended, so the request is on a run this one's work woke. | ||
| if (thread.pendingRequestCount > 0) return { type: "needs_input" } satisfies WaitOutcome; | ||
| } | ||
| }); | ||
|
|
||
|
|
@@ -540,9 +646,10 @@ const finishRun = Effect.fn("cli.thread.finishRun")(function* ( | |
| readonly threadId: ThreadId; | ||
| readonly runId: RunId; | ||
| readonly json: boolean; | ||
| readonly followWakes: boolean; | ||
| }, | ||
| ) { | ||
| const outcome = yield* waitForRun(tools, input.threadId, input.runId); | ||
| const outcome = yield* waitForRun(tools, input.threadId, input.runId, input.followWakes); | ||
| const runId = outcome.type === "ended" ? (outcome.runId ?? input.runId) : input.runId; | ||
| const status = outcome.type === "ended" ? outcome.status : "waiting"; | ||
| const reply = | ||
|
|
@@ -727,6 +834,7 @@ const threadStartCommand = Command.make("start", { | |
| threadId: launched.threadId, | ||
| runId: launched.runId, | ||
| json: flags.json, | ||
| followWakes: true, | ||
| }); | ||
| } | ||
| yield* flags.json ? printJson(launched) : Console.log(launched.threadId); | ||
|
|
@@ -753,13 +861,20 @@ const threadSendCommand = Command.make("send", { | |
| yield* withThreadTools( | ||
| flags, | ||
| Effect.fn("cli.thread.send")(function* (tools) { | ||
| // Work already running when this is sent is not this message's to wait for. | ||
| const followWakes = flags.wait ? yield* threadIsIdle(tools, threadId) : false; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make the idle decision atomic with the send. If this check reports idle, another client can start a run and leave delegated work pending before
|
||
| const sent = yield* tools.call( | ||
| "t3_thread_send", | ||
| { threadId, message, mode: "queue" }, | ||
| OrchestratorMcpThreadSendResult, | ||
| ); | ||
| if (flags.wait) { | ||
| return yield* finishRun(tools, { threadId, runId: sent.runId, json: flags.json }); | ||
| return yield* finishRun(tools, { | ||
| threadId, | ||
| runId: sent.runId, | ||
| json: flags.json, | ||
| followWakes, | ||
| }); | ||
| } | ||
| yield* flags.json | ||
| ? printJson(sent) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Test the
send --waitidle decision through the command.Both new tests call
waitForRundirectly. They cannot catch a change that skipsthreadIsIdle, calls it after sending, or passes the wrongfollowWakesvalue. Add a focused command test for an idle send that follows a wake and a busy send that waits only for its own run. The existing scripted MCP setup provides a starting point. (raw.githubusercontent.com)As per coding guidelines, “Backend behavior changes ship with focused tests for that behavior.” As per path instructions, “ask for missing tests in review only when they would catch a real regression.”
Sources: Coding guidelines, Path instructions