Skip to content
Closed
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
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [Unreleased]

- Restore the profile's default agent preset when `dsh-ops` is disabled or uninstalled: runtime preset default writes now exit the active HMR transaction scope and wait for outer HMR transactions and `<profile.dir>/package.json.lock` to settle instead of failing with `HMR transactions cannot be nested` or timing out on the profile lock, and `dsh-ops uninstall` cleans up any dangling `selectedDefault: ops-ptc` entry in `cordis.patch.yml` so new sessions no longer fail with `Unknown agent preset: ops-ptc` (#881).
- 在禁用或卸载 `dsh-ops` 时恢复 profile 的默认 agent 预设:运行时默认预设写入现在会退出当前 HMR 事务上下文并等待外层 HMR 事务及 `<profile.dir>/package.json.lock` 结束,避免因 `HMR transactions cannot be nested` 或配置锁超时而残留 `selectedDefault: ops-ptc`;同时 `dsh-ops uninstall` 会在卸载后清理 `cordis.patch.yml` 中悬空的 `selectedDefault: ops-ptc`,防止新建会话报 `Unknown agent preset: ops-ptc`(#881)。
- The ops-ptc shape reminder no longer calls a program that only waits on `tools.wait_agent` (dot or bracket call), or one with two or more dispatch sites such as a search whose result picks the next read, a "single operation". The wait is read only from a real dispatch site, not from comments or strings; plan and bypass checks, latch priority and re-arming are unchanged (#893).
- ops-ptc 形状提醒不再把仅等待 `tools.wait_agent`(点调用或方括号调用)的程序,以及含两个以上派发站点的程序(如由搜索结果决定后续读取)判为 single operation。等待只从真实派发站点识别,不认注释或字符串;plan 与 bypass 判断、latch 优先级与重新启用不变(#893)。
- Native tool construction and the publication refusal queue no longer attach listeners or lifetime effects to the composition root. The evidence observer and result admission subscribe on the caller's context with global dispatch, and the refusal queue falls back to the caller lifetime when the root refuses an effect. dsh-tui 0.20 refuses `root.events.on` and `root.effect` from a plugin activation, and the native-tools row is one, so every `dsh-tui --resume` aborted with "cannot resume session" before the agent mounted.
- 原生工具构造与发布拒绝队列不再向组合根挂监听或生命周期 effect:证据观察者与 result admission 改为在调用方上下文订阅并声明 global 分发,拒绝队列在 root 拒绝 effect 时回落到调用方生命周期。dsh-tui 0.20 会拒绝插件激活期内的 `root.events.on` 与 `root.effect`,而原生工具行正是插件激活,导致每次 `dsh-tui --resume` 都在 agent 挂载前报 "cannot resume session"。

Expand Down
141 changes: 133 additions & 8 deletions src/presets/ops-ptc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,20 @@ export interface SettingsService {
update(namespace: string, patch: Record<string, unknown>): Promise<unknown>
}

/** The slice of the host HMR coordinator needed to avoid nested transactions. */
export interface HmrService {
operations?: Promise<unknown>
readonly executing?: {
getStore?(): unknown
exit?<T>(callback: () => T): T
}
}

/** The slice of the host profile context needed to observe its file lock. */
export interface ProfileContext {
readonly dir?: string
}

/** One parsed loader patch list, as the host's `loadOverlayPatches` returns it. */
export type FragmentLoader = (binName: string, file: string) => readonly unknown[]

Expand Down Expand Up @@ -119,6 +133,21 @@ export interface ResolvedAppBoot {
*/
const registered = new WeakSet<object>()

/**
* Operator defaults captured before `ops-ptc` forced its own selection, per registry.
*
* A remount or a second boot that starts while `selectedDefault` is already `ops-ptc`
* would otherwise read `ops-ptc` as the saved selection and lose the operator's earlier
* choice when the row is later disabled or removed.
*/
const capturedDefaults = new WeakMap<object, string | undefined>()

/** Maximum time a deferred default write waits for an outer profile lock to clear. */
const HOST_IDLE_WAIT_MS = 10_000

/** Polling interval while waiting for `<profile.dir>/package.json.lock` to clear. */
const HOST_LOCK_POLL_MS = 10

/** The live registration of one injected context. */
interface Slot {
release?: () => Promise<void>
Expand All @@ -127,6 +156,12 @@ interface Slot {
previous?: string
/** The settings service, kept so teardown can still write while disposing. */
settings?: SettingsService
/** The HMR coordinator, kept so teardown can queue outside a transaction. */
hmr?: HmrService
/** The profile directory, kept so teardown can wait out the profile lock. */
profileDir?: string
/** The in-flight force write, if one started during activation. */
forcing?: Promise<void>
}

/** One human-readable error message. */
Expand All @@ -148,6 +183,65 @@ function settingsOf(ctx: Context): SettingsService | undefined {
return service<SettingsService>(ctx, 'settings')
}

/** The host HMR coordinator, when the profile runs with hot reload. */
function hmrOf(ctx: Context): HmrService | undefined {
return service<HmrService>(ctx, 'hmr')
}

/** The directory of the active profile, when the host publishes one. */
function profileDirOf(ctx: Context): string | undefined {
const dir = service<ProfileContext>(ctx, 'profile')?.dir
return typeof dir === 'string' && dir !== '' ? dir : undefined
}

/**
* Run a callback outside the active HMR transaction's AsyncLocalStorage scope.
*
* `settings.update` delegates to `configEditor.edit`, which calls `hmr.runExclusive`.
* When `dsh-ops` is mounted, reconfigured, disabled, or removed inside an outer
* `hmr.runExclusive`, the `executing` store stays `true` across synchronous calls and
* `await` continuations, causing `hmr.runExclusive` to reject with
* `HMR transactions cannot be nested`. Exiting the store lets the settings write
* queue after `hmr.operations` instead of failing (#881).
*/
function runOutsideHmr<T>(hmr: HmrService | undefined, callback: () => T): T {
const executing = hmr?.executing
if (executing?.getStore?.() === true && typeof executing.exit === 'function') {
return executing.exit(callback)
}
return callback()
}

/**
* Wait until the outer HMR transaction and any outer profile `package.json.lock` finish.
*
* `PluginManager.change` (used by `removeBundle` and `setBundleEnabled`) holds
* `<profile.dir>/package.json.lock` across `hmr.runExclusive` calls, while
* `ConfigEditor.edit` acquires `hmr.runExclusive` first and `package.json.lock`
* second. Waiting for both to settle prevents the settings write from entering
* `hmr.runExclusive` while `PluginManager.change` still holds the file lock.
*/
async function waitForHostIdle(
hmr: HmrService | undefined,
profileDir: string | undefined,
inTransaction: boolean,
): Promise<void> {
const lockFile = profileDir !== undefined ? path.join(profileDir, 'package.json.lock') : undefined
const lockHeld = () => lockFile !== undefined && existsSync(lockFile)
if (!inTransaction && !lockHeld()) return
const deadline = Date.now() + HOST_IDLE_WAIT_MS
await new Promise<void>(resolve => setImmediate(resolve))
while (Date.now() < deadline) {
const operations = hmr?.operations
if (operations !== undefined) {
try { await operations } catch { /* the host owns its own transaction errors */ }
if (hmr?.operations !== undefined && hmr.operations !== operations) continue
}
if (!lockHeld()) return
await new Promise<void>(resolve => setTimeout(resolve, HOST_LOCK_POLL_MS))
}
}

/** The preset new sessions currently default to. */
function currentDefault(registry: PresetRegistry): string | undefined {
try {
Expand Down Expand Up @@ -215,7 +309,16 @@ async function writeSelectedDefault(ctx: Context, slot: Slot, options: Registrat
*/
async function forceDefault(ctx: Context, registry: PresetRegistry, slot: Slot, options: RegistrationOptions): Promise<void> {
if (currentDefault(registry) === PRESET_ID) return
await writeSelectedDefault(ctx, slot, options, PRESET_ID)
const hmr = slot.hmr ?? hmrOf(ctx)
const profileDir = slot.profileDir ?? profileDirOf(ctx)
const inTransaction = hmr?.executing?.getStore?.() === true
const task = runOutsideHmr(hmr, async () => {
await waitForHostIdle(hmr, profileDir, inTransaction)
if (slot.disposed || currentDefault(registry) === PRESET_ID) return
await writeSelectedDefault(ctx, slot, options, PRESET_ID)
})
slot.forcing = task
await task
}

/**
Expand All @@ -224,10 +327,19 @@ async function forceDefault(ctx: Context, registry: PresetRegistry, slot: Slot,
* may have forced is touched; an operator-chosen default is never rewritten.
*/
async function restoreDefault(ctx: Context, registry: PresetRegistry, slot: Slot, options: RegistrationOptions): Promise<void> {
if (currentDefault(registry) !== PRESET_ID) return
const target = restoreTarget(registry, slot.previous)
if (target === undefined || target === PRESET_ID) return
await writeSelectedDefault(ctx, slot, options, target)
const hmr = slot.hmr ?? hmrOf(ctx)
const profileDir = slot.profileDir ?? profileDirOf(ctx)
const inTransaction = hmr?.executing?.getStore?.() === true
await runOutsideHmr(hmr, async () => {
if (slot.forcing !== undefined) {
try { await slot.forcing } catch { /* forceDefault already warned */ }
}
await waitForHostIdle(hmr, profileDir, inTransaction)
if (currentDefault(registry) !== PRESET_ID) return
const target = restoreTarget(registry, slot.previous)
if (target === undefined || target === PRESET_ID) return
await writeSelectedDefault(ctx, slot, options, target)
})
}

/**
Expand Down Expand Up @@ -453,7 +565,12 @@ export function registerOpsPtcPreset(root: Context, options: RegistrationOptions
const activation: object = ctx.fiber ?? ctx
if (registered.has(activation)) return
registered.add(activation)
const slot: Slot = { disposed: false }
const slot: Slot = {
disposed: false,
settings: settingsOf(ctx),
hmr: hmrOf(ctx),
profileDir: profileDirOf(ctx),
}
ctx.effect(() => () => {
slot.disposed = true
releaseRegistration(ctx, registry, slot, options)
Expand All @@ -477,8 +594,16 @@ async function activate(ctx: Context, registry: PresetRegistry, slot: Slot, opti
// before reading, or the new activation would capture a value about to change.
await settlePendingRestore(registry)
// Capture the operator's default before anything can force a new one; teardown
// gives this exact selection back.
slot.previous = savedDefault(registry)
// gives this exact selection back. When the profile booted with `ops-ptc`
// already saved on disk, keep any earlier operator selection seen on this
// registry or fall back to the registry's official default on teardown.
const saved = savedDefault(registry)
if (saved !== PRESET_ID) {
capturedDefaults.set(registry, saved)
slot.previous = saved
} else {
slot.previous = capturedDefaults.get(registry)
}
const definition = await readOpsPtcDefinition(options, ctx)
if (definition === undefined || slot.disposed) return
let release: (() => Promise<void>) | undefined
Expand Down
120 changes: 120 additions & 0 deletions src/profile-default-repair.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Clear a default agent preset that still names `ops-ptc` after the plugin left the profile.
*
* `global.forceDefaultOpsPtc` persists `selectedDefault: ops-ptc` on the
* `agent-preset-registry` row of the profile patch. The row teardown gives it back,
* but a removal that stops the profile first, runs without HMR, or happens while the
* application is closed never reaches that teardown. The host resolves the default as
* `selectedDefault ?? default` without checking that the preset exists, so the leftover
* makes every new session fail with `Unknown agent preset: ops-ptc` (#881).
*
* Only that one line is removed: the registry then falls back to its own `default`,
* exactly as if the operator had never chosen. Any other shape is left untouched and
* reported, never guessed at.
*/
import fs from 'node:fs'
import path from 'node:path'

/** The profile row whose volatile `selectedDefault` names the default preset. */
const REGISTRY_ID = 'agent-preset-registry'
/** The preset this package registers at runtime and the leftover it may leave. */
const PRESET_ID = 'ops-ptc'

/** One top-level sequence item: `- id: agent-preset-registry`, possibly quoted. */
const REGISTRY_ITEM = new RegExp(`^-\\s+id:\\s*(['"]?)${REGISTRY_ID}\\1\\s*(?:#.*)?$`, 'u')
/** A nested `id:` line of a top-level item whose first key is something else. */
const REGISTRY_ID_LINE = new RegExp(`^\\s+id:\\s*(['"]?)${REGISTRY_ID}\\1\\s*(?:#.*)?$`, 'u')
const SELECTED_LEFTOVER = new RegExp(`^(\\s+)selectedDefault:\\s*(['"]?)${PRESET_ID}\\2\\s*(?:#.*)?$`, 'u')
const CONFIG_LINE = /^(\s+)config:\s*(?:#.*)?$/u

/** What one repair found. */
export interface DefaultRepair {
/** The profile patch inspected. */
readonly file: string
/** A `selectedDefault: ops-ptc` line was found on the registry row. */
readonly dangling: boolean
/** The line was removed (false for a dry run or when nothing was found). */
readonly changed: boolean
}

/** Leading spaces of one line. */
function indentOf(line: string): number {
return /^ */u.exec(line)![0].length
}

/**
* Locate the leftover in one profile patch text.
*
* Mirrors the host config editor: when the selection was the only `config` key the
* empty `config:` goes too, and a row left with only `id`/`name` is dropped.
* @param text - the `cordis.patch.yml` contents.
* @returns the 0-based line indexes to remove, ascending; empty when there is none.
*/
export function findDanglingDefault(text: string): number[] {
const lines = text.split(/\r?\n/u)
const topLevel = (line: string) => /^-(?:\s|$)/u.test(line)
let start = 0
while (start < lines.length) {
// Each top-level item starts at column 0 with `- `; the next one ends it.
let end = start + 1
while (end < lines.length && !topLevel(lines[end]!)) end += 1
if (topLevel(lines[start]!)) {
const item = lines.slice(start, end)
const isRegistry = REGISTRY_ITEM.test(item[0]!) || item.slice(1).some(line => REGISTRY_ID_LINE.test(line))
if (isRegistry) {
let config = -1
for (let index = 1; index < item.length; index += 1) {
const line = item[index]!
if (CONFIG_LINE.test(line)) { config = index; continue }
const selected = SELECTED_LEFTOVER.exec(line)
// Only a key nested directly under `config:` is the registry selection.
if (selected === null || config < 0 || selected[1]!.length <= indentOf(item[config]!)) continue
const children = item.slice(config + 1).filter((child, offset) => config + 1 + offset !== index
&& child.trim() !== '' && !child.trim().startsWith('#'))
.filter(child => indentOf(child) > indentOf(item[config]!))
if (children.length > 0) return [start + index]
const rest = item.filter((other, offset) => offset !== 0 && offset !== config && offset !== index
&& other.trim() !== '' && !other.trim().startsWith('#'))
const onlyIdentity = rest.every(other => /^\s+(?:id|name):/u.test(other)) && /^-\s+(?:id|name):/u.test(item[0]!)
if (onlyIdentity) return Array.from({ length: end - start }, (_, offset) => start + offset)
return [start + config, start + index]
}
}
}
start = end
}
return []
}

/**
* Remove a dangling `selectedDefault: ops-ptc` from one profile's patch.
* @param profileDir - the profile directory holding `cordis.patch.yml`.
* @param options - `dryRun` reports without writing.
* @returns what was found and whether the file changed; a missing patch is clean.
*/
export function repairDanglingDefault(profileDir: string, { dryRun = false }: { dryRun?: boolean } = {}): DefaultRepair {
const file = path.join(profileDir, 'cordis.patch.yml')
let text: string
try { text = fs.readFileSync(file, 'utf8') } catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { file, dangling: false, changed: false }
throw error
}
const remove = findDanglingDefault(text)
if (remove.length === 0) return { file, dangling: false, changed: false }
if (dryRun) return { file, dangling: true, changed: false }
const newline = text.includes('\r\n') ? '\r\n' : '\n'
const dropped = new Set(remove)
const lines = text.split(/\r?\n/u).filter((_, index) => !dropped.has(index))
// The host config editor requires a top-level YAML sequence; when the removed
// entry was the only item, leave an empty sequence behind any header comments.
if (!lines.some(line => /^-(?:\s|$)|^\s*\[/u.test(line))) {
if (lines.length > 0 && lines.at(-1) === '') lines.splice(lines.length - 1, 0, '[]')
else lines.push('[]', '')
}
const mode = fs.statSync(file).mode & 0o777
// Write beside the target and rename, so a crash never leaves a half-written profile.
const temporary = `${file}.dsh-ops-${process.pid}.tmp`
fs.writeFileSync(temporary, lines.join(newline), { mode })
fs.renameSync(temporary, file)
return { file, dangling: true, changed: true }
}
2 changes: 2 additions & 0 deletions src/profile-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createRequire } from 'node:module'
import { spawnSync } from 'node:child_process'
import { PACKAGE_ROOT, dshHome } from './paths.js'
import { PLATFORMS } from './execution/runtime.js'
import { repairDanglingDefault } from './profile-default-repair.js'

/**
* Architectures this fork will run its profile commands on.
Expand Down Expand Up @@ -173,6 +174,7 @@ export function profileCommand(operation: ProfileOperation, { profile, dshCli, d
} else result = spawnSync(command, [...prefix, ...args], { stdio: 'inherit', windowsHide: true })
if (result.error) throw result.error
if (result.status !== 0) return result.status ?? 1
if (operation === 'uninstall') repairDanglingDefault(directory)
console.log(operation === 'install'
? 'Installed through the official profile manager. Reload/restart the target application if needed. On Windows the pinned Bash payload ships as an optional dependency; run dsh-ops bash-runtime install to repair a missing payload. No postinstall downloads.'
: 'Removed through the official profile manager, including its runtime dependency references. Package-manager shared caches are not deleted.')
Expand Down
Loading
Loading