diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 9b5497f2c..eb93bb037 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -10716,6 +10716,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d mode, attachments: Array.isArray(runOptions?.traceAttachments) ? runOptions.traceAttachments : [], conversationId: this.conversationIds.get(tabId) || null, + // Derived-run lineage: when this run is launched from an active run on + // the same tab (cloud run, workflow replay), the launching call site + // passes the origin ids so the trace can attribute the derived run. + parentRunId: runOptions?.parentRunId || null, + parentSessionId: runOptions?.parentSessionId || null, force: runOptions?.cloudRun === true, }); } catch { @@ -19594,10 +19599,25 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d await trace.endRun(runId, { status, finalContent }); } + async _getSavedWorkflowParentTrace(runId) { + return trace.getRun(runId); + } + + async _startSavedWorkflowTraceRun(meta) { + return trace.startRun(meta); + } + async replaySavedWorkflow(tabId, workflow, parameters = {}, onUpdate = () => {}, runOptions = {}) { if (!workflow?.id || !Array.isArray(workflow.steps) || !workflow.steps.length) { throw new Error('Saved workflow is missing or invalid.'); } + // Capture the compiling trace before the run claim. Active trace ids are + // cleared when a run ends, but a normalized workflow retains its durable + // source run id and can therefore establish real replay provenance. + const replayParentRunId = runOptions?.parentRunId || workflow.source?.runId || null; + let replayParentSessionId = replayParentRunId + ? runOptions?.parentSessionId || null + : null; await this._claimRunEntry(tabId, 'workflow', runOptions); let completionRunToken = ''; let previousForegroundCapture = false; @@ -19626,8 +19646,16 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d capturePolicyConfigured = true; startUrl = await this._currentUrl(tabId); const conversationId = await this.ensureConversationId(tabId, 'act'); - traceRunId = await trace.startRun({ + if (replayParentRunId && !replayParentSessionId) { + try { + const parentTrace = await this._getSavedWorkflowParentTrace(replayParentRunId); + replayParentSessionId = parentTrace?.conversationId || null; + } catch { /* missing historical traces leave session lineage unset */ } + } + traceRunId = await this._startSavedWorkflowTraceRun({ conversationId, + parentRunId: replayParentRunId, + parentSessionId: replayParentSessionId, userMessage: `Run saved workflow: ${workflow.name}`, tabUrl: startUrl, mode: 'act', diff --git a/src/chrome/src/cloud-runs.js b/src/chrome/src/cloud-runs.js index f8e55e938..43874d753 100644 --- a/src/chrome/src/cloud-runs.js +++ b/src/chrome/src/cloud-runs.js @@ -1096,6 +1096,18 @@ export function createCloudRunController({ let recordingId = null; const strictSecretMode = agent.strictSecretMode === true; try { + // A continuation starts only after its parent has finished, so the + // agent's active-run map cannot identify the parent here. The cloud + // run record keeps the actual trace id; resolve its session from that + // trace instead of attaching the tab's potentially unrelated session. + const parentTraceRunId = parentRun?.traceRunId || null; + let parentTraceSessionId = null; + if (parentTraceRunId && typeof workflowTrace?.getRun === 'function') { + try { + const parentTrace = await workflowTrace.getRun(parentTraceRunId); + parentTraceSessionId = parentTrace?.conversationId || null; + } catch { /* lineage lookup is best-effort and must not fail a run */ } + } if (run.capture === 'video') { try { if (!startRecording || !stopRecording) throw new Error('Cloud run video capture is unavailable.'); @@ -1169,6 +1181,8 @@ export function createCloudRunController({ run.traceRunId = traceRunId; schedulePersist(); }, + parentRunId: parentTraceRunId, + parentSessionId: parentTraceSessionId, }); } run.pendingInput = null; diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index ddf80b31c..0983f4147 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -8577,6 +8577,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d mode, attachments: Array.isArray(runOptions?.traceAttachments) ? runOptions.traceAttachments : [], conversationId: this.conversationIds.get(tabId) || null, + // Derived-run lineage: when this run is launched from an active run on + // the same tab (cloud run, workflow replay), the launching call site + // passes the origin ids so the trace can attribute the derived run. + parentRunId: runOptions?.parentRunId || null, + parentSessionId: runOptions?.parentSessionId || null, force: runOptions?.cloudRun === true, }); } catch { @@ -16758,10 +16763,25 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d await trace.endRun(runId, { status, finalContent }); } + async _getSavedWorkflowParentTrace(runId) { + return trace.getRun(runId); + } + + async _startSavedWorkflowTraceRun(meta) { + return trace.startRun(meta); + } + async replaySavedWorkflow(tabId, workflow, parameters = {}, onUpdate = () => {}, runOptions = {}) { if (!workflow?.id || !Array.isArray(workflow.steps) || !workflow.steps.length) { throw new Error('Saved workflow is missing or invalid.'); } + // Capture the compiling trace before the run claim. Active trace ids are + // cleared when a run ends, but a normalized workflow retains its durable + // source run id and can therefore establish real replay provenance. + const replayParentRunId = runOptions?.parentRunId || workflow.source?.runId || null; + let replayParentSessionId = replayParentRunId + ? runOptions?.parentSessionId || null + : null; await this._claimRunEntry(tabId, 'workflow', runOptions); let completionRunToken = ''; let startUrl = ''; @@ -16787,8 +16807,16 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d completionRunToken = this._beginCompletionInvariant(tabId); startUrl = await this._currentUrl(tabId); const conversationId = await this.ensureConversationId(tabId, 'act'); - traceRunId = await trace.startRun({ + if (replayParentRunId && !replayParentSessionId) { + try { + const parentTrace = await this._getSavedWorkflowParentTrace(replayParentRunId); + replayParentSessionId = parentTrace?.conversationId || null; + } catch { /* missing historical traces leave session lineage unset */ } + } + traceRunId = await this._startSavedWorkflowTraceRun({ conversationId, + parentRunId: replayParentRunId, + parentSessionId: replayParentSessionId, userMessage: `Run saved workflow: ${workflow.name}`, tabUrl: startUrl, mode: 'act', diff --git a/test/run.js b/test/run.js index 0d067a449..aeaa7e352 100644 --- a/test/run.js +++ b/test/run.js @@ -17931,6 +17931,155 @@ test('cloud run controller preserves a boolean false root output schema', async 'persistence marked the false root schema as unstructured'); }); +test('cloud run controller threads lineage from the completed parent cloud trace', async () => { + const session = {}; + const tab = { id: 23, url: 'https://example.test/', active: true, windowId: 5 }; + const receivedRunOptions = []; + let nextRun = 0; + const controller = createCloudRunController({ + chromeApi: { + tabs: { + query: async () => [tab], + get: async () => tab, + update: async () => tab, + }, + windows: { update: async () => ({}) }, + storage: { + session: { + get: async key => ({ [key]: session[key] || [] }), + set: async value => Object.assign(session, value), + }, + }, + runtime: { sendMessage: async () => ({}) }, + }, + agent: { + isRunning: () => false, + abort: () => {}, + setApiMutationsAllowed: () => {}, + setTemporaryApiMutationsAllowed: () => {}, + // A persistent conversation id without a trace parent must never become + // an orphaned parent-session link on a root cloud run. + conversationIds: { get: () => 'conv_unrelated' }, + processMessage: async (_tabId, task, _onUpdate, _mode, _attachments, runOptions) => { + receivedRunOptions.push(runOptions); + runOptions.onTraceStarted?.(task === 'Parent task' ? 'trace_parent_1' : 'trace_child_1'); + return 'cloud result'; + }, + }, + ensureOffscreen: async () => {}, + workflowTrace: { + async getRun(runId) { + assert.equal(runId, 'trace_parent_1'); + return { runId, conversationId: 'conv_parent_9' }; + }, + }, + makeRunId: () => (++nextRun === 1 ? 'cloud_parent_1' : 'cloud_child_1'), + }); + + const parent = await controller.startRun({ task: 'Parent task', apiMutationsAllowed: true }); + await new Promise(resolve => setTimeout(resolve, 0)); + await controller.startRun({ task: 'Child task', parentRunId: parent.runId }); + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.equal(receivedRunOptions[0].parentRunId, null, 'root cloud run received an orphaned parent run'); + assert.equal(receivedRunOptions[0].parentSessionId, null, 'root cloud run received an orphaned parent session'); + assert.equal(receivedRunOptions[1].parentRunId, 'trace_parent_1', 'continuation did not receive its parent trace'); + assert.equal(receivedRunOptions[1].parentSessionId, 'conv_parent_9', 'continuation did not receive its parent trace session'); +}); + +test('cloud run controller tolerates an agent without lineage maps', async () => { + const session = {}; + const tab = { id: 24, url: 'https://example.test/', active: true, windowId: 5 }; + let receivedRunOptions; + const controller = createCloudRunController({ + chromeApi: { + tabs: { + query: async () => [tab], + get: async () => tab, + update: async () => tab, + }, + windows: { update: async () => ({}) }, + storage: { + session: { + get: async key => ({ [key]: session[key] || [] }), + set: async value => Object.assign(session, value), + }, + }, + runtime: { sendMessage: async () => ({}) }, + }, + agent: { + isRunning: () => false, + abort: () => {}, + setApiMutationsAllowed: () => {}, + setTemporaryApiMutationsAllowed: () => {}, + processMessage: async (_tabId, _task, _onUpdate, _mode, _attachments, runOptions) => { + receivedRunOptions = runOptions; + return 'cloud result'; + }, + }, + ensureOffscreen: async () => {}, + makeRunId: () => 'run_lineage_root', + }); + + await controller.startRun({ task: 'Do the thing.', apiMutationsAllowed: true }); + assert.equal(receivedRunOptions.parentRunId, null, 'missing parent run must fall back to null'); + assert.equal(receivedRunOptions.parentSessionId, null, 'missing parent session must fall back to null'); +}); + +test('trace lineage: _startTraceRun and replay plumb parent ids in both builds', () => { + for (const browser of ['chrome', 'firefox']) { + const agentSource = fs.readFileSync(path.join(ROOT, `src/${browser}/src/agent/agent.js`), 'utf8'); + assert.match(agentSource, /parentRunId: runOptions\?\.parentRunId \|\| null,/, `${browser}: _startTraceRun does not forward parentRunId`); + assert.match(agentSource, /parentSessionId: runOptions\?\.parentSessionId \|\| null,/, `${browser}: _startTraceRun does not forward parentSessionId`); + assert.match(agentSource, /const replayParentRunId = runOptions\?\.parentRunId \|\| workflow\.source\?\.runId \|\| null;[\s\S]*?await this\._claimRunEntry\(tabId, 'workflow', runOptions\);/, `${browser}: replay does not capture source lineage before claiming the tab`); + assert.match(agentSource, /this\._getSavedWorkflowParentTrace\(replayParentRunId\)/, `${browser}: replay does not resolve its source trace session`); + assert.match(agentSource, /parentRunId: replayParentRunId,[\s\S]*?parentSessionId: replayParentSessionId,/, `${browser}: replay does not pass captured lineage to tracing`); + } + const chromeCloudRuns = fs.readFileSync(path.join(ROOT, 'src/chrome/src/cloud-runs.js'), 'utf8'); + assert.match(chromeCloudRuns, /const parentTraceRunId = parentRun\?\.traceRunId \|\| null;/, 'cloud-runs does not use the completed parent trace'); + assert.match(chromeCloudRuns, /workflowTrace\.getRun\(parentTraceRunId\)/, 'cloud-runs does not resolve the parent trace session'); + assert.match(chromeCloudRuns, /parentRunId: parentTraceRunId,[\s\S]*?parentSessionId: parentTraceSessionId,/, 'cloud-runs does not thread resolved parent lineage'); + assert.ok(!fs.existsSync(path.join(ROOT, 'src/firefox/src/cloud-runs.js')), 'Firefox has no cloud-runs module — lineage threading is Chrome-only by platform boundary'); +}); + +test('saved workflow replay captures its source lineage before claiming the tab', async () => { + for (const [browser, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { + const tabId = browser === 'chrome' ? 17853 : 17854; + const workflow = { + id: `workflow_lineage_${browser}`, + name: 'Replay lineage', + source: { runId: `trace_source_${browser}` }, + start: { origin: 'https://example.test', pathFamily: '/expected' }, + steps: [{ id: 'step_1', tool: 'navigate', args: { url: 'https://example.test/next' } }], + }; + const agent = new AgentClass({ getActive: () => ({ model: 'test-model' }) }); + let traceMeta = null; + agent._claimRunEntry = async () => { + // If replay reads workflow.source after claiming, this destroys lineage. + workflow.source.runId = ''; + }; + agent._hydrate = async () => {}; + agent._persist = () => {}; + agent._currentUrl = async () => 'https://other.test/'; + agent.ensureConversationId = async () => `conv_child_${browser}`; + agent._getSavedWorkflowParentTrace = async runId => ({ + runId, + conversationId: `conv_parent_${browser}`, + }); + agent._startSavedWorkflowTraceRun = async meta => { + traceMeta = meta; + return `trace_replay_${browser}`; + }; + agent._endSavedWorkflowTraceRun = async () => {}; + + const replay = await agent.replaySavedWorkflow(tabId, workflow); + + assert.equal(replay.status, 'fallback', `${browser}: replay fixture did not stop after trace start`); + assert.equal(traceMeta.parentRunId, `trace_source_${browser}`, `${browser}: replay lost the compiling trace id`); + assert.equal(traceMeta.parentSessionId, `conv_parent_${browser}`, `${browser}: replay used the child conversation as its parent session`); + } +}); + test('cloud run controller rejects duplicate caller-supplied run IDs', async () => { const session = {}; const controller = createCloudRunController({