| 1 | import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; |
| 2 | import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; |
| 3 | import type { |
| 4 | ThreadLinkedPullRequest, |
| 5 | EnvironmentId, |
| 6 | MessageId, |
| 7 | OrchestrationProjectShell, |
| 8 | OrchestrationV2ProviderGoal, |
| 9 | OrchestrationV2RunStatus, |
| 10 | OrchestrationV2ProviderFailureClass, |
| 11 | OrchestrationV2ThreadProjection, |
| 12 | OrchestrationV2ThreadShell, |
| 13 | PlanId, |
| 14 | ProjectId, |
| 15 | ProviderInstanceId, |
| 16 | RunId, |
| 17 | ThreadId, |
| 18 | } from "@t3tools/contracts"; |
| 19 | import * as DateTime from "effect/DateTime"; |
| 20 | |
| 21 | import { formatSubagentDisplayTitle } from "./subagentDisplay.ts"; |
| 22 | |
| 23 | export interface EnvironmentProject extends OrchestrationProjectShell { |
| 24 | readonly environmentId: EnvironmentId; |
| 25 | /** Derived from the environment's configured scratch root, never persisted. */ |
| 26 | readonly isScratch?: true; |
| 27 | } |
| 28 | |
| 29 | /** |
| 30 | * A pristine V2 thread projection paired with the environment that produced it. |
| 31 | * |
| 32 | * The projection stays nested so the server projection and all structurally |
| 33 | * shared collections retain their identities. Rich consumers read V2 state |
| 34 | * directly instead of a second presentation-shaped thread graph. |
| 35 | */ |
| 36 | export interface EnvironmentThread { |
| 37 | readonly environmentId: EnvironmentId; |
| 38 | readonly projection: OrchestrationV2ThreadProjection; |
| 39 | } |
| 40 | |
| 41 | export interface ThreadRunSummary { |
| 42 | readonly runId: RunId; |
| 43 | readonly status: OrchestrationV2RunStatus; |
| 44 | readonly requestedAt: string | null; |
| 45 | readonly startedAt: string | null; |
| 46 | readonly completedAt: string | null; |
| 47 | readonly assistantMessageId: MessageId | null; |
| 48 | readonly sourcePlanRef?: { |
| 49 | readonly threadId: ThreadId; |
| 50 | readonly planId: PlanId; |
| 51 | }; |
| 52 | } |
| 53 | |
| 54 | export interface ThreadRuntimeSummary { |
| 55 | readonly status: OrchestrationV2RunStatus | "idle"; |
| 56 | readonly activeRunId: RunId | null; |
| 57 | readonly activityStartedAt?: string | null | undefined; |
| 58 | readonly providerInstanceId: ProviderInstanceId; |
| 59 | readonly providerName: string | null; |
| 60 | readonly lastError: string | null; |
| 61 | readonly lastErrorClass?: OrchestrationV2ProviderFailureClass | null; |
| 62 | readonly usageLimitResetAt?: string | null; |
| 63 | readonly updatedAt: string; |
| 64 | } |
| 65 | |
| 66 | export function threadRuntimeIsActive(runtime: ThreadRuntimeSummary | null | undefined): boolean { |
| 67 | return runtime !== null && runtime !== undefined && threadRunStatusIsActive(runtime.status); |
| 68 | } |
| 69 | |
| 70 | /** Archiving may discard queued work, but it must not detach a provider that |
| 71 | * is preparing, starting, or running a turn. */ |
| 72 | export function threadRuntimeCanArchive(runtime: ThreadRuntimeSummary | null | undefined): boolean { |
| 73 | if (runtime?.status === "queued") return runtime.activeRunId === null; |
| 74 | return ( |
| 75 | runtime?.status !== "preparing" && |
| 76 | runtime?.status !== "starting" && |
| 77 | runtime?.status !== "running" |
| 78 | ); |
| 79 | } |
| 80 | |
| 81 | function threadRunStatusIsActive(status: ThreadRuntimeSummary["status"]): boolean { |
| 82 | return ( |
| 83 | status === "preparing" || |
| 84 | status === "queued" || |
| 85 | status === "starting" || |
| 86 | status === "running" || |
| 87 | status === "waiting" |
| 88 | ); |
| 89 | } |
| 90 | |
| 91 | export interface EnvironmentThreadShell { |
| 92 | readonly environmentId: EnvironmentId; |
| 93 | readonly id: ThreadId; |
| 94 | readonly projectId: ProjectId; |
| 95 | readonly title: string; |
| 96 | readonly providerInstanceId: ProviderInstanceId; |
| 97 | readonly modelSelection: OrchestrationV2ThreadShell["modelSelection"]; |
| 98 | readonly runtimeMode: OrchestrationV2ThreadShell["runtimeMode"]; |
| 99 | readonly interactionMode: OrchestrationV2ThreadShell["interactionMode"]; |
| 100 | readonly branch: string | null; |
| 101 | readonly worktreePath: string | null; |
| 102 | readonly lineage: OrchestrationV2ThreadShell["lineage"]; |
| 103 | readonly forkedFrom: OrchestrationV2ThreadShell["forkedFrom"]; |
| 104 | readonly activeProviderThreadId: OrchestrationV2ThreadShell["activeProviderThreadId"]; |
| 105 | readonly latestRun: ThreadRunSummary | null; |
| 106 | readonly runtime: ThreadRuntimeSummary | null; |
| 107 | readonly latestUserMessageAt: string | null; |
| 108 | /** The last message the user wrote. `undefined` means the server predates it. */ |
| 109 | readonly latestUserAuthoredMessageAt?: string | null; |
| 110 | readonly hasPendingApprovals: boolean; |
| 111 | readonly hasPendingUserInput: boolean; |
| 112 | /** What a live subagent below this thread waits on the user for. Subagent |
| 113 | rows are hidden, so the threads above them carry it. */ |
| 114 | readonly pendingSubagentRequest?: PendingSubagentRequest | null; |
| 115 | readonly hasActionableProposedPlan: boolean; |
| 116 | readonly pendingBackgroundTasks: ReadonlyArray< |
| 117 | NonNullable<OrchestrationV2ThreadShell["pendingBackgroundTasks"]>[number] |
| 118 | >; |
| 119 | /** Provider instances that have owned the root conversation, oldest first. */ |
| 120 | readonly providerInstanceHistory: ReadonlyArray<ProviderInstanceId>; |
| 121 | /** Native `/goal` on the active provider thread. */ |
| 122 | readonly goal: OrchestrationV2ProviderGoal | null; |
| 123 | readonly itemCount: number; |
| 124 | readonly visibleItemCount: number; |
| 125 | readonly createdAt: string; |
| 126 | readonly updatedAt: string; |
| 127 | readonly archivedAt: string | null; |
| 128 | readonly settledOverride: "settled" | "active" | null; |
| 129 | readonly settledAt: string | null; |
| 130 | readonly unsettledAt: string | null; |
| 131 | readonly snoozedUntil: string | null; |
| 132 | readonly snoozedAt: string | null; |
| 133 | readonly limitRecovery?: import("@t3tools/contracts").OrchestrationV2LimitRecovery | null; |
| 134 | readonly pinnedAt: string | null; |
| 135 | readonly autoSettleDisabledAt?: string | null; |
| 136 | /** Slot in the user-arranged pinned order; null for keyless (legacy) pins. */ |
| 137 | readonly pinOrderKey: string | null; |
| 138 | /** Slot in the user-arranged active order; null for keyless active threads. */ |
| 139 | readonly activeOrderKey: string | null; |
| 140 | readonly pullRequests: ReadonlyArray<import("@t3tools/contracts").ThreadPullRequestLink>; |
| 141 | readonly linkedPullRequest?: ThreadLinkedPullRequest | null; |
| 142 | readonly branchPullRequest?: ThreadLinkedPullRequest | null; |
| 143 | /** |
| 144 | * Server-tracked visited watermark. `undefined` means the environment's |
| 145 | * server predates visited tracking and clients should fall back to any |
| 146 | * local visited state they keep. |
| 147 | */ |
| 148 | readonly lastVisitedAt?: string | null; |
| 149 | /** Pending title regeneration marker; null when no request is in flight. */ |
| 150 | readonly titleRegeneration?: { readonly requestId: string; readonly startedAt: string } | null; |
| 151 | readonly deletedAt: string | null; |
| 152 | readonly source: OrchestrationV2ThreadShell; |
| 153 | } |
| 154 | |
| 155 | function iso(value: DateTime.Utc): string { |
| 156 | return DateTime.formatIso(value); |
| 157 | } |
| 158 | |
| 159 | function nullableIso(value: DateTime.Utc | null): string | null { |
| 160 | return value === null ? null : iso(value); |
| 161 | } |
| 162 | |
| 163 | function terminalRunStatus(status: OrchestrationV2RunStatus): boolean { |
| 164 | return ( |
| 165 | status === "completed" || |
| 166 | status === "interrupted" || |
| 167 | status === "failed" || |
| 168 | status === "cancelled" || |
| 169 | status === "rolled_back" |
| 170 | ); |
| 171 | } |
| 172 | |
| 173 | // Park runtime at idle when the post-settlement background roster holds the |
| 174 | // run's completion, so #4415 waiting-presentation Waiting (session.idle) can |
| 175 | // consume CTM runtime. Only work that wakes the agent holds it: commands it |
| 176 | // left running, such as a dev server, present the run's own status (#14872). |
| 177 | // The server suppresses the roster while an interruptible activity run exists, |
| 178 | // so a remaining roster is stronger than checkpoint-oriented waiting. |
| 179 | // latestRun keeps the latest run's status for history presentation. |
| 180 | // A failed latest run outranks the roster, so the failure stays visible. |
| 181 | function shellRuntime(thread: OrchestrationV2ThreadShell): ThreadRuntimeSummary | null { |
| 182 | const parkAtIdle = |
| 183 | backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) && |
| 184 | thread.status !== "failed"; |
| 185 | // A pull request watch can hold a thread that never ran. |
| 186 | if (thread.latestRunId === null && thread.activeProviderThreadId === null && !parkAtIdle) { |
| 187 | return null; |
| 188 | } |
| 189 | const status = parkAtIdle ? "idle" : (thread.activityRunStatus ?? thread.status); |
| 190 | return { |
| 191 | status, |
| 192 | activeRunId: thread.activeRunId, |
| 193 | activityStartedAt: |
| 194 | thread.activityRunStartedAt === undefined |
| 195 | ? undefined |
| 196 | : nullableIso(thread.activityRunStartedAt), |
| 197 | providerInstanceId: thread.providerInstanceId, |
| 198 | providerName: null, |
| 199 | lastError: thread.lastError ?? null, |
| 200 | lastErrorClass: thread.lastErrorClass ?? null, |
| 201 | usageLimitResetAt: thread.usageLimitResetAt ?? null, |
| 202 | updatedAt: iso(thread.updatedAt), |
| 203 | }; |
| 204 | } |
| 205 | |
| 206 | export function scopeProject( |
| 207 | environmentId: EnvironmentId, |
| 208 | project: OrchestrationProjectShell, |
| 209 | ): EnvironmentProject { |
| 210 | return { ...project, environmentId }; |
| 211 | } |
| 212 | |
| 213 | export type PendingSubagentRequest = "approval" | "user_input"; |
| 214 | |
| 215 | export function presentThreadShell( |
| 216 | environmentId: EnvironmentId, |
| 217 | thread: OrchestrationV2ThreadShell, |
| 218 | pendingSubagentRequest: PendingSubagentRequest | null = null, |
| 219 | ): EnvironmentThreadShell { |
| 220 | const updatedAt = iso(thread.updatedAt); |
| 221 | const latestRun = |
| 222 | thread.latestRunId === null |
| 223 | ? null |
| 224 | : ({ |
| 225 | runId: thread.latestRunId, |
| 226 | status: thread.status === "idle" ? "completed" : thread.status, |
| 227 | requestedAt: nullableIso(thread.latestRunRequestedAt ?? null), |
| 228 | startedAt: nullableIso(thread.latestRunStartedAt ?? null), |
| 229 | completedAt: |
| 230 | thread.latestRunCompletedAt === undefined |
| 231 | ? thread.status === "idle" || terminalRunStatus(thread.status) |
| 232 | ? updatedAt |
| 233 | : null |
| 234 | : nullableIso(thread.latestRunCompletedAt), |
| 235 | assistantMessageId: null, |
| 236 | } satisfies ThreadRunSummary); |
| 237 | return { |
| 238 | environmentId, |
| 239 | id: thread.id, |
| 240 | projectId: thread.projectId, |
| 241 | title: |
| 242 | thread.lineage.relationshipToParent === "subagent" |
| 243 | ? formatSubagentDisplayTitle(thread.title) |
| 244 | : thread.title, |
| 245 | providerInstanceId: thread.providerInstanceId, |
| 246 | modelSelection: thread.modelSelection, |
| 247 | runtimeMode: thread.runtimeMode, |
| 248 | interactionMode: thread.interactionMode, |
| 249 | branch: thread.branch, |
| 250 | worktreePath: thread.worktreePath, |
| 251 | pullRequests: threadPullRequestsOf(thread), |
| 252 | linkedPullRequest: thread.linkedPullRequest ?? null, |
| 253 | branchPullRequest: thread.branchPullRequest ?? null, |
| 254 | lineage: thread.lineage, |
| 255 | forkedFrom: thread.forkedFrom, |
| 256 | activeProviderThreadId: thread.activeProviderThreadId, |
| 257 | latestRun, |
| 258 | runtime: shellRuntime(thread), |
| 259 | latestUserMessageAt: nullableIso(thread.latestUserMessageAt), |
| 260 | ...(thread.latestUserAuthoredMessageAt === undefined |
| 261 | ? {} |
| 262 | : { latestUserAuthoredMessageAt: nullableIso(thread.latestUserAuthoredMessageAt) }), |
| 263 | hasPendingApprovals: |
| 264 | thread.pendingRuntimeRequest !== null && |
| 265 | thread.pendingRuntimeRequest.kind !== "user_input" && |
| 266 | thread.pendingRuntimeRequest.kind !== "auth_refresh", |
| 267 | hasPendingUserInput: thread.pendingRuntimeRequest?.kind === "user_input", |
| 268 | pendingSubagentRequest, |
| 269 | hasActionableProposedPlan: thread.hasActionableProposedPlan, |
| 270 | pendingBackgroundTasks: thread.pendingBackgroundTasks ?? [], |
| 271 | providerInstanceHistory: thread.providerInstanceHistory ?? [], |
| 272 | goal: thread.goal ?? null, |
| 273 | itemCount: thread.itemCount, |
| 274 | visibleItemCount: thread.visibleItemCount, |
| 275 | createdAt: iso(thread.createdAt), |
| 276 | updatedAt, |
| 277 | archivedAt: nullableIso(thread.archivedAt), |
| 278 | settledOverride: thread.settledOverride, |
| 279 | settledAt: nullableIso(thread.settledAt), |
| 280 | unsettledAt: nullableIso(thread.unsettledAt ?? null), |
| 281 | snoozedUntil: nullableIso(thread.snoozedUntil ?? null), |
| 282 | snoozedAt: nullableIso(thread.snoozedAt ?? null), |
| 283 | limitRecovery: thread.limitRecovery ?? null, |
| 284 | pinnedAt: nullableIso(thread.pinnedAt ?? null), |
| 285 | autoSettleDisabledAt: nullableIso(thread.autoSettleDisabledAt ?? null), |
| 286 | pinOrderKey: thread.pinOrderKey ?? null, |
| 287 | activeOrderKey: thread.activeOrderKey ?? null, |
| 288 | ...(thread.lastVisitedAt === undefined |
| 289 | ? {} |
| 290 | : { lastVisitedAt: nullableIso(thread.lastVisitedAt) }), |
| 291 | titleRegeneration: |
| 292 | thread.titleRegeneration == null |
| 293 | ? null |
| 294 | : { |
| 295 | requestId: thread.titleRegeneration.requestId, |
| 296 | startedAt: iso(thread.titleRegeneration.startedAt), |
| 297 | }, |
| 298 | deletedAt: nullableIso(thread.deletedAt), |
| 299 | source: thread, |
| 300 | }; |
| 301 | } |
| 302 | |
| 303 | export function scopeThreadShell( |
| 304 | environmentId: EnvironmentId, |
| 305 | thread: OrchestrationV2ThreadShell, |
| 306 | ): EnvironmentThreadShell { |
| 307 | return presentThreadShell(environmentId, thread); |
| 308 | } |
| 309 | |
| 310 | const THREAD_PROVIDER_STACK_LIMIT = 3; |
| 311 | |
| 312 | /** |
| 313 | * Provider instances to draw in a thread row's trailing stack, back to front: |
| 314 | * the current one is always last, earlier owners precede it oldest first. |
| 315 | * Newest history wins when the thread has been handed off more times than fit. |
| 316 | */ |
| 317 | export function resolveThreadProviderStack( |
| 318 | thread: Pick<EnvironmentThreadShell, "providerInstanceHistory" | "modelSelection" | "runtime">, |
| 319 | ): ReadonlyArray<ProviderInstanceId> { |
| 320 | const current = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; |
| 321 | const previous = thread.providerInstanceHistory.filter((instanceId) => instanceId !== current); |
| 322 | return [...previous.slice(-(THREAD_PROVIDER_STACK_LIMIT - 1)), current]; |
| 323 | } |
| 324 | |
| 325 | /** |
| 326 | * Both shell and detail timers count from the activity-owning run's work |
| 327 | * start, never last activity. A wake keeps the start of the work it continues. |
| 328 | */ |
| 329 | export function resolveThreadWorkingStartedAt(input: { |
| 330 | readonly latestRun: Pick< |
| 331 | ThreadRunSummary, |
| 332 | "runId" | "startedAt" | "requestedAt" | "completedAt" |
| 333 | > | null; |
| 334 | readonly runtime: Pick<ThreadRuntimeSummary, "activeRunId" | "activityStartedAt"> | null; |
| 335 | }): string | null { |
| 336 | const valid = (value: string | null | undefined) => |
| 337 | value != null && Number.isFinite(Date.parse(value)) ? value : null; |
| 338 | if (input.runtime?.activityStartedAt !== undefined) return valid(input.runtime.activityStartedAt); |
| 339 | // Older servers can supply a timestamp only if the newest run owns the work. |
| 340 | const run = input.latestRun; |
| 341 | if (run?.completedAt === null && run.runId === input.runtime?.activeRunId) { |
| 342 | return valid(run.startedAt) ?? valid(run.requestedAt); |
| 343 | } |
| 344 | return null; |
| 345 | } |