From e0df95180275484b2cc90cd2e88f72db8c48945d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 06:15:18 +0000 Subject: [PATCH] Register taskDrive with Cordis provide A live context refuses the bare ctx.taskDrive write, so startup dies before any Bot loads. Register the handle the same way as flybot. Fixes #1120 Co-authored-by: Danyang Chen --- changelog.d/1120.fixed.md | 1 + core/src/host/task-drive.js | 17 +++++++- core/test/task-drive.test.js | 46 ++++++++++++++++++++ docs/subsystems/core-host.md | 2 + patches/ds-bot/files/src/host/task-drive.js | 17 +++++++- patches/ds-bot/files/test/task-drive.test.js | 46 ++++++++++++++++++++ 6 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 changelog.d/1120.fixed.md diff --git a/changelog.d/1120.fixed.md b/changelog.d/1120.fixed.md new file mode 100644 index 00000000..64d14765 --- /dev/null +++ b/changelog.d/1120.fixed.md @@ -0,0 +1 @@ +Starting FlyBot on DSH no longer stops before the Main Bot is ready: the task drive registers its handle with Cordis `provide` instead of a property write the host rejects ([#1120](https://github.com/ThinkFlowLab/FlyBot/issues/1120)). diff --git a/core/src/host/task-drive.js b/core/src/host/task-drive.js index ac1cd6cf..48cf7739 100644 --- a/core/src/host/task-drive.js +++ b/core/src/host/task-drive.js @@ -221,10 +221,25 @@ export function install(rt) { if (event.data?.source?.kind === 'user') clearMiss(session?.id) }) - ctx.taskDrive = { + // A live Cordis context refuses `ctx.taskDrive = …` once the plugin fiber is + // active (`cannot set property "taskDrive" without provide`). Same order as + // provideFlyBot: provide, then reflect.provide, then set, then a plain write. + const api = { get now() { return clock.now }, set now(fn) { clock.now = fn }, settled: () => tail, consider: () => consider(), } + const provide = typeof ctx.provide === 'function' ? ctx.provide.bind(ctx) : typeof ctx.reflect?.provide === 'function' ? ctx.reflect.provide.bind(ctx.reflect) : undefined + if (provide) { + provide('taskDrive', api) + return + } + if (typeof ctx.set === 'function') { + try { + ctx.set('taskDrive', api) + return + } catch { /* the key is refused: fall through to the plain property */ } + } + ctx.taskDrive = api } diff --git a/core/test/task-drive.test.js b/core/test/task-drive.test.js index c17e07aa..3629b7aa 100644 --- a/core/test/task-drive.test.js +++ b/core/test/task-drive.test.js @@ -361,6 +361,52 @@ test('a board card that is not a conversation task does not wake', async (t) => assert.equal(app.calls('s').length, 0) }) +test('a Cordis context provides taskDrive instead of assigning it', async (t) => { + // Live dsh applies this plugin on a Cordis 4 context whose fiber is already + // active, and that proxy throws `cannot set property "taskDrive" without + // provide` on a bare write (#1120). The fake context used above allows the + // write, so this case is the host that actually starts. + const { Context } = await import('@deepseek-ai/cordis') + const home = mkdtempSync(join(tmpdir(), 'ds-bot-drive-cordis-')) + writeFileSync(join(home, 'state.json'), JSON.stringify({ version: 2, revision: 1, mainBotIds: [], members: {}, bots: {}, rooms: {}, grants: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {} })) + t.after(() => rmSync(home, { recursive: true, force: true, maxRetries: 5 })) + const fake = fakeContext() + Object.assign(fake, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}), stream: async function* () {} }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { + create: async () => ({ sessionId: 'made-1' }), + resolveAgent: async id => ({ agent: { id, session: { id } } }), + selectModel: async () => {}, + page: async () => ({ records: [], hasMore: false }), + }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const root = new Context() + for (const name of ['tools', 'systemPrompt', 'connection', 'llm', 'workspaceRegistry', 'sessionController', 'sessions', 'sessionTitle']) { + root.provide(name, fake[name]) + } + let pluginCtx + const fiber = root.plugin({ + name: 'ds-bot', + apply(ctx) { + pluginCtx = ctx + createRuntime(ctx, { home }) + }, + }) + t.after(() => fiber.dispose()) + await fiber + const drive = pluginCtx.taskDrive + assert.equal(typeof drive.settled, 'function') + assert.equal(typeof drive.consider, 'function') + let now = 42 + drive.now = () => now + assert.equal(drive.now(), 42) + await drive.settled() + await drive.consider() +}) + test('asking during the wake does not schedule another wake', async (t) => { const app = await start(t) const body = await app.track('s', { tasks: [{ title: 'Draft the note', column: 'in-progress', progress: 10 }] }) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index b5d5d012..fa3cad25 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -185,6 +185,8 @@ A group job is one overall task plus a subtask per Bot (#1099). The overall task Unfinished conversation tasks wake the owning Bot. `src/host/task-drive.js` runs after a board write and after a turn ends. For each owner, the next task is the one `compareForWork` would name: `open`, `claimed`, or `working`, not `waitingFor: 'input'`, and not an overall group card (`kind: 'group'`). That card is the plan; the subtasks are the work. One drive pass wakes every such owner, so a group assignment starts each member's own subtask in that same pass. One Bot still receives only its own next task. A task with no group wakes the Bot's own chat. A subtask wakes that Bot's Session in the group, opening the Session the first time (`memberSession`) so the driver does not wait for someone to speak there. The wake is a follow-up whose header is `[Continue task]` (`delivery.js`, role `task`). It names the task id, priority, and title. It is not a group post and not a room turn, so the reply is not posted back and does not start a round. +Tests reach the driver through `ctx.taskDrive` (`now`, `settled`, `consider`). That handle is a Cordis service registered with `provide`, the same path as `ctx.flybot`. A live context refuses a bare property write, which stopped startup before any Bot loaded (#1120). + The driver skips a Session that already has a turn, and a second wake while the first is still queued or in progress. A queued wake that has not started is replaced when a different task becomes that Bot's next one. A live group round defers member wakes until the round's turn ends; the parallel wake is the assignment pass, not an extra turn beside a round. A wake that ends with the same board (status, progress, priority, note, title, and who is waiting — not `updatedAt`, which a no-op `track_tasks` call still bumps) waits 30s, then 2min, 10min, and 30min before trying that Session again. A change on the board, or a person speaking in that Session, clears the wait. A task that is waiting on an answer does not wake: the turn that called `ask` asks once, and the driver does not loop. The idle rule still forbids inventing work; unfinished work the user or a group already handed the Bot counts as that task. Tests: `core/test/task-drive.test.js`. ### Work view step (#546) diff --git a/patches/ds-bot/files/src/host/task-drive.js b/patches/ds-bot/files/src/host/task-drive.js index ac1cd6cf..48cf7739 100644 --- a/patches/ds-bot/files/src/host/task-drive.js +++ b/patches/ds-bot/files/src/host/task-drive.js @@ -221,10 +221,25 @@ export function install(rt) { if (event.data?.source?.kind === 'user') clearMiss(session?.id) }) - ctx.taskDrive = { + // A live Cordis context refuses `ctx.taskDrive = …` once the plugin fiber is + // active (`cannot set property "taskDrive" without provide`). Same order as + // provideFlyBot: provide, then reflect.provide, then set, then a plain write. + const api = { get now() { return clock.now }, set now(fn) { clock.now = fn }, settled: () => tail, consider: () => consider(), } + const provide = typeof ctx.provide === 'function' ? ctx.provide.bind(ctx) : typeof ctx.reflect?.provide === 'function' ? ctx.reflect.provide.bind(ctx.reflect) : undefined + if (provide) { + provide('taskDrive', api) + return + } + if (typeof ctx.set === 'function') { + try { + ctx.set('taskDrive', api) + return + } catch { /* the key is refused: fall through to the plain property */ } + } + ctx.taskDrive = api } diff --git a/patches/ds-bot/files/test/task-drive.test.js b/patches/ds-bot/files/test/task-drive.test.js index c17e07aa..3629b7aa 100644 --- a/patches/ds-bot/files/test/task-drive.test.js +++ b/patches/ds-bot/files/test/task-drive.test.js @@ -361,6 +361,52 @@ test('a board card that is not a conversation task does not wake', async (t) => assert.equal(app.calls('s').length, 0) }) +test('a Cordis context provides taskDrive instead of assigning it', async (t) => { + // Live dsh applies this plugin on a Cordis 4 context whose fiber is already + // active, and that proxy throws `cannot set property "taskDrive" without + // provide` on a bare write (#1120). The fake context used above allows the + // write, so this case is the host that actually starts. + const { Context } = await import('@deepseek-ai/cordis') + const home = mkdtempSync(join(tmpdir(), 'ds-bot-drive-cordis-')) + writeFileSync(join(home, 'state.json'), JSON.stringify({ version: 2, revision: 1, mainBotIds: [], members: {}, bots: {}, rooms: {}, grants: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {} })) + t.after(() => rmSync(home, { recursive: true, force: true, maxRetries: 5 })) + const fake = fakeContext() + Object.assign(fake, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}), stream: async function* () {} }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { + create: async () => ({ sessionId: 'made-1' }), + resolveAgent: async id => ({ agent: { id, session: { id } } }), + selectModel: async () => {}, + page: async () => ({ records: [], hasMore: false }), + }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const root = new Context() + for (const name of ['tools', 'systemPrompt', 'connection', 'llm', 'workspaceRegistry', 'sessionController', 'sessions', 'sessionTitle']) { + root.provide(name, fake[name]) + } + let pluginCtx + const fiber = root.plugin({ + name: 'ds-bot', + apply(ctx) { + pluginCtx = ctx + createRuntime(ctx, { home }) + }, + }) + t.after(() => fiber.dispose()) + await fiber + const drive = pluginCtx.taskDrive + assert.equal(typeof drive.settled, 'function') + assert.equal(typeof drive.consider, 'function') + let now = 42 + drive.now = () => now + assert.equal(drive.now(), 42) + await drive.settled() + await drive.consider() +}) + test('asking during the wake does not schedule another wake', async (t) => { const app = await start(t) const body = await app.track('s', { tasks: [{ title: 'Draft the note', column: 'in-progress', progress: 10 }] })