| author | |
| committer | |
| log | 872b90737211f883b73879e3feae5563140a8fbe |
| tree | 7541835a1320ecfe63b181542a21316c71319701 |
| parent | 0b8e098c105297bc9564dc8bb792af21ec91ba4a |
- Ignore auxiliary/non-active turn completions for session lifecycle and checkpoints
- Restrict runtime thread/turn IDs to provider IDs in contracts
- Add regression tests and ADR for lifecycle ownership rules7 files changed, 372 insertions(+), 45 deletions(-)
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+75| ... | ... | @@ -381,6 +381,81 @@ describe("CheckpointReactor", () => { |
| 381 | 381 | ).toBe("v2\n"); |
| 382 | 382 | }); |
| 383 | 383 | |
| 384 | it("ignores auxiliary thread turn completion while primary turn is active", async () => { | |
| 385 | const harness = await createHarness({ seedFilesystemCheckpoints: false }); | |
| 386 | const createdAt = new Date().toISOString(); | |
| 387 | ||
| 388 | await Effect.runPromise( | |
| 389 | harness.engine.dispatch({ | |
| 390 | type: "thread.session.set", | |
| 391 | commandId: CommandId.makeUnsafe("cmd-session-set-primary-running"), | |
| 392 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 393 | session: { | |
| 394 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 395 | status: "running", | |
| 396 | providerName: "codex", | |
| 397 | providerSessionId: asSessionId("sess-1"), | |
| 398 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 399 | approvalPolicy: "on-request", | |
| 400 | sandboxMode: "workspace-write", | |
| 401 | activeTurnId: asTurnId("turn-main"), | |
| 402 | lastError: null, | |
| 403 | updatedAt: createdAt, | |
| 404 | }, | |
| 405 | createdAt, | |
| 406 | }), | |
| 407 | ); | |
| 408 | ||
| 409 | harness.provider.emit({ | |
| 410 | type: "turn.started", | |
| 411 | eventId: EventId.makeUnsafe("evt-turn-started-main"), | |
| 412 | provider: "codex", | |
| 413 | sessionId: asSessionId("sess-1"), | |
| 414 | createdAt: new Date().toISOString(), | |
| 415 | threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 416 | turnId: asTurnId("turn-main"), | |
| 417 | }); | |
| 418 | await waitForGitRefExists( | |
| 419 | harness.cwd, | |
| 420 | checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | |
| 421 | ); | |
| 422 | ||
| 423 | fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); | |
| 424 | ||
| 425 | harness.provider.emit({ | |
| 426 | type: "turn.completed", | |
| 427 | eventId: EventId.makeUnsafe("evt-turn-completed-aux"), | |
| 428 | provider: "codex", | |
| 429 | sessionId: asSessionId("sess-1"), | |
| 430 | createdAt: new Date().toISOString(), | |
| 431 | threadId: ProviderThreadId.makeUnsafe("provider-thread-aux"), | |
| 432 | turnId: asTurnId("turn-aux"), | |
| 433 | status: "completed", | |
| 434 | }); | |
| 435 | ||
| 436 | await Effect.runPromise(Effect.sleep("40 millis")); | |
| 437 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); | |
| 438 | const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 439 | expect(midThread?.checkpoints).toHaveLength(0); | |
| 440 | ||
| 441 | harness.provider.emit({ | |
| 442 | type: "turn.completed", | |
| 443 | eventId: EventId.makeUnsafe("evt-turn-completed-main"), | |
| 444 | provider: "codex", | |
| 445 | sessionId: asSessionId("sess-1"), | |
| 446 | createdAt: new Date().toISOString(), | |
| 447 | threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 448 | turnId: asTurnId("turn-main"), | |
| 449 | status: "completed", | |
| 450 | }); | |
| 451 | ||
| 452 | const thread = await waitForThread( | |
| 453 | harness.engine, | |
| 454 | (entry) => entry.latestTurnId === "turn-main" && entry.checkpoints.length === 1, | |
| 455 | ); | |
| 456 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); | |
| 457 | }); | |
| 458 | ||
| 384 | 459 | it("appends capture failure activity when turn diff summary cannot be derived", async () => { |
| 385 | 460 | const harness = await createHarness({ seedFilesystemCheckpoints: false }); |
| 386 | 461 | const createdAt = new Date().toISOString(); |
apps/server/src/orchestration/Layers/CheckpointReactor.ts+27| ... | ... | @@ -3,6 +3,7 @@ import { |
| 3 | 3 | EventId, |
| 4 | 4 | MessageId, |
| 5 | 5 | ProviderSessionId, |
| 6 | ProviderThreadId, | |
| 6 | 7 | ThreadId, |
| 7 | 8 | TurnId, |
| 8 | 9 | type OrchestrationEvent, |
| ... | ... | @@ -36,6 +37,17 @@ function toTurnId(value: string | undefined): TurnId | null { |
| 36 | 37 | return value === undefined ? null : TurnId.makeUnsafe(value); |
| 37 | 38 | } |
| 38 | 39 | |
| 40 | function toProviderThreadId(value: string | undefined): ProviderThreadId | null { | |
| 41 | return value === undefined ? null : ProviderThreadId.makeUnsafe(value); | |
| 42 | } | |
| 43 | ||
| 44 | function sameId(left: string | null | undefined, right: string | null | undefined): boolean { | |
| 45 | if (left === null || left === undefined || right === null || right === undefined) { | |
| 46 | return false; | |
| 47 | } | |
| 48 | return left === right; | |
| 49 | } | |
| 50 | ||
| 39 | 51 | function checkpointStatusFromRuntime(status: string | undefined): "ready" | "missing" | "error" { |
| 40 | 52 | switch (status) { |
| 41 | 53 | case "failed": |
| ... | ... | @@ -173,6 +185,21 @@ const make = Effect.gen(function* () { |
| 173 | 185 | return; |
| 174 | 186 | } |
| 175 | 187 | |
| 188 | const projectedProviderThreadId = thread.session?.providerThreadId ?? null; | |
| 189 | const eventProviderThreadId = toProviderThreadId(event.threadId); | |
| 190 | if ( | |
| 191 | projectedProviderThreadId !== null && | |
| 192 | eventProviderThreadId !== null && | |
| 193 | !sameId(projectedProviderThreadId, eventProviderThreadId) | |
| 194 | ) { | |
| 195 | return; | |
| 196 | } | |
| 197 | ||
| 198 | // When a primary turn is active, only that turn may produce completion checkpoints. | |
| 199 | if (thread.session?.activeTurnId && !sameId(thread.session.activeTurnId, turnId)) { | |
| 200 | return; | |
| 201 | } | |
| 202 | ||
| 176 | 203 | if (thread.checkpoints.some((checkpoint) => checkpoint.turnId === turnId)) { |
| 177 | 204 | return; |
| 178 | 205 | } |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+108| ... | ... | @@ -31,6 +31,8 @@ import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeInge |
| 31 | 31 | |
| 32 | 32 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 33 | 33 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); |
| 34 | const asProviderThreadId = (value: string): ProviderThreadId => | |
| 35 | ProviderThreadId.makeUnsafe(value); | |
| 34 | 36 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); |
| 35 | 37 | const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); |
| 36 | 38 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| ... | ... | @@ -220,6 +222,112 @@ describe("ProviderRuntimeIngestion", () => { |
| 220 | 222 | expect(thread.session?.lastError).toBe("turn failed"); |
| 221 | 223 | }); |
| 222 | 224 | |
| 225 | it("ignores auxiliary turn completions from a different provider thread", async () => { | |
| 226 | const harness = await createHarness(); | |
| 227 | const now = new Date().toISOString(); | |
| 228 | ||
| 229 | harness.emit({ | |
| 230 | type: "turn.started", | |
| 231 | eventId: asEventId("evt-turn-started-primary"), | |
| 232 | provider: "codex", | |
| 233 | sessionId: asSessionId("sess-1"), | |
| 234 | createdAt: now, | |
| 235 | threadId: asProviderThreadId("provider-thread-1"), | |
| 236 | turnId: asProviderTurnId("turn-primary"), | |
| 237 | }); | |
| 238 | ||
| 239 | await waitForThread( | |
| 240 | harness.engine, | |
| 241 | (thread) => | |
| 242 | thread.session?.status === "running" && thread.session?.activeTurnId === "turn-primary", | |
| 243 | ); | |
| 244 | ||
| 245 | harness.emit({ | |
| 246 | type: "turn.completed", | |
| 247 | eventId: asEventId("evt-turn-completed-aux"), | |
| 248 | provider: "codex", | |
| 249 | sessionId: asSessionId("sess-1"), | |
| 250 | createdAt: new Date().toISOString(), | |
| 251 | threadId: asProviderThreadId("provider-thread-aux"), | |
| 252 | turnId: asProviderTurnId("turn-aux"), | |
| 253 | status: "completed", | |
| 254 | }); | |
| 255 | ||
| 256 | await Effect.runPromise(Effect.sleep("40 millis")); | |
| 257 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); | |
| 258 | const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 259 | expect(midThread?.session?.status).toBe("running"); | |
| 260 | expect(midThread?.session?.activeTurnId).toBe("turn-primary"); | |
| 261 | ||
| 262 | harness.emit({ | |
| 263 | type: "turn.completed", | |
| 264 | eventId: asEventId("evt-turn-completed-primary"), | |
| 265 | provider: "codex", | |
| 266 | sessionId: asSessionId("sess-1"), | |
| 267 | createdAt: new Date().toISOString(), | |
| 268 | threadId: asProviderThreadId("provider-thread-1"), | |
| 269 | turnId: asProviderTurnId("turn-primary"), | |
| 270 | status: "completed", | |
| 271 | }); | |
| 272 | ||
| 273 | await waitForThread( | |
| 274 | harness.engine, | |
| 275 | (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, | |
| 276 | ); | |
| 277 | }); | |
| 278 | ||
| 279 | it("ignores non-active turn completion when runtime omits thread id", async () => { | |
| 280 | const harness = await createHarness(); | |
| 281 | const now = new Date().toISOString(); | |
| 282 | ||
| 283 | harness.emit({ | |
| 284 | type: "turn.started", | |
| 285 | eventId: asEventId("evt-turn-started-guarded"), | |
| 286 | provider: "codex", | |
| 287 | sessionId: asSessionId("sess-1"), | |
| 288 | createdAt: now, | |
| 289 | turnId: asProviderTurnId("turn-guarded-main"), | |
| 290 | }); | |
| 291 | ||
| 292 | await waitForThread( | |
| 293 | harness.engine, | |
| 294 | (thread) => | |
| 295 | thread.session?.status === "running" && | |
| 296 | thread.session?.activeTurnId === "turn-guarded-main", | |
| 297 | ); | |
| 298 | ||
| 299 | harness.emit({ | |
| 300 | type: "turn.completed", | |
| 301 | eventId: asEventId("evt-turn-completed-guarded-other"), | |
| 302 | provider: "codex", | |
| 303 | sessionId: asSessionId("sess-1"), | |
| 304 | createdAt: new Date().toISOString(), | |
| 305 | turnId: asProviderTurnId("turn-guarded-other"), | |
| 306 | status: "completed", | |
| 307 | }); | |
| 308 | ||
| 309 | await Effect.runPromise(Effect.sleep("40 millis")); | |
| 310 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); | |
| 311 | const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 312 | expect(midThread?.session?.status).toBe("running"); | |
| 313 | expect(midThread?.session?.activeTurnId).toBe("turn-guarded-main"); | |
| 314 | ||
| 315 | harness.emit({ | |
| 316 | type: "turn.completed", | |
| 317 | eventId: asEventId("evt-turn-completed-guarded-main"), | |
| 318 | provider: "codex", | |
| 319 | sessionId: asSessionId("sess-1"), | |
| 320 | createdAt: new Date().toISOString(), | |
| 321 | turnId: asProviderTurnId("turn-guarded-main"), | |
| 322 | status: "completed", | |
| 323 | }); | |
| 324 | ||
| 325 | await waitForThread( | |
| 326 | harness.engine, | |
| 327 | (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, | |
| 328 | ); | |
| 329 | }); | |
| 330 | ||
| 223 | 331 | it("maps message delta/completed into finalized assistant messages", async () => { |
| 224 | 332 | const harness = await createHarness(); |
| 225 | 333 | const now = new Date().toISOString(); |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+115-37| ... | ... | @@ -33,6 +33,7 @@ const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120); |
| 33 | 33 | const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; |
| 34 | 34 | const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120); |
| 35 | 35 | const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; |
| 36 | const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0"; | |
| 36 | 37 | |
| 37 | 38 | type TurnStartRequestedDomainEvent = Extract< |
| 38 | 39 | OrchestrationEvent, |
| ... | ... | @@ -53,6 +54,17 @@ function toTurnId(value: string | undefined): TurnId | undefined { |
| 53 | 54 | return value === undefined ? undefined : TurnId.makeUnsafe(value); |
| 54 | 55 | } |
| 55 | 56 | |
| 57 | function toProviderThreadId(value: string | undefined): ProviderThreadId | null { | |
| 58 | return value === undefined ? null : ProviderThreadId.makeUnsafe(value); | |
| 59 | } | |
| 60 | ||
| 61 | function sameId(left: string | null | undefined, right: string | null | undefined): boolean { | |
| 62 | if (left === null || left === undefined || right === null || right === undefined) { | |
| 63 | return false; | |
| 64 | } | |
| 65 | return left === right; | |
| 66 | } | |
| 67 | ||
| 56 | 68 | function truncateDetail(value: string, limit = 180): string { |
| 57 | 69 | return value.length > limit ? `${value.slice(0, limit - 3)}...` : value; |
| 58 | 70 | } |
| ... | ... | @@ -332,6 +344,62 @@ const make = Effect.gen(function* () { |
| 332 | 344 | if (!thread) return; |
| 333 | 345 | |
| 334 | 346 | const now = event.createdAt; |
| 347 | const sessionProviderThreadId = thread.session?.providerThreadId ?? null; | |
| 348 | const eventProviderThreadId = toProviderThreadId(event.threadId); | |
| 349 | const eventTurnId = toTurnId(event.turnId); | |
| 350 | const activeTurnId = thread.session?.activeTurnId ?? null; | |
| 351 | ||
| 352 | const matchesThreadScope = | |
| 353 | eventProviderThreadId === null || | |
| 354 | sessionProviderThreadId === null || | |
| 355 | sameId(eventProviderThreadId, sessionProviderThreadId); | |
| 356 | const conflictsWithActiveTurn = | |
| 357 | activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); | |
| 358 | const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined; | |
| 359 | ||
| 360 | const shouldApplyThreadLifecycle = (() => { | |
| 361 | if (!STRICT_PROVIDER_LIFECYCLE_GUARD) { | |
| 362 | return true; | |
| 363 | } | |
| 364 | switch (event.type) { | |
| 365 | case "session.exited": | |
| 366 | return true; | |
| 367 | case "session.started": | |
| 368 | case "thread.started": | |
| 369 | if (!matchesThreadScope) { | |
| 370 | return false; | |
| 371 | } | |
| 372 | // Never let auxiliary/provider-side spawned threads replace the primary thread binding. | |
| 373 | if ( | |
| 374 | eventProviderThreadId !== null && | |
| 375 | sessionProviderThreadId !== null && | |
| 376 | !sameId(eventProviderThreadId, sessionProviderThreadId) | |
| 377 | ) { | |
| 378 | return false; | |
| 379 | } | |
| 380 | return true; | |
| 381 | case "turn.started": | |
| 382 | if (!matchesThreadScope) { | |
| 383 | return false; | |
| 384 | } | |
| 385 | return !conflictsWithActiveTurn; | |
| 386 | case "turn.completed": | |
| 387 | if (!matchesThreadScope) { | |
| 388 | return false; | |
| 389 | } | |
| 390 | if (conflictsWithActiveTurn || missingTurnForActiveTurn) { | |
| 391 | return false; | |
| 392 | } | |
| 393 | // Only the active turn may close the lifecycle state. | |
| 394 | if (activeTurnId !== null && eventTurnId !== undefined) { | |
| 395 | return sameId(activeTurnId, eventTurnId); | |
| 396 | } | |
| 397 | // Without an active turn, only accept completion when no thread mismatch signal exists. | |
| 398 | return eventProviderThreadId === null || sessionProviderThreadId === null; | |
| 399 | default: | |
| 400 | return true; | |
| 401 | } | |
| 402 | })(); | |
| 335 | 403 | |
| 336 | 404 | if ( |
| 337 | 405 | event.type === "session.started" || |
| ... | ... | @@ -340,8 +408,7 @@ const make = Effect.gen(function* () { |
| 340 | 408 | event.type === "turn.started" || |
| 341 | 409 | event.type === "turn.completed" |
| 342 | 410 | ) { |
| 343 | const activeTurnId = | |
| 344 | event.type === "turn.started" ? (toTurnId(event.turnId) ?? null) : null; | |
| 411 | const nextActiveTurnId = event.type === "turn.started" ? (eventTurnId ?? null) : null; | |
| 345 | 412 | const providerThreadIdFromEvent = |
| 346 | 413 | event.type === "thread.started" |
| 347 | 414 | ? ProviderThreadId.makeUnsafe(event.threadId) |
| ... | ... | @@ -349,7 +416,7 @@ const make = Effect.gen(function* () { |
| 349 | 416 | ? ProviderThreadId.makeUnsafe(event.threadId) |
| 350 | 417 | : null; |
| 351 | 418 | const providerThreadId = |
| 352 | providerThreadIdFromEvent ?? thread.session?.providerThreadId ?? null; | |
| 419 | providerThreadIdFromEvent ?? sessionProviderThreadId ?? null; | |
| 353 | 420 | const status = |
| 354 | 421 | event.type === "turn.started" |
| 355 | 422 | ? "running" |
| ... | ... | @@ -365,24 +432,26 @@ const make = Effect.gen(function* () { |
| 365 | 432 | ? null |
| 366 | 433 | : (thread.session?.lastError ?? null); |
| 367 | 434 | |
| 368 | yield* orchestrationEngine.dispatch({ | |
| 369 | type: "thread.session.set", | |
| 370 | commandId: providerCommandId(event, "thread-session-set"), | |
| 371 | threadId: thread.id, | |
| 372 | session: { | |
| 435 | if (shouldApplyThreadLifecycle) { | |
| 436 | yield* orchestrationEngine.dispatch({ | |
| 437 | type: "thread.session.set", | |
| 438 | commandId: providerCommandId(event, "thread-session-set"), | |
| 373 | 439 | threadId: thread.id, |
| 374 | status, | |
| 375 | providerName: event.provider, | |
| 376 | providerSessionId: event.sessionId, | |
| 377 | providerThreadId, | |
| 378 | approvalPolicy: thread.session?.approvalPolicy ?? DEFAULT_APPROVAL_POLICY, | |
| 379 | sandboxMode: thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE, | |
| 380 | activeTurnId, | |
| 381 | lastError, | |
| 382 | updatedAt: now, | |
| 383 | }, | |
| 384 | createdAt: now, | |
| 385 | }); | |
| 440 | session: { | |
| 441 | threadId: thread.id, | |
| 442 | status, | |
| 443 | providerName: event.provider, | |
| 444 | providerSessionId: event.sessionId, | |
| 445 | providerThreadId, | |
| 446 | approvalPolicy: thread.session?.approvalPolicy ?? DEFAULT_APPROVAL_POLICY, | |
| 447 | sandboxMode: thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE, | |
| 448 | activeTurnId: nextActiveTurnId, | |
| 449 | lastError, | |
| 450 | updatedAt: now, | |
| 451 | }, | |
| 452 | createdAt: now, | |
| 453 | }); | |
| 454 | } | |
| 386 | 455 | } |
| 387 | 456 | |
| 388 | 457 | if (event.type === "message.delta" && event.delta.length > 0) { |
| ... | ... | @@ -470,29 +539,38 @@ const make = Effect.gen(function* () { |
| 470 | 539 | } |
| 471 | 540 | |
| 472 | 541 | if (event.type === "runtime.error") { |
| 542 | const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD | |
| 543 | ? true | |
| 544 | : matchesThreadScope && | |
| 545 | (activeTurnId === null || | |
| 546 | eventTurnId === undefined || | |
| 547 | sameId(activeTurnId, eventTurnId)); | |
| 548 | ||
| 473 | 549 | const providerThreadId = |
| 474 | 550 | event.threadId !== undefined |
| 475 | 551 | ? ProviderThreadId.makeUnsafe(event.threadId) |
| 476 | 552 | : (thread.session?.providerThreadId ?? null); |
| 477 | 553 | |
| 478 | yield* orchestrationEngine.dispatch({ | |
| 479 | type: "thread.session.set", | |
| 480 | commandId: providerCommandId(event, "runtime-error-session-set"), | |
| 481 | threadId: thread.id, | |
| 482 | session: { | |
| 554 | if (shouldApplyRuntimeError) { | |
| 555 | yield* orchestrationEngine.dispatch({ | |
| 556 | type: "thread.session.set", | |
| 557 | commandId: providerCommandId(event, "runtime-error-session-set"), | |
| 483 | 558 | threadId: thread.id, |
| 484 | status: "error", | |
| 485 | providerName: event.provider, | |
| 486 | providerSessionId: event.sessionId, | |
| 487 | providerThreadId, | |
| 488 | approvalPolicy: thread.session?.approvalPolicy ?? DEFAULT_APPROVAL_POLICY, | |
| 489 | sandboxMode: thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE, | |
| 490 | activeTurnId: toTurnId(event.turnId) ?? null, | |
| 491 | lastError: event.message, | |
| 492 | updatedAt: now, | |
| 493 | }, | |
| 494 | createdAt: now, | |
| 495 | }); | |
| 559 | session: { | |
| 560 | threadId: thread.id, | |
| 561 | status: "error", | |
| 562 | providerName: event.provider, | |
| 563 | providerSessionId: event.sessionId, | |
| 564 | providerThreadId, | |
| 565 | approvalPolicy: thread.session?.approvalPolicy ?? DEFAULT_APPROVAL_POLICY, | |
| 566 | sandboxMode: thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE, | |
| 567 | activeTurnId: eventTurnId ?? null, | |
| 568 | lastError: event.message, | |
| 569 | updatedAt: now, | |
| 570 | }, | |
| 571 | createdAt: now, | |
| 572 | }); | |
| 573 | } | |
| 496 | 574 | } |
| 497 | 575 | |
| 498 | 576 | const activities = runtimeEventToActivities(event); |
apps/server/src/provider/Layers/ProviderService.test.ts+5-4| ... | ... | @@ -47,6 +47,7 @@ import { |
| 47 | 47 | |
| 48 | 48 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); |
| 49 | 49 | const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); |
| 50 | const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); | |
| 50 | 51 | const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); |
| 51 | 52 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| 52 | 53 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); |
| ... | ... | @@ -564,7 +565,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 564 | 565 | provider: "codex", |
| 565 | 566 | sessionId: session.sessionId, |
| 566 | 567 | createdAt: new Date().toISOString(), |
| 567 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 568 | threadId: asProviderThreadId("thread-1"), | |
| 568 | 569 | turnId: asTurnId("turn-1"), |
| 569 | 570 | status: "completed", |
| 570 | 571 | }; |
| ... | ... | @@ -612,7 +613,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 612 | 613 | provider: "codex", |
| 613 | 614 | sessionId: session.sessionId, |
| 614 | 615 | createdAt: new Date().toISOString(), |
| 615 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 616 | threadId: asProviderThreadId("thread-1"), | |
| 616 | 617 | turnId: asTurnId("turn-1"), |
| 617 | 618 | toolKind: "command", |
| 618 | 619 | title: "Command run", |
| ... | ... | @@ -624,7 +625,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 624 | 625 | provider: "codex", |
| 625 | 626 | sessionId: session.sessionId, |
| 626 | 627 | createdAt: new Date().toISOString(), |
| 627 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 628 | threadId: asProviderThreadId("thread-1"), | |
| 628 | 629 | turnId: asTurnId("turn-1"), |
| 629 | 630 | delta: "hello", |
| 630 | 631 | }, |
| ... | ... | @@ -634,7 +635,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 634 | 635 | provider: "codex", |
| 635 | 636 | sessionId: session.sessionId, |
| 636 | 637 | createdAt: new Date().toISOString(), |
| 637 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 638 | threadId: asProviderThreadId("thread-1"), | |
| 638 | 639 | turnId: asTurnId("turn-1"), |
| 639 | 640 | status: "completed", |
| 640 | 641 | }, |
docs/adr/0001-provider-runtime-lifecycle-ownership.md created+40| ... | ... | @@ -0,0 +1,40 @@ |
| 1 | # ADR 0001: Provider Runtime Lifecycle Ownership | |
| 2 | ||
| 3 | ## Status | |
| 4 | Accepted | |
| 5 | ||
| 6 | ## Context | |
| 7 | Provider runtime streams can include auxiliary work (for example collab/child-agent turns) under the same provider session as a user-visible primary turn. | |
| 8 | ||
| 9 | The previous orchestration ingestion and checkpoint flows keyed lifecycle updates by `providerSessionId` alone. This allowed auxiliary `turn.completed` events to: | |
| 10 | ||
| 11 | - mark thread sessions as `ready` before the primary turn completed | |
| 12 | - clear `activeTurnId` for a still-running primary turn | |
| 13 | - emit checkpoint turn-diff summaries for auxiliary turns in the main thread timeline | |
| 14 | ||
| 15 | ## Decision | |
| 16 | Define and enforce a lifecycle ownership invariant: | |
| 17 | ||
| 18 | - Only events in the primary thread/turn lane may mutate thread session lifecycle state (`status`, `activeTurnId`, `providerThreadId`) and checkpoint turn completion. | |
| 19 | - Auxiliary events may still append messages and activities. | |
| 20 | ||
| 21 | Current enforcement is implemented with scope guards: | |
| 22 | ||
| 23 | - Runtime ingestion only applies lifecycle transitions when the event targets the active primary scope (provider thread and active turn checks). | |
| 24 | - Checkpoint capture ignores `turn.completed` events from non-primary provider thread scope, and ignores non-active turn completions while a primary turn is active. | |
| 25 | ||
| 26 | ## Consequences | |
| 27 | ||
| 28 | ### Positive | |
| 29 | - Prevents premature loss of "working" state from auxiliary turn completion. | |
| 30 | - Prevents auxiliary changed-files/checkpoint cards from appearing as if they were primary turn completion. | |
| 31 | - Keeps useful auxiliary activity/message visibility. | |
| 32 | ||
| 33 | ### Negative | |
| 34 | - Runtime events that omit thread/turn identifiers are treated conservatively in lifecycle transitions. | |
| 35 | - Full explicit lane modeling is still desirable in contracts for long-term clarity. | |
| 36 | ||
| 37 | ## Follow-up | |
| 38 | 1. Add explicit runtime lane metadata (`primary` vs `auxiliary`) to canonical provider runtime events. | |
| 39 | 2. Persist orchestration-to-provider turn binding (`TurnId` <-> `ProviderTurnId`) for stronger reconciliation and replay safety. | |
| 40 | 3. Promote lifecycle ownership checks from heuristic guards to schema-level invariants once lane metadata is available. |
packages/contracts/src/providerRuntime.ts+2-4| ... | ... | @@ -9,15 +9,13 @@ import { |
| 9 | 9 | ProviderSessionId, |
| 10 | 10 | ProviderThreadId, |
| 11 | 11 | ProviderTurnId, |
| 12 | ThreadId, | |
| 13 | TurnId, | |
| 14 | 12 | IsoDateTime, |
| 15 | 13 | } from "./baseSchemas"; |
| 16 | 14 | import { ProviderApprovalDecision, ProviderKind, ProviderRequestKind } from "./orchestration"; |
| 17 | 15 | |
| 18 | 16 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; |
| 19 | const RuntimeThreadIdSchema = Schema.Union([ThreadId, ProviderThreadId]); | |
| 20 | const RuntimeTurnIdSchema = Schema.Union([TurnId, ProviderTurnId]); | |
| 17 | const RuntimeThreadIdSchema = ProviderThreadId; | |
| 18 | const RuntimeTurnIdSchema = ProviderTurnId; | |
| 21 | 19 | |
| 22 | 20 | export const ProviderRuntimeToolKind = Schema.Union([ProviderRequestKind, Schema.Literal("other")]); |
| 23 | 21 | export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type; |