Files
Meldwork/desktop/src/workspace/local-workspace-agent-invocation.cjs
T
Ryder Sun e1500a73ef fix: conversations no longer wait for each other, even in the same folder
Sending to an Agent while the same Agent (or any Agent) was busy in another
conversation made the new conversation sit on 'Running' until the first one
finished. The cause was the writer lease, which was keyed by the real folder:
every writable conversation that used the same folder (and most keep the
default one) took turns, one at a time, across groups and direct chats.

9198bf6 had deliberately scoped the lease of direct chats to the chat so that
one writable chat could not block another Agent. 17cbd32 reverted that as a
regression and added a 'waiting for another Agent writing in the same folder'
notice. That was the wrong call: it made the product behave like a single
queue. This commit reverts 17cbd32 and goes one step further than 9198bf6:

- the writer lease is scoped to the conversation for groups as well as direct
  chats, so conversations never wait for each other; writers inside one
  conversation still take turns
- the waiting notice, the waitingForWorkspaceKinds snapshot field and the
  scheduler queue callbacks that only existed for the folder lock are removed
  with the revert
- the old rule 'group chats serialize writes across symlink aliases' is
  replaced by its opposite; new tests cover the same Agent in two direct chats
  and a group at once, group chats sharing a folder through a symlink alias,
  and that turns of one conversation share a lease scope while another
  conversation in the same folder does not
- architecture.md states the new rule and the trade-off

Trade-off: two Agents in different conversations can now edit the same files
at the same time, like two terminals in one directory. Output capture compares
.meldwork-output/ before and after a run, so when two conversations produce
deliverables in the same folder at the same time one may attach a file the
other wrote. No guard was added for that.

The new write-scheduling tests fail on the previous commit (two fail, one
never gets its second Agent started).

Checked with: local-workspace-write-scheduling (6 pass), run-scheduler,
run-coordinator, state, harness, npm --prefix frontend test (333 pass).
2026-10-16 15:10:35 +08:00

1391 lines
56 KiB
JavaScript

const { createHash, randomUUID } = require('node:crypto')
const {
assertResumedPermissionBinding,
automaticPermissionResponse,
matchesResumedPermission,
permissionContinuationRequestId,
} = require('../agents/agent-permission-policy.cjs')
const {
agentRuntimeCapabilities,
isCodeReviewAgentKind,
requireTerminalAgentResult,
} = require('../agents/agent-runtime-contract.cjs')
const {
cleanupStagedAgentInputs,
stageAgentInputs,
} = require('../agents/agent-input-staging.cjs')
const { createAnswerMarkerFilter } = require('../collaboration/answer-marker-filter.cjs')
const {
V4_AGENT_PHASES,
parseV4CollaborationReceipt,
} = require('../collaboration/collaboration-receipt.cjs')
const {
canonicalJson,
normalizeContextPackId,
} = require('../collaboration/context-pack-records.cjs')
const { normalizeOutcomeRefs, normalizeSessionMeta } = require('../runs/run-harness.cjs')
const { createInvocationWatchdog } = require('../runs/invocation-watchdog.cjs')
const { processRunScheduler } = require('../runs/run-scheduler.cjs')
const { assertLocalSkillExecution } = require('../skills/local-skill-contract.cjs')
const {
defaultAgentLabel,
HERMES_WORKSPACE_ACP_ENABLED,
agentStoppedError,
attachmentType,
cleanText,
cleanProgressSteps,
parseAutoReply,
settleWithin,
} = require('./local-workspace-inputs.cjs')
const { recordOutboundUsage, recordReplyUsage } = require('./invocation-budget.cjs')
const {
attachErrorMetadata,
attachInvocationFailure,
resolveInvocationStop,
} = require('./invocation-failure.cjs')
const { InvocationOutputs } = require('./invocation-outputs.cjs')
const { assembleInvocationPrompt } = require('./invocation-prompt.cjs')
const {
InvocationSession,
connectorSessionBinding,
createdSessionProvenance,
provenanceMeta,
sha256,
} = require('./invocation-session.cjs')
const MAX_ISOLATED_PROMPT_BYTES = 4 * 1024 * 1024
const OUTCOME_REF_FIELDS = Object.freeze([
'artifactIds',
'evidenceIds',
'findingIds',
'reviewerFindingIds',
'adoptionIds',
'workflowOutcomeRefs',
])
function mergeOutcomeRefs(sources, options = {}) {
const merged = {}
const seen = new Map(OUTCOME_REF_FIELDS.map(field => [field, new Set()]))
for (const source of sources) {
const normalized = normalizeOutcomeRefs(
source,
options.strict === true ? { strict: true } : {},
)
for (const field of OUTCOME_REF_FIELDS) {
if (!Array.isArray(normalized[field])) continue
for (const value of normalized[field]) {
const key = field === 'workflowOutcomeRefs' ? canonicalJson(value) : value
if (seen.get(field).has(key)) continue
seen.get(field).add(key)
merged[field] = [...(merged[field] || []), value]
}
}
}
// Each source is validated strictly; later sources only fill remaining field capacity.
return normalizeOutcomeRefs(merged)
}
const OPERATION_ID = /^[A-Za-z0-9._:-]{1,120}$/
const SHA256 = /^[a-f0-9]{64}$/
function isolatedInvocationContext(context) {
if (!['isolated', 'frozen'].includes(context?.sessionPolicy)
&& !(context?.v4 === true && typeof context.promptOverride === 'string')) return null
const promptOverride = typeof context.promptOverride === 'string'
? context.promptOverride
: ''
const contextPackId = normalizeContextPackId(context.contextPackId)
if (!promptOverride || promptOverride.includes('\u0000')
|| Buffer.byteLength(promptOverride) > MAX_ISOLATED_PROMPT_BYTES
|| !contextPackId) {
throw new Error('LOCAL_RUN_ISOLATED_CONTEXT_INVALID')
}
return {
promptOverride,
contextPackId,
policy: ['isolated', 'frozen'].includes(context?.sessionPolicy) ? context.sessionPolicy : '',
}
}
function deliveredPackedContext(packedContext, promptMode) {
if (promptMode !== 'continuation') return packedContext
return {
...packedContext,
sourceMessageIds: packedContext.continuationSourceMessageIds || [],
sourceEntries: packedContext.continuationSourceEntries || [],
context: packedContext.continuationContext || {
includedCount: 0,
omittedCount: 0,
charCount: 0,
},
}
}
function withCollaborationPackage(packedContext, collaborationPackage) {
if (!collaborationPackage) return packedContext
const text = typeof collaborationPackage.text === 'string'
? collaborationPackage.text
: ''
if (!text || text.length > 128000 || text.includes('\u0000')) {
throw new Error('LOCAL_RUN_COLLABORATION_PACKAGE_INVALID')
}
return { ...packedContext, collaborationText: text }
}
function contextSourceProof(contextPack) {
const sources = (Array.isArray(contextPack?.sources) ? contextPack.sources : []).map(source => ({
type: source.type,
sourceId: source.sourceId,
contentHash: source.contentHash,
targetKinds: source.targetKinds,
captureMode: source.captureMode,
}))
return {
sourceCount: sources.length,
sourceHash: sha256(canonicalJson(sources)),
}
}
function assertRequiredContextFits(packedContext) {
if (!packedContext?.requiredContextOverflow) return
const error = new Error('LOCAL_RUN_REQUIRED_CONTEXT_OVERFLOW')
error.code = 'LOCAL_RUN_REQUIRED_CONTEXT_OVERFLOW'
error.contextOverflow = { ...packedContext.requiredContextOverflow }
throw error
}
class LocalWorkspaceAgentInvocation {
constructor(options) {
this.state = options.state
this.detectedAgents = options.detectedAgents
this.activeRuns = options.activeRuns
this.runAgentTimeoutMs = options.runAgentTimeoutMs
this.runAgentToolTimeoutMs = options.runAgentToolTimeoutMs
this.runAbortGraceMs = options.runAbortGraceMs
this.defaultYolo = options.defaultYolo !== false
this.captureAgentOutputs = options.captureAgentOutputs
this.captureArtifactOutputs = options.captureArtifactOutputs
this.captureAgentOutcomeDescriptors = options.captureAgentOutcomeDescriptors
this.runAgent = options.runAgent
this.importAgentOutputs = options.importAgentOutputs
this.recordAgentOutcomes = options.recordAgentOutcomes
this.sessionKey = options.sessionKey
this.sessionState = options.sessionState
this.openClawSessionRef = options.openClawSessionRef
this.save = options.save
this.packedPromptContext = options.packedPromptContext
this.ensureRunHarness = options.ensureRunHarness
this.emitRunEvent = options.emitRunEvent
this.armAgentSilence = options.armAgentSilence
this.clearAgentSilence = options.clearAgentSilence
this.checkpointRun = options.checkpointRun
this.hasRunLedger = typeof options.hasRunLedger === 'function'
? options.hasRunLedger
: (() => false)
this.scheduleRunCheckpoint = options.scheduleRunCheckpoint
this.emitChanged = options.emitChanged
this.promptFor = options.promptFor
this.reserveTurnSeq = typeof options.reserveTurnSeq === 'function'
? options.reserveTurnSeq
: null
this.agentLabel = typeof options.agentLabel === 'function'
? options.agentLabel
: kind => defaultAgentLabel(kind)
this.persistSessionState = options.persistSessionState
this.createAttemptContextPack = options.createAttemptContextPack
this.recordContextDelivery = options.recordContextDelivery
this.markRuntimeCredential = options.markRuntimeCredential
this.addMessage = options.addMessage
this.runScheduler = options.runScheduler || processRunScheduler
this.registerAgentController = options.registerAgentController
this.unregisterAgentController = options.unregisterAgentController
this.requestHumanGate = options.requestHumanGate
this.completeHumanGateContinuation = options.completeHumanGateContinuation
this.connectorRuntime = options.connectorRuntime || null
this.attachmentSupport = options.attachmentSupport || (() => ({}))
this.activeAgentSnapshots = new Map()
}
commitSessionState(mutator) {
const state = this.state()
const previousSessions = { ...state.sessions }
const previousSessionMeta = { ...state.sessionMeta }
try {
const result = mutator(state)
if (result !== false) this.save()
return result
} catch (error) {
state.sessions = previousSessions
state.sessionMeta = previousSessionMeta
throw error
}
}
async invoke(group, kind, mode, signal, threadRootId = '', context = {}) {
// Group turns get a per-topic dispatch number before anything awaits, so
// concurrent dispatches keep their call order. Batch dispatchers reserve
// numbers themselves and pass them in.
const requestedTurnSeq = Number(context.turnSeq)
const turnSeq = Number.isSafeInteger(requestedTurnSeq) && requestedTurnSeq > 0
? requestedTurnSeq
: (group.conversationType !== 'direct' && threadRootId
&& typeof this.reserveTurnSeq === 'function'
? this.reserveTurnSeq(group.id, threadRootId)
: null)
if (turnSeq) context = { ...context, turnSeq }
const activeRun = this.activeRuns.get(group.id)
const taskId = cleanText(activeRun?.taskId || context.taskId || threadRootId, 120)
if (!taskId) throw new Error('LOCAL_RUN_TASK_INVALID')
// Every conversation, group or direct, is its own execution surface. The
// writer lease is scoped to the conversation, so a writable chat never
// makes an Agent in another conversation wait, including when both use the
// same folder (most chats keep the default one). Writers inside one
// conversation still take turns.
const workspaceKey = createHash('sha256')
.update(`conversation\u0000${group.id}`)
.digest('hex')
const writerKind = cleanText(context.singleWriterKind || context.writerKind, 80)
const permissionMode = group.allowWrite === true
&& (context.permissionMode == null || context.permissionMode === 'workspace-write')
&& (!writerKind || writerKind === kind)
&& isolatedInvocationContext(context)?.policy !== 'isolated'
&& !isCodeReviewAgentKind(kind)
? 'workspace-write' : 'read-only'
context = { ...context, permissionMode }
const agentController = new AbortController()
const registration = { agentRunId: '' }
let queuedAbortHandler = null
this.registerAgentController?.(activeRun, kind, registration.agentRunId, agentController)
if (signal) {
queuedAbortHandler = () => agentController.abort()
if (signal.aborted) queuedAbortHandler()
else signal.addEventListener('abort', queuedAbortHandler, { once: true })
}
try {
return await this.runScheduler.withLease({
taskId,
workspaceKey,
permissionMode,
signal: agentController.signal,
}, (lease) => {
if (signal && queuedAbortHandler) {
signal.removeEventListener('abort', queuedAbortHandler)
queuedAbortHandler = null
}
return this.invokeLeased(
group,
kind,
mode,
signal,
threadRootId,
context,
{ agentController, registration, lease },
)
})
} finally {
if (signal && queuedAbortHandler) {
signal.removeEventListener('abort', queuedAbortHandler)
}
this.unregisterAgentController?.(
activeRun, kind, registration.agentRunId, agentController,
)
}
}
async invokeLeased(
group, kind, mode, signal, threadRootId = '', context = {}, invocation = {},
) {
const detected = this.detectedAgents()
const activeRun = this.activeRuns.get(group.id)
const candidate = detected.find(item => item.kind === kind)
const snapshotKey = `${group.id}:${activeRun?.runId || 'active'}:${kind}`
if (candidate) this.activeAgentSnapshots.set(snapshotKey, { ...candidate })
const snapshot = this.activeAgentSnapshots.get(snapshotKey)
const agent = detected.find(item => item.kind === kind && item.available)
|| (activeRun?.targetKinds?.includes(kind)
&& candidate
&& candidate.installed !== false
&& candidate.compatibilityState !== 'incompatible'
&& candidate.credentialState !== 'missing'
&& candidate.availabilitySource !== 'runtime-auth-failure'
&& candidate.availabilitySource !== 'none'
? candidate
: activeRun?.targetKinds?.includes(kind) && !candidate && snapshot
? snapshot
: null)
if (!agent) throw new Error('LOCAL_AGENT_UNAVAILABLE')
const reviewOnly = isCodeReviewAgentKind(kind)
const invocationContext = isolatedInvocationContext(context)
const isolated = invocationContext?.policy === 'isolated'
const frozen = invocationContext?.policy === 'frozen'
const internal = context.internal === true
if (internal && !isolated) throw new Error('LOCAL_RUN_INTERNAL_CONTEXT_INVALID')
const requestedPermission = context.permissionMode == null
? (group.allowWrite === true ? 'workspace-write' : 'read-only')
: (context.permissionMode === 'workspace-write' ? 'workspace-write' : 'read-only')
const writerKind = cleanText(context.singleWriterKind || context.writerKind, 80)
const writerAllowed = !writerKind || writerKind === kind
const supportsWorkspaceWrite = agentRuntimeCapabilities(kind, { agent })
.permissionModes.includes('workspace-write')
const allowWrite = group.allowWrite === true
&& requestedPermission === 'workspace-write'
&& writerAllowed
&& supportsWorkspaceWrite
&& !reviewOnly
&& !isolated
const skillInputTypes = new Set(['text'])
for (const attachment of context.attachmentSnapshots || []) {
const type = attachmentType(attachment?.mimeType)
if (type) skillInputTypes.add(type)
}
for (const skill of context.skillHints || []) {
if (!skill?.approvedSkillManifest) continue
assertLocalSkillExecution(skill.approvedSkillManifest, {
kind,
version: agent.resolvedVersion || agent.version,
inputTypes: [...skillInputTypes],
capabilities: agent.capabilities,
permissionMode: allowWrite ? 'workspace-write' : 'read-only',
credentialIds: [],
})
}
const state = this.state()
const taskId = cleanText(activeRun?.taskId || context.taskId || threadRootId, 120)
const round = mode === 'auto' ? (activeRun?.currentRound || 1) : 0
const responseVersionRootId = cleanText(context.responseVersionRootId, 100)
|| (mode === 'auto'
? `response-${sha256(JSON.stringify([
group.id,
threadRootId,
kind,
round,
cleanText(context.phase, 40) || 'response',
])).slice(0, 48)}`
: '')
if (!isolated && !frozen) {
const requiredContext = this.packedPromptContext(
group.id, '', threadRootId, context.contextOptions || {},
)
assertRequiredContextFits(requiredContext)
}
const requestedOperationId = String(context.operationId || '')
const operationId = OPERATION_ID.test(requestedOperationId)
? requestedOperationId
: `agent-operation-${createHash('sha256').update(JSON.stringify({
runId: activeRun?.runId || taskId,
kind,
mode,
round,
})).digest('hex')}`
const v4AgentSlotBinding = context.v4 === true
&& V4_AGENT_PHASES.has(context.phase)
&& OPERATION_ID.test(String(context.slotId || ''))
&& SHA256.test(String(context.snapshotHash || ''))
? {
phase: context.phase,
slotId: context.slotId,
operationId,
snapshotHash: context.snapshotHash,
}
: null
const idempotencyMode = agent.idempotencyMode === 'durable' ? 'durable' : 'none'
// Isolated invocations must see only their approved prompt, so they never
// resume or persist the conversation's native session.
const session = new InvocationSession({
resolved: reviewOnly || isolated
? {
key: this.sessionKey(group.id, kind),
sessionRef: '',
sessionMeta: {},
provenance: createdSessionProvenance(taskId, true),
}
: this.sessionState(
group, kind, context.sessionThreadRootId || threadRootId, taskId,
),
group,
kind,
taskId,
deps: {
commitSessionState: mutator => this.commitSessionState(mutator),
openClawSessionRef: (...args) => this.openClawSessionRef(...args),
persistSessionState: (...args) => this.persistSessionState(...args),
},
})
const resumedConnectorGate = ['input', 'permission'].includes(context.resumedGate?.type)
&& context.resumedGate?.request?.source === 'connector'
? context.resumedGate
: null
let resumedPermission = context.resumedGate?.type === 'permission'
&& !resumedConnectorGate
? context.resumedGate
: null
if (resumedPermission) {
assertResumedPermissionBinding(resumedPermission, session.binding(), session.ref)
if (!session.ref) throw new Error('LOCAL_RUN_PERMISSION_RESUME_UNAVAILABLE')
}
const hermesNeedsLegacy = kind === 'hermes' && session.transport === 'acp'
&& (!HERMES_WORKSPACE_ACP_ENABLED || agent.acpAvailable === false)
if (resumedPermission && hermesNeedsLegacy) {
throw new Error('LOCAL_RUN_PERMISSION_RESUME_UNAVAILABLE')
}
if (session.ref && hermesNeedsLegacy) session.discard()
let v4Prompt = invocationContext?.promptOverride || ''
let v4Delivery = null
const buildV4Prompt = () => {
if (context.v4 !== true || typeof context.v4PromptBuilder !== 'function') return v4Prompt
const built = context.v4PromptBuilder({
sessionRefHash: sha256(session.ref),
sessionProvenanceHash: session.binding().sessionProvenanceHash,
sessionRotated: session.rotated,
hasSession: Boolean(session.ref),
})
if (!built || typeof built.prompt !== 'string' || !built.prompt
|| built.prompt.includes('\u0000') || Buffer.byteLength(built.prompt) > MAX_ISOLATED_PROMPT_BYTES) {
throw new Error('LOCAL_RUN_V4_PROMPT_INVALID')
}
v4Prompt = built.prompt
v4Delivery = built.delivery || null
return v4Prompt
}
try {
buildV4Prompt()
} catch (error) {
throw attachInvocationFailure(error, {
outcomeCertainty: 'not_started',
sideEffectsPossible: false,
operationId,
idempotencyMode,
})
}
const knownSessionHistory = group.conversationType === 'direct'
|| session.provenance.completeness !== 'unknown-legacy'
const sameTaskSession = !taskId
|| taskId === session.meta.originTaskId
|| (Array.isArray(session.meta.inheritedTaskIds) && session.meta.inheritedTaskIds.includes(taskId))
let transcriptAfterKind = knownSessionHistory && !session.rotated && session.storedRef && session.storedRef === session.ref
&& (!session.meta.lastDeliveredMessageId || !sameTaskSession)
? kind
: ''
// Group Sessions already contain the immutable bootstrap instructions after
// a successful turn. Keep direct chat unchanged and send only the compact
// Harness context on later group turns.
let promptMode = group.conversationType !== 'direct'
&& Boolean(session.ref)
&& !session.rotated
&& knownSessionHistory
&& session.meta.turns > 0
? 'continuation'
: 'bootstrap'
const sessionContextOptions = (!isolated && !frozen && promptMode === 'continuation' && sameTaskSession)
? { ...(context.contextOptions || {}), afterMessageId: session.meta.lastDeliveredMessageId || '' }
: (context.contextOptions || {})
let packedContext = isolated || frozen
? {
text: v4Prompt,
sourceMessageIds: context.snapshotSourceMessageIds || [],
sourceEntries: context.snapshotSourceEntries || [],
omittedCount: 0,
charCount: v4Prompt.length,
context: {
includedCount: 0,
omittedCount: 0,
charCount: v4Prompt.length,
contextPackId: invocationContext.contextPackId,
contextPackState: 'captured',
},
currentTaskText: v4Prompt,
stableText: '(frozen)',
recentText: '(frozen)',
continuationText: v4Prompt,
}
: this.packedPromptContext(
group.id, transcriptAfterKind, threadRootId, sessionContextOptions,
)
if (!isolated && !frozen) {
packedContext = withCollaborationPackage(
packedContext, context.collaborationPackage,
)
}
if (!isolated && !frozen) assertRequiredContextFits(packedContext)
let deliveredContext = deliveredPackedContext(packedContext, promptMode)
const harness = this.ensureRunHarness(group, activeRun, threadRootId)
const harnessRun = harness?.beginAgent(kind, round, deliveredContext.sourceMessageIds, {
turnSeq: context.turnSeq,
})
let deliveryContext = {
contextPackId: invocationContext?.contextPackId || activeRun?.contextPackId || '',
deliveryRecordIds: [],
sessionProvenance: session.provenance,
}
if (harnessRun) {
const liveHarnessRun = harness.current(kind, round, harnessRun.agentRunId)
if (liveHarnessRun) {
liveHarnessRun.context = {
...deliveredContext.context,
contextMode: promptMode,
sessionRotated: session.rotated,
...deliveryContext,
}
}
this.emitRunEvent(harnessRun)
this.armAgentSilence(activeRun, kind, round, harnessRun.agentRunId)
this.emitChanged()
}
const agentController = invocation.agentController instanceof AbortController
? invocation.agentController
: new AbortController()
const registration = invocation.registration || { agentRunId: '' }
registration.agentRunId = harnessRun?.agentRunId || ''
this.registerAgentController?.(
activeRun, kind, registration.agentRunId, agentController,
)
let parentAbortObserved = false
let agentCallbacksClosed = false
let parentAbortHandler = null
let parentAbortPromise = null
let runPromise = null
let attemptProgress = []
const resolvedGateIds = []
const pendingPermissionCallbacks = new Set()
let resumedPermissionUsed = false
let resumedPermissionError = null
const startedAt = Date.now()
if (signal) {
parentAbortPromise = new Promise((_, reject) => {
parentAbortHandler = () => {
parentAbortObserved = true
agentController.abort()
reject(agentStoppedError())
}
})
parentAbortPromise.catch(() => {})
if (signal.aborted) parentAbortHandler()
else signal.addEventListener('abort', parentAbortHandler, { once: true })
}
// Automatic discussions must make progress between rounds even when an
// Agent keeps emitting heartbeat events without ever returning a result,
// so they get the strict startup and no-progress limits.
const watchdog = createInvocationWatchdog({
timeoutMs: this.runAgentTimeoutMs,
toolTimeoutMs: this.runAgentToolTimeoutMs,
strict: mode === 'auto',
isStopped: () => parentAbortObserved || Boolean(signal?.aborted)
|| agentController.signal.aborted,
onTimeout: () => agentController.abort(),
})
const closeAgentCallbacks = () => {
agentCallbacksClosed = true
watchdog.close()
}
const outputs = new InvocationOutputs({
enabled: allowWrite,
workdir: group.workdir,
signal: agentController.signal,
agentKind: kind,
startedAt,
deps: {
captureAgentOutputs: (...args) => this.captureAgentOutputs(...args),
captureArtifactOutputs: (...args) => this.captureArtifactOutputs(...args),
importAgentOutputs: (...args) => this.importAgentOutputs(...args),
captureAgentOutcomeDescriptors: (...args) => this.captureAgentOutcomeDescriptors(...args),
},
})
const waitForHumanGate = async (operation) => {
watchdog.pause()
try {
return invocation.lease?.suspend
? await invocation.lease.suspend(operation, context.v4 === true
&& context.parallelGraph === true ? { pauseTask: true } : undefined)
: await operation()
} finally {
if (!agentController.signal.aborted) watchdog.resume()
}
}
const onProgress = (step) => {
if (agentCallbacksClosed || agentController.signal.aborted
|| !activeRun || this.activeRuns.get(group.id) !== activeRun) return
watchdog.establish()
watchdog.progress()
const next = [...attemptProgress]
const progressId = typeof step?.id === 'string' && /^[A-Za-z0-9._:-]{1,100}$/.test(step.id)
? step.id
: ''
const existingIndex = progressId
? next.findIndex(item => item?.id === progressId)
: -1
if (existingIndex >= 0) next[existingIndex] = { ...step, id: progressId }
else next.push(progressId ? { ...step, id: progressId } : step)
attemptProgress = next.slice(-8)
if (activeRun.currentKind === kind) activeRun.progress = attemptProgress
this.armAgentSilence(activeRun, kind, round, harnessRun?.agentRunId)
}
const answerFilter = createAnswerMarkerFilter({
mode: context.v4 === true ? 'receipt' : (mode === 'auto' ? 'auto' : 'plain'),
})
const emitHarnessEvent = (rawEvent) => {
if (agentCallbacksClosed || agentController.signal.aborted
|| this.activeRuns.get(group.id) !== activeRun
|| !harness || !harnessRun || !rawEvent) return
const event = harness.ingest(kind, round, rawEvent, harnessRun.agentRunId)
if (!event) return
// Once a connector has produced a real runtime event, the process is
// demonstrably alive. Active tools use a longer shared inactivity window.
watchdog.establish()
const eventType = String(event.type || '').toLowerCase()
const eventStatus = String(event.status || '').toLowerCase()
if (['tool_start', 'tool_update'].includes(eventType)
&& ['queued', 'running', 'waiting', 'in_progress', 'streaming'].includes(eventStatus)) {
watchdog.toolStarted()
} else if (eventType === 'tool_result_summary'
|| (eventType === 'status' && ['completed', 'failed', 'stopped', 'timeout', 'interrupted']
.includes(eventStatus))) {
watchdog.toolFinished()
}
this.emitRunEvent(event)
this.armAgentSilence(activeRun, kind, round, harnessRun.agentRunId)
if (event.type !== 'answer_delta' || event.seq % 8 === 0) {
this.scheduleRunCheckpoint(group.id, activeRun)
this.emitChanged()
}
}
const emitRuntimeEvent = (rawEvent) => {
if (agentCallbacksClosed || agentController.signal.aborted) return
watchdog.progress()
if (!rawEvent || rawEvent.type !== 'answer_delta'
|| (context.v4 !== true && mode !== 'auto')) {
emitHarnessEvent(rawEvent)
return
}
const visible = answerFilter.push(rawEvent.delta)
if (visible) emitHarnessEvent({ ...rawEvent, delta: visible })
}
const flushRuntimeEvent = (finalText = '') => {
const rest = answerFilter.flush(finalText)
if (rest) emitHarnessEvent({ type: 'answer_delta', status: 'running', delta: rest })
}
let stagedInputs = null
let result
let operationStarted = false
let sideEffectsStarted = false
let terminalFailureKnown = false
let harnessFinished = false
let connectorContext = {}
let connectorResume = null
if (resumedConnectorGate) {
const request = resumedConnectorGate.request
const binding = session.binding()
const requestHash = sha256(canonicalJson(request))
if (!agent.connectorInstanceId
|| request.connectorInstanceId !== agent.connectorInstanceId
|| request.connectorId !== agent.connectorId
|| request.connectorVersion !== agent.connectorVersion
|| request.runId !== activeRun?.runId
|| request.agentRunId !== resumedConnectorGate.agentRunId
|| request.operationId !== operationId
|| request.requestId !== resumedConnectorGate.request?.requestId
|| requestHash !== resumedConnectorGate.requestHash
|| binding.sessionRefHash !== request.sessionRefHash
|| binding.sessionProvenanceHash !== request.sessionProvenanceHash) {
throw new Error('LOCAL_RUN_CONNECTOR_REQUEST_MISMATCH')
}
connectorResume = {
type: resumedConnectorGate.type,
requestId: request.requestId,
requestHash,
...binding,
...(resumedConnectorGate.type === 'input'
? { response: resumedConnectorGate.response }
: {
status: resumedConnectorGate.status,
optionId: resumedConnectorGate.optionId,
}),
}
}
const finishHarness = (status, finalText = '', runtimeContext = {}) => {
if (!harness || !harnessRun || harnessFinished) return null
flushRuntimeEvent(finalText)
closeAgentCallbacks()
this.clearAgentSilence(activeRun, kind, round, harnessRun.agentRunId)
const finished = harness.finishAgent(kind, round, status, finalText, {
...deliveredContext.context,
contextMode: promptMode,
sessionRotated: session.rotated,
...(context.phase ? { phase: context.phase } : {}),
...(Number.isInteger(context.executionSequence)
? { executionSequence: context.executionSequence }
: {}),
...(v4AgentSlotBinding
? {
operationId: v4AgentSlotBinding.operationId,
snapshotHash: v4AgentSlotBinding.snapshotHash,
}
: {}),
...runtimeContext,
...deliveryContext,
...connectorContext,
}, harnessRun.agentRunId)
harnessFinished = true
this.emitRunEvent(finished.event)
this.checkpointRun(group.id, activeRun)
this.emitChanged()
return finished.capsule
}
try {
const persisted = this.checkpointRun(group.id, activeRun)
if (this.hasRunLedger() && persisted !== true) {
throw new Error('LOCAL_RUN_PERSIST_FAILED')
}
if (typeof context.onLeaseAcquired === 'function') {
context.onLeaseAcquired({
operationId,
agentRunId: harnessRun?.agentRunId || '',
permissionMode: allowWrite ? 'workspace-write' : 'read-only',
sideEffectsPossible: allowWrite,
})
}
await outputs.begin()
const nativeImageLimit = Math.max(
0,
Math.floor(Number(this.attachmentSupport(kind)?.image) || 0),
)
// Frozen prompts are fixed text, but the user's attachments still have
// to reach the Agent; only isolated invocations see nothing but their
// approved prompt.
stagedInputs = isolated
? null
: stageAgentInputs(group.workdir, context.attachmentSnapshots || [], nativeImageLimit)
const buildPrompt = (afterKind, contextPackage) => assembleInvocationPrompt({
group,
kind,
mode,
threadRootId,
context,
activeRun,
isolated,
frozen,
v4Prompt,
promptMode,
afterKind,
packed: contextPackage,
session,
stagedInputs,
promptFor: (...args) => this.promptFor(...args),
agentLabel: label => this.agentLabel(label),
})
let prompt = buildPrompt(transcriptAfterKind, packedContext)
const recordOutboundPayload = (outbound = {}) => {
watchdog.progress()
recordOutboundUsage(activeRun?.budget, outbound)
const baseContextPackId = isolated?.contextPackId || activeRun?.contextPackId || ''
if (!baseContextPackId || !harness || !harnessRun) return
const deliveredPrompt = typeof outbound?.prompt === 'string' ? outbound.prompt : prompt
const attempt = this.createAttemptContextPack({
baseContextPackId,
group,
taskId,
mode: group.conversationType === 'direct' ? 'direct' : mode,
kind,
packedContext: deliveredContext,
attachments: isolated ? [] : (context.attachmentSnapshots || []),
skillHints: isolated || frozen ? [] : (context.skillHints || []),
knowledgeBaseHints: isolated ? [] : (context.knowledgeBaseHints || []),
// The prompt is context-budgeted, so delivery verification must use
// the exact prompt scheduled for this Agent rather than the raw turn.
// Verify against the prompt actually reported by the transport. This
// remains correct when a recovery path rebuilds a prompt mid-attempt.
approvedPrompt: deliveredPrompt,
forceReadOnly: Boolean(isolated),
})
const delivery = this.recordContextDelivery({
contextPackId: attempt.record.contextPackId,
runId: activeRun.runId,
agentRunId: harnessRun.agentRunId,
kind,
outbound,
permissionMode: allowWrite ? 'workspace-write' : 'read-only',
skills: isolated || frozen ? [] : (context.skillHints || []),
runtimeAdditions: attempt.runtimeAdditions,
sessionProvenance: session.provenance,
})
deliveryContext = {
contextPackId: attempt.record.contextPackId,
deliveryRecordIds: [
...deliveryContext.deliveryRecordIds,
delivery.deliveryRecordId,
].slice(-8),
sessionProvenance: session.provenance,
...contextSourceProof(attempt.record),
promptChars: deliveredPrompt.length,
promptBytes: Buffer.byteLength(deliveredPrompt, 'utf8'),
promptHash: sha256(deliveredPrompt),
wirePayloadBytes: delivery.wirePayloadBytes,
wirePayloadHash: delivery.wirePayloadHash,
}
const liveHarnessRun = harness.current(kind, round, harnessRun.agentRunId)
if (liveHarnessRun) {
liveHarnessRun.context = {
...deliveredContext.context,
contextMode: promptMode,
sessionRotated: session.rotated,
...deliveryContext,
...connectorContext,
}
}
this.checkpointRun(group.id, activeRun)
this.emitChanged()
}
const rebuildFreshSession = () => {
session.discard()
promptMode = 'bootstrap'
deliveryContext = { ...deliveryContext, sessionProvenance: session.provenance }
transcriptAfterKind = ''
buildV4Prompt()
packedContext = frozen
? {
text: v4Prompt,
sourceMessageIds: context.snapshotSourceMessageIds || [],
sourceEntries: context.snapshotSourceEntries || [],
omittedCount: 0,
charCount: v4Prompt.length,
context: {
includedCount: 0, omittedCount: 0, charCount: v4Prompt.length,
contextPackId: invocationContext.contextPackId, contextPackState: 'captured',
},
currentTaskText: v4Prompt,
stableText: '(frozen)', recentText: '(frozen)', continuationText: v4Prompt,
}
: withCollaborationPackage(
this.packedPromptContext(group.id, '', threadRootId, context.contextOptions || {}),
context.collaborationPackage,
)
assertRequiredContextFits(packedContext)
deliveredContext = deliveredPackedContext(packedContext, promptMode)
const liveHarnessRun = harness?.current(
kind, round, harnessRun?.agentRunId || '',
)
if (liveHarnessRun) {
liveHarnessRun.sourceMessageIds = [...deliveredContext.sourceMessageIds]
liveHarnessRun.context = {
...deliveredContext.context,
contextMode: promptMode,
sessionRotated: session.rotated,
...deliveryContext,
...connectorContext,
}
}
prompt = buildPrompt('', packedContext)
this.scheduleRunCheckpoint(group.id, activeRun)
this.emitChanged()
return prompt
}
const runOptions = {
sessionRef: session.ref,
...(Number.isInteger(context.executionSequence)
? { executionSequence: context.executionSequence }
: {}),
onSessionRef: (nextSessionRef, metadata = {}) => {
if (agentCallbacksClosed || agentController.signal.aborted) return
watchdog.progress()
if (reviewOnly || isolated) return
session.adopt(nextSessionRef, metadata)
},
onSessionInvalidated: () => {
if (kind !== 'hermes' || reviewOnly || isolated || resumedPermission) return null
return { prompt: rebuildFreshSession() }
},
signal: agentController.signal,
sandbox: allowWrite ? 'workspace-write' : 'read-only',
operationId,
onActivity: () => {
// A cloud request has no local process events while the remote CLI
// is working. Its activity callback is therefore the first proof
// that the run has started and must release the initial deadline.
if (agent.cloud === true) {
watchdog.establish()
watchdog.progress()
} else {
watchdog.liveness()
}
},
onProgress,
onEvent: emitRuntimeEvent,
onOutboundPayload: recordOutboundPayload,
onPermissionRequest: (request, permissionContext = {}) => {
// Permission prompts default to yolo for every local invocation.
// Callers can opt into an interactive Gate explicitly with
// `yolo: false`; sandbox and workspace authorization remain enforced
// before this callback is reached.
if ((context.yolo ?? this.defaultYolo) !== false) {
const automatic = automaticPermissionResponse(request, { allowWrite })
if (automatic !== undefined) return Promise.resolve(automatic)
}
const pending = Promise.resolve().then(async () => {
if (resumedPermission && !resumedPermissionUsed) {
if (!matchesResumedPermission(request, resumedPermission)) {
resumedPermissionError = new Error('LOCAL_RUN_PERMISSION_REQUEST_MISMATCH')
throw resumedPermissionError
}
resumedPermissionUsed = true
return {
status: resumedPermission.status,
optionId: resumedPermission.optionId,
}
}
const requestHash = sha256(canonicalJson(request))
const binding = session.binding()
const decision = await waitForHumanGate(() => this.requestHumanGate({
type: 'permission',
runId: activeRun.runId,
agentRunId: harnessRun.agentRunId,
agentKind: kind,
summary: 'Agent requests permission to continue a tool action.',
options: request.options,
request,
}, {
signal: permissionContext.signal || agentController.signal,
preserveOnAbort: () => activeRun.stopReason === 'shutdown',
continuation: {
resumeKind: 'agent_slot',
agentRunId: harnessRun.agentRunId,
agentKind: kind,
round,
...(v4AgentSlotBinding || {}),
requestId: permissionContinuationRequestId(requestHash, binding),
requestHash,
...binding,
},
}))
if (decision.gateId) resolvedGateIds.push(decision.gateId)
return { status: decision.status, optionId: decision.optionId }
})
pendingPermissionCallbacks.add(pending)
pending.then(
() => pendingPermissionCallbacks.delete(pending),
() => pendingPermissionCallbacks.delete(pending),
)
return pending
},
sessionTransport: session.transport,
...(kind === 'workbuddy' && Number.isInteger(context.maxInternalTurns)
? { maxTurns: context.maxInternalTurns }
: {}),
attachments: isolated ? [] : (stagedInputs?.nativeImagePaths || []),
// ACP runtimes are shared by the stable group/task session key. OpenClaw
// is excluded because its gateway wrapper owns the process lifetime and
// is intentionally torn down after each invocation.
...(kind !== 'openclaw' ? { acpPersistenceKey: session.key } : {}),
...(kind === 'hermes'
? { hermesAcpAvailable: HERMES_WORKSPACE_ACP_ENABLED && agent.acpAvailable !== false }
: {}),
}
const runCurrentSession = () => {
const currentRunOptions = {
...runOptions, sessionRef: session.ref, sessionTransport: session.transport,
}
if (!agent.connectorInstanceId) {
return this.runAgent(agent, prompt, group.workdir, currentRunOptions)
}
if (!this.connectorRuntime || typeof this.connectorRuntime.run !== 'function') {
throw new Error('AGENT_CONNECTOR_RUNTIME_UNAVAILABLE')
}
if (!activeRun?.runId || !harnessRun?.agentRunId) {
throw new Error('AGENT_CONNECTOR_RUN_ID_INVALID')
}
return this.connectorRuntime.run(agent, prompt, group.workdir, {
...currentRunOptions,
runId: activeRun.runId,
agentRunId: harnessRun.agentRunId,
onActivity: () => {
if (agent.cloud === true) {
watchdog.establish()
watchdog.progress()
} else {
watchdog.liveness()
}
},
onConnectorState: (nextContext) => {
watchdog.progress()
connectorContext = nextContext
const liveHarnessRun = harness.current(kind, round, harnessRun.agentRunId)
if (liveHarnessRun) {
liveHarnessRun.context = {
...deliveredContext.context,
contextMode: promptMode,
sessionRotated: session.rotated,
...deliveryContext,
...connectorContext,
}
}
this.checkpointRun(group.id, activeRun)
this.emitChanged()
},
connectorResume,
})
}
const costDecision = activeRun?.budget?.check('costMicros')
if (costDecision?.action === 'human_gate') {
const resumed = context.resumedGate
const decision = resumed?.type === 'budget'
? resumed
: await waitForHumanGate(() => this.requestHumanGate({
type: 'budget',
runId: activeRun.runId,
agentRunId: harnessRun.agentRunId,
agentKind: kind,
summary: 'Cost usage is unavailable for this Agent attempt.',
options: [
{ optionId: 'continue-unmetered', name: 'Continue', kind: 'allow_once' },
{ optionId: 'cancel-attempt', name: 'Cancel', kind: 'reject_once' },
],
request: costDecision,
}, {
signal: agentController.signal,
preserveOnAbort: () => activeRun.stopReason === 'shutdown',
continuation: {
resumeKind: 'agent_slot',
agentRunId: harnessRun.agentRunId,
agentKind: kind,
round,
...(v4AgentSlotBinding || {}),
},
}))
if (decision.gateId && resumed?.type !== 'budget') resolvedGateIds.push(decision.gateId)
if (decision.status !== 'approved') throw new Error('LOCAL_BUDGET_REJECTED')
activeRun.budget.approveUnobservable('costMicros')
this.checkpointRun(group.id, activeRun)
}
if (agentController.signal.aborted) throw agentStoppedError()
const executeCurrentSession = async () => {
const reusedSessionRef = session.ref
try {
operationStarted = true
if (allowWrite) sideEffectsStarted = true
return await runCurrentSession()
} catch (error) {
if (agentController.signal.aborted || !reusedSessionRef
|| resumedConnectorGate
|| error?.message !== 'LOCAL_AGENT_SESSION_INVALID') throw error
if (resumedPermission) {
if (context.v4 !== true || allowWrite) throw error
resumedPermission = null
}
rebuildFreshSession()
operationStarted = true
if (allowWrite) sideEffectsStarted = true
return await runCurrentSession()
}
}
const raceCurrentSession = async () => {
runPromise = Promise.resolve().then(executeCurrentSession)
runPromise.catch(() => {})
const pending = [runPromise, watchdog.promise]
if (parentAbortPromise) pending.push(parentAbortPromise)
return Promise.race(pending)
}
result = await raceCurrentSession()
if (['waiting_input', 'waiting_permission'].includes(result?.outcome)) {
if (!agent.connectorInstanceId || resumedConnectorGate) {
throw new Error('LOCAL_RUN_CONNECTOR_WAITING_INVALID')
}
const waiting = result.waitingRequest
const waitingSessionRef = String(result.sessionRef || '')
if (waitingSessionRef && waitingSessionRef !== session.ref) {
runOptions.onSessionRef(waitingSessionRef)
session.ref = waitingSessionRef
}
const binding = session.binding()
const request = {
version: 1,
source: 'connector',
outcome: result.outcome,
requestId: waiting?.requestId,
runId: activeRun.runId,
agentRunId: harnessRun.agentRunId,
...(result.outcome === 'waiting_input'
? { prompt: waiting?.prompt }
: {
permission: waiting?.permission,
...(waiting?.summary ? { summary: waiting.summary } : {}),
}),
connectorInstanceId: agent.connectorInstanceId,
connectorId: result.connector?.connectorId,
connectorVersion: result.connector?.connectorVersion,
operationId,
cursor: waiting?.cursor,
...binding,
}
const requestHash = sha256(canonicalJson(request))
const gateType = result.outcome === 'waiting_input' ? 'input' : 'permission'
const decision = await waitForHumanGate(() => this.requestHumanGate({
type: gateType,
runId: activeRun.runId,
agentRunId: harnessRun.agentRunId,
agentKind: kind,
summary: gateType === 'input'
? String(waiting?.prompt || '').trim().replace(/\s+/g, ' ').slice(0, 500)
: String(waiting?.summary || 'Connector requests permission to continue.').slice(0, 500),
options: gateType === 'input'
? [
{ optionId: 'submit-input', name: 'Submit', kind: 'respond' },
{ optionId: 'cancel-input', name: 'Cancel', kind: 'reject' },
]
: [
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
{ optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
],
request,
}, {
signal: agentController.signal,
preserveOnAbort: () => activeRun.stopReason === 'shutdown',
continuation: {
resumeKind: 'agent_slot',
agentRunId: harnessRun.agentRunId,
agentKind: kind,
round,
...(v4AgentSlotBinding || {}),
requestId: request.requestId,
requestHash,
...binding,
},
}))
if (decision.gateId) resolvedGateIds.push(decision.gateId)
if (decision.status !== 'approved') throw new Error(
gateType === 'input' ? 'LOCAL_INPUT_REJECTED' : 'LOCAL_PERMISSION_REJECTED',
)
connectorResume = {
type: gateType,
requestId: request.requestId,
requestHash,
...binding,
...(gateType === 'input'
? { response: decision.response }
: { status: decision.status, optionId: decision.optionId }),
}
result = await raceCurrentSession()
}
if (pendingPermissionCallbacks.size) {
await Promise.allSettled([...pendingPermissionCallbacks])
throw new Error('LOCAL_AGENT_PERMISSION_TERMINAL_RACE')
}
terminalFailureKnown = ['failed', 'cancelled']
.includes(String(result?.outcome || ''))
if (agentController.signal.aborted) throw agentStoppedError()
if (resumedPermissionError) throw resumedPermissionError
if (resumedPermission && !resumedPermissionUsed) {
throw new Error('LOCAL_RUN_PERMISSION_REQUEST_NOT_REISSUED')
}
result = requireTerminalAgentResult(result)
this.markRuntimeCredential(kind, 'ready')
const collaborationReply = parseV4CollaborationReceipt(
result,
context.v4 === true,
context.phase || '',
{
allowMissingProposalReceipt: context.allowMissingProposalReceipt === true,
allowMissingV4Receipt: context.allowMissingV4Receipt === true,
},
)
const reply = mode === 'auto' && context.completionPolicy !== 'typed'
? parseAutoReply(collaborationReply.text)
: { text: collaborationReply.text, consensus: false }
if (!reply.text) throw new Error('LOCAL_AGENT_EMPTY_RESPONSE')
const progress = attemptProgress.length ? attemptProgress : result.progress
const toolCalls = cleanProgressSteps(progress).map(step => ({
...step,
status: step.status === 'in_progress' ? 'completed' : step.status,
}))
if (activeRun) activeRun.progress = toolCalls
if (activeRun?.budget) {
recordReplyUsage(activeRun.budget, {
usage: result.usage,
replyText: reply.text,
toolCallCount: toolCalls.length,
})
this.checkpointRun(group.id, activeRun)
}
const attachments = await outputs.importMedia(reply.text)
if (agentController.signal.aborted) throw agentStoppedError()
const descriptors = await outputs.describeOutcomes({
runId: activeRun?.runId,
agentRunId: harnessRun?.agentRunId,
})
if (agentController.signal.aborted) throw agentStoppedError()
const requestedOutcomeRefs = !internal && context.outcomeRefs
&& typeof context.outcomeRefs === 'object'
&& !Array.isArray(context.outcomeRefs)
? context.outcomeRefs
: {}
const reportedOutcomeRefs = !internal
? normalizeOutcomeRefs(result.outcomeRefs, context.v4 === true ? { strict: true } : {})
: {}
const capturedOutcomeRefs = internal
? { artifactIds: [], evidenceIds: [] }
: this.recordAgentOutcomes({
groupId: group.id,
runId: activeRun?.runId,
agentRunId: harnessRun?.agentRunId,
agentKind: kind,
round,
conclusion: reply.text,
descriptors,
})
const producedOutcomeRefs = mergeOutcomeRefs(
[reportedOutcomeRefs, capturedOutcomeRefs],
{ strict: context.v4 === true },
)
const auditOutcomeRefs = mergeOutcomeRefs([
producedOutcomeRefs,
capturedOutcomeRefs,
requestedOutcomeRefs,
])
const finalStatus = result.outcome
const trace = finishHarness(finalStatus, reply.text, {
promptChars: prompt.length,
promptBytes: Buffer.byteLength(prompt, 'utf8'),
promptHash: sha256(prompt),
externalRunRef: result.externalRunRef,
outcomeRefs: auditOutcomeRefs,
...(collaborationReply.collaboration?.taskDecision
? { taskDecision: collaborationReply.collaboration.taskDecision } : {}),
})
const messageMetadata = {
elapsedMs: Date.now() - startedAt,
toolCalls,
attachments,
trace,
responseVersionRootId,
...(context.turnSeq ? { turnSeq: context.turnSeq } : {}),
}
const pendingMessage = internal
? null
: {
groupId: group.id,
role: 'agent',
content: reply.text,
agentKind: kind,
threadRootId,
system: null,
metadata: messageMetadata,
}
const message = pendingMessage && context.deferMessage !== true
? this.addMessage(
pendingMessage.groupId,
pendingMessage.role,
pendingMessage.content,
pendingMessage.agentKind,
pendingMessage.threadRootId,
pendingMessage.system,
pendingMessage.metadata,
)
: null
if (!reviewOnly && !isolated) {
session.complete(result.sessionRef, {
promptChars: prompt.length,
replyChars: reply.text.length,
rotated: session.rotated,
transport: session.transport,
lastDeliveredMessageId: packedContext.latestMessageId || session.meta.lastDeliveredMessageId,
})
}
return {
message,
pendingMessage,
collaboration: collaborationReply.collaboration,
outcomeRefs: producedOutcomeRefs,
producedOutcomeRefs,
operationId,
consensus: reply.consensus && result.outcome === 'completed',
...(v4Delivery ? {
v4Delivery,
v4SessionBinding: connectorSessionBinding(result.sessionRef || session.ref, session.provenance),
} : {}),
}
} catch (caughtError) {
const stop = resolveInvocationStop({
caughtError, signal, parentAbortObserved, activeRun, agentController, watchdog,
})
if (stop.settleOutstanding) {
const cleanupPromises = [runPromise, ...outputs.pending()].filter(Boolean)
if (cleanupPromises.length) {
await settleWithin(Promise.allSettled(cleanupPromises), this.runAbortGraceMs)
}
}
const { error } = stop
// Only an explicit sign-in failure changes readiness. Network, quota and
// provider refusals stay run-scoped so a healthy Agent remains usable.
if (error?.code === 'LOCAL_AGENT_AUTH_REQUIRED'
&& context.deferCredentialFailure !== true) {
this.markRuntimeCredential(kind, 'missing')
}
// Failure and stop notices take the turn's slot in the timeline.
attachErrorMetadata(error, {
runTrace: finishHarness(stop.status),
turnSeq: context.turnSeq,
})
throw attachInvocationFailure(error, {
outcomeCertainty: terminalFailureKnown
? 'known_failed'
: (operationStarted || sideEffectsStarted ? 'unknown_outcome' : 'not_started'),
sideEffectsPossible: allowWrite && sideEffectsStarted,
operationId,
idempotencyMode,
})
} finally {
closeAgentCallbacks()
if (signal && parentAbortHandler) {
signal.removeEventListener('abort', parentAbortHandler)
}
this.unregisterAgentController?.(
activeRun, kind, registration.agentRunId, agentController,
)
if (activeRun?.stopReason !== 'shutdown') {
for (const gateId of resolvedGateIds) {
this.completeHumanGateContinuation?.(activeRun.runId, gateId, 'completed')
}
}
cleanupStagedAgentInputs(stagedInputs)
}
}
resetSession(group, kind, rotateOpenClaw = true, taskId = '') {
const key = this.sessionKey(group.id, kind)
const legacyPrefix = `${group.id}:${kind}:thread:`
return this.commitSessionState((state) => {
const legacyKeys = new Set([
key,
...(taskId ? [this.sessionKey(group.id, kind, taskId)] : []),
...state.messages.filter(message => message.groupId === group.id && message.role === 'user')
.map(message => this.sessionKey(group.id, kind, message.id)),
])
const belongsToSession = candidate => legacyKeys.has(candidate)
|| candidate.startsWith(legacyPrefix)
|| (candidate.startsWith(`${group.id}:task:`) && candidate.endsWith(`:${kind}`))
let changed = false
for (const candidate of Object.keys(state.sessions)) {
if (!belongsToSession(candidate)) continue
delete state.sessions[candidate]
changed = true
}
for (const candidate of Object.keys(state.sessionMeta)) {
if (!belongsToSession(candidate)) continue
delete state.sessionMeta[candidate]
changed = true
}
if (kind === 'openclaw' && rotateOpenClaw) {
const generation = randomUUID().replace(/[^a-zA-Z0-9]/g, '').slice(0, 12) || 'session'
const provenance = createdSessionProvenance(cleanText(taskId, 120))
state.sessions[key] = this.openClawSessionRef(group, generation)
state.sessionMeta[key] = normalizeSessionMeta(provenanceMeta(provenance))
changed = true
}
return changed
})
}
}
module.exports = { LocalWorkspaceAgentInvocation }