(
messages: Message[],
sessionId: string,
options: AgentOptions,
)
| 901 | |
| 902 | // Expose backtrack/checkpoint to tools via the wired sandbox handle. |
| 903 | this.activeSandbox = sandbox; |
| 904 | let reachedFinal = false; |
| 905 | const firstUserMsg = [...messages].reverse().find(m => m.role === 'user')?.content ?? 'task'; |
| 906 | // Flywheel: collect the reasoning trace + final answer from the event stream |
| 907 | // (no edits to run() needed — we observe what it already emits). |
| 908 | const flywheelEnabled = (this.config as any).flywheel?.enabled === true; |
| 909 | const reasoning: string[] = []; |
| 910 | let finalContent = ''; |
| 911 | try { |
| 912 | yield { type: 'notice', data: { message: `🔒 Sandbox: working on ${sandbox.branch} (isolated)` } }; |
| 913 | for await (const ev of this.run(messages, sessionId, options)) { |
| 914 | if (ev.type === 'final') { reachedFinal = true; finalContent = (ev as any).data?.content ?? ''; } |
| 915 | if (flywheelEnabled && (ev.type as string) === 'thinking') { |
| 916 | const c = (ev as any).data?.content; |
| 917 | if (typeof c === 'string' && !c.startsWith('(format correction')) reasoning.push(c); |
| 918 | } |
| 919 | yield ev; |
| 920 | } |
| 921 | } catch (e: any) { |
| 922 | logger.warn('runSandboxed: inner run threw — abandoning sandbox', { err: e?.message }); |
| 923 | throw e; |
| 924 | } finally { |
| 925 | this.activeSandbox = null; |
| 926 | const commitMsg = `qodex: ${String(firstUserMsg).slice(0, 72).replace(/\n/g, ' ')}`; |
| 927 | // Capture the changed-file list from git BEFORE finishing (the sandbox |
| 928 | // branch still has the diff). Best-effort. |
| 929 | let changedFiles: string[] = []; |
| 930 | if ((flywheelEnabled || process.env.QODEX_RECEIPT_FILE) && reachedFinal) { |
| 931 | try { |
| 932 | const { git } = await import('../tools/git/git-runner.js'); |
| 933 | const diff = await git(['diff', '--name-only', sandbox.baseCommitRef() ?? 'HEAD'], { cwd: this.cwd, signal: options.signal }); |
| 934 | if (diff.exitCode === 0) changedFiles = diff.stdout.split('\n').map(s => s.trim()).filter(Boolean); |
| 935 | } catch { /* ignore */ } |
| 936 | } |
| 937 | // Capture the branch name BEFORE finish() (it nulls internal state) so we |
| 938 | // can point the user at their work if the merge fails. |
| 939 | const sandboxBranch = sandbox.branch; |
| 940 | const finishResult = await sandbox.finish(reachedFinal, commitMsg, options.signal) |
| 941 | .catch((e: any) => ({ merged: false as const, reason: 'error' as const, error: e?.message ? String(e.message) : undefined })); |
| 942 | const merged = finishResult.merged; |
| 943 | if (reachedFinal && merged) { |
| 944 | yield { type: 'notice', data: { message: '🔒 Sandbox: changes squash-merged onto your branch ✓' } }; |
| 945 | // ── Data flywheel: record this successful trajectory (opt-in) ── |
| 946 | if (flywheelEnabled) { |
| 947 | try { |
| 948 | const { recordTrajectory } = await import('./trajectory.js'); |
| 949 | await recordTrajectory(this.cwd, { |
| 950 | prompt: String(firstUserMsg), |
| 951 | reasoning, |
| 952 | filesChanged: changedFiles, |
| 953 | finalSummary: finalContent.slice(0, 500), |
| 954 | messages: [ |
| 955 | { role: 'user', content: String(firstUserMsg) }, |
| 956 | { role: 'assistant', content: finalContent }, |
| 957 | ], |
| 958 | }); |
| 959 | } catch (e: any) { |
| 960 | logger.debug('Flywheel record skipped', { err: e?.message }); |
no test coverage detected