diff --git a/config/agentrun.yaml b/config/agentrun.yaml index 6df6b161..944b1089 100644 --- a/config/agentrun.yaml +++ b/config/agentrun.yaml @@ -59,6 +59,9 @@ controlPlane: unideskWorkspace: /root/unidesk lanes: nc01-v02: + status: + readOnlyCapture: + timeoutMs: 20000 deployment: format: unidesk-yaml-only gitopsRoot: deploy/gitops/node/nc01 diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index edb859cb..1206b01e 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -23,6 +23,8 @@ deliveryTiming: endToEndBudgetSeconds: 120 observability: configRef: config/platform-infra/observability.yaml + readOnlyCapture: + timeoutMs: 25000 tracesEndpoint: http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces serviceName: unidesk-cicd propagation: diff --git a/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R8_Task_Report.md b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R8_Task_Report.md new file mode 100644 index 00000000..6545ff78 --- /dev/null +++ b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R8_Task_Report.md @@ -0,0 +1,79 @@ +# R8 任务报告 + +## 任务定位 + +- MDTODO:`R8`。 +- Issue:`pikasTech/unidesk#2165`。 +- PR:`pikasTech/unidesk#2168`。 +- 本轮范围:仅处理主代理 blocker comment `#issuecomment-4978743309`。 +- Target:`NC01`。 +- targetWorkspace:`/root/unidesk/.worktree/2165-pac-status-visible`。 +- ref:`fix/2165-pac-status-visible`。 + +## 根因 + +- 首轮修复已建立稳定的 typed observation,使 PaC `status/history/debug-step` 与 AgentRun `control-plane status` 在 timeout、remote failure、empty response 和 parse failure 时不再以空 stdout 结束。 +- 首轮仍把 PaC 只读 capture 预算 `25000ms` 与 AgentRun status capture 预算 `20000ms` 写成 TypeScript 常量。 +- 这些预算属于可调运维事实;源码常量绕过 owning YAML,无法披露来源路径,也无法在选中对象缺失预算时 fail-closed。 + +## 官方最佳实践 + +- 遵循仓库 `AGENTS.md` 与 `docs/reference/yaml-first-ops.md`:owning YAML 是可调运维事实的唯一真相,parser 只做结构与类型校验,代码只消费已验证值。 +- 遵循 `docs/reference/cli.md`:远端 timeout、空响应和解析失败必须形成有界 typed 输出,并保留稳定退出语义与只读 drill-down。 +- 遵循 Issue #2165 的 P0 SPEC:Kubernetes、Tekton、Argo 和既有 runtime 聚合器继续作为状态 authority;预算配置不新增状态源、缓存 authority 或交付 mutation。 + +## Current Control Flow + +1. CLI 读取 PaC 或 AgentRun YAML。 +2. parser 解析目标、consumer 或 lane。 +3. capture 调用使用 TypeScript 文件内固定的 timeout 常量。 +4. typed observation 披露实际 timeout 数值,但无法证明该数值的 owning config path。 +5. YAML 缺失预算时仍可继续执行,不能按当前选中对象 fail-closed。 + +## Desired Control Flow + +1. PaC parser 必填读取 `config/platform-infra/pipelines-as-code.yaml#observability.readOnlyCapture.timeoutMs`。 +2. AgentRun lane parser 必填读取 `config/agentrun.yaml#controlPlane.lanes..status.readOnlyCapture.timeoutMs`。 +3. parser 将预算值与精确 config path 放入 typed spec,不提供源码默认值。 +4. capture 只使用已验证的 YAML 预算。 +5. PaC text/JSON observation 与 AgentRun status text/JSON 摘要同时披露 config path 和实际 timeout。 +6. 缺失字段时 parser 在发起远端读取前失败,并指出缺失 YAML 路径。 + +## 修改 + +- `config/platform-infra/pipelines-as-code.yaml` + - 新增 `observability.readOnlyCapture.timeoutMs: 25000`。 +- `scripts/src/platform-infra-pipelines-as-code.ts` + - 删除 `pacReadOnlyCaptureTimeoutMs` 源码常量。 + - parser 必填校验预算并记录精确 config path。 + - `status/history/debug-step` capture 使用 YAML 预算。 + - typed observation 与默认 text 输出披露预算路径和实际数值。 +- `config/agentrun.yaml` + - 在 `controlPlane.lanes.nc01-v02.status.readOnlyCapture.timeoutMs` 声明 `20000`。 +- `scripts/src/agentrun-lanes.ts` + - lane parser 必填解析 status capture 预算与精确 config path。 +- `scripts/src/agentrun/control-plane.ts` + - 删除 `agentRunStatusCaptureTimeoutMs` 源码常量。 + - source、runtime 和 git-mirror 只读 capture 使用选中 lane 的 YAML 预算。 + - status 结果披露 `readCaptureBudget`。 + +## 验证证据 + +- 定向 Bun 测试验证 PaC typed observation 保留 timeout、config path、首断点与只读 Next。 +- TypeScript 语法检查覆盖修改的 parser、PaC 与 AgentRun control-plane 文件。 +- YAML 读取和原 CLI 入口验证证明预算由选中配置解析,并在 text/JSON 中可见。 +- `git diff --check` 验证补丁格式。 +- 所有目标源码、Git、验证和原入口观察均通过 `trans NC01:/root/unidesk/.worktree/2165-pac-status-visible ...` 执行。 + +## 权限与变更边界 + +- 保留现有 typed observation、即时 progress、只读 Next 和非 mutation 行为。 +- 未增加 env、ConfigMap、数据库、缓存或第二状态源 authority。 +- 未增加合同、租约、安全围栏或锁。 +- 未执行 Vitest、PK01 操作、人工 PipelineRun、Argo、mirror 或其他交付 mutation。 +- 不自行合并 PR;管理性结论由主代理审核。 + +## 剩余边界 + +- Issue #2167 不属于 R8 当前 blocker 修复范围,本轮不扩展其 authority、实现或验收。 +- PR 合并与后续交付由主代理审核后决定。 diff --git a/scripts/src/agentrun-lanes.ts b/scripts/src/agentrun-lanes.ts index 8fa8d13b..dbfc0828 100644 --- a/scripts/src/agentrun-lanes.ts +++ b/scripts/src/agentrun-lanes.ts @@ -173,6 +173,12 @@ export interface AgentRunLaneSpec { readonly nodeKubeRoute: string; readonly nodeUnideskWorkspace: string; readonly version: string; + readonly status: { + readonly readOnlyCapture: { + readonly timeoutMs: number; + readonly configPath: string; + }; + }; readonly source: { readonly statusMode: "host-worktree" | "k3s-git-mirror"; readonly repository: string; @@ -677,6 +683,8 @@ function parseLanes(input: Record, nodes: Record, configPath: string): AgentRunLaneSpec { const path = `${configPath}.controlPlane.lanes.${lane}`; + const status = recordField(input, "status", path); + const readOnlyCapture = recordField(status, "readOnlyCapture", `${path}.status`); const source = recordField(input, "source", path); const runtime = recordField(input, "runtime", path); const ci = recordField(input, "ci", path); @@ -694,6 +702,12 @@ function parseLane(lane: string, node: AgentRunNodeSpec, input: Record capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneRuntimeStatusScript(spec, pipelineRunName)], { runtimeTimeoutMs: agentRunStatusCaptureTimeoutMs })); + const runtimeProbe = await timedStatusStage("runtime", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneRuntimeStatusScript(spec, pipelineRunName)], { runtimeTimeoutMs: spec.status.readOnlyCapture.timeoutMs })); const runtimePayload = captureJsonPayload(runtimeProbe.value); const manager = record(runtimePayload.manager); const database = record(runtimePayload.database); @@ -530,6 +528,7 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options: command: "agentrun control-plane status", mode: "pipelines-as-code-migrated-lane", configPath: target.configPath, + readCaptureBudget: spec.status.readOnlyCapture, target: options.full || options.raw ? agentRunLaneSummary(spec) : compactAgentRunLaneStatusTarget(spec), summary, alignment: { @@ -642,8 +641,8 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio if (deliveryAuthority.kind === "pac-pr-merge") return await statusPipelinesAsCodeLane(config, options, target, deliveryAuthority); if (deliveryAuthority.kind === "unknown") return unknownDeliveryAuthorityStatus(options, target, deliveryAuthority); const sourceProbe = await timedStatusStage("source", () => spec.source.statusMode === "k3s-git-mirror" - ? capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneK3sSourceStatusScript(spec)], { runtimeTimeoutMs: agentRunStatusCaptureTimeoutMs }) - : capture(config, `${spec.nodeRoute}:${spec.source.workspace}`, ["sh", "--", yamlLaneSourceStatusScript(spec)], { runtimeTimeoutMs: agentRunStatusCaptureTimeoutMs })); + ? capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneK3sSourceStatusScript(spec)], { runtimeTimeoutMs: spec.status.readOnlyCapture.timeoutMs }) + : capture(config, `${spec.nodeRoute}:${spec.source.workspace}`, ["sh", "--", yamlLaneSourceStatusScript(spec)], { runtimeTimeoutMs: spec.status.readOnlyCapture.timeoutMs })); const sourcePayload = captureJsonPayload(sourceProbe.value); const branchTipCommit = stringOrNull(sourcePayload.remoteBranchCommit) ?? stringOrNull(sourcePayload.localHead); const initialSourceCommit = options.sourceCommit @@ -651,8 +650,8 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio ?? stringOrNull(sourcePayload.localHead); const pipelineRunName = options.pipelineRun ?? (initialSourceCommit ? agentRunPipelineRunName(spec, initialSourceCommit) : null); const [runtimeProbe, mirrorProbe] = await Promise.all([ - timedStatusStage("runtime", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneRuntimeStatusScript(spec, pipelineRunName)], { runtimeTimeoutMs: agentRunStatusCaptureTimeoutMs })), - timedStatusStage("git-mirror", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneGitMirrorStatusScript(spec)], { runtimeTimeoutMs: agentRunStatusCaptureTimeoutMs })), + timedStatusStage("runtime", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneRuntimeStatusScript(spec, pipelineRunName)], { runtimeTimeoutMs: spec.status.readOnlyCapture.timeoutMs })), + timedStatusStage("git-mirror", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneGitMirrorStatusScript(spec)], { runtimeTimeoutMs: spec.status.readOnlyCapture.timeoutMs })), ]); const runtimePayload = captureJsonPayload(runtimeProbe.value); const mirrorPayload = captureJsonPayload(mirrorProbe.value); @@ -904,6 +903,7 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio command: "agentrun control-plane status", mode: "yaml-declared-node-lane", configPath: target.configPath, + readCaptureBudget: spec.status.readOnlyCapture, target: options.full || options.raw ? agentRunLaneSummary(spec) : compactAgentRunLaneStatusTarget(spec), summary: options.full || options.raw ? detailedSummary : commanderSummary, alignment: { diff --git a/scripts/src/platform-infra-pac-failure-evidence.test.ts b/scripts/src/platform-infra-pac-failure-evidence.test.ts index bf2a89f5..0c4846be 100644 --- a/scripts/src/platform-infra-pac-failure-evidence.test.ts +++ b/scripts/src/platform-infra-pac-failure-evidence.test.ts @@ -64,6 +64,7 @@ describe("PaC 失败证据合同", () => { parsed: null, elapsedMs: 25001, timeoutMs: 25000, + configPath: "config/platform-infra/pipelines-as-code.yaml#observability.readOnlyCapture.timeoutMs", next: "bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --consumer agentrun-nc01-v02 --limit 5", }); expect(observation).toMatchObject({ @@ -74,6 +75,7 @@ describe("PaC 失败证据合同", () => { stage: "history-remote-observe", elapsedMs: 25001, timeoutMs: 25000, + configPath: "config/platform-infra/pipelines-as-code.yaml#observability.readOnlyCapture.timeoutMs", exitCode: 124, firstBreak: { code: "pac-read-timeout", phase: "remote-read" }, }); @@ -81,6 +83,7 @@ describe("PaC 失败证据合同", () => { expect(output).toContain("READ OBSERVATION"); expect(output).toContain("history-remote-observe"); expect(output).toContain("pac-read-timeout"); + expect(output).toContain("config/platform-infra/pipelines-as-code.yaml#observability.readOnlyCapture.timeoutMs"); expect(output).toContain("next-read-only:"); }); diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 8219e4a1..f889a6e5 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -52,7 +52,6 @@ const remoteScriptFile = rootPath("scripts", "src", "platform-infra-pipelines-as const evaluatorFile = rootPath("scripts", "native", "cicd", "pac-status-evaluator.cjs"); const sourceArtifactRuntimeObserverFile = rootPath("scripts", "native", "cicd", "pac-source-artifact-runtime.mjs"); const fieldManager = "unidesk-platform-infra-pipelines-as-code"; -const pacReadOnlyCaptureTimeoutMs = 25_000; const y = createYamlFieldReader(configLabel); function kubernetesLabelValue(value: string): string { @@ -111,6 +110,10 @@ export interface PacConfig { deliveryTiming: PacDeliveryTimingPolicy; observability: { configRef: string; + readOnlyCapture: { + timeoutMs: number; + configPath: string; + }; tracesEndpoint: string; serviceName: string; propagation: string[]; @@ -530,6 +533,7 @@ export function parsePacConfigDocument( const display = y.objectField(root, "display", ""); const deliveryTiming = y.objectField(root, "deliveryTiming", ""); const observability = y.objectField(root, "observability", ""); + const readOnlyCapture = y.objectField(observability, "readOnlyCapture", "observability"); const observabilitySampling = y.objectField(observability, "sampling", "observability"); const exporterFailure = y.objectField(observability, "exporterFailure", "observability"); const admin = y.objectField(gitea, "admin", "gitea"); @@ -569,6 +573,10 @@ export function parsePacConfigDocument( }, observability: { configRef: y.stringField(observability, "configRef", "observability"), + readOnlyCapture: { + timeoutMs: integerInRange(readOnlyCapture.timeoutMs, 1_000, 120_000, "observability.readOnlyCapture.timeoutMs"), + configPath: `${configLabel}#observability.readOnlyCapture.timeoutMs`, + }, tracesEndpoint: urlField(observability, "tracesEndpoint", "observability"), serviceName: y.stringField(observability, "serviceName", "observability"), propagation: y.stringArrayField(observability, "propagation", "observability"), @@ -1371,7 +1379,7 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise>; readonly parsed: Record | null; readonly observation: PacReadOnlyCaptureObservation }> { const stage = `${action}-remote-observe`; const startedAt = Date.now(); - printPacReadOnlyProgress(target, consumer, stage, "started", startedAt, { timeoutMs: pacReadOnlyCaptureTimeoutMs }); - const result = await capture(config, target.route, ["sh"], script, { runtimeTimeoutMs: pacReadOnlyCaptureTimeoutMs }); + const captureBudget = pac.observability.readOnlyCapture; + printPacReadOnlyProgress(target, consumer, stage, "started", startedAt, captureBudget); + const result = await capture(config, target.route, ["sh"], script, { runtimeTimeoutMs: captureBudget.timeoutMs }); const parsed = parseJsonOutput(result.stdout); const observation = pacReadOnlyCaptureObservation({ action, @@ -1765,7 +1775,8 @@ async function capturePacReadOnly( result, parsed, elapsedMs: Date.now() - startedAt, - timeoutMs: pacReadOnlyCaptureTimeoutMs, + timeoutMs: captureBudget.timeoutMs, + configPath: captureBudget.configPath, next, }); printPacReadOnlyProgress(target, consumer, stage, observation.status, startedAt, { @@ -1783,6 +1794,7 @@ export function pacReadOnlyCaptureObservation(options: { readonly parsed: Record | null; readonly elapsedMs: number; readonly timeoutMs: number; + readonly configPath: string; readonly next: string; }): PacReadOnlyCaptureObservation { const stdoutBytes = Buffer.byteLength(options.result.stdout, "utf8"); @@ -1806,6 +1818,7 @@ export function pacReadOnlyCaptureObservation(options: { stage: `${options.action}-remote-observe`, elapsedMs: options.elapsedMs, timeoutMs: options.timeoutMs, + configPath: options.configPath, exitCode: options.result.exitCode, stdoutBytes, stderrBytes, @@ -2452,6 +2465,7 @@ function compactStatusJson(result: Record): Record, includeTaskD consumers: result.consumers, detailId: result.detailId, deliveryBudget: result.deliveryBudget, + observation: result.observation, rows: arrayRecords(result.rows).map((row) => { const taskRuns = record(row.taskRuns); const artifact = record(row.artifact); @@ -3125,6 +3140,7 @@ function renderPacReadObservation(result: Record): string[] { boolText(observation.partial), stringValue(firstBreak.code), ]]), + ` budget-config: ${stringValue(observation.configPath)}`, ` next-read-only: ${stringValue(observation.next)}`, ]; }