diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d9d3b2d5..71310b21 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -34,6 +34,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 DEV/PROD 滚动、P - `provider attach [--master-server URL] [--up] [--force]` 在新计算节点生成两项配置的 provider-gateway 挂载包:`.state/provider-.env` 默认只包含 `UNIDESK_MASTER_SERVER` 与 `PROVIDER_ID`,`provider-.yml` 固定 Docker socket、`pid: "host"`、`restart: always`、只读 `/workspace` 和 SSH 维护私钥挂载;`--up` 会立即执行生成的 `docker compose up -d --build`。`provider triage [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]` 是只读多信号健康裁决入口,会把单路径 `provider is not online`、SSH 超时、registry 失败和 service proxy 失败归类成 `runner-local-observation-gap`、`service-degraded`、`provider-degraded` 或 `global-blocker`。默认输出只返回裁决、scope、失败/降级/未知信号和有界 evidence 摘要,完整 evidence 必须显式加 `--full` 或 `--raw`;推荐交叉验证命令仍包含 `debug health`、`debug dispatch host.ssh --wait-ms 15000`、`trans argv true`、`artifact-registry health --provider-id `、`microservice health k3sctl-adapter`、`microservice health code-queue` 和 `codex tasks --view supervisor --limit 20`。 - `trans [operation args...]` / `tran [operation args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;`route` 基础形态是 provider id,例如 `D601` 或 `G14`,也可以扩展为纯定位路径 `provider:plane[:namespace:resource[:container]]`,例如 `D601:win`、`D601:win/c/test`、`G14:k3s`、`D601:k3s` 或 `G14:k3s::`。WSL provider 的 Windows plane 固定使用 `win`,不得使用 `win32`;Windows operation 必须显式区分:`ps` 执行 Windows PowerShell heredoc 或一行 PowerShell 命令,`cmd` 执行 cmd.exe/batch,`skills` 发现 Windows skill 目录。需要 Windows cwd 时用 `trans D601:win/c/test ps` 或 `trans D601:win/c/test cmd cd`,CLI 自动设置 UTF-8/Python 编码默认值;`cmd` 额外设置 `chcp 65001`。非交互远端命令优先使用 `trans argv ...`;需要 POSIX shell 脚本、管道、变量或循环时优先使用 quoted heredoc 单步传输,例如 `trans G14 script <<'SCRIPT'`、`trans G14:k3s script <<'SCRIPT'` 或 `trans G14:k3s:: script <<'SCRIPT'`,把脚本走 stdin。`script` 只表示 host/k3s POSIX shell,不表示 Windows PowerShell;Windows PowerShell 必须写 `trans :win ps <<'PS'`。`script -- '<单个字符串>'` 是无需 stdin 的远端 POSIX shell one-liner,例如 `trans G14:/root/hwlab script -- 'cd /root/hwlab && git status --short --branch'`;`script -- <多个 argv>` 才是 direct argv,适合 `trans D601:/path script -- sed -n '1,20p' file` 这类带短横线的单进程命令。顶层 remote option parser 必须保留命令已经开始后的 `--`,不得把它吞成全局选项结束符。需要远端改文本文件时默认优先使用 ` apply-patch < patch.diff`;需要可靠传输非文本或整文件时使用 ` upload ` 和 ` download `,CLI 会按字节数与 SHA-256 自动校验并在 provider-gateway stdin/argv 限制下切换客户端分块策略;需要旧 helper 时显式使用 `:k3s:: apply-patch-v1` 或 ` apply-patch-v1`。ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示 stdin script/argv 重试和 provider triage 交叉验证。 - `trans apply-patch < patch.diff` 是默认推荐的远端 patch 入口:本地 TypeScript line-based engine 解析和计算新文件内容,远端 route 只负责读写文件;支持 host workspace、k3s pod workspace、Windows workspace route(例如 `D601:win/c/test`)和 frontend transport,并优先处理长中文/Unicode、低上下文插入、重复块 `@@` 定位等旧 helper 容易失败的场景。`apply-patch` 输出按 Codex 标准文本口径,不套 UniDesk JSON 限制:成功 stdout 为 `Success. Updated the following files:`,失败 stdout 为空、stderr 写失败原因;多文件补丁中途失败时,stderr 只列出第一个失败前已成功执行的 hunk 和失败 hunk,随后按 Codex 语义停止,不继续尝试后续 hunk。v2 兼容常见 MiniMax/MXCX 非标准补丁输入,例如重复 nested `*** Begin Patch` / `*** End Patch` envelope、unified-diff hunk header、Add/Delete 误加 `@@`、Update context 漏掉前导空格,并在 stderr 给出 canonical 写法 hint;parser 或上下文失败时仍坚持唯一 v2 引擎,只提示修正 patch 文本或 hunk context,不自动重试或切换到 `apply-patch-v1`;大块/函数替换因上下文过期失败时,正确动作是重新读取当前目标块、缩小或拆分 Update File hunk 后继续用 `apply-patch`,不得改走 `download`/`upload`、远端 Python/Perl/sed heredoc 或整文件重写。Windows route 复用同一套 v2 核心算法,只把底层读写替换成 PowerShell 文件系统接口;`trans apply-patch-v1 [tool args...] < patch.diff` 保留为显式 legacy 入口,直接调用远端注入的 `apply_patch` sh/perl helper;默认 `apply-patch` 不把 v1 当 fallback。 +- `apply-patch` v2 每次结束都会在 stderr 追加一行 `UNIDESK_APPLY_PATCH_TIMING {json}`,字段包含 `durationMs`、`patchBytes`、`fileCount`、`hunkCount`、`changedCount`、`remoteOperationCount`、`remoteOperationCounts`、`remoteElapsedMs`、`remoteFailureCount`、`providerId`、`route` 和 `transport`(可得时)。这条 timing 摘要只用于定位慢在 patch 解析、远端 stat/read/write、provider session 还是传输层,不能替代 Codex 标准 stdout/stderr 成功失败文本,也不是门禁或自动判断。 - `trans py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。 - `trans skills [--scope all|wsl|windows] [--limit N]` 发现目标节点上的 WSL/Linux skill 根目录;当 provider 是 WSL 时同一次调用还会扫描 Windows 用户目录下的 `.agents/skills` 与 `.codex/skills`。 - `trans :k3s[:namespace:workload[:container]] ...` 是原生 k3s 结构化 route 入口,route 只定位控制面或 workload,`kubectl`、`logs`、`exec`、`script`、`apply-patch`、旧 `apply-patch-v1` fallback 和普通容器命令作为 operation 放在 route 之后;CLI 固定注入 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并把 kubectl、workload exec、logs 和 pod workspace 读写参数组装成 argv,避免在 Host SSH、bash、kubectl exec 和容器 shell 之间反复手写多层引号;D601 与 G14 都有 provider-specific guard,分别校验 `d601` 和 G14 k3s 节点身份。 diff --git a/scripts/src/apply-patch-v2.ts b/scripts/src/apply-patch-v2.ts index 4ac1e3d0..84f4af4f 100644 --- a/scripts/src/apply-patch-v2.ts +++ b/scripts/src/apply-patch-v2.ts @@ -50,6 +50,7 @@ export interface ApplyPatchV2Options { stdout: Writable; stderr?: Writable; argv?: string[]; + timing?: ApplyPatchV2TimingOptions; } export interface ApplyPatchV2Executor { @@ -75,6 +76,35 @@ export interface ApplyPatchV2RemoteResult { stderr: string; } +export interface ApplyPatchV2TimingOptions { + providerId?: string; + route?: string; + transport?: "backend-core-broker" | "frontend-websocket" | "local" | "virtual"; + thresholdMs?: number; +} + +export interface ApplyPatchV2TimingSummary { + code: "apply-patch-v2-timing"; + level: "info" | "warning"; + status: "succeeded" | "failed"; + providerId?: string; + route?: string; + transport?: ApplyPatchV2TimingOptions["transport"]; + durationMs: number; + thresholdMs: number; + slow: boolean; + patchBytes: number; + fileCount: number; + hunkCount: number; + changedCount: number; + remoteOperationCount: number; + remoteOperationCounts: Record; + remoteElapsedMs: number; + remoteFailureCount: number; + message: string; + note: string; +} + export class ApplyPatchV2Error extends Error { constructor(message: string, public readonly details: Record = {}) { super(message); @@ -91,6 +121,13 @@ interface ApplyPatchV2Plan { outcomes: ApplyPatchV2Outcome[]; } +export interface ApplyPatchV2RemoteMetrics { + remoteOperationCount: number; + remoteOperationCounts: Record; + remoteElapsedMs: number; + remoteFailureCount: number; +} + const beginMarker = "*** Begin Patch"; const endMarker = "*** End Patch"; const environmentIdMarker = "*** Environment ID: "; @@ -101,6 +138,7 @@ const moveToMarker = "*** Move to: "; const emptyChangeContextMarker = "@@"; const changeContextMarker = "@@ "; const eofMarker = "*** End of File"; +const defaultApplyPatchV2SlowWarningMs = 10_000; export function isApplyPatchV2HelpArgs(args: string[] = []): boolean { const first = args[0] ?? ""; @@ -121,6 +159,24 @@ export function applyPatchV2HelpPayload() { firstLine: beginMarker, lastLine: endMarker }, + timing: { + stderrPrefix: "UNIDESK_APPLY_PATCH_TIMING", + fields: [ + "durationMs", + "patchBytes", + "fileCount", + "hunkCount", + "changedCount", + "remoteOperationCount", + "remoteOperationCounts", + "remoteElapsedMs", + "remoteFailureCount", + "providerId", + "route", + "transport" + ], + stdoutCompatibility: "Timing is written to stderr so success stdout remains Codex apply_patch-compatible." + }, rules: [ "Add File has no @@ hunk marker: put content immediately after `*** Add File: ` and prefix every content line with +.", "A blank line in Add File is a line containing only +.", @@ -302,20 +358,162 @@ export async function runApplyPatchV2(options: ApplyPatchV2Options): Promise { + const operation = applyPatchV2OperationName(command); + const started = Date.now(); + metrics.remoteOperationCount += 1; + metrics.remoteOperationCounts[operation] = (metrics.remoteOperationCounts[operation] ?? 0) + 1; + try { + const result = await executor.run!(command, input); + metrics.remoteElapsedMs += Math.max(0, Date.now() - started); + if (result.exitCode !== 0) metrics.remoteFailureCount += 1; + return result; + } catch (error) { + metrics.remoteElapsedMs += Math.max(0, Date.now() - started); + metrics.remoteFailureCount += 1; + throw error; + } + }, + fs: executor.fs === undefined ? undefined : instrumentApplyPatchV2FileSystem(executor.fs, metrics), + }; +} + +function instrumentApplyPatchV2FileSystem(fs: ApplyPatchV2FileSystem, metrics: ApplyPatchV2RemoteMetrics): ApplyPatchV2FileSystem { + return { + stat: (path) => recordApplyPatchV2FsOperation(metrics, "fs.stat", () => fs.stat(path)), + readBlock: (path, blockIndex, blockBytes) => recordApplyPatchV2FsOperation(metrics, "fs.readBlock", () => fs.readBlock(path, blockIndex, blockBytes)), + writeFile: (path, content) => recordApplyPatchV2FsOperation(metrics, "fs.writeFile", () => fs.writeFile(path, content)), + deleteFile: (path) => recordApplyPatchV2FsOperation(metrics, "fs.deleteFile", () => fs.deleteFile(path)), + }; +} + +async function recordApplyPatchV2FsOperation(metrics: ApplyPatchV2RemoteMetrics, operation: string, run: () => Promise): Promise { + const started = Date.now(); + metrics.remoteOperationCount += 1; + metrics.remoteOperationCounts[operation] = (metrics.remoteOperationCounts[operation] ?? 0) + 1; + try { + const result = await run(); + metrics.remoteElapsedMs += Math.max(0, Date.now() - started); + return result; + } catch (error) { + metrics.remoteElapsedMs += Math.max(0, Date.now() - started); + metrics.remoteFailureCount += 1; + throw error; + } +} + +function applyPatchV2OperationName(command: string[]): string { + const index = command.indexOf("unidesk-apply-patch-v2"); + return index >= 0 ? command[index + 1] ?? "unknown" : command[0] ?? "unknown"; +} + +export function applyPatchV2TimingSummary(options: { + status: ApplyPatchV2TimingSummary["status"]; + patchText: string; + parsed: PatchParseResult | null; + plan: ApplyPatchV2Plan | null; + metrics: ApplyPatchV2RemoteMetrics; + startedAtMs: number; + finishedAtMs?: number; + options?: ApplyPatchV2TimingOptions; +}): ApplyPatchV2TimingSummary { + const finishedAtMs = options.finishedAtMs ?? Date.now(); + const durationMs = Math.max(0, Math.round(finishedAtMs - options.startedAtMs)); + const thresholdMs = options.options?.thresholdMs ?? defaultApplyPatchV2SlowWarningMs; + const slow = durationMs > thresholdMs; + const patchBytes = Buffer.byteLength(options.patchText, "utf8"); + const fileCount = countApplyPatchV2Files(options.parsed?.hunks ?? []); + const hunkCount = options.parsed?.hunks.length ?? 0; + const changedCount = options.plan?.changed.length ?? applyPatchV2ChangedCountFromOutcomes(options.plan?.outcomes ?? []); + const durationSeconds = Number((durationMs / 1000).toFixed(3)); + const thresholdSeconds = Number((thresholdMs / 1000).toFixed(3)); + return { + code: "apply-patch-v2-timing", + level: slow ? "warning" : "info", + status: options.status, + ...(options.options?.providerId === undefined ? {} : { providerId: options.options.providerId }), + ...(options.options?.route === undefined ? {} : { route: options.options.route }), + ...(options.options?.transport === undefined ? {} : { transport: options.options.transport }), + durationMs, + thresholdMs, + slow, + patchBytes, + fileCount, + hunkCount, + changedCount, + remoteOperationCount: options.metrics.remoteOperationCount, + remoteOperationCounts: options.metrics.remoteOperationCounts, + remoteElapsedMs: Math.max(0, Math.round(options.metrics.remoteElapsedMs)), + remoteFailureCount: options.metrics.remoteFailureCount, + message: slow + ? `apply-patch completed in ${durationSeconds}s, above the ${thresholdSeconds}s warning threshold; inspect remoteOperationCounts and remoteElapsedMs before repeating high-frequency patch work.` + : `apply-patch completed in ${durationSeconds}s.`, + note: "Timing summary is written to stderr so stdout remains Codex apply_patch-compatible.", + }; +} + +export function formatApplyPatchV2TimingSummary(summary: ApplyPatchV2TimingSummary): string { + return `UNIDESK_APPLY_PATCH_TIMING ${JSON.stringify(summary)}\n`; +} + +function countApplyPatchV2Files(hunks: PatchHunk[]): number { + const files = new Set(); + for (const hunk of hunks) { + files.add(hunk.path); + if (hunk.kind === "update" && hunk.movePath !== null) files.add(hunk.movePath); + } + return files.size; +} + +function applyPatchV2ChangedCountFromOutcomes(outcomes: ApplyPatchV2Outcome[]): number { + return outcomes.filter((outcome) => outcome.status === "applied").length; +} + async function applyPatchV2Hunks(executor: ApplyPatchV2Executor, hunks: PatchHunk[]): Promise { if (hunks.length === 0) throw new ApplyPatchV2Error("No files were modified."); diff --git a/scripts/src/help.ts b/scripts/src/help.ts index a84ca32b..01e95f42 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -202,7 +202,7 @@ export function sshHelp(): unknown { "When a one-line shell command is easier to type through the script path, `script -- ''` runs that single string through the remote shell without waiting for stdin. When `script --` is followed by multiple tokens, it stays a direct argv form for commands such as `trans D601:/work script -- sed -n '1,20p' file`.", "script and shell helper modes inject a tiny POSIX-compatible printf wrapper before user shell text, so portable printf headings such as `printf \"--- section ---\\n\"` work consistently under dash/sh and bash. Direct argv commands are unchanged.", "For arbitrary stdin streams into a workload command, use a workload route plus `exec --stdin -- ...`; this keeps the route as location-only and avoids heredoc/base64/tar shell wrapping.", - "`apply-patch` is the default remote text patch entry and uses the v2 local line-based patch engine with remote read/write operations, including Windows routes such as `D601:win/c/test`, so long Unicode/Chinese lines and pure insertion hunks avoid the legacy remote shell hunk parser. Its stdout/stderr follows Codex apply_patch text output rather than UniDesk JSON output; on multi-file failure, stderr lists applied hunks before the first failed hunk and the failed hunk, then stops like Codex apply_patch.", + "`apply-patch` is the default remote text patch entry and uses the v2 local line-based patch engine with remote read/write operations, including Windows routes such as `D601:win/c/test`, so long Unicode/Chinese lines and pure insertion hunks avoid the legacy remote shell hunk parser. Its stdout follows Codex apply_patch text output rather than UniDesk JSON output; stderr keeps Codex-style failure text and appends one `UNIDESK_APPLY_PATCH_TIMING` JSON summary with durationMs, patchBytes, fileCount, hunkCount, changedCount, remoteOperationCount, remoteOperationCounts and remoteElapsedMs so slow patch runs can be attributed without changing success stdout.", "`upload` and `download` are the default whole-file transfer entries for non-text and generated files. They write through remote temp files, verify byte count and SHA-256 on both sides, and return `verification.automatic=true`, `verification.verified=true`, and `verification.match.{bytes,sha256}=true`; this JSON is the transfer integrity proof, so callers do not need a separate manual `sha256sum` check. The client falls back from a single stdin payload to bounded chunks before treating provider-gateway limits as a server-side problem.", "`apply-patch-v1` is the only legacy fallback entry: it rejects low-context update hunks by default, reports the matched file:line for each hunk on stderr, and only accepts --allow-loose when the caller has manually reviewed an intentionally ambiguous insertion.", "script defaults to target /bin/sh and inherits provider proxy variables such as HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY; it is for host/k3s POSIX shell only. Use --shell bash only for bash syntax such as pipefail, arrays, or [[ ... ]], not as a proxy workaround.", diff --git a/scripts/src/remote.ts b/scripts/src/remote.ts index 503b4e9a..97232314 100644 --- a/scripts/src/remote.ts +++ b/scripts/src/remote.ts @@ -1294,7 +1294,18 @@ async function runRemoteSshOverFrontend(session: FrontendSession, target: string const executor: ApplyPatchV2Executor = { run: (command, input) => runRemoteSshWebSocketCapture(session, invocation, command, input), }; - return await runApplyPatchV2({ executor, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, argv: normalizedArgs.slice(1) }); + return await runApplyPatchV2({ + executor, + stdin: process.stdin, + stdout: process.stdout, + stderr: process.stderr, + argv: normalizedArgs.slice(1), + timing: { + providerId: invocation.providerId, + route: invocation.route.raw, + transport: "frontend-websocket", + }, + }); } return runRemoteSshWebSocket(session, invocation); } diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 1c38f80c..ea6e9d72 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -2655,6 +2655,11 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st stdout: process.stdout, stderr: process.stderr, argv: normalizedArgs.slice(1), + timing: { + providerId: invocation.providerId, + route: invocation.route.raw, + transport: "backend-core-broker", + }, }); } const startedAtMs = Date.now(); diff --git a/scripts/ssh-argv-guidance-contract-test.ts b/scripts/ssh-argv-guidance-contract-test.ts index 8d1b9913..ba77f1e2 100644 --- a/scripts/ssh-argv-guidance-contract-test.ts +++ b/scripts/ssh-argv-guidance-contract-test.ts @@ -5,7 +5,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, wr import os from "node:os"; import path from "node:path"; import { sshHelp } from "./src/help"; -import { runApplyPatchV2 } from "./src/apply-patch-v2"; +import { runApplyPatchV2, type ApplyPatchV2TimingSummary } from "./src/apply-patch-v2"; import { providerTriageRecommendedCrossChecks } from "./src/provider-triage"; import { extractRemoteCliOptions, remoteSshFrontendPlanForTest } from "./src/remote"; import { runSshFileTransferOperation, type SshFileTransferCommandBuilders, type SshRemoteCommandExecutor } from "./src/ssh-file-transfer"; @@ -227,6 +227,12 @@ async function applyPatchV2FixtureAttempt(patch: string, files: Record item.startsWith("UNIDESK_APPLY_PATCH_TIMING ")); + assertCondition(line !== undefined, "apply-patch stderr must include UNIDESK_APPLY_PATCH_TIMING", stderr); + return JSON.parse(line!.slice("UNIDESK_APPLY_PATCH_TIMING ".length)) as ApplyPatchV2TimingSummary; +} + async function applyPatchV2ActualShellFixtureAttempt( patch: string, files: Record, @@ -787,6 +793,13 @@ export async function runSshArgvGuidanceContract(): Promise { }); assertCondition(unifiedHeaderLineRangeV2.stderr.includes("accepted unified-diff hunk header in repeated-large.txt"), "v2 unified header compatibility should emit a canonical syntax hint", unifiedHeaderLineRangeV2.stderr); + const unifiedTiming = applyPatchTimingFromStderr(unifiedHeaderLineRangeV2.stderr); + assertCondition(unifiedTiming.code === "apply-patch-v2-timing" && unifiedTiming.status === "succeeded", "v2 apply-patch timing summary should identify successful runs", unifiedTiming); + assertCondition(unifiedTiming.patchBytes > 0 && unifiedTiming.fileCount === 1 && unifiedTiming.hunkCount === 1 && unifiedTiming.changedCount === 1, "v2 timing summary should expose patch/file/hunk/change counts", unifiedTiming); + assertCondition(unifiedTiming.remoteOperationCount === unifiedHeaderLineRangeV2.commands.length, "v2 timing summary should count remote operations", { unifiedTiming, commands: unifiedHeaderLineRangeV2.commands }); + assertCondition((unifiedTiming.remoteOperationCounts.stat ?? 0) >= 1 && (unifiedTiming.remoteOperationCounts["read-b64-block"] ?? 0) >= 1 && Object.keys(unifiedTiming.remoteOperationCounts).some((key) => key.startsWith("write-b64")), "v2 timing summary should classify stat/read/write operations", unifiedTiming); + assertCondition(unifiedHeaderLineRangeV2.stdout.startsWith("Success. Updated the following files:"), "v2 timing summary must not change Codex-compatible success stdout", unifiedHeaderLineRangeV2.stdout); + const unprefixedUpdateContextV2 = await applyPatchV2FixtureAttempt([ "*** Begin Patch", "*** Update File: internal/cloud/access-control.ts", @@ -1553,6 +1566,7 @@ export async function runSshArgvGuidanceContract(): Promise { assertCondition(helpText.includes("UNIDESK_SSH_HINT"), "ssh help must document structured failure hint", helpText); assertCondition(helpText.includes("UNIDESK_SSH_RUNTIME_TIMEOUT") && helpText.includes("UNIDESK_TRAN_TIMEOUT_HINT") && helpText.includes("60s") && helpText.includes("submit-and-poll"), "ssh help must document top-level runtime timeout and short polling discipline", helpText); assertCondition(helpText.includes("UNIDESK_SSH_TIMING") && helpText.includes("10s") && helpText.includes("slow successful calls are a distributed performance monitoring signal") && helpText.includes("Routine short calls do not emit timing noise"), "ssh help must document slow-only runtime timing hints", helpText); + assertCondition(helpText.includes("UNIDESK_APPLY_PATCH_TIMING") && helpText.includes("remoteOperationCounts") && helpText.includes("durationMs"), "ssh help must document apply-patch aggregate timing summary", helpText); assertCondition(helpText.includes("must not add provider/plane directory locks") && helpText.includes("k8s/Tekton/Argo/Lease"), "ssh help must document tran's no-local-lock boundary", helpText); const crossChecks = providerTriageRecommendedCrossChecks("D601");