mirror of
https://github.com/Ryder-Sun/Meldwork.git
synced 2026-11-07 06:56:03 -10:00
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:
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)
|
||||
|
||||
Reference in new issue
Block a user