fix: harden local agent runs and broaden natural discussion rounds

- Extract WorkBuddy/Claude/Qwen error-result lists (errors[]) into failure
  diagnostics and classify authentication failures as
  LOCAL_AGENT_AUTH_REQUIRED instead of a bare LOCAL_AGENT_PROCESS_FAILED,
  so the catalog marks the Agent as needing a fresh login and retries stop.
- Scope the isolated OpenClaw runtime directory by session, workspace, and
  CLI version, so an upgraded OpenClaw gets a fresh managed scope instead
  of failing on legacy workspace state written by an older CLI.
- Natural agent-led discussions now open with a full participant round and
  the delivery owner cannot declare completion before peers had a
  discussion round; sequential runs get the same cross-review gate
  (system.autoCrossReview).

Checks: npm --prefix desktop test (1574 pass), npm --prefix frontend test
(343 pass), frontend build and build:desktop, desktop pack.
This commit is contained in:
Ryder Sun committed 2026-10-14 16:52:25 +08:00
1 parent 6f52081e51
commit 22f811cde2
14 files changed
+307 -81

No files matched your search

+18 -1
View File
@@ -219,6 +219,15 @@ function terminalAuthenticationDiagnostic(value) {
: ''
}
function authConfigurationFailure(detail) {
return String(detail || '').split(/\r?\n/).some((line) => {
const text = line.trim().replace(/^(?:error|fatal)\s*:\s*/i, '')
return Boolean(terminalAuthenticationDiagnostic(text))
|| /^(?:(?:invalid|expired|missing|incorrect)\s+(?:api[ _-]?key|(?:access|refresh|auth)[ _-]?token|credentials?)\b|(?:authentication|authorization)\s+(?:failed|required)\b|(?:please\s+)?(?:log|sign)\s+in\b|not\s+logged\s+in\b|select an auth type\b|provider rejected\b)/i.test(text)
|| /^(?:认证|鉴权|登录)(?:失败|过期)|^(?:令牌|凭据|密钥)(?:无效|缺失|已过期)|^请(?:先|重新)登录/.test(text)
})
}
function failureCategory(code) {
const value = String(code || '')
if (value === 'LOCAL_AGENT_SESSION_INVALID') return 'session'
@@ -299,9 +308,16 @@ function requireTerminalAgentResult(result) {
throw agentRuntimeError('LOCAL_AGENT_EXECUTION_STOPPED')
}
if (normalized.outcome === 'failed') {
const failure = normalized.failure?.code
let failure = normalized.failure?.code
? normalized.failure
: agentRuntimeFailure('LOCAL_AGENT_PROCESS_FAILED')
const diagnostic = String(normalized.diagnostic || normalized.text || '')
// A structured failure whose diagnostic names an authentication problem is
// an authentication failure even when the CLI reported a generic process
// error, so the catalog can mark the Agent as needing a fresh login.
if (failure.category !== 'authentication' && authConfigurationFailure(diagnostic)) {
failure = agentRuntimeFailure('LOCAL_AGENT_AUTH_REQUIRED')
}
throw agentRuntimeError(
failure.code,
normalized.diagnostic || normalized.text,
@@ -338,6 +354,7 @@ module.exports = {
agentRuntimeCapabilities,
agentRuntimeError,
agentRuntimeFailure,
authConfigurationFailure,
isCodeReviewAgentKind,
isReviewOnlyAgentKind,
normalizeAgentResult,
+29 -7
View File
@@ -172,17 +172,38 @@ function stepFinishOutcome(reason) {
return 'partial'
}
function structuredResultDiagnostic(event) {
const parts = []
if (typeof event?.error === 'string' && event.error.trim()) parts.push(event.error.trim())
else if (event?.error?.message) parts.push(String(event.error.message))
if (typeof event?.result === 'string' && event.result.trim()) parts.push(event.result.trim())
if (Array.isArray(event?.errors)) {
parts.push(...event.errors.map(value => String(value || '').trim()).filter(Boolean))
}
return parts.join('\n')
}
function ingestStructuredEvent(state, event) {
if (!event || typeof event !== 'object' || Array.isArray(event)) return
state.seen = true
if (['workbuddy', 'claude', 'qwen'].includes(state.kind)
&& event.type === 'result' && typeof event.result === 'string') {
&& event.type === 'result') {
if (typeof event.session_id === 'string') state.sessionRef = event.session_id
state.failure = null
state.diagnostic = ''
state.text = ''
appendStructuredText(state, event.result, '')
state.outcome = event.is_error === true ? 'partial' : 'completed'
if (typeof event.result === 'string') {
state.failure = null
state.diagnostic = ''
state.text = ''
appendStructuredText(state, event.result, '')
state.outcome = event.is_error === true ? 'partial' : 'completed'
return
}
// An error result without a usable result string still carries the CLI's
// error list; keep it as the failure diagnostic so authentication and
// session problems are classified instead of surfacing as a bare
// LOCAL_AGENT_PROCESS_FAILED.
if (event.is_error === true || String(event.subtype || '').startsWith('error')) {
markStructuredFailure(state, 'LOCAL_AGENT_PROCESS_FAILED', structuredResultDiagnostic(event))
}
return
}
if (structuredEventFailed(event)) {
@@ -885,7 +906,8 @@ function classifyCliOutcome(kind, stdout) {
function structuredCliError(stdout) {
const failed = parseJsonOutputEvents(stdout)
.findLast(event => event?.is_error || event?.subtype?.startsWith('error'))
return String(failed?.error?.message || failed?.result || '').trim()
if (!failed) return ''
return structuredResultDiagnostic(failed)
}
function hermesSessionRef(stderr) {
+4 -10
View File
@@ -2,7 +2,10 @@ const path = require('node:path')
const { searchPath, systemChildEnvironment } = require('./cli-discovery.cjs')
const { NETWORK_ENV_KEYS, networkEnvironment } = require('./cli-network-environment.cjs')
const { redactChildSecrets } = require('./cli-runtime-events.cjs')
const { agentRuntimeError, terminalAuthenticationDiagnostic } = require('../agent-runtime-contract.cjs')
const {
agentRuntimeError,
authConfigurationFailure,
} = require('../agent-runtime-contract.cjs')
const { validateOpenClawRuntimeGuard } = require('./openclaw-runtime.cjs')
const TERMINATE_GRACE_MS = 500
@@ -29,15 +32,6 @@ function agentExecutionError(code, diagnostic = '') {
return agentRuntimeError(code, diagnostic)
}
function authConfigurationFailure(detail) {
return String(detail || '').split(/\r?\n/).some((line) => {
const text = line.trim().replace(/^(?:error|fatal)\s*:\s*/i, '')
return Boolean(terminalAuthenticationDiagnostic(text))
|| /^(?:(?:invalid|expired|missing|incorrect)\s+(?:api[ _-]?key|(?:access|refresh|auth)[ _-]?token|credentials?)\b|(?:authentication|authorization)\s+(?:failed|required)\b|(?:please\s+)?(?:log|sign)\s+in\b|not\s+logged\s+in\b|select an auth type\b|provider rejected\b)/i.test(text)
|| /^(?:认证|鉴权|登录)(?:失败|过期)|^(?:令牌|凭据|密钥)(?:无效|缺失|已过期)|^请(?:先|重新)登录/.test(text)
})
}
function invalidSessionFailure(detail) {
const text = String(detail || '').trim()
if (!text || authConfigurationFailure(text)
+13 -6
View File
@@ -192,7 +192,7 @@ function normalizeProvider(input) {
return { apiKey, baseUrl, model }
}
function runtimePaths({ storageRoot, workdir, sessionRef, configName }) {
function runtimePaths({ storageRoot, workdir, sessionRef, configName, runtimeVersion }) {
if (!path.isAbsolute(storageRoot) || !path.isAbsolute(workdir)) {
throw new Error('OPENCLAW_RUNTIME_INVALID_SCOPE')
}
@@ -204,8 +204,15 @@ function runtimePaths({ storageRoot, workdir, sessionRef, configName }) {
throw new Error('OPENCLAW_RUNTIME_INVALID_SCOPE')
}
const resolvedWorkdir = path.resolve(workdir)
// The managed scope is keyed by session, workspace, and CLI version. OpenClaw
// refuses to start on state written by an older CLI ("Legacy workspace setup
// state requires migration"), so a version-scoped directory gives an upgraded
// CLI a fresh isolated scope instead of a guaranteed startup failure.
const versionSegment = String(runtimeVersion || '').trim()
const scope = crypto.createHash('sha256')
.update(`${sessionRef || 'configure'}\0${resolvedWorkdir}`)
.update(versionSegment
? `${sessionRef || 'configure'}\0${resolvedWorkdir}\0${versionSegment}`
: `${sessionRef || 'configure'}\0${resolvedWorkdir}`)
.digest('hex')
.slice(0, 24)
const runtimeRoot = path.join(resolvedStorageRoot, 'openclaw-managed', scope)
@@ -459,14 +466,14 @@ function normalizedNativeModel(input) {
}
function managedOpenClawOptions({
storageRoot, workdir, sessionRef = '', allowWrite = false, provider,
storageRoot, workdir, sessionRef = '', allowWrite = false, provider, openclawVersion,
}) {
const normalized = normalizeProvider(provider)
const configName = allowWrite
? 'openclaw.provider.workspace-write.json'
: 'openclaw.provider.read-only.json'
const runtime = runtimePaths({
storageRoot, workdir, sessionRef, configName,
storageRoot, workdir, sessionRef, configName, runtimeVersion: openclawVersion,
})
const { resolvedWorkdir, home, state, configPath } = runtime
@@ -525,7 +532,7 @@ function managedOpenClawOptions({
}
function nativeOpenClawOptions({
storageRoot, workdir, sessionRef = '', allowWrite = false, runtime,
storageRoot, workdir, sessionRef = '', allowWrite = false, runtime, openclawVersion,
}) {
const modelRef = String(runtime?.model || '').trim()
const provider = runtime?.provider
@@ -557,7 +564,7 @@ function nativeOpenClawOptions({
? 'openclaw.native.workspace-write.json'
: 'openclaw.native.read-only.json'
const runtimePathsResult = runtimePaths({
storageRoot, workdir, sessionRef, configName,
storageRoot, workdir, sessionRef, configName, runtimeVersion: openclawVersion,
})
const { resolvedWorkdir, home, state, configPath } = runtimePathsResult
const modelConfig = { primary: modelRef }
+1
View File
@@ -361,6 +361,7 @@ async function runCoreAgent(agent, prompt, workdir, options = {}) {
workdir,
storageRoot: app.getPath('userData'),
nativeRuntime,
openclawVersion: agent.version,
})
const nativeEnv = connectorCredentialIsolation || agent.kind === 'openclaw'
? {}
@@ -55,7 +55,21 @@ const v4NaturalDecisionMethods = {
'', threadRootId, { key: 'system.autoTaskDecisionMissing' })
return { status: 'partial' }
}
if (decision.status === 'completed') return { status: controller.failedKinds.length ? 'partial' : 'completed' }
if (decision.status === 'completed') {
// The owner may not close the task before selected peers had one discussion
// round of their own; otherwise a single full round collapses into an
// owner-only review and peers never get to challenge the result.
if (activeKinds.some(kind => kind !== owner)
&& !this.v4NaturalPriorPeerDiscussion(group, controller, threadRootId, round, owner)) {
this.addMessage(
group.id, 'system',
'The delivery owner signaled completion before peer discussion; running one cross-review round first.',
'', threadRootId, { key: 'system.autoCrossReview' },
)
return { nextKinds: [...activeKinds], ownerReview: false }
}
return { status: controller.failedKinds.length ? 'partial' : 'completed' }
}
if (decision.status === 'needs-human' && this.requestTaskDecision) {
const response = await this.requestTaskDecision(group, controller, ownerMessage)
if (response?.status === 'approved') return { nextKinds: [owner], ownerReview: true }
@@ -81,6 +95,19 @@ const v4NaturalDecisionMethods = {
return activeKinds.filter(kind => selected.has(kind))
},
v4NaturalPriorPeerDiscussion(group, controller, threadRootId, round, owner) {
return this.state().messages.some(message => (
message.groupId === group.id
&& message.threadRootId === threadRootId
&& message.role === 'agent'
&& message.agentKind !== owner
&& message.trace?.runId === controller.runId
&& message.trace?.phase === 'discussion'
&& Number(message.trace?.round) < round
&& this.v4NaturalMessageMatchesBinding(message, controller)
))
},
v4NaturalOwner(group, controller, threadRootId) {
let owner = controller.targetKinds[0] || ''
for (const message of this.state().messages) {
@@ -2856,8 +2856,10 @@ class LocalWorkspaceAutoRunner {
}
} else {
round = 2
nextKinds = [this.v4NaturalOwner(group, controller, threadRootId)]
if (!activeKinds.includes(nextKinds[0])) return 'partial'
// The first discussion round is a full participant round: every selected
// Agent reacts to the proposal set before the delivery owner reviews and
// judges completion, so a group run cannot collapse to owner-only turns.
nextKinds = [...activeKinds]
}
while (!controller.signal.aborted) {
@@ -51,7 +51,7 @@ test('capability timeouts retry once and remain inconclusive when both attempts
return { stdout: argsForProbe(_command, _args) }
},
})
assert.deepEqual(timeouts, recover ? [8000, 8000, 16000] : [8000, 8000, 16000, 16000])
assert.deepEqual(timeouts, recover ? [8000, 16000] : [8000, 16000])
assert.equal(result.compatibilityState, recover ? 'compatible' : 'unknown')
if (!recover) assert.equal(result.incompatibilityReason, 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT')
}
@@ -521,6 +521,45 @@ process.exitCode = 1
assert.equal(events.some(event => event.type === 'answer_delta'), true)
})
for (const exitCode of [0, 1]) {
test(`WorkBuddy authentication failures classify as auth-required with exit code ${exitCode}`, async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), `meldwork-workbuddy-auth-${exitCode}-`))
const cli = executable(directory, 'workbuddy-auth-required.cjs', `
process.stdout.write(JSON.stringify({
type: 'system', subtype: 'init', session_id: 'workbuddy-auth-session',
apiKeySource: 'www.workbuddy.cn',
}) + '\\n')
process.stdout.write(JSON.stringify({
type: 'assistant', session_id: 'workbuddy-auth-session',
message: { role: 'assistant', content: [
{ type: 'text', text: 'Authentication required. Please use /login command to sign in to your account' },
] },
}) + '\\n')
process.stdout.write(JSON.stringify({
type: 'result', subtype: 'error_during_execution', is_error: true,
session_id: 'workbuddy-auth-session',
errors: ['Authentication required. Please use /login command to sign in to your account'],
}) + '\\n')
process.exitCode = ${exitCode}
`)
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
await assert.rejects(
runAgent(
{ kind: 'workbuddy', executable: cli, name: 'WorkBuddy' },
'hello',
directory,
{ },
),
(error) => {
assert.equal(error.code, 'LOCAL_AGENT_AUTH_REQUIRED')
assert.match(error.diagnostic || '', /Authentication required/u)
return true
},
)
})
}
test('Gemini profile state retains one real tool lifecycle id and summary', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-gemini-profile-state-'))
const cli = executable(directory, 'gemini-profile-state.cjs', `
@@ -90,6 +90,27 @@ test('managed runtime isolates OpenClaw state and keeps the API key out of confi
assert.equal(fs.statSync(result.env.OPENCLAW_CONFIG_PATH).mode & 0o777, 0o600)
})
test('managed runtime scope is keyed by session, workspace, and CLI version', (t) => {
const { directory, workdir } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const base = {
storageRoot: directory,
workdir,
sessionRef: 'agent:main:desktop-meldwork-scope-openclaw',
provider: provider(),
}
const versionA = managedOpenClawOptions({ ...base, openclawVersion: 'OpenClaw 2026.7.1-2 (a1b2c3)' })
const versionARepeat = managedOpenClawOptions({ ...base, openclawVersion: 'OpenClaw 2026.7.1-2 (a1b2c3)' })
const versionB = managedOpenClawOptions({ ...base, openclawVersion: 'OpenClaw 2026.9.1 (ad6fe23)' })
const unversioned = managedOpenClawOptions({ ...base })
assert.equal(versionA.env.OPENCLAW_STATE_DIR, versionARepeat.env.OPENCLAW_STATE_DIR)
assert.notEqual(versionA.env.OPENCLAW_STATE_DIR, versionB.env.OPENCLAW_STATE_DIR)
assert.notEqual(versionA.env.OPENCLAW_STATE_DIR, unversioned.env.OPENCLAW_STATE_DIR)
assert.equal(versionB.env.OPENCLAW_STATE_DIR.startsWith(fs.realpathSync(directory)), true)
})
test('Gateway setup rewrites the guarded config without persisting credentials', (t) => {
const { directory, workdir } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
@@ -47,7 +47,7 @@ for (const nextKinds of [undefined, []]) {
await workspace.refreshAgents()
const group = workspace.createGroup({ name: 'Prose is not a dispatch', agentKinds: ['codex', 'hermes'], workdir: directory, allowWrite: false })
const controller = await runDiscussion(workspace, group, { discussionStyle: 'agent-led', maxRounds: 4 })
assert.deepEqual(turns, ['codex'])
assert.deepEqual(turns, ['codex', 'hermes', 'codex'])
assert.equal(ledger.get(controller.runId).status, 'completed')
const reopened = new RunLedger({ storagePath: ledger.storagePath })
assert.deepEqual(reopened.get(controller.runId).agentRuns.at(-1).context.taskDecision.nextKinds, nextKinds)
@@ -120,22 +120,25 @@ test('Natural routing retains a request beyond the stored message excerpt', asyn
const { directory, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const turns = []
const workspace = new LocalWorkspace({
...options, naturalAgentResponses: true,
runAgent: async (agent, prompt) => {
if (naturalPhase(prompt) === 'proposal') return { outcome: 'completed', text: 'Initial contribution.' }
turns.push(agent.kind)
return { outcome: 'completed', text: turns.length === 1
? decisionReply(`${'x'.repeat(21000)}\n@hermes Please verify the missing evidence.`, 'continue',
{ reason: 'Request evidence verification.', nextKinds: ['hermes'] })
: turns.length === 2 ? 'The evidence is unavailable.'
: decisionReply('Required evidence is unavailable.', 'blocked') }
},
})
const codexDiscussionTurns = new Map()
options.naturalAgentResponses = true
options.runAgent = async (agent, prompt) => {
if (naturalPhase(prompt) === 'proposal') return { outcome: 'completed', text: 'Initial contribution.' }
turns.push(agent.kind)
const turn = (codexDiscussionTurns.get(agent.kind) || 0) + 1
codexDiscussionTurns.set(agent.kind, turn)
if (agent.kind === 'codex' && turn === 1) {
return { outcome: 'completed', text: decisionReply(`${'x'.repeat(21000)}\n@hermes Please verify the missing evidence.`, 'continue',
{ reason: 'Request evidence verification.', nextKinds: ['hermes'] }) }
}
if (agent.kind === 'hermes') return { outcome: 'completed', text: 'The evidence is unavailable.' }
return { outcome: 'completed', text: decisionReply('Required evidence is unavailable.', 'blocked') }
}
const workspace = new LocalWorkspace(options)
await workspace.refreshAgents()
const group = workspace.createGroup({ name: 'Long route', agentKinds: ['codex', 'hermes'], workdir: directory, allowWrite: false })
await runDiscussion(workspace, group, { discussionStyle: 'agent-led', maxRounds: 4 })
assert.deepEqual(turns, ['codex', 'hermes', 'codex'])
assert.deepEqual(turns, ['codex', 'hermes', 'hermes', 'codex'])
})
test('Natural discussion does not complete when a peer reports an explicit blocker', async t => {
@@ -226,12 +229,15 @@ for (const handoffTo of [undefined, 'hermes', 'ghost']) {
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const ledger = new RunLedger({ storagePath: path.join(directory, 'run-ledger.json') })
const turns = []
const discussionTurns = new Map()
const workspace = new LocalWorkspace({
...options, runLedger: ledger, naturalAgentResponses: true,
runAgent: async (agent, prompt) => {
if (naturalPhase(prompt) === 'proposal') return { outcome: 'completed', text: 'Initial answer.', sessionRef: `${agent.kind}-task-session` }
turns.push(agent.kind)
return { outcome: 'completed', sessionRef: `${agent.kind}-task-session`, text: turns.length === 1
const turn = (discussionTurns.get(agent.kind) || 0) + 1
discussionTurns.set(agent.kind, turn)
return { outcome: 'completed', sessionRef: `${agent.kind}-task-session`, text: agent.kind === 'codex' && turn === 1
? decisionReply('More work remains.', 'continue', handoffTo ? { handoffTo } : {})
: decisionReply('The requested work is complete.') }
},
@@ -241,7 +247,10 @@ for (const handoffTo of [undefined, 'hermes', 'ghost']) {
await runDiscussion(workspace, group, { discussionStyle: 'agent-led', maxRounds: 5 })
const record = ledger.list(group.id)[0]
assert.equal(record.status, handoffTo === 'ghost' ? 'partial' : 'completed', record.reason)
assert.deepEqual(turns, handoffTo === 'ghost' ? ['codex'] : ['codex', handoffTo || 'codex'])
assert.deepEqual(turns, handoffTo === 'ghost'
? ['codex', 'hermes']
: handoffTo === 'hermes' ? ['codex', 'hermes', 'hermes']
: ['codex', 'hermes', 'codex'])
})
}
@@ -315,6 +324,68 @@ test('Natural sequential V4 runs every Agent once per round in configured CLI or
)).length, 1)
})
test('Natural V4 requires a prior peer discussion round before the owner completes', async (t) => {
const { directory, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const workspace = new LocalWorkspace({ ...options, naturalAgentResponses: true })
await workspace.refreshAgents()
const group = { id: 'group' }
const controller = {
runId: 'run', targetKinds: ['codex', 'hermes'], failedKinds: [],
discussionStyle: 'agent-led',
}
const systemKeys = []
workspace.autoRunner.state = () => ({ messages: [] })
workspace.autoRunner.v4NaturalMessageMatchesBinding = () => true
workspace.autoRunner.addMessage = (groupId, role, text, _target, threadRootId, metadata) => {
systemKeys.push(metadata?.key)
return { id: `system-${systemKeys.length}` }
}
const completedDecision = {
summary: 'Done.', taskDecision: {
status: 'completed', reason: 'Ready.', deliverables: ['The answer.'],
},
}
// A solo owner round with a completion judgment and no earlier peer
// discussion round cannot close the task; it must dispatch one cross-review
// round with every active participant first.
workspace.autoRunner.state = () => ({
messages: [{
groupId: 'group', threadRootId: 'thread', role: 'agent', agentKind: 'codex',
trace: { phase: 'discussion', runId: 'run', round: 2, context: completedDecision },
}],
})
assert.deepEqual(
await workspace.autoRunner.v4NaturalRoundOutcome(
group, controller, 'thread', 2, ['codex', 'hermes'],
),
{ nextKinds: ['codex', 'hermes'], ownerReview: false },
)
assert.deepEqual(systemKeys, ['system.autoCrossReview'])
// Once a peer contributed to an earlier discussion round the same judgment
// completes the run.
workspace.autoRunner.state = () => ({
messages: [
{
groupId: 'group', threadRootId: 'thread', role: 'agent', agentKind: 'hermes',
trace: { phase: 'discussion', runId: 'run', round: 1, context: null },
},
{
groupId: 'group', threadRootId: 'thread', role: 'agent', agentKind: 'codex',
trace: { phase: 'discussion', runId: 'run', round: 2, context: completedDecision },
},
],
})
assert.deepEqual(
await workspace.autoRunner.v4NaturalRoundOutcome(
group, controller, 'thread', 2, ['codex', 'hermes'],
),
{ status: 'completed' },
)
})
for (const discussionStyle of ['sequential', 'agent-led']) {
test(`Natural ${discussionStyle} V4 reports a one-round budget stop without claiming delivery`, async (t) => {
const { directory, options } = fixture()
@@ -406,12 +477,12 @@ test('Natural Agent-led V4 uses the full first-round transcript and inline menti
assert.deepEqual(calls.map(call => `${call.phase}:${call.kind}`), [
'proposal:codex', 'proposal:hermes', 'proposal:workbuddy',
'discussion:codex',
'discussion:codex', 'discussion:hermes', 'discussion:workbuddy',
'discussion:hermes', 'discussion:workbuddy',
'discussion:codex',
])
assert.equal(maxProposalRunning, 3)
assert.equal(maxRoutedRunning, 2)
assert.equal(maxRoutedRunning, 3)
const coordinator = calls[3]
for (const sentinel of tails.values()) assert.match(coordinator.prompt, new RegExp(sentinel))
assert.ok(coordinator.prompt.length > 24000)
@@ -428,7 +499,7 @@ test('Natural Agent-led V4 uses the full first-round transcript and inline menti
))
assert.deepEqual(messages.map(message => `${message.trace.round}:${message.agentKind}`), [
'1:codex', '1:hermes', '1:workbuddy',
'2:codex',
'2:codex', '2:hermes', '2:workbuddy',
'3:hermes', '3:workbuddy',
'4:codex',
])
@@ -456,7 +527,7 @@ test('Natural routing uses explicit canonical recipients independently of displa
}
})
test('Natural Agent-led V4 preserves chosen peers without forced participation and supplies recent context', async (t) => {
test('Natural Agent-led V4 keeps later rounds routed and supplies recent context', async (t) => {
const { directory, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
options.naturalAgentResponses = true
@@ -479,18 +550,23 @@ test('Natural Agent-led V4 preserves chosen peers without forced participation a
workdir: directory, allowWrite: false,
})
await runDiscussion(workspace, group, { discussionStyle: 'agent-led', maxRounds: 5 })
assert.equal(calls.some(call => call.kind === 'workbuddy' && call.phase === 'discussion'), false)
const peer = calls.at(-1)
assert.equal(peer.kind, 'hermes')
const workbuddyDiscussionCalls = calls.filter(call => (
call.kind === 'workbuddy' && call.phase === 'discussion'
))
// WorkBuddy joins the opening full round, then never runs again without an
// explicit peer request.
assert.equal(workbuddyDiscussionCalls.length, 1)
assert.equal(workbuddyDiscussionCalls[0].prompt.includes('codex proposes an independent approach'), true)
assert.equal(workbuddyDiscussionCalls[0].prompt.includes('hermes proposes an independent approach'), true)
const firstPeerTurn = calls.find(call => call.kind === 'hermes' && call.phase === 'discussion')
assert.match(firstPeerTurn.prompt, /Round 2 - @codex/)
assert.match(firstPeerTurn.prompt, /workbuddy proposes an independent approach/)
assert.doesNotMatch(peer.prompt, /Round 2 - @codex/)
assert.match(peer.prompt, /Previously delivered turns are not repeated/)
assert.match(peer.prompt, /Round 3 - @hermes/)
assert.match(peer.prompt, /Round 4 - @codex/)
assert.match(peer.prompt, /beside its concrete question/)
assert.doesNotMatch(peer.prompt, /final non-empty line/)
assert.match(firstPeerTurn.prompt, /Round 1 - @codex/u)
assert.match(firstPeerTurn.prompt, /workbuddy proposes an independent approach/u)
const laterPeerTurn = calls.filter(call => (
call.kind === 'hermes' && call.phase === 'discussion'
)).at(-1)
assert.match(laterPeerTurn.prompt, /Previously delivered turns are not repeated/u)
assert.match(laterPeerTurn.prompt, /beside its concrete question/u)
assert.doesNotMatch(laterPeerTurn.prompt, /final non-empty line/u)
})
test('Natural Agent-led V4 keeps a peer request when another concurrent peer accepts', async (t) => {
@@ -500,15 +576,16 @@ test('Natural Agent-led V4 keeps a peer request when another concurrent peer acc
const ledger = new RunLedger({ storagePath: path.join(directory, 'run-ledger.json') })
options.runLedger = ledger
const calls = []
let codexTurns = 0
const discussionTurns = new Map()
options.runAgent = async (agent, prompt, _workdir, runOptions) => {
const phase = naturalPhase(prompt)
calls.push(`${phase}:${agent.kind}`)
if (phase === 'discussion' && agent.kind === 'codex') codexTurns += 1
const turn = (discussionTurns.get(agent.kind) || 0) + (phase === 'discussion' ? 1 : 0)
discussionTurns.set(agent.kind, turn)
return {
text: phase === 'proposal' ? `${agent.kind} proposal.`
: agent.kind === 'codex' && codexTurns === 1 ? decisionReply('@hermes assess feasibility; @workbuddy validate risks.', 'continue', { nextKinds: ['hermes', 'workbuddy'] })
: agent.kind === 'workbuddy' ? decisionReply('@codex address this remaining risk before proceeding.', 'continue', { nextKinds: ['codex'] })
: agent.kind === 'codex' && turn === 1 ? decisionReply('@hermes assess feasibility; @workbuddy validate risks.', 'continue', { nextKinds: ['hermes', 'workbuddy'] })
: agent.kind === 'workbuddy' && turn === 1 ? decisionReply('@codex address this remaining risk before proceeding.', 'continue', { nextKinds: ['codex'] })
: decisionReply('I accept the current result without changes.'),
sessionRef: runOptions.sessionRef || `${agent.kind}-task-session`,
}
@@ -524,7 +601,9 @@ test('Natural Agent-led V4 keeps a peer request when another concurrent peer acc
})
assert.deepEqual(calls, [
'proposal:codex', 'proposal:hermes', 'proposal:workbuddy',
'discussion:codex', 'discussion:hermes', 'discussion:workbuddy', 'discussion:codex',
'discussion:codex', 'discussion:hermes', 'discussion:workbuddy',
'discussion:codex', 'discussion:hermes', 'discussion:workbuddy',
'discussion:codex',
])
assert.equal(ledger.get(controller.runId).status, 'completed')
})
@@ -661,7 +740,7 @@ test('Natural Agent-led V4 reuses partially committed routed results after resta
assert.deepEqual(initialCalls.map(call => `${call.phase}:${call.kind}`), [
'proposal:codex', 'proposal:hermes', 'proposal:workbuddy',
'discussion:codex',
'discussion:codex', 'discussion:hermes', 'discussion:workbuddy',
'discussion:hermes', 'discussion:workbuddy',
'discussion:codex',
])
@@ -705,7 +784,7 @@ test('Natural Agent-led V4 reuses partially committed routed results after resta
))
assert.deepEqual(messages.map(message => `${message.trace.round}:${message.agentKind}`), [
'1:codex', '1:hermes', '1:workbuddy',
'2:codex',
'2:codex', '2:hermes', '2:workbuddy',
'3:hermes', '3:workbuddy',
'4:codex',
])
@@ -1135,10 +1214,11 @@ test('Natural Agent-led V4 does not fabricate completion or force a peer review
assert.deepEqual(calls.map(call => `${call.phase}:${call.kind}`), [
'proposal:codex', 'proposal:hermes',
'discussion:codex', 'discussion:hermes',
'discussion:codex',
])
assert.equal(maxProposalRunning, 2)
assert.equal(maxRoutedRunning, 1)
assert.equal(maxRoutedRunning, 2)
assert.match(
calls.find(call => call.phase === 'discussion').prompt,
/Silence from peers is not evidence of completion/u,
@@ -1147,7 +1227,7 @@ test('Natural Agent-led V4 does not fabricate completion or force a peer review
message.role === 'agent' && message.threadRootId === controller.threadRootId
))
assert.deepEqual(messages.map(message => `${message.trace.round}:${message.agentKind}`), [
'1:codex', '1:hermes', '2:codex',
'1:codex', '1:hermes', '2:codex', '2:hermes', '3:codex',
])
assert.equal(ledger.get(controller.runId).status, 'partial')
})
@@ -1195,14 +1275,15 @@ test('Natural Agent-led V4 routes a single mention serially and completes after
assert.deepEqual(calls, [
'proposal:codex', 'proposal:hermes',
'discussion:codex', 'discussion:hermes',
'discussion:hermes',
'discussion:codex',
])
assert.equal(maxRoutedRunning, 1)
assert.equal(maxRoutedRunning, 2)
const messages = workspace.snapshot().messages.filter(message => (
message.role === 'agent' && message.threadRootId === controller.threadRootId
))
assert.deepEqual(messages.map(message => `${message.trace.round}:${message.agentKind}`), [
'1:codex', '1:hermes', '2:codex', '3:hermes', '4:codex',
'1:codex', '1:hermes', '2:codex', '2:hermes', '3:hermes', '4:codex',
])
})
@@ -1240,13 +1321,14 @@ test('Natural Agent-led V4 does not accept a self-only route without a task deci
assert.deepEqual(calls, [
'proposal:codex', 'proposal:hermes',
'discussion:codex', 'discussion:hermes',
'discussion:codex',
])
const messages = workspace.snapshot().messages.filter(message => (
message.role === 'agent' && message.threadRootId === controller.threadRootId
))
assert.deepEqual(messages.map(message => `${message.trace.round}:${message.agentKind}`), [
'1:codex', '1:hermes', '2:codex',
'1:codex', '1:hermes', '2:codex', '2:hermes', '3:codex',
])
})
@@ -1287,7 +1369,7 @@ test('Natural Agent-led V4 does not treat concurrent self-routes as peer confirm
assert.deepEqual(calls, [
'proposal:codex', 'proposal:hermes', 'proposal:workbuddy',
'discussion:codex',
'discussion:codex', 'discussion:hermes', 'discussion:workbuddy',
'discussion:hermes', 'discussion:workbuddy',
'discussion:codex',
])
@@ -1296,7 +1378,8 @@ test('Natural Agent-led V4 does not treat concurrent self-routes as peer confirm
))
assert.deepEqual(messages.map(message => `${message.trace.round}:${message.agentKind}`), [
'1:codex', '1:hermes', '1:workbuddy',
'2:codex', '3:hermes', '3:workbuddy',
'2:codex', '2:hermes', '2:workbuddy',
'3:hermes', '3:workbuddy',
'4:codex',
])
})
@@ -1578,9 +1661,9 @@ test('Natural Agent-led V4 recovers the bound owner decision after a stale messa
if (!crashCaptured
&& input.agentKind === 'codex'
&& input.metadata?.trace?.phase === 'discussion'
&& Number(input.metadata?.trace?.round) === 2
&& Number(input.metadata?.trace?.round) === 3
&& durable?.orchestration?.phase === 'discussion'
&& durable.orchestration.round === 2
&& durable.orchestration.round === 3
&& durable.orchestration.pendingKinds.join(',') === 'codex') {
crashCaptured = true
recoveryLedger.checkpoint(durable)
@@ -1603,14 +1686,14 @@ test('Natural Agent-led V4 recovers the bound owner decision after a stale messa
const crashRecord = await crashCheckpoint.promise
await initialController.promise
const originalCoordinatorRun = crashRecord.agentRuns.find(run => (
run.round === 2 && run.kind === 'codex' && run.status === 'completed'
run.round === 3 && run.kind === 'codex' && run.status === 'completed'
))
assert.ok(originalCoordinatorRun)
const recoveryState = JSON.parse(fs.readFileSync(recoveryStoragePath, 'utf8'))
const staleCoordinatorMessage = recoveryState.messages.find(message => (
message.role === 'agent'
&& message.threadRootId === crashRecord.threadRootId
&& message.trace?.round === 2
&& message.trace?.round === 3
&& message.agentKind === 'codex'
))
assert.ok(staleCoordinatorMessage)
@@ -1643,10 +1726,10 @@ test('Natural Agent-led V4 recovers the bound owner decision after a stale messa
message.role === 'agent' && message.threadRootId === crashRecord.threadRootId
))
assert.deepEqual(messages.map(message => `${message.trace.round}:${message.agentKind}`), [
'1:codex', '1:hermes', '2:codex',
'1:codex', '1:hermes', '2:codex', '2:hermes', '3:codex',
])
const coordinatorMessage = messages.find(message => (
message.trace.round === 2 && message.agentKind === 'codex'
message.trace.round === 3 && message.agentKind === 'codex'
))
assert.equal(coordinatorMessage.trace.agentRunId, originalCoordinatorRun.agentRunId)
assertTurnParity(recoveredRecord, messages)
@@ -102,8 +102,19 @@ for (const writerKind of ['hermes', '']) {
const result = ledger.list(group.id)[0]
assert.equal(result.status, 'completed', JSON.stringify(workspace.snapshot().messages.map(message => message.content)))
assert.equal(result.orchestration.discussionWriterKind || '', writerKind)
assert.deepEqual(calls.filter(call => call.sandbox === 'workspace-write'), writerKind
? [{ kind: writerKind, discussion: true, sandbox: 'workspace-write' }] : [])
const writeCalls = calls.filter(call => call.sandbox === 'workspace-write')
if (writerKind) {
// The writer may join both the opening full round and a later owner
// review round, but only the declared writer ever receives write access.
assert.ok(writeCalls.length >= 1)
assert.ok(writeCalls.every(call => (
call.kind === writerKind && call.discussion === true
)))
assert.ok(calls.filter(call => call.sandbox !== 'workspace-write')
.every(call => call.sandbox === 'read-only'))
} else {
assert.deepEqual(writeCalls, [])
}
})
}
+1
View File
@@ -805,6 +805,7 @@ export default {
'system.autoStopped': 'Automatic discussion stopped: {reason}',
'system.autoDiscussionStalled': 'Automatic discussion stopped because repeated handoffs produced no new contribution.',
'system.autoRoundLimit': 'Automatic discussion reached the {rounds}-round limit without reaching consensus.',
'system.autoCrossReview': 'The delivery owner signaled completion before peer discussion; one cross-review round runs first.',
'system.autoTaskDecisionMissing': 'Discussion stopped without a valid task completion decision.',
'system.autoTaskBlocked': 'Task blocked: {reason}',
'system.autoTaskNeedsHuman': 'Human decision needed: {reason}',
+1
View File
@@ -805,6 +805,7 @@ export default {
'system.autoStopped': '自动讨论已停止:{reason}',
'system.autoDiscussionStalled': '自动讨论因重复转交且没有新增内容而停止。',
'system.autoRoundLimit': '自动讨论已达到 {rounds} 轮上限,尚未达成共识。',
'system.autoCrossReview': '交付负责人在同伴讨论前判断任务完成;将先进行一轮交叉审查。',
'system.autoTaskDecisionMissing': '讨论已停止,尚无有效的任务完成判断。',
'system.autoTaskBlocked': '任务受阻:{reason}',
'system.autoTaskNeedsHuman': '需要人类决定:{reason}',