diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..d4e5f1d39f 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -5,6 +5,13 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AlarmDelivery": { + "enum": [ + "after-turn", + "steer-current-turn" + ], + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index a1e4ca7caf..2a32f56c93 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -90,6 +90,13 @@ "AgentPath": { "type": "string" }, + "AlarmDelivery": { + "enum": [ + "after-turn", + "steer-current-turn" + ], + "type": "string" + }, "AppBranding": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { @@ -2320,6 +2327,85 @@ ], "type": "string" }, + "ThreadAlarm": { + "properties": { + "createdAt": { + "format": "int64", + "type": "integer" + }, + "cronExpression": { + "type": "string" + }, + "delivery": { + "$ref": "#/definitions/AlarmDelivery" + }, + "id": { + "type": "string" + }, + "lastRunAt": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "nextRunAt": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "prompt": { + "type": "string" + }, + "runOnce": { + "type": "boolean" + } + }, + "required": [ + "createdAt", + "cronExpression", + "delivery", + "id", + "prompt", + "runOnce" + ], + "type": "object" + }, + "ThreadAlarmFiredNotification": { + "properties": { + "alarm": { + "$ref": "#/definitions/ThreadAlarm" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "alarm", + "threadId" + ], + "type": "object" + }, + "ThreadAlarmUpdatedNotification": { + "properties": { + "alarms": { + "items": { + "$ref": "#/definitions/ThreadAlarm" + }, + "type": "array" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "alarms", + "threadId" + ], + "type": "object" + }, "ThreadArchivedNotification": { "properties": { "threadId": { @@ -2985,81 +3071,6 @@ } ] }, - "ThreadJob": { - "properties": { - "createdAt": { - "format": "int64", - "type": "integer" - }, - "cronExpression": { - "type": "string" - }, - "id": { - "type": "string" - }, - "lastRunAt": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "nextRunAt": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "prompt": { - "type": "string" - }, - "runOnce": { - "type": "boolean" - } - }, - "required": [ - "createdAt", - "cronExpression", - "id", - "prompt", - "runOnce" - ], - "type": "object" - }, - "ThreadJobFiredNotification": { - "properties": { - "job": { - "$ref": "#/definitions/ThreadJob" - }, - "threadId": { - "type": "string" - } - }, - "required": [ - "job", - "threadId" - ], - "type": "object" - }, - "ThreadJobUpdatedNotification": { - "properties": { - "jobs": { - "items": { - "$ref": "#/definitions/ThreadJob" - }, - "type": "array" - }, - "threadId": { - "type": "string" - } - }, - "required": [ - "jobs", - "threadId" - ], - "type": "object" - }, "ThreadNameUpdatedNotification": { "properties": { "threadId": { @@ -4019,40 +4030,40 @@ "properties": { "method": { "enum": [ - "thread/job/updated" + "thread/alarm/updated" ], - "title": "Thread/job/updatedNotificationMethod", + "title": "Thread/alarm/updatedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ThreadJobUpdatedNotification" + "$ref": "#/definitions/ThreadAlarmUpdatedNotification" } }, "required": [ "method", "params" ], - "title": "Thread/job/updatedNotification", + "title": "Thread/alarm/updatedNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "thread/job/fired" + "thread/alarm/fired" ], - "title": "Thread/job/firedNotificationMethod", + "title": "Thread/alarm/firedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ThreadJobFiredNotification" + "$ref": "#/definitions/ThreadAlarmFiredNotification" } }, "required": [ "method", "params" ], - "title": "Thread/job/firedNotification", + "title": "Thread/alarm/firedNotification", "type": "object" }, { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 35357cdd0c..98f8ce7ee1 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -3561,40 +3561,40 @@ "properties": { "method": { "enum": [ - "thread/job/updated" + "thread/alarm/updated" ], - "title": "Thread/job/updatedNotificationMethod", + "title": "Thread/alarm/updatedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/ThreadJobUpdatedNotification" + "$ref": "#/definitions/v2/ThreadAlarmUpdatedNotification" } }, "required": [ "method", "params" ], - "title": "Thread/job/updatedNotification", + "title": "Thread/alarm/updatedNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "thread/job/fired" + "thread/alarm/fired" ], - "title": "Thread/job/firedNotificationMethod", + "title": "Thread/alarm/firedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/ThreadJobFiredNotification" + "$ref": "#/definitions/v2/ThreadAlarmFiredNotification" } }, "required": [ "method", "params" ], - "title": "Thread/job/firedNotification", + "title": "Thread/alarm/firedNotification", "type": "object" }, { @@ -4968,6 +4968,13 @@ "AgentPath": { "type": "string" }, + "AlarmDelivery": { + "enum": [ + "after-turn", + "steer-current-turn" + ], + "type": "string" + }, "AnalyticsConfig": { "additionalProperties": true, "properties": { @@ -12130,6 +12137,89 @@ ], "type": "string" }, + "ThreadAlarm": { + "properties": { + "createdAt": { + "format": "int64", + "type": "integer" + }, + "cronExpression": { + "type": "string" + }, + "delivery": { + "$ref": "#/definitions/v2/AlarmDelivery" + }, + "id": { + "type": "string" + }, + "lastRunAt": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "nextRunAt": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "prompt": { + "type": "string" + }, + "runOnce": { + "type": "boolean" + } + }, + "required": [ + "createdAt", + "cronExpression", + "delivery", + "id", + "prompt", + "runOnce" + ], + "type": "object" + }, + "ThreadAlarmFiredNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "alarm": { + "$ref": "#/definitions/v2/ThreadAlarm" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "alarm", + "threadId" + ], + "title": "ThreadAlarmFiredNotification", + "type": "object" + }, + "ThreadAlarmUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "alarms": { + "items": { + "$ref": "#/definitions/v2/ThreadAlarm" + }, + "type": "array" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "alarms", + "threadId" + ], + "title": "ThreadAlarmUpdatedNotification", + "type": "object" + }, "ThreadArchiveParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -13000,85 +13090,6 @@ } ] }, - "ThreadJob": { - "properties": { - "createdAt": { - "format": "int64", - "type": "integer" - }, - "cronExpression": { - "type": "string" - }, - "id": { - "type": "string" - }, - "lastRunAt": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "nextRunAt": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "prompt": { - "type": "string" - }, - "runOnce": { - "type": "boolean" - } - }, - "required": [ - "createdAt", - "cronExpression", - "id", - "prompt", - "runOnce" - ], - "type": "object" - }, - "ThreadJobFiredNotification": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "job": { - "$ref": "#/definitions/v2/ThreadJob" - }, - "threadId": { - "type": "string" - } - }, - "required": [ - "job", - "threadId" - ], - "title": "ThreadJobFiredNotification", - "type": "object" - }, - "ThreadJobUpdatedNotification": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "jobs": { - "items": { - "$ref": "#/definitions/v2/ThreadJob" - }, - "type": "array" - }, - "threadId": { - "type": "string" - } - }, - "required": [ - "jobs", - "threadId" - ], - "title": "ThreadJobUpdatedNotification", - "type": "object" - }, "ThreadListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index b4152bd1b6..b12c33abfa 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -142,6 +142,13 @@ "AgentPath": { "type": "string" }, + "AlarmDelivery": { + "enum": [ + "after-turn", + "steer-current-turn" + ], + "type": "string" + }, "AnalyticsConfig": { "additionalProperties": true, "properties": { @@ -8348,40 +8355,40 @@ "properties": { "method": { "enum": [ - "thread/job/updated" + "thread/alarm/updated" ], - "title": "Thread/job/updatedNotificationMethod", + "title": "Thread/alarm/updatedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ThreadJobUpdatedNotification" + "$ref": "#/definitions/ThreadAlarmUpdatedNotification" } }, "required": [ "method", "params" ], - "title": "Thread/job/updatedNotification", + "title": "Thread/alarm/updatedNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "thread/job/fired" + "thread/alarm/fired" ], - "title": "Thread/job/firedNotificationMethod", + "title": "Thread/alarm/firedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ThreadJobFiredNotification" + "$ref": "#/definitions/ThreadAlarmFiredNotification" } }, "required": [ "method", "params" ], - "title": "Thread/job/firedNotification", + "title": "Thread/alarm/firedNotification", "type": "object" }, { @@ -9976,6 +9983,89 @@ ], "type": "string" }, + "ThreadAlarm": { + "properties": { + "createdAt": { + "format": "int64", + "type": "integer" + }, + "cronExpression": { + "type": "string" + }, + "delivery": { + "$ref": "#/definitions/AlarmDelivery" + }, + "id": { + "type": "string" + }, + "lastRunAt": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "nextRunAt": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "prompt": { + "type": "string" + }, + "runOnce": { + "type": "boolean" + } + }, + "required": [ + "createdAt", + "cronExpression", + "delivery", + "id", + "prompt", + "runOnce" + ], + "type": "object" + }, + "ThreadAlarmFiredNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "alarm": { + "$ref": "#/definitions/ThreadAlarm" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "alarm", + "threadId" + ], + "title": "ThreadAlarmFiredNotification", + "type": "object" + }, + "ThreadAlarmUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "alarms": { + "items": { + "$ref": "#/definitions/ThreadAlarm" + }, + "type": "array" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "alarms", + "threadId" + ], + "title": "ThreadAlarmUpdatedNotification", + "type": "object" + }, "ThreadArchiveParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -10846,85 +10936,6 @@ } ] }, - "ThreadJob": { - "properties": { - "createdAt": { - "format": "int64", - "type": "integer" - }, - "cronExpression": { - "type": "string" - }, - "id": { - "type": "string" - }, - "lastRunAt": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "nextRunAt": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "prompt": { - "type": "string" - }, - "runOnce": { - "type": "boolean" - } - }, - "required": [ - "createdAt", - "cronExpression", - "id", - "prompt", - "runOnce" - ], - "type": "object" - }, - "ThreadJobFiredNotification": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "job": { - "$ref": "#/definitions/ThreadJob" - }, - "threadId": { - "type": "string" - } - }, - "required": [ - "job", - "threadId" - ], - "title": "ThreadJobFiredNotification", - "type": "object" - }, - "ThreadJobUpdatedNotification": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "jobs": { - "items": { - "$ref": "#/definitions/ThreadJob" - }, - "type": "array" - }, - "threadId": { - "type": "string" - } - }, - "required": [ - "jobs", - "threadId" - ], - "title": "ThreadJobUpdatedNotification", - "type": "object" - }, "ThreadListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadJobFiredNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadAlarmFiredNotification.json similarity index 73% rename from codex-rs/app-server-protocol/schema/json/v2/ThreadJobFiredNotification.json rename to codex-rs/app-server-protocol/schema/json/v2/ThreadAlarmFiredNotification.json index db63c3320c..09c0191dfc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadJobFiredNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadAlarmFiredNotification.json @@ -1,7 +1,14 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { - "ThreadJob": { + "AlarmDelivery": { + "enum": [ + "after-turn", + "steer-current-turn" + ], + "type": "string" + }, + "ThreadAlarm": { "properties": { "createdAt": { "format": "int64", @@ -10,6 +17,9 @@ "cronExpression": { "type": "string" }, + "delivery": { + "$ref": "#/definitions/AlarmDelivery" + }, "id": { "type": "string" }, @@ -37,6 +47,7 @@ "required": [ "createdAt", "cronExpression", + "delivery", "id", "prompt", "runOnce" @@ -45,17 +56,17 @@ } }, "properties": { - "job": { - "$ref": "#/definitions/ThreadJob" + "alarm": { + "$ref": "#/definitions/ThreadAlarm" }, "threadId": { "type": "string" } }, "required": [ - "job", + "alarm", "threadId" ], - "title": "ThreadJobFiredNotification", + "title": "ThreadAlarmFiredNotification", "type": "object" } \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadJobUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadAlarmUpdatedNotification.json similarity index 73% rename from codex-rs/app-server-protocol/schema/json/v2/ThreadJobUpdatedNotification.json rename to codex-rs/app-server-protocol/schema/json/v2/ThreadAlarmUpdatedNotification.json index 0a2c3523a7..b861a7b990 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadJobUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadAlarmUpdatedNotification.json @@ -1,7 +1,14 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { - "ThreadJob": { + "AlarmDelivery": { + "enum": [ + "after-turn", + "steer-current-turn" + ], + "type": "string" + }, + "ThreadAlarm": { "properties": { "createdAt": { "format": "int64", @@ -10,6 +17,9 @@ "cronExpression": { "type": "string" }, + "delivery": { + "$ref": "#/definitions/AlarmDelivery" + }, "id": { "type": "string" }, @@ -37,6 +47,7 @@ "required": [ "createdAt", "cronExpression", + "delivery", "id", "prompt", "runOnce" @@ -45,9 +56,9 @@ } }, "properties": { - "jobs": { + "alarms": { "items": { - "$ref": "#/definitions/ThreadJob" + "$ref": "#/definitions/ThreadAlarm" }, "type": "array" }, @@ -56,9 +67,9 @@ } }, "required": [ - "jobs", + "alarms", "threadId" ], - "title": "ThreadJobUpdatedNotification", + "title": "ThreadAlarmUpdatedNotification", "type": "object" } \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index 286b3169fa..0b14064db6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -34,10 +34,10 @@ import type { ReasoningTextDeltaNotification } from "./v2/ReasoningTextDeltaNoti import type { ServerRequestResolvedNotification } from "./v2/ServerRequestResolvedNotification"; import type { SkillsChangedNotification } from "./v2/SkillsChangedNotification"; import type { TerminalInteractionNotification } from "./v2/TerminalInteractionNotification"; +import type { ThreadAlarmFiredNotification } from "./v2/ThreadAlarmFiredNotification"; +import type { ThreadAlarmUpdatedNotification } from "./v2/ThreadAlarmUpdatedNotification"; import type { ThreadArchivedNotification } from "./v2/ThreadArchivedNotification"; import type { ThreadClosedNotification } from "./v2/ThreadClosedNotification"; -import type { ThreadJobFiredNotification } from "./v2/ThreadJobFiredNotification"; -import type { ThreadJobUpdatedNotification } from "./v2/ThreadJobUpdatedNotification"; import type { ThreadNameUpdatedNotification } from "./v2/ThreadNameUpdatedNotification"; import type { ThreadRealtimeClosedNotification } from "./v2/ThreadRealtimeClosedNotification"; import type { ThreadRealtimeErrorNotification } from "./v2/ThreadRealtimeErrorNotification"; @@ -59,4 +59,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/job/updated", "params": ThreadJobUpdatedNotification } | { "method": "thread/job/fired", "params": ThreadJobFiredNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/alarm/updated", "params": ThreadAlarmUpdatedNotification } | { "method": "thread/alarm/fired", "params": ThreadAlarmFiredNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadJobFiredNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AlarmDelivery.ts similarity index 54% rename from codex-rs/app-server-protocol/schema/typescript/v2/ThreadJobFiredNotification.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/AlarmDelivery.ts index 7ef9c494da..df082f0200 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadJobFiredNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AlarmDelivery.ts @@ -1,6 +1,5 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ThreadJob } from "./ThreadJob"; -export type ThreadJobFiredNotification = { threadId: string, job: ThreadJob, }; +export type AlarmDelivery = "after-turn" | "steer-current-turn"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAlarm.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAlarm.ts new file mode 100644 index 0000000000..7de104cd58 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAlarm.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AlarmDelivery } from "./AlarmDelivery"; + +export type ThreadAlarm = { id: string, cronExpression: string, prompt: string, runOnce: boolean, delivery: AlarmDelivery, createdAt: number, nextRunAt: number | null, lastRunAt: number | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadJobUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAlarmFiredNotification.ts similarity index 52% rename from codex-rs/app-server-protocol/schema/typescript/v2/ThreadJobUpdatedNotification.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/ThreadAlarmFiredNotification.ts index df15b1a350..f8110dc303 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadJobUpdatedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAlarmFiredNotification.ts @@ -1,6 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ThreadJob } from "./ThreadJob"; +import type { ThreadAlarm } from "./ThreadAlarm"; -export type ThreadJobUpdatedNotification = { threadId: string, jobs: Array, }; +export type ThreadAlarmFiredNotification = { threadId: string, alarm: ThreadAlarm, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAlarmUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAlarmUpdatedNotification.ts new file mode 100644 index 0000000000..734cd8bea1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAlarmUpdatedNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadAlarm } from "./ThreadAlarm"; + +export type ThreadAlarmUpdatedNotification = { threadId: string, alarms: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadJob.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadJob.ts deleted file mode 100644 index 9503ca07c9..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadJob.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ThreadJob = { id: string, cronExpression: string, prompt: string, runOnce: boolean, createdAt: number, nextRunAt: number | null, lastRunAt: number | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index f5aa81b88d..6ce3a82848 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -8,6 +8,7 @@ export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPerm export type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions"; export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile"; export type { AgentMessageDeltaNotification } from "./AgentMessageDeltaNotification"; +export type { AlarmDelivery } from "./AlarmDelivery"; export type { AnalyticsConfig } from "./AnalyticsConfig"; export type { AppBranding } from "./AppBranding"; export type { AppInfo } from "./AppInfo"; @@ -265,6 +266,9 @@ export type { TextPosition } from "./TextPosition"; export type { TextRange } from "./TextRange"; export type { Thread } from "./Thread"; export type { ThreadActiveFlag } from "./ThreadActiveFlag"; +export type { ThreadAlarm } from "./ThreadAlarm"; +export type { ThreadAlarmFiredNotification } from "./ThreadAlarmFiredNotification"; +export type { ThreadAlarmUpdatedNotification } from "./ThreadAlarmUpdatedNotification"; export type { ThreadArchiveParams } from "./ThreadArchiveParams"; export type { ThreadArchiveResponse } from "./ThreadArchiveResponse"; export type { ThreadArchivedNotification } from "./ThreadArchivedNotification"; @@ -274,9 +278,6 @@ export type { ThreadCompactStartResponse } from "./ThreadCompactStartResponse"; export type { ThreadForkParams } from "./ThreadForkParams"; export type { ThreadForkResponse } from "./ThreadForkResponse"; export type { ThreadItem } from "./ThreadItem"; -export type { ThreadJob } from "./ThreadJob"; -export type { ThreadJobFiredNotification } from "./ThreadJobFiredNotification"; -export type { ThreadJobUpdatedNotification } from "./ThreadJobUpdatedNotification"; export type { ThreadListParams } from "./ThreadListParams"; export type { ThreadListResponse } from "./ThreadListResponse"; export type { ThreadLoadedListParams } from "./ThreadLoadedListParams"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 5dd2ac52c6..3227cb54fc 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -317,20 +317,20 @@ client_request_definitions! { params: v2::ThreadReadParams, response: v2::ThreadReadResponse, }, - #[experimental("thread/job/create")] - ThreadJobCreate => "thread/job/create" { - params: v2::ThreadJobCreateParams, - response: v2::ThreadJobCreateResponse, + #[experimental("thread/alarm/create")] + ThreadAlarmCreate => "thread/alarm/create" { + params: v2::ThreadAlarmCreateParams, + response: v2::ThreadAlarmCreateResponse, }, - #[experimental("thread/job/delete")] - ThreadJobDelete => "thread/job/delete" { - params: v2::ThreadJobDeleteParams, - response: v2::ThreadJobDeleteResponse, + #[experimental("thread/alarm/delete")] + ThreadAlarmDelete => "thread/alarm/delete" { + params: v2::ThreadAlarmDeleteParams, + response: v2::ThreadAlarmDeleteResponse, }, - #[experimental("thread/job/list")] - ThreadJobList => "thread/job/list" { - params: v2::ThreadJobListParams, - response: v2::ThreadJobListResponse, + #[experimental("thread/alarm/list")] + ThreadAlarmList => "thread/alarm/list" { + params: v2::ThreadAlarmListParams, + response: v2::ThreadAlarmListResponse, }, SkillsList => "skills/list" { params: v2::SkillsListParams, @@ -933,10 +933,10 @@ server_notification_definitions! { ThreadClosed => "thread/closed" (v2::ThreadClosedNotification), SkillsChanged => "skills/changed" (v2::SkillsChangedNotification), ThreadNameUpdated => "thread/name/updated" (v2::ThreadNameUpdatedNotification), - #[experimental("thread/job/updated")] - ThreadJobUpdated => "thread/job/updated" (v2::ThreadJobUpdatedNotification), - #[experimental("thread/job/fired")] - ThreadJobFired => "thread/job/fired" (v2::ThreadJobFiredNotification), + #[experimental("thread/alarm/updated")] + ThreadAlarmUpdated => "thread/alarm/updated" (v2::ThreadAlarmUpdatedNotification), + #[experimental("thread/alarm/fired")] + ThreadAlarmFired => "thread/alarm/fired" (v2::ThreadAlarmFiredNotification), ThreadTokenUsageUpdated => "thread/tokenUsage/updated" (v2::ThreadTokenUsageUpdatedNotification), TurnStarted => "turn/started" (v2::TurnStartedNotification), HookStarted => "hook/started" (v2::HookStartedNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 9d12bc6c16..db4c662bc0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3149,14 +3149,23 @@ pub struct ThreadReadResponse { pub thread: Thread, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum AlarmDelivery { + AfterTurn, + SteerCurrentTurn, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct ThreadJob { +pub struct ThreadAlarm { pub id: String, pub cron_expression: String, pub prompt: String, pub run_once: bool, + pub delivery: AlarmDelivery, #[ts(type = "number")] pub created_at: i64, #[ts(type = "number | null")] @@ -3168,25 +3177,26 @@ pub struct ThreadJob { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct ThreadJobCreateParams { +pub struct ThreadAlarmCreateParams { pub thread_id: String, pub cron_expression: String, pub prompt: String, #[ts(optional = nullable)] pub run_once: Option, + pub delivery: AlarmDelivery, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct ThreadJobCreateResponse { - pub job: ThreadJob, +pub struct ThreadAlarmCreateResponse { + pub alarm: ThreadAlarm, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct ThreadJobDeleteParams { +pub struct ThreadAlarmDeleteParams { pub thread_id: String, pub id: String, } @@ -3194,38 +3204,38 @@ pub struct ThreadJobDeleteParams { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct ThreadJobDeleteResponse { +pub struct ThreadAlarmDeleteResponse { pub deleted: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct ThreadJobListParams { +pub struct ThreadAlarmListParams { pub thread_id: String, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct ThreadJobListResponse { - pub data: Vec, +pub struct ThreadAlarmListResponse { + pub data: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct ThreadJobUpdatedNotification { +pub struct ThreadAlarmUpdatedNotification { pub thread_id: String, - pub jobs: Vec, + pub alarms: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct ThreadJobFiredNotification { +pub struct ThreadAlarmFiredNotification { pub thread_id: String, - pub job: ThreadJob, + pub alarm: ThreadAlarm, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e1bc314724..7a2e036918 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -144,11 +144,11 @@ Example with notification opt-out: - `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server shuts down and unloads the thread, then emits `thread/closed`. - `thread/name/set` — set or update a thread’s user-facing name for either a loaded thread or a persisted rollout; returns `{}` on success and emits `thread/name/updated` to initialized, opted-in clients. Thread names are not required to be unique; name lookups resolve to the most recently updated thread. - `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success and emits `thread/unarchived`. -- `thread/job/create` — create a runtime-only job on a loaded thread; accepts `cronExpression`, `prompt`, and optional `runOnce`, returns the created job, and requires the experimental feature flag `job_scheduler` to be enabled for that thread. -- `thread/job/delete` — delete one runtime-only job from a loaded thread by id; returns `{}` and requires `job_scheduler`. -- `thread/job/list` — list runtime-only jobs for a loaded thread; returns `{ data }` and requires `job_scheduler`. -- `thread/job/updated` — notification emitted when a loaded thread’s job list changes, including run-once jobs deleting themselves after execution. -- `thread/job/fired` — notification emitted when a thread job fires and starts a synthetic follow-on turn. +- `thread/alarm/create` — create a runtime alarm on a loaded thread; accepts `cronExpression`, `prompt`, `delivery`, and optional `runOnce`, returns the created alarm, and requires the experimental feature flag `alarm_scheduler` to be enabled for that thread. +- `thread/alarm/delete` — delete one runtime alarm from a loaded thread by id; returns `{ deleted }` and requires `alarm_scheduler`. +- `thread/alarm/list` — list runtime alarms for a loaded thread; returns `{ data }` and requires `alarm_scheduler`. +- `thread/alarm/updated` — notification emitted when a loaded thread’s alarm list changes, including run-once alarms deleting themselves after execution or alarms restored from disk. +- `thread/alarm/fired` — notification emitted when a thread alarm fires, either for a synthetic follow-on turn or a same-turn steer delivery. - `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications. - `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ddeae12d1a..cc4cf39100 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -78,10 +78,10 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::SkillsChangedNotification; use codex_app_server_protocol::TerminalInteractionNotification; +use codex_app_server_protocol::ThreadAlarm; +use codex_app_server_protocol::ThreadAlarmFiredNotification; +use codex_app_server_protocol::ThreadAlarmUpdatedNotification; use codex_app_server_protocol::ThreadItem; -use codex_app_server_protocol::ThreadJob; -use codex_app_server_protocol::ThreadJobFiredNotification; -use codex_app_server_protocol::ThreadJobUpdatedNotification; use codex_app_server_protocol::ThreadNameUpdatedNotification; use codex_app_server_protocol::ThreadRealtimeClosedNotification; use codex_app_server_protocol::ThreadRealtimeErrorNotification; @@ -109,9 +109,9 @@ use codex_app_server_protocol::build_turns_from_rollout_items; use codex_app_server_protocol::convert_patch_changes; use codex_core::CodexThread; use codex_core::ThreadManager; +use codex_core::alarms::ALARM_FIRED_BACKGROUND_EVENT_PREFIX; +use codex_core::alarms::ALARM_UPDATED_BACKGROUND_EVENT_PREFIX; use codex_core::find_thread_name_by_id; -use codex_core::jobs::JOB_FIRED_BACKGROUND_EVENT_PREFIX; -use codex_core::jobs::JOB_UPDATED_BACKGROUND_EVENT_PREFIX; use codex_core::review_format::format_review_findings_block; use codex_core::review_prompts; use codex_protocol::ThreadId; @@ -256,12 +256,20 @@ fn guardian_auto_approval_review_notification( } } -fn thread_job_from_core(value: codex_core::jobs::ThreadJob) -> ThreadJob { - ThreadJob { +fn thread_alarm_from_core(value: codex_core::alarms::ThreadAlarm) -> ThreadAlarm { + ThreadAlarm { id: value.id, cron_expression: value.cron_expression, prompt: value.prompt, run_once: value.run_once, + delivery: match value.delivery { + codex_core::alarms::AlarmDelivery::AfterTurn => { + codex_app_server_protocol::AlarmDelivery::AfterTurn + } + codex_core::alarms::AlarmDelivery::SteerCurrentTurn => { + codex_app_server_protocol::AlarmDelivery::SteerCurrentTurn + } + }, created_at: value.created_at, next_run_at: value.next_run_at, last_run_at: value.last_run_at, @@ -1329,27 +1337,28 @@ pub(crate) async fn apply_bespoke_event_handling( EventMsg::BackgroundEvent(event) => { if let Some(payload) = event .message - .strip_prefix(JOB_UPDATED_BACKGROUND_EVENT_PREFIX) - && let Ok(jobs) = serde_json::from_str::>(payload) + .strip_prefix(ALARM_UPDATED_BACKGROUND_EVENT_PREFIX) + && let Ok(alarms) = + serde_json::from_str::>(payload) { - let notification = ThreadJobUpdatedNotification { + let notification = ThreadAlarmUpdatedNotification { thread_id: conversation_id.to_string(), - jobs: jobs.into_iter().map(thread_job_from_core).collect(), + alarms: alarms.into_iter().map(thread_alarm_from_core).collect(), }; outgoing - .send_server_notification(ServerNotification::ThreadJobUpdated(notification)) + .send_server_notification(ServerNotification::ThreadAlarmUpdated(notification)) .await; } else if let Some(payload) = event .message - .strip_prefix(JOB_FIRED_BACKGROUND_EVENT_PREFIX) - && let Ok(job) = serde_json::from_str::(payload) + .strip_prefix(ALARM_FIRED_BACKGROUND_EVENT_PREFIX) + && let Ok(alarm) = serde_json::from_str::(payload) { - let notification = ThreadJobFiredNotification { + let notification = ThreadAlarmFiredNotification { thread_id: conversation_id.to_string(), - job: thread_job_from_core(job), + alarm: thread_alarm_from_core(alarm), }; outgoing - .send_server_notification(ServerNotification::ThreadJobFired(notification)) + .send_server_notification(ServerNotification::ThreadAlarmFired(notification)) .await; } } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 47580c5adf..ff9e6fbea0 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -23,6 +23,7 @@ use chrono::Utc; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; +use codex_app_server_protocol::AlarmDelivery as ApiAlarmDelivery; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; @@ -110,6 +111,13 @@ use codex_app_server_protocol::SkillsConfigWriteResponse; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadAlarm as ApiThreadAlarm; +use codex_app_server_protocol::ThreadAlarmCreateParams; +use codex_app_server_protocol::ThreadAlarmCreateResponse; +use codex_app_server_protocol::ThreadAlarmDeleteParams; +use codex_app_server_protocol::ThreadAlarmDeleteResponse; +use codex_app_server_protocol::ThreadAlarmListParams; +use codex_app_server_protocol::ThreadAlarmListResponse; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; use codex_app_server_protocol::ThreadArchivedNotification; @@ -125,13 +133,6 @@ use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadIncrementElicitationParams; use codex_app_server_protocol::ThreadIncrementElicitationResponse; use codex_app_server_protocol::ThreadItem; -use codex_app_server_protocol::ThreadJob as ApiThreadJob; -use codex_app_server_protocol::ThreadJobCreateParams; -use codex_app_server_protocol::ThreadJobCreateResponse; -use codex_app_server_protocol::ThreadJobDeleteParams; -use codex_app_server_protocol::ThreadJobDeleteResponse; -use codex_app_server_protocol::ThreadJobListParams; -use codex_app_server_protocol::ThreadJobListResponse; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadLoadedListParams; @@ -778,16 +779,16 @@ impl CodexMessageProcessor { self.thread_read(to_connection_request_id(request_id), params) .await; } - ClientRequest::ThreadJobCreate { request_id, params } => { - self.thread_job_create(to_connection_request_id(request_id), params) + ClientRequest::ThreadAlarmCreate { request_id, params } => { + self.thread_alarm_create(to_connection_request_id(request_id), params) .await; } - ClientRequest::ThreadJobDelete { request_id, params } => { - self.thread_job_delete(to_connection_request_id(request_id), params) + ClientRequest::ThreadAlarmDelete { request_id, params } => { + self.thread_alarm_delete(to_connection_request_id(request_id), params) .await; } - ClientRequest::ThreadJobList { request_id, params } => { - self.thread_job_list(to_connection_request_id(request_id), params) + ClientRequest::ThreadAlarmList { request_id, params } => { + self.thread_alarm_list(to_connection_request_id(request_id), params) .await; } ClientRequest::ThreadShellCommand { request_id, params } => { @@ -3053,7 +3054,7 @@ impl CodexMessageProcessor { message: format!("failed to unarchive thread: {err}"), data: None, })?; - tokio::fs::rename(&canonical_rollout_path, &restored_path) + rename_rollout_with_alarm_sidecar(&canonical_rollout_path, &restored_path) .await .map_err(|err| JSONRPCErrorError { code: INTERNAL_ERROR_CODE, @@ -3593,30 +3594,36 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } - async fn thread_job_create( + async fn thread_alarm_create( &mut self, request_id: ConnectionRequestId, - params: ThreadJobCreateParams, + params: ThreadAlarmCreateParams, ) { - let ThreadJobCreateParams { + let ThreadAlarmCreateParams { thread_id, cron_expression, prompt, run_once, + delivery, } = params; - let Some(thread) = self.load_job_thread(request_id.clone(), &thread_id).await else { + let Some(thread) = self.load_alarm_thread(request_id.clone(), &thread_id).await else { return; }; match thread - .create_job(cron_expression, prompt, run_once.unwrap_or(false)) + .create_alarm( + cron_expression, + prompt, + run_once.unwrap_or(false), + alarm_delivery_to_core(delivery), + ) .await { - Ok(job) => { + Ok(alarm) => { self.outgoing .send_response( request_id, - ThreadJobCreateResponse { - job: api_thread_job_from_core(job), + ThreadAlarmCreateResponse { + alarm: api_thread_alarm_from_core(alarm), }, ) .await; @@ -3625,42 +3632,48 @@ impl CodexMessageProcessor { } } - async fn thread_job_delete( + async fn thread_alarm_delete( &mut self, request_id: ConnectionRequestId, - params: ThreadJobDeleteParams, + params: ThreadAlarmDeleteParams, ) { - let ThreadJobDeleteParams { thread_id, id } = params; - let Some(thread) = self.load_job_thread(request_id.clone(), &thread_id).await else { + let ThreadAlarmDeleteParams { thread_id, id } = params; + let Some(thread) = self.load_alarm_thread(request_id.clone(), &thread_id).await else { return; }; - let deleted = thread.delete_job(&id).await; + let deleted = match thread.delete_alarm(&id).await { + Ok(deleted) => deleted, + Err(err) => { + self.send_invalid_request_error(request_id, err).await; + return; + } + }; self.outgoing - .send_response(request_id, ThreadJobDeleteResponse { deleted }) + .send_response(request_id, ThreadAlarmDeleteResponse { deleted }) .await; } - async fn thread_job_list( + async fn thread_alarm_list( &mut self, request_id: ConnectionRequestId, - params: ThreadJobListParams, + params: ThreadAlarmListParams, ) { - let ThreadJobListParams { thread_id } = params; - let Some(thread) = self.load_job_thread(request_id.clone(), &thread_id).await else { + let ThreadAlarmListParams { thread_id } = params; + let Some(thread) = self.load_alarm_thread(request_id.clone(), &thread_id).await else { return; }; let data = thread - .list_jobs() + .list_alarms() .await .into_iter() - .map(api_thread_job_from_core) + .map(api_thread_alarm_from_core) .collect(); self.outgoing - .send_response(request_id, ThreadJobListResponse { data }) + .send_response(request_id, ThreadAlarmListResponse { data }) .await; } - async fn load_job_thread( + async fn load_alarm_thread( &mut self, request_id: ConnectionRequestId, thread_id: &str, @@ -3672,10 +3685,10 @@ impl CodexMessageProcessor { return None; } }; - if !thread.enabled(Feature::JobScheduler) { + if !thread.enabled(Feature::AlarmScheduler) { self.send_invalid_request_error( request_id, - format!("thread {thread_id} does not support job scheduling"), + format!("thread {thread_id} does not support alarm scheduling"), ) .await; return None; @@ -5532,7 +5545,7 @@ impl CodexMessageProcessor { .join(codex_core::ARCHIVED_SESSIONS_SUBDIR); tokio::fs::create_dir_all(&archive_folder).await?; let archived_path = archive_folder.join(&file_name); - tokio::fs::rename(&canonical_rollout_path, &archived_path).await?; + rename_rollout_with_alarm_sidecar(&canonical_rollout_path, &archived_path).await?; if let Some(ctx) = state_db_ctx { let _ = ctx .mark_archived(thread_id, archived_path.as_path(), Utc::now()) @@ -7690,6 +7703,21 @@ impl CodexMessageProcessor { } } +async fn rename_rollout_with_alarm_sidecar(from: &Path, to: &Path) -> std::io::Result<()> { + tokio::fs::rename(from, to).await?; + + let from_sidecar = codex_core::alarms::alarm_sidecar_path_for_rollout(from); + let to_sidecar = codex_core::alarms::alarm_sidecar_path_for_rollout(to); + match tokio::fs::rename(&from_sidecar, &to_sidecar).await { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => { + let _ = tokio::fs::rename(to, from).await; + Err(err) + } + } +} + #[allow(clippy::too_many_arguments)] async fn handle_thread_listener_command( conversation_id: ThreadId, @@ -8774,12 +8802,27 @@ fn build_thread_from_snapshot( } } -fn api_thread_job_from_core(value: codex_core::jobs::ThreadJob) -> ApiThreadJob { - ApiThreadJob { +fn alarm_delivery_to_core(value: ApiAlarmDelivery) -> codex_core::alarms::AlarmDelivery { + match value { + ApiAlarmDelivery::AfterTurn => codex_core::alarms::AlarmDelivery::AfterTurn, + ApiAlarmDelivery::SteerCurrentTurn => codex_core::alarms::AlarmDelivery::SteerCurrentTurn, + } +} + +fn api_alarm_delivery_from_core(value: codex_core::alarms::AlarmDelivery) -> ApiAlarmDelivery { + match value { + codex_core::alarms::AlarmDelivery::AfterTurn => ApiAlarmDelivery::AfterTurn, + codex_core::alarms::AlarmDelivery::SteerCurrentTurn => ApiAlarmDelivery::SteerCurrentTurn, + } +} + +fn api_thread_alarm_from_core(value: codex_core::alarms::ThreadAlarm) -> ApiThreadAlarm { + ApiThreadAlarm { id: value.id, cron_expression: value.cron_expression, prompt: value.prompt, run_once: value.run_once, + delivery: api_alarm_delivery_from_core(value.delivery), created_at: value.created_at, next_run_at: value.next_run_at, last_run_at: value.last_run_at, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2a01078c5f..e2687e4a9e 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -332,6 +332,9 @@ "default": null, "description": "Optional feature toggles scoped to this profile.", "properties": { + "alarm_scheduler": { + "type": "boolean" + }, "apply_patch_freeform": { "type": "boolean" }, @@ -407,9 +410,6 @@ "include_apply_patch_tool": { "type": "boolean" }, - "job_scheduler": { - "type": "boolean" - }, "js_repl": { "type": "boolean" }, @@ -2032,6 +2032,9 @@ "default": null, "description": "Centralized feature flags (new). Prefer this over individual toggles.", "properties": { + "alarm_scheduler": { + "type": "boolean" + }, "apply_patch_freeform": { "type": "boolean" }, @@ -2107,9 +2110,6 @@ "include_apply_patch_tool": { "type": "boolean" }, - "job_scheduler": { - "type": "boolean" - }, "js_repl": { "type": "boolean" }, diff --git a/codex-rs/core/src/alarms.rs b/codex-rs/core/src/alarms.rs new file mode 100644 index 0000000000..72fc902e62 --- /dev/null +++ b/codex-rs/core/src/alarms.rs @@ -0,0 +1,795 @@ +//! Persistent thread-local alarm scheduling for follow-on turns and same-turn steer delivery. +//! +//! This module owns the in-memory alarm registry, schedule parsing, the hidden +//! alarm prompt injected when an alarm fires, and the JSON sidecar format used +//! to restore alarms after a harness restart. + +use chrono::DateTime; +use chrono::Duration as ChronoDuration; +use chrono::TimeZone; +use chrono::Utc; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseInputItem; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use std::io::ErrorKind; +use std::path::Path; +use std::path::PathBuf; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +pub const AFTER_TURN_CRON_EXPRESSION: &str = "@after-turn"; +const EVERY_PREFIX: &str = "@every "; +const EVERY_SECONDS_PREFIX: &str = "@every:"; +pub const ALARM_UPDATED_BACKGROUND_EVENT_PREFIX: &str = "alarm_updated:"; +pub const ALARM_FIRED_BACKGROUND_EVENT_PREFIX: &str = "alarm_fired:"; +pub const MAX_ACTIVE_ALARMS_PER_THREAD: usize = 256; +const MAX_EVERY_SECONDS: u64 = i64::MAX as u64; +const ONE_SHOT_ALARM_PROMPT: &str = include_str!("../templates/alarms/one_shot_prompt.md"); +const RECURRING_ALARM_PROMPT: &str = include_str!("../templates/alarms/recurring_prompt.md"); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum AlarmDelivery { + AfterTurn, + SteerCurrentTurn, +} + +impl AlarmDelivery { + pub fn as_str(self) -> &'static str { + match self { + Self::AfterTurn => "after-turn", + Self::SteerCurrentTurn => "steer-current-turn", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ThreadAlarm { + pub id: String, + pub cron_expression: String, + pub prompt: String, + pub run_once: bool, + pub delivery: AlarmDelivery, + pub created_at: i64, + pub next_run_at: Option, + pub last_run_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AlarmInvocationContext { + pub(crate) current_alarm_id: String, + pub(crate) cron_expression: String, + pub(crate) prompt: String, + pub(crate) run_once: bool, + pub(crate) delivery: AlarmDelivery, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ClaimedAlarm { + pub(crate) alarm: ThreadAlarm, + pub(crate) context: AlarmInvocationContext, + pub(crate) deleted_run_once_alarm: bool, +} + +#[derive(Debug)] +pub(crate) struct CreateAlarm { + pub(crate) id: String, + pub(crate) cron_expression: String, + pub(crate) prompt: String, + pub(crate) run_once: bool, + pub(crate) delivery: AlarmDelivery, + pub(crate) now: DateTime, +} + +#[derive(Debug, Default)] +pub(crate) struct AlarmsState { + alarms: HashMap, +} + +#[derive(Debug)] +pub(crate) struct AlarmRuntime { + pub(crate) alarm: ThreadAlarm, + schedule: AlarmSchedule, + pending_run: bool, + pub(crate) timer_cancel: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AlarmSchedule { + AfterTurn, + EverySeconds(u64), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct PersistedAlarm { + pub(crate) alarm: ThreadAlarm, + pub(crate) pending_run: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct AlarmTimerSpec { + pub(crate) seconds: u64, + pub(crate) initial_delay: std::time::Duration, +} + +impl AlarmSchedule { + pub(crate) fn parse(cron_expression: &str) -> Result { + if cron_expression == AFTER_TURN_CRON_EXPRESSION { + return Ok(Self::AfterTurn); + } + + if let Some(seconds) = cron_expression + .strip_prefix(EVERY_PREFIX) + .map(str::trim) + .and_then(parse_duration_literal) + { + return Self::parse_every_seconds(seconds, cron_expression); + } + + if let Some(seconds) = cron_expression + .strip_prefix(EVERY_SECONDS_PREFIX) + .and_then(|raw| raw.parse::().ok()) + .filter(|seconds| *seconds > 0) + { + return Self::parse_every_seconds(seconds, cron_expression); + } + + Err(format!( + "unsupported cron_expression `{cron_expression}`; supported values are `{AFTER_TURN_CRON_EXPRESSION}`, `@every 5m`, or `@every:300`" + )) + } + + fn parse_every_seconds(seconds: u64, cron_expression: &str) -> Result { + if seconds > MAX_EVERY_SECONDS { + return Err(format!( + "unsupported cron_expression `{cron_expression}`; @every values must be between 1 and {MAX_EVERY_SECONDS} seconds" + )); + } + Ok(Self::EverySeconds(seconds)) + } + + fn next_run_at(self, now: DateTime) -> Option> { + match self { + Self::AfterTurn => None, + Self::EverySeconds(seconds) => { + now.checked_add_signed(ChronoDuration::seconds(i64::try_from(seconds).ok()?)) + } + } + } +} + +impl AlarmsState { + pub(crate) fn list_alarms(&self) -> Vec { + let mut alarms = self + .alarms + .values() + .map(|runtime| runtime.alarm.clone()) + .collect::>(); + alarms.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.id.cmp(&right.id)) + }); + alarms + } + + pub(crate) fn persisted_alarms(&self) -> Vec { + let mut alarms = self + .alarms + .values() + .map(|runtime| PersistedAlarm { + alarm: runtime.alarm.clone(), + pending_run: runtime.pending_run, + }) + .collect::>(); + alarms.sort_by(|left, right| { + left.alarm + .created_at + .cmp(&right.alarm.created_at) + .then_with(|| left.alarm.id.cmp(&right.alarm.id)) + }); + alarms + } + + pub(crate) fn create_alarm( + &mut self, + create_alarm: CreateAlarm, + timer_cancel: Option, + ) -> Result { + if self.alarms.len() >= MAX_ACTIVE_ALARMS_PER_THREAD { + return Err(format!( + "too many active alarms; each thread supports at most {MAX_ACTIVE_ALARMS_PER_THREAD} alarms" + )); + } + let CreateAlarm { + id, + cron_expression, + prompt, + run_once, + delivery, + now, + } = create_alarm; + let schedule = AlarmSchedule::parse(&cron_expression)?; + let next_run_at = match schedule { + AlarmSchedule::AfterTurn => None, + AlarmSchedule::EverySeconds(_) => Some(schedule.next_run_at(now).ok_or_else(|| { + format!( + "unsupported cron_expression `{cron_expression}`; next run time is out of range" + ) + })?), + }; + let alarm = ThreadAlarm { + id: id.clone(), + cron_expression, + prompt, + run_once, + delivery, + created_at: now.timestamp(), + next_run_at: next_run_at.map(|value| value.timestamp()), + last_run_at: None, + }; + self.alarms.insert( + id, + AlarmRuntime { + alarm: alarm.clone(), + schedule, + pending_run: matches!(schedule, AlarmSchedule::AfterTurn), + timer_cancel, + }, + ); + Ok(alarm) + } + + pub(crate) fn restore_alarm( + &mut self, + persisted: PersistedAlarm, + now: DateTime, + timer_cancel: Option, + ) -> Result, String> { + if self.alarms.len() >= MAX_ACTIVE_ALARMS_PER_THREAD { + return Err(format!( + "too many persisted alarms; each thread supports at most {MAX_ACTIVE_ALARMS_PER_THREAD} alarms" + )); + } + let schedule = AlarmSchedule::parse(&persisted.alarm.cron_expression)?; + let mut alarm = persisted.alarm; + let mut pending_run = persisted.pending_run; + let timer_spec = match schedule { + AlarmSchedule::AfterTurn => { + alarm.next_run_at = None; + None + } + AlarmSchedule::EverySeconds(seconds) => { + let initial_delay = match alarm + .next_run_at + .and_then(|timestamp| Utc.timestamp_opt(timestamp, 0).single()) + { + Some(next_run_at) if next_run_at > now => (next_run_at - now) + .to_std() + .map_err(|err| err.to_string())?, + _ => { + pending_run = true; + alarm.next_run_at = + schedule.next_run_at(now).map(|value| value.timestamp()); + std::time::Duration::from_secs(seconds) + } + }; + Some(AlarmTimerSpec { + seconds, + initial_delay, + }) + } + }; + let id = alarm.id.clone(); + self.alarms.insert( + id, + AlarmRuntime { + alarm, + schedule, + pending_run, + timer_cancel, + }, + ); + Ok(timer_spec) + } + + pub(crate) fn remove_alarm(&mut self, id: &str) -> Option { + self.alarms.remove(id) + } + + pub(crate) fn restore_runtime(&mut self, runtime: AlarmRuntime) { + self.alarms.insert(runtime.alarm.id.clone(), runtime); + } + + pub(crate) fn cancel_runtime(runtime: &AlarmRuntime) { + if let Some(cancel) = runtime.timer_cancel.as_ref() { + cancel.cancel(); + } + } + + pub(crate) fn mark_after_turn_alarms_due(&mut self) -> bool { + let mut changed = false; + for runtime in self.alarms.values_mut() { + if matches!(runtime.schedule, AlarmSchedule::AfterTurn) && !runtime.pending_run { + runtime.pending_run = true; + changed = true; + } + } + changed + } + + pub(crate) fn mark_alarm_due(&mut self, id: &str, now: DateTime) -> bool { + let Some(runtime) = self.alarms.get_mut(id) else { + return false; + }; + let mut changed = !runtime.pending_run; + runtime.pending_run = true; + let next_run_at = runtime + .schedule + .next_run_at(now) + .map(|value| value.timestamp()); + if runtime.alarm.next_run_at != next_run_at { + runtime.alarm.next_run_at = next_run_at; + changed = true; + } + changed + } + + pub(crate) fn claim_next_alarm( + &mut self, + now: DateTime, + can_after_turn: bool, + can_steer_current_turn: bool, + ) -> Option { + let (next_alarm_id, actual_delivery) = self + .alarms + .values() + .filter(|runtime| runtime.pending_run) + .filter_map(|runtime| { + let actual_delivery = match runtime.alarm.delivery { + AlarmDelivery::AfterTurn if can_after_turn => AlarmDelivery::AfterTurn, + AlarmDelivery::AfterTurn => return None, + AlarmDelivery::SteerCurrentTurn if can_steer_current_turn => { + AlarmDelivery::SteerCurrentTurn + } + AlarmDelivery::SteerCurrentTurn if can_after_turn => AlarmDelivery::AfterTurn, + AlarmDelivery::SteerCurrentTurn => return None, + }; + Some((runtime, actual_delivery)) + }) + .min_by(|(left, _), (right, _)| { + left.alarm + .last_run_at + .unwrap_or(left.alarm.created_at) + .cmp(&right.alarm.last_run_at.unwrap_or(right.alarm.created_at)) + .then_with(|| left.alarm.created_at.cmp(&right.alarm.created_at)) + .then_with(|| left.alarm.id.cmp(&right.alarm.id)) + }) + .map(|(runtime, actual_delivery)| (runtime.alarm.id.clone(), actual_delivery))?; + + let runtime = self.alarms.remove(&next_alarm_id)?; + let AlarmRuntime { + mut alarm, + schedule, + pending_run: _, + timer_cancel, + } = runtime; + let deleted_run_once_alarm = alarm.run_once; + if deleted_run_once_alarm { + if let Some(cancel) = timer_cancel.as_ref() { + cancel.cancel(); + } + } else { + alarm.last_run_at = Some(now.timestamp()); + self.alarms.insert( + alarm.id.clone(), + AlarmRuntime { + alarm: alarm.clone(), + schedule, + pending_run: false, + timer_cancel, + }, + ); + } + Some(ClaimedAlarm { + alarm: alarm.clone(), + context: AlarmInvocationContext { + current_alarm_id: alarm.id, + cron_expression: alarm.cron_expression, + prompt: alarm.prompt, + run_once: alarm.run_once, + delivery: actual_delivery, + }, + deleted_run_once_alarm, + }) + } +} + +pub(crate) fn alarm_prompt_input_item(alarm: &AlarmInvocationContext) -> ResponseInputItem { + let text = if alarm.run_once { + render_alarm_prompt_template(ONE_SHOT_ALARM_PROMPT, alarm) + } else { + render_alarm_prompt_template(RECURRING_ALARM_PROMPT, alarm) + }; + ResponseInputItem::Message { + role: "developer".to_string(), + content: vec![ContentItem::InputText { text }], + } +} + +fn render_alarm_prompt_template(template: &str, alarm: &AlarmInvocationContext) -> String { + template + .replace("{{CURRENT_ALARM_ID}}", &alarm.current_alarm_id) + .replace("{{SCHEDULE}}", &alarm.cron_expression) + .replace("{{PROMPT}}", &alarm.prompt) + .replace("{{DELIVERY}}", alarm.delivery.as_str()) + .trim_end() + .to_string() +} + +pub fn alarm_sidecar_path_for_rollout(rollout_path: &Path) -> PathBuf { + PathBuf::from(format!("{}.alarms.json", rollout_path.display())) +} + +pub(crate) async fn load_alarm_sidecar(rollout_path: &Path) -> Result, String> { + let sidecar_path = alarm_sidecar_path_for_rollout(rollout_path); + let bytes = match tokio::fs::read(&sidecar_path).await { + Ok(bytes) => bytes, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => { + return Err(format!( + "failed to read alarm sidecar `{}`: {err}", + sidecar_path.display() + )); + } + }; + serde_json::from_slice(&bytes).map_err(|err| { + format!( + "failed to parse alarm sidecar `{}`: {err}", + sidecar_path.display() + ) + }) +} + +pub(crate) async fn write_alarm_sidecar( + rollout_path: &Path, + alarms: &[PersistedAlarm], +) -> Result<(), String> { + let sidecar_path = alarm_sidecar_path_for_rollout(rollout_path); + if alarms.is_empty() { + match tokio::fs::remove_file(&sidecar_path).await { + Ok(()) => return Ok(()), + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()), + Err(err) => { + return Err(format!( + "failed to remove empty alarm sidecar `{}`: {err}", + sidecar_path.display() + )); + } + } + } + + let bytes = serde_json::to_vec_pretty(alarms).map_err(|err| { + format!( + "failed to serialize alarm sidecar `{}`: {err}", + sidecar_path.display() + ) + })?; + let tmp_path = PathBuf::from(format!("{}.tmp-{}", sidecar_path.display(), Uuid::new_v4())); + tokio::fs::write(&tmp_path, bytes).await.map_err(|err| { + format!( + "failed to write temporary alarm sidecar `{}`: {err}", + tmp_path.display() + ) + })?; + tokio::fs::rename(&tmp_path, &sidecar_path) + .await + .map_err(|err| { + format!( + "failed to atomically replace alarm sidecar `{}`: {err}", + sidecar_path.display() + ) + })?; + Ok(()) +} + +fn parse_duration_literal(raw: &str) -> Option { + let mut digits = String::new(); + let mut unit = String::new(); + for ch in raw.chars() { + if ch.is_ascii_digit() && unit.is_empty() { + digits.push(ch); + } else if !ch.is_whitespace() { + unit.push(ch); + } + } + let value = digits.parse::().ok().filter(|value| *value > 0)?; + match unit.as_str() { + "s" | "sec" | "secs" | "second" | "seconds" => Some(value), + "m" | "min" | "mins" | "minute" | "minutes" => value.checked_mul(60), + "h" | "hr" | "hrs" | "hour" | "hours" => value.checked_mul(60 * 60), + "d" | "day" | "days" => value.checked_mul(60 * 60 * 24), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::AFTER_TURN_CRON_EXPRESSION; + use super::AlarmDelivery; + use super::AlarmInvocationContext; + use super::AlarmSchedule; + use super::AlarmsState; + use super::CreateAlarm; + use super::MAX_ACTIVE_ALARMS_PER_THREAD; + use super::MAX_EVERY_SECONDS; + use super::PersistedAlarm; + use super::alarm_prompt_input_item; + use super::alarm_sidecar_path_for_rollout; + use super::load_alarm_sidecar; + use super::write_alarm_sidecar; + use chrono::DateTime; + use chrono::Duration as ChronoDuration; + use chrono::TimeZone; + use chrono::Utc; + use codex_protocol::models::ContentItem; + use codex_protocol::models::ResponseInputItem; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn parses_supported_alarm_schedules() { + assert_eq!( + AlarmSchedule::parse(AFTER_TURN_CRON_EXPRESSION), + Ok(AlarmSchedule::AfterTurn) + ); + assert_eq!( + AlarmSchedule::parse("@every 5m"), + Ok(AlarmSchedule::EverySeconds(300)) + ); + assert_eq!( + AlarmSchedule::parse("@every:3600"), + Ok(AlarmSchedule::EverySeconds(3600)) + ); + } + + #[test] + fn rejects_overflowing_every_alarm_schedules() { + let too_large = MAX_EVERY_SECONDS + 1; + assert_eq!( + AlarmSchedule::parse(&format!("@every:{too_large}")), + Err(format!( + "unsupported cron_expression `@every:{too_large}`; @every values must be between 1 and {MAX_EVERY_SECONDS} seconds" + )) + ); + assert_eq!( + AlarmSchedule::parse(&format!("@every {too_large}s")), + Err(format!( + "unsupported cron_expression `@every {too_large}s`; @every values must be between 1 and {MAX_EVERY_SECONDS} seconds" + )) + ); + } + + #[test] + fn create_alarm_rejects_every_schedule_when_next_run_at_is_out_of_range() { + let now = DateTime::::MAX_UTC - ChronoDuration::seconds(1); + let mut alarms = AlarmsState::default(); + + let result = alarms.create_alarm( + CreateAlarm { + id: "alarm-1".to_string(), + cron_expression: "@every:2".to_string(), + prompt: "overflow".to_string(), + run_once: false, + delivery: AlarmDelivery::AfterTurn, + now, + }, + /*timer_cancel*/ None, + ); + + assert_eq!( + result, + Err( + "unsupported cron_expression `@every:2`; next run time is out of range".to_string() + ) + ); + } + + #[test] + fn claim_run_once_alarm_removes_it() { + let now = Utc.timestamp_opt(100, 0).single().expect("valid timestamp"); + let mut alarms = AlarmsState::default(); + let alarm = alarms + .create_alarm( + CreateAlarm { + id: "alarm-1".to_string(), + cron_expression: AFTER_TURN_CRON_EXPRESSION.to_string(), + prompt: "run tests".to_string(), + run_once: true, + delivery: AlarmDelivery::AfterTurn, + now, + }, + /*timer_cancel*/ None, + ) + .expect("alarm should be created"); + assert_eq!(alarms.list_alarms(), vec![alarm]); + + let claimed = alarms + .claim_next_alarm( + now, /*can_after_turn*/ true, /*can_steer_current_turn*/ true, + ) + .expect("alarm should be claimed"); + assert_eq!(claimed.context.current_alarm_id, "alarm-1"); + assert!(claimed.deleted_run_once_alarm); + assert!(alarms.list_alarms().is_empty()); + } + + #[test] + fn claim_next_alarm_prefers_pending_alarm_that_ran_least_recently() { + let create_first = Utc.timestamp_opt(100, 0).single().expect("valid timestamp"); + let create_second = Utc.timestamp_opt(101, 0).single().expect("valid timestamp"); + let first_claimed_at = Utc.timestamp_opt(110, 0).single().expect("valid timestamp"); + let second_claimed_at = Utc.timestamp_opt(111, 0).single().expect("valid timestamp"); + let mut alarms = AlarmsState::default(); + alarms + .create_alarm( + CreateAlarm { + id: "alarm-1".to_string(), + cron_expression: AFTER_TURN_CRON_EXPRESSION.to_string(), + prompt: "older recurring alarm".to_string(), + run_once: false, + delivery: AlarmDelivery::AfterTurn, + now: create_first, + }, + /*timer_cancel*/ None, + ) + .expect("alarm should be created"); + alarms + .create_alarm( + CreateAlarm { + id: "alarm-2".to_string(), + cron_expression: AFTER_TURN_CRON_EXPRESSION.to_string(), + prompt: "newer recurring alarm".to_string(), + run_once: false, + delivery: AlarmDelivery::AfterTurn, + now: create_second, + }, + /*timer_cancel*/ None, + ) + .expect("alarm should be created"); + + let first = alarms + .claim_next_alarm( + first_claimed_at, + /*can_after_turn*/ true, + /*can_steer_current_turn*/ true, + ) + .expect("first alarm should be claimed"); + assert_eq!(first.context.current_alarm_id, "alarm-1"); + + alarms.mark_after_turn_alarms_due(); + + let second = alarms + .claim_next_alarm( + second_claimed_at, + /*can_after_turn*/ true, + /*can_steer_current_turn*/ true, + ) + .expect("second alarm should be claimed"); + assert_eq!(second.context.current_alarm_id, "alarm-2"); + } + + #[test] + fn create_alarm_rejects_more_than_maximum_active_alarms() { + let now = Utc.timestamp_opt(100, 0).single().expect("valid timestamp"); + let mut alarms = AlarmsState::default(); + for index in 0..MAX_ACTIVE_ALARMS_PER_THREAD { + alarms + .create_alarm( + CreateAlarm { + id: format!("alarm-{index}"), + cron_expression: AFTER_TURN_CRON_EXPRESSION.to_string(), + prompt: format!("prompt-{index}"), + run_once: false, + delivery: AlarmDelivery::AfterTurn, + now, + }, + /*timer_cancel*/ None, + ) + .expect("alarm should be created"); + } + + let result = alarms.create_alarm( + CreateAlarm { + id: "alarm-overflow".to_string(), + cron_expression: AFTER_TURN_CRON_EXPRESSION.to_string(), + prompt: "overflow".to_string(), + run_once: false, + delivery: AlarmDelivery::AfterTurn, + now, + }, + /*timer_cancel*/ None, + ); + + assert_eq!( + result, + Err(format!( + "too many active alarms; each thread supports at most {MAX_ACTIVE_ALARMS_PER_THREAD} alarms" + )) + ); + } + + #[test] + fn alarm_prompt_input_is_hidden_developer_input() { + let item = alarm_prompt_input_item(&AlarmInvocationContext { + current_alarm_id: "alarm-1".to_string(), + cron_expression: "@every 10s".to_string(), + prompt: "run tests".to_string(), + run_once: false, + delivery: AlarmDelivery::SteerCurrentTurn, + }); + assert_eq!( + item, + ResponseInputItem::Message { + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "Recurring scheduled alarm prompt:\nrun tests\n\ncurrentAlarmId: alarm-1\nConfigured delivery: steer-current-turn\nSchedule: @every 10s\n\nThis alarm should keep running on its schedule unless the user asked for a stopping condition and that condition is now satisfied.\nIf that stopping condition is satisfied, stop the alarm by calling AlarmDelete with {\"id\":\"alarm-1\"}.\nDo not expose scheduler internals unless they matter to the user.".to_string(), + }], + } + ); + } + + #[test] + fn one_shot_alarm_prompt_input_omits_delete_instruction() { + let item = alarm_prompt_input_item(&AlarmInvocationContext { + current_alarm_id: "alarm-1".to_string(), + cron_expression: "@after-turn".to_string(), + prompt: "run tests once".to_string(), + run_once: true, + delivery: AlarmDelivery::AfterTurn, + }); + assert_eq!( + item, + ResponseInputItem::Message { + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "One-shot scheduled alarm prompt:\nrun tests once\n\ncurrentAlarmId: alarm-1\nConfigured delivery: after-turn\nSchedule: @after-turn\n\nThis one-shot alarm has already been removed from the schedule, so you do not need to call AlarmDelete.\nDo not expose scheduler internals unless they matter to the user.".to_string(), + }], + } + ); + } + + #[tokio::test] + async fn alarm_sidecar_round_trips_persisted_alarms() { + let tempdir = TempDir::new().expect("tempdir"); + let rollout_path = tempdir.path().join("rollout.jsonl"); + let persisted = vec![PersistedAlarm { + alarm: super::ThreadAlarm { + id: "alarm-1".to_string(), + cron_expression: "@after-turn".to_string(), + prompt: "run tests".to_string(), + run_once: false, + delivery: AlarmDelivery::AfterTurn, + created_at: 1, + next_run_at: None, + last_run_at: None, + }, + pending_run: true, + }]; + + write_alarm_sidecar(&rollout_path, &persisted) + .await + .expect("write sidecar"); + let loaded = load_alarm_sidecar(&rollout_path) + .await + .expect("load sidecar"); + + assert_eq!(loaded, persisted); + assert_eq!( + alarm_sidecar_path_for_rollout(&rollout_path), + tempdir.path().join("rollout.jsonl.alarms.json") + ); + } +} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6f0680d23e..6f641d8cbd 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -246,6 +246,19 @@ use crate::SkillInjections; use crate::SkillLoadOutcome; use crate::SkillMetadata; use crate::SkillsManager; +use crate::alarms::ALARM_FIRED_BACKGROUND_EVENT_PREFIX; +use crate::alarms::ALARM_UPDATED_BACKGROUND_EVENT_PREFIX; +use crate::alarms::AlarmDelivery; +use crate::alarms::AlarmSchedule; +use crate::alarms::AlarmTimerSpec; +use crate::alarms::AlarmsState; +use crate::alarms::ClaimedAlarm; +use crate::alarms::CreateAlarm; +use crate::alarms::PersistedAlarm; +use crate::alarms::ThreadAlarm; +use crate::alarms::alarm_prompt_input_item; +use crate::alarms::load_alarm_sidecar; +use crate::alarms::write_alarm_sidecar; use crate::build_skill_injections; use crate::collect_env_var_dependencies; use crate::collect_explicit_skill_mentions; @@ -262,14 +275,6 @@ use crate::injection::ToolMentionKind; use crate::injection::app_id_from_path; use crate::injection::tool_kind_for_path; use crate::instructions::UserInstructions; -use crate::jobs::ClaimedJob; -use crate::jobs::JOB_FIRED_BACKGROUND_EVENT_PREFIX; -use crate::jobs::JOB_UPDATED_BACKGROUND_EVENT_PREFIX; -use crate::jobs::JobTurnContext; -use crate::jobs::JobsState; -use crate::jobs::ThreadJob; -use crate::jobs::job_prompt_input_item; -use crate::jobs::job_turn_developer_instructions; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::McpManager; use crate::mcp::auth::compute_auth_statuses; @@ -820,14 +825,14 @@ pub(crate) struct Session { pending_mcp_server_refresh_config: Mutex>, pub(crate) conversation: Arc, pub(crate) active_turn: Mutex>, - /// Prevents concurrent job timers from claiming multiple jobs before a + /// Prevents concurrent alarm timers from claiming multiple alarms before a /// newly started turn becomes the active turn. - job_start_in_progress: Mutex, + alarm_start_in_progress: Mutex, mailbox: Mailbox, mailbox_rx: Mutex, idle_pending_input: Mutex>, // TODO (jif) merge with mailbox! - jobs: Mutex, - job_timers_cancellation_token: CancellationToken, + alarms: Mutex, + alarm_timers_cancellation_token: CancellationToken, pub(crate) guardian_review_session: GuardianReviewSessionManager, pub(crate) services: SessionServices, js_repl: Arc, @@ -836,7 +841,7 @@ pub(crate) struct Session { impl Drop for Session { fn drop(&mut self) { - self.job_timers_cancellation_token.cancel(); + self.alarm_timers_cancellation_token.cancel(); } } @@ -878,7 +883,6 @@ pub(crate) struct TurnContext { pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, pub(crate) developer_instructions: Option, - pub(crate) job_context: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, pub(crate) collaboration_mode: CollaborationMode, @@ -987,7 +991,6 @@ impl TurnContext { timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), developer_instructions: self.developer_instructions.clone(), - job_context: self.job_context.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), collaboration_mode, @@ -1454,7 +1457,6 @@ impl Session { timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), developer_instructions: session_configuration.developer_instructions.clone(), - job_context: None, compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), collaboration_mode: session_configuration.collaboration_mode.clone(), @@ -1948,12 +1950,12 @@ impl Session { pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), - job_start_in_progress: Mutex::new(false), + alarm_start_in_progress: Mutex::new(false), mailbox, mailbox_rx: Mutex::new(mailbox_rx), idle_pending_input: Mutex::new(Vec::new()), - jobs: Mutex::new(JobsState::default()), - job_timers_cancellation_token: CancellationToken::new(), + alarms: Mutex::new(AlarmsState::default()), + alarm_timers_cancellation_token: CancellationToken::new(), guardian_review_session: GuardianReviewSessionManager::default(), services, js_repl, @@ -1963,6 +1965,7 @@ impl Session { let mut guard = network_policy_decider_session.write().await; *guard = Arc::downgrade(&sess); } + sess.restore_alarms_from_sidecar().await; // Dispatch the SessionConfiguredEvent first and then report any errors. // If resuming, include converted initial messages in the payload so UIs can render them immediately. let initial_messages = initial_history.get_event_msgs(); @@ -2441,7 +2444,6 @@ impl Session { session_configuration, updates.final_output_json_schema, sandbox_policy_changed, - /*job_context*/ None, ) .await) } @@ -2452,7 +2454,6 @@ impl Session { session_configuration: SessionConfiguration, final_output_json_schema: Option>, sandbox_policy_changed: bool, - job_context: Option, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); self.services @@ -2525,7 +2526,6 @@ impl Session { if let Some(final_schema) = final_output_json_schema { turn_context.final_output_json_schema = final_schema; } - turn_context.job_context = job_context; let turn_context = Arc::new(turn_context); turn_context.turn_metadata_state.spawn_git_enrichment_task(); turn_context @@ -2631,175 +2631,304 @@ impl Session { session_configuration, /*final_output_json_schema*/ None, /*sandbox_policy_changed*/ false, - /*job_context*/ None, ) .await } - pub(crate) async fn new_job_turn_with_sub_id( - &self, - sub_id: String, - job_context: JobTurnContext, - ) -> Arc { - let session_configuration = { - let state = self.state.lock().await; - state.session_configuration.clone() - }; - self.new_turn_from_configuration( - sub_id, - session_configuration, - /*final_output_json_schema*/ None, - /*sandbox_policy_changed*/ false, - Some(job_context), - ) - .await + pub(crate) async fn list_alarms(&self) -> Vec { + self.alarms.lock().await.list_alarms() } - pub(crate) async fn list_jobs(&self) -> Vec { - self.jobs.lock().await.list_jobs() - } - - pub(crate) async fn create_job( + pub(crate) async fn create_alarm( self: &Arc, cron_expression: String, prompt: String, run_once: bool, - ) -> Result { - let schedule = crate::jobs::JobSchedule::parse(&cron_expression)?; - let timer_cancel = matches!(schedule, crate::jobs::JobSchedule::EverySeconds(_)) - .then(CancellationToken::new); + delivery: AlarmDelivery, + ) -> Result { + self.ensure_rollout_materialized().await; + let Some(rollout_path) = self.current_rollout_path().await else { + return Err("alarms require a persisted rollout path for restoration".to_string()); + }; + + let schedule = AlarmSchedule::parse(&cron_expression)?; + let timer_cancel = + matches!(schedule, AlarmSchedule::EverySeconds(_)).then(CancellationToken::new); let id = uuid::Uuid::new_v4().to_string(); - let job = { - let mut jobs = self.jobs.lock().await; - jobs.create_job( - id.clone(), - cron_expression, - prompt, - run_once, - Utc::now(), + let alarm = { + let mut alarms = self.alarms.lock().await; + alarms.create_alarm( + CreateAlarm { + id: id.clone(), + cron_expression, + prompt, + run_once, + delivery, + now: Utc::now(), + }, timer_cancel.clone(), )? }; - if let (crate::jobs::JobSchedule::EverySeconds(seconds), Some(cancel)) = - (schedule, timer_cancel) - { - self.spawn_job_timer(id, seconds, cancel); - } - self.emit_job_updated_notification().await; - self.maybe_start_pending_job().await; - Ok(job) - } - - pub(crate) async fn delete_job(&self, id: &str) -> bool { - let deleted = self.jobs.lock().await.delete_job(id); - if deleted { - self.emit_job_updated_notification().await; - } - deleted - } - - pub(crate) async fn mark_after_turn_jobs_due(&self) { - self.jobs.lock().await.mark_after_turn_jobs_due(); - } - - pub(crate) async fn maybe_start_pending_job(self: &Arc) { - let has_pending_turn_inputs = self.has_queued_response_items_for_next_turn().await - || self.has_trigger_turn_mailbox_items().await; - let Some(ClaimedJob { - job, - context, - deleted_run_once_job, - }) = ({ - let mut job_start_in_progress = self.job_start_in_progress.lock().await; - let mut active_turn = self.active_turn.lock().await; - if *job_start_in_progress || active_turn.is_some() || has_pending_turn_inputs { - None - } else { - let claimed = self.jobs.lock().await.claim_next_job(Utc::now()); - if claimed.is_some() { - *job_start_in_progress = true; - *active_turn = Some(ActiveTurn::default()); - } - claimed + if let Err(err) = self.persist_alarms_to_rollout_sidecar(&rollout_path).await { + if let Some(runtime) = self.alarms.lock().await.remove_alarm(&id) { + AlarmsState::cancel_runtime(&runtime); } - }) + return Err(err); + } + + if let (AlarmSchedule::EverySeconds(seconds), Some(cancel)) = (schedule, timer_cancel) { + self.spawn_alarm_timer( + id, + AlarmTimerSpec { + seconds, + initial_delay: std::time::Duration::from_secs(seconds), + }, + cancel, + ); + } + self.emit_alarm_updated_notification().await; + self.maybe_start_pending_alarm().await; + Ok(alarm) + } + + pub(crate) async fn delete_alarm(&self, id: &str) -> Result { + let Some(runtime) = self.alarms.lock().await.remove_alarm(id) else { + return Ok(false); + }; + let Some(rollout_path) = self.current_rollout_path().await else { + self.alarms.lock().await.restore_runtime(runtime); + return Err("alarms require a persisted rollout path for restoration".to_string()); + }; + if let Err(err) = self.persist_alarms_to_rollout_sidecar(&rollout_path).await { + self.alarms.lock().await.restore_runtime(runtime); + return Err(err); + } + AlarmsState::cancel_runtime(&runtime); + self.emit_alarm_updated_notification().await; + Ok(true) + } + + pub(crate) async fn mark_after_turn_alarms_due(&self) { + let changed = self.alarms.lock().await.mark_after_turn_alarms_due(); + if changed { + self.persist_alarms_sidecar_best_effort().await; + } + } + + pub(crate) async fn maybe_start_pending_alarm(self: &Arc) { + let Some(ClaimedAlarm { + alarm, + context, + deleted_run_once_alarm, + }) = self.claim_next_alarm_for_delivery().await else { return; }; - self.emit_job_fired_notification(&job).await; - if deleted_run_once_job { - self.emit_job_updated_notification().await; + + self.emit_alarm_fired_notification(&alarm).await; + if deleted_run_once_alarm { + self.emit_alarm_updated_notification().await; } - self.queue_response_items_for_next_turn(vec![job_prompt_input_item(&context)]) - .await; - let turn_context = self - .new_job_turn_with_sub_id(uuid::Uuid::new_v4().to_string(), context) - .await; - self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) - .await; - let session = Arc::clone(self); - let result = tokio::task::spawn_blocking(move || { - futures::executor::block_on(session.start_task( - turn_context, - Vec::new(), - crate::tasks::RegularTask::new(), - )); - }) - .await; - *self.job_start_in_progress.lock().await = false; - if let Err(err) = result { - warn!("failed to start job turn: {err}"); + self.persist_alarms_sidecar_best_effort().await; + + let input_item = alarm_prompt_input_item(&context); + match context.delivery { + AlarmDelivery::SteerCurrentTurn => { + if !self.inject_alarm_into_active_turn(input_item.clone()).await { + self.queue_response_items_for_next_turn(vec![input_item]) + .await; + self.maybe_start_turn_for_pending_work().await; + } + } + AlarmDelivery::AfterTurn => { + self.queue_response_items_for_next_turn(vec![input_item]) + .await; + self.maybe_start_turn_for_pending_work().await; + } } + *self.alarm_start_in_progress.lock().await = false; } - fn spawn_job_timer( + async fn claim_next_alarm_for_delivery(self: &Arc) -> Option { + let mut alarm_start_in_progress = self.alarm_start_in_progress.lock().await; + if *alarm_start_in_progress { + return None; + } + *alarm_start_in_progress = true; + drop(alarm_start_in_progress); + + let has_pending_turn_inputs = self.has_queued_response_items_for_next_turn().await + || self.has_trigger_turn_mailbox_items().await; + + let (has_active_turn, active_turn_is_regular) = { + let active_turn = self.active_turn.lock().await; + let has_active_turn = active_turn.is_some(); + let active_turn_is_regular = active_turn + .as_ref() + .and_then(|turn| turn.tasks.first()) + .is_some_and(|(_, task)| matches!(task.kind, crate::state::TaskKind::Regular)); + (has_active_turn, active_turn_is_regular) + }; + let can_after_turn = !has_active_turn && !has_pending_turn_inputs; + let claimed = self.alarms.lock().await.claim_next_alarm( + Utc::now(), + can_after_turn, + active_turn_is_regular, + ); + if claimed.is_none() { + *self.alarm_start_in_progress.lock().await = false; + } + claimed + } + + async fn inject_alarm_into_active_turn(&self, item: ResponseInputItem) -> bool { + let turn_state = { + let active = self.active_turn.lock().await; + let Some(active_turn) = active.as_ref() else { + return false; + }; + + match active_turn.tasks.first().map(|(_, task)| task.kind) { + Some(crate::state::TaskKind::Regular) => Arc::clone(&active_turn.turn_state), + Some(crate::state::TaskKind::Review | crate::state::TaskKind::Compact) | None => { + return false; + } + } + }; + + let mut turn_state = turn_state.lock().await; + turn_state.push_pending_input(item); + true + } + + fn spawn_alarm_timer( self: &Arc, id: String, - seconds: u64, + timer_spec: AlarmTimerSpec, cancellation_token: CancellationToken, ) { let weak = Arc::downgrade(self); - let session_cancel = self.job_timers_cancellation_token.clone(); + let session_cancel = self.alarm_timers_cancellation_token.clone(); tokio::spawn(async move { + let mut delay = timer_spec.initial_delay; loop { tokio::select! { _ = session_cancel.cancelled() => break, _ = cancellation_token.cancelled() => break, - _ = tokio::time::sleep(std::time::Duration::from_secs(seconds)) => {} + _ = tokio::time::sleep(delay) => {} } let Some(session) = weak.upgrade() else { break; }; - session.jobs.lock().await.mark_job_due(&id, Utc::now()); - session.maybe_start_pending_job().await; + let changed = session.alarms.lock().await.mark_alarm_due(&id, Utc::now()); + if changed { + session.persist_alarms_sidecar_best_effort().await; + } + session.maybe_start_pending_alarm().await; + delay = std::time::Duration::from_secs(timer_spec.seconds); } }); } - async fn emit_job_updated_notification(&self) { - let jobs = self.list_jobs().await; - let Ok(payload) = serde_json::to_string(&jobs) else { - warn!("failed to serialize job update payload"); + async fn persist_alarms_to_rollout_sidecar(&self, rollout_path: &Path) -> Result<(), String> { + let alarms = self.alarms.lock().await.persisted_alarms(); + write_alarm_sidecar(rollout_path, &alarms).await + } + + async fn persist_alarms_sidecar_best_effort(&self) { + let Some(rollout_path) = self.current_rollout_path().await else { + return; + }; + if let Err(err) = self.persist_alarms_to_rollout_sidecar(&rollout_path).await { + warn!("failed to persist alarms sidecar: {err}"); + } + } + + async fn restore_alarms_from_sidecar(self: &Arc) { + let Some(rollout_path) = self.current_rollout_path().await else { + return; + }; + let persisted = match load_alarm_sidecar(&rollout_path).await { + Ok(persisted) => persisted, + Err(err) => { + warn!("{err}"); + return; + } + }; + if persisted.is_empty() { + return; + } + + let mut normalized = Vec::::new(); + for persisted_alarm in persisted { + let schedule = match AlarmSchedule::parse(&persisted_alarm.alarm.cron_expression) { + Ok(schedule) => schedule, + Err(err) => { + warn!( + "skipping invalid persisted alarm {}: {err}", + persisted_alarm.alarm.id + ); + continue; + } + }; + let timer_cancel = + matches!(schedule, AlarmSchedule::EverySeconds(_)).then(CancellationToken::new); + let timer_spec = { + let mut alarms = self.alarms.lock().await; + match alarms.restore_alarm( + persisted_alarm.clone(), + Utc::now(), + timer_cancel.clone(), + ) { + Ok(timer_spec) => { + normalized = alarms.persisted_alarms(); + timer_spec + } + Err(err) => { + warn!( + "skipping invalid persisted alarm {}: {err}", + persisted_alarm.alarm.id + ); + continue; + } + } + }; + if let (Some(timer_spec), Some(cancel)) = (timer_spec, timer_cancel) { + self.spawn_alarm_timer(persisted_alarm.alarm.id.clone(), timer_spec, cancel); + } + } + + if let Err(err) = write_alarm_sidecar(&rollout_path, &normalized).await { + warn!("failed to rewrite normalized alarm sidecar: {err}"); + } + self.emit_alarm_updated_notification().await; + self.maybe_start_pending_alarm().await; + } + + async fn emit_alarm_updated_notification(&self) { + let alarms = self.list_alarms().await; + let Ok(payload) = serde_json::to_string(&alarms) else { + warn!("failed to serialize alarm update payload"); return; }; self.send_event_raw(Event { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::BackgroundEvent(BackgroundEventEvent { - message: format!("{JOB_UPDATED_BACKGROUND_EVENT_PREFIX}{payload}"), + message: format!("{ALARM_UPDATED_BACKGROUND_EVENT_PREFIX}{payload}"), }), }) .await; } - async fn emit_job_fired_notification(&self, job: &ThreadJob) { - let Ok(payload) = serde_json::to_string(job) else { - warn!("failed to serialize job fired payload"); + async fn emit_alarm_fired_notification(&self, alarm: &ThreadAlarm) { + let Ok(payload) = serde_json::to_string(alarm) else { + warn!("failed to serialize alarm fired payload"); return; }; self.send_event_raw(Event { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::BackgroundEvent(BackgroundEventEvent { - message: format!("{JOB_FIRED_BACKGROUND_EVENT_PREFIX}{payload}"), + message: format!("{ALARM_FIRED_BACKGROUND_EVENT_PREFIX}{payload}"), }), }) .await; @@ -3789,9 +3918,6 @@ impl Session { { developer_sections.push(developer_instructions.to_string()); } - if let Some(job_context) = turn_context.job_context.as_ref() { - developer_sections.push(job_turn_developer_instructions(job_context)); - } // Add developer instructions for memories. if turn_context.features.enabled(Feature::MemoryTool) && turn_context.config.memories.use_memories @@ -5737,7 +5863,6 @@ async fn spawn_review_thread( timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), developer_instructions: None, - job_context: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), collaboration_mode: parent_turn_context.collaboration_mode.clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 652951b1b8..10494958bc 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1,5 +1,7 @@ use super::*; use crate::CodexAuth; +use crate::alarms::AlarmDelivery; +use crate::alarms::AlarmsState; use crate::config::ConfigBuilder; use crate::config::test_config; use crate::config_loader::ConfigLayerStack; @@ -12,7 +14,6 @@ use crate::config_loader::Sourced; use crate::exec::ExecCapturePolicy; use crate::exec::ExecToolCallOutput; use crate::function_tool::FunctionCallError; -use crate::jobs::JobsState; use crate::mcp_connection_manager::ToolInfo; use crate::models_manager::model_info; use crate::shell::default_user_shell; @@ -2749,12 +2750,12 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), - job_start_in_progress: Mutex::new(false), + alarm_start_in_progress: Mutex::new(false), mailbox, mailbox_rx: Mutex::new(mailbox_rx), idle_pending_input: Mutex::new(Vec::new()), - jobs: Mutex::new(JobsState::default()), - job_timers_cancellation_token: CancellationToken::new(), + alarms: Mutex::new(AlarmsState::default()), + alarm_timers_cancellation_token: CancellationToken::new(), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, js_repl, @@ -3595,12 +3596,12 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), - job_start_in_progress: Mutex::new(false), + alarm_start_in_progress: Mutex::new(false), mailbox, mailbox_rx: Mutex::new(mailbox_rx), idle_pending_input: Mutex::new(Vec::new()), - jobs: Mutex::new(JobsState::default()), - job_timers_cancellation_token: CancellationToken::new(), + alarms: Mutex::new(AlarmsState::default()), + alarm_timers_cancellation_token: CancellationToken::new(), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, js_repl, @@ -3621,9 +3622,9 @@ pub(crate) async fn make_session_and_context_with_rx() -> ( } #[tokio::test] -async fn dropping_session_cancels_job_timers() { +async fn dropping_session_cancels_alarm_timers() { let (session, _, _) = make_session_and_context_with_rx().await; - let cancel_token = session.job_timers_cancellation_token.clone(); + let cancel_token = session.alarm_timers_cancellation_token.clone(); drop(session); @@ -3631,41 +3632,52 @@ async fn dropping_session_cancels_job_timers() { } #[tokio::test] -async fn maybe_start_pending_job_claims_only_one_job_while_start_is_in_progress() { +async fn maybe_start_pending_alarm_claims_only_one_alarm_while_start_is_in_progress() { let (session, _, _) = make_session_and_context_with_rx().await; let now = chrono::Utc::now(); { - let mut jobs = session.jobs.lock().await; - jobs.create_job( - "job-1".to_string(), - crate::jobs::AFTER_TURN_CRON_EXPRESSION.to_string(), - "first".to_string(), - /*run_once*/ false, - now, - /*timer_cancel*/ None, - ) - .expect("first job should be created"); - jobs.create_job( - "job-2".to_string(), - crate::jobs::AFTER_TURN_CRON_EXPRESSION.to_string(), - "second".to_string(), - /*run_once*/ false, - now, - /*timer_cancel*/ None, - ) - .expect("second job should be created"); + let mut alarms = session.alarms.lock().await; + alarms + .create_alarm( + crate::alarms::CreateAlarm { + id: "alarm-1".to_string(), + cron_expression: crate::alarms::AFTER_TURN_CRON_EXPRESSION.to_string(), + prompt: "first".to_string(), + run_once: false, + delivery: AlarmDelivery::AfterTurn, + now, + }, + /*timer_cancel*/ None, + ) + .expect("first alarm should be created"); + alarms + .create_alarm( + crate::alarms::CreateAlarm { + id: "alarm-2".to_string(), + cron_expression: crate::alarms::AFTER_TURN_CRON_EXPRESSION.to_string(), + prompt: "second".to_string(), + run_once: false, + delivery: AlarmDelivery::AfterTurn, + now, + }, + /*timer_cancel*/ None, + ) + .expect("second alarm should be created"); } let first = Arc::clone(&session); let second = Arc::clone(&session); tokio::join!( - first.maybe_start_pending_job(), - second.maybe_start_pending_job() + first.maybe_start_pending_alarm(), + second.maybe_start_pending_alarm() ); - let jobs = session.jobs.lock().await.list_jobs(); + let alarms = session.alarms.lock().await.list_alarms(); assert_eq!( - jobs.iter().filter(|job| job.last_run_at.is_some()).count(), + alarms + .iter() + .filter(|alarm| alarm.last_run_at.is_some()) + .count(), 1 ); } diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 7e9ee15a68..319093ce2e 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -26,7 +26,8 @@ use std::path::PathBuf; use tokio::sync::Mutex; use tokio::sync::watch; -use crate::jobs::ThreadJob; +use crate::alarms::AlarmDelivery; +use crate::alarms::ThreadAlarm; use crate::state_db::StateDbHandle; #[derive(Clone, Debug)] @@ -238,24 +239,25 @@ impl CodexThread { Ok(*guard) } - pub async fn create_job( + pub async fn create_alarm( &self, cron_expression: String, prompt: String, run_once: bool, - ) -> Result { + delivery: AlarmDelivery, + ) -> Result { self.codex .session - .create_job(cron_expression, prompt, run_once) + .create_alarm(cron_expression, prompt, run_once, delivery) .await } - pub async fn delete_job(&self, id: &str) -> bool { - self.codex.session.delete_job(id).await + pub async fn delete_alarm(&self, id: &str) -> Result { + self.codex.session.delete_alarm(id).await } - pub async fn list_jobs(&self) -> Vec { - self.codex.session.list_jobs().await + pub async fn list_alarms(&self) -> Vec { + self.codex.session.list_alarms().await } } diff --git a/codex-rs/core/src/jobs.rs b/codex-rs/core/src/jobs.rs deleted file mode 100644 index 5a074ab56d..0000000000 --- a/codex-rs/core/src/jobs.rs +++ /dev/null @@ -1,514 +0,0 @@ -//! Runtime-only thread-local job scheduling for follow-on turns. -//! -//! This module owns the in-memory job registry, limited schedule parsing, and -//! the hidden turn context injected when a job fires so the model can act on -//! the scheduled prompt and deschedule itself via `JobDelete(currentJobId)`. - -use chrono::DateTime; -use chrono::Duration as ChronoDuration; -use chrono::Utc; -use codex_protocol::models::ContentItem; -use codex_protocol::models::ResponseInputItem; -use serde::Deserialize; -use serde::Serialize; -use std::collections::HashMap; -use tokio_util::sync::CancellationToken; - -pub const AFTER_TURN_CRON_EXPRESSION: &str = "@after-turn"; -const EVERY_PREFIX: &str = "@every "; -const EVERY_SECONDS_PREFIX: &str = "@every:"; -pub const JOB_UPDATED_BACKGROUND_EVENT_PREFIX: &str = "job_updated:"; -pub const JOB_FIRED_BACKGROUND_EVENT_PREFIX: &str = "job_fired:"; -pub const MAX_ACTIVE_JOBS_PER_THREAD: usize = 256; -const MAX_EVERY_SECONDS: u64 = i64::MAX as u64; -const ONE_SHOT_JOB_TURN_INSTRUCTIONS: &str = - include_str!("../templates/jobs/one_shot_turn_instructions.md"); -const RECURRING_JOB_TURN_INSTRUCTIONS: &str = - include_str!("../templates/jobs/recurring_turn_instructions.md"); -const ONE_SHOT_JOB_PROMPT: &str = include_str!("../templates/jobs/one_shot_prompt.md"); -const RECURRING_JOB_PROMPT: &str = include_str!("../templates/jobs/recurring_prompt.md"); - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ThreadJob { - pub id: String, - pub cron_expression: String, - pub prompt: String, - pub run_once: bool, - pub created_at: i64, - pub next_run_at: Option, - pub last_run_at: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct JobTurnContext { - pub(crate) current_job_id: String, - pub(crate) cron_expression: String, - pub(crate) prompt: String, - pub(crate) run_once: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ClaimedJob { - pub(crate) job: ThreadJob, - pub(crate) context: JobTurnContext, - pub(crate) deleted_run_once_job: bool, -} - -#[derive(Debug, Default)] -pub(crate) struct JobsState { - jobs: HashMap, -} - -#[derive(Debug)] -struct JobRuntime { - job: ThreadJob, - schedule: JobSchedule, - pending_run: bool, - timer_cancel: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum JobSchedule { - AfterTurn, - EverySeconds(u64), -} - -impl JobSchedule { - pub(crate) fn parse(cron_expression: &str) -> Result { - if cron_expression == AFTER_TURN_CRON_EXPRESSION { - return Ok(Self::AfterTurn); - } - - if let Some(seconds) = cron_expression - .strip_prefix(EVERY_PREFIX) - .map(str::trim) - .and_then(parse_duration_literal) - { - return Self::parse_every_seconds(seconds, cron_expression); - } - - if let Some(seconds) = cron_expression - .strip_prefix(EVERY_SECONDS_PREFIX) - .and_then(|raw| raw.parse::().ok()) - .filter(|seconds| *seconds > 0) - { - return Self::parse_every_seconds(seconds, cron_expression); - } - - Err(format!( - "unsupported cron_expression `{cron_expression}`; supported values are `{AFTER_TURN_CRON_EXPRESSION}`, `@every 5m`, or `@every:300`" - )) - } - - fn parse_every_seconds(seconds: u64, cron_expression: &str) -> Result { - if seconds > MAX_EVERY_SECONDS { - return Err(format!( - "unsupported cron_expression `{cron_expression}`; @every values must be between 1 and {MAX_EVERY_SECONDS} seconds" - )); - } - Ok(Self::EverySeconds(seconds)) - } - - fn next_run_at(self, now: DateTime) -> Option> { - match self { - Self::AfterTurn => None, - Self::EverySeconds(seconds) => { - now.checked_add_signed(ChronoDuration::seconds(i64::try_from(seconds).ok()?)) - } - } - } -} - -impl JobsState { - pub(crate) fn list_jobs(&self) -> Vec { - let mut jobs = self - .jobs - .values() - .map(|runtime| runtime.job.clone()) - .collect::>(); - jobs.sort_by(|left, right| { - left.created_at - .cmp(&right.created_at) - .then_with(|| left.id.cmp(&right.id)) - }); - jobs - } - - pub(crate) fn create_job( - &mut self, - id: String, - cron_expression: String, - prompt: String, - run_once: bool, - now: DateTime, - timer_cancel: Option, - ) -> Result { - if self.jobs.len() >= MAX_ACTIVE_JOBS_PER_THREAD { - return Err(format!( - "too many active jobs; each thread supports at most {MAX_ACTIVE_JOBS_PER_THREAD} jobs" - )); - } - let schedule = JobSchedule::parse(&cron_expression)?; - let next_run_at = match schedule { - JobSchedule::AfterTurn => None, - JobSchedule::EverySeconds(_) => Some(schedule.next_run_at(now).ok_or_else(|| { - format!( - "unsupported cron_expression `{cron_expression}`; next run time is out of range" - ) - })?), - }; - let job = ThreadJob { - id: id.clone(), - cron_expression, - prompt, - run_once, - created_at: now.timestamp(), - next_run_at: next_run_at.map(|value| value.timestamp()), - last_run_at: None, - }; - self.jobs.insert( - id, - JobRuntime { - job: job.clone(), - schedule, - pending_run: matches!(schedule, JobSchedule::AfterTurn), - timer_cancel, - }, - ); - Ok(job) - } - - pub(crate) fn delete_job(&mut self, id: &str) -> bool { - let Some(runtime) = self.jobs.remove(id) else { - return false; - }; - if let Some(cancel) = runtime.timer_cancel { - cancel.cancel(); - } - true - } - - pub(crate) fn mark_after_turn_jobs_due(&mut self) { - for runtime in self.jobs.values_mut() { - if matches!(runtime.schedule, JobSchedule::AfterTurn) { - runtime.pending_run = true; - } - } - } - - pub(crate) fn mark_job_due(&mut self, id: &str, now: DateTime) { - let Some(runtime) = self.jobs.get_mut(id) else { - return; - }; - runtime.pending_run = true; - runtime.job.next_run_at = runtime - .schedule - .next_run_at(now) - .map(|value| value.timestamp()); - } - - pub(crate) fn claim_next_job(&mut self, now: DateTime) -> Option { - let next_job_id = self - .jobs - .values() - .filter(|runtime| runtime.pending_run) - .min_by(|left, right| { - left.job - .last_run_at - .unwrap_or(left.job.created_at) - .cmp(&right.job.last_run_at.unwrap_or(right.job.created_at)) - .then_with(|| left.job.created_at.cmp(&right.job.created_at)) - .then_with(|| left.job.id.cmp(&right.job.id)) - }) - .map(|runtime| runtime.job.id.clone())?; - - let runtime = self.jobs.remove(&next_job_id)?; - let JobRuntime { - mut job, - schedule, - pending_run: _, - timer_cancel, - } = runtime; - let deleted_run_once_job = job.run_once; - if deleted_run_once_job { - if let Some(cancel) = timer_cancel { - cancel.cancel(); - } - } else { - job.last_run_at = Some(now.timestamp()); - self.jobs.insert( - job.id.clone(), - JobRuntime { - job: job.clone(), - schedule, - pending_run: false, - timer_cancel, - }, - ); - } - Some(ClaimedJob { - job: job.clone(), - context: JobTurnContext { - current_job_id: job.id, - cron_expression: job.cron_expression, - prompt: job.prompt, - run_once: job.run_once, - }, - deleted_run_once_job, - }) - } -} - -pub(crate) fn job_turn_developer_instructions(job: &JobTurnContext) -> String { - if job.run_once { - render_job_prompt_template(ONE_SHOT_JOB_TURN_INSTRUCTIONS, job) - } else { - render_job_prompt_template(RECURRING_JOB_TURN_INSTRUCTIONS, job) - } -} - -pub(crate) fn job_prompt_input_item(job: &JobTurnContext) -> ResponseInputItem { - let text = if job.run_once { - render_job_prompt_template(ONE_SHOT_JOB_PROMPT, job) - } else { - render_job_prompt_template(RECURRING_JOB_PROMPT, job) - }; - ResponseInputItem::Message { - role: "developer".to_string(), - content: vec![ContentItem::InputText { text }], - } -} - -fn render_job_prompt_template(template: &str, job: &JobTurnContext) -> String { - template - .replace("{{CURRENT_JOB_ID}}", &job.current_job_id) - .replace("{{SCHEDULE}}", &job.cron_expression) - .replace("{{PROMPT}}", &job.prompt) - .trim_end() - .to_string() -} - -fn parse_duration_literal(raw: &str) -> Option { - let mut digits = String::new(); - let mut unit = String::new(); - for ch in raw.chars() { - if ch.is_ascii_digit() && unit.is_empty() { - digits.push(ch); - } else if !ch.is_whitespace() { - unit.push(ch); - } - } - let value = digits.parse::().ok().filter(|value| *value > 0)?; - match unit.as_str() { - "s" | "sec" | "secs" | "second" | "seconds" => Some(value), - "m" | "min" | "mins" | "minute" | "minutes" => value.checked_mul(60), - "h" | "hr" | "hrs" | "hour" | "hours" => value.checked_mul(60 * 60), - "d" | "day" | "days" => value.checked_mul(60 * 60 * 24), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::AFTER_TURN_CRON_EXPRESSION; - use super::JobSchedule; - use super::JobTurnContext; - use super::JobsState; - use super::MAX_ACTIVE_JOBS_PER_THREAD; - use super::MAX_EVERY_SECONDS; - use super::job_prompt_input_item; - use chrono::DateTime; - use chrono::Duration as ChronoDuration; - use chrono::TimeZone; - use chrono::Utc; - use codex_protocol::models::ContentItem; - use codex_protocol::models::ResponseInputItem; - use pretty_assertions::assert_eq; - - #[test] - fn parses_supported_job_schedules() { - assert_eq!( - JobSchedule::parse(AFTER_TURN_CRON_EXPRESSION), - Ok(JobSchedule::AfterTurn) - ); - assert_eq!( - JobSchedule::parse("@every 5m"), - Ok(JobSchedule::EverySeconds(300)) - ); - assert_eq!( - JobSchedule::parse("@every:3600"), - Ok(JobSchedule::EverySeconds(3600)) - ); - } - - #[test] - fn rejects_overflowing_every_job_schedules() { - let too_large = MAX_EVERY_SECONDS + 1; - assert_eq!( - JobSchedule::parse(&format!("@every:{too_large}")), - Err(format!( - "unsupported cron_expression `@every:{too_large}`; @every values must be between 1 and {MAX_EVERY_SECONDS} seconds" - )) - ); - assert_eq!( - JobSchedule::parse(&format!("@every {too_large}s")), - Err(format!( - "unsupported cron_expression `@every {too_large}s`; @every values must be between 1 and {MAX_EVERY_SECONDS} seconds" - )) - ); - } - - #[test] - fn create_job_rejects_every_schedule_when_next_run_at_is_out_of_range() { - let now = DateTime::::MAX_UTC - ChronoDuration::seconds(1); - let mut jobs = JobsState::default(); - - let result = jobs.create_job( - "job-1".to_string(), - "@every:2".to_string(), - "overflow".to_string(), - /*run_once*/ false, - now, - /*timer_cancel*/ None, - ); - - assert_eq!( - result, - Err( - "unsupported cron_expression `@every:2`; next run time is out of range".to_string() - ) - ); - } - - #[test] - fn claim_run_once_job_removes_it() { - let now = Utc.timestamp_opt(100, 0).single().expect("valid timestamp"); - let mut jobs = JobsState::default(); - let job = jobs - .create_job( - "job-1".to_string(), - AFTER_TURN_CRON_EXPRESSION.to_string(), - "run tests".to_string(), - /*run_once*/ true, - now, - /*timer_cancel*/ None, - ) - .expect("job should be created"); - assert_eq!(jobs.list_jobs(), vec![job]); - - let claimed = jobs.claim_next_job(now).expect("job should be claimed"); - assert_eq!(claimed.context.current_job_id, "job-1"); - assert!(claimed.deleted_run_once_job); - assert!(jobs.list_jobs().is_empty()); - } - - #[test] - fn claim_next_job_prefers_pending_job_that_ran_least_recently() { - let create_first = Utc.timestamp_opt(100, 0).single().expect("valid timestamp"); - let create_second = Utc.timestamp_opt(101, 0).single().expect("valid timestamp"); - let first_claimed_at = Utc.timestamp_opt(110, 0).single().expect("valid timestamp"); - let second_claimed_at = Utc.timestamp_opt(111, 0).single().expect("valid timestamp"); - let mut jobs = JobsState::default(); - jobs.create_job( - "job-1".to_string(), - AFTER_TURN_CRON_EXPRESSION.to_string(), - "older recurring job".to_string(), - /*run_once*/ false, - create_first, - /*timer_cancel*/ None, - ) - .expect("job should be created"); - jobs.create_job( - "job-2".to_string(), - AFTER_TURN_CRON_EXPRESSION.to_string(), - "newer recurring job".to_string(), - /*run_once*/ false, - create_second, - /*timer_cancel*/ None, - ) - .expect("job should be created"); - - let first = jobs - .claim_next_job(first_claimed_at) - .expect("first job should be claimed"); - assert_eq!(first.context.current_job_id, "job-1"); - - jobs.mark_after_turn_jobs_due(); - - let second = jobs - .claim_next_job(second_claimed_at) - .expect("second job should be claimed"); - assert_eq!(second.context.current_job_id, "job-2"); - } - - #[test] - fn create_job_rejects_more_than_maximum_active_jobs() { - let now = Utc.timestamp_opt(100, 0).single().expect("valid timestamp"); - let mut jobs = JobsState::default(); - for index in 0..MAX_ACTIVE_JOBS_PER_THREAD { - jobs.create_job( - format!("job-{index}"), - AFTER_TURN_CRON_EXPRESSION.to_string(), - format!("prompt-{index}"), - /*run_once*/ false, - now, - /*timer_cancel*/ None, - ) - .expect("job should be created"); - } - - let result = jobs.create_job( - "job-overflow".to_string(), - AFTER_TURN_CRON_EXPRESSION.to_string(), - "overflow".to_string(), - /*run_once*/ false, - now, - /*timer_cancel*/ None, - ); - - assert_eq!( - result, - Err(format!( - "too many active jobs; each thread supports at most {MAX_ACTIVE_JOBS_PER_THREAD} jobs" - )) - ); - } - - #[test] - fn job_prompt_input_is_hidden_developer_input() { - let item = job_prompt_input_item(&JobTurnContext { - current_job_id: "job-1".to_string(), - cron_expression: "@every 10s".to_string(), - prompt: "run tests".to_string(), - run_once: false, - }); - assert_eq!( - item, - ResponseInputItem::Message { - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "Recurring scheduled job prompt:\nrun tests\n\nThis job should keep running on its schedule unless the user asked for a stopping condition and that condition is now satisfied.\nIf that stopping condition is satisfied, stop the job by calling JobDelete with {\"id\":\"job-1\"}.".to_string(), - }], - } - ); - } - - #[test] - fn one_shot_job_prompt_input_omits_delete_instruction() { - let item = job_prompt_input_item(&JobTurnContext { - current_job_id: "job-1".to_string(), - cron_expression: "@after-turn".to_string(), - prompt: "run tests once".to_string(), - run_once: true, - }); - assert_eq!( - item, - ResponseInputItem::Message { - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "One-shot scheduled job prompt:\nrun tests once".to_string(), - }], - } - ); - } -} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index c151bd4e8d..171b9e06e4 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -31,6 +31,7 @@ pub mod connectors; mod context_manager; mod contextual_user_message; pub use codex_utils_path::env; +pub mod alarms; mod environment_context; pub mod error; pub mod exec; @@ -44,7 +45,6 @@ mod git_info_tests; mod guardian; mod hook_runtime; pub mod instructions; -pub mod jobs; pub mod landlock; pub mod mcp; mod mcp_connection_manager; diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index ac7b7f80a2..1f6b3b52f1 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -453,17 +453,16 @@ impl Session { last_agent_message, }); self.send_event(turn_context.as_ref(), event).await; - self.mark_after_turn_jobs_due().await; - self.maybe_start_pending_job().await; - - if should_clear_active_turn { - let session = Arc::clone(self); - let _scheduler = tokio::task::spawn_blocking(move || { - tokio::runtime::Handle::current().block_on(async move { + let session = Arc::clone(self); + let _scheduler = tokio::task::spawn_blocking(move || { + tokio::runtime::Handle::current().block_on(async move { + session.mark_after_turn_alarms_due().await; + session.maybe_start_pending_alarm().await; + if should_clear_active_turn { session.maybe_start_turn_for_pending_work().await; - }); + } }); - } + }); } async fn take_active_turn(&self) -> Option { diff --git a/codex-rs/core/src/tools/handlers/jobs.rs b/codex-rs/core/src/tools/handlers/alarms.rs similarity index 63% rename from codex-rs/core/src/tools/handlers/jobs.rs rename to codex-rs/core/src/tools/handlers/alarms.rs index b55d64c192..2c9343c900 100644 --- a/codex-rs/core/src/tools/handlers/jobs.rs +++ b/codex-rs/core/src/tools/handlers/alarms.rs @@ -1,11 +1,12 @@ -//! Built-in tool handlers for thread-local runtime job management. +//! Built-in tool handlers for thread-local persistent alarm management. //! -//! These handlers bridge `JobCreate`, `JobDelete`, and `JobList` tool calls -//! onto the current thread session's in-memory job registry. +//! These handlers bridge `AlarmCreate`, `AlarmDelete`, and `AlarmList` tool +//! calls onto the current thread session's alarm registry. use async_trait::async_trait; use serde::Deserialize; +use crate::alarms::AlarmDelivery; use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; @@ -15,21 +16,22 @@ use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; #[derive(Deserialize)] -struct JobCreateArgs { +struct AlarmCreateArgs { cron_expression: String, prompt: String, run_once: Option, + delivery: AlarmDelivery, } #[derive(Deserialize)] -struct JobDeleteArgs { +struct AlarmDeleteArgs { id: String, } -pub struct JobCreateHandler; +pub struct AlarmCreateHandler; #[async_trait] -impl ToolHandler for JobCreateHandler { +impl ToolHandler for AlarmCreateHandler { type Output = FunctionToolOutput; fn kind(&self) -> ToolKind { @@ -39,30 +41,31 @@ impl ToolHandler for JobCreateHandler { async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolPayload::Function { arguments } = invocation.payload else { return Err(FunctionCallError::RespondToModel( - "JobCreate received unsupported payload".to_string(), + "AlarmCreate received unsupported payload".to_string(), )); }; - let args: JobCreateArgs = parse_arguments(&arguments)?; - let job = invocation + let args: AlarmCreateArgs = parse_arguments(&arguments)?; + let alarm = invocation .session - .create_job( + .create_alarm( args.cron_expression, args.prompt, args.run_once.unwrap_or(false), + args.delivery, ) .await .map_err(FunctionCallError::RespondToModel)?; - let content = serde_json::to_string(&job).map_err(|err| { - FunctionCallError::Fatal(format!("failed to serialize JobCreate response: {err}")) + let content = serde_json::to_string(&alarm).map_err(|err| { + FunctionCallError::Fatal(format!("failed to serialize AlarmCreate response: {err}")) })?; Ok(FunctionToolOutput::from_text(content, Some(true))) } } -pub struct JobDeleteHandler; +pub struct AlarmDeleteHandler; #[async_trait] -impl ToolHandler for JobDeleteHandler { +impl ToolHandler for AlarmDeleteHandler { type Output = FunctionToolOutput; fn kind(&self) -> ToolKind { @@ -72,20 +75,24 @@ impl ToolHandler for JobDeleteHandler { async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolPayload::Function { arguments } = invocation.payload else { return Err(FunctionCallError::RespondToModel( - "JobDelete received unsupported payload".to_string(), + "AlarmDelete received unsupported payload".to_string(), )); }; - let args: JobDeleteArgs = parse_arguments(&arguments)?; - let deleted = invocation.session.delete_job(&args.id).await; + let args: AlarmDeleteArgs = parse_arguments(&arguments)?; + let deleted = invocation + .session + .delete_alarm(&args.id) + .await + .map_err(FunctionCallError::RespondToModel)?; let content = serde_json::json!({ "deleted": deleted }).to_string(); Ok(FunctionToolOutput::from_text(content, Some(deleted))) } } -pub struct JobListHandler; +pub struct AlarmListHandler; #[async_trait] -impl ToolHandler for JobListHandler { +impl ToolHandler for AlarmListHandler { type Output = FunctionToolOutput; fn kind(&self) -> ToolKind { @@ -97,13 +104,13 @@ impl ToolHandler for JobListHandler { ToolPayload::Function { .. } => {} _ => { return Err(FunctionCallError::RespondToModel( - "JobList received unsupported payload".to_string(), + "AlarmList received unsupported payload".to_string(), )); } } - let jobs = invocation.session.list_jobs().await; - let content = serde_json::to_string(&jobs).map_err(|err| { - FunctionCallError::Fatal(format!("failed to serialize JobList response: {err}")) + let alarms = invocation.session.list_alarms().await; + let content = serde_json::to_string(&alarms).map_err(|err| { + FunctionCallError::Fatal(format!("failed to serialize AlarmList response: {err}")) })?; Ok(FunctionToolOutput::from_text(content, Some(true))) } diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 06455cdac9..23f9510927 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -1,7 +1,7 @@ pub(crate) mod agent_jobs; +mod alarms; pub mod apply_patch; mod dynamic; -mod jobs; mod js_repl; mod list_dir; mod mcp; @@ -34,13 +34,13 @@ use crate::function_tool::FunctionCallError; use crate::sandboxing::SandboxPermissions; pub(crate) use crate::tools::code_mode::CodeModeExecuteHandler; pub(crate) use crate::tools::code_mode::CodeModeWaitHandler; +pub use alarms::AlarmCreateHandler; +pub use alarms::AlarmDeleteHandler; +pub use alarms::AlarmListHandler; pub use apply_patch::ApplyPatchHandler; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; pub use dynamic::DynamicToolHandler; -pub use jobs::JobCreateHandler; -pub use jobs::JobDeleteHandler; -pub use jobs::JobListHandler; pub use js_repl::JsReplHandler; pub use js_repl::JsReplResetHandler; pub use list_dir::ListDirHandler; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 48412ddfa5..66870ee9b4 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -7,9 +7,9 @@ use crate::shell::Shell; use crate::shell::ShellType; use crate::tools::code_mode::PUBLIC_TOOL_NAME; use crate::tools::code_mode::WAIT_TOOL_NAME; -use crate::tools::handlers::JobCreateHandler; -use crate::tools::handlers::JobDeleteHandler; -use crate::tools::handlers::JobListHandler; +use crate::tools::handlers::AlarmCreateHandler; +use crate::tools::handlers::AlarmDeleteHandler; +use crate::tools::handlers::AlarmListHandler; use crate::tools::handlers::PLAN_TOOL; use crate::tools::handlers::TOOL_SEARCH_DEFAULT_LIMIT; use crate::tools::handlers::TOOL_SEARCH_TOOL_NAME; @@ -49,14 +49,14 @@ use codex_tools::ToolSuggestEntry; use codex_tools::ViewImageToolOptions; use codex_tools::WaitAgentTimeoutOptions; use codex_tools::augment_tool_spec_for_code_mode; +use codex_tools::create_alarm_create_tool; +use codex_tools::create_alarm_delete_tool; +use codex_tools::create_alarm_list_tool; use codex_tools::create_assign_task_tool; use codex_tools::create_close_agent_tool_v1; use codex_tools::create_close_agent_tool_v2; use codex_tools::create_code_mode_tool; use codex_tools::create_exec_command_tool; -use codex_tools::create_job_create_tool; -use codex_tools::create_job_delete_tool; -use codex_tools::create_job_list_tool; use codex_tools::create_js_repl_reset_tool; use codex_tools::create_js_repl_tool; use codex_tools::create_list_agents_tool; @@ -173,7 +173,7 @@ pub(crate) struct ToolsConfig { pub collab_tools: bool, pub multi_agent_v2: bool, pub request_user_input: bool, - pub job_scheduler: bool, + pub alarm_scheduler: bool, pub default_mode_request_user_input: bool, pub experimental_supported_tools: Vec, pub agent_jobs_tools: bool, @@ -224,7 +224,7 @@ impl ToolsConfig { let include_multi_agent_v2 = features.enabled(Feature::MultiAgentV2); let include_agent_jobs = features.enabled(Feature::SpawnCsv); let include_request_user_input = !matches!(session_source, SessionSource::SubAgent(_)); - let include_job_scheduler = features.enabled(Feature::JobScheduler); + let include_alarm_scheduler = features.enabled(Feature::AlarmScheduler); let include_default_mode_request_user_input = include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput); let include_search_tool = @@ -309,7 +309,7 @@ impl ToolsConfig { collab_tools: include_collab_tools, multi_agent_v2: include_multi_agent_v2, request_user_input: include_request_user_input, - job_scheduler: include_job_scheduler, + alarm_scheduler: include_alarm_scheduler, default_mode_request_user_input: include_default_mode_request_user_input, experimental_supported_tools: model_info.experimental_supported_tools.clone(), agent_jobs_tools: include_agent_jobs, @@ -635,28 +635,28 @@ pub(crate) fn build_specs_with_discoverable_tools( builder.register_handler("request_permissions", request_permissions_handler); } - if config.job_scheduler { + if config.alarm_scheduler { push_tool_spec( &mut builder, - create_job_create_tool(), + create_alarm_create_tool(), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); push_tool_spec( &mut builder, - create_job_delete_tool(), + create_alarm_delete_tool(), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); push_tool_spec( &mut builder, - create_job_list_tool(), + create_alarm_list_tool(), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - builder.register_handler("JobCreate", Arc::new(JobCreateHandler)); - builder.register_handler("JobDelete", Arc::new(JobDeleteHandler)); - builder.register_handler("JobList", Arc::new(JobListHandler)); + builder.register_handler("AlarmCreate", Arc::new(AlarmCreateHandler)); + builder.register_handler("AlarmDelete", Arc::new(AlarmDeleteHandler)); + builder.register_handler("AlarmList", Arc::new(AlarmListHandler)); } if config.search_tool diff --git a/codex-rs/core/templates/alarms/one_shot_prompt.md b/codex-rs/core/templates/alarms/one_shot_prompt.md new file mode 100644 index 0000000000..1682ef58f0 --- /dev/null +++ b/codex-rs/core/templates/alarms/one_shot_prompt.md @@ -0,0 +1,9 @@ +One-shot scheduled alarm prompt: +{{PROMPT}} + +currentAlarmId: {{CURRENT_ALARM_ID}} +Configured delivery: {{DELIVERY}} +Schedule: {{SCHEDULE}} + +This one-shot alarm has already been removed from the schedule, so you do not need to call AlarmDelete. +Do not expose scheduler internals unless they matter to the user. diff --git a/codex-rs/core/templates/alarms/recurring_prompt.md b/codex-rs/core/templates/alarms/recurring_prompt.md new file mode 100644 index 0000000000..7654603fcf --- /dev/null +++ b/codex-rs/core/templates/alarms/recurring_prompt.md @@ -0,0 +1,10 @@ +Recurring scheduled alarm prompt: +{{PROMPT}} + +currentAlarmId: {{CURRENT_ALARM_ID}} +Configured delivery: {{DELIVERY}} +Schedule: {{SCHEDULE}} + +This alarm should keep running on its schedule unless the user asked for a stopping condition and that condition is now satisfied. +If that stopping condition is satisfied, stop the alarm by calling AlarmDelete with {"id":"{{CURRENT_ALARM_ID}}"}. +Do not expose scheduler internals unless they matter to the user. diff --git a/codex-rs/core/templates/jobs/one_shot_prompt.md b/codex-rs/core/templates/jobs/one_shot_prompt.md deleted file mode 100644 index 498497ac21..0000000000 --- a/codex-rs/core/templates/jobs/one_shot_prompt.md +++ /dev/null @@ -1,2 +0,0 @@ -One-shot scheduled job prompt: -{{PROMPT}} diff --git a/codex-rs/core/templates/jobs/one_shot_turn_instructions.md b/codex-rs/core/templates/jobs/one_shot_turn_instructions.md deleted file mode 100644 index c0e600a625..0000000000 --- a/codex-rs/core/templates/jobs/one_shot_turn_instructions.md +++ /dev/null @@ -1,7 +0,0 @@ -This turn was triggered by a one-shot thread job. -currentJobId: {{CURRENT_JOB_ID}} -Schedule: {{SCHEDULE}} - -The job prompt has already been injected as hidden context for this turn. -This one-shot job has already been removed from the schedule, so you do not need to call JobDelete. -Do not expose scheduler internals unless they matter to the user. diff --git a/codex-rs/core/templates/jobs/recurring_prompt.md b/codex-rs/core/templates/jobs/recurring_prompt.md deleted file mode 100644 index 2c07238278..0000000000 --- a/codex-rs/core/templates/jobs/recurring_prompt.md +++ /dev/null @@ -1,5 +0,0 @@ -Recurring scheduled job prompt: -{{PROMPT}} - -This job should keep running on its schedule unless the user asked for a stopping condition and that condition is now satisfied. -If that stopping condition is satisfied, stop the job by calling JobDelete with {"id":"{{CURRENT_JOB_ID}}"}. diff --git a/codex-rs/core/templates/jobs/recurring_turn_instructions.md b/codex-rs/core/templates/jobs/recurring_turn_instructions.md deleted file mode 100644 index 4de2c88574..0000000000 --- a/codex-rs/core/templates/jobs/recurring_turn_instructions.md +++ /dev/null @@ -1,8 +0,0 @@ -This turn was triggered by a recurring thread job. -currentJobId: {{CURRENT_JOB_ID}} -Schedule: {{SCHEDULE}} - -The job prompt has already been injected as hidden context for this turn. -Do not delete this recurring job just because you completed one run. -Only call JobDelete with the exact arguments {"id":"{{CURRENT_JOB_ID}}"} if the user explicitly asked for a stopping condition and that condition is now satisfied, or if the user explicitly asked to stop the recurring job. -Do not expose scheduler internals unless they matter to the user. diff --git a/codex-rs/core/tests/suite/jobs.rs b/codex-rs/core/tests/suite/jobs.rs index e336d50198..a244e0ffd5 100644 --- a/codex-rs/core/tests/suite/jobs.rs +++ b/codex-rs/core/tests/suite/jobs.rs @@ -1,8 +1,9 @@ use anyhow::Result; use anyhow::anyhow; -use codex_core::jobs::AFTER_TURN_CRON_EXPRESSION; -use codex_core::jobs::JOB_FIRED_BACKGROUND_EVENT_PREFIX; -use codex_core::jobs::ThreadJob; +use codex_core::alarms::AFTER_TURN_CRON_EXPRESSION; +use codex_core::alarms::ALARM_FIRED_BACKGROUND_EVENT_PREFIX; +use codex_core::alarms::AlarmDelivery; +use codex_core::alarms::ThreadAlarm; use codex_features::Feature; use codex_protocol::protocol::EventMsg; use core_test_support::responses::ev_assistant_message; @@ -17,22 +18,22 @@ use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn create_job_emits_fired_background_event_when_job_starts() -> Result<()> { - assert_after_turn_job_starts_and_emits_fired_event().await +async fn create_alarm_emits_fired_background_event_when_alarm_starts() -> Result<()> { + assert_after_turn_alarm_starts_and_emits_fired_event().await } #[tokio::test(flavor = "current_thread")] -async fn create_job_starts_on_current_thread_runtime() -> Result<()> { - assert_after_turn_job_starts_and_emits_fired_event().await +async fn create_alarm_starts_on_current_thread_runtime() -> Result<()> { + assert_after_turn_alarm_starts_and_emits_fired_event().await } -async fn assert_after_turn_job_starts_and_emits_fired_event() -> Result<()> { +async fn assert_after_turn_alarm_starts_and_emits_fired_event() -> Result<()> { let server = start_mock_server().await; let _mock = mount_sse_once( &server, sse(vec![ ev_response_created("resp-1"), - ev_assistant_message("msg-1", "job ran"), + ev_assistant_message("msg-1", "alarm ran"), ev_completed("resp-1"), ]), ) @@ -41,17 +42,18 @@ async fn assert_after_turn_job_starts_and_emits_fired_event() -> Result<()> { let mut builder = test_codex().with_config(|config| { config .features - .enable(Feature::JobScheduler) + .enable(Feature::AlarmScheduler) .unwrap_or_else(|err| panic!("test config should allow feature update: {err}")); }); let test = builder.build(&server).await?; let created = test .codex - .create_job( + .create_alarm( AFTER_TURN_CRON_EXPRESSION.to_string(), - "run job".to_string(), + "run alarm".to_string(), /*run_once*/ false, + AlarmDelivery::AfterTurn, ) .await .map_err(|err| anyhow!("{err}"))?; @@ -59,17 +61,17 @@ async fn assert_after_turn_job_starts_and_emits_fired_event() -> Result<()> { let payload = wait_for_event_match(&test.codex, |event| match event { EventMsg::BackgroundEvent(event) => event .message - .strip_prefix(JOB_FIRED_BACKGROUND_EVENT_PREFIX) + .strip_prefix(ALARM_FIRED_BACKGROUND_EVENT_PREFIX) .map(str::to_owned), _ => None, }) .await; - let fired: ThreadJob = serde_json::from_str(&payload)?; - let expected = ThreadJob { + let fired: ThreadAlarm = serde_json::from_str(&payload)?; + let expected = ThreadAlarm { last_run_at: Some( fired .last_run_at - .ok_or_else(|| anyhow!("claimed job should record last_run_at"))?, + .ok_or_else(|| anyhow!("claimed alarm should record last_run_at"))?, ), ..created }; diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 56c7b60654..84e6dbdb42 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -172,8 +172,8 @@ pub enum Feature { Personality, /// Enable native artifact tools. Artifact, - /// Enable per-thread runtime job scheduling tools and APIs. - JobScheduler, + /// Enable per-thread persistent alarm scheduling tools and APIs. + AlarmScheduler, /// Enable Fast mode selection in the TUI and request layer. FastMode, /// Enable experimental realtime voice conversation mode in the TUI. @@ -816,11 +816,11 @@ pub const FEATURES: &[FeatureSpec] = &[ default_enabled: false, }, FeatureSpec { - id: Feature::JobScheduler, - key: "job_scheduler", + id: Feature::AlarmScheduler, + key: "alarm_scheduler", stage: Stage::Experimental { - name: "Job Scheduler", - menu_description: "Enable thread-local scheduled jobs and the /loop command.", + name: "Alarm Scheduler", + menu_description: "Enable thread-local scheduled alarms and the /loop command.", announcement: "", }, default_enabled: false, diff --git a/codex-rs/tools/src/job_tool.rs b/codex-rs/tools/src/alarm_tool.rs similarity index 51% rename from codex-rs/tools/src/job_tool.rs rename to codex-rs/tools/src/alarm_tool.rs index d4c19b4c02..33de83be95 100644 --- a/codex-rs/tools/src/job_tool.rs +++ b/codex-rs/tools/src/alarm_tool.rs @@ -1,20 +1,21 @@ -//! Responses API tool specs for thread-local runtime job management. +//! Responses API tool specs for thread-local persistent alarm management. //! -//! These specs expose the `JobCreate`, `JobDelete`, and `JobList` built-in -//! tools so models can create, inspect, and delete jobs on the current thread. +//! These specs expose the `AlarmCreate`, `AlarmDelete`, and `AlarmList` +//! built-in tools so models can create, inspect, and delete alarms on the +//! current thread. use crate::JsonSchema; use crate::ResponsesApiTool; use crate::ToolSpec; use std::collections::BTreeMap; -pub fn create_job_create_tool() -> ToolSpec { +pub fn create_alarm_create_tool() -> ToolSpec { let properties = BTreeMap::from([ ( "cron_expression".to_string(), JsonSchema::String { description: Some( - "Scheduler expression for the job. Supported values are scheduler-specific." + "Scheduler expression for the alarm. Supported values are scheduler-specific." .to_string(), ), }, @@ -22,14 +23,23 @@ pub fn create_job_create_tool() -> ToolSpec { ( "prompt".to_string(), JsonSchema::String { - description: Some("Prompt to execute when the job fires.".to_string()), + description: Some("Prompt to execute when the alarm fires.".to_string()), }, ), ( "run_once".to_string(), JsonSchema::Boolean { description: Some( - "Optional. When true, delete the job after its next execution is claimed." + "Optional. When true, delete the alarm after its next execution is claimed." + .to_string(), + ), + }, + ), + ( + "delivery".to_string(), + JsonSchema::String { + description: Some( + "Delivery mode for the alarm. Use `after-turn` or `steer-current-turn`." .to_string(), ), }, @@ -37,32 +47,36 @@ pub fn create_job_create_tool() -> ToolSpec { ]); ToolSpec::Function(ResponsesApiTool { - name: "JobCreate".to_string(), + name: "AlarmCreate".to_string(), description: - "Create a runtime-only thread job using a structured scheduler expression and prompt." + "Create a thread alarm using a structured scheduler expression, prompt, and delivery mode." .to_string(), strict: false, defer_loading: None, parameters: JsonSchema::Object { properties, - required: Some(vec!["cron_expression".to_string(), "prompt".to_string()]), + required: Some(vec![ + "cron_expression".to_string(), + "prompt".to_string(), + "delivery".to_string(), + ]), additional_properties: Some(false.into()), }, output_schema: None, }) } -pub fn create_job_delete_tool() -> ToolSpec { +pub fn create_alarm_delete_tool() -> ToolSpec { let properties = BTreeMap::from([( "id".to_string(), JsonSchema::String { - description: Some("Identifier of the job to delete.".to_string()), + description: Some("Identifier of the alarm to delete.".to_string()), }, )]); ToolSpec::Function(ResponsesApiTool { - name: "JobDelete".to_string(), - description: "Delete a runtime-only thread job by id.".to_string(), + name: "AlarmDelete".to_string(), + description: "Delete a thread alarm by id.".to_string(), strict: false, defer_loading: None, parameters: JsonSchema::Object { @@ -74,10 +88,10 @@ pub fn create_job_delete_tool() -> ToolSpec { }) } -pub fn create_job_list_tool() -> ToolSpec { +pub fn create_alarm_list_tool() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { - name: "JobList".to_string(), - description: "List runtime-only thread jobs for the current thread.".to_string(), + name: "AlarmList".to_string(), + description: "List thread alarms for the current thread.".to_string(), strict: false, defer_loading: None, parameters: JsonSchema::Object { @@ -90,5 +104,5 @@ pub fn create_job_list_tool() -> ToolSpec { } #[cfg(test)] -#[path = "job_tool_tests.rs"] +#[path = "alarm_tool_tests.rs"] mod tests; diff --git a/codex-rs/tools/src/job_tool_tests.rs b/codex-rs/tools/src/alarm_tool_tests.rs similarity index 51% rename from codex-rs/tools/src/job_tool_tests.rs rename to codex-rs/tools/src/alarm_tool_tests.rs index be74e0b25e..4ccaf5f2a5 100644 --- a/codex-rs/tools/src/job_tool_tests.rs +++ b/codex-rs/tools/src/alarm_tool_tests.rs @@ -2,30 +2,30 @@ use crate::ResponsesApiTool; use crate::ToolSpec; use pretty_assertions::assert_eq; -use super::create_job_create_tool; -use super::create_job_delete_tool; -use super::create_job_list_tool; +use super::create_alarm_create_tool; +use super::create_alarm_delete_tool; +use super::create_alarm_list_tool; #[test] -fn job_create_tool_uses_expected_name() { - let ToolSpec::Function(ResponsesApiTool { name, .. }) = create_job_create_tool() else { +fn alarm_create_tool_uses_expected_name() { + let ToolSpec::Function(ResponsesApiTool { name, .. }) = create_alarm_create_tool() else { panic!("expected function tool"); }; - assert_eq!(name, "JobCreate"); + assert_eq!(name, "AlarmCreate"); } #[test] -fn job_delete_tool_uses_expected_name() { - let ToolSpec::Function(ResponsesApiTool { name, .. }) = create_job_delete_tool() else { +fn alarm_delete_tool_uses_expected_name() { + let ToolSpec::Function(ResponsesApiTool { name, .. }) = create_alarm_delete_tool() else { panic!("expected function tool"); }; - assert_eq!(name, "JobDelete"); + assert_eq!(name, "AlarmDelete"); } #[test] -fn job_list_tool_uses_expected_name() { - let ToolSpec::Function(ResponsesApiTool { name, .. }) = create_job_list_tool() else { +fn alarm_list_tool_uses_expected_name() { + let ToolSpec::Function(ResponsesApiTool { name, .. }) = create_alarm_list_tool() else { panic!("expected function tool"); }; - assert_eq!(name, "JobList"); + assert_eq!(name, "AlarmList"); } diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 6dccbc9286..aa50128998 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -3,9 +3,9 @@ mod agent_job_tool; mod agent_tool; +mod alarm_tool; mod code_mode; mod dynamic_tool; -mod job_tool; mod js_repl_tool; mod json_schema; mod local_tool; @@ -34,14 +34,14 @@ pub use agent_tool::create_spawn_agent_tool_v1; pub use agent_tool::create_spawn_agent_tool_v2; pub use agent_tool::create_wait_agent_tool_v1; pub use agent_tool::create_wait_agent_tool_v2; +pub use alarm_tool::create_alarm_create_tool; +pub use alarm_tool::create_alarm_delete_tool; +pub use alarm_tool::create_alarm_list_tool; pub use code_mode::augment_tool_spec_for_code_mode; pub use code_mode::create_code_mode_tool; pub use code_mode::create_wait_tool; pub use code_mode::tool_spec_to_code_mode_tool_definition; pub use dynamic_tool::parse_dynamic_tool; -pub use job_tool::create_job_create_tool; -pub use job_tool::create_job_delete_tool; -pub use job_tool::create_job_list_tool; pub use js_repl_tool::create_js_repl_reset_tool; pub use js_repl_tool::create_js_repl_tool; pub use json_schema::AdditionalProperties; diff --git a/codex-rs/tui/src/job_scheduler.rs b/codex-rs/tui/src/alarm_scheduler.rs similarity index 77% rename from codex-rs/tui/src/job_scheduler.rs rename to codex-rs/tui/src/alarm_scheduler.rs index 40f05b0546..22a60382d6 100644 --- a/codex-rs/tui/src/job_scheduler.rs +++ b/codex-rs/tui/src/alarm_scheduler.rs @@ -1,6 +1,7 @@ use crate::AppServerTarget; use crate::start_app_server_for_picker; use codex_app_server_client::AppServerEvent; +use codex_app_server_protocol::AlarmDelivery; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadItem; use codex_core::config::Config; @@ -10,22 +11,24 @@ use serde_json::json; use tracing::warn; #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ParsedJobSpec { +pub(crate) struct ParsedAlarmSpec { pub(crate) cron_expression: String, pub(crate) prompt: String, pub(crate) run_once: Option, + pub(crate) delivery: AlarmDelivery, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -struct ParsedJobSpecResponse { +struct ParsedAlarmSpecResponse { cron_expression: String, prompt: String, run_once: Option, + delivery: AlarmDelivery, } -impl ParsedJobSpecResponse { - fn into_parsed(self) -> std::result::Result { +impl ParsedAlarmSpecResponse { + fn into_parsed(self) -> std::result::Result { let cron_expression = self.cron_expression.trim().to_string(); let prompt = self.prompt.trim().to_string(); if cron_expression.is_empty() { @@ -34,19 +37,20 @@ impl ParsedJobSpecResponse { if prompt.is_empty() { return Err("Could not determine a prompt from /loop input.".to_string()); } - Ok(ParsedJobSpec { + Ok(ParsedAlarmSpec { cron_expression, prompt, run_once: self.run_once, + delivery: self.delivery, }) } } -pub(crate) async fn parse_job_spec( +pub(crate) async fn parse_alarm_spec( config: Config, target: AppServerTarget, spec: String, -) -> std::result::Result { +) -> std::result::Result { let mut app_server = start_app_server_for_picker(&config, &target) .await .map_err(|err| format!("Failed to start parser session: {err}"))?; @@ -61,16 +65,17 @@ pub(crate) async fn parse_job_spec( "properties": { "cronExpression": { "type": "string" }, "prompt": { "type": "string" }, - "runOnce": { "type": ["boolean", "null"] } + "runOnce": { "type": ["boolean", "null"] }, + "delivery": { "type": "string" } }, - "required": ["cronExpression", "prompt", "runOnce"], + "required": ["cronExpression", "prompt", "runOnce", "delivery"], "additionalProperties": false }); let parser_prompt = format!( "{}\n{}", concat!( - "Convert the user's /loop spec into JSON with keys cronExpression, prompt, and runOnce.\n", + "Convert the user's /loop spec into JSON with keys cronExpression, prompt, runOnce, and delivery.\n", "Only emit valid JSON matching the schema.\n", "Supported cronExpression values for this client are:\n", "- @after-turn\n", @@ -80,7 +85,9 @@ pub(crate) async fn parse_job_spec( "- @every d\n", "Use @after-turn when no explicit interval is present.\n", "Set runOnce to true only when the user clearly asked for a one-shot run.\n", - "Keep stop conditions and job intent inside prompt.\n", + "Set delivery to `steer-current-turn` only when the user clearly asked for the alarm to be delivered during the current turn or as a steer.\n", + "Otherwise set delivery to `after-turn`.\n", + "Keep stop conditions and alarm intent inside prompt.\n", "If the schedule request is unsupported by the allowed cronExpression formats, return an empty cronExpression.\n", "User spec:" ), @@ -139,7 +146,7 @@ pub(crate) async fn parse_job_spec( let response = final_message.ok_or_else(|| "The /loop parser returned no output.".to_string())?; - let parsed: ParsedJobSpecResponse = serde_json::from_str(&response) + let parsed: ParsedAlarmSpecResponse = serde_json::from_str(&response) .map_err(|err| format!("Invalid /loop parser JSON: {err}"))?; parsed.into_parsed() } @@ -152,7 +159,22 @@ pub(crate) async fn parse_job_spec( result } -pub(crate) fn format_job_summary(cron_expression: &str, run_once: bool, prompt: &str) -> String { +pub(crate) fn format_alarm_summary( + cron_expression: &str, + run_once: bool, + delivery: AlarmDelivery, + prompt: &str, +) -> String { let mode = if run_once { "one-shot" } else { "recurring" }; - format!("{cron_expression} ({mode}) -> {prompt}") + format!( + "{cron_expression} ({mode}, {}) -> {prompt}", + delivery_str(delivery) + ) +} + +fn delivery_str(delivery: AlarmDelivery) -> &'static str { + match delivery { + AlarmDelivery::AfterTurn => "after-turn", + AlarmDelivery::SteerCurrentTurn => "steer-current-turn", + } } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ccdf3a0859..7eaa32a06f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1,3 +1,5 @@ +use crate::alarm_scheduler::format_alarm_summary; +use crate::alarm_scheduler::parse_alarm_spec; use crate::app_backtrack::BacktrackState; use crate::app_command::AppCommand; use crate::app_command::AppCommandView; @@ -32,8 +34,6 @@ use crate::history_cell; use crate::history_cell::HistoryCell; #[cfg(not(debug_assertions))] use crate::history_cell::UpdateAvailableHistoryCell; -use crate::job_scheduler::format_job_summary; -use crate::job_scheduler::parse_job_spec; use crate::model_catalog::ModelCatalog; use crate::model_migration::ModelMigrationOutcome; use crate::model_migration::migration_copy_for_models; @@ -1971,7 +1971,7 @@ impl App { }); } - fn parse_thread_job_spec(&mut self, thread_id: ThreadId, spec: String) { + fn parse_thread_alarm_spec(&mut self, thread_id: ThreadId, spec: String) { let config = self.config.clone(); let target = match self.remote_app_server_url.clone() { Some(websocket_url) => crate::AppServerTarget::Remote { @@ -1982,8 +1982,8 @@ impl App { }; let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { - let result = parse_job_spec(config, target, spec.clone()).await; - app_event_tx.send(AppEvent::ThreadJobSpecParsed { + let result = parse_alarm_spec(config, target, spec.clone()).await; + app_event_tx.send(AppEvent::ThreadAlarmSpecParsed { thread_id, spec, result, @@ -3298,11 +3298,11 @@ impl App { self.replace_chat_widget(ChatWidget::new_with_app_event(init)); self.enqueue_primary_thread_session(started.session, started.turns) .await?; - if self.config.features.enabled(Feature::JobScheduler) { - match app_server.thread_job_list(thread_id).await { - Ok(jobs) => self.chat_widget.on_thread_jobs_updated(jobs), + if self.config.features.enabled(Feature::AlarmScheduler) { + match app_server.thread_alarm_list(thread_id).await { + Ok(alarms) => self.chat_widget.on_thread_alarms_updated(alarms), Err(err) => { - tracing::warn!(%err, "failed to load thread jobs while attaching thread"); + tracing::warn!(%err, "failed to load thread alarms while attaching thread"); } } } @@ -4184,44 +4184,49 @@ impl App { tui.frame_requester().schedule_frame(); } - AppEvent::OpenThreadJobs { thread_id } => { - match app_server.thread_job_list(thread_id).await { - Ok(jobs) => { + AppEvent::OpenThreadAlarms { thread_id } => { + match app_server.thread_alarm_list(thread_id).await { + Ok(alarms) => { if self.chat_widget.thread_id() == Some(thread_id) { - self.chat_widget.open_thread_jobs_popup(thread_id, jobs); + self.chat_widget.open_thread_alarms_popup(thread_id, alarms); } } Err(err) => { if self.chat_widget.thread_id() == Some(thread_id) { self.chat_widget - .add_error_message(format!("Failed to load thread jobs: {err}")); + .add_error_message(format!("Failed to load thread alarms: {err}")); } } } } - AppEvent::CreateThreadJobFromSpec { thread_id, spec } => { - self.parse_thread_job_spec(thread_id, spec); + AppEvent::CreateThreadAlarmFromSpec { thread_id, spec } => { + self.parse_thread_alarm_spec(thread_id, spec); } - AppEvent::ThreadJobSpecParsed { + AppEvent::ThreadAlarmSpecParsed { thread_id, spec, result, } => match result { Ok(parsed) => match app_server - .thread_job_create( + .thread_alarm_create( thread_id, parsed.cron_expression.clone(), parsed.prompt.clone(), parsed.run_once, + parsed.delivery, ) .await { - Ok(job) => { + Ok(alarm) => { if self.chat_widget.thread_id() == Some(thread_id) { - let summary = - format_job_summary(&job.cron_expression, job.run_once, &job.prompt); + let summary = format_alarm_summary( + &alarm.cron_expression, + alarm.run_once, + alarm.delivery, + &alarm.prompt, + ); self.chat_widget.add_info_message( - format!("Created thread job from `/loop {spec}`."), + format!("Created thread alarm from `/loop {spec}`."), Some(summary), ); } @@ -4229,7 +4234,7 @@ impl App { Err(err) => { if self.chat_widget.thread_id() == Some(thread_id) { self.chat_widget - .add_error_message(format!("Failed to create thread job: {err}")); + .add_error_message(format!("Failed to create thread alarm: {err}")); } } }, @@ -4240,12 +4245,12 @@ impl App { } } }, - AppEvent::DeleteThreadJob { thread_id, id } => { - match app_server.thread_job_delete(thread_id, id.clone()).await { + AppEvent::DeleteThreadAlarm { thread_id, id } => { + match app_server.thread_alarm_delete(thread_id, id.clone()).await { Ok(true) => { if self.chat_widget.thread_id() == Some(thread_id) { self.chat_widget.add_info_message( - format!("Deleted thread job `{id}`."), + format!("Deleted thread alarm `{id}`."), /*hint*/ None, ); } @@ -4253,13 +4258,13 @@ impl App { Ok(false) => { if self.chat_widget.thread_id() == Some(thread_id) { self.chat_widget - .add_error_message(format!("No thread job matched `{id}`.")); + .add_error_message(format!("No thread alarm matched `{id}`.")); } } Err(err) => { if self.chat_widget.thread_id() == Some(thread_id) { self.chat_widget.add_error_message(format!( - "Failed to delete thread job `{id}`: {err}" + "Failed to delete thread alarm `{id}`: {err}" )); } } diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 06a33575af..1294c6a0d2 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -329,8 +329,10 @@ fn server_notification_thread_target( ServerNotification::ThreadNameUpdated(notification) => { Some(notification.thread_id.as_str()) } - ServerNotification::ThreadJobFired(notification) => Some(notification.thread_id.as_str()), - ServerNotification::ThreadJobUpdated(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ThreadAlarmFired(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ThreadAlarmUpdated(notification) => { + Some(notification.thread_id.as_str()) + } ServerNotification::ThreadTokenUsageUpdated(notification) => { Some(notification.thread_id.as_str()) } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 8632ad67cb..c075e9bdf0 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -26,11 +26,11 @@ use codex_protocol::protocol::RateLimitSnapshot; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; +use crate::alarm_scheduler::ParsedAlarmSpec; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::TerminalTitleItem; use crate::history_cell::HistoryCell; -use crate::job_scheduler::ParsedJobSpec; use codex_core::config::types::ApprovalsReviewer; use codex_features::Feature; @@ -109,26 +109,26 @@ pub(crate) enum AppEvent { /// Fork the current session into a new thread. ForkCurrentSession, - /// List jobs for the specified thread and open the management UI. - OpenThreadJobs { + /// List alarms for the specified thread and open the management UI. + OpenThreadAlarms { thread_id: ThreadId, }, - /// Parse a `/loop ` input into structured job params. - CreateThreadJobFromSpec { + /// Parse a `/loop ` input into structured alarm params. + CreateThreadAlarmFromSpec { thread_id: ThreadId, spec: String, }, /// Result of parsing a `/loop ` input. - ThreadJobSpecParsed { + ThreadAlarmSpecParsed { thread_id: ThreadId, spec: String, - result: Result, + result: Result, }, - /// Delete one job from the specified thread. - DeleteThreadJob { + /// Delete one alarm from the specified thread. + DeleteThreadAlarm { thread_id: ThreadId, id: String, }, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index ef263c2d7b..fe99a86ec0 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -6,6 +6,7 @@ use codex_app_server_client::AppServerEvent; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::Account; +use codex_app_server_protocol::AlarmDelivery; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigBatchWriteParams; @@ -24,19 +25,19 @@ use codex_app_server_protocol::ReviewStartResponse; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadAlarm; +use codex_app_server_protocol::ThreadAlarmCreateParams; +use codex_app_server_protocol::ThreadAlarmCreateResponse; +use codex_app_server_protocol::ThreadAlarmDeleteParams; +use codex_app_server_protocol::ThreadAlarmDeleteResponse; +use codex_app_server_protocol::ThreadAlarmListParams; +use codex_app_server_protocol::ThreadAlarmListResponse; use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams; use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse; use codex_app_server_protocol::ThreadCompactStartParams; use codex_app_server_protocol::ThreadCompactStartResponse; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; -use codex_app_server_protocol::ThreadJob; -use codex_app_server_protocol::ThreadJobCreateParams; -use codex_app_server_protocol::ThreadJobCreateResponse; -use codex_app_server_protocol::ThreadJobDeleteParams; -use codex_app_server_protocol::ThreadJobDeleteResponse; -use codex_app_server_protocol::ThreadJobListParams; -use codex_app_server_protocol::ThreadJobListResponse; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadLoadedListParams; @@ -412,62 +413,67 @@ impl AppServerSession { Ok(response.thread) } - pub(crate) async fn thread_job_create( + pub(crate) async fn thread_alarm_create( &mut self, thread_id: ThreadId, cron_expression: String, prompt: String, run_once: Option, - ) -> Result { + delivery: AlarmDelivery, + ) -> Result { let request_id = self.next_request_id(); - let response: ThreadJobCreateResponse = self + let response: ThreadAlarmCreateResponse = self .client - .request_typed(ClientRequest::ThreadJobCreate { + .request_typed(ClientRequest::ThreadAlarmCreate { request_id, - params: ThreadJobCreateParams { + params: ThreadAlarmCreateParams { thread_id: thread_id.to_string(), cron_expression, prompt, run_once, + delivery, }, }) .await - .wrap_err("thread/job/create failed in TUI")?; - Ok(response.job) + .wrap_err("thread/alarm/create failed in TUI")?; + Ok(response.alarm) } - pub(crate) async fn thread_job_delete( + pub(crate) async fn thread_alarm_delete( &mut self, thread_id: ThreadId, id: String, ) -> Result { let request_id = self.next_request_id(); - let response: ThreadJobDeleteResponse = self + let response: ThreadAlarmDeleteResponse = self .client - .request_typed(ClientRequest::ThreadJobDelete { + .request_typed(ClientRequest::ThreadAlarmDelete { request_id, - params: ThreadJobDeleteParams { + params: ThreadAlarmDeleteParams { thread_id: thread_id.to_string(), id, }, }) .await - .wrap_err("thread/job/delete failed in TUI")?; + .wrap_err("thread/alarm/delete failed in TUI")?; Ok(response.deleted) } - pub(crate) async fn thread_job_list(&mut self, thread_id: ThreadId) -> Result> { + pub(crate) async fn thread_alarm_list( + &mut self, + thread_id: ThreadId, + ) -> Result> { let request_id = self.next_request_id(); - let response: ThreadJobListResponse = self + let response: ThreadAlarmListResponse = self .client - .request_typed(ClientRequest::ThreadJobList { + .request_typed(ClientRequest::ThreadAlarmList { request_id, - params: ThreadJobListParams { + params: ThreadAlarmListParams { thread_id: thread_id.to_string(), }, }) .await - .wrap_err("thread/job/list failed in TUI")?; + .wrap_err("thread/alarm/list failed in TUI")?; Ok(response.data) } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 181950f986..6569c88d0e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -328,7 +328,7 @@ pub(crate) struct ChatComposer { collaboration_mode_indicator: Option, connectors_enabled: bool, plugins_command_enabled: bool, - job_scheduler_command_enabled: bool, + alarm_scheduler_command_enabled: bool, fast_command_enabled: bool, personality_command_enabled: bool, realtime_conversation_enabled: bool, @@ -368,7 +368,7 @@ impl ChatComposer { collaboration_modes_enabled: self.collaboration_modes_enabled, connectors_enabled: self.connectors_enabled, plugins_command_enabled: self.plugins_command_enabled, - job_scheduler_command_enabled: self.job_scheduler_command_enabled, + alarm_scheduler_command_enabled: self.alarm_scheduler_command_enabled, fast_command_enabled: self.fast_command_enabled, personality_command_enabled: self.personality_command_enabled, realtime_conversation_enabled: self.realtime_conversation_enabled, @@ -451,7 +451,7 @@ impl ChatComposer { collaboration_mode_indicator: None, connectors_enabled: false, plugins_command_enabled: false, - job_scheduler_command_enabled: false, + alarm_scheduler_command_enabled: false, fast_command_enabled: false, personality_command_enabled: false, realtime_conversation_enabled: false, @@ -490,8 +490,8 @@ impl ChatComposer { self.plugins_command_enabled = enabled; } - pub fn set_job_scheduler_command_enabled(&mut self, enabled: bool) { - self.job_scheduler_command_enabled = enabled; + pub fn set_alarm_scheduler_command_enabled(&mut self, enabled: bool) { + self.alarm_scheduler_command_enabled = enabled; } /// Toggle composer-side image paste handling. @@ -3070,7 +3070,7 @@ impl ChatComposer { let collaboration_modes_enabled = self.collaboration_modes_enabled; let connectors_enabled = self.connectors_enabled; let plugins_command_enabled = self.plugins_command_enabled; - let job_scheduler_command_enabled = self.job_scheduler_command_enabled; + let alarm_scheduler_command_enabled = self.alarm_scheduler_command_enabled; let fast_command_enabled = self.fast_command_enabled; let personality_command_enabled = self.personality_command_enabled; let realtime_conversation_enabled = self.realtime_conversation_enabled; @@ -3079,7 +3079,7 @@ impl ChatComposer { collaboration_modes_enabled, connectors_enabled, plugins_command_enabled, - job_scheduler_command_enabled, + alarm_scheduler_command_enabled, fast_command_enabled, personality_command_enabled, realtime_conversation_enabled, diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 05d803df83..3380df7280 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -33,7 +33,7 @@ pub(crate) struct CommandPopupFlags { pub(crate) collaboration_modes_enabled: bool, pub(crate) connectors_enabled: bool, pub(crate) plugins_command_enabled: bool, - pub(crate) job_scheduler_command_enabled: bool, + pub(crate) alarm_scheduler_command_enabled: bool, pub(crate) fast_command_enabled: bool, pub(crate) personality_command_enabled: bool, pub(crate) realtime_conversation_enabled: bool, @@ -47,7 +47,7 @@ impl From for slash_commands::BuiltinCommandFlags { collaboration_modes_enabled: value.collaboration_modes_enabled, connectors_enabled: value.connectors_enabled, plugins_command_enabled: value.plugins_command_enabled, - job_scheduler_command_enabled: value.job_scheduler_command_enabled, + alarm_scheduler_command_enabled: value.alarm_scheduler_command_enabled, fast_command_enabled: value.fast_command_enabled, personality_command_enabled: value.personality_command_enabled, realtime_conversation_enabled: value.realtime_conversation_enabled, @@ -357,7 +357,7 @@ mod tests { connectors_enabled: false, plugins_command_enabled: false, fast_command_enabled: false, - job_scheduler_command_enabled: false, + alarm_scheduler_command_enabled: false, personality_command_enabled: true, realtime_conversation_enabled: false, audio_device_selection_enabled: false, @@ -378,7 +378,7 @@ mod tests { connectors_enabled: false, plugins_command_enabled: false, fast_command_enabled: false, - job_scheduler_command_enabled: false, + alarm_scheduler_command_enabled: false, personality_command_enabled: true, realtime_conversation_enabled: false, audio_device_selection_enabled: false, @@ -399,7 +399,7 @@ mod tests { connectors_enabled: false, plugins_command_enabled: false, fast_command_enabled: false, - job_scheduler_command_enabled: false, + alarm_scheduler_command_enabled: false, personality_command_enabled: false, realtime_conversation_enabled: false, audio_device_selection_enabled: false, @@ -427,7 +427,7 @@ mod tests { connectors_enabled: false, plugins_command_enabled: false, fast_command_enabled: false, - job_scheduler_command_enabled: false, + alarm_scheduler_command_enabled: false, personality_command_enabled: true, realtime_conversation_enabled: false, audio_device_selection_enabled: false, @@ -448,7 +448,7 @@ mod tests { connectors_enabled: false, plugins_command_enabled: false, fast_command_enabled: false, - job_scheduler_command_enabled: false, + alarm_scheduler_command_enabled: false, personality_command_enabled: true, realtime_conversation_enabled: true, audio_device_selection_enabled: false, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 7bf3ec2ba2..696c5af787 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -272,8 +272,8 @@ impl BottomPane { self.request_redraw(); } - pub fn set_job_scheduler_command_enabled(&mut self, enabled: bool) { - self.composer.set_job_scheduler_command_enabled(enabled); + pub fn set_alarm_scheduler_command_enabled(&mut self, enabled: bool) { + self.composer.set_alarm_scheduler_command_enabled(enabled); self.request_redraw(); } diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 77dfcf5eb1..b490e62b70 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -15,7 +15,7 @@ pub(crate) struct BuiltinCommandFlags { pub(crate) collaboration_modes_enabled: bool, pub(crate) connectors_enabled: bool, pub(crate) plugins_command_enabled: bool, - pub(crate) job_scheduler_command_enabled: bool, + pub(crate) alarm_scheduler_command_enabled: bool, pub(crate) fast_command_enabled: bool, pub(crate) personality_command_enabled: bool, pub(crate) realtime_conversation_enabled: bool, @@ -34,7 +34,7 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st }) .filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps) .filter(|(_, cmd)| flags.plugins_command_enabled || *cmd != SlashCommand::Plugins) - .filter(|(_, cmd)| flags.job_scheduler_command_enabled || *cmd != SlashCommand::Loop) + .filter(|(_, cmd)| flags.alarm_scheduler_command_enabled || *cmd != SlashCommand::Loop) .filter(|(_, cmd)| flags.fast_command_enabled || *cmd != SlashCommand::Fast) .filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality) .filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime) @@ -68,7 +68,7 @@ mod tests { collaboration_modes_enabled: true, connectors_enabled: true, plugins_command_enabled: true, - job_scheduler_command_enabled: true, + alarm_scheduler_command_enabled: true, fast_command_enabled: true, personality_command_enabled: true, realtime_conversation_enabled: true, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fd16791883..740deebe23 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -345,7 +345,7 @@ use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; -use codex_app_server_protocol::ThreadJob; +use codex_app_server_protocol::ThreadAlarm; mod interrupts; use self::interrupts::InterruptManager; mod session_header; @@ -841,7 +841,7 @@ pub(crate) struct ChatWidget { thread_id: Option, thread_name: Option, forked_from: Option, - thread_jobs: Vec, + thread_alarms: Vec, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured show_welcome_banner: bool, @@ -1951,7 +1951,7 @@ impl ChatWidget { self.thread_id = Some(event.session_id); self.thread_name = event.thread_name.clone(); self.forked_from = event.forked_from_id; - self.thread_jobs.clear(); + self.thread_alarms.clear(); self.current_rollout_path = event.rollout_path.clone(); self.current_cwd = Some(event.cwd.clone()); match AbsolutePathBuf::try_from(event.cwd.clone()) { @@ -1999,7 +1999,7 @@ impl ChatWidget { self.refresh_model_display(); self.refresh_status_surfaces(); self.sync_fast_command_enabled(); - self.sync_job_scheduler_command_enabled(); + self.sync_alarm_scheduler_command_enabled(); self.sync_personality_command_enabled(); self.sync_plugins_command_enabled(); self.refresh_plugin_mentions(); @@ -2394,7 +2394,7 @@ impl ChatWidget { if self.has_queued_follow_up_messages() { return; } - if !self.thread_jobs.is_empty() { + if !self.thread_alarms.is_empty() { return; } if self.active_mode_kind() != ModeKind::Plan { @@ -4761,7 +4761,7 @@ impl ChatWidget { thread_id: None, thread_name: None, forked_from: None, - thread_jobs: Vec::new(), + thread_alarms: Vec::new(), queued_user_messages: VecDeque::new(), rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), @@ -4819,7 +4819,7 @@ impl ChatWidget { .bottom_pane .set_collaboration_modes_enabled(/*enabled*/ true); widget.sync_fast_command_enabled(); - widget.sync_job_scheduler_command_enabled(); + widget.sync_alarm_scheduler_command_enabled(); widget.sync_personality_command_enabled(); widget.sync_plugins_command_enabled(); widget @@ -5152,7 +5152,7 @@ impl ChatWidget { return; }; self.app_event_tx - .send(AppEvent::OpenThreadJobs { thread_id }); + .send(AppEvent::OpenThreadAlarms { thread_id }); } SlashCommand::Rename => { self.session_telemetry @@ -5568,9 +5568,9 @@ impl ChatWidget { }; self.add_info_message( format!("Scheduling `/loop {prepared_args}`..."), - Some("Parsing the spec and creating the thread job.".to_string()), + Some("Parsing the spec and creating the thread alarm.".to_string()), ); - self.app_event_tx.send(AppEvent::CreateThreadJobFromSpec { + self.app_event_tx.send(AppEvent::CreateThreadAlarmFromSpec { thread_id, spec: prepared_args, }); @@ -6436,11 +6436,11 @@ impl ChatWidget { } } } - ServerNotification::ThreadJobUpdated(notification) => { - self.on_thread_jobs_updated(notification.jobs); + ServerNotification::ThreadAlarmUpdated(notification) => { + self.on_thread_alarms_updated(notification.alarms); } - ServerNotification::ThreadJobFired(notification) => { - self.on_thread_job_fired(notification.job); + ServerNotification::ThreadAlarmFired(notification) => { + self.on_thread_alarm_fired(notification.alarm); } ServerNotification::TurnStarted(_) => { self.last_non_retry_error = None; @@ -9453,8 +9453,8 @@ impl ChatWidget { if feature == Feature::FastMode { self.sync_fast_command_enabled(); } - if feature == Feature::JobScheduler { - self.sync_job_scheduler_command_enabled(); + if feature == Feature::AlarmScheduler { + self.sync_alarm_scheduler_command_enabled(); } if feature == Feature::Personality { self.sync_personality_command_enabled(); @@ -9696,9 +9696,10 @@ impl ChatWidget { .set_fast_command_enabled(self.fast_mode_enabled()); } - fn sync_job_scheduler_command_enabled(&mut self) { - self.bottom_pane - .set_job_scheduler_command_enabled(self.config.features.enabled(Feature::JobScheduler)); + fn sync_alarm_scheduler_command_enabled(&mut self) { + self.bottom_pane.set_alarm_scheduler_command_enabled( + self.config.features.enabled(Feature::AlarmScheduler), + ); } fn sync_personality_command_enabled(&mut self) { @@ -10022,49 +10023,63 @@ impl ChatWidget { self.request_redraw(); } - pub(crate) fn on_thread_jobs_updated(&mut self, jobs: Vec) { - self.thread_jobs = jobs; + pub(crate) fn on_thread_alarms_updated(&mut self, alarms: Vec) { + self.thread_alarms = alarms; } - pub(crate) fn on_thread_job_fired(&mut self, job: ThreadJob) { - let schedule = if job.run_once { - format!("Running thread job • {} • one-shot", job.cron_expression) - } else { - format!("Running thread job • {}", job.cron_expression) + pub(crate) fn on_thread_alarm_fired(&mut self, alarm: ThreadAlarm) { + let delivery = match alarm.delivery { + codex_app_server_protocol::AlarmDelivery::AfterTurn => "after-turn", + codex_app_server_protocol::AlarmDelivery::SteerCurrentTurn => "steer-current-turn", }; - self.add_info_message(job.prompt, Some(schedule)); + let schedule = if alarm.run_once { + format!( + "Running thread alarm • {} • one-shot • {}", + alarm.cron_expression, delivery + ) + } else { + format!( + "Running thread alarm • {} • {}", + alarm.cron_expression, delivery + ) + }; + self.add_info_message(alarm.prompt, Some(schedule)); } - pub(crate) fn open_thread_jobs_popup(&mut self, thread_id: ThreadId, jobs: Vec) { - self.thread_jobs = jobs.clone(); - if jobs.is_empty() { + pub(crate) fn open_thread_alarms_popup( + &mut self, + thread_id: ThreadId, + alarms: Vec, + ) { + self.thread_alarms = alarms.clone(); + if alarms.is_empty() { self.add_info_message( - "No thread jobs are currently scheduled.".to_string(), + "No thread alarms are currently scheduled.".to_string(), Some("Use `/loop ` to create one.".to_string()), ); return; } - let items = jobs + let items = alarms .into_iter() - .map(|job| { - let job_id = job.id.clone(); + .map(|alarm| { + let alarm_id = alarm.id.clone(); let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::DeleteThreadJob { + tx.send(AppEvent::DeleteThreadAlarm { thread_id, - id: job_id.clone(), + id: alarm_id.clone(), }); })]; - let name = if job.run_once { - format!("{} • one-shot", job.cron_expression) + let name = if alarm.run_once { + format!("{} • one-shot", alarm.cron_expression) } else { - job.cron_expression.clone() + alarm.cron_expression.clone() }; let selected_description = - format!("{}\n\nPress Enter to delete this job.", job.prompt); + format!("{}\n\nPress Enter to delete this alarm.", alarm.prompt); SelectionItem { name, - description: Some(job.prompt), + description: Some(alarm.prompt), selected_description: Some(selected_description), is_current: false, actions, @@ -10074,8 +10089,8 @@ impl ChatWidget { }) .collect(); self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Thread jobs".to_string()), - subtitle: Some("Review a job, then press Enter to delete it.".to_string()), + title: Some("Thread alarms".to_string()), + subtitle: Some("Review an alarm, then press Enter to delete it.".to_string()), footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 51e458ff8f..c206b831f4 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -242,7 +242,7 @@ pub(super) async fn make_chatwidget_manual( pending_status_indicator_restore: false, suppress_queue_autosend: false, thread_id: None, - thread_jobs: Vec::new(), + thread_alarms: Vec::new(), thread_name: None, forked_from: None, frame_requester: FrameRequester::test_dummy(), diff --git a/codex-rs/tui/src/chatwidget/tests/jobs.rs b/codex-rs/tui/src/chatwidget/tests/jobs.rs index 7646f2c689..49e84d76f0 100644 --- a/codex-rs/tui/src/chatwidget/tests/jobs.rs +++ b/codex-rs/tui/src/chatwidget/tests/jobs.rs @@ -1,20 +1,22 @@ use super::*; -use codex_app_server_protocol::ThreadJob; -use codex_app_server_protocol::ThreadJobFiredNotification; +use codex_app_server_protocol::AlarmDelivery; +use codex_app_server_protocol::ThreadAlarm; +use codex_app_server_protocol::ThreadAlarmFiredNotification; use insta::assert_snapshot; #[tokio::test] -async fn thread_job_fired_renders_prompt_history() { +async fn thread_alarm_fired_renders_prompt_history() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.handle_server_notification( - ServerNotification::ThreadJobFired(ThreadJobFiredNotification { + ServerNotification::ThreadAlarmFired(ThreadAlarmFiredNotification { thread_id: ThreadId::new().to_string(), - job: ThreadJob { - id: "job-1".to_string(), + alarm: ThreadAlarm { + id: "alarm-1".to_string(), cron_expression: "@after-turn".to_string(), prompt: "Give me a random animal name.".to_string(), run_once: false, + delivery: AlarmDelivery::AfterTurn, created_at: 0, next_run_at: None, last_run_at: None, @@ -25,21 +27,22 @@ async fn thread_job_fired_renders_prompt_history() { let cells = drain_insert_history(&mut rx); let rendered = lines_to_single_string(&cells[0]); - assert_snapshot!(rendered, @"• Give me a random animal name. Running thread job • @after-turn + assert_snapshot!(rendered, @"• Give me a random animal name. Running thread alarm • @after-turn • after-turn "); } #[tokio::test] -async fn thread_jobs_popup_keeps_selected_job_prompt_visible() { +async fn thread_alarms_popup_keeps_selected_alarm_prompt_visible() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.open_thread_jobs_popup( + chat.open_thread_alarms_popup( ThreadId::new(), - vec![ThreadJob { - id: "job-1".to_string(), + vec![ThreadAlarm { + id: "alarm-1".to_string(), cron_expression: "@after-turn".to_string(), prompt: "Give me a random animal name.".to_string(), run_once: false, + delivery: AlarmDelivery::AfterTurn, created_at: 0, next_run_at: None, last_run_at: None, @@ -47,5 +50,8 @@ async fn thread_jobs_popup_keeps_selected_job_prompt_visible() { ); let popup = render_bottom_popup(&chat, /*width*/ 80); - assert_snapshot!("thread_jobs_popup_keeps_selected_job_prompt_visible", popup); + assert_snapshot!( + "thread_alarms_popup_keeps_selected_alarm_prompt_visible", + popup + ); } diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__jobs__thread_alarms_popup_keeps_selected_alarm_prompt_visible.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__jobs__thread_alarms_popup_keeps_selected_alarm_prompt_visible.snap new file mode 100644 index 0000000000..704d0ae611 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__jobs__thread_alarms_popup_keeps_selected_alarm_prompt_visible.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/jobs.rs +expression: popup +--- + Thread alarms + Review an alarm, then press Enter to delete it. + +› 1. @after-turn Give me a random animal name. + Press Enter to delete this alarm. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index adc4f100e9..165a7f6ac0 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -94,6 +94,7 @@ mod audio_device { )) } } +mod alarm_scheduler; mod bottom_pane; mod chatwidget; mod cli; @@ -113,7 +114,6 @@ mod frames; mod get_git_diff; mod history_cell; pub mod insert_history; -mod job_scheduler; mod key_hint; mod line_truncation; pub mod live_wrap; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 7dd81e9dcd..ad918af261 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -76,7 +76,7 @@ impl SlashCommand { SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", SlashCommand::Review => "review my current changes and find issues", - SlashCommand::Loop => "create or list thread jobs", + SlashCommand::Loop => "create or list thread alarms", SlashCommand::Rename => "rename the current thread", SlashCommand::Resume => "resume a saved chat", SlashCommand::Clear => "clear the terminal and start a new chat", diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 0952a52d34..9fa851422f 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -82,10 +82,11 @@ impl StatusHistoryHandle { } else { compose_rate_limit_data_many(rate_limits, now) }; - let mut state = match self.rate_limit_state.write() { - Ok(state) => state, - Err(err) => err.into_inner(), - }; + #[expect(clippy::expect_used)] + let mut state = self + .rate_limit_state + .write() + .expect("status history rate-limit state poisoned"); state.rate_limits = rate_limits; state.refreshing_rate_limits = false; } @@ -558,10 +559,11 @@ impl HistoryCell for StatusHistoryCell { .collect(); let mut seen: BTreeSet = labels.iter().cloned().collect(); let thread_name = self.thread_name.as_deref().filter(|name| !name.is_empty()); - let rate_limit_state = match self.rate_limit_state.read() { - Ok(state) => state, - Err(err) => err.into_inner(), - }; + #[expect(clippy::expect_used)] + let rate_limit_state = self + .rate_limit_state + .read() + .expect("status history rate-limit state poisoned"); if self.model_provider.is_some() { push_label(&mut labels, &mut seen, "Model provider");