fix(schedule): reconcile latest master contracts

This commit is contained in:
Tianyi Cui
2026-08-08 22:39:07 +08:00
parent 31e83f8ee7
commit 1f88085b53
35 changed files with 260 additions and 141 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md
2026-08-05-durable-web-schedule.md: 584d7be639b5611a5ea3279f59dfd05f0c746a62 2026-08-05-durable-web-schedule.md: 5f55876e8c23483f01680eda1b0f0a5075c4a5fd
2026-08-05-durable-web-schedule.zh.md: 2bfeb81cac0c1bc8df84d065bdae278a345b5358 2026-08-05-durable-web-schedule.zh.md: 1e29e1d8fdae4e467ca3f7411e4966194faa87da

View File

@@ -24,3 +24,5 @@ The [CLI behavior reference](reference/README.md) owns exact layer precedence, f
## Development ## Development
Production runs require built package and frontend artifacts. From a checkout, `pnpm run dsh` runs the TypeScript entry and forwards arguments; the [source-launcher reference](reference/README.md#source-launcher) describes the PATH symlink and module-resolution contract. Production runs require built package and frontend artifacts. From a checkout, `pnpm run dsh` runs the TypeScript entry and forwards arguments; the [source-launcher reference](reference/README.md#source-launcher) describes the PATH symlink and module-resolution contract.
Schedule reminders are opt-in rather than part of the default Web tree. `dsh web --patch examples/web-schedule/cordis.yml` loads the Schedule tools and receipt renderer over the existing JSONL persistence path; reminders run only while their original Session has a live root Agent and are reported as `session-local`, never as an external notification.

View File

@@ -19,6 +19,7 @@
"@cordisjs/plugin-timer": "workspace:*", "@cordisjs/plugin-timer": "workspace:*",
"@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-base": "workspace:^", "@deepseek-ai/dsh-base": "workspace:^",
"@deepseek-ai/dsh-client-ui-schedule": "workspace:^",
"@deepseek-ai/dsh-headless": "workspace:^", "@deepseek-ai/dsh-headless": "workspace:^",
"@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^",
@@ -29,6 +30,7 @@
"@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^",
"@deepseek-ai/dsh-tool-schedule": "workspace:^",
"@deepseek-ai/dsh-web-app": "workspace:^", "@deepseek-ai/dsh-web-app": "workspace:^",
"commander": "^15.0.0", "commander": "^15.0.0",
"cordis": "^4.0.0-rc.7", "cordis": "^4.0.0-rc.7",

View File

@@ -27,6 +27,7 @@ This table connects model-visible tool names to the plugin package and service s
| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. |
| `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. |
| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `goal/change for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | | `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `goal/change for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. |
| `@deepseek-ai/dsh-tool-schedule` | `schedule_create`, `schedule_delete`, `schedule_list` | `ctx.tools`, `ctx.sessions`, `Session persistence`, `a future live root Agent` | `tool/call`, `schedule/change create or delete`, `tool/result` | - | Registered only inside live root Agent scopes created after the opt-in Schedule plugin loads. Version 1 accepts positive safe-integer after_seconds and discloses session-local delivery; management reads and mutations require the shared Session persistence barrier. |
| `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. |
| `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. |
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - |
@@ -826,6 +827,70 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/
create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds.
## `@deepseek-ai/dsh-tool-schedule`
### `schedule_create`
Create one reminder in the current session. v1 accepts only a non-empty prompt and a positive safe-integer after_seconds delay. Delivery is session-local: the reminder runs on time only while this session is live and otherwise becomes overdue until the session is resumed.
```json
{
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "Reminder content to present when the target becomes due."
},
"after_seconds": {
"type": "number",
"description": "Positive safe-integer delay in seconds."
}
},
"required": [
"prompt",
"after_seconds"
]
}
```
Source: [`packages/schedule/tool-schedule/src/tools.ts`](../packages/schedule/tool-schedule/src/tools.ts)
### `schedule_delete`
Delete one active reminder in the current session by the exact id returned by schedule_create or schedule_list. Unknown or already-finished ids return deleted false.
```json
{
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Exact session-local schedule id."
}
},
"required": [
"id"
]
}
```
Source: [`packages/schedule/tool-schedule/src/tools.ts`](../packages/schedule/tool-schedule/src/tools.ts)
### `schedule_list`
List every active reminder in the current session in creation order, including its exact id, UTC target, scheduled or overdue state, and session-local delivery mode.
```json
{
"type": "object",
"properties": {}
}
```
Source: [`packages/schedule/tool-schedule/src/tools.ts`](../packages/schedule/tool-schedule/src/tools.ts)
Registered only inside live root Agent scopes created after the opt-in Schedule plugin loads. Version 1 accepts positive safe-integer after_seconds and discloses session-local delivery; management reads and mutations require the shared Session persistence barrier.
## `@deepseek-ai/dsh-tool-lsp` ## `@deepseek-ai/dsh-tool-lsp`
### `lsp` ### `lsp`

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write examples/README.md # pnpm run verify-translation-pairing --write examples/README.md
README.md: 5d021d9d9c7abae90b5f96bccd6447f4e2c3dc57 README.md: 209b23d6325b1ed0db8f23ab049369efc9427af4
README.zh.md: 66b355a93c0a0e6b53d1353de4024b7f86e82f7c README.zh.md: 5a9e5615d8ccef2c1627e3facf97a30a25e1fb5e

View File

@@ -22,7 +22,7 @@ A self-referential agent that can inspect and change its in-memory Cordis plugin
## web-schedule ## web-schedule
An opt-in Web overlay for durable, Session-local reminders. It supports positive whole-second `after_seconds` reminders through `schedule_create`, `schedule_list`, and `schedule_delete`; active reminders persist in the original Session, resume when that Session becomes live again, and do not run while it is cold. Run `dsh web --config examples/web-schedule/cordis.yml`; see [web-schedule/README.md](web-schedule/README.md) for the delivery and recovery boundary. An opt-in Web overlay for durable, Session-local reminders. It supports positive whole-second `after_seconds` reminders through `schedule_create`, `schedule_list`, and `schedule_delete`; active reminders persist in the original Session, resume when that Session becomes live again, and do not run while it is cold. Run `dsh web --patch examples/web-schedule/cordis.yml`; see [web-schedule/README.md](web-schedule/README.md) for the delivery and recovery boundary.
## acp-agent ## acp-agent

View File

@@ -22,7 +22,7 @@
## web-schedule ## web-schedule
用于持久、仅限 Session 内提醒的显式 Web overlay。它通过 `schedule_create`、`schedule_list` 和 `schedule_delete` 支持正整数秒的 `after_seconds` 提醒;活动提醒保存在原 Session 中,该 Session 再次 live 时恢复,而 cold 期间不会运行。使用 `dsh web --config examples/web-schedule/cordis.yml` 启动;交付与恢复边界详见 [web-schedule/README.md](web-schedule/README.md)。 用于持久、仅限 Session 内提醒的显式 Web overlay。它通过 `schedule_create`、`schedule_list` 和 `schedule_delete` 支持正整数秒的 `after_seconds` 提醒;活动提醒保存在原 Session 中,该 Session 再次 live 时恢复,而 cold 期间不会运行。使用 `dsh web --patch examples/web-schedule/cordis.yml` 启动;交付与恢复边界详见 [web-schedule/README.md](web-schedule/README.md)。
## acp-agent ## acp-agent

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write examples/web-schedule/README.md # pnpm run verify-translation-pairing --write examples/web-schedule/README.md
README.md: 98ba3c78cfff2c6db62487db727f08825077f150 README.md: 4071bdb4f52ccd75359a91ad8269d1bc18bae521
README.zh.md: e36b12acc97313d7feaa3af55e5dc46294d7e8da README.zh.md: 10db04f9ec494d86c93141ace4f6f56fa79deaca

View File

@@ -5,7 +5,7 @@ English | [中文](README.zh.md)
This overlay opts one `dsh web` process into durable Schedule reminders without changing the shipped default Web composition: This overlay opts one `dsh web` process into durable Schedule reminders without changing the shipped default Web composition:
```sh ```sh
dsh web --config examples/web-schedule/cordis.yml dsh web --patch examples/web-schedule/cordis.yml
``` ```
The current overlay supports one-shot reminders created with a positive whole-number `after_seconds`. The model manages them through `schedule_create`, `schedule_list`, and `schedule_delete`; every result identifies the delivery mode as `session-local`. The current overlay supports one-shot reminders created with a positive whole-number `after_seconds`. The model manages them through `schedule_create`, `schedule_list`, and `schedule_delete`; every result identifies the delivery mode as `session-local`.

View File

@@ -5,7 +5,7 @@
此 overlay 让一个 `dsh web` 进程显式启用持久 Schedule 提醒,同时不改变交付的默认 Web 组合: 此 overlay 让一个 `dsh web` 进程显式启用持久 Schedule 提醒,同时不改变交付的默认 Web 组合:
```sh ```sh
dsh web --config examples/web-schedule/cordis.yml dsh web --patch examples/web-schedule/cordis.yml
``` ```
当前 overlay 支持使用正整数 `after_seconds` 创建的一次性提醒。模型通过 `schedule_create`、`schedule_list` 和 `schedule_delete` 管理它们;每个结果都会把交付模式标为 `session-local`。 当前 overlay 支持使用正整数 `after_seconds` 创建的一次性提醒。模型通过 `schedule_create`、`schedule_list` 和 `schedule_delete` 管理它们;每个结果都会把交付模式标为 `session-local`。

View File

@@ -3,9 +3,8 @@
// one, the durable event type and JSON sidecar remain inspectable in the flow. // one, the durable event type and JSON sidecar remain inspectable in the flow.
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { IconSparkle16 } from '@deepseek-ai/dsh-client-ui-primitives' import { DisclosureRow, IconSparkle16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, EventRowOwnerProps } from '../contract/slots.ts' import type { ChatViewSlotProps, EventRowOwnerProps } from '../contract/slots.ts'
import { DisclosureRow } from './DisclosureRow.tsx'
import css from './ContextInjectionRow.module.css' import css from './ContextInjectionRow.module.css'
/** Card props: the event owner payload plus the render site's locale seat. */ /** Card props: the event owner payload plus the render site's locale seat. */

View File

@@ -63,8 +63,6 @@
"lib/index.js", "lib/index.js",
"lib/invariant.js", "lib/invariant.js",
"lib/client.js", "lib/client.js",
"lib/types/**/*.d.ts", "lib/types/**/*.d.ts"
"lib/types/**/*.d.ts.map",
"src"
] ]
} }

View File

@@ -7,7 +7,6 @@ interface ReminderPresentation {
scheduleId: string scheduleId: string
prompt: string prompt: string
occurrenceAt: string occurrenceAt: string
deliveryMode: 'session-local'
} }
/** Full Schedule row props: event owner/runtime share plus the locale seat. */ /** Full Schedule row props: event owner/runtime share plus the locale seat. */
@@ -20,12 +19,10 @@ function reminderPresentation(value: unknown): ReminderPresentation | null {
if (typeof record['scheduleId'] !== 'string' || record['scheduleId'].length === 0) return null if (typeof record['scheduleId'] !== 'string' || record['scheduleId'].length === 0) return null
if (typeof record['prompt'] !== 'string') return null if (typeof record['prompt'] !== 'string') return null
if (typeof record['occurrenceAt'] !== 'string' || record['occurrenceAt'].length === 0) return null if (typeof record['occurrenceAt'] !== 'string' || record['occurrenceAt'].length === 0) return null
if (record['deliveryMode'] !== 'session-local') return null
return { return {
scheduleId: record['scheduleId'], scheduleId: record['scheduleId'],
prompt: record['prompt'], prompt: record['prompt'],
occurrenceAt: record['occurrenceAt'], occurrenceAt: record['occurrenceAt'],
deliveryMode: record['deliveryMode'],
} }
} }

View File

@@ -15,11 +15,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
} }
} }
/** export const inject = ['slots', 'locale']
* `conversation` is an ordering edge: its service is published after the chat
* entry has declared `conversation.chat.eventview`.
*/
export const inject = ['slots', 'conversation', 'locale']
/** /**
* Register bilingual copy and the Schedule reminder keyed row. * Register bilingual copy and the Schedule reminder keyed row.
@@ -27,12 +23,12 @@ export const inject = ['slots', 'conversation', 'locale']
*/ */
export function apply(ctx: ClientContext): void { export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-schedule: dictionaries') ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-schedule: dictionaries')
ctx.effect( ctx.slots.inject(
'conversation.chat.eventview',
() => ctx.slots.register({ () => ctx.slots.register({
name: 'conversation.chat.eventview', name: 'conversation.chat.eventview',
key: 'schedule/change', key: 'schedule/change',
locale: NS, locale: NS,
}, ReminderRow), }, ReminderRow),
'ui-schedule: reminder row registration',
) )
} }

View File

@@ -1,6 +1,7 @@
import { Context } from 'cordis' import { Context } from 'cordis'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '../src/client/index.ts' import { apply, inject } from '../src/client/index.ts'
import { ReminderRow } from '../src/client/ReminderRow.tsx' import { ReminderRow } from '../src/client/ReminderRow.tsx'
import { apply as nodeApply } from '../src/index.ts' import { apply as nodeApply } from '../src/index.ts'
@@ -10,41 +11,55 @@ import {
name as invariantName, name as invariantName,
} from '../src/invariant.ts' } from '../src/invariant.ts'
interface CapturedEntry { async function bench(declareBeforeApply = true) {
name: string
key?: string
locale?: string
component: unknown
}
function bench() {
const ctx = new Context() const ctx = new Context()
let entry: CapturedEntry | undefined await ctx.plugin(SlotsService)
ctx.provide('slots', { const slots = ctx.slots as unknown as {
register(options: Omit<CapturedEntry, 'component'>, component: unknown) { register: (options: object, component: unknown) => () => void
entry = { ...options, component } }
return () => { entry = undefined } const declareHost = () => slots.register({
}, name: 'root',
}) children: { 'conversation.chat.eventview': { kind: 'keyed', scope: 'session' } },
ctx.provide('conversation', {}) }, () => null)
const initialHost = declareBeforeApply ? declareHost() : undefined
ctx.provide('locale', new LocaleService(ctx)) ctx.provide('locale', new LocaleService(ctx))
const fiber = ctx.plugin({ inject: [...inject], apply }) const fiber = ctx.plugin({ inject: [...inject], apply })
return { ctx, fiber, entry: () => entry } await fiber.await()
return {
ctx,
fiber,
declareHost,
initialHost,
entry: () => ctx.slots.entries('conversation.chat.eventview')[0],
}
} }
describe('ui-schedule browser plugin', () => { describe('ui-schedule browser plugin', () => {
it('registers the keyed reminder renderer and unloads it with the fiber', async () => { it('registers the keyed reminder renderer and unloads it with the fiber', async () => {
const b = bench() const b = await bench()
await b.fiber.await() expect(b.entry()?.options).toEqual({ key: 'schedule/change' })
expect(b.entry()).toEqual({ expect(b.entry()?.locale).toBe('schedule')
name: 'conversation.chat.eventview', expect(b.entry()?.component).toBe(ReminderRow)
key: 'schedule/change',
locale: 'schedule',
component: ReminderRow,
})
await b.fiber.dispose() await b.fiber.dispose()
expect(b.entry()).toBeUndefined() expect(b.entry()).toBeUndefined()
b.initialHost?.()
})
it('follows delayed declaration, collapse, and redeclaration until contributor disposal', async () => {
const b = await bench(false)
expect(b.entry()).toBeUndefined()
const firstHost = b.declareHost()
expect(b.entry()?.component).toBe(ReminderRow)
firstHost()
expect(b.entry()).toBeUndefined()
const secondHost = b.declareHost()
expect(b.entry()?.component).toBe(ReminderRow)
await b.fiber.dispose()
expect(b.entry()).toBeUndefined()
secondHost()
}) })
}) })

View File

@@ -19,7 +19,6 @@ const invalidSidecars: ReadonlyArray<{ name: string; view: unknown }> = [
scheduleId: null, scheduleId: null,
prompt: 'not trusted', prompt: 'not trusted',
occurrenceAt: '2026-08-05T08:00:00.000Z', occurrenceAt: '2026-08-05T08:00:00.000Z',
deliveryMode: 'session-local',
}, },
}, },
{ {
@@ -28,7 +27,6 @@ const invalidSidecars: ReadonlyArray<{ name: string; view: unknown }> = [
scheduleId: '', scheduleId: '',
prompt: 'not trusted', prompt: 'not trusted',
occurrenceAt: '2026-08-05T08:00:00.000Z', occurrenceAt: '2026-08-05T08:00:00.000Z',
deliveryMode: 'session-local',
}, },
}, },
{ {
@@ -37,7 +35,6 @@ const invalidSidecars: ReadonlyArray<{ name: string; view: unknown }> = [
scheduleId: 'schedule-7', scheduleId: 'schedule-7',
prompt: 7, prompt: 7,
occurrenceAt: '2026-08-05T08:00:00.000Z', occurrenceAt: '2026-08-05T08:00:00.000Z',
deliveryMode: 'session-local',
}, },
}, },
{ {
@@ -46,7 +43,6 @@ const invalidSidecars: ReadonlyArray<{ name: string; view: unknown }> = [
scheduleId: 'schedule-7', scheduleId: 'schedule-7',
prompt: 'not trusted', prompt: 'not trusted',
occurrenceAt: 7, occurrenceAt: 7,
deliveryMode: 'session-local',
}, },
}, },
{ {
@@ -55,16 +51,6 @@ const invalidSidecars: ReadonlyArray<{ name: string; view: unknown }> = [
scheduleId: 'schedule-7', scheduleId: 'schedule-7',
prompt: 'not trusted', prompt: 'not trusted',
occurrenceAt: '', occurrenceAt: '',
deliveryMode: 'session-local',
},
},
{
name: 'unsupported delivery mode',
view: {
scheduleId: 'schedule-7',
prompt: 'not trusted',
occurrenceAt: '2026-08-05T08:00:00.000Z',
deliveryMode: 'external',
}, },
}, },
] ]
@@ -88,7 +74,6 @@ describe('ReminderRow', () => {
scheduleId: 'schedule-7', scheduleId: 'schedule-7',
prompt: 'Check the deploy', prompt: 'Check the deploy',
occurrenceAt: '2026-08-05T08:00:00.000Z', occurrenceAt: '2026-08-05T08:00:00.000Z',
deliveryMode: 'session-local',
})} />) })} />)
expect(screen.getByRole('note')).toBeTruthy() expect(screen.getByRole('note')).toBeTruthy()

View File

@@ -198,7 +198,7 @@ describe('sessions.flush()', () => {
const checkpoints: number[] = [] const checkpoints: number[] = []
ctx.on('session/flushed', (_session, throughSeq) => { checkpoints.push(throughSeq) }) ctx.on('session/flushed', (_session, throughSeq) => { checkpoints.push(throughSeq) })
const session = ctx.sessions.create() const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1 })
const first = ctx.sessions.flush(session) const first = ctx.sessions.flush(session)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -256,7 +256,7 @@ describe('sessions.flush()', () => {
const session = ctx.sessions.create() const session = ctx.sessions.create()
const first = ctx.sessions.flush(session) const first = ctx.sessions.flush(session)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1 })
const second = ctx.sessions.flush(session) const second = ctx.sessions.flush(session)
secondGate.resolve(undefined) secondGate.resolve(undefined)
await second await second

View File

@@ -1115,13 +1115,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const persistence = ctx.get('sessionPersistence') const persistence = ctx.get('sessionPersistence')
if (persistence !== undefined) { if (persistence !== undefined) {
try { try {
const stored = await persistence.inspect(sessionId) const stored = await persistence.readFrom(sessionId, 0)
presentedThroughSeq = identityMatchingStoredPrefix(attached, events, stored) presentedThroughSeq = identityMatchingStoredPrefix(attached, events, stored)
} catch (error: unknown) { } catch (error: unknown) {
// Attached history remains available from the live Session. A // Attached history remains available from the live Session. A
// failed or not-yet-materialized inspection only withholds // failed or not-yet-materialized physical read only withholds
// commit-gated event presentation sidecars. // commit-gated event presentation sidecars.
ctx.logger.warn(`session.history: persistence inspection for attached "${sessionId}" failed; serving raw events: ${String(error)}`) ctx.logger.warn(`session.history: physical persistence read for attached "${sessionId}" failed; serving raw events: ${String(error)}`)
} }
} }
return { return {
@@ -1133,10 +1133,25 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
} }
const inspected = await inspectServable(sessionId) const inspected = await inspectServable(sessionId)
const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined
let presentedThroughSeq = 0
const persistence = ctx.get('sessionPersistence')
/* v8 ignore next -- inspectServable already rejects when persistence is absent */
if (persistence !== undefined) {
try {
const stored = await persistence.readFrom(sessionId, 0)
presentedThroughSeq = identityMatchingStoredPrefix(
{ header: inspected.meta },
inspected.events,
stored,
)
} catch (error: unknown) {
ctx.logger.warn(`session.history: physical persistence read for detached "${sessionId}" failed; serving raw events: ${String(error)}`)
}
}
return { return {
header: inspected.meta, header: inspected.meta,
events: inspected.events, events: inspected.events,
presentedThroughSeq: inspected.events.length, presentedThroughSeq,
...projections === undefined ? {} : { projections }, ...projections === undefined ? {} : { projections },
} }
} }

View File

@@ -83,7 +83,7 @@ describe('commit-aware Schedule live views', () => {
const ctx = await harness({ const ctx = await harness({
handler: () => ++calls === 1 ? first.promise : true, handler: () => ++calls === 1 ? first.promise : true,
}) })
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const abort = new AbortController() const abort = new AbortController()
const collected = collectEvents( const collected = collectEvents(
api.events.mux({ rpcId: RpcId('schedule-live'), payload: {} }, abort.signal), api.events.mux({ rpcId: RpcId('schedule-live'), payload: {} }, abort.signal),
@@ -110,14 +110,14 @@ describe('commit-aware Schedule live views', () => {
for: 'event', for: 'event',
view: { view: {
scheduleId: 'schedule-1', prompt: 'first', scheduleId: 'schedule-1', prompt: 'first',
occurrenceAt: '2026-08-05T12:00:01.000Z', deliveryMode: 'session-local', occurrenceAt: '2026-08-05T12:00:01.000Z',
}, },
}, },
{ {
for: 'event', for: 'event',
view: { view: {
scheduleId: 'schedule-2', prompt: 'second', scheduleId: 'schedule-2', prompt: 'second',
occurrenceAt: '2026-08-05T12:00:01.000Z', deliveryMode: 'session-local', occurrenceAt: '2026-08-05T12:00:01.000Z',
}, },
}, },
]) ])
@@ -130,7 +130,7 @@ describe('commit-aware Schedule live views', () => {
const ctx = await harness({ const ctx = await harness({
handler: () => ++calls === 1 ? Promise.reject(new Error('disk unavailable')) : true, handler: () => ++calls === 1 ? Promise.reject(new Error('disk unavailable')) : true,
}) })
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const abort = new AbortController() const abort = new AbortController()
const collected = collectEvents( const collected = collectEvents(
api.events.mux({ rpcId: RpcId('schedule-retry'), payload: {} }, abort.signal), api.events.mux({ rpcId: RpcId('schedule-retry'), payload: {} }, abort.signal),
@@ -171,9 +171,9 @@ describe('Schedule history views', () => {
}) })
const child = ctx.sessions.fork(resumed, undefined, SessionId('schedule-fork')) const child = ctx.sessions.fork(resumed, undefined, SessionId('schedule-fork'))
ctx.provide('sessionPersistence', { ctx.provide('sessionPersistence', {
inspect: () => Promise.resolve({ meta: child.header, events: [...child.events] }), readFrom: () => Promise.resolve({ meta: child.header, events: [...child.events] }),
} as never) } as never)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const response = await api.sessions.history({ const response = await api.sessions.history({
rpcId: RpcId('schedule-resumed-fork'), payload: { sessionId: child.id }, rpcId: RpcId('schedule-resumed-fork'), payload: { sessionId: child.id },
@@ -185,7 +185,6 @@ describe('Schedule history views', () => {
scheduleId, scheduleId,
prompt: 'after restart', prompt: 'after restart',
occurrenceAt: '2026-08-05T12:00:01.000Z', occurrenceAt: '2026-08-05T12:00:01.000Z',
deliveryMode: 'session-local',
}, },
}) })
await ctx.fiber.dispose() await ctx.fiber.dispose()
@@ -199,14 +198,14 @@ describe('Schedule history views', () => {
seed: [...parent.events], seed: [...parent.events],
meta: { cwd: '/tmp', parentSession: parent.id, seedLength: 2 }, meta: { cwd: '/tmp', parentSession: parent.id, seedLength: 2 },
}) })
let inspect = (): Promise<{ meta: SessionHeader; events: SessionEvent[] }> => Promise.resolve({ let readFrom = (): Promise<{ meta: SessionHeader; events: SessionEvent[] }> => Promise.resolve({
meta: session.header, meta: session.header,
events: [...session.events.slice(0, 1)], events: [...session.events.slice(0, 1)],
}) })
ctx.provide('sessionPersistence', { ctx.provide('sessionPersistence', {
inspect: () => inspect(), readFrom: () => readFrom(),
} as never) } as never)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const history = async () => { const history = async () => {
const response = await api.sessions.history({ const response = await api.sessions.history({
rpcId: RpcId('schedule-history'), payload: { sessionId: session.id }, rpcId: RpcId('schedule-history'), payload: { sessionId: session.id },
@@ -216,19 +215,19 @@ describe('Schedule history views', () => {
} }
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined() expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
inspect = () => Promise.resolve({ readFrom = () => Promise.resolve({
meta: { ...session.header, delegationDepth: 0 }, meta: { ...session.header, delegationDepth: 0 },
events: [...session.events.slice(0, 2)], events: [...session.events.slice(0, 2)],
}) })
expect((await history()).find(entry => entry.event.seq === 1)?.view).toMatchObject({ expect((await history()).find(entry => entry.event.seq === 1)?.view).toMatchObject({
for: 'event', for: 'event',
}) })
inspect = () => Promise.resolve({ readFrom = () => Promise.resolve({
meta: { ...session.header, cwd: '/different', delegationDepth: 0 }, meta: { ...session.header, cwd: '/different', delegationDepth: 0 },
events: [...session.events.slice(0, 2)], events: [...session.events.slice(0, 2)],
}) })
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined() expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
inspect = () => Promise.reject(new Error('inspect unavailable')) readFrom = () => Promise.reject(new Error('physical read unavailable'))
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined() expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
await ctx.fiber.dispose() await ctx.fiber.dispose()
}) })
@@ -247,8 +246,9 @@ describe('Schedule history views', () => {
ctx.provide('sessionPersistence', { ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]), list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }), inspect: () => Promise.resolve({ meta, events }),
readFrom: () => Promise.resolve({ meta, events }),
} as never) } as never)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const response = await api.sessions.history({ const response = await api.sessions.history({
rpcId: RpcId('schedule-cold'), payload: { sessionId: meta.id }, rpcId: RpcId('schedule-cold'), payload: { sessionId: meta.id },
}) })
@@ -258,4 +258,36 @@ describe('Schedule history views', () => {
}) })
await ctx.fiber.dispose() await ctx.fiber.dispose()
}) })
it('withholds a detached view that exists only in a logical inspection', async () => {
const ctx = await harness()
let source: Session | undefined
const owner = await ctx.plugin(Object.assign((inner: Context) => {
source = inner.sessions.create(SessionId('schedule-logical-only'), { meta: { cwd: '/tmp' } })
}, { inject: ['sessions'] }))
if (source === undefined) throw new Error('session owner did not publish its session')
appendReminder(source, 'schedule-logical', 'not physically committed')
const meta = source.header
const events = [...source.events]
await owner.dispose()
let physicalEvents = events.slice(0, 1)
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }),
readFrom: () => Promise.resolve({ meta, events: physicalEvents }),
} as never)
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const history = async () => {
const response = await api.sessions.history({
rpcId: RpcId('schedule-logical-only-history'), payload: { sessionId: meta.id },
})
if (!response.result.ok) throw new Error(response.result.error.message)
return response.result.value.events
}
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
physicalEvents = events
expect((await history()).find(entry => entry.event.seq === 1)?.view).toMatchObject({ for: 'event' })
await ctx.fiber.dispose()
})
}) })

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/schedule/tool-schedule/README.md # pnpm run verify-translation-pairing --write packages/schedule/tool-schedule/README.md
README.md: 55842c3cb49c43b5c577835a26ef43e6ad452dfd README.md: 8068e649d2116da628af1436e1e3cc71b09dcaa0
README.zh.md: 8738ac6b4516a1933b206b6baee5bb3d7d77d23a README.zh.md: 72367b421a8b8dbf5ac866933684740be82157bf

View File

@@ -32,7 +32,7 @@ The closed v1 domain error codes are `invalid_prompt`, `invalid_selector`, `inva
The live owner derives the earliest target from the durable fold. It splits waits longer than the Node timer range and rereads the wall clock after every wake, so a rollback cannot fire early and a forward jump makes the record overdue. The live owner derives the earliest target from the durable fold. It splits waits longer than the Node timer range and rereads the wall clock after every wake, so a rollback cannot fire early and a forward jump makes the record overdue.
An overdue reminder first checkpoints persistence. If `reserveTurnAdmission()` returns `undefined`, the record stays active and the owner retries after `whenIdle()`. A successful reservation samples one decision time, builds the complete framing, synchronously queues `followup()`, appends an id-only dispatch, releases in `finally`, and then checkpoints the dispatch. Framing or synchronous followup failure writes no dispatch. An append failure faults that owner because the message may already be queued; a barrier rejection leaves the dispatch pending for a later ordinary preflight and does not start a private retry timer. An overdue reminder first checkpoints persistence. If a turn or another maintenance task already owns the Agent, `runMaintenance()` rejects the idle-phase claim; the record stays active and the owner retries after `whenIdle()`. A successful maintenance task samples one decision time, builds the complete framing, synchronously queues `followup()`, and appends an id-only dispatch before releasing the phase. Waking input remains parked until that release, after which the owner checkpoints dispatch. Framing or synchronous followup failure writes no dispatch. An append failure faults that owner because the message may already be queued; a barrier rejection leaves the dispatch pending for a later ordinary preflight and does not start a private retry timer.
Agent or plugin disposal cancels timers, stops new work, and awaits in-flight preflights and idle waits. It never appends delete records during teardown. Agent or plugin disposal cancels timers, stops new work, and awaits in-flight preflights and idle waits. It never appends delete records during teardown.

View File

@@ -32,7 +32,7 @@
live owner 从持久折叠结果派生最早的目标。它会拆分超过 Node timer 范围的等待,并在每次唤醒后重新读取墙钟,因此时钟回拨不会提前触发,时钟前跳则会使记录进入 overdue 状态。 live owner 从持久折叠结果派生最早的目标。它会拆分超过 Node timer 范围的等待,并在每次唤醒后重新读取墙钟,因此时钟回拨不会提前触发,时钟前跳则会使记录进入 overdue 状态。
overdue 提醒首先为持久化建立检查点。如果 `reserveTurnAdmission()` 返回 `undefined`,记录会保持活动,并在 `whenIdle()` 后重试。reservation 成功后,owner 会采样一次决策时间,构造完整 framing,同步将 `followup()` 入队,追加只含 id 的 dispatch,在 `finally` 中释放 reservation,随后为 dispatch 建立检查点。framing 构造或同步 `followup` 失败不会写入 dispatch。追加失败会使该 owner 进入故障状态,因为消息可能已经入队;barrier 拒绝会把 dispatch 留给后续普通 preflight 处理,而不会启动私有重试 timer。 overdue 提醒首先为持久化建立检查点。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝对 idle phase 的认领;记录会保持活动,owner 会在 `whenIdle()` 后重试。获准执行的 maintenance task 会采样一次决策时间,构造完整 framing,同步将 `followup()` 入队,并在释放 phase 前追加只含 id 的 dispatch。触发唤醒的 input 会保持 parked,直到该 phase 释放;随后 owner 为 dispatch 建立检查点。framing 构造或同步 `followup` 失败不会写入 dispatch。追加失败会使该 owner 进入故障状态,因为消息可能已经入队;barrier 拒绝会把 dispatch 留给后续普通 preflight 处理,而不会启动私有重试 timer。
agent 或插件执行 dispose(资源释放)时,会取消 timer、停止新工作,并等待进行中的 preflight 和 idle wait。清理期间绝不会追加 delete 记录。 agent 或插件执行 dispose(资源释放)时,会取消 timer、停止新工作,并等待进行中的 preflight 和 idle wait。清理期间绝不会追加 delete 记录。

View File

@@ -21,9 +21,7 @@
"files": [ "files": [
"lib/index.js", "lib/index.js",
"lib/invariant.js", "lib/invariant.js",
"lib/types/**/*.d.ts", "lib/types/**/*.d.ts"
"lib/types/**/*.d.ts.map",
"src"
], ],
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"peerDependencies": { "peerDependencies": {

View File

@@ -328,7 +328,6 @@ export function scheduleReminderPresentation(
scheduleId: change.schedule.id, scheduleId: change.schedule.id,
prompt: change.schedule.prompt, prompt: change.schedule.prompt,
occurrenceAt: change.schedule.scheduledAt, occurrenceAt: change.schedule.scheduledAt,
deliveryMode: 'session-local',
}) })
case 'delete': case 'delete':
case 'dispatch': case 'dispatch':

View File

@@ -38,13 +38,13 @@ export function apply(ctx: Context): void {
let stopping = false let stopping = false
ctx.effect(() => { ctx.effect(() => {
const stopCreated = ctx.on('agent/created', (agent) => { const stopCreated = ctx.on('agent/created', ({ agent }) => {
if (stopping || owners.has(agent) || !ctx.agents.roots().includes(agent)) return if (stopping || owners.has(agent) || !ctx.agents.roots().includes(agent)) return
const owner = new ScheduleOwner(ctx, agent) const owner = new ScheduleOwner(ctx, agent)
const cleanup: OwnerCleanup = agent.ctx.effect(() => { const cleanup: OwnerCleanup = agent.ctx.effect(() => {
const disposeTools = registerScheduleTools(ctx, agent.ctx, agent, () => { owner.requestDrive() }) const disposeTools = registerScheduleTools(ctx, agent.ctx, agent, () => { owner.requestDrive() })
const stopStatus = agent.ctx.on('agent/status', (subject, status) => { const stopStatus = agent.ctx.on('agent/status', ({ status }) => {
if (subject === agent && status === 'idle') owner.requestDrive() if (status === 'idle') owner.requestDrive()
}) })
owner.start() owner.start()
return async () => { return async () => {

View File

@@ -72,8 +72,6 @@ export interface ScheduleReminderPresentation {
readonly prompt: string readonly prompt: string
/** Scheduled one-shot occurrence represented by the dispatch. */ /** Scheduled one-shot occurrence represented by the dispatch. */
readonly occurrenceAt: string readonly occurrenceAt: string
/** Fixed delivery boundary rendered by the client plugin. */
readonly deliveryMode: ScheduleDeliveryMode
} }
/** Management operations whose persistence barrier may be uncertain. */ /** Management operations whose persistence barrier may be uncertain. */

View File

@@ -101,13 +101,11 @@ describe('version-1 Schedule decoding and folding', () => {
scheduleId: 'same-id', scheduleId: 'same-id',
prompt: 'parent prompt', prompt: 'parent prompt',
occurrenceAt: '2026-08-05T12:00:00.000Z', occurrenceAt: '2026-08-05T12:00:00.000Z',
deliveryMode: 'session-local',
}) })
expect(scheduleReminderPresentation(events, 3, 2)).toEqual({ expect(scheduleReminderPresentation(events, 3, 2)).toEqual({
scheduleId: 'same-id', scheduleId: 'same-id',
prompt: 'child prompt', prompt: 'child prompt',
occurrenceAt: '2026-08-05T12:00:00.000Z', occurrenceAt: '2026-08-05T12:00:00.000Z',
deliveryMode: 'session-local',
}) })
const nested = [ const nested = [
scheduleEvent(createData('same-id', 'grandparent prompt'), 0), scheduleEvent(createData('same-id', 'grandparent prompt'), 0),
@@ -120,7 +118,6 @@ describe('version-1 Schedule decoding and folding', () => {
scheduleId: 'same-id', scheduleId: 'same-id',
prompt: 'parent prompt', prompt: 'parent prompt',
occurrenceAt: '2026-08-05T12:00:00.000Z', occurrenceAt: '2026-08-05T12:00:00.000Z',
deliveryMode: 'session-local',
}) })
const resumedThenForked = [ const resumedThenForked = [
scheduleEvent(createData('resumed-id', 'resumed prompt'), 0), scheduleEvent(createData('resumed-id', 'resumed prompt'), 0),
@@ -131,7 +128,6 @@ describe('version-1 Schedule decoding and folding', () => {
scheduleId: 'resumed-id', scheduleId: 'resumed-id',
prompt: 'resumed prompt', prompt: 'resumed prompt',
occurrenceAt: '2026-08-05T12:00:00.000Z', occurrenceAt: '2026-08-05T12:00:00.000Z',
deliveryMode: 'session-local',
}) })
expect(() => scheduleReminderPresentation([ expect(() => scheduleReminderPresentation([
scheduleEvent(createData('parent-only'), 0), scheduleEvent(createData('parent-only'), 0),

View File

@@ -37,7 +37,7 @@ describe('Schedule package invariant', () => {
it('accepts valid candidates and rejects invalid transitions before append', async () => { it('accepts valid candidates and rejects invalid transitions before append', async () => {
const { ctx } = await harness() const { ctx } = await harness()
const session = ctx.sessions.create(SessionId('schedule-invariant')) const session = ctx.sessions.create(SessionId('schedule-invariant'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1 })
session.append('schedule/change', create('schedule-1')) session.append('schedule/change', create('schedule-1'))
expect(session.events).toHaveLength(2) expect(session.events).toHaveLength(2)

View File

@@ -124,7 +124,6 @@ describe('Schedule production JSONL restart', () => {
scheduleId: 'schedule-1', scheduleId: 'schedule-1',
prompt: 'restart reminder', prompt: 'restart reminder',
occurrenceAt: pendingRecord.scheduledAt, occurrenceAt: pendingRecord.scheduledAt,
deliveryMode: 'session-local',
}) })
expect(dispatchingAdapter.requests).toHaveLength(1) expect(dispatchingAdapter.requests).toHaveLength(1)
await handle.dispose() await handle.dispose()

View File

@@ -55,8 +55,8 @@ describe('Schedule plugin composition', () => {
expect(created.isError).toBe(false) expect(created.isError).toBe(false)
if (created.isError) throw new Error('expected Schedule create value') if (created.isError) throw new Error('expected Schedule create value')
expect(created.value).toMatchObject({ id: 'schedule-1', deliveryMode: 'session-local' }) expect(created.value).toMatchObject({ id: 'schedule-1', deliveryMode: 'session-local' })
agentEvents(ctx, root.agent).emit('agent/status', 'running') agentEvents(ctx, root.agent).emit('agent/status', { status: 'running' })
agentEvents(ctx, root.agent).emit('agent/status', 'idle') agentEvents(ctx, root.agent).emit('agent/status', { status: 'idle' })
const child = await root.agent.ctx.agents.create({ sessionId: SessionId('schedule-child') }) const child = await root.agent.ctx.agents.create({ sessionId: SessionId('schedule-child') })
expect(ctx.agents.roots()).toEqual([existing.agent, root.agent]) expect(ctx.agents.roots()).toEqual([existing.agent, root.agent])

View File

@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis' import { Context } from 'cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentCancelCause, SendOptions } from '@deepseek-ai/dsh-agent' import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agent'
import type { UserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import { import {
@@ -26,6 +26,7 @@ interface RuntimeHarness {
flushCount: number flushCount: number
flushOutcomes: Array<'resolve' | 'reject'> flushOutcomes: Array<'resolve' | 'reject'>
flushHandler: (() => Promise<void> | undefined) | undefined flushHandler: (() => Promise<void> | undefined) | undefined
onBusy: (() => void) | undefined
onReserve: (() => void) | undefined onReserve: (() => void) | undefined
onFollowup: (() => void) | undefined onFollowup: (() => void) | undefined
idle: PromiseWithResolvers<undefined> idle: PromiseWithResolvers<undefined>
@@ -49,30 +50,35 @@ async function harness(): Promise<RuntimeHarness> {
flushCount: 0, flushCount: 0,
flushOutcomes: [] as Array<'resolve' | 'reject'>, flushOutcomes: [] as Array<'resolve' | 'reject'>,
flushHandler: undefined as (() => Promise<void> | undefined) | undefined, flushHandler: undefined as (() => Promise<void> | undefined) | undefined,
onBusy: undefined as (() => void) | undefined,
onReserve: undefined as (() => void) | undefined, onReserve: undefined as (() => void) | undefined,
onFollowup: undefined as (() => void) | undefined, onFollowup: undefined as (() => void) | undefined,
idle: Promise.withResolvers<undefined>(), idle: Promise.withResolvers<undefined>(),
} }
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const agent: Agent = { const agent: Agent = {
id: session.id, id: session.id,
options: {}, options: {},
session, session,
inbox,
status: 'idle', status: 'idle',
acceptsNextStep: false,
ctx: new Context(), ctx: new Context(),
send(_message: UserMessage, _options: SendOptions) {}, send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {},
updateInbox: () => 'not-found', runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> {
reserveTurnAdmission() { order.push('maintenance')
order.push('reserve') if (!controls.canReserve) {
if (!controls.canReserve) return undefined controls.onBusy?.()
controls.onReserve?.() throw new Error('agent busy')
let active = true
return () => {
if (!active) return
active = false
controls.releaseCount += 1
order.push('release')
} }
controls.onReserve?.()
return (async () => {
try {
return await task(new AbortController().signal)
} finally {
controls.releaseCount += 1
order.push('release')
}
})()
}, },
cancel(_cause: AgentCancelCause) {}, cancel(_cause: AgentCancelCause) {},
whenIdle() { whenIdle() {
@@ -86,7 +92,7 @@ async function harness(): Promise<RuntimeHarness> {
if (controls.throwFollowup) throw new Error('queue unavailable') if (controls.throwFollowup) throw new Error('queue unavailable')
followed.push(message) followed.push(message)
}, },
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), steer(_message: UserMessage) {},
inject(_message: UserMessage) {}, inject(_message: UserMessage) {},
} }
const disposeAgent = ctx.agents.register(agent) const disposeAgent = ctx.agents.register(agent)
@@ -195,7 +201,7 @@ describe('Schedule timer and admission runtime', () => {
await owner.dispose() await owner.dispose()
}) })
it('keeps an overdue record active until whenIdle permits reservation', async () => { it('keeps an overdue record active until whenIdle permits maintenance', async () => {
const test = await harness() const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000) appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
test.controls.canReserve = false test.controls.canReserve = false
@@ -219,7 +225,7 @@ describe('Schedule timer and admission runtime', () => {
await owner.dispose() await owner.dispose()
}) })
it('orders preflight, reservation, framing followup, dispatch, release, and barrier', async () => { it('orders preflight, maintenance, framing followup, dispatch, release, and barrier', async () => {
const test = await harness() const test = await harness()
appendAfter(test, 'schedule-"1', 1, Date.now() - 1_000, 'line\noccurrence_at: forged') appendAfter(test, 'schedule-"1', 1, Date.now() - 1_000, 'line\noccurrence_at: forged')
test.order.length = 0 test.order.length = 0
@@ -227,7 +233,7 @@ describe('Schedule timer and admission runtime', () => {
owner.start() owner.start()
await settle() await settle()
expect(test.order.slice(0, 6)).toEqual(['flush', 'reserve', 'followup', 'dispatch', 'release', 'flush']) expect(test.order.slice(0, 6)).toEqual(['flush', 'maintenance', 'followup', 'dispatch', 'release', 'flush'])
expect(test.followed[0]?.content).toEqual([{ expect(test.followed[0]?.content).toEqual([{
type: 'text', type: 'text',
text: [ text: [
@@ -259,7 +265,7 @@ describe('Schedule timer and admission runtime', () => {
await owner.dispose() await owner.dispose()
}) })
it('rechecks the wall clock after reservation before queuing', async () => { it('rechecks the wall clock after claiming maintenance before queuing', async () => {
const test = await harness() const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000) appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
test.controls.onReserve = () => { test.controls.onReserve = () => {
@@ -548,7 +554,7 @@ describe('Schedule runtime failure and teardown boundaries', () => {
expect(departedRun.followed).toEqual([]) expect(departedRun.followed).toEqual([])
}) })
it('releases admission without work when liveness changes during reservation', async () => { it('releases maintenance without work when liveness changes during its claim', async () => {
const test = await harness() const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000) appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
test.controls.onReserve = test.disposeAgent test.controls.onReserve = test.disposeAgent
@@ -558,6 +564,17 @@ describe('Schedule runtime failure and teardown boundaries', () => {
expect(test.controls.releaseCount).toBe(1) expect(test.controls.releaseCount).toBe(1)
expect(test.followed).toEqual([]) expect(test.followed).toEqual([])
await owner.dispose() await owner.dispose()
const busy = await harness()
appendAfter(busy, 'schedule-1', 1, Date.now() - 1_000)
busy.controls.canReserve = false
busy.controls.onBusy = busy.disposeAgent
const busyOwner = ownerFor(busy)
busyOwner.start()
await settle()
expect(busy.controls.whenIdleCount).toBe(0)
expect(busy.followed).toEqual([])
await busyOwner.dispose()
}) })
it('waits for in-flight preflight during dispose and does no post-dispose work', async () => { it('waits for in-flight preflight during dispose and does no post-dispose work', async () => {

View File

@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis' import { Context } from 'cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentCancelCause, SendOptions } from '@deepseek-ai/dsh-agent' import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm' import { CallId } from '@deepseek-ai/dsh-llm'
import type { UserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -24,20 +24,20 @@ interface ToolHarness {
function stubAgent(ctx: Context, id: string): Agent { function stubAgent(ctx: Context, id: string): Agent {
const session = ctx.sessions.create(SessionId(id)) const session = ctx.sessions.create(SessionId(id))
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
return { return {
id: session.id, id: session.id,
options: {}, options: {},
session, session,
inbox,
status: 'idle', status: 'idle',
acceptsNextStep: false,
ctx: new Context(), ctx: new Context(),
send(_message: UserMessage, _options: SendOptions) {}, send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {},
updateInbox: () => 'not-found', runMaintenance: task => task(signal),
reserveTurnAdmission: () => undefined,
cancel(_cause: AgentCancelCause) {}, cancel(_cause: AgentCancelCause) {},
whenIdle: () => Promise.resolve(), whenIdle: () => Promise.resolve(),
followup(_message: UserMessage) {}, followup(_message: UserMessage) {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), steer(_message: UserMessage) {},
inject(_message: UserMessage) {}, inject(_message: UserMessage) {},
} }
} }

View File

@@ -812,7 +812,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
}, },
{ {
signature: 'async flush(session: Session): Promise<boolean>', signature: 'async flush(session: Session): Promise<boolean>',
jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the checkpoint policy\'s per-request\n * barrier, goal-session\'s idle checkpoint, teardown drains, and consumers\n * that flush themselves before reading storage) must come through here\n * rather than dispatch a raw `ctx.parallel(\'session/flush\', …)` — one owner,\n * one spelling, and the scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns whether at least one durability listener participated, after every\n * listener has settled successfully.\n * @throws the first registered listener failure after every listener settles.\n */', jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the checkpoint policy\'s per-request\n * barrier, goal-session\'s idle checkpoint, teardown drains, and consumers\n * that flush themselves before reading storage) must come through here\n * rather than dispatch a raw `ctx.parallel(\'session/flush\', …)` — one owner,\n * one spelling, and the scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns whether at least one listener acknowledged completed durability,\n * after every listener has settled successfully.\n * @throws the first registered listener failure after every listener settles.\n */',
}, },
{ {
signature: 'get(id: SessionId): Session | undefined', signature: 'get(id: SessionId): Session | undefined',
@@ -1481,9 +1481,16 @@ export const EVENT_API: readonly EventApiEntry[] = [
{ {
name: 'session/flush', name: 'session/flush',
mode: 'parallel', mode: 'parallel',
signature: '\'session/flush\'(this: Scoped<Session>, session: Session): Promise<void> | void', signature: '\'session/flush\'(this: Scoped<Session>, session: Session): Promise<true | void> | true | void',
jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */', jsDoc: '/**\n * Awaited parallel checkpoint: every listener runs and the caller awaits\n * all of them, with no waterfall veto. A listener returns literal `true`\n * only after completing durability work; observe-only listeners return\n * void. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the\n * session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */',
summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.', summary: 'Awaited parallel checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.',
},
{
name: 'session/flushed',
mode: 'emit',
signature: '\'session/flushed\'(this: Scoped<Session>, session: Session, throughSeq: number): void',
jsDoc: '/**\n * Observe a successful durability checkpoint. `throughSeq` is the exclusive\n * event boundary captured when {@link SessionStore.flush} began; events\n * appended while its listeners run require a later successful checkpoint.\n * Concurrent checkpoints may publish their boundaries out of order, so a\n * consumer retaining progress must advance by the maximum observed value.\n * No notification is published when no durability listener participated or\n * any listener failed. Observer failures are logged and contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session\'s\n * owner scope.\n * @param session - the session whose prefix completed the checkpoint.\n * @param throughSeq - exclusive event sequence boundary proven by the checkpoint.\n * @dshScopeScan unsupported\n * @mode emit\n */',
summary: 'Observe a successful durability checkpoint.',
}, },
{ {
name: 'settings/document-updated', name: 'settings/document-updated',

View File

@@ -1125,8 +1125,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
/** Build one live controller whose write readiness retries the immutable initial prefix. */ /** Build one live controller whose write readiness retries the immutable initial prefix. */
private createLiveState(session: Session): LiveSessionState { private createLiveState(session: Session): LiveSessionState {
let live: LiveSessionState const live: LiveSessionState = {
live = {
init: undefined, init: undefined,
writes: this.createWriteBehind(session, () => this.ensureInitialized(session, live)), writes: this.createWriteBehind(session, () => this.ensureInitialized(session, live)),
} }

View File

@@ -399,7 +399,7 @@ describe('PersistenceCoordinator retryable live initialization', () => {
const session = ctx.sessions.create(SessionId('retry-new-empty')) const session = ctx.sessions.create(SessionId('retry-new-empty'))
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) }) await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) })
const first = ctx.sessions.flush(session) const first = ctx.sessions.flush(session)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
loadGate.resolve(undefined) loadGate.resolve(undefined)
await expect(first).rejects.toThrow('transient init read failure') await expect(first).rejects.toThrow('transient init read failure')