Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions .agents/docs/cli-effect-ts.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ wait for cleanup. Native Effect callers yield the service directly.
A Session owns a container Scope. Each spawn uses a child Scope: failed or
interrupted setup closes it before returning; successful setup retains it until
whole-group exit and drained stdio. Closing the container Scope terminates its trees and stops its
monitor fibers, then removes cgroup resources. `startProcess` is reserved for
monitor fibers, then removes cgroup resources. `startProcessLegacy` is reserved for
legacy synchronous/raw Node handles (including IPC and explicit detach), whose
owner must await `terminate`; it is not a scoped Effect API.

Expand All @@ -79,9 +79,30 @@ The process foundation is `@lody/shared/node/process`. Its Promise entry points
run v4 programs at the legacy boundary. CLI's `platform/process-options.ts` only
composes services and logging; it executes no program and has no Promise API.
`SessionSandbox`, the ACP runner and authentication probes use this foundation.
Other process callers migrate in the next layers. Delete each temporary facade
Other CLI callers use the same core, enforced by `check:cli-process-boundary`.
Electron, supervisor and shared helpers migrate in the final layer. Delete each temporary facade
when the corresponding caller itself becomes an Effect service.

Execution facades carry a `Legacy` suffix and `@deprecated`; keep that suffix
visible in imports and calls. New Effect workflows compose core APIs and leave
execution to their owning application entry point. Layer builders and pure error
conversions retain their names because they do not execute a program. Current
shared execution facades are:

- `runCommandTextLegacy` and `runCommandTextSyncLegacy`: text output for Promise
and blocking callers, respectively.
- `startProcessLegacy`: raw Node handle with manual ownership
(`ProcessHandleLegacy`).
- `terminateChildTreeLegacy` and `signalChildTreeNowLegacy`: awaited tree cleanup
and synchronous exit-hook signalling, respectively.
- `isPidAliveSyncLegacy` and `probePidSyncLegacy`: synchronous process probes.
- `makeProcessRunnerLegacy` (`ProcessRunnerLegacy`) and `runPromiseSquashedLegacy`:
temporary execution and error conversion for Promise entry points.

`runCommand`, `runCommandOk`, their Effect-returning Sync variants, `spawnProcess`,
`terminateTree`, `childProcessTree`, `isPidAlive` and `probePid` retain their names.
The guard rejects retired facade imports/exports and aliases that hide Legacy.

## Testing

- Use `@effect/vitest` (`it.effect`, `it.live`) with `TestClock` from
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,31 @@ The tests are local evidence. No real Windows host, Linux cgroup hierarchy or
signed desktop installer has been tested. taskkill cannot retain descendants
whose Windows parent has already exited; [#429](https://github.com/LodyAI/Lody/issues/429)
remains partly addressed until the separate Job Object work.

## Remaining CLI consumers (#1069)

Git, worktree setup/GC, daemon commands, MCP, preview, file scanning, resource
probes and PTY termination use the same process facades. The CLI guard rejects
raw process APIs and process-spawning dependencies, including dynamic imports
and direct child kill forms. Standalone generated scripts and node-pty spawning
are named exceptions; PTY termination still uses the core.

The shared login-shell probe is introduced with its CLI caller. All fallback
shells share one deadline, printf delimiters preserve PATH, probe-only tmux and
update variables are restored to their original values, and multiline values
remain intact. CLI startup still unblocks after 3 seconds while the bounded
probe finishes in the background. shell-env is removed from CLI dependencies.
PTY hangup retains a bounded grace period before force-killing its group.

Current-main integration also migrates the new Simulator worker, native server,
guest helper, fixed xcrun commands and memory-provider CLI calls in #1069.
IPC ownership and guest EOF release remain with their owners; shared tree
termination replaces local signal loops. Global-only host Git identity and the
verified upgrade-installation handoff from main are preserved.

The timeout regressions for the login shell and daemon runner now wait for explicit
startup and advance virtual time. They still assert that roots and descendants
stop, and that a survivor reports failure. Disabling command grouping fails the
shell timeout case. The browser command-builder test only repeated its literal
mapping; it and the test-only export were removed. Platform opener behavior and
URL argument passing remain unchanged.
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,22 @@ daemon 级运行时留给后续会话资源阶段。此 stack 不引入 v3 进
这些是本地证据;未验证真实 Windows、Linux cgroup 或签名安装包。taskkill 无法保留 Windows
父进程已退出后的后代身份,因此 [#429](https://github.com/LodyAI/Lody/issues/429) 仍待单独的
Job Object 工作补齐。

## 其余 CLI 调用方(#1069)

Git、worktree setup/GC、daemon 命令、MCP、preview、文件扫描、资源探测与 PTY 终止统一使用进程
门面。CLI 守卫拦截直接进程 API、启动进程的第三方依赖、动态导入和对子进程直接 kill。
独立生成脚本与 node-pty 启动是写明理由的例外;PTY 终止仍使用核心。

共享登录 shell 探测随 CLI 调用方引入。fallback shell 共用一个截止时间,printf 分隔符保留 PATH,
仅供探测的 tmux/update 变量恢复原值,多行环境值保持完整。CLI 启动仍在 3 秒后先放行,后台有上限
地完成探测。删除 CLI 的 shell-env 依赖;PTY hangup 后保留有上限的宽限再强杀进程组。

合并当前 main 后,#1069 也迁移新加入的 Simulator worker、原生服务、guest helper、
固定 xcrun 命令和 memory provider 命令。IPC 所有权及 guest EOF 释放仍由原模块负责,
进程树终止复用共享层;保留 main 的全局 Git 身份读取和已验证升级安装路径交接。

登录 shell 和 daemon runner 的超时回归测试改为确认启动后推进虚拟时钟,继续检查根进程与
后代退出,以及幸存进程必须报告失败。禁用命令进程组会使 shell 超时案例失败。
浏览器命令构造器测试仅重复其字面量映射,已连同仅供测试的导出删除;平台打开方式与 URL
参数传递不变。
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ The shared Promise runner forwards an explicit AbortSignal to runPromiseExit.
It still starts a separate root fiber: wrapping it in tryPromise without forwarding
that signal cannot propagate cancellation, and even forwarding a signal does not
make an outer Promise wrapper await the nested finalizers automatically. Effect
callers must yield the service. Synchronous legacy startProcess remains manually
callers must yield the service. Synchronous legacy startProcessLegacy remains manually
owned and supports an explicit signal; callers needing proof of termination await
its terminate method. It is not represented as a scoped Effect acquisition.

Expand All @@ -104,6 +104,19 @@ ACP callers, containers and this decision belong to #1065. Remaining CLI import
changes and guard guidance belong to #1069; cross-runtime consumers inherit the
service through #1348 without another implementation.

After #1065 merged, [#1069](https://github.com/LodyAI/Lody/pull/1069) names all
nine shared execution facades with a Legacy suffix and marks them deprecated.
The runner and manually owned handle types also carry that suffix. Text and Sync
still describe output and blocking behavior; Legacy identifies temporary
compatibility, not another process implementation. Old exports are removed and
callers use the full names without aliases hiding the suffix. New Effect programs
compose the core and leave execution to their owning entry point. Layer builders,
pure conversion helpers and Effect-returning core APIs keep their names. The
process guard rejects retired entry names and aliases hiding Legacy. Cross-runtime
consumers are updated in #1348. This changes names and documentation only; explicit
cancellation, typed failures, bounded tree termination and Scope retirement retain
the existing implementation and behavioral tests.

## Validation and limits

Behavioral tests cover official output helpers and a pipeline, replacement
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ cgroup.events 成为初始化必需能力。best-effort cleanup 记录失败,

shared Promise runner 把明确的 AbortSignal 交给 runPromiseExit,但仍启动独立根 fiber。
用 tryPromise 包装且不传递信号,无法传播取消;即使传递信号,也不会自动让外层 Promise
包装等待内层 finalizer 完成。Effect 调用方应直接 yield 服务。同步兼容 startProcess 仍由
包装等待内层 finalizer 完成。Effect 调用方应直接 yield 服务。同步兼容 startProcessLegacy 仍由
旧调用方手动持有,支持明确的取消信号;需要证明终止的调用方等待 terminate。它不被描述为
具有 Scope 所有权的 Effect 获取。

Expand All @@ -75,6 +75,15 @@ container 和 Scope;已删除的 cgroup 不会自动重建。
container 和这份决定属于 #1065;其它 CLI 调用方的导入调整及守卫指引属于 #1069;
跨运行时调用方通过 #1348 继承服务,不增加另一份实现。

#1065 合入后,[#1069](https://github.com/LodyAI/Lody/pull/1069) 为九个 shared 执行兼容入口
统一加上 Legacy 后缀和 deprecated 标记,runner 与手动持有的 handle 类型也带此后缀。
Text/Sync 仍表达输出和阻塞方式,Legacy 表明临时兼容用途,不代表另一套进程实现。
删除旧导出,调用方使用完整名称,不通过别名隐藏后缀。新 Effect 工作流组合核心 API,
由所属入口统一执行;Layer 构造器、纯转换函数和返回 Effect 的核心 API 保持原名。
进程守卫拒绝旧入口名称及隐藏 Legacy 的别名,跨运行时调用方在 #1348 同步。
此次只改命名和说明:显式取消、有类型的错误、有界进程树终止和 Scope 释放保持原实现,
继续使用已有行为测试验证。

## 验证与限制

行为测试覆盖官方输出方法及管道、替换环境变量、Scope 中断、孤儿后代、owner hook 失败、
Expand Down
9 changes: 4 additions & 5 deletions apps/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ Root `AGENTS.md` applies; this file adds CLI context. Build, PR-poller, and adap
## Coding rules

- Prefer Effect v4 for new/refactored CLI code; follow the [Effect guide](../../.agents/docs/cli-effect-ts.md).
- Processes use `@lody/shared/node/process` (CLI wiring: `src/platform/process-options`);
[rules](../../packages/shared/src/node/AGENTS.md), enforced by `check:cli-process-boundary`.
- Keep the strict tsconfig, no `any` or non-null assertions, and Zod at every foreign boundary:
context/cli-type-safety.md.
- After a remote prompt arrives, only correctness-critical setup may block before ACP
Expand Down Expand Up @@ -108,8 +110,5 @@ execution/consent rules. These rules also bind CLI callers outside that director
- Pi extension scanning runs only the pinned runtime's read-only listing entry under a frozen
default or saved-profile environment — never caller-supplied launch fields. Selections
require the pinned extension-aware runtime (`piExtensionsProtocolVersion`), not a fallback.
- `src/lib/pr-poller/` compensates for a broken hosted GitHub webhook → Streams fan-out. Keep
policy in its pure modules with a thin scheduler, keep priority driven by presence and
`lastMessageAt` rather than a turn-end hook, and keep only scheduling state (never PR status) in
`~/.lody/pr-poller-state.json`. Spec: `specs/pr-status-reconciler.md`; invariants:
`src/lib/pr-poller/AGENTS.md`.
- `src/lib/pr-poller/` compensates for the hosted GitHub webhook fan-out. Spec:
`specs/pr-status-reconciler.md`; invariants: [pr-poller](src/lib/pr-poller/AGENTS.md).
1 change: 0 additions & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,6 @@
"@napi-rs/keyring": "2.1.0",
"better-sqlite3": "catalog:",
"loro-crdt": "1.16.3",
"shell-env": "^4.0.3",
"tinypool": "^1.0.0",
"typescript": "^5.9.3"
}
Expand Down
6 changes: 3 additions & 3 deletions apps/cli/src/agent/acp-authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
BuiltinAuthenticationOutputParser,
} from './acp-authentication-output';
import { shutdownLocalAcpAgent, spawnAcpProcess, terminateAcpProcessTree } from './acp-runner';
import { startProcess } from '@lody/shared/node/process';
import { startProcessLegacy } from '@lody/shared/node/process';

import type { NodeProcessApi } from '@lody/shared/node/process';
import type { ManagedRuntimeProgressEvent } from './managed-agent-runtime';
Expand Down Expand Up @@ -440,7 +440,7 @@ export async function probeBuiltinAuthentication(
}
// Its own group, so a timeout or cancel ends whatever the status command
// started, not just the command itself.
const { child } = startProcess(
const { child } = startProcessLegacy(
{
command: launch.command,
args: launch.args,
Expand Down Expand Up @@ -699,7 +699,7 @@ export class AcpAuthenticationManager {
if (preparationInterruption) return preparationInterruption;

options.onProgress?.({ status: 'starting' });
const { child } = startProcess(
const { child } = startProcessLegacy(
{
command: launch.command,
args: options.codexProfile
Expand Down
6 changes: 3 additions & 3 deletions apps/cli/src/agent/acp-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { z } from 'zod';

import type { Logger } from '@/utils/logger';
import { formatErrorMessage } from '@/utils/format-error';
import { startProcess, terminateChildTree } from '@lody/shared/node/process';
import { startProcessLegacy, terminateChildTreeLegacy } from '@lody/shared/node/process';
import { withSpawn } from '@/platform/process-options';

import type { NodeProcessApi } from '@lody/shared/node/process';
Expand Down Expand Up @@ -191,7 +191,7 @@ export async function terminateAcpProcessTree(
nodeProcess?: NodeProcessApi;
}
): Promise<void> {
await terminateChildTree(
await terminateChildTreeLegacy(
child,
{
graceMs: options.force ? 0 : options.exitTimeoutMs,
Expand Down Expand Up @@ -244,7 +244,7 @@ export const spawnAcpProcess = (options: SpawnAcpProcessOptions): ChildProcess =

// Its own process group on POSIX (a tree rooted at it on Windows), so
// `terminateAcpProcessTree` reaches everything the agent starts.
return startProcess(
return startProcessLegacy(
{
command: executable.command,
args: executable.args,
Expand Down
11 changes: 5 additions & 6 deletions apps/cli/src/agent/codex-profile-process-usage.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { toShared } from '@/platform/process-options';
import { randomUUID } from 'node:crypto';
import { writeFileSync } from 'node:fs';
import { lstat, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { z } from 'zod';
import { isPidAliveSyncLegacy } from '@lody/shared/node/process';

import type { ResolvedCodexProfile } from './codex-profile-store';

const UsageRecordSchema = z.object({ version: z.literal(1), token: z.uuid() }).strict();
Expand All @@ -15,14 +18,10 @@ export type CodexProfileProcessUsage = (() => Promise<void>) & {
abandonBeforeSpawn(): Promise<void>;
};

/** A pid owned by another user (EPERM) still counts as running. */
function nativeProcessIsGone(pid: number | undefined): boolean {
if (pid === undefined) return false;
try {
process.kill(pid, 0);
return false;
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'ESRCH';
}
return !isPidAliveSyncLegacy(pid, toShared());
}

async function readProof(file: string) {
Expand Down
37 changes: 22 additions & 15 deletions apps/cli/src/agent/codex-profile-store.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { toShared } from '@/platform/process-options';
import { createHash, randomUUID } from 'node:crypto';
import { lstat, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
Expand All @@ -10,7 +11,8 @@ import {
} from '@lody/shared';
import { getLodyDataDir } from '@lody/shared/node/installation-profile';
import { SystemCodexCredentialVault, type CodexCredentialVault } from './codex-credential-vault';
import { execFile } from 'node:child_process';
import { runCommandTextLegacy } from '@lody/shared/node/process';

import { resolveBuiltinAuthenticationProcessLaunch } from './setting';
import {
registerCodexProfileProcess,
Expand Down Expand Up @@ -356,21 +358,26 @@ async function logoutNativeProfile(
if (/^(CODEX_|OPENAI_|LODY_CODEX_|DYLD_|NODE_OPTIONS$|LD_PRELOAD$|LD_LIBRARY_PATH$)/i.test(key))
delete env[key];
env.CODEX_HOME = profile.home;
await new Promise<void>((resolve, reject) => {
const child = execFile(
launch.command,
[
...(profile.authStore === 'keyring' ? ['-c', 'cli_auth_credentials_store="keyring"'] : []),
'logout',
],
{ env, timeout: 20_000, windowsHide: true },
(error) => {
if (error) reject(new Error('Codex account cleanup could not complete'));
else resolve();
}
try {
await runCommandTextLegacy(
{
command: launch.command,
args: [
...(profile.authStore === 'keyring'
? ['-c', 'cli_auth_credentials_store="keyring"']
: []),
'logout',
],
env,
timeout: 20_000,
check: 'exit-0',
onSpawned: (child) => lease.recordNativePid(child.pid),
},
toShared()
);
lease.recordNativePid(child.pid);
});
} catch {
throw new Error('Codex account cleanup could not complete');
}
}

let defaultStore: CodexProfileStore | undefined;
Expand Down
Loading
Loading