Merge pull request #726 from rowboatlabs/runtime-refactor

fix(x): close out the runtime review — findings 4-10
This commit is contained in:
Ramnique Singh 2026-07-10 21:07:27 +05:30 committed by GitHub
commit a1f03d0a69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 139 additions and 97 deletions

View file

@ -41,9 +41,9 @@ Every `llm_usage` emit point in the codebase:
| `use_case` | `sub_use_case` | `agent_name`? | Where | File:line | | `use_case` | `sub_use_case` | `agent_name`? | Where | File:line |
|---|---|---|---|---| |---|---|---|---|---|
| `copilot_chat` | (none) | yes | User chat in renderer (default for any `createRun` without `useCase`) | `packages/core/src/agents/runtime.ts:1313` (finish-step in `streamLlm`) | | `copilot_chat` | (none) | yes | User chat in renderer (turn runtime; the ALS default when no caller set a use case) | `packages/core/src/runtime/turns/bridges/real-usage-reporter.ts` (`reportModelUsage`); legacy runs (code-mode carve-out) still emit from `packages/core/src/runtime/legacy/engine.ts` (`streamLlm` finish-step) |
| `copilot_chat` | `scheduled` | yes | Background scheduled agent runner | `packages/core/src/agent-schedule/runner.ts:167` | | `copilot_chat` | `scheduled` | yes | Background scheduled agent runner | `packages/core/src/agent-schedule/runner.ts:167` |
| `copilot_chat` | `file_parse` | inherits | `parseFile` builtin tool inside any chat | `packages/core/src/application/lib/builtin-tools.ts:770` | | `copilot_chat` | `file_parse` | inherits | `parseFile` builtin tool inside any chat | `packages/core/src/runtime/tools/domains/parsing.ts:179` |
| `live_note_agent` | `routing` | no | Pass 1 routing classifier (`generateObject`) | `packages/core/src/knowledge/live-note/routing.ts:93` | | `live_note_agent` | `routing` | no | Pass 1 routing classifier (`generateObject`) | `packages/core/src/knowledge/live-note/routing.ts:93` |
| `live_note_agent` | `manual` | yes | Pass 2 agent run — user clicked Run / called the `run-live-note-agent` tool | `packages/core/src/knowledge/live-note/runner.ts:140` (createRun, `subUseCase: trigger`) | | `live_note_agent` | `manual` | yes | Pass 2 agent run — user clicked Run / called the `run-live-note-agent` tool | `packages/core/src/knowledge/live-note/runner.ts:140` (createRun, `subUseCase: trigger`) |
| `live_note_agent` | `cron` | yes | Pass 2 agent run — cron expression matched | same call site | | `live_note_agent` | `cron` | yes | Pass 2 agent run — cron expression matched | same call site |

View file

@ -176,10 +176,10 @@ Push-to-talk is disabled while a call owns the mic.
| Prompt | Where | | Prompt | Where |
|--------|-------| |--------|-------|
| `# Video Mode (Live Camera)` system section — how to use webcam frames, coaching guidance, screen-share rules ("treat the screen as the primary subject", "last screen frame is current"), etiquette (never comment on appearance) | `packages/core/src/runtime/assembly/capabilities/modes.ts` (the `VIDEO_MODE` fragment of the `video-mode` capability, composed by `agents/compose-instructions.ts`) | | `# Video Mode (Live Camera)` system section — how to use webcam frames, coaching guidance, screen-share rules ("treat the screen as the primary subject", "last screen frame is current"), etiquette (never comment on appearance) | `packages/core/src/runtime/assembly/capabilities/modes.ts` (the `VIDEO_MODE` fragment of the `video-mode` capability, composed by `runtime/assembly/compose-instructions.ts`) |
| `# Practice Session (Coach Mode)` system section — coaching persona: specific/actionable feedback after each take, one-sentence interjections mid-flow, structured debrief on wrap-up | `capabilities/modes.ts` (the `COACH_MODE` fragment, directly after the video capability) | | `# Practice Session (Coach Mode)` system section — coaching persona: specific/actionable feedback after each take, one-sentence interjections mid-flow, structured debrief on wrap-up | `capabilities/modes.ts` (the `COACH_MODE` fragment, directly after the video capability) |
| "Driving the app" paragraph in the video-mode section — on calls, prefer app-navigation read-view/open-item (show while telling) over describing or squinting at frames | same `# Video Mode` section; full action docs in the `app-navigation` skill (`runtime/assembly/skills/app-navigation/skill.ts`) | | "Driving the app" paragraph in the video-mode section — on calls, prefer app-navigation read-view/open-item (show while telling) over describing or squinting at frames | same `# Video Mode` section; full action docs in the `app-navigation` skill (`runtime/assembly/skills/app-navigation/skill.ts`) |
| Per-message frame context line `[Video mode: N live webcam frames … and M frames of the user's shared screen …]` + group labels | `packages/core/src/runtime/legacy/engine.ts` (`convertFromMessages`) | | Per-message frame context line `[Video mode: N live webcam frames … and M frames of the user's shared screen …]` + group labels | `packages/core/src/runtime/assembly/message-encoding.ts` (`convertFromMessages`) |
| `videoMode` / `coachMode` composition overrides (session-sticky; flips bust prefix cache) | `packages/core/src/runtime/turns/bridges/real-agent-resolver.ts` (`CompositionOverrides`); set from `App.tsx` `sendConfig` | | `videoMode` / `coachMode` composition overrides (session-sticky; flips bust prefix cache) | `packages/core/src/runtime/turns/bridges/real-agent-resolver.ts` (`CompositionOverrides`); set from `App.tsx` `sendConfig` |
Voice input/output prompt sections (`# Voice Input`, `# Voice Output`) are Voice input/output prompt sections (`# Voice Input`, `# Voice Output`) are

View file

@ -1,9 +1,10 @@
# Turn Runtime Technical Specification # Turn Runtime Technical Specification
Status: implemented and live. All chat, background, and knowledge callers Status: implemented and live. All chat, background, and knowledge callers
run on this runtime; the legacy runs runtime remains only for code-mode run on this runtime; the legacy runs runtime (`src/runtime/legacy/`) remains
sessions (see the carve-out section in the repo-root `AGENTS.md`). The only for code-mode sessions and the mini-apps host API, and is deleted as a
companion session layer is specified in `session-design.md`. unit when those migrate. The companion session layer is specified in
`session-design.md`.
This document specifies a new turn-oriented agent loop for `@x/core`. It is This document specifies a new turn-oriented agent loop for `@x/core`. It is
intended to replace the behavioral responsibilities of the current run runtime intended to replace the behavioral responsibilities of the current run runtime

View file

@ -0,0 +1,7 @@
// One home for the error -> human-readable message idiom. The legacy runs
// engine's getErrorDetails also unwrapped RunFailedError's multi-error list;
// live callers run agents through the headless runner, whose HeadlessRunError
// carries the failure detail in .message — so this is the whole contract.
export function getErrorDetails(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -68,12 +68,19 @@ const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`;
let knowledgeCommitTimer: ReturnType<typeof setTimeout> | null = null; let knowledgeCommitTimer: ReturnType<typeof setTimeout> | null = null;
let canonicalWorkspaceRoot: string | null = null; let canonicalWorkspaceRoot: string | null = null;
function isPathInside(parent: string, child: string): boolean { // Exported as the one live containment check: permission decisions
// (assembly/permission-metadata) key on it, so a divergent copy is a
// permission-bypass risk, not a style issue. (legacy/repo.ts keeps its own
// frozen copy — the quarantine must not import live modules.)
export function isPathInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child); const relative = path.relative(parent, child);
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative)); return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
} }
function expandHomePath(inputPath: string): string { // Exported for tool inputs that take user/model-supplied paths (code cwd,
// bg-task projectDir): the blank guard matters there because callers feed
// the result to path.resolve, and resolve('') silently becomes process.cwd().
export function expandHomePath(inputPath: string): string {
const trimmed = inputPath.trim(); const trimmed = inputPath.trim();
if (!trimmed) { if (!trimmed) {
throw new Error('Path is required'); throw new Error('Path is required');

View file

@ -4,7 +4,7 @@ import { google } from 'googleapis';
import { WorkDir } from '../config/config.js'; import { WorkDir } from '../config/config.js';
import { runWhenPossible } from '../runtime/assembly/headless-app.js'; import { runWhenPossible } from '../runtime/assembly/headless-app.js';
import { getKgModel } from '../models/defaults.js'; import { getKgModel } from '../models/defaults.js';
import { getErrorDetails } from '../runtime/legacy/utils.js'; import { getErrorDetails } from '../application/lib/errors.js';
import { serviceLogger } from '../services/service_logger.js'; import { serviceLogger } from '../services/service_logger.js';
import { loadUserConfig, updateUserEmail } from '../config/user_config.js'; import { loadUserConfig, updateUserEmail } from '../config/user_config.js';
import { GoogleClientFactory } from './google-client-factory.js'; import { GoogleClientFactory } from './google-client-factory.js';

View file

@ -3,7 +3,7 @@ import path from 'path';
import { WorkDir } from '../config/config.js'; import { WorkDir } from '../config/config.js';
import { getKgModel } from '../models/defaults.js'; import { getKgModel } from '../models/defaults.js';
import { runWhenPossible, toolInputPaths } from '../runtime/assembly/headless-app.js'; import { runWhenPossible, toolInputPaths } from '../runtime/assembly/headless-app.js';
import { getErrorDetails } from '../runtime/legacy/utils.js'; import { getErrorDetails } from '../application/lib/errors.js';
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
import { import {
loadState, loadState,

View file

@ -3,7 +3,7 @@ import path from 'path';
import { WorkDir } from '../config/config.js'; import { WorkDir } from '../config/config.js';
import { runWhenPossible, toolInputPaths } from '../runtime/assembly/headless-app.js'; import { runWhenPossible, toolInputPaths } from '../runtime/assembly/headless-app.js';
import { getKgModel } from '../models/defaults.js'; import { getKgModel } from '../models/defaults.js';
import { getErrorDetails } from '../runtime/legacy/utils.js'; import { getErrorDetails } from '../application/lib/errors.js';
import { serviceLogger } from '../services/service_logger.js'; import { serviceLogger } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js'; import { limitEventItems } from './limit_event_items.js';
import { import {

View file

@ -3,7 +3,7 @@ import path from 'path';
import { WorkDir } from '../config/config.js'; import { WorkDir } from '../config/config.js';
import { runWhenPossible, toolInputPaths } from '../runtime/assembly/headless-app.js'; import { runWhenPossible, toolInputPaths } from '../runtime/assembly/headless-app.js';
import { getKgModel } from '../models/defaults.js'; import { getKgModel } from '../models/defaults.js';
import { getErrorDetails } from '../runtime/legacy/utils.js'; import { getErrorDetails } from '../application/lib/errors.js';
import { serviceLogger } from '../services/service_logger.js'; import { serviceLogger } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js'; import { limitEventItems } from './limit_event_items.js';
import { import {

View file

@ -7,7 +7,6 @@ import {
isSlackAvailable, isSlackAvailable,
} from "../connections.js"; } from "../connections.js";
import { composioAccountsRepo } from "../../../composio/repo.js"; import { composioAccountsRepo } from "../../../composio/repo.js";
import { isConfigured as isComposioConfigured } from "../../../composio/client.js";
import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js"; import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js";
import { knowledgeSourcesRepo } from "../../../knowledge/sources/repo.js"; import { knowledgeSourcesRepo } from "../../../knowledge/sources/repo.js";
import { listApps } from "../../../apps/indexer.js"; import { listApps } from "../../../apps/indexer.js";
@ -52,7 +51,10 @@ When a question matches what an app tracks, PREFER the app over external calls:
* Lists connected toolkits and explains the meta-tool discovery flow. * Lists connected toolkits and explains the meta-tool discovery flow.
*/ */
async function getComposioToolsPrompt(slackConnected: boolean = false, googleConnected: boolean = false): Promise<string> { async function getComposioToolsPrompt(slackConnected: boolean = false, googleConnected: boolean = false): Promise<string> {
if (!(await isComposioConfigured())) { // connections.js, not the raw composio client: the skill catalog's
// availability filter uses the same check, so the prompt's Composio
// section and the catalog's composio skill can never disagree.
if (!(await isComposioAvailable())) {
return ''; return '';
} }

View file

@ -11,15 +11,10 @@ import { z } from "zod";
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js"; import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
import { isBlocked, extractCommandNames } from "../../application/lib/command-executor.js"; import { isBlocked, extractCommandNames } from "../../application/lib/command-executor.js";
import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../../config/security.js"; import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../../config/security.js";
import { resolveFilePathForPermission } from "../../filesystem/files.js"; import { isPathInside, resolveFilePathForPermission } from "../../filesystem/files.js";
type ToolPermissionMetadataValue = z.infer<typeof ToolPermissionMetadata>; type ToolPermissionMetadataValue = z.infer<typeof ToolPermissionMetadata>;
function isPathInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
}
function fileGrantCoversPath(grant: FileAccessGrant, operation: FileAccessOperation, resolvedPath: string): boolean { function fileGrantCoversPath(grant: FileAccessGrant, operation: FileAccessOperation, resolvedPath: string): boolean {
return grant.operation === operation && isPathInside(path.resolve(grant.pathPrefix), path.resolve(resolvedPath)); return grant.operation === operation && isPathInside(path.resolve(grant.pathPrefix), path.resolve(resolvedPath));
} }

View file

@ -14,24 +14,20 @@ import { lazyResolve } from "../../di/lazy-resolve.js";
import type { IAgentsRepo } from "./repo.js"; import type { IAgentsRepo } from "./repo.js";
// The registry of built-in agents: one table instead of the historical // The registry of built-in agents: one table instead of the historical
// if (id === ...) ladder. An entry owns its builder and its traits; traits // if (id === ...) ladder. An entry owns its builder. Traits (which replace
// replace stringly-typed id comparisons ("copilot" || "rowboatx") scattered // stringly-typed id comparisons scattered across the assembly layer) live in
// across the assembly layer. User-defined agents are the fallthrough, // traits.ts — a leaf module, because this file's builders transitively reach
// fetched from the agents repo. // di/container and upstream trait readers would cycle. Re-exported here so
// assembly-level consumers keep one import.
export interface AgentTraits { export {
// Receives workspace context — agent notes and the user work directory — carriesSkillsForward,
// composed into its system prompt. hasWorkspaceContext,
workspaceContext?: boolean; type AgentTraits,
// Session-loaded skills (activeSkills) re-attach their tools on later } from "./traits.js";
// turns. Distinct from workspaceContext: a trait per concern, so neither
// silently inherits the other's meaning.
skillCarryForward?: boolean;
}
interface BuiltinAgentDefinition { interface BuiltinAgentDefinition {
build: () => Promise<z.infer<typeof Agent>> | z.infer<typeof Agent>; build: () => Promise<z.infer<typeof Agent>> | z.infer<typeof Agent>;
traits?: AgentTraits;
} }
// Prompt-file agents: instructions ship as a raw string whose optional YAML // Prompt-file agents: instructions ship as a raw string whose optional YAML
@ -55,7 +51,6 @@ function promptFileAgent(id: string, getRaw: () => string): BuiltinAgentDefiniti
// definition object. // definition object.
const COPILOT: BuiltinAgentDefinition = { const COPILOT: BuiltinAgentDefinition = {
build: buildCopilotAgent, build: buildCopilotAgent,
traits: { workspaceContext: true, skillCarryForward: true },
}; };
const builtinAgents: Record<string, BuiltinAgentDefinition> = { const builtinAgents: Record<string, BuiltinAgentDefinition> = {
@ -75,26 +70,6 @@ export function builtinAgentIds(): string[] {
return Object.keys(builtinAgents); return Object.keys(builtinAgents);
} }
// Trait lookups for assembly decisions. Unknown/user agents have no traits.
function hasTrait(
agentId: string | null | undefined,
trait: keyof AgentTraits,
): boolean {
return (
agentId != null &&
Object.hasOwn(builtinAgents, agentId) &&
builtinAgents[agentId].traits?.[trait] === true
);
}
export function hasWorkspaceContext(agentId: string | null | undefined): boolean {
return hasTrait(agentId, "workspaceContext");
}
export function carriesSkillsForward(agentId: string | null | undefined): boolean {
return hasTrait(agentId, "skillCarryForward");
}
export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> { export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
// Object.hasOwn: a plain lookup would traverse Object.prototype, so a // Object.hasOwn: a plain lookup would traverse Object.prototype, so a
// user agent named "constructor"/"toString" would hit an inherited // user agent named "constructor"/"toString" would hit an inherited

View file

@ -36,8 +36,6 @@ const CATALOG_PREFIX = "src/runtime/assembly/skills";
// carry loadSkill references under this prefix, so it stays a resolvable alias. // carry loadSkill references under this prefix, so it stays a resolvable alias.
const LEGACY_CATALOG_PREFIX = "src/application/assistant/skills"; const LEGACY_CATALOG_PREFIX = "src/application/assistant/skills";
// console.log(liveNoteSkill);
// A skill IS the model-activated variant of the capability record // A skill IS the model-activated variant of the capability record
// (capabilities/types.ts): lazy guidance the model pulls in via loadSkill, // (capabilities/types.ts): lazy guidance the model pulls in via loadSkill,
// plus the BuiltinTools it owns. `id` doubles as the folder name. Tool names // plus the BuiltinTools it owns. `id` doubles as the folder name. Tool names

View file

@ -0,0 +1,49 @@
// Built-in agent traits: static metadata with deliberately zero imports.
// The registry (registry.ts) owns the id -> builder table, but its builders
// drag in the copilot instruction chain, which transitively reaches
// di/container — so upstream layers (sessions) reading a trait through the
// registry would create a module cycle back into themselves. Traits live in
// this leaf so trait checks are safe to import from anywhere.
export interface AgentTraits {
// Receives workspace context — agent notes and the user work directory —
// composed into its system prompt.
workspaceContext?: boolean;
// Session-loaded skills (activeSkills) re-attach their tools on later
// turns. Distinct from workspaceContext: a trait per concern, so neither
// silently inherits the other's meaning.
skillCarryForward?: boolean;
}
// "rowboatx" is a legacy alias for the copilot: both ids share one traits
// object. Agents absent from this table have no traits (user agents, and
// builtins that need none).
const COPILOT_TRAITS: AgentTraits = {
workspaceContext: true,
skillCarryForward: true,
};
const agentTraits: Record<string, AgentTraits> = {
copilot: COPILOT_TRAITS,
rowboatx: COPILOT_TRAITS,
};
// Trait lookups for assembly decisions. Unknown/user agents have no traits.
function hasTrait(
agentId: string | null | undefined,
trait: keyof AgentTraits,
): boolean {
return (
agentId != null &&
Object.hasOwn(agentTraits, agentId) &&
agentTraits[agentId][trait] === true
);
}
export function hasWorkspaceContext(agentId: string | null | undefined): boolean {
return hasTrait(agentId, "workspaceContext");
}
export function carriesSkillsForward(agentId: string | null | undefined): boolean {
return hasTrait(agentId, "skillCarryForward");
}

View file

@ -20,16 +20,6 @@ export class RunFailedError extends Error {
} }
} }
export function getErrorDetails(error: unknown): string {
if (error instanceof RunFailedError) {
return error.errors.join("\n\n");
}
if (error instanceof Error) {
return error.message;
}
return String(error);
}
/** /**
* Extract the assistant's final text response from a run's log. * Extract the assistant's final text response from a run's log.
* @param runId * @param runId

View file

@ -943,6 +943,24 @@ describe("active-skill carry-forward", () => {
}); });
}); });
it("does not inject activeSkills for agents without the skillCarryForward trait", async () => {
// The resolver would ignore them anyway (real-agent-resolver gates on
// the same trait); this pins that sessions doesn't persist an
// ever-growing list into the requested composition either.
const { sessions, fake } = makeSessions();
const sessionId = await sessions.createSession();
const { turnId } = await sessions.sendMessage(sessionId, user("one"), {
agent: { agentId: "my-user-agent" },
});
await flush();
fake.setLog(turnId, skillLoadLog(turnId, sessionId, { agentId: "my-user-agent" }));
await sessions.sendMessage(sessionId, user("two"), {
agent: { agentId: "my-user-agent" },
});
expect(fake.createTurnInputs[1].agent).toEqual({ agentId: "my-user-agent" });
});
it("accumulates across turns and unions with caller-supplied skills, preserving order", async () => { it("accumulates across turns and unions with caller-supplied skills, preserving order", async () => {
const { sessions, fake } = makeSessions(); const { sessions, fake } = makeSessions();
const sessionId = await sessions.createSession(); const sessionId = await sessions.createSession();

View file

@ -28,6 +28,10 @@ import type {
TurnExternalInput, TurnExternalInput,
TurnOutcome, TurnOutcome,
} from "../turns/api.js"; } from "../turns/api.js";
// traits.js, not registry.js: the registry's builders transitively reach
// di/container, which imports this module — a cycle. The traits leaf exists
// exactly so upstream layers can gate on traits.
import { carriesSkillsForward } from "../assembly/traits.js";
import type { IClock } from "../turns/clock.js"; import type { IClock } from "../turns/clock.js";
import { import {
type ISessions, type ISessions,
@ -461,7 +465,15 @@ function withActiveSkills(
agent: SendMessageConfig["agent"], agent: SendMessageConfig["agent"],
activeSkills: string[], activeSkills: string[],
): SendMessageConfig["agent"] { ): SendMessageConfig["agent"] {
if (isInlineAgentRequest(agent) || activeSkills.length === 0) { // Mirrors the resolver's carriesSkillsForward gate (real-agent-resolver):
// the resolver would ignore activeSkills for a non-trait agent anyway,
// but injecting them here would still persist an ever-growing list into
// every turn's requested composition.
if (
isInlineAgentRequest(agent) ||
activeSkills.length === 0 ||
!carriesSkillsForward(agent.agentId)
) {
return agent; return agent;
} }
const composition = agent.overrides?.composition; const composition = agent.overrides?.composition;

View file

@ -10,7 +10,7 @@ import container from "../../../di/container.js";
import { BackgroundTaskSchema, TriggersSchema } from "@x/shared/dist/background-task.js"; import { BackgroundTaskSchema, TriggersSchema } from "@x/shared/dist/background-task.js";
import * as gitService from "../../../code-mode/git/service.js"; import * as gitService from "../../../code-mode/git/service.js";
import type { ICodeProjectsRepo } from "../../../code-mode/projects/repo.js"; import type { ICodeProjectsRepo } from "../../../code-mode/projects/repo.js";
import { expandHome } from "../paths.js"; import { expandHomePath } from "../../../filesystem/files.js";
// Inputs for the bg-task builtin tools. Reuse the canonical schema field // Inputs for the bg-task builtin tools. Reuse the canonical schema field
// descriptions; only `triggers` gets a tighter contextual override (the // descriptions; only `triggers` gets a tighter contextual override (the
@ -45,7 +45,7 @@ export const PatchBackgroundTaskInput = BackgroundTaskSchema.pick({
export async function resolveCodeProject(dirPath: string): Promise< export async function resolveCodeProject(dirPath: string): Promise<
{ ok: true; projectId: string; path: string; warning?: string } | { ok: false; error: string } { ok: true; projectId: string; path: string; warning?: string } | { ok: false; error: string }
> { > {
const abs = path.resolve(expandHome(dirPath)); const abs = path.resolve(expandHomePath(dirPath));
const projectsRepo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo'); const projectsRepo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo');
let project: Awaited<ReturnType<ICodeProjectsRepo['add']>>; let project: Awaited<ReturnType<ICodeProjectsRepo['add']>>;
try { try {

View file

@ -12,7 +12,7 @@ import { ICodeModeConfigRepo } from "../../../code-mode/repo.js";
import type { ApprovalPolicy, CodeRunEvent as CodeRunEventType } from "@x/shared/dist/code-mode.js"; import type { ApprovalPolicy, CodeRunEvent as CodeRunEventType } from "@x/shared/dist/code-mode.js";
import type { CodeRunFeed } from "../../../code-mode/feed.js"; import type { CodeRunFeed } from "../../../code-mode/feed.js";
import type { ToolContext } from "../exec-tool.js"; import type { ToolContext } from "../exec-tool.js";
import { expandHome } from "../paths.js"; import { expandHomePath } from "../../../filesystem/files.js";
import { BuiltinToolsSchema } from "../types.js"; import { BuiltinToolsSchema } from "../types.js";
@ -61,7 +61,7 @@ export const codeAgentRunTools: z.infer<typeof BuiltinToolsSchema> = {
// cwd argument over the session's. Expand `~` and resolve to an absolute path: // cwd argument over the session's. Expand `~` and resolve to an absolute path:
// the engine is spawned with this as the child's cwd, and `child_process.spawn` // the engine is spawned with this as the child's cwd, and `child_process.spawn`
// does NO shell tilde expansion. // does NO shell tilde expansion.
const effectiveCwd = path.resolve(expandHome(ctx.codeCwd ?? cwd)); const effectiveCwd = path.resolve(expandHomePath(ctx.codeCwd ?? cwd));
// Fail loudly if the directory is missing. Otherwise the spawn below fails with // Fail loudly if the directory is missing. Otherwise the spawn below fails with
// Node's misleading "spawn <command> ENOENT" (it blames the executable, not the // Node's misleading "spawn <command> ENOENT" (it blames the executable, not the
// bad cwd), which reads as "the coding engine isn't installed" — see the enriched // bad cwd), which reads as "the coding engine isn't installed" — see the enriched

View file

@ -7,6 +7,7 @@ import container from "../../../di/container.js";
import type { ToolContext } from "../exec-tool.js"; import type { ToolContext } from "../exec-tool.js";
import { getCurrentUseCase } from "../../../analytics/use_case.js"; import { getCurrentUseCase } from "../../../analytics/use_case.js";
import type { INotificationService } from "../../../application/notification/service.js"; import type { INotificationService } from "../../../application/notification/service.js";
import type { ITurnRepo } from "../../turns/repo.js";
import { notifyIfEnabled } from "../../../application/notification/notifier.js"; import { notifyIfEnabled } from "../../../application/notification/notifier.js";
import { BuiltinToolsSchema } from "../types.js"; import { BuiltinToolsSchema } from "../types.js";
@ -42,14 +43,19 @@ export const notificationTools: z.infer<typeof BuiltinToolsSchema> = {
return { success: false, error: 'Notifications are not supported on this system' }; return { success: false, error: 'Notifications are not supported on this system' };
} }
let uc = getCurrentUseCase()?.useCase; let uc = getCurrentUseCase()?.useCase;
// ALS doesn't reliably propagate across the run's async generator, // The ALS context can be missing when the turn is advanced
// so when the in-context use-case is missing, fall back to the // outside the chain that started it (crash recovery, resume
// persisted use case on the run record via ctx.runId. // paths). The durable signal is the agent id on the turn
// record — only the background-task runner starts
// 'background-task-agent' turns. ctx.runId is the turn id.
if (!uc && ctx?.runId) { if (!uc && ctx?.runId) {
try { try {
const { fetchRun } = await import("../../legacy/runs.js"); const turnRepo = container.resolve<ITurnRepo>('turnRepo');
const run = await fetchRun(ctx.runId); const [created] = await turnRepo.read(ctx.runId);
uc = run.useCase; if (created?.type === 'turn_created'
&& created.agent.resolved.agentId === 'background-task-agent') {
uc = 'background_task_agent';
}
} catch { } catch {
// best effort — fall through to the default branch // best effort — fall through to the default branch
} }

View file

@ -1,18 +0,0 @@
// Lenient ~-expansion for user-supplied paths in tool inputs. Distinct from
// filesystem/files.ts' private expandHomePath, which throws on empty input —
// tool entries want pass-through semantics for their own validation.
import * as path from "path";
import * as os from "os";
// Turn a user-supplied directory into a registered code project id. Reuses the
// same idempotent registry the Code-section picker writes to (add() validates the
// dir exists & is a directory, and dedupes by resolved path). Returns a soft
// `warning` — not an error — when the repo isn't yet worktree-ready, so the task
// still gets created and the copilot can tell the user what to fix.
export function expandHome(p: string): string {
const t = p.trim();
if (t === '~') return os.homedir();
if (t.startsWith('~/') || t.startsWith(`~${path.sep}`)) return path.join(os.homedir(), t.slice(2));
return t;
}