diff --git a/apps/server/src/mcp/toolkits/environment/handlers.test.ts b/apps/server/src/mcp/toolkits/environment/handlers.test.ts index f2b3dc33e0dd..e0912f7a3340 100644 --- a/apps/server/src/mcp/toolkits/environment/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/environment/handlers.test.ts @@ -51,6 +51,8 @@ const layerEnvironment = Layer.mock(Environment.ServerEnvironment)({ it.effect("updates allowed MCP preferences without enabling automatic continuity", () => Effect.gen(function* () { + const seedSettings = yield* Settings.ServerSettingsService; + yield* seedSettings.updateSettings({ workContinuityAutoEnroll: false }); const server = yield* McpServer.McpServer; const result = yield* server .callTool({ diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index f8702a7983f9..3ce89996f4ca 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -925,6 +925,14 @@ const make = Effect.gen(function* () { )(function* (input) { const shell = input.byOwnAgent ? yield* orchestrator.getThreadShell(input.threadId) : null; if (shell != null && shell.activeRunId !== null) { + // A pending own-agent settle is a deliberate end: supervision must not + // spend a continuation check on the turn that is about to settle. + yield* dispatch({ + type: "thread.continuity.set", + commandId: CommandId.make(`${input.commandId}:continuity-off`), + threadId: input.threadId, + enabled: false, + }).pipe(Effect.ignoreCause({ log: true })); yield* settleAfterRun({ projectId: shell.projectId, threadId: shell.id, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 2be96f8529a7..b4179aa529b9 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -844,6 +844,8 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { it.effect("resolves delivery intent against the active run and starts after it completes", () => Effect.gen(function* () { + const settings = yield* ServerSettings.ServerSettingsService; + yield* settings.updateSettings({ workContinuityAutoEnroll: false }); const orchestrator = yield* Orchestrator.OrchestratorV2; const eventSink = yield* EventSink.EventSinkV2; const outbox = yield* EffectOutbox.EffectOutboxV2; @@ -3256,6 +3258,8 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { "does not admit a restart continuation of a %s run with only lost background work", (status) => Effect.gen(function* () { + const settings = yield* ServerSettings.ServerSettingsService; + yield* settings.updateSettings({ workContinuityAutoEnroll: false }); const orchestrator = yield* Orchestrator.OrchestratorV2; const eventSink = yield* EventSink.EventSinkV2; const threadId = ThreadId.make(`runtime-layer-restart-${status}-source`); @@ -5725,7 +5729,17 @@ it.layer(layerTest.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)))( yield* settings.updateSettings({ workContinuityAutoEnroll: true }); return yield* program.pipe( Effect.ensuring( - settings.updateSettings({ workContinuityAutoEnroll: false }).pipe(Effect.orDie), + settings.updateSettings({ workContinuityAutoEnroll: true }).pipe(Effect.orDie), + ), + ); + }); + const withoutAutoEnrollment = (program: Effect.Effect) => + Effect.gen(function* () { + const settings = yield* ServerSettings.ServerSettingsService; + yield* settings.updateSettings({ workContinuityAutoEnroll: false }); + return yield* program.pipe( + Effect.ensuring( + settings.updateSettings({ workContinuityAutoEnroll: true }).pipe(Effect.orDie), ), ); }); @@ -5745,13 +5759,42 @@ it.layer(layerTest.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)))( ); it.effect("disabled auto enrollment leaves accepted human messages unenrolled", () => - Effect.gen(function* () { - const input = yield* seedContinuity("auto-off", undefined, true); - const orchestrator = yield* Orchestrator.OrchestratorV2; - assert.isNull( - (yield* orchestrator.getThreadProjection(input.threadId)).thread.continuity ?? null, - ); - }), + withoutAutoEnrollment( + Effect.gen(function* () { + const input = yield* seedContinuity("auto-off", undefined, true); + const orchestrator = yield* Orchestrator.OrchestratorV2; + assert.isNull( + (yield* orchestrator.getThreadProjection(input.threadId)).thread.continuity ?? null, + ); + }), + ), + ); + + it.effect( + "auto enrollment is on by default and spends exactly one check after a missing terminal report", + () => + Effect.gen(function* () { + const input = yield* seedContinuity("auto-default", undefined, true); + const orchestrator = yield* Orchestrator.OrchestratorV2; + const before = (yield* orchestrator.getThreadProjection(input.threadId)).thread + .continuity; + assert.equal(before?.authorityMessageId, input.authorityMessageId); + assert.equal(before?.checksRemaining, 3); + const events = yield* EventSink.EventSinkV2; + const started = yield* events + .stream({ afterSequence: yield* events.latestSequence(), eventType: "run.created" }) + .pipe( + Stream.filter((stored) => stored.event.threadId === input.threadId), + Stream.take(1), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + yield* finishRun(input.threadId, input.run); + yield* Fiber.join(started); + const after = yield* orchestrator.getThreadProjection(input.threadId); + assert.lengthOf(after.runs, 2); + assert.equal(after.thread.continuity?.checksRemaining, 2); + }), ); it.effect( @@ -5803,7 +5846,7 @@ it.layer(layerTest.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)))( "enabling the setting alone does not enroll or wake existing work, but a queued human turn does", () => Effect.gen(function* () { - const input = yield* seedContinuity("auto-queued", undefined, true); + const input = yield* withoutAutoEnrollment(seedContinuity("auto-queued", undefined, true)); const orchestrator = yield* Orchestrator.OrchestratorV2; yield* withAutoEnrollment( Effect.gen(function* () { @@ -5962,7 +6005,7 @@ it.layer(layerTest.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)))( "cancelling the queued authority blocks supervision without spending or replacing its allowance", () => Effect.gen(function* () { - const input = yield* seedContinuity("auto-queued-cancel", undefined, true); + const input = yield* withoutAutoEnrollment(seedContinuity("auto-queued-cancel", undefined, true)); const orchestrator = yield* Orchestrator.OrchestratorV2; yield* withAutoEnrollment( Effect.gen(function* () { @@ -6190,6 +6233,7 @@ it.layer(layerTest.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)))( it.effect.each(["server", "mcp", "provider"] as const)( "human-shaped %s dispatch does not automatically enroll", (source) => + withoutAutoEnrollment( Effect.gen(function* () { const input = yield* seedContinuity(`auto-source-${source}`, undefined, true); const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -6211,6 +6255,7 @@ it.layer(layerTest.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)))( (yield* orchestrator.getThreadProjection(input.threadId)).thread.continuity ?? null, ); }), + ), ); it.effect.each([ "plan", @@ -6221,6 +6266,7 @@ it.layer(layerTest.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)))( "scheduled", "sender", ] as const)("%s prevents automatic continuity enrollment", (exclusion) => + withoutAutoEnrollment( Effect.gen(function* () { const input = yield* seedContinuity(`auto-excluded-${exclusion}`, undefined, true); const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -6296,6 +6342,7 @@ it.layer(layerTest.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)))( (yield* orchestrator.getThreadProjection(input.threadId)).thread.continuity ?? null, ); }), + ), ); it.effect( @@ -6479,10 +6526,10 @@ it.layer(layerTest.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)))( }) .pipe(Effect.flip); assert.instanceOf(refused, Orchestrator.OrchestratorDispatchError); - assert.equal( - (yield* orchestrator.getThreadProjection(input.threadId)).thread.continuity?.status, - "complete", - ); + const reenrolled = (yield* orchestrator.getThreadProjection(input.threadId)).thread + .continuity; + assert.equal(reenrolled?.status, "active"); + assert.equal(reenrolled?.authorityMessageId, MessageId.make(`${input.threadId}:new-request`)); }), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 375c2ac78aa1..2b8cca3edb30 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1846,7 +1846,9 @@ const layerWsRpc = ( ThreadManagementService.withCreationProvenance(command, { createdBy: "user", creationSource: - "creationSource" in command ? command.creationSource : "web", + "creationSource" in command + ? (command.creationSource ?? "web") + : "web", }), ) ).pipe(Effect.provide(intakeContext)), diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 62ad92c4f66c..afcbf1bee063 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -24,8 +24,8 @@ const encodeServerSettings = Schema.encodeSync(ServerSettings); const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); describe("work continuity auto enrollment", () => { - it("keeps automatic enrollment disabled when decoding old settings", () => { - expect(decodeServerSettings({}).workContinuityAutoEnroll).toBe(false); + it("enables automatic enrollment when decoding old settings without the key", () => { + expect(decodeServerSettings({}).workContinuityAutoEnroll).toBe(true); }); it("round-trips the environment enrollment toggle without changing other settings", () => { expect(decodeServerSettingsPatch({ workContinuityAutoEnroll: true })).toEqual({ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 881c81764181..9640e36a9295 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1081,7 +1081,7 @@ export const StorageCleanupSettings = Schema.Struct({ export type StorageCleanupSettings = typeof StorageCleanupSettings.Type; export const ServerSettings = Schema.Struct({ - workContinuityAutoEnroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + workContinuityAutoEnroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Null means no client has migrated this preference yet; an empty value is // authoritative so another device cannot resurrect cleared preferences. favorites: Schema.NullOr(ModelFavorites).pipe(Schema.withDecodingDefault(Effect.succeed(null))),