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))),