From 0a0443fa6bd1ef4700a09aae3298661d84c8a3f4 Mon Sep 17 00:00:00 2001 From: Ji Mao Cai <44921326+jinghero@users.noreply.github.com> Date: Wed, 11 Nov 2026 21:39:22 +0800 Subject: [PATCH 1/9] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=E9=94=81=E3=80=81?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E4=B8=8E=E6=81=A2=E5=A4=8D=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=20(T12.11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...42\345\244\215\350\257\212\346\226\255.md" | 21 ++++ ...42\345\244\215\350\257\212\346\226\255.md" | 100 +++++++++++++++++ ...73\345\212\241\346\213\206\345\210\206.md" | 12 ++ ...66\346\236\204\350\256\276\350\256\241.md" | 7 ++ internal/cli/telemetry.go | 43 +++++-- internal/cli/telemetry_test.go | 99 +++++++++++++++++ internal/gitrepo/recovery.go | 12 +- internal/gitrepo/recovery_diagnostics.go | 68 ++++++++++++ internal/gitrepo/recovery_test.go | 24 ++++ internal/lock/diagnostics_windows_test.go | 41 +++++++ internal/lock/errors.go | 14 +++ internal/telemetry/diagnostics.go | 1 + internal/telemetry/sentry_test.go | 34 ++++++ internal/uv/dependencies.go | 23 +++- internal/uv/dependencies_mirror.go | 23 ++-- internal/uv/dependencies_relay_test.go | 105 ++++++++++++++++++ internal/uv/errors.go | 18 ++- 17 files changed, 618 insertions(+), 27 deletions(-) create mode 100644 "doc/current/M12/\350\256\241\345\210\222-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" create mode 100644 "doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" create mode 100644 internal/gitrepo/recovery_diagnostics.go create mode 100644 internal/lock/diagnostics_windows_test.go diff --git "a/doc/current/M12/\350\256\241\345\210\222-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" "b/doc/current/M12/\350\256\241\345\210\222-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" new file mode 100644 index 0000000..dc3c139 --- /dev/null +++ "b/doc/current/M12/\350\256\241\345\210\222-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" @@ -0,0 +1,21 @@ +# T12.11 实施计划 + +1. 锁与通用观察:扩展锁故障夹具与 CLI 回归,确认缺少操作/系统错误码的红灯,再实现诊断接口和错误链合并。 + 固定用例 `TestNewSet_FailureDiagnostics`、`TestRunOperation_PreservesNestedFailureDiagnostics`。 +2. 依赖:中继同步退出 0 且输出读取失败,诊断必须保留底层分类,协议和清理保持原状。 + 固定用例 `TestDependencies_RelayPreservesExecutionDiagnostics`,扩展现有失败阶段测试。 + P2 复审:`TestDependencies_MirrorFallbackPreservesExecutionDiagnostics` 覆盖中继启动失败后的官方回退和仅镜像耗尽,最后一次失败必须与之前尝试区分。 +3. 恢复:扩展 `TestRecovery_SwapDamagedRepositoryWithPreviousHasNoSideEffects` 和 + `TestRecovery_NonCloneReaderFailureRemainsAmbiguous`,先证明缺少快照再补局部快照。 +4. 最终出口:`TestSentry_OperationalFailureDiagnostics` 验证两种新增容器、执行分类与隐私净化; + 检查协议未增加诊断字段、离线/关闭路径保持。完成后回写设计与任务证据。 + +```powershell +$env:GOCACHE = Join-Path $env:TEMP 'auto-mas-runtime-verify' +go test ./internal/lock ./internal/uv ./internal/gitrepo ./internal/cli ./internal/telemetry -run 'TestNewSet_FailureDiagnostics|TestRunOperation_PreservesNestedFailureDiagnostics|TestRunOperation_DiagnosticsRespectSelectedFailure|TestDependencies_(RelayPreservesExecutionDiagnostics|MirrorFallbackPreservesExecutionDiagnostics)|TestRecovery_SwapDamagedRepositoryWithPreviousHasNoSideEffects|TestRecovery_NonCloneReaderFailureRemainsAmbiguous|TestSentry_OperationalFailureDiagnostics' -count=1 -v +if ($LASTEXITCODE -ne 0) { throw 'targeted tests failed' } +``` + +验证须确认出现目标 PASS 且无 `[no tests to run]`,随后执行仓库标准门。 +按用户授权提交独立分支并向上游 `main` 提 PR;本机完整门的失败及基线对照范围写入正文, +等待 Windows CI 复核。未授权合并、替换安装版或发布,任务保留进行中状态。 diff --git "a/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" "b/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" new file mode 100644 index 0000000..67589b9 --- /dev/null +++ "b/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" @@ -0,0 +1,100 @@ +# T12.11 锁、依赖与恢复失败诊断 + +## 范围 + +v0.1.29 的 Sentry 样本中,Mutex 故障没有操作或系统错误码;依赖同步有退出码 0 却标为非零退出; +更新恢复只有 `swap_shape/update_unknown`,无法重建目录形态。本任务补充诊断事实,不据此推断现场根因。 + +不修改错误码、退出码、retryable、remediation、协议 details、分组、重试、取消、清理或恢复判定; +不上传原始错误、输出、锁名称、目录路径,也不访问真实 Sentry。保持关闭/离线零网络。 + +## 所有权与数据流 + +锁包的结构化操作错误通过 `DiagnosticDetails()` 提供 `mutex`:`operation` 为现有操作 token, +`component` 为已知锁用途,`windowsError` 只从结构化 errno 提取。错误链和本地日志保持不变。 + +依赖包装错误通过诊断接口提供既有计数、耗时及底层 uv Error 的执行分类和安全 details。 +底层有明确分类时优先保留;否则仅在退出码非零时使用现有 `*_nonzero`,其余归 `*_failed`。 +公共协议 details 保持原值,Sentry 诊断覆盖其中失真的分类;不解析 stderr 或错误文本。 +中继不可用而回退镜像轮换时,最终错误同样附加最后一次执行的诊断,包含官方源与仅镜像两种策略。 +只读取 `lastResult/lastErr`,不从包含整轮历史的错误链提取执行事实,避免把最早一次失败混作最后一次。 +保留两种策略既有错误码、重试与取消/报告失败优先级。 + +恢复服务在现有分类调用旁保存局部快照,失败返回时经可解包包装携带 `directories`。 +固定三个角色为 `repo/update/previous`,初始状态 `not_observed`;已完成分类记录 +`missing/incomplete/valid`,分类失败为 `unknown`。仅有效身份记录已知版本和提交。 +同时提供事务目标版本和已知目标提交。快照表示检查时事实,不承诺是清理后的磁盘状态。 +遇到分类失败立即停止,后续角色保持 `not_observed`;不额外访问磁盘。 + +CLI 在既有终态观察位置遍历错误链,合并专用诊断副本;较外层提供的事实优先,普通协议 details +作为回退。自身带协议分类的诊断只在 code/stage 与最终选中错误一致时合并,避免清理错误优先时 +被次要执行错误的退出码覆盖。后端既有身份、终止请求与 primary/cleanup 规则保持。新增 `mutex/directories` +容器走已有深度、字段数、大小与凭据净化约束;CLI 和 BeforeSend 各净化一次。 + +## 验证 + +复用 Windows 锁 API 夹具产生真实结构化故障;复用依赖中继夹具注入成功退出但输出读取失败, +证明退出码、同步错误四元组与清理行为不变。扩展恢复歧义与读取失败用例,证明目录状态有用且 +仍无副作用、未观察目录不补查。通过 CLI recorder 与 Sentry fake transport 验证最终保留字段、 +未知/凭据字段丢弃和协议隔离。先红后绿,再运行 gofmt、vet、build、完整测试与 diff check。 + +## 本地验证记录 + +2026-11-11,基于上游 `c88a364e64690d24195671c228d3f87415fc6900`,工作树 +`.worktrees/failure-diagnostic-context`,分支 `fix/failure-diagnostic-context`。 +环境为 Windows amd64、Go 1.26.10,使用本机临时 GOCACHE。 + +锁生产 API 夹具、依赖中继同步、恢复歧义、CLI 嵌套诊断与 Sentry 出口用例均在原实现失败, +失败点分别为缺少诊断接口、分类仍为 nonzero、目录快照缺失或最终字段被丢弃;实现后全部通过。 +CLI 对照检查还复现了次要执行错误覆盖主清理错误退出码的问题,增加 code/stage 匹配后通过。 +真实 bootstrap 会话验证锁初始化错误经既有包装仍能上报操作、锁用途和 errno,协议没有新增这些字段。 + +以下定向命令退出 0,均出现目标 PASS,没有 `[no tests to run]`: + +```powershell +go test ./internal/lock ./internal/uv ./internal/gitrepo ./internal/cli ./internal/telemetry -run 'TestNewSet_FailureDiagnostics|TestRunOperation_PreservesNestedFailureDiagnostics|TestDependencies_RelayPreservesExecutionDiagnostics|TestRecovery_SwapDamagedRepositoryWithPreviousHasNoSideEffects|TestRecovery_NonCloneReaderFailureRemainsAmbiguous|TestSentry_OperationalFailureDiagnostics|TestBackendTelemetry_PreservesPrimaryAndCleanupFacts' -count=1 -v +go test ./internal/cli ./internal/uv ./internal/gitrepo ./internal/telemetry -run 'TestRunOperation_.*Diagnostics|TestDependencies_RelayPreservesExecutionDiagnostics|TestRecovery_SwapDamagedRepositoryWithPreviousHasNoSideEffects|TestRecovery_NonCloneReaderFailureRemainsAmbiguous|TestSentry_OperationalFailureDiagnostics|TestBackendTelemetry_PreservesPrimaryAndCleanupFacts' -count=1 -v +go test ./internal/cli -run '^TestBootstrap_MutexFailureReportsDiagnostics$' -count=1 -v +``` + +gofmt 检查输出为空,`go vet ./...`、`go build -buildvcs=false ./...`、`git diff --check` +及业务包不依赖 Sentry SDK 的扫描通过。没有改变并发和资源所有权,未新增 race 验收结论。 + +首次 `go test ./... -count=1` 退出 1:filesystem 的两个 TempDir RemoveAll 用例报目录非空, +lock 的 `TestSet_PhysicalRootAliasesShareMutex/SUBST` 报 W: 未就绪;其余包通过。 +在独立、未修改的上述上游基线运行完全相同命令,同样退出 1:CLI 的 +`TestM5Component_CutpointRerunMatrix` 出现 TempDir RemoveAll 目录非空,lock 的同一 SUBST 用例 +同样报 W: 未就绪。只能确认这两类本机失败在基线也存在,不能声称首轮 filesystem 用例在基线逐项复现。 + +P2 复审前再次运行 `go test ./... -count=1`,退出 1:只有同一 SUBST W: 用例失败,其余所有包 +通过,包括 CLI、uv、gitrepo、telemetry、filesystem 和 Windows backend E2E。 +临时目录清理失败在这一轮未再出现。基线对照工作树确认无改动后已移除,修复工作树保留供审阅。 + +未修改对应文件系统实现或夹具,未向生产 Sentry 发送验收事件。以上为提交前本地验收记录; +随后按用户授权提交独立分支并提 PR,不合并、发布或替换安装版。 + +## 镜像回退 P2 修复与 pstack 复审 + +`TestDependencies_MirrorFallbackPreservesExecutionDiagnostics` 经真实 `Sync` 入口令中继启动失败, +再执行逐源轮换,分别覆盖官方源回退失败和仅镜像耗尽。先前尝试返回非零退出,最后一次退出 0 +但输出读取失败;原实现两种策略均因诊断为空而失败。给最终包装错误附加 `lastResult/lastErr` +诊断后,两种策略通过,仍保留原错误码、协议 details、错误链、尝试次数及暂存目录清理。 + +以下红灯命令在修复前退出 1,修复后退出 0,两种子用例均出现 PASS: + +```powershell +go test ./internal/uv -run '^TestDependencies_MirrorFallbackPreservesExecutionDiagnostics$' -count=1 -v +``` + +pstack 再沿 `Sync → syncWithMirrors → mapRotationFailure → failureTelemetryDetails → SanitizeDiagnostics` +检查诊断归属与隐私出口,并复查取消、尝试报告失败和清理路径。最后一次执行事实不会从整轮历史中 +误取,原有优先级和协议输出未改变。本轮未发现新的可执行 P1/P2;定向诊断、取消、锁、恢复和 +Sentry fake transport 回归通过,gofmt、vet、build 和 diff check 通过。 + +P2 修复后完整 `go test ./... -count=1` 退出 1:CLI 的 +`TestM5Component_CutpointRerunMatrix/python.install`、filesystem 的 +`TestWindows_ClassicStateUnlinkCrashCutpoints/destination/after-anchor-verification`、gitrepo 的 +`TestRecovery_StagedSameVersionSwapWindows/after-activation` 报 TempDir RemoveAll 目录非空; +lock 的同一 SUBST W: 用例仍未就绪,其余包通过。串行定向重跑前三个父用例,filesystem 和 +gitrepo 通过,CLI 同一清理失败仍出现。已有未修改上游基线证据只支持同类 TempDir 清理失败与 +同一 SUBST 失败,不把本轮所有具体清理用例都视作已在基线逐项复现。完整门尚未全绿。 diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index 668f1e5..96f1177 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -1113,6 +1113,14 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 - 本地实现:`a4a253e`、`8c2bc5c`;吸收 `214577b`、评审修正 `8201381`;相关回归先红后绿,格式/vet/build/diff check 通过。 本机完整测试与原始基线都出现 TempDir 清理及 W: 未就绪,详见设计验收记录;待 PR CI 与合入。 +- [ ] **T12.11 锁、依赖与恢复失败诊断**(M) 🚧 2026-11-11 + - 依赖:T12.9、T12.10、T5.17;输入:v0.1.29 的 Mutex 空诊断、依赖同步分类与退出码矛盾、恢复目录形态缺失。 + - 设计:[失败诊断补齐](current/M12/设计-T12.11-锁依赖与恢复诊断.md)。 + - 内容:锁操作和系统错误码、依赖执行分类透传、已经观察的恢复目录状态,经既有诊断通道与双重净化上报。 + - 边界:不改变错误四元组、协议 details、恢复决策与操作顺序;不增加目录扫描,不上传锁名称、原始错误或完整路径。 + - 验收:生产入口红绿回归、CLI 与 Sentry fake transport、标准门及差异审查;按用户授权提独立 PR,完整门由 Windows CI 复核,不合并或发版。 + - 本地结果:三处诊断、主错误优先级与镜像回退 P2 回归先红后绿;gofmt/vet/build/diff check 通过,pstack 复审无新增 P1/P2。P2 修复后完整门仍有 TempDir 清理与 SUBST W: 失败,定向重跑及上游基线对照范围见设计记录;待 PR CI 与合入。 + ### - [ ] M13 dev 基线接入配套 > 2026-08-31 按决策 D12 与 [协议 v1 契约补充 增补 1](./契约补充-v1-增补1.md)(C6~C11)立项。 @@ -1677,6 +1685,10 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 ## 7. 变更记录 +2026-11-11:按用户要求登记 T12.11,补齐锁、依赖同步与恢复目录诊断。复用已经产生的结构化事实, +不改变协议错误选择、恢复安全边界或执行顺序,不访问生产 Sentry 验收;完成本地实现与隔离验证后, +按用户授权向上游提独立 PR。 + 2026-11-10:登记 T10.17,新增显式健康复用选项与 version 能力声明;桌面端复用既有版本查询识别支持,不增加查询进程。保留日常启动和显式重建,完成同条件计时与 pstack 审查。 2026-11-10:按 AUTO-MAS CNB #263 登记 T5.17,保留 uv 准备失败的结构化详情, diff --git "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" index 3d394cd..f142cb2 100644 --- "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" +++ "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -2044,6 +2044,13 @@ Umami;这些 provider 方案均已由 2026-08-12 的 D11 取消,仅保留历 环境状态读取遇到 status 字段校验失败时,沿用 `STATE_WRITE_FAILED` 与既有退出码, 附带固定 reason `invalid_lifecycle_state`。损坏文件不覆盖、不转成默认状态;磁盘内容和原始错误不上传。 +锁操作错误可经专用诊断通道提供 `mutex`,仅包含操作、锁用途与可提取的 `windowsError`, +不包含命名 Mutex 名称或 app root 身份。恢复失败可提供 `directories`,按固定角色 `repo/update/previous` +记录已经观察的 `missing/incomplete/valid/unknown/not_observed` 状态与已确认的版本、提交; +不得为补诊断继续扫描尚未检查的目录。依赖失败诊断保留底层结构化执行分类;非零退出才使用 +`*_nonzero` 分类,其他未知失败使用 `*_failed`。这些补充只进入普通失败诊断,不改变协议 details、 +错误四元组、恢复决策或操作顺序。 + Sentry SDK 可以为传输协议生成 envelope/header 和 `event_id`、`timestamp`、`platform` 等 技术字段;这不放宽应用数据白名单。应用可控内容仍仅限固定 message/exception、level、 release/environment 和上述标签/净化栈帧,DSN 认证信息与 SDK 技术字段不得复制到标签、 diff --git a/internal/cli/telemetry.go b/internal/cli/telemetry.go index aae7deb..52dbf07 100644 --- a/internal/cli/telemetry.go +++ b/internal/cli/telemetry.go @@ -91,6 +91,7 @@ func recordSessionTelemetry( stage = classifiedStage reason = telemetryReason(details) if telemetry.IsReportableFailure(code) && code != protocol.CodeInternalError { + details = failureTelemetryDetails(operationErr, code, stage, details) if command == "backend supervise" { details = backendTelemetryDetails(operationErr, details) } @@ -118,18 +119,46 @@ func recordSessionTelemetry( safeTelemetryClose(recorder) } -// backendTelemetryDetails 保留身份与被清理错误遮盖的分类,不改变 result 的错误优先级。 -func backendTelemetryDetails(err error, selected map[string]any) map[string]any { +// failureTelemetryDetails 合并专用诊断副本,外层事实优先,协议 details 只作回退。 +func failureTelemetryDetails(err error, code protocol.Code, stage protocol.Stage, selected map[string]any) map[string]any { details := make(map[string]any, len(selected)+4) - for key, value := range selected { - details[key] = value + var visit func(error) + visit = func(current error) { + if current == nil { + return + } + failure, classified := current.(operationError) + if diagnostic, ok := current.(interface{ DiagnosticDetails() map[string]any }); ok && + (!classified || failure.Code() == code && failure.Stage() == stage) { + for key, value := range diagnostic.DiagnosticDetails() { + if _, exists := details[key]; !exists { + details[key] = value + } + } + } + if joined, ok := current.(interface{ Unwrap() []error }); ok { + for _, child := range joined.Unwrap() { + visit(child) + } + } else if wrapped, ok := current.(interface{ Unwrap() error }); ok { + visit(wrapped.Unwrap()) + } } - var diagnostic interface{ DiagnosticDetails() map[string]any } - if errors.As(err, &diagnostic) { - for key, value := range diagnostic.DiagnosticDetails() { + visit(err) + for key, value := range selected { + if _, exists := details[key]; !exists { details[key] = value } } + return details +} + +// backendTelemetryDetails 保留被清理错误遮盖的分类,不改变 result 的错误优先级。 +func backendTelemetryDetails(err error, selected map[string]any) map[string]any { + details := make(map[string]any, len(selected)+1) + for key, value := range selected { + details[key] = value + } var primary, cleanup operationError var visit func(error) visit = func(current error) { diff --git a/internal/cli/telemetry_test.go b/internal/cli/telemetry_test.go index e2eae15..ca3e6c8 100644 --- a/internal/cli/telemetry_test.go +++ b/internal/cli/telemetry_test.go @@ -12,6 +12,7 @@ import ( "strings" "sync" "sync/atomic" + "syscall" "testing" "time" @@ -19,6 +20,7 @@ import ( "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/config" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/doctor" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/gitrepo" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/lock" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/state" @@ -321,6 +323,103 @@ type telemetryDiagnosticError struct { func (e telemetryDiagnosticError) Unwrap() error { return e.error } func (e telemetryDiagnosticError) DiagnosticDetails() map[string]any { return e.details } +func TestRunOperation_PreservesNestedFailureDiagnostics(t *testing.T) { + legacy := map[string]any{"failureKind": "dependency_sync_nonzero", "exitCode": 0} + primary := doctor.NewError(protocol.CodeDependencySyncFailed, protocol.StageDependenciesSync, "同步失败", legacy, errors.New("private cause")) + inner := telemetryDiagnosticError{error: primary, details: map[string]any{ + "failureKind": "output_read_failed", "version": "v5.8.0", "password": "private-secret", + "mutex": map[string]any{"operation": "create-mutex", "component": "mutation", "windowsError": 5}, + "directories": []map[string]any{{"component": "repo", "state": "missing"}}, + }} + failure := telemetryDiagnosticError{error: errors.Join(inner), details: map[string]any{"version": "v5.8.1"}} + recorder := &telemetryRecorderSpy{} + var stdout bytes.Buffer + exitCode := executeWithTelemetry(t, []string{"--output", "ndjson", "doctor"}, recorder, &stdout, + WithDoctorFactory(func(*config.Layout, doctor.Probes) (doctorService, error) { + return fakeDoctorService{run: func(context.Context, *protocol.Emitter) (doctor.Report, error) { + return doctor.Report{}, failure + }}, nil + }), + ) + if exitCode != protocol.ExitCodeEnvironmentFailure { + t.Fatalf("exit code = %d, want %d", exitCode, protocol.ExitCodeEnvironmentFailure) + } + observations, _, _ := recorder.snapshot() + if len(observations) != 1 { + t.Fatalf("observations = %#v, want one", observations) + } + details := observations[0].DiagnosticDetails + if details["failureKind"] != "output_read_failed" || details["version"] != "v5.8.1" || details["exitCode"] != 0 || details["mutex"] == nil || details["directories"] == nil || details["password"] != nil { + t.Fatalf("diagnostics = %#v, want nested facts with outer precedence and sanitization", details) + } + events := parseNDJSON(t, stdout.String()) + if got := events[len(events)-1].object["details"]; !reflect.DeepEqual(got, map[string]any{"failureKind": "dependency_sync_nonzero", "exitCode": float64(0)}) { + t.Fatalf("protocol details = %#v, want original facts", got) + } + if !reflect.DeepEqual(legacy, map[string]any{"failureKind": "dependency_sync_nonzero", "exitCode": 0}) { + t.Fatalf("source details mutated: %#v", legacy) + } +} + +func TestBootstrap_MutexFailureReportsDiagnostics(t *testing.T) { + failure := &lock.OperationError{Operation: "create-mutex", Kind: lock.KindMutation, Name: "private-mutex-name", Cause: syscall.Errno(5)} + recorder := &telemetryRecorderSpy{} + var stdout bytes.Buffer + exitCode := executeWithTelemetry(t, []string{"--output", "ndjson", "bootstrap", "--version", "v5.8.0"}, recorder, &stdout, + WithMutationCoordinatorFactory(func(context.Context, *config.Layout) (gitrepo.MutationCoordinator, error) { + return nil, failure + }), + ) + if exitCode != protocol.ExitCodeOperationConflict { + t.Fatalf("exit code = %d, want %d", exitCode, protocol.ExitCodeOperationConflict) + } + observations, _, _ := recorder.snapshot() + if len(observations) != 1 || observations[0].Code != string(protocol.CodeMutexOperationFailed) { + t.Fatalf("observations = %#v, want mutex failure", observations) + } + want := map[string]any{"mutex": map[string]any{"component": "mutation", "operation": "create-mutex", "windowsError": uint64(5)}} + if got := observations[0].DiagnosticDetails; !reflect.DeepEqual(got, want) { + t.Fatalf("diagnostics = %#v, want %#v", got, want) + } + if strings.Contains(stdout.String(), `"mutex"`) { + t.Fatalf("protocol gained telemetry-only facts: %s", stdout.String()) + } +} + +type telemetryExecutionError struct { + *commandError + diagnostics map[string]any +} + +func (e telemetryExecutionError) DiagnosticDetails() map[string]any { return e.diagnostics } + +func TestRunOperation_DiagnosticsRespectSelectedFailure(t *testing.T) { + cleanup := &commandError{code: protocol.CodeStateWriteFailed, stage: protocol.StageWorkspaceCleanup, message: "清理失败", details: map[string]any{"exitCode": 42}} + execution := telemetryExecutionError{ + commandError: &commandError{code: protocol.CodeDependencySyncFailed, stage: protocol.StageDependenciesSync, message: "执行失败"}, + diagnostics: map[string]any{"failureKind": "output_read_failed", "exitCode": 0}, + } + recorder := &telemetryRecorderSpy{} + var stdout bytes.Buffer + exitCode := executeWithTelemetry(t, []string{"--output", "ndjson", "doctor"}, recorder, &stdout, + WithDoctorFactory(func(*config.Layout, doctor.Probes) (doctorService, error) { + return fakeDoctorService{run: func(context.Context, *protocol.Emitter) (doctor.Report, error) { + return doctor.Report{}, errors.Join(cleanup, execution) + }}, nil + }), + ) + if exitCode != protocol.ExitCodeOperationConflict { + t.Fatalf("exit code = %d, want %d", exitCode, protocol.ExitCodeOperationConflict) + } + observations, _, _ := recorder.snapshot() + if len(observations) != 1 || observations[0].Code != string(protocol.CodeStateWriteFailed) { + t.Fatalf("observations = %#v, want selected cleanup failure", observations) + } + if got := observations[0].DiagnosticDetails; !reflect.DeepEqual(got, map[string]any{"exitCode": 42}) { + t.Fatalf("diagnostics = %#v, want selected failure facts", got) + } +} + func TestBackendTelemetry_PreservesPrimaryAndCleanupFacts(t *testing.T) { primary := &commandError{code: protocol.CodeBackendHealthTimeout, stage: protocol.StageBackendHealth, message: "timeout", committed: true} cleanup := &commandError{code: protocol.CodeStateWriteFailed, stage: protocol.StageBackendCleanup, message: "cleanup", committed: true, details: map[string]any{"exitCode": 1}} diff --git a/internal/gitrepo/recovery.go b/internal/gitrepo/recovery.go index 50bf8f5..c87d67c 100644 --- a/internal/gitrepo/recovery.go +++ b/internal/gitrepo/recovery.go @@ -142,7 +142,7 @@ func newRecoveryWithDependencies( func (r *Recovery) Recover( ctx context.Context, request RecoveryRequest, -) (RecoveryResult, error) { +) (result RecoveryResult, returnErr error) { if ctx == nil || r == nil || r.layout == nil || nilDependency(r.operator) || nilDependency(r.store) || nilDependency(r.reader) { return RecoveryResult{}, recoveryInternalError(errInvalidRecovery) @@ -182,10 +182,17 @@ func (r *Recovery) Recover( if err != nil { return RecoveryResult{}, recoveryInternalError(err) } + diagnostics := recoveryDiagnostics{targetVersion: transaction.state.TargetVersion, targetCommit: transaction.state.TargetCommit} + defer func() { + if returnErr != nil { + returnErr = &recoveryDiagnosticError{cause: returnErr, snapshot: diagnostics} + } + }() if transaction.state.Command == "workspace stage" && (transaction.state.Stage == protocol.StageWorkspaceClone || transaction.state.Stage == protocol.StageWorkspaceVerify) { update, err := r.classifyPath(ctx, paths.update, recoveryPathPolicy{ allowUnreadable: transaction.state.Stage == protocol.StageWorkspaceClone, }) + diagnostics.update = describeRecoveryDirectory(update, err) if err != nil { return RecoveryResult{}, r.classificationError(ctx, transaction.state.Stage, "staged_update_unknown", err) } @@ -208,10 +215,12 @@ func (r *Recovery) Recover( allowUnreadable: allowDamagedRepository, allowInvalidIdentity: transaction.state.Stage == protocol.StageWorkspaceSwap, }) + diagnostics.repository = describeRecoveryDirectory(repository, err) if err != nil { return RecoveryResult{}, r.classificationError(ctx, transaction.state.Stage, "repository_unknown", err) } update, err := r.classifyPath(ctx, paths.update, recoveryPathPolicy{allowUnreadable: allowIncompleteUpdate}) + diagnostics.update = describeRecoveryDirectory(update, err) if err != nil { return RecoveryResult{}, r.classificationError(ctx, transaction.state.Stage, "update_unknown", err) } @@ -220,6 +229,7 @@ func (r *Recovery) Recover( allowUnreadable: allowIncompletePrevious, allowInvalidIdentity: allowIncompletePrevious, }) + diagnostics.previous = describeRecoveryDirectory(previous, err) if err != nil { return RecoveryResult{}, r.classificationError(ctx, transaction.state.Stage, "previous_unknown", err) } diff --git a/internal/gitrepo/recovery_diagnostics.go b/internal/gitrepo/recovery_diagnostics.go new file mode 100644 index 0000000..96e3d52 --- /dev/null +++ b/internal/gitrepo/recovery_diagnostics.go @@ -0,0 +1,68 @@ +package gitrepo + +// recoveryDiagnosticError 保存恢复判定时已经观察的目录事实,不参与协议错误选择。 +type recoveryDiagnosticError struct { + cause error + snapshot recoveryDiagnostics +} + +type recoveryDiagnostics struct { + targetVersion string + targetCommit string + repository recoveryDirectoryDiagnostic + update recoveryDirectoryDiagnostic + previous recoveryDirectoryDiagnostic +} + +type recoveryDirectoryDiagnostic struct { + state string + identity repositoryIdentity +} + +func (e *recoveryDiagnosticError) Error() string { return e.cause.Error() } +func (e *recoveryDiagnosticError) Unwrap() error { return e.cause } + +// DiagnosticDetails 返回检查时的快照,不访问磁盘或暴露目录路径。 +func (e *recoveryDiagnosticError) DiagnosticDetails() map[string]any { + details := map[string]any{"directories": []map[string]any{ + e.snapshot.repository.details("repo"), + e.snapshot.update.details("update"), + e.snapshot.previous.details("previous"), + }} + if e.snapshot.targetVersion != "" { + details["expectedVersion"] = e.snapshot.targetVersion + } + if e.snapshot.targetCommit != "" { + details["commit"] = e.snapshot.targetCommit + } + return details +} + +func (p recoveryDirectoryDiagnostic) details(role string) map[string]any { + status := p.state + if status == "" { + status = "not_observed" + } + details := map[string]any{"component": role, "state": status} + if status == "valid" { + details["version"] = p.identity.version + details["commit"] = p.identity.commit + } + return details +} + +func describeRecoveryDirectory(path recoveryPath, err error) recoveryDirectoryDiagnostic { + if err != nil { + return recoveryDirectoryDiagnostic{state: "unknown"} + } + switch path.kind { + case recoveryPathMissing: + return recoveryDirectoryDiagnostic{state: "missing"} + case recoveryPathIncomplete: + return recoveryDirectoryDiagnostic{state: "incomplete"} + case recoveryPathValid: + return recoveryDirectoryDiagnostic{state: "valid", identity: path.identity} + default: + return recoveryDirectoryDiagnostic{state: "unknown"} + } +} diff --git a/internal/gitrepo/recovery_test.go b/internal/gitrepo/recovery_test.go index f4aa80a..9c0178e 100644 --- a/internal/gitrepo/recovery_test.go +++ b/internal/gitrepo/recovery_test.go @@ -519,6 +519,7 @@ func TestRecovery_SwapDamagedRepositoryWithPreviousHasNoSideEffects(t *testing.T result, err := recovery.Recover(t.Context(), RecoveryRequest{LogPath: recoveryLogPath(t, layout)}) assertGitrepoCode(t, err, protocol.CodeUpdateStateAmbiguous) + assertRecoveryDirectoryStates(t, err, map[string]string{"repo": "incomplete", "update": "valid", "previous": "valid"}) if result.MutationApplied || result.EnvironmentWritten || result.TransactionRemoved || store.transactionRemoved { t.Fatalf("Recover() result = %#v, want no side effects", result) } @@ -1323,6 +1324,7 @@ func TestRecovery_NonCloneReaderFailureRemainsAmbiguous(t *testing.T) { result, err := recovery.Recover(t.Context(), RecoveryRequest{LogPath: recoveryLogPath(t, layout)}) assertGitrepoCode(t, err, protocol.CodeUpdateStateAmbiguous) + assertRecoveryDirectoryStates(t, err, map[string]string{"repo": "missing", "update": "unknown", "previous": "not_observed"}) var operationErr *Error if !errors.As(err, &operationErr) { t.Fatalf("error = %v, want *Error", err) @@ -1345,6 +1347,28 @@ type fakeRecoveryOperator struct { remove func(ctx context.Context, request filesystem.DeleteRequest) (filesystem.DeleteResult, error) } +func assertRecoveryDirectoryStates(t *testing.T, err error, want map[string]string) { + t.Helper() + var diagnostic interface{ DiagnosticDetails() map[string]any } + if !errors.As(err, &diagnostic) { + t.Fatalf("error = %v, want recovery directory diagnostics", err) + } + details := diagnostic.DiagnosticDetails() + directories, ok := details["directories"].([]map[string]any) + if !ok || len(directories) != len(want) { + t.Fatalf("directories = %#v, want three fixed roles", details["directories"]) + } + for _, directory := range directories { + role, _ := directory["component"].(string) + if directory["state"] != want[role] { + t.Errorf("directory = %#v, want state %q", directory, want[role]) + } + if directory["state"] == "valid" && (directory["version"] == "" || directory["commit"] == "") { + t.Errorf("directory = %#v, want verified identity", directory) + } + } +} + func (o *fakeRecoveryOperator) AtomicRename(ctx context.Context, request filesystem.RenameRequest) (filesystem.RenameResult, error) { return o.rename(ctx, request) } diff --git a/internal/lock/diagnostics_windows_test.go b/internal/lock/diagnostics_windows_test.go new file mode 100644 index 0000000..c482ef4 --- /dev/null +++ b/internal/lock/diagnostics_windows_test.go @@ -0,0 +1,41 @@ +//go:build windows + +package lock + +import ( + "errors" + "path/filepath" + "reflect" + "testing" + + "golang.org/x/sys/windows" + + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/config" +) + +func TestNewSet_FailureDiagnostics(t *testing.T) { + root := t.TempDir() + layout, err := config.NewLayout(root, filepath.Dir(root)) + if err != nil { + t.Fatal(err) + } + api := newTestWindowsAPI() + api.createMutexErr[1] = windows.ERROR_ACCESS_DENIED + set, err := newSet(t.Context(), layout, api) + if set != nil { + _ = set.Close() + } + if !errors.Is(err, windows.ERROR_ACCESS_DENIED) { + t.Fatalf("newSet() error = %v, want access denied", err) + } + var diagnostic interface{ DiagnosticDetails() map[string]any } + if !errors.As(err, &diagnostic) { + t.Fatalf("error = %v, want structured diagnostic facts", err) + } + want := map[string]any{"mutex": map[string]any{ + "operation": "create-mutex", "component": "backend", "windowsError": uint64(5), + }} + if got := diagnostic.DiagnosticDetails(); !reflect.DeepEqual(got, want) { + t.Fatalf("diagnostics = %#v, want %#v", got, want) + } +} diff --git a/internal/lock/errors.go b/internal/lock/errors.go index ba807ac..49eb651 100644 --- a/internal/lock/errors.go +++ b/internal/lock/errors.go @@ -3,6 +3,7 @@ package lock import ( "errors" "fmt" + "syscall" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" ) @@ -66,6 +67,19 @@ func (e *OperationError) Code() protocol.Code { return protocol.CodeMutexOperationFailed } +// DiagnosticDetails 返回锁操作的安全事实,不包含命名 Mutex 或根目录身份。 +func (e *OperationError) DiagnosticDetails() map[string]any { + operation := map[string]any{"operation": e.Operation} + if e.Kind.Valid() { + operation["component"] = e.Kind.String() + } + var errno syscall.Errno + if errors.As(e.Cause, &errno) { + operation["windowsError"] = uint64(errno) + } + return map[string]any{"mutex": operation} +} + // PoisonedError 保留首次使 Set 中毒的 ReleaseMutex 故障。 type PoisonedError struct { Cause error diff --git a/internal/telemetry/diagnostics.go b/internal/telemetry/diagnostics.go index c4d3003..d59da3b 100644 --- a/internal/telemetry/diagnostics.go +++ b/internal/telemetry/diagnostics.go @@ -75,6 +75,7 @@ var diagnosticFieldPolicies = map[string]diagnosticFieldKind{ "attempts": diagnosticContainer, "failures": diagnosticContainer, "items": diagnosticContainer, "summary": diagnosticContainer, "relay": diagnosticContainer, "details": diagnosticContainer, + "mutex": diagnosticContainer, "directories": diagnosticContainer, "checks": diagnosticContainer, "orphans": diagnosticContainer, "bySource": diagnosticDynamicNumberMap, } diff --git a/internal/telemetry/sentry_test.go b/internal/telemetry/sentry_test.go index f3785ba..d515c23 100644 --- a/internal/telemetry/sentry_test.go +++ b/internal/telemetry/sentry_test.go @@ -195,6 +195,40 @@ func TestSentry_RemoteFailureDiagnostics(t *testing.T) { } } +func TestSentry_OperationalFailureDiagnostics(t *testing.T) { + provider, transport := newSentryTestProvider(t) + observation := validSentryObservation(false) + observation.Command = "bootstrap" + observation.Stage = string(protocol.StageBootstrap) + observation.Code = string(protocol.CodeMutexOperationFailed) + observation.DiagnosticDetails = map[string]any{ + "mutex": map[string]any{"component": "mutation", "operation": "create-mutex", "windowsError": 5, "name": "private-lock-name", "error": "private cause"}, + "directories": []map[string]any{ + {"component": "repo", "state": "valid", "version": "v5.8.0", "commit": strings.Repeat("a", 40), "password": "private-secret"}, + {"component": "update", "state": "unknown"}, + {"component": "previous", "state": "not_observed"}, + }, + "failureKind": "output_read_failed", "exitCode": 0, + } + provider.captureInternal(observation) + _, events, _, _ := transport.snapshot() + if len(events) != 1 { + t.Fatalf("events = %#v, want one", events) + } + encoded, err := json.Marshal(events[0].Contexts["diagnostics"]) + if err != nil { + t.Fatal(err) + } + for _, required := range []string{`"mutex"`, `"windowsError":5`, `"directories"`, `"component":"previous"`, `"state":"not_observed"`, `"failureKind":"output_read_failed"`, `"exitCode":0`} { + if !bytes.Contains(encoded, []byte(required)) { + t.Errorf("diagnostics = %s, want %s", encoded, required) + } + } + if bytes.Contains(encoded, []byte("private")) { + t.Fatalf("private diagnostics escaped sanitization: %s", encoded) + } +} + func TestSentry_BeforeSendRejectsUnreportableFailure(t *testing.T) { provider, transport := newSentryTestProvider(t) observation := validSentryObservation(false) diff --git a/internal/uv/dependencies.go b/internal/uv/dependencies.go index deaa6f5..e18ea24 100644 --- a/internal/uv/dependencies.go +++ b/internal/uv/dependencies.go @@ -414,26 +414,45 @@ func dependencySyncError(result UVResult, cause error) error { if cause == nil { cause = errors.New("uv sync exited with a non-zero status") } - return newError( + failure := newError( protocol.CodeDependencySyncFailed, protocol.StageDependenciesSync, "Python 依赖同步失败", uvFailureDetails(result, "dependency_sync_nonzero"), cause, ) + failure.diagnostics = dependencyExecutionDiagnostics(result, cause, "dependency_sync") + return failure } func dependencyCheckError(result UVResult, cause error) error { if cause == nil { cause = errors.New("uv sync check exited with a non-zero status") } - return newError( + failure := newError( protocol.CodeDependencySyncFailed, protocol.StageDependenciesCheck, "主项目依赖环境未同步", uvFailureDetails(result, "dependency_check_nonzero"), cause, ) + failure.diagnostics = dependencyExecutionDiagnostics(result, cause, "dependency_check") + return failure +} + +func dependencyExecutionDiagnostics(result UVResult, cause error, phase string) map[string]any { + kind := phase + "_failed" + if result.ExitCode != 0 { + kind = phase + "_nonzero" + } + details := uvFailureDetails(result, kind) + var execution *Error + if errors.As(cause, &execution) { + for key, value := range execution.Details() { + details[key] = value + } + } + return details } func uvFailureDetails(result UVResult, failureKind string) map[string]any { diff --git a/internal/uv/dependencies_mirror.go b/internal/uv/dependencies_mirror.go index 57ce157..913d05b 100644 --- a/internal/uv/dependencies_mirror.go +++ b/internal/uv/dependencies_mirror.go @@ -377,23 +377,24 @@ func (s *DependenciesService) mapRotationFailure( } var rotationError *mirror.RotationError if errors.As(rotationErr, &rotationError) && rotationError.Code() == protocol.CodeMirrorExhausted { + code := protocol.CodeMirrorExhausted + message := "所有镜像源均不可用" + cause := rotationErr if planHasOfficialSource(plan) { // 回退那次也失败了,语义与今天的单次 --locked 失败完全一致。 - return newError( - protocol.CodeDependencySyncFailed, - protocol.StageDependenciesSync, - "Python 依赖同步失败", - details, - errors.Join(state.lastErr, rotationErr), - ) + code = protocol.CodeDependencySyncFailed + message = "Python 依赖同步失败" + cause = errors.Join(state.lastErr, rotationErr) } - return newError( - protocol.CodeMirrorExhausted, + failure := newError( + code, protocol.StageDependenciesSync, - "所有镜像源均不可用", + message, details, - rotationErr, + cause, ) + failure.diagnostics = dependencyExecutionDiagnostics(state.lastResult, state.lastErr, "dependency_sync") + return failure } return rotationErr } diff --git a/internal/uv/dependencies_relay_test.go b/internal/uv/dependencies_relay_test.go index ebf7e59..29f10b9 100644 --- a/internal/uv/dependencies_relay_test.go +++ b/internal/uv/dependencies_relay_test.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" @@ -234,6 +235,110 @@ func TestDependencies_RelayFailuresMapToMirrorExhausted(t *testing.T) { } } +func TestDependencies_RelayPreservesExecutionDiagnostics(t *testing.T) { + for _, test := range []struct { + name string + cause error + exitCode int + wantKind string + }{ + {name: "read failure after successful exit", cause: newError(protocol.CodeUVExecFailed, protocol.StageDependenciesSync, "读取失败", map[string]any{"failureKind": "output_read_failed", "operation": "read", "windowsError": uint64(6)}, errors.New("private cause")), wantKind: "output_read_failed"}, + {name: "unknown failure after successful exit", cause: errors.New("private cause"), wantKind: "dependency_sync_failed"}, + {name: "nonzero exit", cause: errors.New("private cause"), exitCode: 2, wantKind: "dependency_sync_nonzero"}, + } { + t.Run(test.name, func(t *testing.T) { + session := &fakeRelaySession{baseURL: "http://127.0.0.1:39171"} + fixture := newRelayFixture(t, &fakeRelayFactory{session: session}) + fixture.runner.responses = []fakeRunnerResponse{{}, { + result: UVResult{ExitCode: test.exitCode, Stderr: "private stderr", Duration: 125 * time.Millisecond}, err: test.cause, + }} + _, err := fixture.service.Sync(t.Context(), fixture.request(t, mirror.PolicySpec{})) + assertPythonCode(t, err, protocol.CodeDependencySyncFailed) + if !errors.Is(err, test.cause) { + t.Fatalf("Sync() error = %v, want original cause", err) + } + var diagnostic interface{ DiagnosticDetails() map[string]any } + if !errors.As(err, &diagnostic) { + t.Fatalf("error = %v, want execution diagnostics", err) + } + details := diagnostic.DiagnosticDetails() + if details["failureKind"] != test.wantKind || details["exitCode"] != test.exitCode || details["durationMs"] != int64(125) { + t.Fatalf("diagnostics = %#v, want %s and original execution facts", details, test.wantKind) + } + if test.wantKind == "output_read_failed" && (details["operation"] != "read" || details["windowsError"] != uint64(6)) { + t.Fatalf("diagnostics = %#v, want structured low-level facts", details) + } + var failure *Error + if !errors.As(err, &failure) || failure.Details()["failureKind"] != "dependency_sync_nonzero" { + t.Fatalf("protocol details changed: %v", err) + } + if session.closed != 1 { + t.Fatalf("relay closes = %d, want 1", session.closed) + } + fixture.assertNoStagingLeftovers(t) + }) + } +} + +func TestDependencies_MirrorFallbackPreservesExecutionDiagnostics(t *testing.T) { + for _, test := range []struct { + name string + mirrorOnly bool + wantCode protocol.Code + }{ + {name: "official fallback", wantCode: protocol.CodeDependencySyncFailed}, + {name: "mirror only", mirrorOnly: true, wantCode: protocol.CodeMirrorExhausted}, + } { + t.Run(test.name, func(t *testing.T) { + factory := &fakeRelayFactory{err: errors.New("relay unavailable")} + fixture := newRelayFixture(t, factory) + request := fixture.request(t, mirror.PolicySpec{MirrorOnly: test.mirrorOnly}) + plan, err := fixture.service.buildPackageIndexPlan(t.Context(), request.MirrorPolicy) + if err != nil { + t.Fatal(err) + } + sources := plan.Sources() + lastCause := newError(protocol.CodeUVExecFailed, protocol.StageDependenciesSync, "读取失败", map[string]any{ + "failureKind": "output_read_failed", "operation": "read", "windowsError": uint64(6), + }, errors.New("private cause")) + fixture.runner.responses = []fakeRunnerResponse{{}} + for index := range sources { + response := fakeRunnerResponse{result: UVResult{ExitCode: 2}, err: newError( + protocol.CodeUVExecFailed, protocol.StageDependenciesSync, "执行失败", + map[string]any{"failureKind": "nonzero_exit", "exitCode": 2}, errors.New("earlier failure"), + )} + if index == len(sources)-1 { + response = fakeRunnerResponse{result: UVResult{ExitCode: 0, Stderr: "private stderr", Duration: 125 * time.Millisecond}, err: lastCause} + } + fixture.runner.responses = append(fixture.runner.responses, response) + } + + _, err = fixture.service.Sync(t.Context(), request) + assertPythonCode(t, err, test.wantCode) + if !errors.Is(err, lastCause) { + t.Fatalf("Sync() error = %v, want last execution cause", err) + } + var failure *Error + if !errors.As(err, &failure) { + t.Fatalf("Sync() error = %v, want structured error", err) + } + wantProtocol := map[string]any{"sourceKind": "package-index", "attemptCount": len(sources), "exitCode": 0} + if got := failure.Details(); !reflect.DeepEqual(got, wantProtocol) { + t.Fatalf("protocol details = %#v, want %#v", got, wantProtocol) + } + diagnostics := failure.DiagnosticDetails() + if diagnostics["failureKind"] != "output_read_failed" || diagnostics["exitCode"] != 0 || diagnostics["durationMs"] != int64(125) || diagnostics["windowsError"] != uint64(6) { + t.Fatalf("diagnostics = %#v, want last execution facts", diagnostics) + } + if factory.starts != 1 || len(fixture.runner.calls) != len(sources)+1 || len(fixture.attempts) != len(sources) { + t.Fatalf("relay starts / calls / attempts = %d / %d / %d, want one startup and one execution per source", factory.starts, len(fixture.runner.calls), len(fixture.attempts)) + } + fixture.assertRepositoryUntouched(t) + fixture.assertNoStagingLeftovers(t) + }) + } +} + // TestDependencies_MirrorOnlySkipsDirectFallback 锁定 --mirror-only 下中继上游不含官方源,且中继失败不直连。 func TestDependencies_MirrorOnlySkipsDirectFallback(t *testing.T) { session := &fakeRelaySession{baseURL: "http://127.0.0.1:39170", summary: relay.Summary{Failures: []relay.FileFailure{{Item: "x.whl"}}}} diff --git a/internal/uv/errors.go b/internal/uv/errors.go index e29255c..9b4eabe 100644 --- a/internal/uv/errors.go +++ b/internal/uv/errors.go @@ -4,12 +4,13 @@ import "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" // Error 是 uv、Python 与依赖操作向 CLI 暴露的结构化错误。 type Error struct { - code protocol.Code - stage protocol.Stage - message string - details map[string]any - cause error - committed bool + code protocol.Code + stage protocol.Stage + message string + details map[string]any + diagnostics map[string]any + cause error + committed bool } // Error 返回不包含外部命令原文的稳定诊断文本。 @@ -60,6 +61,11 @@ func (e *Error) Details() map[string]any { return cloneDetails(e.details) } +// DiagnosticDetails 返回执行诊断副本,不改变协议详情。 +func (e *Error) DiagnosticDetails() map[string]any { + return cloneDetails(e.diagnostics) +} + // Committed 报告错误是否发生在不可逆发布之后。 func (e *Error) Committed() bool { return e != nil && e.committed From 0af00a35a21bfcf3d846b715c1d41fa69d0feef6 Mon Sep 17 00:00:00 2001 From: Ji Mao Cai <44921326+jinghero@users.noreply.github.com> Date: Thu, 12 Nov 2026 09:16:02 +0800 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E8=AF=8A=E6=96=AD=E4=B8=8E=E6=97=A9=E6=9C=9F?= =?UTF-8?q?=E6=97=A5=E5=BF=97=20(T12.11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...42\345\244\215\350\257\212\346\226\255.md" | 7 + ...42\345\244\215\350\257\212\346\226\255.md" | 23 +++ ...73\345\212\241\346\213\206\345\210\206.md" | 4 + ...66\346\236\204\350\256\276\350\256\241.md" | 6 + internal/cli/cli.go | 2 +- internal/cli/session.go | 17 +- internal/cli/state_diagnostics.go | 51 +++++ internal/cli/state_diagnostics_test.go | 189 ++++++++++++++++++ internal/cli/telemetry.go | 11 +- internal/state/diagnostics.go | 86 ++++++++ internal/state/diagnostics_test.go | 79 ++++++++ internal/telemetry/diagnostics.go | 3 + internal/telemetry/sentry_test.go | 29 +++ 13 files changed, 498 insertions(+), 9 deletions(-) create mode 100644 internal/cli/state_diagnostics.go create mode 100644 internal/cli/state_diagnostics_test.go create mode 100644 internal/state/diagnostics.go create mode 100644 internal/state/diagnostics_test.go diff --git "a/doc/current/M12/\350\256\241\345\210\222-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" "b/doc/current/M12/\350\256\241\345\210\222-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" index dc3c139..02db8e4 100644 --- "a/doc/current/M12/\350\256\241\345\210\222-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" +++ "b/doc/current/M12/\350\256\241\345\210\222-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" @@ -9,6 +9,13 @@ `TestRecovery_NonCloneReaderFailureRemainsAmbiguous`,先证明缺少快照再补局部快照。 4. 最终出口:`TestSentry_OperationalFailureDiagnostics` 验证两种新增容器、执行分类与隐私净化; 检查协议未增加诊断字段、离线/关闭路径保持。完成后回写设计与任务证据。 +5. 状态失败补齐:先增加 `TestWriteError_DiagnosticDetails`、`TestReadError_DiagnosticDetails`、 + `TestSentry_StateFailureDiagnostics`、`TestBootstrap_EarlyStateFailureIsRecorded`、 + `TestRunOperation_StateDiagnosticsRespectSelectedFailure`、`TestRecordOperationFailure_StateErrorChain` + 与 `TestRunOperation_StateFailureLogFallback`,确认现有实现缺少字段或文件记录,再实现最小改动。 + 文件为 `internal/state/diagnostics{,_test}.go`、`internal/cli/{cli,session,telemetry,state_diagnostics}.go` 及对应测试、 + `internal/telemetry/{diagnostics.go,sentry_test.go}`。提交前检查暂存清单只含这些文件与本任务文档。 + 定向回归通过后执行完整标准门与审查,追加到现有 PR #35,不合并、发布或替换安装版。 ```powershell $env:GOCACHE = Join-Path $env:TEMP 'auto-mas-runtime-verify' diff --git "a/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" "b/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" index 67589b9..2a1cb13 100644 --- "a/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" +++ "b/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" @@ -31,6 +31,29 @@ CLI 在既有终态观察位置遍历错误链,合并专用诊断副本;较 被次要执行错误的退出码覆盖。后端既有身份、终止请求与 primary/cleanup 规则保持。新增 `mutex/directories` 容器走已有深度、字段数、大小与凭据净化约束;CLI 和 BeforeSend 各净化一次。 +## 2026-11-12 状态失败诊断增补 + +用户提供的 exFAT 日志记录了 `mutation` 写入的 `finalize` 失败,但只保留外层摘要。 +五次失败只有首次留下 Runtime error 记录,后续命令可能在恢复检查、日志器建立之前返回。 +本次沿用 T12.11 补齐诊断,不据此判断 exFAT 故障根因,不改变状态写入与恢复流程。 + +`state.WriteError` 经 `DiagnosticDetails()` 提供 `persistence`,包含已知逻辑文件角色、 +写入阶段、`mutationApplied` 和 `recoveryRequired`。主因与独立清理错误分别放在 +`primary` 和 `cleanup` 中,只提取文件操作 token、结构化系统错误号与已知错误类别。 +存在错误但没有可提取分类时记录 `failureKind=unknown`,不把未知清理错误当作没有错误。 +`state.ReadError` 同样提供逻辑文件角色和读取错误事实。未知文件名不回显,原错误链保持不变。 +CLI 合并诊断时校验仅带 Code 的嵌套错误也与最终错误码一致,避免次要状态错误污染主错误。 +新增容器和布尔字段经过既有双重净化;路径、原始错误和文件内容不进入 Sentry。 + +本地 State 失败记录额外保存有界错误链与结构化诊断,保留主因和清理错误。 +最多遍历 32 个错误,每项文本最多 4096 字节,截断记录单独标识。 +即使命令尚未打开日志器,会话也为已经分类的 `STATE_WRITE_FAILED` 尝试建立受管终态日志。 +工厂沿用路径安全校验和现有清理预算;打开或写入失败时将有界错误链写 stderr。 +其他早期失败仍不创建日志目录。已有日志、取消与错误优先级保持原语义。 + +新增回归覆盖提交后 finalize、恢复读取失败、独立清理错误、完整错误链、早期 bootstrap +失败的真实文件日志、日志不可写降级、协议 details 隔离与 Sentry fake transport。 + ## 验证 复用 Windows 锁 API 夹具产生真实结构化故障;复用依赖中继夹具注入成功退出但输出读取失败, diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index 96f1177..693b13e 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -1120,6 +1120,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 - 边界:不改变错误四元组、协议 details、恢复决策与操作顺序;不增加目录扫描,不上传锁名称、原始错误或完整路径。 - 验收:生产入口红绿回归、CLI 与 Sentry fake transport、标准门及差异审查;按用户授权提独立 PR,完整门由 Windows CI 复核,不合并或发版。 - 本地结果:三处诊断、主错误优先级与镜像回退 P2 回归先红后绿;gofmt/vet/build/diff check 通过,pstack 复审无新增 P1/P2。P2 修复后完整门仍有 TempDir 清理与 SUBST W: 失败,定向重跑及上游基线对照范围见设计记录;待 PR CI 与合入。 + - 2026-11-12 增补:按用户提供的 exFAT 状态收尾失败日志,在 PR #35 继续补齐 State 提交与恢复标记、主因与清理错误、系统操作诊断及早期失败落盘。先更新契约与回归,不改变文件系统或恢复语义。 ### - [ ] M13 dev 基线接入配套 @@ -1685,6 +1686,9 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 ## 7. 变更记录 +2026-11-12:T12.11 按用户要求在 PR #35 增补状态写入与恢复诊断,并记录早于日志器建立的 State 失败。 +仅扩大受控诊断与本地失败日志覆盖,未知故障不按磁盘格式定因,恢复与错误优先级不变。 + 2026-11-11:按用户要求登记 T12.11,补齐锁、依赖同步与恢复目录诊断。复用已经产生的结构化事实, 不改变协议错误选择、恢复安全边界或执行顺序,不访问生产 Sentry 验收;完成本地实现与隔离验证后, 按用户授权向上游提独立 PR。 diff --git "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" index f142cb2..fb0cfc4 100644 --- "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" +++ "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -2051,6 +2051,12 @@ Umami;这些 provider 方案均已由 2026-08-12 的 D11 取消,仅保留历 `*_nonzero` 分类,其他未知失败使用 `*_failed`。这些补充只进入普通失败诊断,不改变协议 details、 错误四元组、恢复决策或操作顺序。 +状态错误可经 `persistence` 提供已知文件角色、写入阶段、`mutationApplied`、`recoveryRequired`, +并用 `primary`、`cleanup` 区分主因与独立清理错误的文件操作、系统错误号和已知失败类别。 +读取错误只记录逻辑角色及读取事实。未知角色、原始错误、路径与磁盘内容不上传。 +早期 `STATE_WRITE_FAILED` 也尽力建立受管终态日志,记录有界错误链;日志故障降级至 stderr, +不改变主错误、退出码、恢复决策或协议 details。其他早期失败的日志副作用保持不变。 + Sentry SDK 可以为传输协议生成 envelope/header 和 `event_id`、`timestamp`、`platform` 等 技术字段;这不放宽应用数据白名单。应用可控内容仍仅限固定 message/exception、level、 release/environment 和上述标签/净化栈帧,DSN 认证信息与 SDK 技术字段不得复制到标签、 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 42f5a27..c586590 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -154,7 +154,7 @@ type deps struct { options options global globalOptions // opLog 绑定命令的操作日志器;命令收尾后它可能已关闭。 - // 会话仅为曾开启操作日志的失败命令创建终态日志器,再通过此绑定记录最终失败。 + // 会话为曾开日志或发生状态故障的命令创建终态日志器,再通过此绑定记录最终失败。 opLog *workspaceLogBinding exitCode int } diff --git a/internal/cli/session.go b/internal/cli/session.go index 201e0a8..ae6e31b 100644 --- a/internal/cli/session.go +++ b/internal/cli/session.go @@ -176,9 +176,9 @@ func runOperationSession( if panicked { telemetryState.addPanic(err, panicFrames) } - // 命令已关闭自己的日志器;终态使用独立 writer,才能记录包含清理错误的最终失败。 - // 只为已经开过日志的命令追加,避免早期失败创建原本不存在的受管目录。 - if deps.opLog.Get() != nil { + // 独立终态 writer 记录命令收尾后的错误;早期状态故障也须保留可导出的恢复线索。 + failureCode, _, _, _ := classifyFailure(err, stage) + if deps.opLog.Get() != nil || failureCode == protocol.CodeStateWriteFailed { deps.opLog.Set(nil) logContext, cancelLog := context.WithTimeout(context.WithoutCancel(ctx), m5TransactionCleanupTimeout) defer cancelLog() @@ -448,7 +448,7 @@ func exitCodeFor(code protocol.Code) int { // 唯一出口补一条 error 记录。状态写入失败另在本地日志保留底层 cause, // 以便区分文件系统能力、占用与权限;协议事件仍只含稳定字段。 // -// 日志器尚未打开时,状态写入失败把 cause 写到 stderr,其余失败静默跳过; +// 日志器不可用时,状态写入失败把有界错误链写到 stderr,其余失败静默跳过; // 写日志失败不改变命令结局。 func recordOperationFailure( deps *deps, @@ -464,7 +464,7 @@ func recordOperationFailure( logger := deps.opLog.Get() if logger == nil { if code == protocol.CodeStateWriteFailed && cause != nil { - writeDiagnostic(deps.io, cause) + writeStateFailureDiagnostic(deps.io, cause) } return } @@ -475,10 +475,14 @@ func recordOperationFailure( entry["code"] = string(code) entry["stage"] = string(stage) if code == protocol.CodeStateWriteFailed { + for key, value := range failureTelemetryDetails(cause, code, stage, nil) { + entry[key] = value + } var commandErr *commandError if errors.As(cause, &commandErr) && commandErr.cause != nil { entry["cause"] = commandErr.cause.Error() } + entry["causes"], entry["causesTruncated"] = localStateErrorChain(cause) } ctx := deps.ctx if ctx == nil { @@ -489,5 +493,8 @@ func recordOperationFailure( defer cancel() if _, err := logger.Record(ctx, logging.LevelError, message, entry); err != nil { writeDiagnostic(deps.io, err) + if code == protocol.CodeStateWriteFailed { + writeStateFailureDiagnostic(deps.io, cause) + } } } diff --git a/internal/cli/state_diagnostics.go b/internal/cli/state_diagnostics.go new file mode 100644 index 0000000..55e33de --- /dev/null +++ b/internal/cli/state_diagnostics.go @@ -0,0 +1,51 @@ +package cli + +import ( + "errors" + "strings" +) + +const ( + maxStateErrorChainItems = 32 + maxStateErrorTextBytes = 4096 +) + +// localStateErrorChain 展开包装与并列错误,让本地日志保留被 Error 摘要隐藏的底层故障。 +// 数量与文本大小有界,错误链中的路径和原始文本不进入遥测诊断。 +func localStateErrorChain(err error) ([]string, bool) { + values := make([]string, 0) + pending := []error{err} + truncated := false + for len(pending) > 0 && len(values) < maxStateErrorChainItems { + current := pending[len(pending)-1] + pending = pending[:len(pending)-1] + if current == nil { + continue + } + message := current.Error() + if len(message) > maxStateErrorTextBytes { + message = strings.ToValidUTF8(message[:maxStateErrorTextBytes], "") + truncated = true + } + values = append(values, message) + if joined, ok := current.(interface{ Unwrap() []error }); ok { + children := joined.Unwrap() + for index := len(children) - 1; index >= 0; index-- { + pending = append(pending, children[index]) + } + } else if wrapped, ok := current.(interface{ Unwrap() error }); ok { + pending = append(pending, wrapped.Unwrap()) + } + } + return values, truncated || len(pending) > 0 +} + +func writeStateFailureDiagnostic(output IO, cause error) { + values, truncated := localStateErrorChain(cause) + for _, value := range values { + writeDiagnostic(output, errors.New(value)) + } + if truncated { + writeDiagnostic(output, errors.New("state error chain truncated")) + } +} diff --git a/internal/cli/state_diagnostics_test.go b/internal/cli/state_diagnostics_test.go new file mode 100644 index 0000000..82c7686 --- /dev/null +++ b/internal/cli/state_diagnostics_test.go @@ -0,0 +1,189 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + "unicode/utf8" + + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/config" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/doctor" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/filesystem" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/gitrepo" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/logging" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/state" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/telemetry" +) + +type earlyStateFailureStore struct { + *m5TestStateStore + err error +} + +func (s earlyStateFailureStore) ReadTransaction(context.Context, state.TransactionKind) (state.TransactionSnapshot, error) { + return state.TransactionSnapshot{}, s.err +} + +func TestBootstrap_EarlyStateFailureIsRecorded(t *testing.T) { + t.Setenv("AUTO_MAS_TELEMETRY", "enabled") + root := t.TempDir() + var calls []string + failure := &state.WriteError{File: "mutation", Phase: state.WritePhaseRecover, RecoveryRequired: true, + Cause: &filesystem.FileError{Operation: "classic-unlink", Path: "private-path", Err: syscall.Errno(5)}} + store := earlyStateFailureStore{m5TestStateStore: &m5TestStateStore{calls: &calls}, err: failure} + var stdout, stderr bytes.Buffer + recorder := &telemetryRecorderSpy{} + code := Execute(t.Context(), []string{"--app-root", root, "--output", "ndjson", "bootstrap", "--version", "v5.8.0"}, + IO{In: strings.NewReader(""), Out: &stdout, Err: &stderr}, WithCWD(root), + WithTelemetryFactory(func(telemetry.Config) telemetry.Recorder { return recorder }), + WithEnvironmentStateStoreFactory(func(context.Context, *config.Layout, func() time.Time) (environmentStateStore, error) { + return store, nil + }), + WithMutationCoordinatorFactory(func(context.Context, *config.Layout) (gitrepo.MutationCoordinator, error) { + return &m5TestCoordinator{calls: &calls}, nil + }), + ) + if code != protocol.ExitCodeOperationConflict { + t.Fatalf("exit code = %d, want STATE_WRITE_FAILED", code) + } + layout, err := config.NewLayout(root, root) + if err != nil { + t.Fatal(err) + } + paths, err := filepath.Glob(filepath.Join(layout.RuntimeLogDir(), "bootstrap-*.log")) + if err != nil || len(paths) != 1 { + t.Fatalf("log files = %v, error = %v, want one early failure log", paths, err) + } + payload, err := os.ReadFile(paths[0]) + if err != nil { + t.Fatal(err) + } + errorsFound := 0 + for _, line := range bytes.Split(bytes.TrimSpace(payload), []byte{'\n'}) { + var row map[string]any + if err := json.Unmarshal(line, &row); err != nil { + t.Fatal(err) + } + if row["level"] != "error" { + continue + } + errorsFound++ + details := row["details"].(map[string]any) + if details["persistence"] == nil || details["causes"] == nil || row["operationId"] == "" { + t.Fatalf("failure = %#v, want state facts, full cause chain and operation ID", row) + } + } + if errorsFound != 1 { + t.Fatalf("error records = %d, want 1", errorsFound) + } + events := parseNDJSON(t, stdout.String()) + if strings.Contains(events[len(events)-1].raw, "persistence") || strings.Contains(stdout.String(), "private-path") { + t.Fatalf("protocol leaked diagnostics: %s", stdout.String()) + } + observations, _, _ := recorder.snapshot() + if len(observations) != 1 || observations[0].DiagnosticDetails["persistence"] == nil { + t.Fatalf("observations = %#v, want persistence details", observations) + } +} + +func TestRecordOperationFailure_StateErrorChain(t *testing.T) { + log := &m5TestLog{} + binding := &workspaceLogBinding{} + binding.Set(log) + deps := &deps{ctx: t.Context(), opLog: binding} + failure := stateStoreError(protocol.StageUVDownload, &state.WriteError{ + File: "mutation", Phase: state.WritePhaseFinalize, MutationApplied: true, RecoveryRequired: true, + Cause: &filesystem.FileError{Operation: "classic-unlink", Path: "private-path", Err: syscall.Errno(5)}, + CleanupError: errors.New("private cleanup cause"), + }) + recordOperationFailure(deps, protocol.CodeStateWriteFailed, protocol.StageUVDownload, "状态写入失败", nil, failure) + if len(log.records) != 1 { + t.Fatalf("records = %d, want 1", len(log.records)) + } + encoded, err := json.Marshal(log.records[0].details) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"classic-unlink", "private cleanup cause", `"mutationApplied":true`, `"recoveryRequired":true`} { + if !bytes.Contains(encoded, []byte(want)) { + t.Fatalf("local details = %s, want %q", encoded, want) + } + } +} + +func TestRunOperation_StateDiagnosticsRespectSelectedFailure(t *testing.T) { + stateErr := &state.WriteError{File: "mutation", Phase: state.WritePhaseFinalize, MutationApplied: true} + main := &commandError{code: protocol.CodeMutexOperationFailed, stage: protocol.StageBootstrap, message: "锁失败"} + details := failureTelemetryDetails(errors.Join(main, stateErr), main.code, main.stage, nil) + if details["persistence"] != nil { + t.Fatalf("diagnostics = %#v, want no secondary state facts", details) + } +} + +type failingStateLogger struct{ m5TestLog } + +func (l *failingStateLogger) Record(context.Context, logging.Level, string, map[string]any) (logging.WriteResult, error) { + return logging.WriteResult{}, errors.New("state log write failed") +} + +func TestRunOperation_StateFailureLogFallback(t *testing.T) { + t.Setenv("AUTO_MAS_TELEMETRY", "disabled") + for _, test := range []struct { + name string + writeFailure bool + }{{name: "open fails"}, {name: "write fails", writeFailure: true}} { + t.Run(test.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + attempts := 0 + cause := stateStoreError(protocol.StageBootstrap, &state.WriteError{File: "mutation", Phase: state.WritePhaseFinalize, Cause: errors.New("private deepest cause")}) + code := Execute(t.Context(), []string{"--app-root", t.TempDir(), "--output", "ndjson", "doctor"}, IO{In: strings.NewReader(""), Out: &stdout, Err: &stderr}, + WithDoctorFactory(func(*config.Layout, doctor.Probes) (doctorService, error) { + return fakeDoctorService{run: func(context.Context, *protocol.Emitter) (doctor.Report, error) { return doctor.Report{}, cause }}, nil + }), + WithWorkspaceLoggerFactory(func(context.Context, *config.Layout, io.Writer, string, string, func() time.Time) (workspaceLogger, error) { + attempts++ + if test.writeFailure { + return &failingStateLogger{}, nil + } + return nil, errors.New("state log open failed") + }), + ) + if code != protocol.ExitCodeOperationConflict || attempts != 1 || !strings.Contains(stderr.String(), "private deepest cause") { + t.Fatalf("exit=%d attempts=%d stderr=%q, want unchanged exit, one log attempt and full cause", code, attempts, stderr.String()) + } + if strings.Contains(stdout.String(), "private") { + t.Fatal("protocol leaked the local cause") + } + }) + } +} + +func TestLocalStateErrorChain_Bounds(t *testing.T) { + var err error = errors.New("root") + for range 31 { + err = fmt.Errorf("wrap: %w", err) + } + values, truncated := localStateErrorChain(err) + if len(values) != 32 || truncated { + t.Fatalf("chain count=%d truncated=%t, want 32 and false", len(values), truncated) + } + err = fmt.Errorf("wrap: %w", err) + values, truncated = localStateErrorChain(err) + if len(values) != 32 || !truncated { + t.Fatalf("chain count=%d truncated=%t, want 32 and true", len(values), truncated) + } + values, truncated = localStateErrorChain(errors.New(strings.Repeat("错", 2000))) + if len(values) != 1 || len(values[0]) > 4096 || !utf8.ValidString(values[0]) || !truncated { + t.Fatalf("long chain=%#v truncated=%t, want bounded UTF-8", values, truncated) + } +} diff --git a/internal/cli/telemetry.go b/internal/cli/telemetry.go index 52dbf07..bb52691 100644 --- a/internal/cli/telemetry.go +++ b/internal/cli/telemetry.go @@ -127,9 +127,14 @@ func failureTelemetryDetails(err error, code protocol.Code, stage protocol.Stage if current == nil { return } - failure, classified := current.(operationError) - if diagnostic, ok := current.(interface{ DiagnosticDetails() map[string]any }); ok && - (!classified || failure.Code() == code && failure.Stage() == stage) { + // 不匹配的分类分支连同其底层诊断一起跳过,避免次要错误污染终态事实。 + if failure, ok := current.(operationError); ok && (failure.Code() != code || failure.Stage() != stage) { + return + } + if failure, ok := current.(interface{ Code() protocol.Code }); ok && failure.Code() != code { + return + } + if diagnostic, ok := current.(interface{ DiagnosticDetails() map[string]any }); ok { for key, value := range diagnostic.DiagnosticDetails() { if _, exists := details[key]; !exists { details[key] = value diff --git a/internal/state/diagnostics.go b/internal/state/diagnostics.go new file mode 100644 index 0000000..b3bf1cf --- /dev/null +++ b/internal/state/diagnostics.go @@ -0,0 +1,86 @@ +package state + +import ( + "context" + "errors" + "syscall" + + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/filesystem" +) + +// DiagnosticDetails 返回状态写入的结构化事实,原始错误与路径只留在本地错误链。 +func (e *WriteError) DiagnosticDetails() map[string]any { + if e == nil { + return nil + } + details := persistenceDetails(e.File, e.Cause) + if e.Phase.Valid() { + details["phase"] = e.Phase.String() + } + details["mutationApplied"] = e.MutationApplied + details["recoveryRequired"] = e.RecoveryRequired + if cleanup := persistenceCauseDetails(e.CleanupError); len(cleanup) > 0 { + details["cleanup"] = cleanup + } + return map[string]any{"persistence": details} +} + +// DiagnosticDetails 返回状态读取的逻辑角色与结构化故障,不暴露文件内容。 +func (e *ReadError) DiagnosticDetails() map[string]any { + if e == nil { + return nil + } + details := persistenceDetails(e.File, e.Cause) + details["phase"] = "read" + return map[string]any{"persistence": details} +} + +func persistenceDetails(file string, cause error) map[string]any { + details := make(map[string]any) + if kind := filesystem.StateFileKind(file); kind.Valid() { + details["component"] = kind.String() + } + if primary := persistenceCauseDetails(cause); len(primary) > 0 { + details["primary"] = primary + } + return details +} + +func persistenceCauseDetails(err error) map[string]any { + details := make(map[string]any) + errnoSource := err + var fileError *filesystem.FileError + var operationError *filesystem.Error + if errors.As(err, &fileError) { + details["operation"] = fileError.Operation + errnoSource = fileError.Err + } else if errors.As(err, &operationError) { + details["operation"] = operationError.Operation + errnoSource = operationError.Err + } + var errno syscall.Errno + if errors.As(errnoSource, &errno) { + details["windowsError"] = uint64(errno) + } + for _, classification := range []struct { + err error + kind string + }{ + {err: filesystem.ErrIdentityChanged, kind: "identity_changed"}, + {err: filesystem.ErrStateRecoveryRequired, kind: "recovery_required"}, + {err: filesystem.ErrPOSIXUnlinkUnsupported, kind: "unlink_unsupported"}, + {err: filesystem.ErrStateGuardTimeout, kind: "guard_timeout"}, + {err: filesystem.ErrUnsafeHardLink, kind: "unsafe_hard_link"}, + {err: context.Canceled, kind: "cancelled"}, + {err: context.DeadlineExceeded, kind: "deadline_exceeded"}, + } { + if errors.Is(err, classification.err) { + details["failureKind"] = classification.kind + break + } + } + if err != nil && len(details) == 0 { + details["failureKind"] = "unknown" + } + return details +} diff --git a/internal/state/diagnostics_test.go b/internal/state/diagnostics_test.go new file mode 100644 index 0000000..c6aa4b9 --- /dev/null +++ b/internal/state/diagnostics_test.go @@ -0,0 +1,79 @@ +package state + +import ( + "errors" + "reflect" + "syscall" + "testing" + + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/filesystem" +) + +func TestWriteError_DiagnosticDetails(t *testing.T) { + for _, test := range []struct { + name string + err *WriteError + want map[string]any + }{ + {name: "committed finalize with cleanup failure", err: &WriteError{ + File: "mutation", Phase: WritePhaseFinalize, MutationApplied: true, RecoveryRequired: true, + Cause: &filesystem.FileError{Operation: "classic-unlink", Path: "private-path", Err: syscall.Errno(5)}, + CleanupError: &filesystem.FileError{Operation: "close", Path: "private-path", Err: syscall.Errno(6)}, + }, want: map[string]any{"persistence": map[string]any{ + "component": "mutation", "phase": "finalize", "mutationApplied": true, "recoveryRequired": true, + "primary": map[string]any{"operation": "classic-unlink", "windowsError": uint64(5)}, + "cleanup": map[string]any{"operation": "close", "windowsError": uint64(6)}, + }}}, + {name: "recovery identity failure", err: &WriteError{ + File: "update", Phase: WritePhaseRecover, RecoveryRequired: true, Cause: filesystem.ErrIdentityChanged, + }, want: map[string]any{"persistence": map[string]any{ + "component": "update", "phase": "recover", "mutationApplied": false, "recoveryRequired": true, + "primary": map[string]any{"failureKind": "identity_changed"}, + }}}, + {name: "unknown file is omitted", err: &WriteError{File: "private-path", Phase: WritePhaseWrite, Cause: errors.New("private cause")}, + want: map[string]any{"persistence": map[string]any{"phase": "write", "mutationApplied": false, "recoveryRequired": false, + "primary": map[string]any{"failureKind": "unknown"}}}}, + {name: "unknown cleanup is still a failure", err: &WriteError{File: "update", Phase: WritePhaseClose, CleanupError: errors.New("private cleanup")}, + want: map[string]any{"persistence": map[string]any{"component": "update", "phase": "close", "mutationApplied": false, "recoveryRequired": false, + "cleanup": map[string]any{"failureKind": "unknown"}}}}, + {name: "errno belongs to its own operation", err: &WriteError{File: "mutation", Phase: WritePhaseFinalize, + Cause: errors.Join(&filesystem.FileError{Operation: "verify-anchor", Path: "private-path", Err: filesystem.ErrIdentityChanged}, syscall.Errno(6)), + }, want: map[string]any{"persistence": map[string]any{ + "component": "mutation", "phase": "finalize", "mutationApplied": false, "recoveryRequired": false, + "primary": map[string]any{"operation": "verify-anchor", "failureKind": "identity_changed"}, + }}}, + } { + t.Run(test.name, func(t *testing.T) { + diagnostic, ok := any(test.err).(interface{ DiagnosticDetails() map[string]any }) + if !ok { + t.Fatal("WriteError has no diagnostic details") + } + if got := diagnostic.DiagnosticDetails(); !reflect.DeepEqual(got, test.want) { + t.Fatalf("diagnostics = %#v, want %#v", got, test.want) + } + if test.err.Cause != nil && !errors.Is(test.err, test.err.Cause) { + t.Fatal("error chain no longer contains the primary cause") + } + if test.err.CleanupError != nil && !errors.Is(test.err, test.err.CleanupError) { + t.Fatal("error chain no longer contains the cleanup cause") + } + }) + } +} + +func TestReadError_DiagnosticDetails(t *testing.T) { + err := &ReadError{File: "mutation", Cause: &filesystem.FileError{ + Operation: "open-read", Path: "private-path", Err: syscall.Errno(32), + }} + diagnostic, ok := any(err).(interface{ DiagnosticDetails() map[string]any }) + if !ok { + t.Fatal("ReadError has no diagnostic details") + } + want := map[string]any{"persistence": map[string]any{ + "component": "mutation", "phase": "read", + "primary": map[string]any{"operation": "open-read", "windowsError": uint64(32)}, + }} + if got := diagnostic.DiagnosticDetails(); !reflect.DeepEqual(got, want) { + t.Fatalf("diagnostics = %#v, want %#v", got, want) + } +} diff --git a/internal/telemetry/diagnostics.go b/internal/telemetry/diagnostics.go index d59da3b..122cbe6 100644 --- a/internal/telemetry/diagnostics.go +++ b/internal/telemetry/diagnostics.go @@ -70,12 +70,15 @@ var diagnosticFieldPolicies = map[string]diagnosticFieldKind{ "partial": diagnosticBoolean, "committed": diagnosticBoolean, "removed": diagnosticBoolean, "terminationRequested": diagnosticBoolean, + "mutationApplied": diagnosticBoolean, + "recoveryRequired": diagnosticBoolean, "truncated": diagnosticBoolean, "lockRewritten": diagnosticBoolean, "synchronized": diagnosticBoolean, "lockfileChecked": diagnosticBoolean, "orphansTruncated": diagnosticBoolean, "attempts": diagnosticContainer, "failures": diagnosticContainer, "items": diagnosticContainer, "summary": diagnosticContainer, "relay": diagnosticContainer, "details": diagnosticContainer, "mutex": diagnosticContainer, "directories": diagnosticContainer, + "persistence": diagnosticContainer, "primary": diagnosticContainer, "cleanup": diagnosticContainer, "checks": diagnosticContainer, "orphans": diagnosticContainer, "bySource": diagnosticDynamicNumberMap, } diff --git a/internal/telemetry/sentry_test.go b/internal/telemetry/sentry_test.go index d515c23..aa554c3 100644 --- a/internal/telemetry/sentry_test.go +++ b/internal/telemetry/sentry_test.go @@ -229,6 +229,35 @@ func TestSentry_OperationalFailureDiagnostics(t *testing.T) { } } +func TestSentry_StateFailureDiagnostics(t *testing.T) { + provider, transport := newSentryTestProvider(t) + observation := validSentryObservation(false) + observation.Code = string(protocol.CodeStateWriteFailed) + observation.DiagnosticDetails = map[string]any{"persistence": map[string]any{ + "component": "mutation", "phase": "finalize", "mutationApplied": true, "recoveryRequired": true, + "primary": map[string]any{"operation": "classic-unlink", "windowsError": uint64(5), "error": "private cause"}, + "cleanup": map[string]any{"operation": "close", "windowsError": uint64(6), "password": "private password"}, + "causes": []string{"private local cause"}, + }} + provider.captureInternal(observation) + _, events, _, _ := transport.snapshot() + if len(events) != 1 { + t.Fatalf("events = %d, want 1", len(events)) + } + encoded, err := json.Marshal(events[0].Contexts["diagnostics"]) + if err != nil { + t.Fatal(err) + } + for _, required := range []string{`"persistence"`, `"component":"mutation"`, `"mutationApplied":true`, `"recoveryRequired":true`, `"primary"`, `"cleanup"`, `"windowsError":5`, `"windowsError":6`} { + if !bytes.Contains(encoded, []byte(required)) { + t.Fatalf("diagnostics = %s, want %s", encoded, required) + } + } + if bytes.Contains(encoded, []byte("private")) || bytes.Contains(encoded, []byte("causes")) { + t.Fatalf("private data escaped: %s", encoded) + } +} + func TestSentry_BeforeSendRejectsUnreportableFailure(t *testing.T) { provider, transport := newSentryTestProvider(t) observation := validSentryObservation(false) From ad7b73bd54a87fa19dbd40c7080fc5e126370996 Mon Sep 17 00:00:00 2001 From: Ji Mao Cai <44921326+jinghero@users.noreply.github.com> Date: Thu, 12 Nov 2026 09:17:43 +0800 Subject: [PATCH 3/9] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E8=AF=8A=E6=96=AD=E5=A2=9E=E8=A1=A5=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=20(T12.11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...42\345\244\215\350\257\212\346\226\255.md" | 1 + ...42\345\244\215\350\257\212\346\226\255.md" | 38 +++++++++++++++++++ ...73\345\212\241\346\213\206\345\210\206.md" | 1 + 3 files changed, 40 insertions(+) diff --git "a/doc/current/M12/\350\256\241\345\210\222-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" "b/doc/current/M12/\350\256\241\345\210\222-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" index 02db8e4..60f73ca 100644 --- "a/doc/current/M12/\350\256\241\345\210\222-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" +++ "b/doc/current/M12/\350\256\241\345\210\222-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" @@ -13,6 +13,7 @@ `TestSentry_StateFailureDiagnostics`、`TestBootstrap_EarlyStateFailureIsRecorded`、 `TestRunOperation_StateDiagnosticsRespectSelectedFailure`、`TestRecordOperationFailure_StateErrorChain` 与 `TestRunOperation_StateFailureLogFallback`,确认现有实现缺少字段或文件记录,再实现最小改动。 + `TestLocalStateErrorChain_Bounds` 验证本地错误链数量、文本与 UTF-8 边界。 文件为 `internal/state/diagnostics{,_test}.go`、`internal/cli/{cli,session,telemetry,state_diagnostics}.go` 及对应测试、 `internal/telemetry/{diagnostics.go,sentry_test.go}`。提交前检查暂存清单只含这些文件与本任务文档。 定向回归通过后执行完整标准门与审查,追加到现有 PR #35,不合并、发布或替换安装版。 diff --git "a/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" "b/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" index 2a1cb13..7244b99 100644 --- "a/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" +++ "b/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" @@ -96,6 +96,44 @@ P2 复审前再次运行 `go test ./... -count=1`,退出 1:只有同一 SUBS 未修改对应文件系统实现或夹具,未向生产 Sentry 发送验收事件。以上为提交前本地验收记录; 随后按用户授权提交独立分支并提 PR,不合并、发布或替换安装版。 +## 状态诊断增补验收 + +2026-11-12,代码提交 `0af00a3`。状态诊断接口、早期失败文件日志、完整错误链和 Sentry +出口先在原实现失败,再实现修复。补充对照还先复现了次要 State 错误污染主分类、同级错误的 +errno 被错误归到其他操作,以及未知清理错误没有诊断事实,修复后回归通过。 + +以下定向命令退出 0,全部目标出现 PASS,输出没有 `[no tests to run]`: + +```powershell +go test ./internal/state ./internal/cli ./internal/telemetry -run '^(TestWriteError_DiagnosticDetails|TestReadError_DiagnosticDetails|TestBootstrap_EarlyStateFailureIsRecorded|TestRunOperation_StateDiagnosticsRespectSelectedFailure|TestRecordOperationFailure_StateErrorChain|TestRunOperation_StateFailureLogFallback|TestLocalStateErrorChain_Bounds|TestSentry_StateFailureDiagnostics|TestRunOperation_.*Diagnostics|TestSentry_OperationalFailureDiagnostics)$' -count=10 -v +``` + +最终 `gofmt -l .` 输出为空,`go vet ./...`、`go build -buildvcs=false ./...` 和 +`git diff --check` 退出 0,新增业务代码没有 stdout 或 Sentry SDK 依赖。 +未改变并发和资源所有权,不增加 race 验收结论。 + +编译同一提交的真实 EXE,在隔离受管根中把 `mutation.json` 设置为目录,制造早于日志器建立的 +读取拒绝。离线 bootstrap 退出 70,协议仍为 `STATE_WRITE_FAILED`;新日志中同一 operationId +恰好一条 error,包含 `component=mutation`、`phase=read`、`operation=open-relative`、 +`windowsError=5` 和六项本地错误链。协议 details 没有新增 diagnostics。 +没有替换安装版,没有操作用户提供日志所在的磁盘,也没有向生产 Sentry 发送事件。 + +本机完整 `go test ./... -count=1` 退出 1:filesystem 的 +`TestStateFiles_WriteAtomicCopiesCallerPayload` 出现 TempDir 清理失败, +`TestStateFiles_ExFATIdentityLifecycle` 出现测试文件已不存在及 TempDir 清理失败; +lock 的 SUBST W: 未就绪,process 的 `TestJob_BreakawayGrandchildSurvivesJobClose` 报 Job 归属断言失败。 +该轮 CLI、state、telemetry 及其余包通过。没有修改这些实现或夹具。 + +在未修改的 PR 基线 `0a0443f` 上定向运行上述四个父用例,SUBST 和 Job 归属失败均复现, +filesystem 两项通过;修复版相同定向命令得到相同结果。只能确认前两类环境限制, +不能声称 filesystem 两项具体失败已在基线复现。 + +后续受影响包检查中,CLI 的 M13 取消夹具与 M5 cutpoint 夹具出现 TempDir 清理失败。 +未修改基线的 CLI 全包也在 M5 cutpoint 的 python.check/python.install 出现同类清理失败。 +修复版定向重跑时 M13 用例通过,M5 的 python.check/dependencies.rebuild 仍报目录非空。 +state 和 telemetry 全包通过;新增诊断用例十轮通过。完整 Windows 门禁由本次 PR CI 再验收, +不把本机结果表述为全仓全绿。 + ## 镜像回退 P2 修复与 pstack 复审 `TestDependencies_MirrorFallbackPreservesExecutionDiagnostics` 经真实 `Sync` 入口令中继启动失败, diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index 693b13e..e0809b0 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -1121,6 +1121,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 - 验收:生产入口红绿回归、CLI 与 Sentry fake transport、标准门及差异审查;按用户授权提独立 PR,完整门由 Windows CI 复核,不合并或发版。 - 本地结果:三处诊断、主错误优先级与镜像回退 P2 回归先红后绿;gofmt/vet/build/diff check 通过,pstack 复审无新增 P1/P2。P2 修复后完整门仍有 TempDir 清理与 SUBST W: 失败,定向重跑及上游基线对照范围见设计记录;待 PR CI 与合入。 - 2026-11-12 增补:按用户提供的 exFAT 状态收尾失败日志,在 PR #35 继续补齐 State 提交与恢复标记、主因与清理错误、系统操作诊断及早期失败落盘。先更新契约与回归,不改变文件系统或恢复语义。 + - 增补实现 `0af00a3`:新增诊断回归十轮通过,格式/vet/build/diff check 通过,真实 EXE 离线验证早期失败落盘。完整本机测试仍有文件清理、SUBST 和 Job 断言失败,基线对照范围见设计验收;待本次 PR CI 与合入。 ### - [ ] M13 dev 基线接入配套 From 24b4b8a2f2eff38435729f2cd876abeca5d60a81 Mon Sep 17 00:00:00 2001 From: Ji Mao Cai <44921326+jinghero@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:20:50 +0800 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E4=B8=8E=20Python=20=E6=89=A7=E8=A1=8C=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=20(T12.11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...42\345\244\215\350\257\212\346\226\255.md" | 9 ++ ...42\345\244\215\350\257\212\346\226\255.md" | 21 ++++ ...73\345\212\241\346\213\206\345\210\206.md" | 4 + ...66\346\236\204\350\256\276\350\256\241.md" | 4 + internal/cli/operation_diagnostics_test.go | 77 ++++++++++++ internal/cli/session.go | 6 +- internal/cli/workspace.go | 2 + internal/gitrepo/clone.go | 23 +++- internal/gitrepo/errors.go | 13 +- internal/gitrepo/operation_diagnostics.go | 88 +++++++++++++ .../gitrepo/operation_diagnostics_test.go | 89 +++++++++++++ internal/gitrepo/recovery.go | 18 +-- internal/gitrepo/swap.go | 14 +-- internal/telemetry/diagnostics.go | 5 +- internal/telemetry/sentry_test.go | 29 +++++ internal/uv/dependencies.go | 29 +---- internal/uv/dependencies_mirror.go | 2 +- internal/uv/execution_diagnostics.go | 75 +++++++++++ internal/uv/execution_diagnostics_test.go | 119 ++++++++++++++++++ internal/uv/network.go | 18 ++- internal/uv/python.go | 8 +- internal/uv/python_relay.go | 12 +- internal/uv/runner.go | 26 +++- 23 files changed, 608 insertions(+), 83 deletions(-) create mode 100644 internal/cli/operation_diagnostics_test.go create mode 100644 internal/gitrepo/operation_diagnostics.go create mode 100644 internal/gitrepo/operation_diagnostics_test.go create mode 100644 internal/uv/execution_diagnostics.go create mode 100644 internal/uv/execution_diagnostics_test.go diff --git "a/doc/current/M12/\350\256\241\345\210\222-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" "b/doc/current/M12/\350\256\241\345\210\222-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" index 60f73ca..cb4da5d 100644 --- "a/doc/current/M12/\350\256\241\345\210\222-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" +++ "b/doc/current/M12/\350\256\241\345\210\222-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" @@ -18,6 +18,15 @@ `internal/telemetry/{diagnostics.go,sentry_test.go}`。提交前检查暂存清单只含这些文件与本任务文档。 定向回归通过后执行完整标准门与审查,追加到现有 PR #35,不合并、发布或替换安装版。 +6. 目录操作:先补 `TestSwapper_OperationDiagnostics`、`TestFetcher_CleanupDiagnostics`、 + `TestSentry_RepositoryFailureDiagnostics` 及 CLI 本地结构诊断回归,再修改 gitrepo 错误和操作边界。 + 文件为 `internal/gitrepo/{errors,operation_diagnostics,swap,clone,recovery}.go` 及对应测试、 + `internal/cli/{workspace,session}.go` 及测试、遥测白名单与本任务文档。 +7. uv/Python:先补 `TestRunner_IsolationFailureDiagnostics`、`TestPython_InstallFailureDiagnostics`。 + 私有 Job 工厂确定性注入创建/Assign 失败,确认字段红灯后实现;文件为 + `internal/uv/{runner,python,python_relay,network,execution_diagnostics,dependencies,dependencies_mirror}.go` + 及对应测试。定向回归、全仓标准门与审查后追加现有 PR #35。 + ```powershell $env:GOCACHE = Join-Path $env:TEMP 'auto-mas-runtime-verify' go test ./internal/lock ./internal/uv ./internal/gitrepo ./internal/cli ./internal/telemetry -run 'TestNewSet_FailureDiagnostics|TestRunOperation_PreservesNestedFailureDiagnostics|TestRunOperation_DiagnosticsRespectSelectedFailure|TestDependencies_(RelayPreservesExecutionDiagnostics|MirrorFallbackPreservesExecutionDiagnostics)|TestRecovery_SwapDamagedRepositoryWithPreviousHasNoSideEffects|TestRecovery_NonCloneReaderFailureRemainsAmbiguous|TestSentry_OperationalFailureDiagnostics' -count=1 -v diff --git "a/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" "b/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" index 7244b99..2ed8093 100644 --- "a/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" +++ "b/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" @@ -54,6 +54,27 @@ CLI 合并诊断时校验仅带 Code 的嵌套错误也与最终错误码一致 新增回归覆盖提交后 finalize、恢复读取失败、独立清理错误、完整错误链、早期 bootstrap 失败的真实文件日志、日志不可写降级、协议 details 隔离与 Sentry fake transport。 +## 目录与 Python 执行诊断增补 + +在 PR #35 继续补齐目录占用、仓库清理、uv Job 隔离和 Python 安装失败。所有事实来自已经 +执行的操作与结果,不增加扫描、重试、删除、终止或恢复动作,不改变协议 details。 + +Git 错误经 `repository` 提供重命名阶段、固定源/目标角色、清理角色、`committed`、 +`mutationApplied`、`removed`、`partial`、`auditCompleted` 及底层操作/系统错误号。 +目录角色从既有 RenameKind/DeleteKind 推导,不从路径猜测;轮换只提升最后一次同分类失败的 +诊断。CLI 本地失败记录同样保存结构事实,workspace 已打开的日志绑定到终态出口。 + +uv 的 Job 工厂通过消费方私有接口注入,生产缺省仍调用 `process.NewJob`。创建与 Assign +保留现有 `process_isolation_failed`,另用 operation 区分,附带 errno 与已有执行上下文。 +Python 中继和逐源失败保存最后一次执行的分类、耗时、捕获字节数及源尝试信息,未知失败与 +真实非零退出分开。共享执行诊断从依赖专用命名迁移到 uv 包内通用实现,原调用方同步迁移。 +源尝试只保留最后四条安全摘要,总尝试数不变,超出时标记 truncated,给最后一次执行事实留出字段预算。 +取消、网络策略错误和进程清理保持原样,Sentry 仍只接收白名单结构字段。 + +新增回归 `TestSwapper_OperationDiagnostics`、`TestFetcher_CleanupDiagnostics`、 +`TestRunner_IsolationFailureDiagnostics`、`TestPython_InstallFailureDiagnostics`、 +`TestSentry_RepositoryFailureDiagnostics` 及 CLI 本地诊断回归,先确认缺项再实现。 + ## 验证 复用 Windows 锁 API 夹具产生真实结构化故障;复用依赖中继夹具注入成功退出但输出读取失败, diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index e0809b0..c77b73b 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -1122,6 +1122,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 - 本地结果:三处诊断、主错误优先级与镜像回退 P2 回归先红后绿;gofmt/vet/build/diff check 通过,pstack 复审无新增 P1/P2。P2 修复后完整门仍有 TempDir 清理与 SUBST W: 失败,定向重跑及上游基线对照范围见设计记录;待 PR CI 与合入。 - 2026-11-12 增补:按用户提供的 exFAT 状态收尾失败日志,在 PR #35 继续补齐 State 提交与恢复标记、主因与清理错误、系统操作诊断及早期失败落盘。先更新契约与回归,不改变文件系统或恢复语义。 - 增补实现 `0af00a3`:新增诊断回归十轮通过,格式/vet/build/diff check 通过,真实 EXE 离线验证早期失败落盘。完整本机测试仍有文件清理、SUBST 和 Job 断言失败,基线对照范围见设计验收;待本次 PR CI 与合入。 + - 后续范围:继续补齐目录占用与清理角色/结果、uv Job 创建和 Assign、Python 中继与逐源安装的最后执行事实;复用既有诊断通道,并让 workspace 失败事实进入本地终态日志。 ### - [ ] M13 dev 基线接入配套 @@ -1687,6 +1688,9 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 ## 7. 变更记录 +2026-11-12:按用户继续补齐的要求,T12.11 在 PR #35 扩展目录占用、仓库清理、uv 隔离和 +Python 安装失败诊断,复用已执行操作与最后一次源尝试,不扩大文件操作或协议契约。 + 2026-11-12:T12.11 按用户要求在 PR #35 增补状态写入与恢复诊断,并记录早于日志器建立的 State 失败。 仅扩大受控诊断与本地失败日志覆盖,未知故障不按磁盘格式定因,恢复与错误优先级不变。 diff --git "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" index fb0cfc4..a4411cf 100644 --- "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" +++ "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -2057,6 +2057,10 @@ Umami;这些 provider 方案均已由 2026-08-12 的 D11 取消,仅保留历 早期 `STATE_WRITE_FAILED` 也尽力建立受管终态日志,记录有界错误链;日志故障降级至 stderr, 不改变主错误、退出码、恢复决策或协议 details。其他早期失败的日志副作用保持不变。 +仓库操作可经 `repository` 记录固定源/目标与清理角色、rename/delete 阶段、提交与删除结果、 +审计完成事实及底层操作/系统错误号,不包含路径或文件名。uv Job 失败诊断区分创建和 Assign, +Python 安装诊断保留最后一次执行事实与安全源尝试汇总;均不改变协议 details、错误优先级或重试。 + Sentry SDK 可以为传输协议生成 envelope/header 和 `event_id`、`timestamp`、`platform` 等 技术字段;这不放宽应用数据白名单。应用可控内容仍仅限固定 message/exception、level、 release/environment 和上述标签/净化栈帧,DSN 认证信息与 SDK 技术字段不得复制到标签、 diff --git a/internal/cli/operation_diagnostics_test.go b/internal/cli/operation_diagnostics_test.go new file mode 100644 index 0000000..f65805d --- /dev/null +++ b/internal/cli/operation_diagnostics_test.go @@ -0,0 +1,77 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/config" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/gitrepo" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" +) + +func TestWorkspaceSync_FailureDiagnosticsAreRecorded(t *testing.T) { + t.Setenv("AUTO_MAS_TELEMETRY", "disabled") + root := t.TempDir() + var stdout, stderr bytes.Buffer + failure := telemetryDiagnosticError{error: &commandError{code: protocol.CodeGitRepoCleanupFailed, stage: protocol.StageWorkspaceCleanup, message: "仓库清理失败"}, + details: map[string]any{"repository": map[string]any{"component": "previous", "committed": true, "partial": true}}} + code := Execute(t.Context(), []string{"--app-root", root, "--output", "ndjson", "workspace", "sync", "--version", "v5.8.0"}, + IO{In: strings.NewReader(""), Out: &stdout, Err: &stderr}, WithCWD(root), + WithWorkspaceFactory(func(*config.Layout) (workspaceService, error) { + return workspaceTestService{sync: func(ctx context.Context, request gitrepo.SyncRequest) (gitrepo.SyncResult, error) { + logger, err := request.LoggerFactory(ctx, "workspace-sync", request.OperationID) + if err != nil { + t.Fatal(err) + } + if err := logger.Close(); err != nil { + t.Fatal(err) + } + return gitrepo.SyncResult{}, failure + }}, nil + }), + ) + if code != protocol.ExitCodeGitFailure { + t.Fatalf("exit = %d, want git failure", code) + } + layout, err := config.NewLayout(root, root) + if err != nil { + t.Fatal(err) + } + files, err := filepath.Glob(filepath.Join(layout.RuntimeLogDir(), "*.log")) + if err != nil { + t.Fatal(err) + } + count := 0 + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + for _, line := range bytes.Split(bytes.TrimSpace(data), []byte{'\n'}) { + var row map[string]any + if err := json.Unmarshal(line, &row); err != nil { + t.Fatal(err) + } + if row["level"] != "error" { + continue + } + count++ + details := row["details"].(map[string]any) + if details["repository"] == nil { + t.Fatalf("local details = %#v, want repository facts", details) + } + } + } + if count != 1 { + t.Fatalf("error records = %d, want 1", count) + } + events := parseNDJSON(t, stdout.String()) + if strings.Contains(events[len(events)-1].raw, "repository") { + t.Fatal("protocol details gained diagnostics") + } +} diff --git a/internal/cli/session.go b/internal/cli/session.go index ae6e31b..cb99910 100644 --- a/internal/cli/session.go +++ b/internal/cli/session.go @@ -474,10 +474,10 @@ func recordOperationFailure( } entry["code"] = string(code) entry["stage"] = string(stage) + for key, value := range failureTelemetryDetails(cause, code, stage, nil) { + entry[key] = value + } if code == protocol.CodeStateWriteFailed { - for key, value := range failureTelemetryDetails(cause, code, stage, nil) { - entry[key] = value - } var commandErr *commandError if errors.As(cause, &commandErr) && commandErr.cause != nil { entry["cause"] = commandErr.cause.Error() diff --git a/internal/cli/workspace.go b/internal/cli/workspace.go index 5f1102f..6944c2c 100644 --- a/internal/cli/workspace.go +++ b/internal/cli/workspace.go @@ -104,6 +104,7 @@ func workspaceStageCommand(deps *deps) *cobra.Command { logger, loggerErr := deps.options.workspaceLoggerFactory(loggerContext, deps.global.layout, deps.io.Err, command, operationID, deps.options.clock) if loggerErr == nil { binding.Set(logger) + deps.opLog.Set(logger) } return logger, loggerErr }, @@ -229,6 +230,7 @@ func runWorkspaceSync( ) if loggerErr == nil { binding.Set(logger) + deps.opLog.Set(logger) } return logger, loggerErr }, diff --git a/internal/gitrepo/clone.go b/internal/gitrepo/clone.go index c9714dc..0b96ccb 100644 --- a/internal/gitrepo/clone.go +++ b/internal/gitrepo/clone.go @@ -284,6 +284,7 @@ func (f *Fetcher) Fetch(ctx context.Context, request FetchRequest) (FetchResult, var directoryIdentity *filesystem.DirectoryIdentity var diagnostics []map[string]any + var lastFailure *Error rotationResult, err := f.rotator.Run( ctx, request.Plan, @@ -297,12 +298,16 @@ func (f *Fetcher) Fetch(ctx context.Context, request FetchRequest) (FetchResult, if errors.As(outcome.Err, &failure) { details = failure.Details() } + lastFailure = failure diagnostics = append(diagnostics, details) return outcome }, ) if err != nil { failure := mapFetchFailure(ctx, err, rotationResult.Reports) + if lastFailure != nil && failure.Code() == lastFailure.Code() && failure.Stage() == lastFailure.Stage() { + failure.diagnostics = lastFailure.diagnostics + } attempts := failure.details["attempts"].([]map[string]any) for i, details := range diagnostics { if i >= len(attempts) { @@ -668,13 +673,13 @@ func (f *Fetcher) fetchAttempt( repositoryPath, mirror.OutcomeTargetFailure, failureCleanup, - newError( + withRepositoryCloseDiagnostics(newError( protocol.CodeGitRepoCleanupFailed, protocol.StageWorkspaceCleanup, messageForCode(protocol.CodeGitRepoCleanupFailed), map[string]any{}, closeErr, - ), + ), closeErr), false, directoryIdentity, ) @@ -747,13 +752,13 @@ func (f *Fetcher) finishFailedAttemptWithIdentity( } if lease != nil { if closeErr := closeDirectoryLease(lease); closeErr != nil { - operationErr = newError( + operationErr = withRepositoryCloseDiagnostics(newError( protocol.CodeGitRepoCleanupFailed, protocol.StageWorkspaceCleanup, messageForCode(protocol.CodeGitRepoCleanupFailed), map[string]any{}, errors.Join(operationErr, closeErr), - ) + ), closeErr) failureKind = failureCleanup outcomeKind = mirror.OutcomeTargetFailure cleanup = false @@ -793,6 +798,10 @@ func (f *Fetcher) finishFailedAttemptWithIdentity( errors.Join(operationErr, cleanupErr), ) failureKind = failureCleanup + var cleanupFailure *Error + if errors.As(cleanupErr, &cleanupFailure) { + operationErr.diagnostics = cleanupFailure.diagnostics + } } outcomeKind = mirror.OutcomeTargetFailure } @@ -834,10 +843,12 @@ func (f *Fetcher) cleanupTemporary( ExpectedIdentity: expectedIdentity, }) if err != nil { - return fmt.Errorf("remove repository update: %w", err) + return withRepositoryCleanupDiagnostics(newError(protocol.CodeGitRepoCleanupFailed, protocol.StageWorkspaceCleanup, + messageForCode(protocol.CodeGitRepoCleanupFailed), map[string]any{}, fmt.Errorf("remove repository update: %w", err)), filesystem.DeleteRepositoryUpdate, result) } if result.Partial || !result.AuditCompleted { - return errors.New("repository update cleanup is incomplete") + return withRepositoryCleanupDiagnostics(newError(protocol.CodeGitRepoCleanupFailed, protocol.StageWorkspaceCleanup, + messageForCode(protocol.CodeGitRepoCleanupFailed), map[string]any{}, errors.New("repository update cleanup is incomplete")), filesystem.DeleteRepositoryUpdate, result) } return nil } diff --git a/internal/gitrepo/errors.go b/internal/gitrepo/errors.go index 9f45e58..17c0973 100644 --- a/internal/gitrepo/errors.go +++ b/internal/gitrepo/errors.go @@ -8,12 +8,13 @@ import ( // Error 描述工作区 Git 服务失败,供 CLI 映射协议四元组。 type Error struct { - code protocol.Code - stage protocol.Stage - message string - details map[string]any - cause error - committed bool + code protocol.Code + stage protocol.Stage + message string + details map[string]any + diagnostics map[string]any + cause error + committed bool } func newError( diff --git a/internal/gitrepo/operation_diagnostics.go b/internal/gitrepo/operation_diagnostics.go new file mode 100644 index 0000000..f76c660 --- /dev/null +++ b/internal/gitrepo/operation_diagnostics.go @@ -0,0 +1,88 @@ +package gitrepo + +import ( + "errors" + "os" + "syscall" + + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/filesystem" +) + +// DiagnosticDetails 返回仓库操作的诊断副本,不改变协议详情。 +func (e *Error) DiagnosticDetails() map[string]any { + if e == nil || len(e.diagnostics) == 0 { + return nil + } + details := make(map[string]any, len(e.diagnostics)) + for key, value := range e.diagnostics { + if fields, ok := value.(map[string]any); ok { + copyOf := make(map[string]any, len(fields)) + for name, field := range fields { + copyOf[name] = field + } + value = copyOf + } + details[key] = value + } + return map[string]any{"repository": details} +} + +func withRepositoryRenameDiagnostics(err *Error, kind filesystem.RenameKind, result filesystem.RenameResult) *Error { + details := repositoryOperationDetails(err.Committed(), err.cause, "rename") + details["phase"] = kind.String() + details["mutationApplied"] = result.MutationApplied + switch kind { + case filesystem.RenameRepositoryToRetired: + details["sourceRole"], details["destinationRole"] = "repo", "previous" + case filesystem.RenameUpdateToRepository: + details["sourceRole"], details["destinationRole"] = "update", "repo" + case filesystem.RenameRepositoryRollback: + details["sourceRole"], details["destinationRole"] = "previous", "repo" + } + err.diagnostics = details + return err +} + +func withRepositoryCleanupDiagnostics(err *Error, kind filesystem.DeleteKind, result filesystem.DeleteResult) *Error { + details := repositoryOperationDetails(err.Committed(), err.cause, "remove_tree") + switch kind { + case filesystem.DeleteRepositoryRetired: + details["component"] = "previous" + case filesystem.DeleteRepositoryUpdate: + details["component"] = "update" + } + details["removed"], details["partial"], details["auditCompleted"] = result.Removed, result.Partial, result.AuditCompleted + err.diagnostics = details + return err +} + +func withRepositoryCloseDiagnostics(err *Error, cause error) *Error { + err.diagnostics = repositoryOperationDetails(err.Committed(), cause, "close_lease") + err.diagnostics["component"] = "update" + return err +} + +func repositoryOperationDetails(committed bool, cause error, operation string) map[string]any { + details := map[string]any{"operation": operation, "committed": committed} + primary := make(map[string]any) + var fileError *filesystem.FileError + var operationError *filesystem.Error + var pathError *os.PathError + switch { + case errors.As(cause, &fileError): + primary["operation"], cause = fileError.Operation, fileError.Err + case errors.As(cause, &operationError): + primary["operation"], cause = operationError.Operation, operationError.Err + case errors.As(cause, &pathError): + primary["operation"], cause = pathError.Op, pathError.Err + } + var errno syscall.Errno + if errors.As(cause, &errno) { + primary["windowsError"] = uint64(errno) + } + if len(primary) == 0 { + primary["failureKind"] = "unknown" + } + details["primary"] = primary + return details +} diff --git a/internal/gitrepo/operation_diagnostics_test.go b/internal/gitrepo/operation_diagnostics_test.go new file mode 100644 index 0000000..f53e449 --- /dev/null +++ b/internal/gitrepo/operation_diagnostics_test.go @@ -0,0 +1,89 @@ +package gitrepo + +import ( + "context" + "errors" + "syscall" + "testing" + + git "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/filesystem" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" +) + +func TestSwapper_OperationDiagnostics(t *testing.T) { + for _, test := range []struct { + name string + cleanup bool + }{{name: "occupied rename"}, {name: "committed cleanup", cleanup: true}} { + t.Run(test.name, func(t *testing.T) { + layout := mustGitLayout(t) + request := validSwapRequest(t, layout, "01ARZ3NDEKTSV4RRFFQ69G5FAZ") + writeSwapMarker(t, layout.RepoDir(), "old-only", "old") + writeSwapMarker(t, mustRepoUpdateDir(t, layout, request.Transaction.OperationID), "new-only", "new") + operator := successfulSwapOperator(t) + if test.cleanup { + operator.remove = func(context.Context, filesystem.DeleteRequest) (filesystem.DeleteResult, error) { + return filesystem.DeleteResult{Partial: true, AuditCompleted: true}, &filesystem.FileError{Operation: "remove-tree", Path: "private-path", Err: syscall.Errno(5)} + } + } else { + operator.rename = func(context.Context, filesystem.RenameRequest) (filesystem.RenameResult, error) { + return filesystem.RenameResult{}, errors.Join(testSwapCodeError{code: protocol.CodeDirectoryOccupied}, &filesystem.FileError{Operation: "rename", Path: "private-path", Err: syscall.Errno(32)}) + } + } + _, err := mustTestSwapper(t, layout, operator, &fakeSwapTransactionWriter{}).Swap(t.Context(), request) + var failure *Error + if !errors.As(err, &failure) { + t.Fatalf("error = %v, want repository error", err) + } + if len(failure.Details()) != 0 { + t.Fatalf("protocol details = %#v, want unchanged empty map", failure.Details()) + } + diagnostic, ok := any(failure).(interface{ DiagnosticDetails() map[string]any }) + if !ok { + t.Fatal("repository error has no diagnostics") + } + details, ok := diagnostic.DiagnosticDetails()["repository"].(map[string]any) + if !ok { + t.Fatalf("diagnostics = %#v, want repository facts", diagnostic.DiagnosticDetails()) + } + if test.cleanup { + if details["component"] != "previous" || details["partial"] != true || details["auditCompleted"] != true || details["committed"] != true || details["removed"] != false { + t.Fatalf("cleanup diagnostics = %#v, want original cleanup and activation facts", details) + } + } else if details["sourceRole"] != "repo" || details["destinationRole"] != "previous" || details["phase"] != "repository_to_retired" || details["mutationApplied"] != false { + t.Fatalf("rename diagnostics = %#v, want exact failed rename", details) + } + primary, ok := details["primary"].(map[string]any) + if !ok || primary["windowsError"] == nil { + t.Fatalf("primary = %#v, want system error", primary) + } + }) + } +} + +func TestFetcher_CleanupDiagnostics(t *testing.T) { + target := mustParseTarget(t, "v5.4.0") + client := &fakeGitClient{ + list: func(context.Context, string, []byte) ([]*plumbing.Reference, error) { + return targetBranchReferences(target), nil + }, + clone: func(context.Context, string, git.CloneOptions) error { return errTestClone }, + } + deps := successfulFetcherDependencies(t, client) + deps.remover = &fakeTreeRemover{remove: func(context.Context, filesystem.DeleteRequest) (filesystem.DeleteResult, error) { + return filesystem.DeleteResult{Partial: true, AuditCompleted: true}, &filesystem.FileError{Operation: "remove-tree", Path: "private-path", Err: syscall.Errno(5)} + }} + _, err := mustTestFetcher(t, deps).Fetch(t.Context(), FetchRequest{Plan: mustGitPlan(t, "cnb"), Target: target, OperationID: "OPERATION-CLEANUP-DIAGNOSTICS"}) + assertGitrepoCode(t, err, protocol.CodeGitRepoCleanupFailed) + var diagnostic interface{ DiagnosticDetails() map[string]any } + if !errors.As(err, &diagnostic) { + t.Fatal("fetch failure has no cleanup diagnostics") + } + repository, ok := diagnostic.DiagnosticDetails()["repository"].(map[string]any) + if !ok || repository["component"] != "update" || repository["partial"] != true || repository["committed"] != false { + t.Fatalf("diagnostics = %#v, want update cleanup facts on final error", diagnostic.DiagnosticDetails()) + } +} diff --git a/internal/gitrepo/recovery.go b/internal/gitrepo/recovery.go index c87d67c..9449037 100644 --- a/internal/gitrepo/recovery.go +++ b/internal/gitrepo/recovery.go @@ -808,12 +808,12 @@ func (r *Recovery) renameRecoveryRepository( if isAmbiguousFilesystemError(err) && !renameResult.MutationApplied { return recoveryAmbiguousError(protocol.StageWorkspaceSwap, "directory_identity_changed", err) } - return mapRenameFailure( + return withRepositoryRenameDiagnostics(mapRenameFailure( ctx, err, renameResult.MutationApplied, kind == filesystem.RenameUpdateToRepository && renameResult.MutationApplied, - ) + ), kind, renameResult) } if !renameResult.MutationApplied { return swapFailureError(protocol.StageWorkspaceSwap, errSwapNotApplied) @@ -845,35 +845,35 @@ func (r *Recovery) removeRecoveryTree( if err != nil { if isAmbiguousFilesystemError(err) { if committed { - return mapCommittedCleanupFailure(deleteResult, err) + return withRepositoryCleanupDiagnostics(mapCommittedCleanupFailure(deleteResult, err), kind, deleteResult) } return recoveryAmbiguousError(protocol.StageWorkspaceCleanup, "directory_identity_changed", err) } if committed { - return mapCommittedCleanupFailure(deleteResult, err) + return withRepositoryCleanupDiagnostics(mapCommittedCleanupFailure(deleteResult, err), kind, deleteResult) } if isCancellation(ctx, err) { return recoveryCancelledError(protocol.StageWorkspaceCleanup, err) } - return newError( + return withRepositoryCleanupDiagnostics(newError( protocol.CodeGitRepoCleanupFailed, protocol.StageWorkspaceCleanup, messageForCode(protocol.CodeGitRepoCleanupFailed), map[string]any{}, err, - ) + ), kind, deleteResult) } if deleteResult.Partial || !deleteResult.AuditCompleted { if committed { - return mapCommittedCleanupFailure(deleteResult, errRecoveryCleanupIncomplete) + return withRepositoryCleanupDiagnostics(mapCommittedCleanupFailure(deleteResult, errRecoveryCleanupIncomplete), kind, deleteResult) } - return newError( + return withRepositoryCleanupDiagnostics(newError( protocol.CodeGitRepoCleanupFailed, protocol.StageWorkspaceCleanup, messageForCode(protocol.CodeGitRepoCleanupFailed), map[string]any{}, errRecoveryCleanupIncomplete, - ) + ), kind, deleteResult) } return nil } diff --git a/internal/gitrepo/swap.go b/internal/gitrepo/swap.go index 7c721f8..b8353b7 100644 --- a/internal/gitrepo/swap.go +++ b/internal/gitrepo/swap.go @@ -201,7 +201,7 @@ func (s *Swapper) Swap(ctx context.Context, request SwapRequest) (SwapResult, er }) result.MutationApplied = renameResult.MutationApplied if renameErr != nil { - return result, mapRenameFailure(ctx, renameErr, renameResult.MutationApplied, false) + return result, withRepositoryRenameDiagnostics(mapRenameFailure(ctx, renameErr, renameResult.MutationApplied, false), filesystem.RenameRepositoryToRetired, renameResult) } if !renameResult.MutationApplied { return result, swapFailureError(protocol.StageWorkspaceSwap, errSwapNotApplied) @@ -219,7 +219,7 @@ func (s *Swapper) Swap(ctx context.Context, request SwapRequest) (SwapResult, er result.MutationApplied = result.MutationApplied || renameResult.MutationApplied result.RepositoryActivated = renameResult.MutationApplied if renameErr != nil { - return result, mapRenameFailure(ctx, renameErr, renameResult.MutationApplied, renameResult.MutationApplied) + return result, withRepositoryRenameDiagnostics(mapRenameFailure(ctx, renameErr, renameResult.MutationApplied, renameResult.MutationApplied), filesystem.RenameUpdateToRepository, renameResult) } if !renameResult.MutationApplied { return result, swapFailureError(protocol.StageWorkspaceSwap, errSwapNotApplied) @@ -412,20 +412,20 @@ func mapRetiredCleanupFailure( result.AuditCompleted, ) } - return newError( + return withRepositoryCleanupDiagnostics(newError( protocol.CodeGitRepoCleanupFailed, protocol.StageWorkspaceCleanup, messageForCode(protocol.CodeGitRepoCleanupFailed), map[string]any{}, cause, - ) + ), filesystem.DeleteRepositoryRetired, result) } func mapCommittedRetiredCleanupFailure( result filesystem.DeleteResult, cause error, ) *Error { - return mapCommittedCleanupFailure(result, cause) + return withRepositoryCleanupDiagnostics(mapCommittedCleanupFailure(result, cause), filesystem.DeleteRepositoryRetired, result) } func mapCommittedCleanupFailure( @@ -439,13 +439,13 @@ func mapCommittedCleanupFailure( result.AuditCompleted, ) } - return newCommittedError( + return withRepositoryCleanupDiagnostics(newCommittedError( protocol.CodeGitRepoCleanupFailed, protocol.StageWorkspaceCleanup, messageForCode(protocol.CodeGitRepoCleanupFailed), map[string]any{}, cause, - ) + ), "", result) } func isCancellation(ctx context.Context, cause error) bool { diff --git a/internal/telemetry/diagnostics.go b/internal/telemetry/diagnostics.go index 122cbe6..bda984f 100644 --- a/internal/telemetry/diagnostics.go +++ b/internal/telemetry/diagnostics.go @@ -46,6 +46,7 @@ var diagnosticFieldPolicies = map[string]diagnosticFieldKind{ "reason": diagnosticStableToken, "sourceKind": diagnosticStableToken, "source": diagnosticSource, "id": diagnosticStableToken, "status": diagnosticStableValue, "brokenStage": diagnosticStableToken, "mode": diagnosticStableToken, "state": diagnosticStableToken, "sink": diagnosticStableToken, + "sourceRole": diagnosticStableToken, "destinationRole": diagnosticStableToken, "code": diagnosticProtocolCode, "stage": diagnosticProtocolStage, "pythonVersion": diagnosticIdentifier, "expectedVersion": diagnosticIdentifier, @@ -71,6 +72,7 @@ var diagnosticFieldPolicies = map[string]diagnosticFieldKind{ "partial": diagnosticBoolean, "committed": diagnosticBoolean, "removed": diagnosticBoolean, "terminationRequested": diagnosticBoolean, "mutationApplied": diagnosticBoolean, + "auditCompleted": diagnosticBoolean, "recoveryRequired": diagnosticBoolean, "truncated": diagnosticBoolean, "lockRewritten": diagnosticBoolean, "synchronized": diagnosticBoolean, "lockfileChecked": diagnosticBoolean, "orphansTruncated": diagnosticBoolean, @@ -79,7 +81,8 @@ var diagnosticFieldPolicies = map[string]diagnosticFieldKind{ "summary": diagnosticContainer, "relay": diagnosticContainer, "details": diagnosticContainer, "mutex": diagnosticContainer, "directories": diagnosticContainer, "persistence": diagnosticContainer, "primary": diagnosticContainer, "cleanup": diagnosticContainer, - "checks": diagnosticContainer, "orphans": diagnosticContainer, + "repository": diagnosticContainer, + "checks": diagnosticContainer, "orphans": diagnosticContainer, "bySource": diagnosticDynamicNumberMap, } diff --git a/internal/telemetry/sentry_test.go b/internal/telemetry/sentry_test.go index aa554c3..6bbd8a0 100644 --- a/internal/telemetry/sentry_test.go +++ b/internal/telemetry/sentry_test.go @@ -229,6 +229,35 @@ func TestSentry_OperationalFailureDiagnostics(t *testing.T) { } } +func TestSentry_RepositoryFailureDiagnostics(t *testing.T) { + provider, transport := newSentryTestProvider(t) + observation := validSentryObservation(false) + observation.Code = string(protocol.CodeGitRepoCleanupFailed) + observation.DiagnosticDetails = map[string]any{"repository": map[string]any{ + "operation": "remove_tree", "phase": "repository_to_retired", "sourceRole": "repo", "destinationRole": "previous", + "component": "previous", "committed": true, "removed": false, "partial": true, "auditCompleted": true, + "primary": map[string]any{"operation": "remove-tree", "windowsError": 5, "cause": "private cause"}, + "target": "private path", "password": "private password", + }} + provider.captureInternal(observation) + _, events, _, _ := transport.snapshot() + if len(events) != 1 { + t.Fatalf("events = %d, want 1", len(events)) + } + encoded, err := json.Marshal(events[0].Contexts["diagnostics"]) + if err != nil { + t.Fatal(err) + } + for _, required := range []string{`"repository"`, `"sourceRole":"repo"`, `"destinationRole":"previous"`, `"auditCompleted":true`, `"windowsError":5`} { + if !bytes.Contains(encoded, []byte(required)) { + t.Fatalf("diagnostics = %s, want %s", encoded, required) + } + } + if bytes.Contains(encoded, []byte("private")) { + t.Fatalf("private data escaped: %s", encoded) + } +} + func TestSentry_StateFailureDiagnostics(t *testing.T) { provider, transport := newSentryTestProvider(t) observation := validSentryObservation(false) diff --git a/internal/uv/dependencies.go b/internal/uv/dependencies.go index e18ea24..6abeb67 100644 --- a/internal/uv/dependencies.go +++ b/internal/uv/dependencies.go @@ -421,7 +421,7 @@ func dependencySyncError(result UVResult, cause error) error { uvFailureDetails(result, "dependency_sync_nonzero"), cause, ) - failure.diagnostics = dependencyExecutionDiagnostics(result, cause, "dependency_sync") + failure.diagnostics = executionDiagnostics(result, cause, "dependency_sync") return failure } @@ -436,35 +436,10 @@ func dependencyCheckError(result UVResult, cause error) error { uvFailureDetails(result, "dependency_check_nonzero"), cause, ) - failure.diagnostics = dependencyExecutionDiagnostics(result, cause, "dependency_check") + failure.diagnostics = executionDiagnostics(result, cause, "dependency_check") return failure } -func dependencyExecutionDiagnostics(result UVResult, cause error, phase string) map[string]any { - kind := phase + "_failed" - if result.ExitCode != 0 { - kind = phase + "_nonzero" - } - details := uvFailureDetails(result, kind) - var execution *Error - if errors.As(cause, &execution) { - for key, value := range execution.Details() { - details[key] = value - } - } - return details -} - -func uvFailureDetails(result UVResult, failureKind string) map[string]any { - return map[string]any{ - "failureKind": failureKind, - "exitCode": result.ExitCode, - "durationMs": result.Duration.Milliseconds(), - "capturedStdoutBytes": len(result.Stdout), - "capturedStderrBytes": len(result.Stderr), - } -} - func containsNUL(value string) bool { for _, character := range value { if character == '\x00' { diff --git a/internal/uv/dependencies_mirror.go b/internal/uv/dependencies_mirror.go index 913d05b..e9da977 100644 --- a/internal/uv/dependencies_mirror.go +++ b/internal/uv/dependencies_mirror.go @@ -393,7 +393,7 @@ func (s *DependenciesService) mapRotationFailure( details, cause, ) - failure.diagnostics = dependencyExecutionDiagnostics(state.lastResult, state.lastErr, "dependency_sync") + failure.diagnostics = executionDiagnostics(state.lastResult, state.lastErr, "dependency_sync") return failure } return rotationErr diff --git a/internal/uv/execution_diagnostics.go b/internal/uv/execution_diagnostics.go new file mode 100644 index 0000000..aa71520 --- /dev/null +++ b/internal/uv/execution_diagnostics.go @@ -0,0 +1,75 @@ +package uv + +import ( + "errors" + "syscall" + + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" +) + +const maxNetworkDiagnosticAttempts = 4 + +func executionDiagnostics(result UVResult, cause error, phase string) map[string]any { + kind := phase + "_failed" + if result.ExitCode != 0 { + kind = phase + "_nonzero" + } + details := uvFailureDetails(result, kind) + var execution *Error + if errors.As(cause, &execution) { + for key, value := range execution.Details() { + details[key] = value + } + for key, value := range execution.DiagnosticDetails() { + details[key] = value + } + } + return details +} + +func uvFailureDetails(result UVResult, failureKind string) map[string]any { + return map[string]any{"failureKind": failureKind, "exitCode": result.ExitCode, + "durationMs": result.Duration.Milliseconds(), "capturedStdoutBytes": len(result.Stdout), "capturedStderrBytes": len(result.Stderr)} +} + +func isolationDiagnostics(result UVResult, options resolvedRunOptions, operation string, cause error) map[string]any { + details := runnerFailureDetails(result, options, "process_isolation_failed") + details["operation"] = operation + var errno syscall.Errno + if errors.As(cause, &errno) { + details["windowsError"] = uint64(errno) + } + return details +} + +func networkExecutionDiagnostics(result UVResult, cause error, options RunOptions) map[string]any { + details := executionDiagnostics(result, cause, "python_install") + details["pythonVersion"], details["branch"], details["commit"] = options.PythonVersion, options.Branch, options.Commit + return details +} + +func pythonInstallFailure(spec PythonSpec, result UVResult, cause error) error { + err := pythonError(protocol.CodePythonInstallFailed, protocol.StagePythonInstall, "Python 安装失败", + map[string]any{"pythonVersion": spec.Version.String(), "exitCode": result.ExitCode}, cause) + if failure, ok := err.(*Error); ok && failure.Code() == protocol.CodePythonInstallFailed { + failure.diagnostics = executionDiagnostics(result, cause, "python_install") + failure.diagnostics["pythonVersion"] = spec.Version.String() + failure.diagnostics["sourceKind"] = mirror.KindPython.String() + failure.diagnostics["attemptCount"] = 1 + } + return err +} + +func networkAttemptDiagnostics(reports []mirror.AttemptReport) []map[string]any { + // 留出字段预算给最后一次执行的分类与计数,不让长尝试列表挤掉关键事实。 + if len(reports) > maxNetworkDiagnosticAttempts { + reports = reports[len(reports)-maxNetworkDiagnosticAttempts:] + } + details := make([]map[string]any, 0, len(reports)) + for _, report := range reports { + details = append(details, map[string]any{"source": report.SourceKey, "sourceTry": report.SourceTry, + "globalTry": report.GlobalTry, "outcome": report.Outcome.String(), "durationMs": report.Duration.Milliseconds()}) + } + return details +} diff --git a/internal/uv/execution_diagnostics_test.go b/internal/uv/execution_diagnostics_test.go new file mode 100644 index 0000000..e439e87 --- /dev/null +++ b/internal/uv/execution_diagnostics_test.go @@ -0,0 +1,119 @@ +package uv + +import ( + "context" + "errors" + "syscall" + "testing" + "time" + + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/config" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/mirror" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" +) + +type failingIsolationJob struct { + assigned uint32 + closed bool +} + +func (j *failingIsolationJob) Assign(pid uint32) error { j.assigned = pid; return syscall.Errno(5) } +func (j *failingIsolationJob) Terminate(uint32) error { return nil } +func (j *failingIsolationJob) Close() error { j.closed = true; return nil } + +func TestRunner_IsolationFailureDiagnostics(t *testing.T) { + for _, test := range []struct { + name string + assign bool + }{{name: "create_job"}, {name: "assign_job", assign: true}} { + t.Run(test.name, func(t *testing.T) { + runner := newTestRunner(t) + job := &failingIsolationJob{} + runner.newJob = func() (runnerJob, error) { + if test.assign { + return job, nil + } + return nil, syscall.Errno(5) + } + _, err := runner.Run(t.Context(), []string{"-test.run=^TestFakeUVProcess$"}, RunOptions{Stage: protocol.StageUVVerify}) + var failure *Error + if !errors.As(err, &failure) || failure.Code() != protocol.CodeUVExecFailed { + t.Fatalf("error = %v, want UV_EXEC_FAILED", err) + } + details := failure.DiagnosticDetails() + if details["operation"] != test.name || details["windowsError"] != uint64(5) || details["durationMs"] == nil { + t.Fatalf("diagnostics = %#v, want operation, errno and elapsed time", details) + } + if len(failure.Details()) != 1 || failure.Details()["failureKind"] != "process_isolation_failed" { + t.Fatalf("protocol details changed: %#v", failure.Details()) + } + if test.assign && (job.assigned == 0 || !job.closed) { + t.Fatalf("job = %+v, want assign and close", job) + } + }) + } +} + +func TestPython_InstallFailureDiagnostics(t *testing.T) { + for _, name := range []string{"offline", "relay", "rotation"} { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + layout, err := config.NewLayout(root, root) + if err != nil { + t.Fatal(err) + } + project := t.TempDir() + writePythonProject(t, project, "3.12.10", "[project]\nrequires-python = \">=3.12\"\n") + last := newError(protocol.CodeUVExecFailed, protocol.StagePythonInstall, "读取失败", map[string]any{"failureKind": "output_read_failed", "operation": "read", "windowsError": uint64(6)}, errors.New("private cause")) + response := fakeRunnerResponse{result: UVResult{ExitCode: 0, Stderr: "private stderr", Duration: 125 * time.Millisecond}, err: last} + runner := &fakePythonRunner{listOutput: `[{"version":"3.12.10"}]`, installResults: []fakeRunnerResponse{response}} + service, err := NewPythonService(layout, runner) + if err != nil { + t.Fatal(err) + } + policy, err := mirror.NewPolicy(mirror.PolicySpec{Offline: name == "offline", MirrorOnly: name != "offline"}) + if err != nil { + t.Fatal(err) + } + wantAttempts := 1 + if name == "relay" { + factory := &fakeRelayFactory{session: &fakeRelaySession{baseURL: "http://127.0.0.1:39171"}} + service.relay = factory.start + } + if name == "rotation" { + service.network.rotator, err = mirror.NewRotator(mirror.WithMaxSourceAttempts(2), mirror.WithRotatorWait(func(context.Context, time.Duration) error { return nil })) + if err != nil { + t.Fatal(err) + } + plan, planErr := service.network.buildPlan(t.Context(), policy, mirror.KindPython) + if planErr != nil { + t.Fatal(planErr) + } + wantAttempts = 2 * len(plan.Sources()) + runner.installResults = make([]fakeRunnerResponse, wantAttempts) + for i := range runner.installResults { + runner.installResults[i] = fakeRunnerResponse{result: UVResult{ExitCode: 2}, err: newError(protocol.CodeUVExecFailed, protocol.StagePythonInstall, "退出失败", map[string]any{"failureKind": "nonzero_exit"}, errors.New("earlier failure"))} + } + runner.installResults[wantAttempts-1] = response + } + _, err = service.Prepare(t.Context(), PythonRequest{ProjectDir: project, MirrorPolicy: policy}) + var failure *Error + if !errors.As(err, &failure) { + t.Fatalf("error = %v, want Python failure", err) + } + details := failure.DiagnosticDetails() + if details["failureKind"] != "output_read_failed" || details["exitCode"] != 0 || details["windowsError"] != uint64(6) || details["durationMs"] != int64(125) { + t.Fatalf("diagnostics = %#v, want last execution facts", details) + } + if name != "relay" && details["attemptCount"] != wantAttempts { + t.Fatalf("attempt count = %#v, want %d", details["attemptCount"], wantAttempts) + } + if name == "rotation" { + attempts := details["attempts"].([]map[string]any) + if len(attempts) > maxNetworkDiagnosticAttempts || attempts[len(attempts)-1]["globalTry"] != wantAttempts { + t.Fatalf("attempts = %#v, want bounded tail including last attempt", attempts) + } + } + }) + } +} diff --git a/internal/uv/network.go b/internal/uv/network.go index 16709ec..26f3b90 100644 --- a/internal/uv/network.go +++ b/internal/uv/network.go @@ -93,17 +93,21 @@ func (e *networkExecutor) run( if errors.Is(runErr, context.Canceled) || errors.Is(runErr, context.DeadlineExceeded) { return result, runErr } - return result, newError( + failure := newError( protocol.CodeNetworkUnavailable, options.Stage, "离线缓存不足,操作需要网络", map[string]any{"sourceKind": kind.String(), "exitCode": result.ExitCode}, nonNilRunError(runErr), ) + failure.diagnostics = networkExecutionDiagnostics(result, runErr, options) + failure.diagnostics["attemptCount"] = 1 + return result, failure } var lastResult UVResult - _, err = e.rotator.Run(ctx, plan, target, func(attemptCtx context.Context, attempt mirror.Attempt) mirror.AttemptOutcome { + var lastErr error + rotation, err := e.rotator.Run(ctx, plan, target, func(attemptCtx context.Context, attempt mirror.Attempt) mirror.AttemptOutcome { attemptArgs := append([]string(nil), args...) attemptOptions := cloneRunOptions(options) switch kind { @@ -121,6 +125,7 @@ func (e *networkExecutor) run( } result, runErr := runner.Run(attemptCtx, attemptArgs, attemptOptions) lastResult = result + lastErr = runErr if runErr == nil && result.ExitCode == 0 { return mirror.AttemptOutcome{Kind: mirror.OutcomeSucceeded} } @@ -143,13 +148,20 @@ func (e *networkExecutor) run( if code == protocol.CodeMirrorExhausted { message = "所有镜像源均不可用" } - return lastResult, newError( + failure := newError( code, options.Stage, message, map[string]any{"sourceKind": kind.String(), "exitCode": lastResult.ExitCode}, err, ) + failure.diagnostics = networkExecutionDiagnostics(lastResult, lastErr, options) + failure.diagnostics["attemptCount"] = len(rotation.Reports) + failure.diagnostics["attempts"] = networkAttemptDiagnostics(rotation.Reports) + if len(rotation.Reports) > maxNetworkDiagnosticAttempts { + failure.diagnostics["truncated"] = true + } + return lastResult, failure } return lastResult, err } diff --git a/internal/uv/python.go b/internal/uv/python.go index 7d9bc04..2e0b789 100644 --- a/internal/uv/python.go +++ b/internal/uv/python.go @@ -270,13 +270,7 @@ func (s *PythonService) Prepare(ctx context.Context, request PythonRequest) (Pyt if isNetworkPolicyError(err) { return PythonResult{}, err } - return PythonResult{}, pythonError( - protocol.CodePythonInstallFailed, - protocol.StagePythonInstall, - "Python 安装失败", - map[string]any{"pythonVersion": spec.Version.String(), "exitCode": installResult.ExitCode}, - err, - ) + return PythonResult{}, pythonInstallFailure(spec, installResult, err) } } findResult, err := s.runner.Run(ctx, []string{ diff --git a/internal/uv/python_relay.go b/internal/uv/python_relay.go index e7ba105..1cc264c 100644 --- a/internal/uv/python_relay.go +++ b/internal/uv/python_relay.go @@ -95,7 +95,7 @@ func (s *PythonService) installWithRelay( return result, summary, true, runErr } if hasRelayTransferFailure(summary) { - return result, summary, true, newError( + failure := newError( protocol.CodeMirrorExhausted, protocol.StagePythonInstall, "所有镜像源均不可用", @@ -107,14 +107,10 @@ func (s *PythonService) installWithRelay( }, nonNilRunError(runErr), ) + failure.diagnostics = executionDiagnostics(result, runErr, "python_install") + return result, summary, true, failure } - return result, summary, true, withRelayDiagnostics(pythonError( - protocol.CodePythonInstallFailed, - protocol.StagePythonInstall, - "Python 安装失败", - map[string]any{"pythonVersion": spec.Version.String(), "exitCode": result.ExitCode}, - nonNilRunError(runErr), - ), summary) + return result, summary, true, withRelayDiagnostics(pythonInstallFailure(spec, result, nonNilRunError(runErr)), summary) } func (s *PythonService) probeResults(kind mirror.Kind) []mirror.ProbeResult { diff --git a/internal/uv/runner.go b/internal/uv/runner.go index 2865cf1..535665b 100644 --- a/internal/uv/runner.go +++ b/internal/uv/runner.go @@ -110,6 +110,13 @@ type UVResult struct { Duration time.Duration } +// runnerJob 是一次性 uv 命令消费的最小进程隔离能力。 +type runnerJob interface { + Assign(uint32) error + Terminate(uint32) error + Close() error +} + // UVRunner 是 Runtime 唯一的 uv 子进程执行入口。 type UVRunner struct { Executable string @@ -118,6 +125,7 @@ type UVRunner struct { ProjectEnvDir string CacheDir string Clock func() time.Time + newJob func() (runnerJob, error) } // NewRunner 创建固定可执行文件的 uv 执行器。 @@ -241,7 +249,11 @@ func (r *UVRunner) Run( closeWriteErr, ) } - job, jobErr := process.NewJob() + newJob := r.newJob + if newJob == nil { + newJob = func() (runnerJob, error) { return process.NewJob() } + } + job, jobErr := newJob() if errors.Is(jobErr, process.ErrUnsupported) { _ = command.Process.Kill() _ = command.Wait() @@ -255,27 +267,31 @@ func (r *UVRunner) Run( } if jobErr != nil { _ = command.Process.Kill() - _ = command.Wait() - return UVResult{}, newError( + waitErr := command.Wait() + failure := newError( protocol.CodeUVExecFailed, options.Stage, "uv 进程隔离失败", map[string]any{"failureKind": "process_isolation_failed"}, jobErr, ) + failure.diagnostics = isolationDiagnostics(UVResult{ExitCode: exitCode(waitErr), Duration: clock().Sub(started)}, resolved, "create_job", jobErr) + return UVResult{}, failure } if job != nil { if err := job.Assign(uint32(command.Process.Pid)); err != nil { _ = command.Process.Kill() - _ = command.Wait() + waitErr := command.Wait() _ = job.Close() - return UVResult{}, newError( + failure := newError( protocol.CodeUVExecFailed, options.Stage, "uv 进程隔离失败", map[string]any{"failureKind": "process_isolation_failed"}, err, ) + failure.diagnostics = isolationDiagnostics(UVResult{ExitCode: exitCode(waitErr), Duration: clock().Sub(started)}, resolved, "assign_job", err) + return UVResult{}, failure } } jobStop := make(chan struct{}) From ea7ab83f6150e4a9438063a8b46630d2dbf2d534 Mon Sep 17 00:00:00 2001 From: Ji Mao Cai <44921326+jinghero@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:22:46 +0800 Subject: [PATCH 5/9] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E4=B8=8E=20Python=20=E8=AF=8A=E6=96=AD=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=20(T12.11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...42\345\244\215\350\257\212\346\226\255.md" | 27 +++++++++++++++++++ ...73\345\212\241\346\213\206\345\210\206.md" | 1 + 2 files changed, 28 insertions(+) diff --git "a/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" "b/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" index 2ed8093..13a4eaa 100644 --- "a/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" +++ "b/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" @@ -155,6 +155,33 @@ filesystem 两项通过;修复版相同定向命令得到相同结果。只能 state 和 telemetry 全包通过;新增诊断用例十轮通过。完整 Windows 门禁由本次 PR CI 再验收, 不把本机结果表述为全仓全绿。 +## 目录与 Python 增补验收 + +2026-11-12,实现提交 `24b4b8a`。目录占用与清理回归在原实现缺少诊断接口时失败; +Job 工厂注入后,真实 runner 创建与 Assign 失败分支因诊断为空失败。Python 离线、中继和 +逐源三条路径,以及 CLI 日志绑定/Sentry 白名单使用原实现 overlay,均复现字段或文件记录缺失。 +补齐后以下命令退出 0,目标均有 PASS,未出现 `[no tests to run]`: + +```powershell +go test ./internal/gitrepo ./internal/uv ./internal/cli ./internal/telemetry -run '^(TestSwapper_OperationDiagnostics|TestFetcher_CleanupDiagnostics|TestRunner_IsolationFailureDiagnostics|TestPython_InstallFailureDiagnostics|TestWorkspaceSync_FailureDiagnosticsAreRecorded|TestSentry_RepositoryFailureDiagnostics|TestDependencies_(RelayPreservesExecutionDiagnostics|MirrorFallbackPreservesExecutionDiagnostics)|TestRunOperation_.*Diagnostics|TestRunOperation_DiagnosticsRespectSelectedFailure|TestBackendTelemetry_PreservesPrimaryAndCleanupFacts)$' -count=10 -v +``` + +覆盖真实 Swapper/Fetcher/Prepare/Run/CLI 入口:新仓库激活后清理失败、临时 update 清理失败、 +Job 创建与 Assign、Python 离线与中继以及先前尝试非零退出但最后一次退出 0 且读取失败。 +本地文件日志恰好一条最终 error,Sentry fake transport 保留目录角色和审计标记并清除私有字段。 +既有协议 details、取消和错误优先级回归通过。新增业务代码没有 stdout 或 Sentry SDK 依赖, +旧依赖专用诊断函数已删除,全部调用方迁移到共享实现。 + +`gofmt -l .` 输出为空,`go vet ./...`、`go build -buildvcs=false ./...`、 +`git diff --check` 退出 0。完整 `go test ./... -count=1` 退出 1,仅 lock 的 SUBST W: +设备未就绪和 process 的 `TestJob_BreakawayGrandchildSurvivesJobClose` Job 归属断言失败; +其余包均通过,包括 filesystem、gitrepo、uv、CLI、telemetry 和 backend Windows E2E。 +在未修改的本轮 PR 基线 `ad7b73b` 定向运行相同两个父用例,两项错误均复现。 +未修改这些实现或夹具,不把本机完整测试表述为全绿,等待本轮 Windows CI。 + +没有修改 Job 的终止/等待/关闭顺序、重试策略或并发所有权,不增加 race 通过结论。 +验证只使用临时受管根、假 Git/uv/中继和 Sentry transport,没有访问生产 Sentry 或替换安装版。 + ## 镜像回退 P2 修复与 pstack 复审 `TestDependencies_MirrorFallbackPreservesExecutionDiagnostics` 经真实 `Sync` 入口令中继启动失败, diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index c77b73b..2b87159 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -1123,6 +1123,7 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 - 2026-11-12 增补:按用户提供的 exFAT 状态收尾失败日志,在 PR #35 继续补齐 State 提交与恢复标记、主因与清理错误、系统操作诊断及早期失败落盘。先更新契约与回归,不改变文件系统或恢复语义。 - 增补实现 `0af00a3`:新增诊断回归十轮通过,格式/vet/build/diff check 通过,真实 EXE 离线验证早期失败落盘。完整本机测试仍有文件清理、SUBST 和 Job 断言失败,基线对照范围见设计验收;待本次 PR CI 与合入。 - 后续范围:继续补齐目录占用与清理角色/结果、uv Job 创建和 Assign、Python 中继与逐源安装的最后执行事实;复用既有诊断通道,并让 workspace 失败事实进入本地终态日志。 + - 后续实现 `24b4b8a`:四类诊断及 CLI/Sentry 出口回归十轮通过,格式/vet/build/diff check 通过;完整本机测试仅剩在本轮未修改基线 `ad7b73b` 同样复现的 SUBST 与 Job 限制,等待本轮 CI 与合入。 ### - [ ] M13 dev 基线接入配套 From bed713e50dff2e33693d623a3d25d492ca7dc738 Mon Sep 17 00:00:00 2001 From: Ji Mao Cai <44921326+jinghero@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:03:59 +0800 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E4=B8=8E=20Sentry=20=E5=85=B3=E8=81=94?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=20(T12.11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...42\345\244\215\350\257\212\346\226\255.md" | 18 ++ ...42\345\244\215\350\257\212\346\226\255.md" | 24 ++ ...66\346\236\204\350\256\276\350\256\241.md" | 8 + internal/cli/backend.go | 2 + internal/cli/cli.go | 7 +- internal/cli/contract_staging_test.go | 1 + internal/cli/session.go | 41 ++- internal/cli/session_diagnostics.go | 181 +++++++++++ internal/cli/session_diagnostics_test.go | 292 ++++++++++++++++++ internal/cli/telemetry.go | 41 ++- internal/cli/telemetry_test.go | 9 +- .../state_diagnostics_windows_test.go | 63 ++++ internal/filesystem/state_files.go | 5 +- internal/filesystem/state_files_windows.go | 28 +- internal/logging/logger.go | 8 + internal/logging/volume.go | 3 + internal/state/diagnostics.go | 79 +++++ internal/state/diagnostics_test.go | 24 ++ internal/state/errors.go | 1 + internal/state/write.go | 8 +- internal/telemetry/diagnostics.go | 56 ++++ internal/telemetry/observer.go | 41 ++- internal/telemetry/observer_test.go | 13 +- internal/telemetry/runtime_context_test.go | 38 +++ internal/telemetry/sentry.go | 18 +- 25 files changed, 958 insertions(+), 51 deletions(-) create mode 100644 internal/cli/session_diagnostics.go create mode 100644 internal/cli/session_diagnostics_test.go create mode 100644 internal/filesystem/state_diagnostics_windows_test.go create mode 100644 internal/telemetry/runtime_context_test.go diff --git "a/doc/current/M12/\350\256\241\345\210\222-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" "b/doc/current/M12/\350\256\241\345\210\222-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" index cb4da5d..247e5cd 100644 --- "a/doc/current/M12/\350\256\241\345\210\222-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" +++ "b/doc/current/M12/\350\256\241\345\210\222-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" @@ -1,5 +1,23 @@ # T12.11 实施计划 +## 会话增补 + +代码提交范围:`internal/cli/{backend,cli,session,telemetry,session_diagnostics}.go`、 +`internal/cli/{contract_staging,telemetry,session_diagnostics}_test.go`、 +`internal/filesystem/{state_files,state_files_windows,state_diagnostics_windows_test}.go`、 +`internal/logging/{logger,volume}.go`、`internal/state/{diagnostics,diagnostics_test,errors,write}.go`、 +`internal/telemetry/{diagnostics,observer,observer_test,sentry,runtime_context_test}.go`, +以及本设计/计划和架构增补。任务进度与最终验收独立提交。 + +1. 先增加 TestSessionDiagnostics_Lifecycle、TestSessionDiagnostics_EarlyFailure、 + TestSessionDiagnostics_ReadOnly、TestSessionDiagnostics_Concurrent:真实文件中验证版本、操作 ID、 + 成功摘要、阶段计时、状态顺序及早期失败;只读成功无日志。确认原实现字段缺失红灯。 +2. 增加 TestSentry_CorrelationReceipt、TestSanitizeDiagnostics_RuntimeContext、 + TestStateErrors_DiagnosticDetails、TestStateFiles_FailureUnlinkMode,确认缺失字段红灯; + 实现 renderer 装饰、事件回执 API、白名单及状态模式快照,所有替身同步迁移。 +3. 定向测试 -count=10 -v,完整基础门、protocol -count=100 和完整 race; + 审查实际文件和 fake transport。代码提交后独立提交验收记录,继续更新 PR #35。 + 1. 锁与通用观察:扩展锁故障夹具与 CLI 回归,确认缺少操作/系统错误码的红灯,再实现诊断接口和错误链合并。 固定用例 `TestNewSet_FailureDiagnostics`、`TestRunOperation_PreservesNestedFailureDiagnostics`。 2. 依赖:中继同步退出 0 且输出读取失败,诊断必须保留底层分类,协议和清理保持原状。 diff --git "a/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" "b/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" index 13a4eaa..af2acb5 100644 --- "a/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" +++ "b/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" @@ -1,5 +1,29 @@ # T12.11 锁、依赖与恢复失败诊断 +## 会话与关联诊断增补(2026-11-12) + +写操作在 hello 保存开始时间,在首个有效进度/状态或有效终态建立独立会话日志器, +覆盖业务日志器建立前的失败;参数/版本/模式被拒绝时不创建目录。 +记录 operation.start、operation.end、state.change 和 stage.end;阶段仅在变化时计时, +同阶段进度不逐条复制,成功摘要保留净化结构字段及 changed/staged/updateAvailable 布尔值。 +版本、操作 ID 和持续时间可在本地关联。本地失败统一保存有界错误链,原始文本不进入 Sentry。 +只读命令保持零日志副作用;若发生失败,可在失败出口尝试创建终态日志。日志不可用仍用 stderr, +不得改变错误优先级、协议或退出码。会话日志器由会话关闭,业务日志器的所有权保持原状。 + +CLI 使用 EventRenderer 装饰器观察已成功投影的状态、进度和终态,不修改 protocol 的序列或锁。 +观察路径只写独立本地日志,不调用 Emitter;错误与 panic 均静默隔离,顺序沿用 renderer 锁。 +Sentry 继续仅在终态后捕获,使用同一规范 ULID operation_id;该字段不参与 fingerprint。 +Recorder.CaptureInternal 返回 SDK 接受的事件 ID(空值表示没有回执),所有实现及替身一起迁移。 +本地记录 sentryEventId 和 accepted,表示 SDK 接受,不承诺远端投递成功;不报告遥测失败。 + +文件系统格式复用日志器已有探测,白名单仅 NTFS/ReFS/exFAT/FAT/FAT32/unknown; +状态写入/条件删除错误在现有锁内保存 unlinkMode(classic/posix/unknown), +已经尝试删除、但没有独立清理错误时也用 StateRemoveError 包装原 cause,以免丢失模式; +未尝试删除的干净身份不匹配、取消、关闭等保持原错误形态。 +errors.Is/As、提交与恢复标志保持原值。 +不新增探测或改变文件系统行为。Corrupt/UnsupportedSchema/Validation/PIDProbe 提供结构诊断, +不上传状态内容。logPath 只额外接受已知命令生成的带空格日志文件名,其他任意空格名称仍替换为 [path]。 + ## 范围 v0.1.29 的 Sentry 样本中,Mutex 故障没有操作或系统错误码;依赖同步有退出码 0 却标为非零退出; diff --git "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" index a4411cf..d92f8b8 100644 --- "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" +++ "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -1976,6 +1976,14 @@ Runtime 不实现自更新。`auto-mas-runtime.exe` 跟随 Electron 桌面应用 ## 遥测与错误观测策略(Sentry-only) +2026-11-12 T12.11 增补:写操作的独立会话日志记录版本、开始/结束、状态迁移、阶段耗时和净化后的成功摘要; +只读成功不创建日志,早期失败尽力落盘,日志故障不改变协议或命令结果。Sentry 可携带与协议/本地日志相同的 +规范 ULID `operation_id`(不参与分组);SDK 接受的事件 ID 只在本地记为 `sentryEventId`,不作为投递成功证明。 +诊断白名单增补受限文件系统格式、状态 `unlinkMode` 和状态校验/探针分类;禁止状态内容、原始错误和完整路径。 +成功摘要的 changed/staged/updateAvailable 仅接受布尔值;参数、版本或模式校验拒绝不创建日志目录。 +StateFiles 条件删除在已经尝试 unlink 后以 StateRemoveError 保留主因、清理错误及实际 unlinkMode;未尝试删除的干净身份不匹配等保持原错误形态,错误链和副作用标志不变。 +带空格的 `logPath` 仅保留已知 Runtime 命令产生的文件名。 + 2026-08-04 的 D8、2026-08-11 的 D9 和 2026-08-12 的 D10 曾先后规划 PostHog 与自建 Umami;这些 provider 方案均已由 2026-08-12 的 D11 取消,仅保留历史记录用于追溯。 当前 M12 只接入 Sentry 错误观测。更换 Sentry 项目时只替换 DSN,不改变业务接入点、 diff --git a/internal/cli/backend.go b/internal/cli/backend.go index 85d4864..e8fab39 100644 --- a/internal/cli/backend.go +++ b/internal/cli/backend.go @@ -242,7 +242,9 @@ func runBackendSuperviseSession( exitCode, telemetryState.terminalWritten = safeEmitPanicFailure(deps, emitter, stage, failure) } } + deps.sessionLog.finish(exitCode) telemetryState.finish() + deps.sessionLog.close() }() output, err := newProcessOutput(deps) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index c586590..f6c7b40 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -154,9 +154,10 @@ type deps struct { options options global globalOptions // opLog 绑定命令的操作日志器;命令收尾后它可能已关闭。 - // 会话为曾开日志或发生状态故障的命令创建终态日志器,再通过此绑定记录最终失败。 - opLog *workspaceLogBinding - exitCode int + // 会话为有效失败创建终态日志器,再通过此绑定记录最终失败。 + opLog *workspaceLogBinding + sessionLog *sessionDiagnostics + exitCode int } // Execute 执行一次 Runtime 顶层命令并返回进程退出码。 diff --git a/internal/cli/contract_staging_test.go b/internal/cli/contract_staging_test.go index b9b2cbc..39587e8 100644 --- a/internal/cli/contract_staging_test.go +++ b/internal/cli/contract_staging_test.go @@ -111,6 +111,7 @@ func TestWorkspaceRemote_FailureDiagnostics(t *testing.T) { } observations, _, _ := recorder.snapshot() wantDiagnostics := telemetry.SanitizeDiagnostics(map[string]any{"attempts": attempts}) + wantDiagnostics["fileSystem"] = expectedSessionFileSystem(t, observations) if len(observations) != 1 || observations[0].Command != "workspace check" || observations[0].Stage != string(protocol.StageWorkspaceCheck) || !reflect.DeepEqual(observations[0].DiagnosticDetails, wantDiagnostics) { diff --git a/internal/cli/session.go b/internal/cli/session.go index cb99910..555fd50 100644 --- a/internal/cli/session.go +++ b/internal/cli/session.go @@ -89,7 +89,9 @@ func runOperationSession( exitCode, telemetryState.terminalWritten = safeEmitPanicFailure(deps, emitter, stage, failure) } } + deps.sessionLog.finish(exitCode) telemetryState.finish() + deps.sessionLog.close() }() output, err := newProcessOutput(deps) @@ -176,9 +178,9 @@ func runOperationSession( if panicked { telemetryState.addPanic(err, panicFrames) } - // 独立终态 writer 记录命令收尾后的错误;早期状态故障也须保留可导出的恢复线索。 + // 独立终态 writer 记录命令收尾和业务日志建立前的错误。 failureCode, _, _, _ := classifyFailure(err, stage) - if deps.opLog.Get() != nil || failureCode == protocol.CodeStateWriteFailed { + if sessionRecordsFailure(failureCode) && (deps.opLog.Get() != nil || deps.sessionLog != nil) { deps.opLog.Set(nil) logContext, cancelLog := context.WithTimeout(context.WithoutCancel(ctx), m5TransactionCleanupTimeout) defer cancelLog() @@ -228,18 +230,21 @@ func helloRuntimeVersion(ctx context.Context, source versionSourceFunc) string { } func newProcessOutput(deps *deps) (*protocol.ProcessOutput, error) { + var renderer protocol.EventRenderer + var err error switch deps.global.output { case outputNDJSON: - return protocol.NewProcessOutput(deps.io.Out) + renderer, err = protocol.NewNDJSONRenderer(deps.io.Out) case outputHuman: - renderer, err := protocol.NewHumanRenderer(deps.io.Out, deps.io.Err) - if err != nil { - return nil, err - } - return protocol.NewProcessOutputWithRenderer(renderer) + renderer, err = protocol.NewHumanRenderer(deps.io.Out, deps.io.Err) default: return nil, fmt.Errorf("unsupported output mode %q", deps.global.output) } + if err != nil { + return nil, err + } + deps.sessionLog = &sessionDiagnostics{EventRenderer: renderer, deps: deps} + return protocol.NewProcessOutputWithRenderer(deps.sessionLog) } // sessionSetupFailure 发生在协议会话建立阶段,此时只能写 stderr 诊断。 @@ -263,6 +268,9 @@ func emitSuccess(deps *deps, emitter *protocol.Emitter, stage protocol.Stage, su func emitFailure(deps *deps, emitter *protocol.Emitter, fallbackStage protocol.Stage, err error) (int, bool) { code, stage, message, details := classifyFailure(err, fallbackStage) + if deps.sessionLog != nil && sessionRecordsFailure(code) { + deps.sessionLog.observe(func() { deps.sessionLog.open() }) + } recordOperationFailure(deps, code, stage, message, details, err) errorEvent, eventErr := protocol.NewErrorEvent(code, stage, message, details) if eventErr != nil { @@ -446,7 +454,7 @@ func exitCodeFor(code protocol.Code) int { // 操作日志此前只有 uv 输出透传与清理审计,全是 info:真机上取到 2468 行日志零 warn // 零 error,失败本身、错误码与 stage 一条都不落盘,用户报上来无从查起。这里在失败的 // 唯一出口补一条 error 记录。状态写入失败另在本地日志保留底层 cause, -// 以便区分文件系统能力、占用与权限;协议事件仍只含稳定字段。 +// 以便区分文件系统能力、占用与权限;其他失败也保留有界错误链,协议仍只含稳定字段。 // // 日志器不可用时,状态写入失败把有界错误链写到 stderr,其余失败静默跳过; // 写日志失败不改变命令结局。 @@ -462,10 +470,17 @@ func recordOperationFailure( return } logger := deps.opLog.Get() + if logger == nil && code == protocol.CodeStateWriteFailed && cause != nil { + writeStateFailureDiagnostic(deps.io, cause) + } + if logger == nil && deps.sessionLog != nil { + deps.sessionLog.observe(func() { + if deps.sessionLog.logger != nil { + logger = deps.sessionLog.logger + } + }) + } if logger == nil { - if code == protocol.CodeStateWriteFailed && cause != nil { - writeStateFailureDiagnostic(deps.io, cause) - } return } entry := make(map[string]any, len(details)+2) @@ -482,8 +497,8 @@ func recordOperationFailure( if errors.As(cause, &commandErr) && commandErr.cause != nil { entry["cause"] = commandErr.cause.Error() } - entry["causes"], entry["causesTruncated"] = localStateErrorChain(cause) } + entry["causes"], entry["causesTruncated"] = localStateErrorChain(cause) ctx := deps.ctx if ctx == nil { ctx = context.Background() diff --git a/internal/cli/session_diagnostics.go b/internal/cli/session_diagnostics.go new file mode 100644 index 0000000..9dc0028 --- /dev/null +++ b/internal/cli/session_diagnostics.go @@ -0,0 +1,181 @@ +package cli + +import ( + "context" + "io" + "sync" + "time" + + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/logging" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/telemetry" +) + +// sessionDiagnostics 只观察协议投影,独立持有本地日志,不回调 Emitter。 +type sessionDiagnostics struct { + protocol.EventRenderer + deps *deps + // mu 保护日志器与计时快照;renderer 和终态收口共用该顺序。 + mu sync.Mutex + logger *logging.Logger + command string + operationID string + runtimeVersion string + started time.Time + stage protocol.Stage + stageStarted time.Time + openAttempted bool + ended bool +} + +func (s *sessionDiagnostics) RenderHello(event protocol.HelloEvent) error { + if err := s.EventRenderer.RenderHello(event); err != nil { + return err + } + s.observe(func() { + s.command, s.operationID, s.runtimeVersion = event.Command, event.OperationID, event.RuntimeVersion + s.started = s.deps.options.clock() + }) + return nil +} + +func sessionWritesLogs(command string) bool { + switch command { + case "bootstrap", "workspace sync", "workspace stage", "environment ensure", "environment repair", "dependencies sync", "dependencies rebuild", "backend supervise", "repair", "cleanup": + return true + default: + return false + } +} + +func (s *sessionDiagnostics) RenderProgress(event protocol.ProgressEvent) error { + if err := s.EventRenderer.RenderProgress(event); err != nil { + return err + } + s.observe(func() { s.changeStage(event.Stage) }) + return nil +} + +func (s *sessionDiagnostics) RenderState(event protocol.StateEvent) error { + if err := s.EventRenderer.RenderState(event); err != nil { + return err + } + s.observe(func() { + s.changeStage(event.Stage) + s.record("state.change", map[string]any{"stage": string(event.Stage), "status": string(event.Status), "sequence": event.Sequence}) + }) + return nil +} + +func (s *sessionDiagnostics) RenderResult(event protocol.ResultEvent) error { + err := s.EventRenderer.RenderResult(event) + s.observe(func() { + s.ended = true + if (event.Success && sessionWritesLogs(s.command)) || (!event.Success && sessionRecordsFailure(protocol.Code(event.Code))) { + s.open() + } + s.finishStage(s.deps.options.clock()) + s.record("operation.end", map[string]any{ + "success": event.Success, "code": event.Code, "stage": string(event.Stage), "status": event.Status, + "durationMs": elapsedMilliseconds(s.started, s.deps.options.clock()), "terminalWritten": err == nil, + "summary": telemetry.SanitizeDiagnostics(event.Details), + }) + }) + return err +} + +func (s *sessionDiagnostics) finish(exitCode int) { + if s == nil { + return + } + s.observe(func() { + if s.ended || s.logger == nil { + return + } + s.ended = true + s.finishStage(s.deps.options.clock()) + s.record("operation.end", map[string]any{"success": false, "exitCode": exitCode, "terminalWritten": false, + "durationMs": elapsedMilliseconds(s.started, s.deps.options.clock())}) + }) +} + +func (s *sessionDiagnostics) open() { + if s.openAttempted || s.operationID == "" { + return + } + s.openAttempted = true + ctx, cancel := context.WithTimeout(context.Background(), m5TransactionCleanupTimeout) + defer cancel() + logger, err := logging.New(ctx, s.deps.global.layout, io.Discard, s.command, s.operationID, logging.WithClock(s.deps.options.clock)) + if err != nil { + return + } + s.logger = logger + s.record("operation.start", map[string]any{"runtimeVersion": s.runtimeVersion, "protocolVersion": protocol.Version, "startedAt": s.started.Format(time.RFC3339Nano)}) +} + +func (s *sessionDiagnostics) changeStage(stage protocol.Stage) { + if sessionWritesLogs(s.command) { + s.open() + } + if s.stage == stage { + return + } + now := s.deps.options.clock() + s.finishStage(now) + s.stage, s.stageStarted = stage, now +} + +func sessionRecordsFailure(code protocol.Code) bool { + switch code { + case protocol.CodeInvalidArgument, protocol.CodeInvalidVersion, protocol.CodeUnsupportedMode: + return false + default: + return true + } +} + +func (s *sessionDiagnostics) finishStage(now time.Time) { + if s.stage == "" { + return + } + s.record("stage.end", map[string]any{"stage": string(s.stage), "durationMs": elapsedMilliseconds(s.stageStarted, now)}) + s.stage = "" +} + +func elapsedMilliseconds(start, end time.Time) int64 { + if start.IsZero() || end.Before(start) { + return 0 + } + return end.Sub(start).Milliseconds() +} + +func (s *sessionDiagnostics) record(operation string, details map[string]any) { + if s.logger == nil { + return + } + details["operation"] = operation + ctx, cancel := context.WithTimeout(context.Background(), m5TransactionCleanupTimeout) + defer cancel() + // 会话观察失败不能遮盖业务结果,业务日志仍保留原有错误处理。 + _, _ = s.logger.Record(ctx, logging.LevelInfo, "运行时操作诊断", details) +} + +func (s *sessionDiagnostics) observe(run func()) { + defer func() { _ = recover() }() + s.mu.Lock() + defer s.mu.Unlock() + run() +} + +func (s *sessionDiagnostics) close() { + if s == nil { + return + } + s.observe(func() { + if s.logger != nil { + // 附加观察日志不改变退出码,日志器负责尽力关闭全部句柄。 + _ = s.logger.Close() + } + }) +} diff --git a/internal/cli/session_diagnostics_test.go b/internal/cli/session_diagnostics_test.go new file mode 100644 index 0000000..05d0525 --- /dev/null +++ b/internal/cli/session_diagnostics_test.go @@ -0,0 +1,292 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/config" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/telemetry" +) + +func diagnosticSessionFixture(t *testing.T) (*deps, *bytes.Buffer, *time.Time) { + t.Helper() + root := t.TempDir() + layout, err := config.NewLayout(root, root) + if err != nil { + t.Fatal(err) + } + values, err := applyOptions() + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 11, 12, 12, 0, 0, 0, time.Local) + values.clock = func() time.Time { return now } + out := &bytes.Buffer{} + d := &deps{ctx: t.Context(), io: IO{In: strings.NewReader(""), Out: out, Err: &bytes.Buffer{}}, options: values, + global: globalOptions{layout: layout, output: outputNDJSON}, opLog: &workspaceLogBinding{}} + return d, out, &now +} + +func expectedSessionFileSystem(t *testing.T, observations []telemetry.InternalObservation) any { + t.Helper() + if len(observations) != 1 { + t.Fatalf("got %d observations, want 1", len(observations)) + } + format := observations[0].DiagnosticDetails["fileSystem"] + switch format { + case "NTFS", "ReFS", "exFAT", "FAT", "FAT32", "unknown": + return format + } + t.Fatalf("got filesystem %v, want known diagnostic format", format) + return nil +} + +func readSessionRecords(t *testing.T, d *deps) []map[string]any { + t.Helper() + paths, err := filepath.Glob(filepath.Join(d.global.layout.RuntimeLogDir(), "*.log")) + if err != nil { + t.Fatal(err) + } + var rows []map[string]any + for _, path := range paths { + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, line := range bytes.Split(bytes.TrimSpace(data), []byte{'\n'}) { + var row map[string]any + if err := json.Unmarshal(line, &row); err != nil { + t.Fatal(err) + } + rows = append(rows, row) + } + } + return rows +} + +func TestSessionDiagnostics_Lifecycle(t *testing.T) { + t.Setenv("AUTO_MAS_TELEMETRY", "disabled") + d, out, now := diagnosticSessionFixture(t) + code := runOperation(t.Context(), d, "bootstrap", protocol.StageBootstrap, func(_ context.Context, e *protocol.Emitter) (sessionSuccess, error) { + if err := e.EmitProgress(protocol.ProgressEvent{Stage: protocol.StageUVDownload, Status: protocol.ProgressRunning}); err != nil { + return sessionSuccess{}, err + } + *now = now.Add(2 * time.Second) + if err := e.EmitState(protocol.StateEvent{Stage: protocol.StagePythonInstall, Status: protocol.StatePreparingPython}); err != nil { + return sessionSuccess{}, err + } + *now = now.Add(3 * time.Second) + return sessionSuccess{message: "完成", details: map[string]any{"changed": true, "commit": "abcdef", "secret": "private"}}, nil + }) + if code != 0 { + t.Fatalf("got exit %d, want 0", code) + } + events := parseNDJSON(t, out.String()) + id := events[0].object["operationId"] + counts := map[string]int{} + stages := map[string]float64{} + for _, row := range readSessionRecords(t, d) { + details, _ := row["details"].(map[string]any) + operation, _ := details["operation"].(string) + counts[operation]++ + if operation != "" && row["operationId"] != id { + t.Fatalf("got ID %v, want %v", row["operationId"], id) + } + if operation == "operation.start" && details["runtimeVersion"] == nil { + t.Fatal("missing runtime version") + } + if operation == "stage.end" { + stages[details["stage"].(string)] = details["durationMs"].(float64) + } + if operation == "operation.end" && (details["success"] != true || details["durationMs"] != float64(5000) || strings.Contains(string(mustJSON(t, details)), "private")) { + t.Fatalf("got end %#v, want safe success and 5000 ms", details) + } + if operation == "operation.end" && details["summary"].(map[string]any)["changed"] != true { + t.Fatal("missing success change summary") + } + } + if counts["operation.start"] != 1 || counts["operation.end"] != 1 || counts["state.change"] != 1 || stages["uv.download"] != 2000 || stages["python.install"] != 3000 { + t.Fatalf("got counts=%v stages=%v, want complete lifecycle", counts, stages) + } +} + +func mustJSON(t *testing.T, value any) []byte { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return data +} + +func TestSessionDiagnostics_EarlyFailure(t *testing.T) { + t.Setenv("AUTO_MAS_TELEMETRY", "disabled") + d, _, _ := diagnosticSessionFixture(t) + code := runOperation(t.Context(), d, "bootstrap", protocol.StageBootstrap, func(context.Context, *protocol.Emitter) (sessionSuccess, error) { + return sessionSuccess{}, &commandError{code: protocol.CodeMutexOperationFailed, stage: protocol.StageBootstrap, message: "锁失败", cause: errors.New("private local cause")} + }) + if code != exitCodeFor(protocol.CodeMutexOperationFailed) { + t.Fatalf("got exit %d, want mutex failure", code) + } + found := false + for _, row := range readSessionRecords(t, d) { + if row["level"] == "error" { + found = true + } + } + if !found { + t.Fatal("missing early failure record") + } +} + +func TestSessionDiagnostics_ReadOnly(t *testing.T) { + t.Setenv("AUTO_MAS_TELEMETRY", "disabled") + d, _, _ := diagnosticSessionFixture(t) + code := runOperation(t.Context(), d, "workspace check", protocol.StageWorkspaceCheck, func(context.Context, *protocol.Emitter) (sessionSuccess, error) { return sessionSuccess{}, nil }) + if code != 0 || len(readSessionRecords(t, d)) != 0 { + t.Fatal("read-only success created logs") + } +} + +func TestSessionDiagnostics_Concurrent(t *testing.T) { + t.Setenv("AUTO_MAS_TELEMETRY", "disabled") + d, _, _ := diagnosticSessionFixture(t) + code := runOperation(t.Context(), d, "bootstrap", protocol.StageBootstrap, func(_ context.Context, e *protocol.Emitter) (sessionSuccess, error) { + var wg sync.WaitGroup + failures := make(chan error, 20) + for range 20 { + wg.Go(func() { + failures <- e.EmitState(protocol.StateEvent{Stage: protocol.StageUVDownload, Status: protocol.StatePreparingUV}) + }) + } + wg.Wait() + close(failures) + for err := range failures { + if err != nil { + return sessionSuccess{}, err + } + } + return sessionSuccess{}, nil + }) + count := 0 + var previous float64 + for _, row := range readSessionRecords(t, d) { + details, _ := row["details"].(map[string]any) + if details["operation"] == "state.change" { + sequence := details["sequence"].(float64) + if sequence <= previous { + t.Fatal("state log order differs from protocol") + } + previous = sequence + count++ + } + } + if code != 0 || count != 20 { + t.Fatalf("got code=%d states=%d, want 0 and 20", code, count) + } +} + +func TestSessionDiagnostics_TelemetryReceipt(t *testing.T) { + t.Setenv("AUTO_MAS_TELEMETRY", "enabled") + d, out, _ := diagnosticSessionFixture(t) + recorder := &telemetryRecorderSpy{} + d.options.telemetryFactory = func(telemetry.Config) telemetry.Recorder { return recorder } + code := runOperation(t.Context(), d, "bootstrap", protocol.StageBootstrap, func(context.Context, *protocol.Emitter) (sessionSuccess, error) { + return sessionSuccess{}, &commandError{code: protocol.CodeMutexOperationFailed, stage: protocol.StageBootstrap, message: "锁失败"} + }) + if code != exitCodeFor(protocol.CodeMutexOperationFailed) { + t.Fatalf("got exit %d, want mutex failure", code) + } + events := parseNDJSON(t, out.String()) + observations, closes, _ := recorder.snapshot() + if len(observations) != 1 || closes != 1 || observations[0].OperationID != events[0].object["operationId"] || observations[0].DiagnosticDetails["fileSystem"] == nil { + t.Fatalf("got observations %#v closes %d, want correlated format", observations, closes) + } + found := false + for _, row := range readSessionRecords(t, d) { + details, _ := row["details"].(map[string]any) + if details["operation"] == "telemetry.accepted" && details["sentryEventId"] == "0123456789abcdef0123456789abcdef" { + found = true + } + } + if !found { + t.Fatal("missing local event receipt") + } +} + +func TestSessionTelemetry_EnvironmentIsSanitized(t *testing.T) { + recorder := &telemetryRecorderSpy{} + failure := &commandError{code: protocol.CodeMutexOperationFailed, stage: protocol.StageBootstrap, message: "锁失败"} + recordSessionTelemetry(recorder, "bootstrap", protocol.StageBootstrap, "dev", failure, false, nil, "01J00000000000000000000000", "private-format") + observations, _, _ := recorder.snapshot() + if len(observations) != 1 || observations[0].DiagnosticDetails["fileSystem"] != nil { + t.Fatalf("got %#v, want no unapproved filesystem format", observations) + } +} + +func TestSessionDiagnostics_UnavailableLogPreservesResult(t *testing.T) { + t.Setenv("AUTO_MAS_TELEMETRY", "disabled") + d, out, _ := diagnosticSessionFixture(t) + if err := os.WriteFile(filepath.Join(d.global.layout.AppRoot(), "debug"), []byte("blocked"), 0600); err != nil { + t.Fatal(err) + } + code := runOperation(t.Context(), d, "bootstrap", protocol.StageBootstrap, func(context.Context, *protocol.Emitter) (sessionSuccess, error) { + return sessionSuccess{message: "完成"}, nil + }) + events := parseNDJSON(t, out.String()) + if code != 0 || eventType(events[len(events)-1]) != "result" || events[len(events)-1].object["success"] != true { + t.Fatal("observation log failure changed protocol result") + } +} + +type failingDiagnosticOutput struct { + bytes.Buffer + broken bool +} + +func (w *failingDiagnosticOutput) Write(value []byte) (int, error) { + if w.broken { + return 0, io.ErrClosedPipe + } + return w.Buffer.Write(value) +} + +func TestSessionDiagnostics_BrokenOutputStillEnds(t *testing.T) { + t.Setenv("AUTO_MAS_TELEMETRY", "disabled") + d, _, _ := diagnosticSessionFixture(t) + out := &failingDiagnosticOutput{} + d.io.Out = out + code := runOperation(t.Context(), d, "bootstrap", protocol.StageBootstrap, func(_ context.Context, e *protocol.Emitter) (sessionSuccess, error) { + if err := e.EmitProgress(protocol.ProgressEvent{Stage: protocol.StageUVDownload, Status: protocol.ProgressRunning}); err != nil { + return sessionSuccess{}, err + } + out.broken = true + if err := e.EmitProgress(protocol.ProgressEvent{Stage: protocol.StageUVDownload, Status: protocol.ProgressRunning}); err != nil { + return sessionSuccess{}, err + } + return sessionSuccess{}, nil + }) + ends := 0 + for _, row := range readSessionRecords(t, d) { + details, _ := row["details"].(map[string]any) + if details["operation"] == "operation.end" { + ends++ + if details["terminalWritten"] != false { + t.Fatal("broken output marked terminal written") + } + } + } + if code != exitCodeFor(protocol.CodeOutputWriteFailed) || ends != 1 { + t.Fatalf("got code=%d ends=%d, want output failure with one local end", code, ends) + } +} diff --git a/internal/cli/telemetry.go b/internal/cli/telemetry.go index bb52691..bb6f974 100644 --- a/internal/cli/telemetry.go +++ b/internal/cli/telemetry.go @@ -13,6 +13,7 @@ import ( type telemetryFactory func(telemetry.Config) telemetry.Recorder type sessionTelemetryState struct { + deps *deps recorder telemetry.Recorder command string stage protocol.Stage @@ -71,9 +72,11 @@ func recordSessionTelemetry( operationErr error, panicked bool, panicFrames []telemetry.StackFrame, -) { + operationID string, + fileSystem string, +) string { if recorder == nil { - return + return "" } if recoveredFrames, recovered := recoveredControlReaderPanic(operationErr); recovered { panicked = true @@ -103,7 +106,12 @@ func recordSessionTelemetry( diagnosticDetails = map[string]any{} } if telemetry.IsReportableFailure(code) { - safeTelemetryCaptureInternal(recorder, telemetry.InternalObservation{ + if code != protocol.CodeInternalError { + diagnosticDetails["fileSystem"] = fileSystem + diagnosticDetails = telemetry.SanitizeDiagnostics(diagnosticDetails) + } + receipt := safeTelemetryCaptureInternal(recorder, telemetry.InternalObservation{ + OperationID: operationID, Command: command, Stage: string(stage), Code: string(code), @@ -115,8 +123,11 @@ func recordSessionTelemetry( Panic: panicked, PanicFrames: append([]telemetry.StackFrame(nil), panicFrames...), }) + safeTelemetryClose(recorder) + return receipt } safeTelemetryClose(recorder) + return "" } // failureTelemetryDetails 合并专用诊断副本,外层事实优先,协议 details 只作回退。 @@ -220,6 +231,7 @@ func newSessionTelemetryState( panicFrames []telemetry.StackFrame, ) *sessionTelemetryState { return &sessionTelemetryState{ + deps: deps, recorder: newTelemetryRecorder(deps, runtimeVersion), command: command, stage: stage, @@ -234,7 +246,17 @@ func (s *sessionTelemetryState) finish() { return } if s.terminalWritten || (!s.sessionStarted && s.panicked) { - recordSessionTelemetry( + operationID, fileSystem := "", "unknown" + if s.deps != nil && s.deps.sessionLog != nil { + log := s.deps.sessionLog + log.observe(func() { + operationID = log.operationID + if log.logger != nil { + fileSystem = log.logger.FileSystem() + } + }) + } + receipt := recordSessionTelemetry( s.recorder, s.command, s.stage, @@ -242,7 +264,13 @@ func (s *sessionTelemetryState) finish() { s.operationErr, s.panicked, s.panicFrames, + operationID, + fileSystem, ) + if receipt != "" && s.deps != nil && s.deps.sessionLog != nil { + log := s.deps.sessionLog + log.observe(func() { log.record("telemetry.accepted", map[string]any{"sentryEventId": receipt, "accepted": true}) }) + } return } safeTelemetryClose(s.recorder) @@ -301,11 +329,12 @@ func telemetryOutputFailure(stage protocol.Stage) error { } } -func safeTelemetryCaptureInternal(recorder telemetry.Recorder, observation telemetry.InternalObservation) { +func safeTelemetryCaptureInternal(recorder telemetry.Recorder, observation telemetry.InternalObservation) (receipt string) { defer func() { _ = recover() }() if recorder != nil { - recorder.CaptureInternal(context.Background(), observation) + return recorder.CaptureInternal(context.Background(), observation) } + return "" } func safeTelemetryClose(recorder telemetry.Recorder) { diff --git a/internal/cli/telemetry_test.go b/internal/cli/telemetry_test.go index ca3e6c8..3dc99ee 100644 --- a/internal/cli/telemetry_test.go +++ b/internal/cli/telemetry_test.go @@ -37,13 +37,14 @@ type telemetryRecorderSpy struct { closeWaits bool } -func (r *telemetryRecorderSpy) CaptureInternal(_ context.Context, observation telemetry.InternalObservation) { +func (r *telemetryRecorderSpy) CaptureInternal(_ context.Context, observation telemetry.InternalObservation) string { r.mu.Lock() defer r.mu.Unlock() if r.resultWritten != nil && !r.resultWritten.Load() { r.beforeResult = true } r.internal = append(r.internal, observation) + return "0123456789abcdef0123456789abcdef" } func (r *telemetryRecorderSpy) Close(ctx context.Context) { @@ -378,6 +379,7 @@ func TestBootstrap_MutexFailureReportsDiagnostics(t *testing.T) { t.Fatalf("observations = %#v, want mutex failure", observations) } want := map[string]any{"mutex": map[string]any{"component": "mutation", "operation": "create-mutex", "windowsError": uint64(5)}} + want["fileSystem"] = expectedSessionFileSystem(t, observations) if got := observations[0].DiagnosticDetails; !reflect.DeepEqual(got, want) { t.Fatalf("diagnostics = %#v, want %#v", got, want) } @@ -415,7 +417,7 @@ func TestRunOperation_DiagnosticsRespectSelectedFailure(t *testing.T) { if len(observations) != 1 || observations[0].Code != string(protocol.CodeStateWriteFailed) { t.Fatalf("observations = %#v, want selected cleanup failure", observations) } - if got := observations[0].DiagnosticDetails; !reflect.DeepEqual(got, map[string]any{"exitCode": 42}) { + if got := observations[0].DiagnosticDetails; !reflect.DeepEqual(got, map[string]any{"exitCode": 42, "fileSystem": expectedSessionFileSystem(t, observations)}) { t.Fatalf("diagnostics = %#v, want selected failure facts", got) } } @@ -440,7 +442,8 @@ func TestBackendTelemetry_PreservesPrimaryAndCleanupFacts(t *testing.T) { } want := map[string]any{ "mode": "managed", "version": "v1.2.3", "commit": strings.Repeat("a", 40), - "exitCode": 1, "terminationRequested": true, + "fileSystem": expectedSessionFileSystem(t, observations), + "exitCode": 1, "terminationRequested": true, "failures": []any{ map[string]any{"phase": "primary", "code": "BACKEND_HEALTH_TIMEOUT", "stage": "backend.health"}, map[string]any{"phase": "cleanup", "code": "STATE_WRITE_FAILED", "stage": "backend.cleanup"}, diff --git a/internal/filesystem/state_diagnostics_windows_test.go b/internal/filesystem/state_diagnostics_windows_test.go new file mode 100644 index 0000000..b044f9d --- /dev/null +++ b/internal/filesystem/state_diagnostics_windows_test.go @@ -0,0 +1,63 @@ +//go:build windows + +package filesystem + +import ( + "errors" + "testing" + + "golang.org/x/sys/windows" +) + +func TestStateFiles_FailureUnlinkMode(t *testing.T) { + for _, mode := range []string{"posix", "classic", "unknown"} { + t.Run(mode, func(t *testing.T) { + files, _ := newStateFilesFixture(t) + originalAPI := files.api + if mode != "unknown" { + if _, err := files.WriteAtomic(t.Context(), StateMutation, []byte("prime")); err != nil { + t.Fatal(err) + } + files.classicUnlink = mode == "classic" + files.api.setStateDisposition = func(windows.Handle, stateDispositionSpec) error { return windows.ERROR_ACCESS_DENIED } + } else { + files.api.ntCreateRelative = func(windows.Handle, string, ntCreateSpec) (windows.Handle, error) { + return windows.InvalidHandle, windows.ERROR_ACCESS_DENIED + } + } + _, err := files.WriteAtomic(t.Context(), StateMutation, []byte("new")) + var writeErr *StateWriteError + if !errors.As(err, &writeErr) { + t.Fatalf("got %v, want StateWriteError", err) + } + if writeErr.UnlinkMode != mode { + t.Fatalf("got mode %v, want %s", writeErr.UnlinkMode, mode) + } + files.api = originalAPI + if _, err := files.WriteAtomic(t.Context(), StateMutation, []byte("recovered")); err != nil { + t.Fatalf("recover fixture state: %v", err) + } + }) + } +} + +func TestStateFiles_RemoveFailureUnlinkMode(t *testing.T) { + for _, mode := range []string{"posix", "classic"} { + t.Run(mode, func(t *testing.T) { + files, _ := newStateFilesFixture(t) + snapshot := writeAndReadStateSnapshot(t, files, StateMutation, []byte("payload")) + originalAPI := files.api + files.classicUnlink = mode == "classic" + files.api.setStateDisposition = func(windows.Handle, stateDispositionSpec) error { return windows.ERROR_ACCESS_DENIED } + _, err := files.RemoveTransactionIfUnchanged(t.Context(), snapshot) + var failure *StateRemoveError + if !errors.As(err, &failure) || failure.UnlinkMode != mode { + t.Fatalf("got %#v, want %s removal failure", err, mode) + } + files.api = originalAPI + if _, err := files.WriteAtomic(t.Context(), StateMutation, []byte("recovered")); err != nil { + t.Fatalf("recover fixture state: %v", err) + } + }) + } +} diff --git a/internal/filesystem/state_files.go b/internal/filesystem/state_files.go index c3dd2ac..6354adf 100644 --- a/internal/filesystem/state_files.go +++ b/internal/filesystem/state_files.go @@ -71,7 +71,7 @@ type stateFileOwner struct { // StateFiles 表示受管状态目录能力。 type StateFiles struct { - mu sync.RWMutex // 保护 closed、closeErr、pins、probePassed、classicUnlink 与操作/关闭的句柄生命周期。 + mu sync.RWMutex // 保护 closed、closeErr、pins、probePassed、classicUnlink、unlinkMode 与操作/关闭的句柄生命周期。 layout *config.Layout api pathAPI @@ -82,6 +82,7 @@ type StateFiles struct { pins [2]pinnedObject probePassed map[StateFileKind]bool classicUnlink bool + unlinkMode string closed bool closeErr error } @@ -121,6 +122,7 @@ type StateRemoveResult struct { // StateWriteError 保存写入阶段、主因和独立清理错误。 type StateWriteError struct { + UnlinkMode string Phase StateWritePhase MutationApplied bool RecoveryRequired bool @@ -154,6 +156,7 @@ func (e *StateWriteError) Unwrap() []error { // StateRemoveError 分离条件删除的主因和独立清理错误。 type StateRemoveError struct { + UnlinkMode string Cause error CleanupError error } diff --git a/internal/filesystem/state_files_windows.go b/internal/filesystem/state_files_windows.go index 62d2ed2..e2b6a59 100644 --- a/internal/filesystem/state_files_windows.go +++ b/internal/filesystem/state_files_windows.go @@ -328,7 +328,7 @@ func (f *StateFiles) WriteAtomic( ctx context.Context, kind StateFileKind, payload []byte, -) (WriteAtomicResult, error) { +) (result WriteAtomicResult, operationErr error) { if ctx == nil { return WriteAtomicResult{}, fmt.Errorf("%w: context is nil", ErrInvalidArgument) } @@ -342,6 +342,13 @@ func (f *StateFiles) WriteAtomic( f.mu.Lock() defer f.mu.Unlock() + f.unlinkMode = "unknown" + defer func() { + var failure *StateWriteError + if errors.As(operationErr, &failure) { + failure.UnlinkMode = f.unlinkMode + } + }() if f.closed { return WriteAtomicResult{}, ErrClosed } @@ -388,7 +395,7 @@ func (f *StateFiles) WriteAtomic( func (f *StateFiles) RemoveTransactionIfUnchanged( ctx context.Context, snapshot StateFileSnapshot, -) (StateRemoveResult, error) { +) (result StateRemoveResult, operationErr error) { if ctx == nil { return StateRemoveResult{}, fmt.Errorf("%w: context is nil", ErrInvalidArgument) } @@ -401,6 +408,21 @@ func (f *StateFiles) RemoveTransactionIfUnchanged( f.mu.Lock() defer f.mu.Unlock() + f.unlinkMode = "unknown" + defer func() { + var failure *StateRemoveError + if operationErr == nil { + return + } + if !errors.As(operationErr, &failure) { + if f.unlinkMode == "unknown" { + return + } + failure = &StateRemoveError{Cause: operationErr} + operationErr = failure + } + failure.UnlinkMode = f.unlinkMode + }() if f.closed { return StateRemoveResult{}, ErrClosed } @@ -1885,7 +1907,9 @@ func (f *StateFiles) unlinkStateObject( objectPath := object.path.String() disposition := statePOSIXDispositionSpec() operation := "posix-unlink" + f.unlinkMode = "posix" if f.classicUnlink { + f.unlinkMode = "classic" disposition = stateClassicDispositionSpec() operation = "classic-unlink" } diff --git a/internal/logging/logger.go b/internal/logging/logger.go index 35e8ccb..b145822 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -32,6 +32,14 @@ type Logger struct { activePath string closed bool closeErr error + fileSystem string +} + +// FileSystem 返回构造时已有的磁盘格式诊断,不再访问文件系统。 +func (l *Logger) FileSystem() string { + l.mu.Lock() + defer l.mu.Unlock() + return l.fileSystem } // New 创建并立即打开当前本地日期的 Runtime 日志文件。 diff --git a/internal/logging/volume.go b/internal/logging/volume.go index 270ec5b..bda4029 100644 --- a/internal/logging/volume.go +++ b/internal/logging/volume.go @@ -9,6 +9,9 @@ func (l *Logger) recordFileSystem(ctx context.Context, probe func(context.Contex // 格式只用于排障,探测不可用不能成为安装或启动的新前置条件。 format = "unknown" } + l.mu.Lock() + l.fileSystem = format + l.mu.Unlock() _, err = l.Diagnostic(ctx, LevelInfo, "当前磁盘格式:"+format, map[string]any{ "path": path, "fileSystem": format, diff --git a/internal/state/diagnostics.go b/internal/state/diagnostics.go index b3bf1cf..0ebe5e8 100644 --- a/internal/state/diagnostics.go +++ b/internal/state/diagnostics.go @@ -3,6 +3,7 @@ package state import ( "context" "errors" + "strings" "syscall" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/filesystem" @@ -19,12 +20,90 @@ func (e *WriteError) DiagnosticDetails() map[string]any { } details["mutationApplied"] = e.MutationApplied details["recoveryRequired"] = e.RecoveryRequired + if e.UnlinkMode == "classic" || e.UnlinkMode == "posix" || e.UnlinkMode == "unknown" { + details["unlinkMode"] = e.UnlinkMode + } if cleanup := persistenceCauseDetails(e.CleanupError); len(cleanup) > 0 { details["cleanup"] = cleanup } return map[string]any{"persistence": details} } +// DiagnosticDetails 返回状态损坏分类,不回显文件内容。 +func (e *CorruptError) DiagnosticDetails() map[string]any { + if e == nil { + return nil + } + details := persistenceDetails(e.File, nil) + details["phase"], details["failureKind"] = "decode", "corrupt" + for _, cause := range []struct { + err error + reason string + }{ + {errInvalidJSON, "invalid_json"}, {errDuplicateField, "duplicate_field"}, {errMissingField, "missing_field"}, + {errStateFileTooLarge, "too_large"}, {errInvalidValue, "invalid_value"}, + } { + if errors.Is(e.Cause, cause.err) { + details["reason"] = cause.reason + break + } + } + var validation *ValidationError + if errors.As(e.Cause, &validation) { + if field := stateDiagnosticField(validation.Field); field != "" { + details["field"] = field + } + } + return map[string]any{"persistence": details} +} + +// DiagnosticDetails 返回不支持的 schema 数字,不读取磁盘。 +func (e *UnsupportedSchemaError) DiagnosticDetails() map[string]any { + if e == nil { + return nil + } + details := persistenceDetails(e.File, nil) + details["phase"], details["failureKind"], details["got"] = "decode", "unsupported_schema", e.Got + return map[string]any{"persistence": details} +} + +// DiagnosticDetails 返回已知校验字段,不携带调用值。 +func (e *ValidationError) DiagnosticDetails() map[string]any { + if e == nil { + return nil + } + details := map[string]any{"phase": "validate", "failureKind": "validation"} + if field := stateDiagnosticField(e.Field); field != "" { + details["field"] = field + } + return map[string]any{"persistence": details} +} + +func stateDiagnosticField(value string) string { + switch value { + case "pid", "kind", "schemaVersion", "operationId", "command", "stage", "targetVersion", "baseCommit", "targetCommit", "status", "updatedAt", "startedAt", "version", "commit", "toolVersion", "logPath", "snapshot", "store", "ctx", "layout", "openFiles", "marshalIndent", "files", "lastSuccessful", "broken", "branch", "pythonVersion", "uvVersion", "reason": + return strings.ToLower(value) + default: + return "" + } +} + +// DiagnosticDetails 返回进程探针的 Win32 事实,不读取进程命令行。 +func (e *PIDProbeError) DiagnosticDetails() map[string]any { + if e == nil { + return nil + } + details := map[string]any{"phase": "probe", "failureKind": "pid_probe", "pid": e.PID} + switch e.Operation { + case "open-process", "wait-process", "close-handle", "get-process-times": + details["operation"] = e.Operation + } + if primary := persistenceCauseDetails(e.Cause); len(primary) > 0 { + details["primary"] = primary + } + return map[string]any{"persistence": details} +} + // DiagnosticDetails 返回状态读取的逻辑角色与结构化故障,不暴露文件内容。 func (e *ReadError) DiagnosticDetails() map[string]any { if e == nil { diff --git a/internal/state/diagnostics_test.go b/internal/state/diagnostics_test.go index c6aa4b9..7ac8e54 100644 --- a/internal/state/diagnostics_test.go +++ b/internal/state/diagnostics_test.go @@ -9,6 +9,30 @@ import ( "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/filesystem" ) +func TestStateErrors_DiagnosticDetails(t *testing.T) { + for _, test := range []struct { + name string + err error + kind string + }{ + {name: "corrupt", err: &CorruptError{File: "mutation", Cause: errInvalidJSON}, kind: "corrupt"}, + {name: "schema", err: &UnsupportedSchemaError{File: "environment", Got: 9}, kind: "unsupported_schema"}, + {name: "validation", err: &ValidationError{Field: "pid", Cause: errInvalidValue}, kind: "validation"}, + {name: "pid", err: &PIDProbeError{Operation: "open-process", PID: 123, Cause: syscall.Errno(5)}, kind: "pid_probe"}, + } { + t.Run(test.name, func(t *testing.T) { + diagnostic, ok := test.err.(interface{ DiagnosticDetails() map[string]any }) + if !ok { + t.Fatal("missing State diagnostic details") + } + details, _ := diagnostic.DiagnosticDetails()["persistence"].(map[string]any) + if details["failureKind"] != test.kind { + t.Fatalf("got %#v, want %s", details, test.kind) + } + }) + } +} + func TestWriteError_DiagnosticDetails(t *testing.T) { for _, test := range []struct { name string diff --git a/internal/state/errors.go b/internal/state/errors.go index b2ff72d..1d2de6a 100644 --- a/internal/state/errors.go +++ b/internal/state/errors.go @@ -141,6 +141,7 @@ func (e *ReadError) Unwrap() error { // WriteError 表示持久化写入或条件删除故障。 type WriteError struct { + UnlinkMode string File string Phase WritePhase MutationApplied bool diff --git a/internal/state/write.go b/internal/state/write.go index 4385741..bd119be 100644 --- a/internal/state/write.go +++ b/internal/state/write.go @@ -230,8 +230,9 @@ func classifyWriteResult( cause = err } return &WriteError{ - File: file, - Phase: phase, + File: file, + Phase: phase, + UnlinkMode: filesystemWriteErr.UnlinkMode, MutationApplied: result.mutationApplied || filesystemWriteErr.MutationApplied, RecoveryRequired: result.recoveryRequired || @@ -285,12 +286,14 @@ func classifyRemoveResult( cause := err var cleanup error + unlinkMode := "" var removeErr *filesystem.StateRemoveError if errors.As(err, &removeErr) { if removeErr.Cause != nil { cause = removeErr.Cause } cleanup = removeErr.CleanupError + unlinkMode = removeErr.UnlinkMode } if errors.Is(cause, filesystem.ErrIdentityChanged) { cause = &transactionChangedError{cause: cause} @@ -298,6 +301,7 @@ func classifyRemoveResult( return &WriteError{ File: file, Phase: WritePhaseRemove, + UnlinkMode: unlinkMode, MutationApplied: result.mutationApplied, RecoveryRequired: result.recoveryRequired, Cause: cause, diff --git a/internal/telemetry/diagnostics.go b/internal/telemetry/diagnostics.go index bda984f..1cabd7c 100644 --- a/internal/telemetry/diagnostics.go +++ b/internal/telemetry/diagnostics.go @@ -7,7 +7,9 @@ import ( "path/filepath" "reflect" "sort" + "strconv" "strings" + "time" "unicode" "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" @@ -37,10 +39,13 @@ const ( diagnosticProtocolStage diagnosticSHA256 diagnosticHTTPStatus + diagnosticFileSystem + diagnosticUnlinkMode ) // diagnosticFieldPolicies 是允许离开 Runtime 的结构字段白名单;未知字段默认丢弃。 var diagnosticFieldPolicies = map[string]diagnosticFieldKind{ + "fileSystem": diagnosticFileSystem, "unlinkMode": diagnosticUnlinkMode, "operation": diagnosticStableToken, "component": diagnosticStableToken, "phase": diagnosticStableToken, "failureKind": diagnosticStableToken, "outcome": diagnosticStableToken, "field": diagnosticStableToken, "reason": diagnosticStableToken, "sourceKind": diagnosticStableToken, "source": diagnosticSource, @@ -70,6 +75,7 @@ var diagnosticFieldPolicies = map[string]diagnosticFieldKind{ "maxBytes": diagnosticNumber, "count": diagnosticNumber, "orphanCount": diagnosticNumber, "partial": diagnosticBoolean, "committed": diagnosticBoolean, "removed": diagnosticBoolean, + "changed": diagnosticBoolean, "staged": diagnosticBoolean, "updateAvailable": diagnosticBoolean, "terminationRequested": diagnosticBoolean, "mutationApplied": diagnosticBoolean, "auditCompleted": diagnosticBoolean, @@ -124,6 +130,31 @@ func sanitizeDiagnosticValue(value any, key string, depth int, fields *int) any return nil } switch policy { + case diagnosticFileSystem: + typed, ok := diagnosticString(value) + if ok { + switch strings.ToLower(typed) { + case "ntfs": + return "NTFS" + case "refs": + return "ReFS" + case "exfat": + return "exFAT" + case "fat": + return "FAT" + case "fat32": + return "FAT32" + case "unknown": + return "unknown" + } + } + return nil + case diagnosticUnlinkMode: + typed, ok := diagnosticString(value) + if ok && (typed == "classic" || typed == "posix" || typed == "unknown") { + return typed + } + return nil case diagnosticStableToken: typed, ok := diagnosticString(value) if !ok || !validStableToken(typed, maxDiagnosticStringBytes, true) { @@ -488,6 +519,9 @@ func validDiagnosticFilename(value string) bool { if value == "" || len(value) > maxDiagnosticStringBytes || strings.TrimSpace(value) != value { return false } + if strings.Contains(value, " ") { + return validRuntimeLogFilename(value) + } for _, character := range value { if !((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || character == '.' || character == '_' || @@ -498,6 +532,28 @@ func validDiagnosticFilename(value string) bool { return true } +func validRuntimeLogFilename(value string) bool { + if !strings.HasSuffix(value, ".log") { + return false + } + stem := strings.TrimSuffix(value, ".log") + if index := strings.LastIndexByte(stem, '.'); index >= 0 { + segment, err := strconv.ParseUint(stem[index+1:], 10, 64) + if err != nil || segment == 0 { + return false + } + stem = stem[:index] + } + if len(stem) <= 9 || stem[len(stem)-9] != '-' { + return false + } + if !validCommand(stem[:len(stem)-9]) { + return false + } + _, err := time.Parse("20060102", stem[len(stem)-8:]) + return err == nil +} + func sensitiveDiagnosticKey(key string) bool { lower := strings.ToLower(key) for _, marker := range []string{"token", "secret", "password", "authorization", "cookie", "credential", "dsn"} { diff --git a/internal/telemetry/observer.go b/internal/telemetry/observer.go index 336f905..a20e59f 100644 --- a/internal/telemetry/observer.go +++ b/internal/telemetry/observer.go @@ -13,6 +13,7 @@ import ( // InternalObservation 是允许发送到 Sentry 的失败分类。 type InternalObservation struct { + OperationID string Command string Stage string Code string @@ -32,14 +33,14 @@ type StackFrame struct { Lineno int } -// Recorder 是 CLI 消费的最小遥测接口;实现不得把错误返回给业务调用方。 +// Recorder 是 CLI 消费的最小遥测接口;捕获只返回 SDK 接受的事件 ID,不返回错误或投递保证。 type Recorder interface { - CaptureInternal(context.Context, InternalObservation) + CaptureInternal(context.Context, InternalObservation) string Close(context.Context) } type provider interface { - captureInternal(InternalObservation) + captureInternal(InternalObservation) string close(context.Context) } @@ -110,14 +111,14 @@ func safeProviderFactory(factory providerFactory, config Config) (candidate prov } // CaptureInternal 记录允许上报的失败分类;名称为既有内部接口,不携带原始错误文本。 -func (o *Observer) CaptureInternal(ctx context.Context, observation InternalObservation) { +func (o *Observer) CaptureInternal(ctx context.Context, observation InternalObservation) string { if o == nil || ctx == nil || ctx.Err() != nil || !validInternalObservation(observation) { - return + return "" } o.mu.RLock() if o.closed { o.mu.RUnlock() - return + return "" } providers := append([]provider(nil), o.providers...) o.mu.RUnlock() @@ -127,11 +128,15 @@ func (o *Observer) CaptureInternal(ctx context.Context, observation InternalObse closed := o.closed o.mu.RUnlock() if closed { - return + return "" } + receipt := "" for _, candidate := range providers { - safeProviderCaptureInternal(candidate, observation) + if id := safeProviderCaptureInternal(candidate, observation); id != "" { + receipt = id + } } + return receipt } // Close 在给定上下文截止前尽力收口全部 provider,重复调用安全。 @@ -171,6 +176,9 @@ func (o *Observer) Close(ctx context.Context) { } func validInternalObservation(observation InternalObservation) bool { + if observation.OperationID != "" && !validOperationID(observation.OperationID) { + return false + } if !observation.Panic && len(observation.PanicFrames) > 0 { return false } @@ -279,11 +287,24 @@ func safeProviderClose(candidate provider, ctx context.Context) { candidate.close(ctx) } -func safeProviderCaptureInternal(candidate provider, observation InternalObservation) { +func safeProviderCaptureInternal(candidate provider, observation InternalObservation) (receipt string) { defer func() { _ = recover() }() if candidate != nil { - candidate.captureInternal(observation) + return candidate.captureInternal(observation) + } + return "" +} + +func validOperationID(value string) bool { + if len(value) != 26 || value[0] > '7' { + return false } + for _, character := range value { + if !strings.ContainsRune("0123456789ABCDEFGHJKMNPQRSTVWXYZ", character) { + return false + } + } + return true } func (o *Observer) flushDuration() time.Duration { diff --git a/internal/telemetry/observer_test.go b/internal/telemetry/observer_test.go index c0e636f..b5ecddd 100644 --- a/internal/telemetry/observer_test.go +++ b/internal/telemetry/observer_test.go @@ -18,7 +18,7 @@ type fakeProvider struct { deadline chan time.Time } -func (p *fakeProvider) captureInternal(InternalObservation) {} +func (p *fakeProvider) captureInternal(InternalObservation) string { return "" } func (p *fakeProvider) close(ctx context.Context) { p.mu.Lock() @@ -213,13 +213,13 @@ func TestConfig_InvalidSentryAddressSkipsFactory(t *testing.T) { type recordingProvider struct{ internalCalls int } -func (p *recordingProvider) captureInternal(InternalObservation) { p.internalCalls++ } -func (p *recordingProvider) close(context.Context) {} +func (p *recordingProvider) captureInternal(InternalObservation) string { p.internalCalls++; return "" } +func (p *recordingProvider) close(context.Context) {} type panickingProvider struct{} -func (panickingProvider) captureInternal(InternalObservation) { panic("capture") } -func (panickingProvider) close(context.Context) { panic("close") } +func (panickingProvider) captureInternal(InternalObservation) string { panic("capture") } +func (panickingProvider) close(context.Context) { panic("close") } type blockingProvider struct { captureStarted chan struct{} @@ -227,9 +227,10 @@ type blockingProvider struct { closeStarted chan struct{} } -func (p *blockingProvider) captureInternal(InternalObservation) { +func (p *blockingProvider) captureInternal(InternalObservation) string { close(p.captureStarted) <-p.releaseCapture + return "" } func (p *blockingProvider) close(context.Context) { close(p.closeStarted) } diff --git a/internal/telemetry/runtime_context_test.go b/internal/telemetry/runtime_context_test.go new file mode 100644 index 0000000..e8a38be --- /dev/null +++ b/internal/telemetry/runtime_context_test.go @@ -0,0 +1,38 @@ +package telemetry + +import ( + "reflect" + "testing" +) + +func TestSanitizeDiagnostics_RuntimeContext(t *testing.T) { + got := SanitizeDiagnostics(map[string]any{"fileSystem": "exFAT", "unlinkMode": "classic", "logPath": `C:\private\backend supervise-20261112.log`}) + want := map[string]any{"fileSystem": "exFAT", "unlinkMode": "classic", "logPath": "backend supervise-20261112.log"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } + for _, value := range []string{"private words.log", "backend supervise-private.log"} { + if got := SanitizeDiagnostics(map[string]any{"logPath": value}); got["logPath"] != "[path]" { + t.Fatalf("got %#v, want redacted", got) + } + } + if got := SanitizeDiagnostics(map[string]any{"fileSystem": "private", "unlinkMode": "private"}); len(got) != 0 { + t.Fatalf("got %#v, want empty", got) + } +} + +func TestSentry_CorrelationReceipt(t *testing.T) { + p, transport := newSentryTestProvider(t) + observation := validSentryObservation(false) + id := "01J00000000000000000000000" + observation.OperationID = id + receipt := p.captureInternal(observation) + _, events, _, _ := transport.snapshot() + if len(events) != 1 || receipt == "" || receipt != string(events[0].EventID) || events[0].Tags["operation_id"] != id { + t.Fatalf("got receipt=%q events=%#v, want correlated event", receipt, events) + } + observation.OperationID = "private-token" + if receipt := p.captureInternal(observation); receipt != "" { + t.Fatal("accepted invalid operation ID") + } +} diff --git a/internal/telemetry/sentry.go b/internal/telemetry/sentry.go index c8bbf4a..c6fce6d 100644 --- a/internal/telemetry/sentry.go +++ b/internal/telemetry/sentry.go @@ -115,9 +115,9 @@ func newSentryProviderWithTransport(config Config, transport sentry.Transport) ( }, nil } -func (p *sentryProvider) captureInternal(observation InternalObservation) { +func (p *sentryProvider) captureInternal(observation InternalObservation) string { if p == nil || !validInternalObservation(observation) { - return + return "" } kind := sentryObservationInternal @@ -147,6 +147,9 @@ func (p *sentryProvider) captureInternal(observation InternalObservation) { if observation.Reason != "" { event.Tags["reason"] = observation.Reason } + if observation.OperationID != "" { + event.Tags["operation_id"] = observation.OperationID + } if kind == sentryObservationFailure && len(observation.DiagnosticDetails) > 0 { event.Contexts = map[string]sentry.Context{"diagnostics": { "details": observation.DiagnosticDetails, @@ -178,9 +181,12 @@ func (p *sentryProvider) captureInternal(observation InternalObservation) { p.mu.RLock() defer p.mu.RUnlock() if p.closed || p.hub == nil { - return + return "" + } + if id := p.hub.CaptureEvent(event); id != nil { + return string(*id) } - p.hub.CaptureEvent(event) + return "" } func (p *sentryProvider) close(ctx context.Context) { @@ -305,7 +311,7 @@ func sanitizeSentryDiagnostics(contexts map[string]sentry.Context) sentry.Contex } func sanitizeSentryTags(tags map[string]string) map[string]string { - allowed := [...]string{"command", "stage", "code", "reason", "runtime_version", "protocol_version", "platform"} + allowed := [...]string{"command", "stage", "code", "reason", "runtime_version", "protocol_version", "platform", "operation_id"} clean := make(map[string]string, len(allowed)) for _, key := range allowed { value := strings.TrimSpace(tags[key]) @@ -321,6 +327,8 @@ func validSentryTag(key, value string) bool { return false } switch key { + case "operation_id": + return validOperationID(value) case "command": return validCommand(value) case "stage": From 2aafdde31fe5838ca21562f55a21c711c9ca32ed Mon Sep 17 00:00:00 2001 From: Ji Mao Cai <44921326+jinghero@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:04:49 +0800 Subject: [PATCH 7/9] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=97=A5=E5=BF=97=E4=B8=8E=E8=AF=8A=E6=96=AD=E5=A2=9E?= =?UTF-8?q?=E8=A1=A5=E9=AA=8C=E6=94=B6=20(T12.11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...42\345\244\215\350\257\212\346\226\255.md" | 33 +++++++++++++++++++ ...73\345\212\241\346\213\206\345\210\206.md" | 4 +++ 2 files changed, 37 insertions(+) diff --git "a/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" "b/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" index af2acb5..18ae917 100644 --- "a/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" +++ "b/doc/current/M12/\350\256\276\350\256\241-T12.11-\351\224\201\344\276\235\350\265\226\344\270\216\346\201\242\345\244\215\350\257\212\346\226\255.md" @@ -1,5 +1,38 @@ # T12.11 锁、依赖与恢复失败诊断 +## 会话增补验收 + +2026-11-12,实现提交 `bed713e`。生命周期、早期非 State 失败、State 子类、文件名与环境白名单、 +操作关联回执均先复现缺项红灯。成功摘要 changed 和 CLI 环境字段净化分别新增红灯,修复后通过。 +参数拒绝零落盘、条件删除干净身份不匹配保持原形态,均由既有回归发现并修复。 + +CLI、telemetry、state、logging 的新旧诊断、错误优先级、Sentry fake transport 和隐私回归 +最后混合重复十轮通过(各包退出 0,目标有 PASS,无 `[no tests to run]`)。filesystem 的写入/ +删除模式、原条件删除矩阵曾完整重复十轮通过;最后混合重复中字段断言通过,但两轮用例结束后的 +TempDir RemoveAll 报目录非空,因此不能把该次混合命令称为整体通过。 + +`gofmt -l .` 无输出、`go vet ./...`、`go build -buildvcs=false ./...`、`git diff --check` +退出 0;`go test ./internal/protocol -count=100` 退出 0。完整 `go test ./... -count=1` 最后一次 +退出 1:filesystem 新模式测试和既有 StateGuard crash cutpoint 的 TempDir 清理失败,lock 的 +SUBST W: 未就绪、process 的 breakaway Job 归属断言失败;其余包通过,包括 CLI/backend E2E。 +在未修改的 PR 基线 `ea7ab83` 加入只复放文件操作、不读取诊断字段的临时测试,20 轮中同一 +classic 写入失败→恢复后的目录清理失败复现;既有 StateGuard crash cutpoint 也复现同类清理失败。 +这确认同操作的清理失败并非新诊断字段造成,但不声称每个 cutpoint 或 posix 子用例逐项复现。 +SUBST 与 Job 的先前基线证据保持有效。本轮不修改这些环境或测试基础设施。 + +本机 `go env CGO_ENABLED` 为 0,PATH 解析不到 gcc;完整 race 命令退出 2,提示需要 cgo, +未执行 race 测试,不能记录为通过。并发状态顺序用 channel/WaitGroup 测试,未使用 Sleep。 + +真实编译 EXE 在独立临时目录离线运行 bootstrap,退出 30/NETWORK_UNAVAILABLE,stage 为 +uv.download;文件中各一条 operation.start、最终 error、operation.end,版本为 dev, +全部记录 operationId 与协议 hello 一致,stdout 仍只有 hello 开始、唯一 result 结尾的 NDJSON。 +没有替换安装版,也没有向生产 Sentry 发送事件。证据、基线复放测试与完整命令输出保存在 +`%TEMP%/auto-mas-pr35-session-20261112/`。临时基线 worktree 已清理,原日志证据保留。 + +本轮自查覆盖 logger/renderer 所有权、关闭、协议锁内无 Emitter 回调、错误链与提交标记、 +只读成功及参数拒绝副作用、Sentry 双重净化和 fingerprint;事件回执不参与分组,不表示已投递。 +标准完整门仍待本轮 Windows CI 复核,任务保持进行中,不合并或发版。 + ## 会话与关联诊断增补(2026-11-12) 写操作在 hello 保存开始时间,在首个有效进度/状态或有效终态建立独立会话日志器, diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index 2b87159..6b52111 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -1114,6 +1114,8 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 本机完整测试与原始基线都出现 TempDir 清理及 W: 未就绪,详见设计验收记录;待 PR CI 与合入。 - [ ] **T12.11 锁、依赖与恢复失败诊断**(M) 🚧 2026-11-11 + - 2026-11-12 会话增补:在 PR #35 继续完成操作开始/结束、状态与阶段耗时、成功摘要、早期失败、操作 ID/Sentry 回执关联、带空格日志名、文件系统与 unlink 模式及 State 子类诊断;不改业务判定。 + - 会话增补实现 `bed713e`:CLI/telemetry/state/logging 诊断与隐私回归十轮通过,真实离线 EXE 验证首尾与失败记录;格式/vet/build/diff 及 protocol 100 轮通过。filesystem TempDir 清理在同操作基线复现,SUBST/Job 限制仍在;本机未配置 CGO/gcc,race 未执行。完整门待本轮 CI,详见设计验收。 - 依赖:T12.9、T12.10、T5.17;输入:v0.1.29 的 Mutex 空诊断、依赖同步分类与退出码矛盾、恢复目录形态缺失。 - 设计:[失败诊断补齐](current/M12/设计-T12.11-锁依赖与恢复诊断.md)。 - 内容:锁操作和系统错误码、依赖执行分类透传、已经观察的恢复目录状态,经既有诊断通道与双重净化上报。 @@ -1689,6 +1691,8 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 ## 7. 变更记录 +2026-11-12:按用户继续补齐的要求,T12.11 增补统一会话日志、Sentry 关联回执、状态环境和子类诊断;先修订设计、计划与架构,保持协议及错误优先级。 + 2026-11-12:按用户继续补齐的要求,T12.11 在 PR #35 扩展目录占用、仓库清理、uv 隔离和 Python 安装失败诊断,复用已执行操作与最后一次源尝试,不扩大文件操作或协议契约。 From 474fb130ef9b59034d0705304ccb1278a9cc4d2f Mon Sep 17 00:00:00 2001 From: Ji Mao Cai <44921326+jinghero@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:31:41 +0800 Subject: [PATCH 8/9] =?UTF-8?q?chore:=20=E5=8D=87=E7=BA=A7=20Go=20?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E9=93=BE=E8=87=B3=201.26.11=20(T10.18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 +- AGENTS.md | 4 ++-- ...11\345\205\250\346\233\264\346\226\260.md" | 23 +++++++++++++++++++ go.mod | 2 +- 4 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 "doc/current/M10/\350\256\276\350\256\241-T10.18-Go\345\256\211\345\205\250\346\233\264\346\226\260.md" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 446cbd5..179eff5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.26.10" + go-version: "1.26.11" cache: true - name: Check formatting diff --git a/AGENTS.md b/AGENTS.md index 4a6ddd6..580d7ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,7 +127,7 @@ doc/ 权威文档 ### 5.1 工具链 - Windows 10/11 + **PowerShell 7(`pwsh`)**; -- **Go 1.26**(`go.mod` 声明 `go 1.26.0`;固定工具链 Go 1.26.10); +- **Go 1.26**(`go.mod` 声明 `go 1.26.0`;固定工具链 Go 1.26.11); - MSYS2 UCRT64 **GCC/G++ 16.1.0**,`CGO_ENABLED=1`; - golangci-lint 2.12.2+(可选,本机当前未安装)。 @@ -181,7 +181,7 @@ if ($unformatted) { $unformatted; throw "gofmt found unformatted files" } ### 5.4 CI `.github/workflows/ci.yml`:push / PR 触发,`windows-latest` + `pwsh`, -步骤 = checkout → setup-go(固定 Go 1.26.10)→ gofmt 检查 → `go vet` → `go build` → `go test` → 固定版本 `govulncheck`。 +步骤 = checkout → setup-go(固定 Go 1.26.11)→ gofmt 检查 → `go vet` → `go build` → `go test` → 固定版本 `govulncheck`。 本地基础验证门与 CI 保持一致;race detector 是并发相关改动的本地追加门,当前 CI 未覆盖, 不得把本机 race 结果表述成 CI 证据。 diff --git "a/doc/current/M10/\350\256\276\350\256\241-T10.18-Go\345\256\211\345\205\250\346\233\264\346\226\260.md" "b/doc/current/M10/\350\256\276\350\256\241-T10.18-Go\345\256\211\345\205\250\346\233\264\346\226\260.md" new file mode 100644 index 0000000..a4334a5 --- /dev/null +++ "b/doc/current/M10/\350\256\276\350\256\241-T10.18-Go\345\256\211\345\205\250\346\233\264\346\226\260.md" @@ -0,0 +1,23 @@ +# T10.18 Go 安全更新 + +日期:2026-11-12。 + +PR #35 的 [CI 43145779274](https://github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/actions/runs/43145779274) +已通过格式、vet、build 和完整测试,唯一失败为 govulncheck 报告 net/http@go1.26.10 的 +GO-2026-6859,可达链涉及下载、中继和健康 HTTP 调用;检查注明修复于 go1.26.11。 +[Go 官方下载](https://go.dev/dl/#go1.26.11) 已提供对应版本。 + +范围是 go.mod 的 toolchain、CI 的 setup-go 固定版本和 AGENTS.md 当前开发说明。 +保留 go 1.26.0 语言版本、固定 govulncheck@v1.9.0、依赖版本、Action SHA 和全部检查。 +本地由标准 GOTOOLCHAIN=auto 选择工具链,不替换全局 Go 安装,不改 Runtime 协议或业务代码。 + +验证使用真实工具链与漏洞扫描,不增加复述版本字符串的单元测试。执行计划: + +1. 保存旧工具链的固定 govulncheck 失败,确认错误是 GO-2026-6859。 +2. 只改 go.mod、.github/workflows/ci.yml 和 AGENTS.md 三处当前配置。 +3. 确认 go version 实际为 go1.26.11,执行 gofmt、vet、build、完整 test、diff check 和固定 govulncheck。 +4. 构建临时 EXE,用 go version -m 确认实际工具链;按用户授权提交并推送 #35,跟踪最新 Windows CI。 +5. 本机已知 SUBST、Job 和 TempDir 清理限制如实记录,远端完整 CI 必须包含漏洞检查成功。 + +代码和设计提交范围仅为上述三文件及本设计;任务进度和最终验收独立提交。 +不触发合并、安装版替换、Tag、签名或 Release。 diff --git a/go.mod b/go.mod index 94511e4..7c70ae4 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/AUTO-MAS-Project/AUTO-MAS-Runtime go 1.26.0 -toolchain go1.26.10 +toolchain go1.26.11 require ( github.com/BurntSushi/toml v1.5.0 From cf08a0aeefc2e736f5b76997b8a66d941b4c3d4b Mon Sep 17 00:00:00 2001 From: Ji Mao Cai <44921326+jinghero@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:32:08 +0800 Subject: [PATCH 9/9] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20Go=20=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E6=9B=B4=E6=96=B0=E6=9C=AC=E5=9C=B0=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=20(T10.18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...56\211\345\205\250\346\233\264\346\226\260.md" | 15 +++++++++++++++ ...73\273\345\212\241\346\213\206\345\210\206.md" | 8 ++++++++ 2 files changed, 23 insertions(+) diff --git "a/doc/current/M10/\350\256\276\350\256\241-T10.18-Go\345\256\211\345\205\250\346\233\264\346\226\260.md" "b/doc/current/M10/\350\256\276\350\256\241-T10.18-Go\345\256\211\345\205\250\346\233\264\346\226\260.md" index a4334a5..d851134 100644 --- "a/doc/current/M10/\350\256\276\350\256\241-T10.18-Go\345\256\211\345\205\250\346\233\264\346\226\260.md" +++ "b/doc/current/M10/\350\256\276\350\256\241-T10.18-Go\345\256\211\345\205\250\346\233\264\346\226\260.md" @@ -21,3 +21,18 @@ GO-2026-6859,可达链涉及下载、中继和健康 HTTP 调用;检查注 代码和设计提交范围仅为上述三文件及本设计;任务进度和最终验收独立提交。 不触发合并、安装版替换、Tag、签名或 Release。 + +## 本地验收 + +实现提交 `474fb13`。旧 Go 1.26.10 的固定 govulncheck 命令退出 1,工具退出状态 3, +准确报告 GO-2026-6859;同一业务代码切换为 Go 1.26.11 后,固定命令退出 0、可达漏洞 0。 +仍有 3 项 require 模块级提示,但不在导入或可达调用中,不把扫描结果表述为所有依赖零提示。 + +`go version` 和临时 EXE 的 `go version -m` 均确认为 go1.26.11 windows/amd64。 +gofmt 无输出,vet、build、diff check 退出 0;完整 `go test ./... -count=1` 退出 1, +仅有先前已在未修改基线复现的 SUBST W: 未就绪和 breakaway Job 归属断言失败,其余包全通过, +包括 filesystem、CLI、backend Windows E2E、release 和 telemetry。没有修改相关实现或夹具。 +没有生产并发改动,本机原有 CGO/gcc 限制未处理,不新增 race 通过结论。 + +证据与候选 EXE 保存在 `%TEMP%/auto-mas-pr35-go-20261112/`。待 #35 本轮 Windows CI 验收, +任务在合入前保持进行中,不触发发布。 diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index 6b52111..e67e27c 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -1689,8 +1689,16 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 - 设计与验证:[T10.17](current/M10/设计-T10.17-健康环境准备复用.md)。已获贡献分支提交、推送与正式 PR 授权,不替换安装版或发布。 - 实现、真实计时、损坏验收与双轴复审完成,详见[验收](current/M10/验收-T10.17-健康环境准备复用.md)。最新中位数 7461.03 → 3814.17 ms;P2 输出失败与 P3 测量身份问题已修复,全仓有环境失败,待 PR 合入。 +- [ ] **T10.18 Go 1.26.11 安全更新** 🚧 2026-11-12 + - 依赖:T10.13;输入:PR #35 的完整测试通过,但固定 Go 1.26.10 的 net/http 命中 GO-2026-6859,govulncheck 失败。 + - 内容:统一模块 toolchain、CI 和当前开发说明至 Go 1.26.11,保留 go 1.26.0 语言版本及固定漏洞检查,不更新其他依赖或跳过门禁。 + - 设计与计划:[T10.18 安全更新](current/M10/设计-T10.18-Go安全更新.md)。验收实际工具链、格式/vet/build/完整测试、govulncheck 与 PR #35 最新 CI;不合并或发版。 + - 实现 `474fb13`:实际 Go 与候选 EXE 均为 1.26.11,固定漏洞扫描由 GO-2026-6859 失败变为退出 0、可达漏洞 0。格式/vet/build/diff 通过,本机完整测试仅余原有 SUBST/Job 限制,其余包通过;等待最新 Windows CI。 + ## 7. 变更记录 +2026-11-12:按用户要求在 PR #35 登记 T10.18,升级 Go 1.26.11 解除标准库漏洞门禁;保留语言版本、依赖和所有检查,以真实漏洞扫描及最新 CI 验收。 + 2026-11-12:按用户继续补齐的要求,T12.11 增补统一会话日志、Sentry 关联回执、状态环境和子类诊断;先修订设计、计划与架构,保持协议及错误优先级。 2026-11-12:按用户继续补齐的要求,T12.11 在 PR #35 扩展目录占用、仓库清理、uv 隔离和