fix: isolate chat runs and preserve agent sessions

Keep direct and multi-round conversations on stable Agent sessions, isolate writable direct chats in the scheduler, and remove orphaned local attachments when deleting a conversation.
This commit is contained in:
Ryder Sun committed 2026-09-15 16:57:51 +08:00
1 parent 956ddd0dd9
commit 9198bf6548
6 files changed
+81 -6

No files matched your search

@@ -11,7 +11,6 @@ const {
nextSessionMeta,
normalizeOutcomeRefs,
normalizeSessionMeta,
shouldRotateSession,
} = require('../runs/run-harness.cjs')
const {
canonicalJson,
@@ -547,8 +546,13 @@ class LocalWorkspaceAgentInvocation {
if (!taskId) throw new Error('LOCAL_RUN_TASK_INVALID')
let workspacePath = group.workdir ? path.resolve(group.workdir) : group.id
try { workspacePath = fs.realpathSync(workspacePath) } catch { /* runtime validates unavailable directories */ }
// Direct chats are independent execution surfaces. Scope their scheduler
// resource to the chat so one writable chat cannot block another Agent.
const schedulerWorkspaceKey = group.conversationType === 'direct'
? `${workspacePath}\u0000direct:${group.id}`
: workspacePath
const workspaceKey = createHash('sha256')
.update(String(workspacePath))
.update(String(schedulerWorkspaceKey))
.digest('hex')
const writerKind = cleanText(context.singleWriterKind || context.writerKind, 80)
const permissionMode = group.allowWrite === true
@@ -719,10 +723,9 @@ class LocalWorkspaceAgentInvocation {
&& !resumedConnectorGate
? context.resumedGate
: null
const preserveGroupSession = group.conversationType !== 'direct'
const sessionNeedsRotation = sessionRef
&& shouldRotateSession(sessionMeta)
&& !preserveGroupSession
// A conversation owns one native session for its lifetime. Rebuild only
// when the Agent reports that the existing native session is invalid.
const sessionNeedsRotation = false
if (resumedPermission) {
const requestHash = sha256(canonicalJson(resumedPermission.request))
const persistedBinding = {
@@ -1269,6 +1272,8 @@ class LocalWorkspaceAgentInvocation {
].join('\n')
const buildPrompt = (afterKind, contextPackage) => isolated || frozen
? v4Prompt
: group.conversationType === 'direct'
? contextPackage.currentTaskText
: [
context.v4 === true && v4Prompt
? v4Prompt
@@ -23,6 +23,7 @@ class LocalWorkspaceConversations {
this.isGroupBusy = options.isGroupBusy
this.clearSessionState = options.clearSessionState
this.runLedger = options.runLedger
this.discardAttachments = options.discardAttachments
this.agentLabel = options.agentLabel
this.createId = options.createId
this.now = options.now
@@ -152,11 +153,17 @@ class LocalWorkspaceConversations {
if (!state.groups.some(group => group.id === groupId)) {
throw new Error('LOCAL_GROUP_NOT_FOUND')
}
const attachmentIds = [...new Set(state.messages
.filter(message => message.groupId === groupId)
.flatMap(message => Array.isArray(message.attachments) ? message.attachments : [])
.map(attachment => attachment?.id)
.filter(id => typeof id === 'string'))]
this.commit(() => {
this.clearSessionState(groupId)
state.groups = state.groups.filter(group => group.id !== groupId)
state.messages = state.messages.filter(message => message.groupId !== groupId)
}, () => this.runLedger?.deleteGroup?.(groupId))
this.discardAttachments?.(attachmentIds)
}
deleteMessage(groupId, messageId) {
@@ -217,6 +217,13 @@ class LocalWorkspace extends EventEmitter {
})
this.runLedgerCoordinator = new LocalWorkspaceRunLedger({
runLedger: this.runLedger,
discardAttachments: ids => {
const referenced = new Set(this.state.messages.flatMap(message => (
Array.isArray(message.attachments) ? message.attachments : []
)).map(attachment => attachment?.id).filter(Boolean))
const orphaned = ids.filter(id => !referenced.has(id))
if (orphaned.length) this.contentBlobStore.discard(orphaned)
},
state: () => this.state,
save: () => this.save(),
createId: () => this.createId(),
@@ -66,6 +66,32 @@ test('direct Pi conversations reuse the native JSON session across messages', as
assert.deepEqual(calls.map(call => call.runOptions.sessionTransport), ['', ''])
})
test('direct conversations do not rotate their session after the default turn budget', async (t) => {
const { directory, calls, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
options.detectAgents = async () => [{
kind: 'pi', name: 'Pi Agent', executable: '/tmp/pi', version: '0.84.2',
}]
options.runAgent = async (_agent, prompt, _workdir, runOptions) => {
calls.push({ prompt, runOptions })
const sessionRef = runOptions.sessionRef || 'pi-long-session'
await runOptions.onSessionRef(sessionRef)
return { text: 'reply', sessionRef, outcome: 'completed' }
}
const workspace = new LocalWorkspace(options)
await workspace.refreshAgents()
const group = workspace.createGroup({
name: 'Long direct', agentKinds: ['pi'], directAgentKind: 'pi',
conversationType: 'direct', workdir: directory,
})
for (let index = 0; index < 20; index += 1) {
await workspace.sendMessage({ groupId: group.id, text: `message-${index}` })
}
assert.equal(calls.length, 20)
assert.equal(calls.filter(call => call.runOptions.sessionRef === '').length, 1)
assert.equal(calls.slice(1).every(call => call.runOptions.sessionRef === 'pi-long-session'), true)
})
test('group conversations dispatch Pi alongside other available Agents', async (t) => {
const { directory, calls, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
@@ -82,6 +82,35 @@ test('read-only group invocations retain parallel execution in the same director
await Promise.all(runs)
})
test('writable direct chats do not block another Agent in the same directory', { timeout: 10000 }, async (t) => {
const { directory, options } = fixture()
const release = deferred()
const bothStarted = deferred()
t.after(() => {
release.resolve()
fs.rmSync(directory, { recursive: true, force: true })
})
options.runScheduler = new RunScheduler()
const calls = []
options.runAgent = async (agent, _prompt, _cwd, runOptions) => {
calls.push({ kind: agent.kind, sandbox: runOptions.sandbox })
if (calls.length === 2) bothStarted.resolve()
await release.promise
return { text: `${agent.kind} completed.` }
}
const workspace = new LocalWorkspace(options)
await workspace.refreshAgents()
const groups = ['codex', 'hermes'].map(kind => workspace.createGroup({
name: `${kind} direct`, agentKinds: [kind], directAgentKind: kind,
conversationType: 'direct', workdir: directory, allowWrite: true,
}))
const runs = groups.map(group => workspace.sendMessage({ groupId: group.id, text: 'Run independently.' }))
await bothStarted.promise
assert.deepEqual(calls.map(call => call.sandbox), ['workspace-write', 'workspace-write'])
release.resolve()
await Promise.all(runs)
})
test('Manual V4 retains parallel readers alongside its single writer and stable message order', { timeout: 10000 }, async (t) => {
const { directory, options } = fixture()
const release = deferred()
@@ -1399,6 +1399,7 @@ test('direct conversations force manual mode and reuse their group Agent session
assert.equal(direct.directAgentKind, 'codex')
assert.deepEqual(calls.map(call => call.agent.kind), ['codex', 'codex'])
assert.deepEqual(calls.map(call => call.runOptions.sessionRef), ['', 'codex-session'])
assert.deepEqual(calls.map(call => call.prompt), ['第一条', '第二条'])
assert.equal(calls.some(call => call.prompt.includes('MELDWORK_CONSENSUS')), false)
assert.equal(workspace.snapshot().messages.some(message => message.threadRootId), false)
const restored = new LocalWorkspace(options)