sillyspec 3.28.4 → 3.28.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -0
- package/package.json +1 -1
- package/results-review-tier.md +18 -0
- package/src/agent-session-log.js +4 -3
- package/src/archive-delta.js +2 -2
- package/src/change-list.js +11 -1
- package/src/commit-suggest.js +52 -1
- package/src/config-cat.js +6 -1
- package/src/decision-distill.js +45 -5
- package/src/decisions-io.js +96 -0
- package/src/design-facts.js +3 -0
- package/src/dispatch/probe.js +31 -8
- package/src/docs-check.js +68 -3
- package/src/index.js +426 -17
- package/src/mcp-server.js +144 -0
- package/src/module-impact.js +8 -1
- package/src/modules.js +46 -34
- package/src/progress/change-registry.js +18 -0
- package/src/progress.js +13 -1
- package/src/quicklog.js +60 -6
- package/src/review-dispatch.js +377 -0
- package/src/run/archive-distill.js +48 -0
- package/src/run/command.js +19 -5
- package/src/run/complete-handlers.js +63 -3
- package/src/run/complete.js +139 -2
- package/src/run/gate-snapshot.js +88 -0
- package/src/run/gates.js +115 -55
- package/src/run/prompt.js +54 -9
- package/src/run/quick-audit.js +38 -13
- package/src/run/shared.js +109 -6
- package/src/run/stage.js +17 -2
- package/src/run/verify-quality-scan.js +336 -0
- package/src/scope-audit.js +735 -0
- package/src/sillyhub-mcp/client.js +58 -3
- package/src/stage-review-checklist.js +44 -0
- package/src/stage-review.js +122 -4
- package/src/stages/archive.js +57 -164
- package/src/stages/brainstorm.js +5 -9
- package/src/stages/execute.js +13 -10
- package/src/stages/plan.js +3 -10
- package/src/stages/verify.js +14 -33
- package/src/sync.js +51 -10
- package/src/task-review.js +108 -16
- package/src/taskcard.js +46 -3
- package/src/validate-artifacts.js +163 -0
- package/src/verify-postcheck.js +90 -10
- package/src/verify-probes.js +134 -12
- package/src/worktree-apply.js +119 -14
- package/templates/agents-instruction.md +1 -0
package/src/run/complete.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { join } from 'node:path'
|
|
20
20
|
import { existsSync, readFileSync, mkdirSync, writeFileSync, appendFileSync } from 'node:fs'
|
|
21
21
|
import { writeAtomicSync } from '../fs-atomic.js'
|
|
22
|
+
import { gitQuiet } from '../git-helper.js'
|
|
22
23
|
import { withFileLock } from '../quicklog.js'
|
|
23
24
|
import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveRuntimeRoot, getOrCreateMultiRepoContext, resolveChangeDir } from './shared.js'
|
|
24
25
|
|
|
@@ -48,7 +49,6 @@ import { formatExecuteSummary } from '../worktree-apply.js'
|
|
|
48
49
|
import { validateDecisionModuleRefs } from '../design-facts.js'
|
|
49
50
|
import { isEndToEndTaskText } from '../change-risk-profile.js'
|
|
50
51
|
import { deriveTitleFromLinkedChange } from '../quicklog.js'
|
|
51
|
-
import { gitQuiet } from '../git-helper.js'
|
|
52
52
|
|
|
53
53
|
// validateMetadata / readDesignScale / validateFileLocations 已迁至 ./gates.js(completeStageGates
|
|
54
54
|
// 共享收尾管线,消除 noAI 末步 / continueStep 完成分支绕过 gate 的 S1/S2/S3 不对称)。
|
|
@@ -97,6 +97,26 @@ export function resolveWaitingStepWithAnswer(steps, doneAnswer, nowStr) {
|
|
|
97
97
|
return waitIdx
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* 事实性步骤摘要合成(P0-2,noai-ir-roadmap §3):--output 省略时由 CLI 按既有事实合成
|
|
102
|
+
* ——步骤名 + 变更窗口(git porcelain 计数与前几个路径)+ 门禁以 CLI 校验输出为准。
|
|
103
|
+
* 纯事实零编造(无「通过/成功」等判断词——判断在 gate,不在摘要);语义性说明(方案
|
|
104
|
+
* 取舍/用户反馈)不合成,摘要自带手写指引。长度控制在 MAX_OUTPUT(200) 截断线内。
|
|
105
|
+
*/
|
|
106
|
+
export function synthesizeStepOutput({ stageName, stepName, cwd }) {
|
|
107
|
+
let windowNote = ''
|
|
108
|
+
try {
|
|
109
|
+
const porcelain = gitQuiet(cwd, ['status', '--porcelain'])
|
|
110
|
+
if (porcelain !== null) {
|
|
111
|
+
const paths = porcelain.split('\n').filter(Boolean).map((l) => (l.slice(3) || '').trim()).filter(Boolean)
|
|
112
|
+
windowNote = paths.length === 0
|
|
113
|
+
? ';工作区无未提交变更'
|
|
114
|
+
: `;变更窗口 ${paths.length} 文件(${paths.slice(0, 3).map((p) => p.split(/[\\/]/).pop()).join('、')}${paths.length > 3 ? ' 等' : ''})`
|
|
115
|
+
}
|
|
116
|
+
} catch { /* git 不可读 → 窗口段缺省(摘要仍成立) */ }
|
|
117
|
+
return `【CLI 合成】步骤「${stepName}」完成${windowNote};门禁以本次 CLI 校验输出为准(方案取舍等语义说明需 --output 手写)`
|
|
118
|
+
}
|
|
119
|
+
|
|
100
120
|
export async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
|
|
101
121
|
const { printNext = true, confirm = false, changeName, platformOpts = {}, nonInteractive = false, isForceBaseline = false, isAllowNew = false, isAllowDelete = false, quickFiles = [] } = options
|
|
102
122
|
// specRoot(平台) > specDriftAnchor(worktree 漂移锚定主仓) > cwd/.sillyspec(本地)——
|
|
@@ -158,6 +178,16 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
158
178
|
currentIdx = _resolvedWaitIdx
|
|
159
179
|
console.log(`⚠️ Step "${steps[_resolvedWaitIdx].name}" 此前处于 waiting,--done --answer 已补回答并拉回待完成。`)
|
|
160
180
|
}
|
|
181
|
+
|
|
182
|
+
// ── P0-2(noai-ir-roadmap §3):--output 省略 → CLI 按事实合成步骤摘要 ──
|
|
183
|
+
// agent 手打摘要是每步命中的幻觉源;事实面(步骤名 + 变更窗口 + 门禁以 CLI 校验为准)
|
|
184
|
+
// CLI 本就持有,纯事实合成零编造(判断词留给 gate)。语义性说明(方案取舍/用户反馈)仍应
|
|
185
|
+
// --output 手写——合成摘要自带该指引。quick 阶段除外(末步四字段硬契约
|
|
186
|
+
// validateQuickResult,合成文本过不了四字段校验;quick 的省略提示由其阶段文案承担)。
|
|
187
|
+
if (!outputText && stageName !== 'quick' && currentIdx !== -1 && steps[currentIdx]) {
|
|
188
|
+
outputText = synthesizeStepOutput({ stageName, stepName: steps[currentIdx].name, cwd })
|
|
189
|
+
console.log(`🤖 --output 未提供——CLI 已按事实合成步骤摘要(gate 校验照常;方案取舍等语义说明下次带 --output 手写)。`)
|
|
190
|
+
}
|
|
161
191
|
// ── waiting 前置守卫(坑 archive-step3-wait-answer-hint-late)──
|
|
162
192
|
// 普通 --done(无 --answer)时若存在 waiting 步骤:currentIdx 会跳过它推进后续步骤
|
|
163
193
|
//(findIndex 只查 pending/in-progress),步骤被静默越过 + --answer 要求要到别处报错才暴露。
|
|
@@ -380,6 +410,16 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
380
410
|
} else if (_cliAction === 'progressConfirm') {
|
|
381
411
|
// 与 stage.js noAI 分支同语义(brainstorm/execute/verify step1 进度确认)
|
|
382
412
|
executeProgressConfirm({ stageName, cwd, stageData, changeName, pm })
|
|
413
|
+
} else if (_cliAction === 'verifyRunQualityScan') {
|
|
414
|
+
// 与 stage.js noAI 分支同语义(P0-1 noai-ir-roadmap §3:verify 测试步实测提前 + 指纹复用;
|
|
415
|
+
// 失败 throw → completeStep 不推进,步骤保持 pending)
|
|
416
|
+
const { executeVerifyQualityScan } = await import('./verify-quality-scan.js')
|
|
417
|
+
await executeVerifyQualityScan({ cwd, specBase, changeName, platformOpts })
|
|
418
|
+
} else if (_cliAction === 'archiveDistill') {
|
|
419
|
+
// 与 stage.js noAI 分支同语义(P0-4 安全变体:distill 提炼 CLI 机械执行,needsWait 裁决
|
|
420
|
+
// 收敛到「确认归档 --confirm」;全路径不抛)
|
|
421
|
+
const { executeArchiveDistill } = await import('./archive-distill.js')
|
|
422
|
+
await executeArchiveDistill({ cwd, specBase, changeName })
|
|
383
423
|
} else if (_cliAction === 'doctorRunDiagnostics') {
|
|
384
424
|
// 2026-09-09-doctor-noai:与 stage.js 分支同语义(诊断+渲染+落盘)
|
|
385
425
|
const { runDoctorDiagnostics, renderDoctorSummary, writeDoctorDiagnosis } = await import('../doctor-diagnostics.js')
|
|
@@ -622,6 +662,10 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
622
662
|
const total = steps.length
|
|
623
663
|
console.log(`✅ ${stageName} 阶段已完成(${total}/${total} 步)`)
|
|
624
664
|
|
|
665
|
+
// task-04(2026-09-10-change-scope-audit):execute 完成打范围对账全表+落 .runtime 快照 /
|
|
666
|
+
// verify 完成出一行漂移确认。advisory fail-soft(D-006)——helper 内部全兜,绝不阻断完成。
|
|
667
|
+
await printStageCompletionScopeAudit({ stageName, cwd, changeName, specBase, platformOpts })
|
|
668
|
+
|
|
625
669
|
if (stageName === 'execute') {
|
|
626
670
|
// execute run summary:展示真实可得的结构化信息
|
|
627
671
|
try {
|
|
@@ -666,6 +710,7 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
666
710
|
// 走下方 _getNextSuggestion 会因 scan 是 STAGE_ORDER 首位、永未完成而误推 scan(回头路),
|
|
667
711
|
// 与 brainstorm 同类问题(见上注释),故给 quick 专属分支。
|
|
668
712
|
console.log('\n👉 quick 已完成。下一步:提交本次改动(git commit / sillyspec-commit),或 sillyspec run <stage> 继续其他阶段。')
|
|
713
|
+
console.log(' ⚠️ 多会话共享仓禁目录级 git add / git add -A(会夹带并行会话文件,2026-09-10 实证误删已提交文档):用显式 pathspec(git add -- 文件…),提交前 git status 核对暂存面。')
|
|
669
714
|
} else {
|
|
670
715
|
// D1 暗衔接修:阶段刚完成,CLI 本就知道状态机下一步是哪个阶段(_getNextSuggestion),
|
|
671
716
|
// 却只丢一句「下一步由你决定」让 agent 自己猜命令。改为按 progress 实际状态给出精确命令。
|
|
@@ -729,6 +774,95 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
729
774
|
return { stageCompleted: false, currentIdx, nextPendingIdx }
|
|
730
775
|
}
|
|
731
776
|
|
|
777
|
+
// ── 变更范围对账 advisory 注入(2026-09-10-change-scope-audit task-04,FR-03)──
|
|
778
|
+
// execute 完成打全表+落快照、verify 完成出一行漂移确认;completeStep 与 continueStep(wait
|
|
779
|
+
// 解除完成)两路径收敛点同调 printStageCompletionScopeAudit(Grill G-2:单锚 completeStep 会漏
|
|
780
|
+
// wait 解除路径致 verify 假报无快照)。全程 fail-soft(D-006):任何异常只打单行 ⚠️,不阻断
|
|
781
|
+
// 阶段完成、不进任何门禁判定。scope-audit.js 经动态 import 接入(本文件 runtime-hygiene /
|
|
782
|
+
// design-facts 等同款先例)——只读纯函数模块,模块缓存下零重复加载。
|
|
783
|
+
// - execute:renderScopeAuditTable 全表(maxRows 60 截断 + 完整表跑 scope-audit 指引 +
|
|
784
|
+
// ⚠️ 出口指引行——计划外补 design.md 声明或 --output 注明原因,均 renderer 内置)+ 落
|
|
785
|
+
// .runtime/scope-audit-<change>.json 快照(结构即 ScopeAuditResult + savedAt 落盘时间戳,
|
|
786
|
+
// 只写 .runtime 运行时产物区不动 git 状态)。
|
|
787
|
+
// - verify:读快照对比文件集差集出一行「变更范围:N 文件 +X/-Y(vs execute 时点:一致|漂移
|
|
788
|
+
// M 文件:<列表截断>)」。漂移过滤面 = filterDeliverableFiles 结果(resolveReconcileActualFiles
|
|
789
|
+
// 已过、本就保留 docs 子树)再排除 .sillyspec/docs/**——verify 阶段合法文档同步不计漂移
|
|
790
|
+
// (Grill C-5/P2-②:组合过滤在本侧做,不动 worktree-apply.js 过滤器本体)。快照缺失/降级 →
|
|
791
|
+
// 跳过对比只打当前 totals 一行。
|
|
792
|
+
|
|
793
|
+
/** 漂移对比排除面:verify 阶段合法文档同步子树(posix 归一后前缀匹配) */
|
|
794
|
+
const SCOPE_DRIFT_EXCLUDE_RE = /^\.sillyspec\/docs\//
|
|
795
|
+
|
|
796
|
+
/** 快照/当前结果 → 漂移对比用文件集(posix 归一 + docs 子树排除);畸形结构防御式得空集 */
|
|
797
|
+
function scopeAuditPathSet(result) {
|
|
798
|
+
const rows = Array.isArray(result?.rows) ? result.rows : []
|
|
799
|
+
const set = new Set()
|
|
800
|
+
for (const r of rows) {
|
|
801
|
+
const p = r && typeof r.path === 'string' ? r.path.replace(/\\/g, '/') : ''
|
|
802
|
+
if (p && !SCOPE_DRIFT_EXCLUDE_RE.test(p)) set.add(p)
|
|
803
|
+
}
|
|
804
|
+
return set
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** execute 完成路径:全表 + 快照落盘(快照写失败只提示,verify 对比按无快照降级) */
|
|
808
|
+
async function printExecuteScopeAudit({ cwd, changeName, specBase, platformOpts, computeChangeScopeAudit, renderScopeAuditTable }) {
|
|
809
|
+
const result = await computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts })
|
|
810
|
+
console.log(`\n${renderScopeAuditTable(result, { maxRows: 60 })}`)
|
|
811
|
+
try {
|
|
812
|
+
const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
|
|
813
|
+
const snapPath = join(runtimeRoot, `scope-audit-${changeName}.json`)
|
|
814
|
+
mkdirSync(runtimeRoot, { recursive: true })
|
|
815
|
+
writeFileSync(snapPath, JSON.stringify({ ...result, savedAt: new Date().toISOString() }, null, 2) + '\n')
|
|
816
|
+
} catch (e) {
|
|
817
|
+
console.warn(` ⚠️ 范围对账快照写入失败(不阻断,verify 漂移对比将按无快照降级):${e && e.message ? String(e.message).split('\n')[0] : e}`)
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** verify 完成路径:读 execute 时点快照对比文件集差集,一行结论(行数不比——execute 形态 A 锚
|
|
822
|
+
* worktree meta、verify 形态 B 锚 merge-base,基点不同行数天然抖动,比行数必假阳) */
|
|
823
|
+
async function printVerifyScopeDrift({ cwd, changeName, specBase, platformOpts, computeChangeScopeAudit }) {
|
|
824
|
+
const result = await computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts })
|
|
825
|
+
const totals = result?.totals && typeof result.totals === 'object' ? result.totals : {}
|
|
826
|
+
const scopeLine = `变更范围:${totals.files ?? (Array.isArray(result?.rows) ? result.rows.length : 0)} 文件 +${totals.additions ?? 0}/-${totals.deletions ?? 0}`
|
|
827
|
+
if (result?.ok !== true) {
|
|
828
|
+
console.log(` ⚠️ ${scopeLine}(对账降级:${result?.degradedReason || '实际侧文件集解析失败'}——advisory,不影响 verify 完成)`)
|
|
829
|
+
return
|
|
830
|
+
}
|
|
831
|
+
let snapshot = null
|
|
832
|
+
try {
|
|
833
|
+
snapshot = JSON.parse(readFileSync(join(resolveRuntimeRoot(platformOpts, specBase), `scope-audit-${changeName}.json`), 'utf8'))
|
|
834
|
+
} catch { /* 快照缺失/损坏 → 跳过对比只打当前 totals */ }
|
|
835
|
+
if (!snapshot || snapshot.ok !== true) {
|
|
836
|
+
console.log(` ${scopeLine}(无 execute 时点快照可对比——完整表跑 sillyspec scope-audit --change ${changeName})`)
|
|
837
|
+
return
|
|
838
|
+
}
|
|
839
|
+
const current = scopeAuditPathSet(result)
|
|
840
|
+
const baseline = scopeAuditPathSet(snapshot)
|
|
841
|
+
const drift = [...new Set([...current, ...baseline])].filter(p => current.has(p) !== baseline.has(p)).sort()
|
|
842
|
+
if (drift.length === 0) {
|
|
843
|
+
console.log(` ${scopeLine}(vs execute 时点:一致)`)
|
|
844
|
+
} else {
|
|
845
|
+
const shown = drift.slice(0, 5).join(', ')
|
|
846
|
+
const tail = drift.length > 5 ? ` 等 ${drift.length} 个` : ''
|
|
847
|
+
console.log(` ${scopeLine}(vs execute 时点:漂移 ${drift.length} 文件:${shown}${tail}——advisory 不阻断,请确认 verify 期改动并按需补 design.md 声明)`)
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** 两完成路径公共入口(stage 派发 + 动态 import + fail-soft 兜底,D-006) */
|
|
852
|
+
async function printStageCompletionScopeAudit({ stageName, cwd, changeName, specBase, platformOpts }) {
|
|
853
|
+
try {
|
|
854
|
+
if (!changeName || (stageName !== 'execute' && stageName !== 'verify')) return
|
|
855
|
+
const { computeChangeScopeAudit, renderScopeAuditTable } = await import('../scope-audit.js')
|
|
856
|
+
if (stageName === 'execute') {
|
|
857
|
+
await printExecuteScopeAudit({ cwd, changeName, specBase, platformOpts, computeChangeScopeAudit, renderScopeAuditTable })
|
|
858
|
+
} else {
|
|
859
|
+
await printVerifyScopeDrift({ cwd, changeName, specBase, platformOpts, computeChangeScopeAudit })
|
|
860
|
+
}
|
|
861
|
+
} catch (e) {
|
|
862
|
+
console.warn(`⚠️ 变更范围对账输出异常(advisory,不阻断 ${stageName} 阶段完成):${e && e.message ? String(e.message).split('\n')[0] : e}`)
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
732
866
|
// ── Step 调度兄弟函数(W6 Step7b-2 从 run.js 搬入:skip/wait/continue + formatWaitHistory)──
|
|
733
867
|
/**
|
|
734
868
|
* plan.md checkbox 自动勾选:execute 完成 + 各 task review.json 双 verdict 非 fail → 自动勾选。
|
|
@@ -858,7 +992,7 @@ export function shouldAutoCheckTask(r, endToEnd, ctx = null) {
|
|
|
858
992
|
|
|
859
993
|
return true
|
|
860
994
|
}
|
|
861
|
-
async function autoCheckPlanFromReviews({ stageName, changeName, cwd, platformOpts }) {
|
|
995
|
+
export async function autoCheckPlanFromReviews({ stageName, changeName, cwd, platformOpts }) {
|
|
862
996
|
if (stageName !== 'execute' || !changeName) {
|
|
863
997
|
return { autoChecked: false, checkedCount: 0, skippedCount: 0 }
|
|
864
998
|
}
|
|
@@ -1337,6 +1471,9 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
|
|
|
1337
1471
|
// gate 全过:persist completed(task-03 移后;此处无 triggerSync)。
|
|
1338
1472
|
pm._write(cwd, progress, changeName)
|
|
1339
1473
|
console.log(`\n✅ ${stageName} 阶段已完成(${stageData.steps.length}/${stageData.steps.length} 步)`)
|
|
1474
|
+
// task-04:wait 解除完成路径与 completeStep 完成分支同源注入(Grill G-2:本路径不漏,
|
|
1475
|
+
// 否则 execute 快照缺失 → verify 假报「无快照可对比」)。
|
|
1476
|
+
await printStageCompletionScopeAudit({ stageName, cwd, changeName, specBase, platformOpts })
|
|
1340
1477
|
// 阶段完成后明确下一步(agent 常卡:stageData completed 但不知要 run <下一阶段> 推进 currentStage)
|
|
1341
1478
|
const nextStageHint = { brainstorm: 'plan', plan: 'execute', execute: 'verify', verify: 'archive' }[stageName]
|
|
1342
1479
|
if (nextStageHint) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gate-snapshot.js — quick --done test+lint 门禁的隔离快照执行(2026-09-10 驾驭小结第六批②,
|
|
3
|
+
* 用户实证「lint 实测对账在主仓跑,被并行会话的脏文件拦门」)。
|
|
4
|
+
*
|
|
5
|
+
* 问题:门禁在主仓工作区跑 commands.test/lint——多会话共享仓里并行会话的未提交 src/test
|
|
6
|
+
* 改动(语法错误/半成品/锚漂)会污染实测结果,把无辜会话的 --done 拦在门上(只能走
|
|
7
|
+
* advisory 逃生口)。
|
|
8
|
+
*
|
|
9
|
+
* 修法:HEAD 干净快照 + 会话文件 overlay——`git worktree add --detach <tmp> HEAD` 取干净
|
|
10
|
+
* 基线(并行脏文件天然不在),把本会话审计/声明的变更文件覆盖进快照,node_modules 经
|
|
11
|
+
* junction 复用主仓依赖(零拷贝),local.yaml 从主仓复制(gitignore 不进 HEAD),在快照
|
|
12
|
+
* cwd 跑对账。快照基建任何失败 → 返回 null,调用方回退主仓现行为(零回归兜底)。
|
|
13
|
+
*
|
|
14
|
+
* 快照生命周期:一次门禁一批(create → run → cleanup);崩溃残留由 git worktree prune /
|
|
15
|
+
* 临时目录自然回收(OS tmp 清理),不进主仓 .runtime。
|
|
16
|
+
*/
|
|
17
|
+
import { execFileSync } from 'node:child_process'
|
|
18
|
+
import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, rmSync, existsSync, readdirSync, statSync, symlinkSync, readFileSync } from 'node:fs'
|
|
19
|
+
import { join, dirname, relative, resolve } from 'node:path'
|
|
20
|
+
import { tmpdir } from 'node:os'
|
|
21
|
+
|
|
22
|
+
function git(cwd, args) {
|
|
23
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60000 }).trim()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 创建隔离快照:HEAD worktree + 会话文件 overlay + node_modules junction + local.yaml。
|
|
28
|
+
* @param {{ cwd: string, files: string[] }} opts cwd=主仓根;files=本会话变更文件(仓库根相对 POSIX)
|
|
29
|
+
* @returns {{ snapshotRoot: string, cleanup: () => void, reason?: string }|null} 失败返回 null(调用方回退主仓)
|
|
30
|
+
*/
|
|
31
|
+
export function createGateSnapshot({ cwd, files }) {
|
|
32
|
+
let snapshotRoot = null
|
|
33
|
+
try {
|
|
34
|
+
// 前置:主仓须是 git 仓且有 HEAD(无 git 环境回退主仓现行为)
|
|
35
|
+
git(cwd, ['rev-parse', 'HEAD'])
|
|
36
|
+
|
|
37
|
+
snapshotRoot = mkdtempSync(join(tmpdir(), 'sillyspec-gate-'))
|
|
38
|
+
git(cwd, ['worktree', 'add', '--detach', '--quiet', snapshotRoot, 'HEAD'])
|
|
39
|
+
|
|
40
|
+
// 会话文件 overlay:主仓工作区版本覆盖进快照(本会话的最新态;文件不存在=已删,跳过)。
|
|
41
|
+
// .sillyspec/ 跳过面收窄(P2-d 落地实证的快照盲区):原一刀切跳过整个 .sillyspec/ ——
|
|
42
|
+
// 会话声明的 tracked docs(module-map 补录/知识库/模块卡)进不了快照,门禁读到 HEAD
|
|
43
|
+
// 旧版恒误报(src 未录 module-map 的修复在主仓生效、快照里仍然失败)。真正需要隔离的
|
|
44
|
+
// 是共享运行时面:.runtime/(sqlite 锁/在途 marker)与 quicklog/(跨会话共享账本);
|
|
45
|
+
// local.yaml 由下方 cfg 段显式复制(单一来源,不经 overlay)。
|
|
46
|
+
let overlaid = 0
|
|
47
|
+
for (const f of files) {
|
|
48
|
+
const isSillyspecRuntime = typeof f === 'string' && (f.startsWith('.sillyspec/.runtime/') || f.startsWith('.sillyspec/quicklog/') || f === '.sillyspec/local.yaml' || f === '.sillyspec/.sillyspec-platform.json')
|
|
49
|
+
if (typeof f !== 'string' || f.includes('..') || f.startsWith('/') || isSillyspecRuntime) continue
|
|
50
|
+
const src = join(cwd, f)
|
|
51
|
+
if (!existsSync(src)) continue
|
|
52
|
+
const dst = join(snapshotRoot, f)
|
|
53
|
+
mkdirSync(dirname(dst), { recursive: true })
|
|
54
|
+
copyFileSync(src, dst)
|
|
55
|
+
overlaid++
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// node_modules junction(依赖主仓安装态;junction Windows 无需特权,POSIX 用 symlink)
|
|
59
|
+
const nmSrc = join(cwd, 'node_modules')
|
|
60
|
+
if (existsSync(nmSrc)) {
|
|
61
|
+
try { symlinkSync(nmSrc, join(snapshotRoot, 'node_modules'), 'junction') }
|
|
62
|
+
catch { /* 依赖链接失败:test/lint 可能跑不起来 → 仍返回快照,命令层报错走 fallback */ }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// local.yaml(gitignore 不进 HEAD;门禁命令配置来源)+ package-lock 保持 HEAD 版(npm test 不装新依赖)
|
|
66
|
+
for (const cfg of [join('.sillyspec', 'local.yaml')]) {
|
|
67
|
+
const src = join(cwd, cfg)
|
|
68
|
+
if (existsSync(src)) {
|
|
69
|
+
mkdirSync(dirname(join(snapshotRoot, cfg)), { recursive: true })
|
|
70
|
+
copyFileSync(src, join(snapshotRoot, cfg))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const cleanup = () => {
|
|
75
|
+
try { git(cwd, ['worktree', 'remove', '--force', '--quiet', snapshotRoot]) } catch {
|
|
76
|
+
try { rmSync(snapshotRoot, { recursive: true, force: true }) } catch { /* 残留交 OS tmp 清理 */ }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { snapshotRoot, cleanup, overlaid }
|
|
80
|
+
} catch (e) {
|
|
81
|
+
// 基建失败(worktree add 拒绝/磁盘满/…):尽力清理后回退主仓
|
|
82
|
+
if (snapshotRoot) {
|
|
83
|
+
try { git(cwd, ['worktree', 'remove', '--force', '--quiet', snapshotRoot]) } catch {}
|
|
84
|
+
try { rmSync(snapshotRoot, { recursive: true, force: true }) } catch {}
|
|
85
|
+
}
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/run/gates.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { basename, join, relative, dirname } from 'node:path'
|
|
23
23
|
import { createHash } from 'node:crypto'
|
|
24
|
+
import { execFileSync } from 'node:child_process'
|
|
24
25
|
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
25
26
|
import { writeAtomicSync } from '../fs-atomic.js'
|
|
26
27
|
import { triggerSync, resolveChangeDir, resolveRuntimeRoot } from './shared.js'
|
|
@@ -556,12 +557,14 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
|
|
|
556
557
|
// ── verify 收尾前置:facts slot-backfill + verifyStartAt(2026-09-08-ir-verify-facts FR-03)──
|
|
557
558
|
// 执行次序(design Phase 3):backfill(结论/证据/回执槽固化)先行 → runValidators(结论门/
|
|
558
559
|
// 集成证据门读得到固化值与 context.verifyStartAt)→ verify 专属块(test 实测 → tests 段二次
|
|
559
|
-
// 回填 → cannot_verify 硬门)。verifyStartAt
|
|
560
|
-
//
|
|
560
|
+
// 回填 → cannot_verify 硬门)。verifyStartAt 锚点(2026-09-10 用户反馈②放宽):execute 行
|
|
561
|
+
// **started_at**(证据合法产自 execute 或 verify 两窗口)→ 缺列/旧行退 completed_at →
|
|
562
|
+
// 再缺走 R-05 design created_at fallback。
|
|
561
563
|
let verifyStartAtIso = null
|
|
562
564
|
if (stageName === 'verify') {
|
|
563
565
|
try {
|
|
564
|
-
verifyStartAtIso = pm.
|
|
566
|
+
verifyStartAtIso = (pm.getStageStartedAt && pm.getStageStartedAt(cwd, changeName, 'execute'))
|
|
567
|
+
|| pm.getStageCompletedAt(cwd, changeName, 'execute')
|
|
565
568
|
} catch { verifyStartAtIso = null }
|
|
566
569
|
try {
|
|
567
570
|
const verifyMdPath = join(specBase, 'changes', changeName, 'verify-result.md')
|
|
@@ -622,10 +625,22 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
|
|
|
622
625
|
// 自报告 PASS 但实测失败 → 阻断(防止"文案通过"绕过验证)。
|
|
623
626
|
if (stageName === 'verify') {
|
|
624
627
|
const { runVerifyTestCheck, printVerifyTestCheck } = await import('../verify-postcheck.js')
|
|
625
|
-
//
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
628
|
+
// P0-1 指纹复用(noai-ir-roadmap §3 前置一·防重复跑):noAI 质量扫描步已实测且代码指纹
|
|
629
|
+
// 未变 → 直接复用免重跑(长套件 2~10 分钟不再跑两遍);无记录/失配/git 不可用 → 照旧
|
|
630
|
+
// 亲测(本路径语义与产物逐字不变)。
|
|
631
|
+
const { loadReusableQualityScan } = await import('./verify-quality-scan.js')
|
|
632
|
+
const reusableScan = loadReusableQualityScan({ specBase, cwd, changeName })
|
|
633
|
+
let testCheck
|
|
634
|
+
if (reusableScan && reusableScan.testResult) {
|
|
635
|
+
testCheck = reusableScan.testResult
|
|
636
|
+
console.log(`\n♻️ Verify 测试对账:复用 noAI 质量扫描步的实测结果(代码指纹匹配,免重跑;实测于 ${reusableScan.ranAt || '本变更 verify 期间'}${testCheck.resultPath ? `,台账 ${testCheck.resultPath}` : ''})`)
|
|
637
|
+
printVerifyTestCheck(testCheck)
|
|
638
|
+
} else {
|
|
639
|
+
// 测试实测是同步 execSync,长套件可跑 2~10min 且中途无输出——先预告避免 agent 误判卡死
|
|
640
|
+
console.log(`\n⏳ Verify 测试对账:CLI 亲自执行 local.yaml 的 commands.test(同步,耗时可能较长,请等待…)`)
|
|
641
|
+
testCheck = runVerifyTestCheck({ cwd, specBase, changeName, ctx })
|
|
642
|
+
printVerifyTestCheck(testCheck)
|
|
643
|
+
}
|
|
629
644
|
// tests 段二次回填(2026-09-08-ir-verify-facts FR-03 次序:实测在 runValidators 之后,
|
|
630
645
|
// tests 快照此刻才可得;不参与门禁——供 P3d 追溯)
|
|
631
646
|
try {
|
|
@@ -643,44 +658,13 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
|
|
|
643
658
|
} catch { /* fail-soft:回填失败不影响门禁 */ }
|
|
644
659
|
if (testCheck.status === 'failed') {
|
|
645
660
|
console.error('\n❌ verify 阶段被阻断:verify-result.md 自报告通过,但 CLI 实测测试失败。')
|
|
646
|
-
// 并行 WIP 归因鉴别(坑 verify-reconcile-foreign-wip
|
|
647
|
-
//
|
|
661
|
+
// 并行 WIP 归因鉴别(坑 verify-reconcile-foreign-wip):单一实现迁至 verify-quality-scan.js
|
|
662
|
+
// renderVerifyTestAttribution(P0-1 前置二:noAI 质量扫描失败路径与本 --done 路径共用,
|
|
663
|
+
// 输出逐字保持)——实测跑在主仓共享工作区,并行会话在途 WIP 物理在场,他者文件命中
|
|
664
|
+
// 声明集时提示复验归因(含判据②生成产物旧基线 / verify-attr2 双过滤),不静默背锅。
|
|
648
665
|
try {
|
|
649
|
-
const {
|
|
650
|
-
|
|
651
|
-
const foreignMap = collectForeignDeclaredFiles(cwd, changeName, { specBase, runtimeRoot: resolveRuntimeRoot(platformOpts, specBase) })
|
|
652
|
-
// dirty 集提前算好:归因提示①(他者在途)与②(生成产物旧基线)共用
|
|
653
|
-
const dirty = (gitQuiet(cwd, ['diff', '--name-only', 'HEAD']) || '')
|
|
654
|
-
.split('\n').filter(Boolean).map(f => f.replace(/\\/g, '/'))
|
|
655
|
-
const dirtySet = new Set(dirty)
|
|
656
|
-
if (foreignMap.size > 0) {
|
|
657
|
-
const hitForeign = dirty.filter(f => foreignMap.has(f))
|
|
658
|
-
if (hitForeign.length > 0) {
|
|
659
|
-
console.error(` ℹ️ 归因提示:主仓检出 ${hitForeign.length} 个并行会话声明的在途文件(${hitForeign.slice(0, 5).join(', ')}${hitForeign.length > 5 ? ' 等' : ''})——实测失败可能混入他者 WIP 而非本变更问题。待其提交/收尾后复验,仍失败才是本变更的。`)
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
// 第二判据(坑 derived-artifact-stale-baseline):本变更 apply 的文件(apply-pathspec 落盘)
|
|
663
|
-
// 与主仓近期提交重叠——本变更旧基线的生成产物可能覆盖了已合入内容,先在新基线重跑生成命令再复验。
|
|
664
|
-
// 坑 verify-attr2-foreign-wip-misdirection(2026-08-28 实证):并行会话在途/近期收尾文件
|
|
665
|
-
// 让重叠集虚高(51 文件),每轮误导 agent 重跑 gen:types,实际失败源是他者 WIP。两道过滤:
|
|
666
|
-
// - 必须 dirty:apply 落盘的旧内容此刻物理在场(verify gate 时本变更未提交,旧基线覆盖
|
|
667
|
-
// 必然表现为对 HEAD 的修改)——内容与 HEAD 一致时重跑生成命令改变不了本轮实测;
|
|
668
|
-
// - 剔除他者声明集:那是归因提示①的领地(待其收尾后复验),不是生成产物过期。
|
|
669
|
-
try {
|
|
670
|
-
const pathspecFile = join(resolveRuntimeRoot(platformOpts, specBase), `apply-pathspec-${changeName}.txt`)
|
|
671
|
-
if (changeName && existsSync(pathspecFile)) {
|
|
672
|
-
const ownFiles = new Set(readFileSync(pathspecFile, 'utf8').split('\n').map(l => l.trim()).filter(Boolean).map(f => f.replace(/\\/g, '/')))
|
|
673
|
-
if (ownFiles.size > 0) {
|
|
674
|
-
const recent = (gitQuiet(cwd, ['log', '-n', '10', '--name-only', '--pretty=format:']) || '')
|
|
675
|
-
.split('\n').map(l => l.trim().replace(/\\/g, '/')).filter(Boolean)
|
|
676
|
-
const overlap = [...new Set(recent)].filter(f => ownFiles.has(f))
|
|
677
|
-
const actionable = filterStaleBaselineOverlap(overlap, dirtySet, foreignMap)
|
|
678
|
-
if (actionable.length > 0) {
|
|
679
|
-
console.error(` ℹ️ 归因提示②:本变更有 ${actionable.length} 个文件与主仓最近 10 条提交重叠且旧内容仍在工作区(${actionable.slice(0, 5).join(', ')}${actionable.length > 5 ? ' 等' : ''})——若为生成产物(api-types 等),本变更可能在旧基线生成并覆盖了已合入内容;先在新基线重跑生成命令(如 gen:types)再复验。(重叠共 ${overlap.length} 个,其余未在工作区改动或属他者会话在途文件,已归提示①/无需处理)`)
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
} catch { /* 判据②失败不影响主流程 */ }
|
|
666
|
+
const { renderVerifyTestAttribution } = await import('./verify-quality-scan.js')
|
|
667
|
+
await renderVerifyTestAttribution({ cwd, changeName, specBase, runtimeRoot: resolveRuntimeRoot(platformOpts, specBase) })
|
|
684
668
|
} catch { /* 归因提示失败不影响阻断语义 */ }
|
|
685
669
|
console.error(' 请修复失败的测试并更新 verify-result.md 后重新完成此步骤。')
|
|
686
670
|
return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
|
|
@@ -688,9 +672,15 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
|
|
|
688
672
|
// lint 对账(2026-08-21 审查 CLI-1):test 侧"自报告 PASS 但实测失败→阻断"已闭环,
|
|
689
673
|
// lint 侧此前纯口头——CLI 亲自执行 commands.lint,advisory 起步(失败打印不阻断,观察期后升级)
|
|
690
674
|
const { runVerifyLintCheck, printVerifyLintCheck } = await import('../verify-postcheck.js')
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
675
|
+
// P0-1 指纹复用:noAI 质量扫描记录同指纹时 lint 一并复用(lint tally 只记真实执行次数)
|
|
676
|
+
let lintCheck = (reusableScan && reusableScan.lintResult) || null
|
|
677
|
+
if (lintCheck) {
|
|
678
|
+
console.log(`\n♻️ Verify lint 对账:复用 noAI 质量扫描步的实测结果(代码指纹匹配,免重跑)`)
|
|
679
|
+
} else {
|
|
680
|
+
lintCheck = runVerifyLintCheck({ cwd, specBase })
|
|
681
|
+
if (lintCheck.status !== 'skipped') {
|
|
682
|
+
console.log(`\n⏳ Verify lint 对账:CLI 亲自执行 local.yaml 的 commands.lint…`)
|
|
683
|
+
}
|
|
694
684
|
}
|
|
695
685
|
printVerifyLintCheck(lintCheck)
|
|
696
686
|
// lint 硬门(2026-09-09 升硬:观察期 14 次 5 败全真阳性,pre-push 本就硬拦——fail-fast 前移;
|
|
@@ -720,9 +710,19 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
|
|
|
720
710
|
// execute Task Review Gate 把 cannot_verify 任务的 evidence 落盘 verify-required-evidence.json。
|
|
721
711
|
// v2 槽优先分类核验(FR-02):blocked = missing 无豁免或 satisfied 核验不过 → 阻断 verify 完成
|
|
722
712
|
// (此前 advisory 只查「提及」——死链不闭环);无槽存量 md 降级 legacy 子串对账(warning 不阻断)。
|
|
723
|
-
const { runVerifyRequiredEvidenceCheck, printVerifyRequiredEvidenceCheck } = await import('../verify-postcheck.js')
|
|
713
|
+
const { runVerifyRequiredEvidenceCheck, printVerifyRequiredEvidenceCheck, trackVerifyResultRegression } = await import('../verify-postcheck.js')
|
|
724
714
|
const evidenceCheck = runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName, verifyStartAt: verifyStartAtIso })
|
|
725
715
|
printVerifyRequiredEvidenceCheck(evidenceCheck)
|
|
716
|
+
// verify-result.md 内容回退检测(2026-09-10 用户反馈③:报告被平台同步覆盖回旧版一次,
|
|
717
|
+
// 只能靠人眼发现重写):高水位指纹(hash+mtime)检出「内容变且 mtime 回退」→ ⚠️ 告警。
|
|
718
|
+
// 纯 advisory——真回退也由 agent 决定重写,CLI 只保证可见。
|
|
719
|
+
try {
|
|
720
|
+
const vr = trackVerifyResultRegression(specBase, changeName, join(specBase, 'changes', changeName, 'verify-result.md'))
|
|
721
|
+
if (vr.regressed) {
|
|
722
|
+
console.warn(`\n⚠️ verify-result.md 疑被覆盖回旧版(内容变化 + mtime 回退至 ${new Date(vr.prevMtime).toLocaleString('zh-CN')} 之前)——平台/daemon 按服务端旧版本回写的指纹。`)
|
|
723
|
+
console.warn(` 若你刚重写过请忽略本行(下次读取刷新高水位);否则当前盘上可能是旧内容,需对照记忆/git 重新补写。`)
|
|
724
|
+
}
|
|
725
|
+
} catch { /* 检测失败静默(advisory) */ }
|
|
726
726
|
if (evidenceCheck.status === 'blocked') {
|
|
727
727
|
console.error(`\n❌ verify 阶段被阻断:cannot_verify 证据账未闭环(${evidenceCheck.summary})。`)
|
|
728
728
|
for (const d of (evidenceCheck.detailed || [])) {
|
|
@@ -915,6 +915,20 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
|
|
|
915
915
|
if (!reviewResult.ok) {
|
|
916
916
|
return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
|
|
917
917
|
}
|
|
918
|
+
// 降级自审留痕(2026-09-10 用户反馈①:PI agent 等宿主无 Agent tool,prompt 降级条款允许
|
|
919
|
+
// 当前 agent 自审产出 review.json)。放行但独立性折损必须可见:⚠️ 审计行区分降级 review
|
|
920
|
+
// 与真子代理 review(事后可审计),与 ⚖️/🔍 软归属同哲学——可见的折损优于静默的伪装。
|
|
921
|
+
// channel 识别(2026-09-10 通道优先序批次):reviewer.channel 结构化落款优先,兼容
|
|
922
|
+
// reviewerNotes 首行「降级:」约定;platform 通道(P2 review-dispatch 落地后)ℹ️ 留痕
|
|
923
|
+
// missionId 供追溯,其余通道正常放行。
|
|
924
|
+
const { classifyReviewerChannel } = await import('../stage-review.js')
|
|
925
|
+
const reviewChannel = classifyReviewerChannel(reviewResult.review)
|
|
926
|
+
if (reviewChannel === 'self') {
|
|
927
|
+
console.warn(`\n⚠️ Stage Review 降级自审(tier=independent 但宿主无 Agent tool):${stageName} review 按 prompt 降级条款由当前 agent 自审产出——独立性折损已留痕(reviewerNotes 首行「降级:」),结论应附源码锚点补偿,建议人工抽查关键结论。`)
|
|
928
|
+
} else if (reviewChannel === 'platform') {
|
|
929
|
+
const mid = reviewResult.review && reviewResult.review.reviewer && reviewResult.review.reviewer.missionId
|
|
930
|
+
console.log(`\nℹ️ Stage Review 平台派发(channel=platform${mid ? `,missionId=${mid}` : ',missionId 未落款'}):${stageName} review 由平台独立 worker 产出(独立进程/会话)。`)
|
|
931
|
+
}
|
|
918
932
|
}
|
|
919
933
|
} catch (e) {
|
|
920
934
|
// fail-closed:Gate 自身异常阻断完成,不静默放行(与 Task Review Gate 一致)
|
|
@@ -978,14 +992,16 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
|
|
|
978
992
|
// marker 缺失:先扫描 execute-runs/ 既有目录找回真实 runId(与 getLatestStageReviewRunId
|
|
979
993
|
// 目录扫描兜底同语义),避免 marker 丢失而 agent 已用旧 runId 落盘时,直接 generate 新 ID
|
|
980
994
|
// 找不到旧 review、误判缺 review.json。仅当确实无既有 run 才 generate 新 ID 并落盘。
|
|
981
|
-
const { generateExecuteRunId, resolveLatestExecuteRunId, stampExecuteRunChange } = await import('../task-review.js')
|
|
995
|
+
const { generateExecuteRunId, resolveLatestExecuteRunId, stampExecuteRunChange, claimExecuteRunId } = await import('../task-review.js')
|
|
982
996
|
executeRunId = resolveLatestExecuteRunId({ runtimeRoot, changeName }) || ''
|
|
983
997
|
if (!executeRunId) {
|
|
984
|
-
executeRunId = generateExecuteRunId()
|
|
998
|
+
executeRunId = generateExecuteRunId(changeName)
|
|
985
999
|
// 落盘(marker 缺失时 fallback 生成后写盘,保证后续 checkbox/gate 读到同一 ID)
|
|
986
1000
|
// D-001#1 fallback 写入点:mkdir execute-runs/<runId>/tasks 先于 marker(不变量:
|
|
987
1001
|
// marker 在则目录在)。不 try/catch——异常直穿外层 catch 走 fail-closed 阻断
|
|
988
1002
|
//(gate 自身写 run 目录失败不能静默放行完成)。
|
|
1003
|
+
// 排他认领(坑 exec-run-id-same-second-collision):并行会话同秒碰撞在认领处消解
|
|
1004
|
+
executeRunId = claimExecuteRunId(runtimeRoot, executeRunId)
|
|
989
1005
|
mkdirSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'), { recursive: true })
|
|
990
1006
|
writeAtomicSync(runIdFile, executeRunId + '\n')
|
|
991
1007
|
stampExecuteRunChange(runtimeRoot, executeRunId, changeName)
|
|
@@ -1263,9 +1279,13 @@ export async function completeStageGates({ stageName, cwd, changeName, platformO
|
|
|
1263
1279
|
validateFileLocations(cwd, stageName, progress, changeName, specBase)
|
|
1264
1280
|
}
|
|
1265
1281
|
|
|
1266
|
-
// 辅助阶段完成后重置步骤(scan 等 auxiliary 阶段重置回 pending
|
|
1282
|
+
// 辅助阶段完成后重置步骤(scan 等 auxiliary 阶段重置回 pending 可重跑)。
|
|
1283
|
+
// archive 例外(P0-4 并步补丁):确认归档成为末步后本重置会与 unregisterChange 的终态
|
|
1284
|
+
// (steps 全 completed + current_stage=archive——平台按步骤数渲染完成度的数据源)互相
|
|
1285
|
+
// 覆盖,把已注销变更抹回 0/N。archive 是终态阶段(变更已注销,不存在「重跑归档」流),
|
|
1286
|
+
// 不参与可重跑重置。
|
|
1267
1287
|
const stageDef = stageRegistry[stageName]
|
|
1268
|
-
if (stageDef?.auxiliary) {
|
|
1288
|
+
if (stageDef?.auxiliary && stageName !== 'archive') {
|
|
1269
1289
|
const freshSteps = (stageDef.steps || []).map(s => ({
|
|
1270
1290
|
name: s.name,
|
|
1271
1291
|
status: 'pending',
|
|
@@ -1615,11 +1635,50 @@ export function printProbeConsistencyCheck(r, envelope = buildProbeConsistencyEn
|
|
|
1615
1635
|
* 一天多——verify prompt 要求 PID 按变更分片登记 .runtime/verify-services-<change>.pids。
|
|
1616
1636
|
* 坑 verify-pids-cross-session-kill(2026-08-23):原单文件无归属,A 会话 --done 会把 B 正在
|
|
1617
1637
|
* 收集 Runtime Evidence 的服务一并杀掉——分片后各回收各的;旧单文件兼容回收(升级过渡期)。
|
|
1638
|
+
* 坑 verify-service-process-leak 子进程泄漏(2026-09-10 驾驭小结第三批③,用户实锤 python
|
|
1639
|
+
* 子进程泄漏):原 process.kill(pid) 只杀登记的 shell 包装进程(`cmd /c` / `sh -c` 起的
|
|
1640
|
+
* uvicorn 等),子进程成了孤儿继续挂机——改 killProcessTree 进程树击杀(win taskkill /T /F
|
|
1641
|
+
* + POSIX 递归枚举子进程);prompt 指引同步补「优先登记服务本体(叶子)PID」。
|
|
1618
1642
|
* 回执同样按变更分片(原单份被并行会话后写覆盖),checkIntegrationEvidence(stage-contract)
|
|
1619
1643
|
* 读分片名注入证据匹配。best-effort:ESRCH(已退出)静默,kill 失败 warn 不阻断 verify。
|
|
1620
1644
|
*
|
|
1621
1645
|
* @returns {{ reaped: number, receiptPath: string|null }}
|
|
1622
1646
|
*/
|
|
1647
|
+
export function killProcessTree(pid, sig = 'SIGTERM') {
|
|
1648
|
+
const pidNum = parseInt(pid, 10)
|
|
1649
|
+
if (!Number.isInteger(pidNum) || pidNum <= 0) return false
|
|
1650
|
+
if (process.platform === 'win32') {
|
|
1651
|
+
// /T = 连同子进程树(shell 包装下的 python/node 服务本体);/F = 强制(长驻服务常吞 SIGTERM 等价物)
|
|
1652
|
+
try {
|
|
1653
|
+
execFileSync('taskkill', ['/PID', String(pidNum), '/T', '/F'], { stdio: 'ignore', timeout: 15000 })
|
|
1654
|
+
return true
|
|
1655
|
+
} catch (e) {
|
|
1656
|
+
// taskkill 对不存在进程退出码 128(等价 ESRCH,已退出按回收静默);其余(拒绝访问等)上抛
|
|
1657
|
+
if (e && (e.status === 128 || /could not be found|找不到/i.test(String(e.stderr || '') + String(e.message || '')))) return false
|
|
1658
|
+
throw e
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
// POSIX:先递归杀子进程(shell 包装的子进程是服务本体),再杀登记进程本身
|
|
1662
|
+
for (const child of listChildPids(pidNum)) {
|
|
1663
|
+
try { killProcessTree(child, sig) } catch { /* 子进程已退出/权限不足不连坐父进程回收 */ }
|
|
1664
|
+
}
|
|
1665
|
+
try { process.kill(pidNum, sig); return true }
|
|
1666
|
+
catch (e) { if (e.code === 'ESRCH') return false; throw e }
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/** ps 枚举直接子进程 PID(POSIX;失败返回空——杀不到子进程不阻断父进程回收) */
|
|
1670
|
+
function listChildPids(parentPid) {
|
|
1671
|
+
try {
|
|
1672
|
+
const out = execFileSync('ps', ['-axo', 'pid=,ppid='], { encoding: 'utf8', timeout: 5000 })
|
|
1673
|
+
const children = []
|
|
1674
|
+
for (const line of String(out).split('\n')) {
|
|
1675
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)$/)
|
|
1676
|
+
if (m && Number(m[2]) === parentPid) children.push(Number(m[1]))
|
|
1677
|
+
}
|
|
1678
|
+
return children
|
|
1679
|
+
} catch { return [] }
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1623
1682
|
export function reapVerifyServices(platformOpts, specBase, changeName) {
|
|
1624
1683
|
const runtimeDir = resolveRuntimeRoot(platformOpts, specBase)
|
|
1625
1684
|
const reapFile = (path, label) => {
|
|
@@ -1627,11 +1686,12 @@ export function reapVerifyServices(platformOpts, specBase, changeName) {
|
|
|
1627
1686
|
const pids = readFileSync(path, 'utf8').split('\n').map(l => l.trim()).filter(l => /^\d+$/.test(l))
|
|
1628
1687
|
const reaped = []; const failed = []
|
|
1629
1688
|
for (const pid of pids) {
|
|
1630
|
-
|
|
1631
|
-
|
|
1689
|
+
// 进程树击杀(坑 verify-service-process-leak 子进程泄漏):连带 shell 包装下的服务本体
|
|
1690
|
+
try { if (killProcessTree(pid)) reaped.push(pid) }
|
|
1691
|
+
catch (e) { if (e.code !== 'ESRCH') failed.push(`${pid}(${e.code || e.status || e.message})`) }
|
|
1632
1692
|
}
|
|
1633
1693
|
try { unlinkSync(path) } catch {}
|
|
1634
|
-
if (reaped.length > 0) console.log(`🧹 verify 服务进程已回收 ${reaped.length} 个${label}(PID: ${reaped.join(', ')}
|
|
1694
|
+
if (reaped.length > 0) console.log(`🧹 verify 服务进程已回收 ${reaped.length} 个${label}(PID: ${reaped.join(', ')},进程树击杀)`)
|
|
1635
1695
|
return { reaped: reaped.length, failed }
|
|
1636
1696
|
}
|
|
1637
1697
|
const own = reapFile(join(runtimeDir, `verify-services-${changeName}.pids`), '')
|