Merge remote-tracking branch 'origin/master' into worktree/deepseek-harness-proxy-config-2f5b4a

# Conflicts:
#	packages/session/session-telemetry-otel/package.json
This commit is contained in:
Yichen Jiang
2026-09-01 11:15:18 +08:00
1160 changed files with 10420 additions and 11919 deletions
-6
View File
@@ -160,12 +160,6 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
// sandbox-local resolves it through the package's ./runner export. tsdown
// also shares its generated FFI code through a hashed runtime chunk.
'@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js'],
// SQLite loads its compression dictionary and every statement from immutable
// package resources at runtime.
'@deepseek-ai/dsh-session-persistence-sqlite': [
'resources/zstd-dictionary.bin',
'resources/sql/**/*.sql',
],
'@deepseek-ai/dsh-skill-badge': ['assets'],
// tsdown shares the repository/pack code between the lib entry and the bin
// through a hashed chunk. The committed bin.js is the link target pnpm can
+40 -1
View File
@@ -67,11 +67,12 @@ describe('CI workflow', () => {
|| !isRecord(workflow.jobs['node-24'])
|| !isRecord(workflow.jobs['node-24-coverage'])
|| !isRecord(workflow.jobs['node-24-consumers'])
|| !isRecord(workflow.jobs['node-compat'])
|| !isRecord(workflow.jobs['all-checks-passed'])
|| !isRecord(masterWorkflow.jobs)
|| !isRecord(masterWorkflow.jobs['wine-apt-cache'])
|| !isRecord(masterWorkflow.jobs['serial-windows'])) {
throw new TypeError('CI workflow must define windows, windows-build, windows-coverage, windows-native-tests, windows-observational, node-24, node-24-coverage, node-24-consumers, and all-checks-passed; ci-master must define wine-apt-cache and serial-windows')
throw new TypeError('CI workflow must define windows, windows-build, windows-coverage, windows-native-tests, windows-observational, node-24, node-24-coverage, node-24-consumers, node-compat, and all-checks-passed; ci-master must define wine-apt-cache and serial-windows')
}
const windows = workflow.jobs.windows
@@ -84,6 +85,7 @@ describe('CI workflow', () => {
const node24 = workflow.jobs['node-24']
const node24Coverage = workflow.jobs['node-24-coverage']
const node24Consumers = workflow.jobs['node-24-consumers']
const nodeCompat = workflow.jobs['node-compat']
const aggregate = workflow.jobs['all-checks-passed']
if (!Array.isArray(windows.steps) || !Array.isArray(aggregate.needs)) {
throw new TypeError('Windows job must define steps and the aggregate must define needs')
@@ -154,6 +156,14 @@ describe('CI workflow', () => {
isRecord(step) && typeof step.run === 'string'
))
expect(coverageCommands.map(step => step.run)).toContain('pnpm run check:ci:coverage')
// Windows coverage runs zero-build like the Linux lane: workspace imports
// resolve to src through the tsconfig paths map, and the lib-consuming
// suites (webworker-packer image-loadable, webworker-runtime
// transform-corpus, client ui-trajectory client-bundle) self-skip on
// unbuilt checkouts. The regex catches a regression spelled as
// 'corepack pnpm run build' or folded into a multi-line run block, which
// an exact string match would miss.
expect(coverageCommands.every(step => !/\bpnpm\s+run\s+build(?:\s|$)/.test(step.run))).toBe(true)
// windows-native-tests runs the Windows-specific specs.
expect(windowsNativeTests.name).toBe('windows node 24 / native tests')
@@ -199,6 +209,14 @@ describe('CI workflow', () => {
expect(serialInstall!.run.split('\n').map(line => line.trim())).toContain('} else {')
expect(serialInstall!.run.split('\n').map(line => line.trim())).toContain('pnpm install --frozen-lockfile')
expect(serialInstall!.run).not.toContain('$cloneFlag')
// The unsharded reference runs the whole coverage inventory at the same
// per-test budget the PR coverage lane grants; the default 5000ms times
// out load-sensitive store scans (e.g. gen-third-party-notices).
const serialGate = serialSteps.find((step): step is Record<string, unknown> & { env?: Record<string, unknown> } => (
isRecord(step) && step.name === 'Run complete unsharded Windows gate inventory serially'
))
expect(serialGate).toBeDefined()
expect(serialGate!.env).toMatchObject({ DSH_COVERAGE_TEST_TIMEOUT_MS: '90000' })
// Aggregate: Wine and the required split native jobs are needed;
// windows-coverage is temporarily non-blocking while Windows ACP
@@ -222,6 +240,26 @@ describe('CI workflow', () => {
expect(aggregate['runs-on']).toContain('DSH_CI_FAILOVER_LINUX')
expect(aggregate['runs-on']).not.toContain('DSH_CI_FAILOVER_WINDOWS')
expect(aggregate['runs-on']).toContain('vm-backup')
// The run-gates aggregate lanes stop at the first blocking gate failure so
// a red aggregate does not keep burning runner time on the remaining
// gates. Removing the flag silently reverts to running every independent
// gate to completion.
for (const [jobName, job] of [['node-24', node24], ['node-24-coverage', node24Coverage], ['node-24-consumers', node24Consumers], ['node-compat', nodeCompat]] as const) {
expect(job.env, `${jobName} must enable fail-fast`).toMatchObject({ DSH_GATE_FAIL_FAST: '1' })
}
// The native Windows lanes with run-gates aggregates fail fast for the
// same reason: a failing gate aborts the sibling gate instead of waiting
// out the multi-minute instrumented coverage run.
expect(windowsBuild.env, 'windows-build must enable fail-fast').toMatchObject({ DSH_GATE_FAIL_FAST: '1' })
expect(windowsCoverage.env, 'windows-coverage must enable fail-fast').toMatchObject({ DSH_GATE_FAIL_FAST: '1' })
// The observational lane stays complete: it is continue-on-error by design
// and exists to collect as much Windows-native evidence per run as
// possible, so the first failure must not truncate the rest.
expect(windowsObservational.env).toBeDefined()
expect(windowsObservational.env).not.toMatchObject({ DSH_GATE_FAIL_FAST: '1' })
})
it('gives the Wine Host TypeScript compile the repository heap budget', () => {
@@ -630,6 +668,7 @@ describe('Issue lifecycle workflow', () => {
// review events.
const lifecyclePullRequest = workflowEvent(lifecycle, 'pull_request')
const lifecycleReview = workflowEvent(lifecycle, 'pull_request_review')
expect(lifecyclePullRequest.types).toContain('opened')
expect(lifecyclePullRequest.types).not.toContain('ready_for_review')
expect(lifecyclePullRequest.types).toContain('review_requested')
expect(lifecycleReview.types).toEqual(['submitted'])
+3 -2
View File
@@ -50,8 +50,9 @@ export const coverageExemptHeavySuites: readonly CoverageExemptSuite[] = [
{ filter: 'scripts/change-scope.spec.ts', exclude: 'scripts/change-scope.spec.ts' },
{ filter: 'scripts/translation-pairing-merge.spec.ts', exclude: 'scripts/translation-pairing-merge.spec.ts' },
// Built-artifact proof. Packer/runtime src is threshold-excluded, and the
// native Windows aggregate makes this uninstrumented gate wait for build so
// the suite never observes a partially emitted workspace closure.
// suite self-skips on unbuilt checkouts; the serial-windows complete
// reference still starts this uninstrumented gate after its build gate, so
// the assertions execute against complete real artifacts there.
{
filter: 'packages/experimental/webworker-packer/tests/image-loadable.spec.ts',
exclude: 'packages/experimental/webworker-packer/tests/image-loadable.spec.ts',
+3 -3
View File
@@ -165,8 +165,8 @@ describe('dsh-doc skill consolidation', () => {
it('keeps the reference example linked from the skill', () => {
const skill = readFileSync(resolve(root, '.agents/skills/dsh-doc/SKILL.md'), 'utf8')
expect(skill).toContain('session-persistence-sqlite/README.md')
expect(skill).toContain('session-persistence-sqlite/README.zh.md')
expect(skill).toContain('session-persistence-jsonl/README.md')
expect(skill).toContain('session-persistence-jsonl/README.zh.md')
})
it('defines controlled English as a precision-preserving review discipline', () => {
@@ -252,7 +252,7 @@ describe('dsh-doc skill consolidation', () => {
})
describe('reference-example README pair', () => {
const dir = 'packages/session/session-persistence-sqlite'
const dir = 'packages/session/session-persistence-jsonl'
it('keeps exact English/Chinese physical line alignment', () => {
const sourceLines = readFileSync(resolve(root, dir, 'README.md'), 'utf8').split('\n').length
+3 -3
View File
@@ -226,9 +226,9 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'session-persistence',
title: 'Durable session persistence seam',
mode: 'seam',
implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'],
implementations: ['session-persistence-jsonl'],
consumers: ['agent-loop', 'tool-bash', 'hooks-claude-code', 'hooks-codex', 'session-query', 'session-query-sqlite', 'message-feedback'],
note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
note: 'The JSONL backend persists the SessionEvent vocabulary as one artifact per Session.',
},
{
key: 'settings',
@@ -430,7 +430,7 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'agent-loop',
title: 'Concrete loop driver',
mode: 'bundle',
consumers: ['agent-spine-demo'],
consumers: ['base', 'sdk-minimal'],
note: 'The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package.',
},
{
+19 -14
View File
@@ -279,11 +279,13 @@ const workspaceLinkedManifestCache = new Map<string, VirtualManifest | undefined
* Resolve the package version selected for a declaring workspace instead of an
* unrelated historical version that still occupies the shared virtual store.
* @param name - external package identity.
* @param manifests - workspace manifests already loaded by the caller, so one
* load serves every dependency instead of a full re-read per name.
* @returns the first current workspace link for that package, when installed.
*/
function workspaceLinkedManifest(name: string): VirtualManifest | undefined {
function workspaceLinkedManifest(name: string, manifests: Map<string, Manifest>): VirtualManifest | undefined {
if (workspaceLinkedManifestCache.has(name)) return workspaceLinkedManifestCache.get(name)
for (const [path, manifest] of loadWorkspaceManifests().manifests) {
for (const [path, manifest] of manifests) {
if (!ALL_KINDS.some(kind => name in (manifest[kind] ?? {}))) continue
const linked = resolve(root, dirname(path), 'node_modules', name, 'package.json')
if (!existsSync(linked)) continue
@@ -296,8 +298,8 @@ function workspaceLinkedManifest(name: string): VirtualManifest | undefined {
}
/** Resolve one installed external package manifest from either pnpm store. */
function installedManifest(name: string, expectedVersion?: string): VirtualManifest | undefined {
const linked = workspaceLinkedManifest(name)
function installedManifest(name: string, manifests: Map<string, Manifest>, expectedVersion?: string): VirtualManifest | undefined {
const linked = workspaceLinkedManifest(name, manifests)
if (linked !== undefined && (expectedVersion === undefined || linked.version === expectedVersion)) return linked
let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined
// Workspace-local link farms can expose a dependency that is not linked at
@@ -320,9 +322,9 @@ function installedManifest(name: string, expectedVersion?: string): VirtualManif
}
/** License and repository URL for an installed external package, from the pnpm store. */
function installedMetadata(name: string): { license: string; repo: string } {
function installedMetadata(name: string, manifests: Map<string, Manifest>): { license: string; repo: string } {
const override = OVERRIDES[name]
const manifest = installedManifest(name)
const manifest = installedManifest(name, manifests)
const license = override?.license ?? manifest?.license
const rawRepo = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url ?? manifest?.homepage
const repo = override?.repo ?? normalizeRepo(rawRepo)
@@ -332,8 +334,8 @@ function installedMetadata(name: string): { license: string; repo: string } {
return { license, repo }
}
function collectClaudeDistribution(): ClaudeDistribution {
const manifest = installedManifest(CLAUDE_AGENT_SDK_PACKAGE)
function collectClaudeDistribution(manifests: Map<string, Manifest>): ClaudeDistribution {
const manifest = installedManifest(CLAUDE_AGENT_SDK_PACKAGE, manifests)
if (manifest === undefined) {
throw new Error(
`gen-third-party-notices: cannot resolve ${CLAUDE_AGENT_SDK_PACKAGE}; run \`pnpm install\`.`,
@@ -342,7 +344,7 @@ function collectClaudeDistribution(): ClaudeDistribution {
const distribution = claudeDistributionFromManifest(manifest)
let installedPayloads = 0
for (const payload of distribution.payloads) {
const installed = installedManifest(payload.name, payload.version)
const installed = installedManifest(payload.name, manifests, payload.version)
if (installed === undefined) continue
installedPayloads += 1
if (
@@ -383,12 +385,11 @@ function normalizeRepo(raw: string | undefined): string | undefined {
* by tooling, test infrastructure, the website, or the demo leaves — whatever
* the declaring section is called — is development-only.
*/
function collectNpmDeps(): ExternalDep[] {
const { manifests, names } = loadWorkspaceManifests()
function collectNpmDeps(manifests: Map<string, Manifest>, names: Set<string>): ExternalDep[] {
return [...tierExternalDeps(manifests, names)]
.filter(([name]) => !FIRST_PARTY.has(name))
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, runtime]) => ({ name, ...installedMetadata(name), runtime }))
.map(([name, runtime]) => ({ name, ...installedMetadata(name, manifests), runtime }))
}
/**
@@ -692,7 +693,11 @@ ${rows.join('\n')}
*/
export function render(): string {
verifyBuildTimePins()
const npm = collectNpmDeps()
// The linked-manifest cache is keyed by name only, so it must not outlive
// the manifests map it was resolved from; render() owns that single load.
workspaceLinkedManifestCache.clear()
const { manifests, names } = loadWorkspaceManifests()
const npm = collectNpmDeps(manifests, names)
const runtimeDeps = npm.filter(dep => dep.runtime)
const devDeps = npm.filter(dep => !dep.runtime)
const vendored = collectVendored()
@@ -701,7 +706,7 @@ export function render(): string {
const claudeDistribution = runtimeDeps.some(
dep => dep.name === CLAUDE_AGENT_SDK_PACKAGE,
)
? collectClaudeDistribution()
? collectClaudeDistribution(manifests)
: undefined
const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license))
// A copyleft license reaching a shipped surface is a distribution decision,
-15
View File
@@ -241,21 +241,6 @@ const EXACT_EDITS: readonly ExactEdit[] = [
replace: '| Directory | npm name | Upstream name | Version | Upstream repo | Commit |\n|---|---|---|---|---|---|',
expect: 1,
},
{
// A plain fence listing the bundle's mounted tree: a bare token, no quotes.
id: 'agent-spine-demo-mounted-tree',
file: 'packages/examples/agent-spine-demo/README.md',
find: '@cordisjs/plugin-timer timer service',
replace: '@deepseek-ai/cordis-plugin-timer timer service',
expect: 1,
},
{
id: 'agent-spine-demo-mounted-tree-zh',
file: 'packages/examples/agent-spine-demo/README.zh.md',
find: '@cordisjs/plugin-timer timer service',
replace: '@deepseek-ai/cordis-plugin-timer timer service',
expect: 1,
},
{
// The root contract claimed vendored packages keep their upstream names.
id: 'root-agents-vendored-name-contract',
+379 -2
View File
@@ -1,14 +1,94 @@
import { describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { describe, expect, it, vi, type MockInstance } from 'vitest'
import {
cliGateOptions,
defaultConcurrency,
formatGateResultReason,
gatesForMode,
parsePidPpidLines,
runGate,
runGates,
taskkillArgs,
type Gate,
type GateResult,
} from './run-gates.ts'
/**
* Capture output a gate streams through runGate's streamOutput path.
* @returns the accumulated chunks and the stdout spy to restore in finally.
*/
function captureStreamedOutput(): { writes: string[]; write: MockInstance } {
const writes: string[] = []
const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
writes.push(String(chunk))
return true
})
return { writes, write }
}
/**
* A process has stopped executing when its /proc entry is gone, or when it
* lingers as a zombie ('Z') — an un-reaped but dead entry still answers
* kill(pid, 0), so existence is not a liveness check. Non-Linux falls back to
* kill(pid, 0), whose ESRCH means the process is gone.
*/
function procStopped(pid: number): boolean {
if (process.platform === 'linux') {
try {
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
return /\)\s+Z\s/.test(stat)
} catch {
return true
}
}
try {
process.kill(pid, 0)
return false
} catch {
return true
}
}
/**
* Wait until the captured output contains `marker` and the grandchild pid the
* gate printed, then return that pid.
* @param writes - chunks captured from the gate's streamed stdout.
* @param marker - the output line that proves the gate reached the abort point.
* @param deadline - fail the wait when exceeded.
* @returns the grandchild pid printed by the gate script.
*/
async function waitForGrandchildPid(writes: string[], marker: string, deadline: number): Promise<number> {
let pid: number | undefined
while ((pid === undefined || !writes.join('').includes(marker)) && Date.now() < deadline) {
const match = writes.join('').match(/grandchild:(\d+)/)
if (match !== null) pid = Number(match[1])
await new Promise(resolve => setTimeout(resolve, 50))
}
expect(pid ?? 0).toBeGreaterThan(0)
expect(writes.join('')).toContain(marker)
return pid!
}
/**
* Abort the run and assert it settles marked aborted with the grandchild no
* longer executing — the abort path must have signalled it from the captured
* descendant list rather than settling over a live orphan.
* @param promise - the pending `runGate` promise.
* @param controller - the signal source to abort.
* @param pid - the grandchild pid the gate script printed.
*/
async function abortAndExpectTreeStopped(promise: Promise<GateResult>, controller: AbortController, pid: number): Promise<void> {
controller.abort()
const result = await promise
expect(result.aborted).toBe(true)
const stopDeadline = Date.now() + 8000
while (!procStopped(pid) && Date.now() < stopDeadline) {
await new Promise(resolve => setTimeout(resolve, 50))
}
expect(procStopped(pid)).toBe(true)
}
function gate(id: string, options: Partial<Gate> = {}): Gate {
return {
id,
@@ -294,7 +374,7 @@ describe('gate graph validation', () => {
const results = await runGates([dependent, root], 1, execute)
expect(execute).toHaveBeenCalledOnce()
expect(execute).toHaveBeenCalledWith(root)
expect(execute).toHaveBeenCalledWith(root, undefined)
expect(results[0]).toMatchObject({ gate: dependent, status: 'skipped', error: 'dependency failed or skipped: root' })
})
@@ -529,3 +609,300 @@ describe('gate process outcomes', () => {
expect(formatGateResultReason(result)).toBe('signal SIGTERM')
})
})
describe('fail-fast scheduling', () => {
it('aborts the aggregate at the first blocking failure', async () => {
const slow = gate('slow')
const fast = gate('fast')
const dependent = gate('dependent', { needs: ['slow'] })
const execute = vi.fn(async (subject: Gate, signal?: AbortSignal) => {
if (subject.id === 'fast') {
return new Promise<GateResult>((resolve) => {
signal?.addEventListener('abort', () => {
// The real runGate marks a gate the abort terminated; the drain
// must then record it skipped rather than keep the failure.
resolve({ ...resultFor(subject, 'failed'), aborted: true })
}, { once: true })
})
}
return resultFor(subject, subject.id === 'slow' ? 'failed' : 'passed')
})
const results = await runGates([slow, fast, dependent], 2, execute, () => {}, { failFast: true })
expect(execute.mock.calls.map(([subject]) => subject.id)).toEqual(['slow', 'fast'])
expect(results.map(result => result.status)).toEqual(['failed', 'skipped', 'skipped'])
expect(results[1]).toMatchObject({
status: 'skipped',
error: 'aborted by fail-fast: slow failed',
})
expect(results[2]).toMatchObject({
status: 'skipped',
error: 'aborted by fail-fast: slow failed',
})
})
it('does not abort on a non-blocking gate failure', async () => {
const observational = gate('observational', { allowFailure: true })
const root = gate('root')
const execute = vi.fn(async (subject: Gate) => (
resultFor(subject, subject.id === 'observational' ? 'failed' : 'passed')
))
const results = await runGates([observational, root], 2, execute, () => {}, { failFast: true })
expect(execute).toHaveBeenCalledTimes(2)
expect(results.map(result => result.status)).toEqual(['failed', 'passed'])
})
it('runs independent gates to completion when fail-fast is disabled', async () => {
const root = gate('root')
const sibling = gate('sibling')
const execute = vi.fn(async (subject: Gate) => (
resultFor(subject, subject.id === 'root' ? 'failed' : 'passed')
))
const results = await runGates([root, sibling], 2, execute, () => {}, { failFast: false })
expect(execute).toHaveBeenCalledTimes(2)
expect(results.map(result => result.status)).toEqual(['failed', 'passed'])
})
it('kills the child when the abort signal fires', async () => {
const controller = new AbortController()
const promise = runGate(gate('killable', { args: ['-e', 'setInterval(() => {}, 1000)'] }), controller.signal)
controller.abort()
const result = await promise
expect(result.status).toBe('failed')
expect(result.aborted).toBe(true)
if (process.platform !== 'win32') expect(result.signalCode).toBe('SIGTERM')
})
it.skipIf(process.platform === 'win32')('marks a zero-exit child as aborted when the signal fired', async () => {
const { writes, write } = captureStreamedOutput()
try {
const controller = new AbortController()
const child = gate('traps-signal', {
args: ['-e', "process.stdout.write('ready\\n'); process.on('SIGTERM', () => process.exit(0)); setInterval(() => {}, 1000)"],
streamOutput: true,
})
const promise = runGate(child, controller.signal)
// Wait for the child to register its SIGTERM trap before aborting, so
// the signal is caught and the child really exits zero.
const deadline = Date.now() + 5000
while (!writes.join('').includes('ready') && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 10))
}
controller.abort()
const result = await promise
// The child trapped the signal and exited zero; the drain must not
// report this gate passed, so the raw outcome carries the abort mark.
expect(result.status).toBe('passed')
expect(result.aborted).toBe(true)
} finally {
write.mockRestore()
}
})
it.skipIf(process.platform === 'win32')('kills the whole gate process tree when the abort signal fires', async () => {
const { writes, write } = captureStreamedOutput()
const controller = new AbortController()
let promise: Promise<GateResult> | undefined
try {
const script = [
"const { spawn } = require('node:child_process')",
// Detached, so the grandchild leads its own process group: the gate
// group signal cannot reach it, and only the descendant enumeration in
// treeKill does — the shape of a nested run-gates' leaf gates.
"const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true })",
"process.stdout.write('grandchild:' + grandchild.pid + '\\n')",
'setInterval(() => {}, 1000)',
].join(';')
promise = runGate(gate('tree', { args: ['-e', script], streamOutput: true }), controller.signal)
const deadline = Date.now() + 5000
let pid: number | undefined
while (pid === undefined && Date.now() < deadline) {
const match = writes.join('').match(/grandchild:(\d+)/)
if (match !== null) pid = Number(match[1])
else await new Promise(resolve => setTimeout(resolve, 20))
}
expect(pid ?? 0).toBeGreaterThan(0)
controller.abort()
const result = await promise
expect(result.status).toBe('failed')
// The descendant enumeration signals the detached grandchild at the same
// time as the group signal reaches the direct child; the direct child's
// own death closes the gate pipes, so poll for the grandchild to stop
// executing rather than asserting on a fixed instant.
const stopDeadline = Date.now() + 5000
while (!procStopped(pid!) && Date.now() < stopDeadline) {
await new Promise(resolve => setTimeout(resolve, 20))
}
expect(procStopped(pid!)).toBe(true)
} finally {
// A failed wait or assertion must not leave the forever-looping detached
// grandchild behind on the host: abort the gate and wait for the
// process tree to settle before restoring the spy.
controller.abort()
await promise
write.mockRestore()
}
})
it('forwards host interruption signals to the abort path', async () => {
const slow = gate('slow')
const sibling = gate('sibling')
const execute = vi.fn(async (subject: Gate, signal?: AbortSignal) => {
if (subject.id === 'slow') {
return new Promise<GateResult>((resolve) => {
signal?.addEventListener('abort', () => {
// A child can trap the signal and exit zero; the drain must still
// record the gate skipped so the interrupted run fails.
resolve({ ...resultFor(subject, 'passed'), aborted: true })
}, { once: true })
})
}
return resultFor(subject)
})
const promise = runGates([slow, sibling], 1, execute, () => {}, { failFast: true, forwardProcessSignals: true })
// The first loop iteration starts `slow` synchronously, so its abort
// listener is registered before the signal is emitted.
process.emit('SIGTERM')
const results = await promise
expect(execute).toHaveBeenCalledOnce()
expect(results.map(result => result.status)).toEqual(['skipped', 'skipped'])
expect(results[0]).toMatchObject({
status: 'skipped',
error: 'aborted by fail-fast: host interruption',
})
})
it('pairs host signal forwarding with fail-fast at the CLI entrypoint', () => {
expect(cliGateOptions(true)).toEqual({ failFast: true, forwardProcessSignals: true })
expect(cliGateOptions(false)).toEqual({ failFast: false, forwardProcessSignals: false })
})
it('rejects host signal forwarding without fail-fast', async () => {
const execute = vi.fn(async (subject: Gate) => resultFor(subject))
await expect(runGates([gate('subject')], 1, execute, () => {}, { forwardProcessSignals: true }))
.rejects.toThrow('forwardProcessSignals requires failFast')
expect(execute).not.toHaveBeenCalled()
})
it('leaves an un-aborted child running to completion', async () => {
const result = await runGate(gate('settles', { args: ['-e', ''] }), new AbortController().signal)
expect(result.status).toBe('passed')
expect(result.aborted).toBe(false)
})
it.skipIf(process.platform === 'win32')('kills a detached descendant that outlived the child when the abort arrives later', async () => {
const writes: string[] = []
const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
writes.push(String(chunk))
return true
})
const controller = new AbortController()
let promise: Promise<GateResult> | undefined
try {
const script = [
"const { spawn } = require('node:child_process')",
// Detached with inherited stdio: the grandchild leads its own process
// group (the gate group signal misses it) and holds the gate's
// stdout write end (so `close` stays pending past the child exit).
"const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'inherit' })",
"process.stdout.write('grandchild:' + grandchild.pid + '\\n')",
// Outlive the first descendant-sampler tick with margin so the cache
// holds the grandchild even on a loaded runner, then exit normally
// before the abort arrives.
"setTimeout(() => { process.stdout.write('child-exit\\n'); process.exit(0) }, 8000)",
].join(';')
promise = runGate(gate('late-abort', { args: ['-e', script], streamOutput: true }), controller.signal)
const pid = await waitForGrandchildPid(writes, 'child-exit', Date.now() + 10000)
// terminate must not re-enumerate over the sampler cache now that the
// child is gone; the detached grandchild is killed from the cached list.
await abortAndExpectTreeStopped(promise, controller, pid)
} finally {
// A failed wait or assertion must not leave the forever-looping detached
// grandchild behind on the host: abort the gate and wait for the
// process tree to settle before restoring the spy.
controller.abort()
await promise
write.mockRestore()
}
}, 20000)
it.skipIf(process.platform === 'win32')('keeps a reparented detached descendant tracked across a sampler tick', async () => {
const { writes, write } = captureStreamedOutput()
const controller = new AbortController()
let promise: Promise<GateResult> | undefined
try {
const script = [
"const { spawn } = require('node:child_process')",
// Wrapper spawns a detached grandchild with inherited stdio (its own
// process group, holding the gate's stdout write end), prints the pid,
// then exits after 7 seconds — after the first sampler tick, before
// the second. From then on the grandchild is reparented and
// unreachable by parent id.
"const wrapper = spawn(process.execPath, ['-e', \"const { spawn } = require('node:child_process'); const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'inherit' }); process.stdout.write('grandchild:' + grandchild.pid + '\\\\n'); setTimeout(() => process.exit(0), 7000)\"], { stdio: 'inherit' })",
"wrapper.on('exit', () => process.stdout.write('wrapper-exited\\n'))",
// Keep the root child alive past the abort with a heartbeat so the
// test can abort while it is still running.
"setInterval(() => process.stdout.write('hb\\n'), 1000)",
].join(';')
promise = runGate(gate('sampler-merge', { args: ['-e', script], streamOutput: true }), controller.signal)
const pid = await waitForGrandchildPid(writes, 'wrapper-exited', Date.now() + 15000)
// Wait past the second sampler tick (t=10) with margin: a replacing tick
// would drop the reparented grandchild from the cache, after which the
// abort cannot reach it. The root child keeps running throughout.
const tickDeadline = Date.now() + 10000
const wrapperExitedAt = Date.now()
while (Date.now() - wrapperExitedAt < 5000 && Date.now() < tickDeadline) {
await new Promise(resolve => setTimeout(resolve, 50))
}
expect(Date.now() - wrapperExitedAt).toBeGreaterThanOrEqual(5000)
await abortAndExpectTreeStopped(promise, controller, pid)
} finally {
// A failed wait or assertion must not leave the forever-looping detached
// grandchild behind on the host: abort the gate and wait for the
// process tree to settle before restoring the spy.
controller.abort()
await promise
write.mockRestore()
}
}, 30000)
})
describe('process-table parsing', () => {
it('parses `pid ppid` rows from a POSIX ps dump', () => {
expect(parsePidPpidLines(' 123 1\n456 123\n 789 456\n')).toEqual([[123, 1], [456, 123], [789, 456]])
})
it('parses Windows PowerShell Get-CimInstance output of the same shape', () => {
expect(parsePidPpidLines(' 123 1\r\n456 123\r\n')).toEqual([[123, 1], [456, 123]])
})
it('drops blank and malformed lines', () => {
expect(parsePidPpidLines(' 123 1\n\ncommand not found\n999 abc\n')).toEqual([[123, 1]])
})
})
describe('Windows tree termination', () => {
it('targets the root first and each captured descendant after it', () => {
expect(taskkillArgs(100, [201, 302, 403])).toEqual([
['/PID', '100', '/T', '/F'],
['/PID', '201', '/T', '/F'],
['/PID', '302', '/T', '/F'],
['/PID', '403', '/T', '/F'],
])
})
it('terminates the root alone when no descendant was captured', () => {
expect(taskkillArgs(100, [])).toEqual([['/PID', '100', '/T', '/F']])
})
})
+579 -41
View File
@@ -5,7 +5,8 @@
* dependency graphs, scheduler environment, and process diagnostics.
* @see ../.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md
*/
import { spawn } from 'node:child_process'
import { spawn, spawnSync } from 'node:child_process'
import { readdirSync, readFileSync } from 'node:fs'
import { availableParallelism } from 'node:os'
import { resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
@@ -69,6 +70,10 @@ export interface GateResult {
exitCode: number | null
signalCode: NodeJS.Signals | null
error?: string
/** True when the shared abort signal terminated this gate before its outcome
* was observed; such a result must not be reported as passed, even if the
* child trapped the signal and exited zero. */
aborted?: boolean
}
interface GateOutputChunk {
@@ -86,7 +91,7 @@ interface ConcurrencyDefault {
source: string
}
type GateExecutor = (gate: Gate) => Promise<GateResult>
type GateExecutor = (gate: Gate, signal?: AbortSignal) => Promise<GateResult>
type ResultObserver = (result: GateResult) => void
const root = resolve(import.meta.dirname, '..')
@@ -103,16 +108,28 @@ async function main(args: string[]): Promise<number> {
const concurrencySource = concurrencyOverride === undefined || concurrencyOverride === ''
? concurrencyDefault.source
: '$DSH_GATE_CONCURRENCY'
const failFast = flagEnabled('DSH_GATE_FAIL_FAST')
const startedAt = performance.now()
console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`)
console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}${failFast ? ', fail-fast after first blocking failure' : ''}.`)
const results = await runGates(gates, maxConcurrency, runGate, printResult)
const results = await runGates(gates, maxConcurrency, runGate, printResult, cliGateOptions(failFast))
printSummary(results, performance.now() - startedAt)
return results.some(result => result.gate.allowFailure !== true && (result.status === 'failed' || result.status === 'skipped'))
? 1
: 0
}
/**
* The options the CLI entrypoint hands to the scheduler. Host signal
* forwarding always follows fail-fast: children are detached only then, so
* without it the forwarding would have no tree to drain.
* @param failFast - whether `DSH_GATE_FAIL_FAST` is enabled.
* @returns the scheduler options for the entrypoint.
*/
export function cliGateOptions(failFast: boolean): RunGatesOptions {
return { failFast, forwardProcessSignals: failFast }
}
function parseMode(raw: string | undefined): Mode {
switch (raw) {
case 'ci-primary':
@@ -838,12 +855,30 @@ function findDependencyCycle(gates: readonly Gate[]): string[] | undefined {
return undefined
}
/**
* Scheduling options for one aggregate.
*/
export interface RunGatesOptions {
/** Stop the aggregate at the first blocking gate failure. */
failFast?: boolean
/** Forward host SIGINT/SIGTERM to the abort path so detached gate trees are
* terminated when the run itself is interrupted or the runner cancels it.
* Tree termination additionally requires failFast, because only then is the
* abort signal passed to the executor and children detached. */
forwardProcessSignals?: boolean
}
/**
* Validate and run one aggregate before the injected executor can start a child.
* @param gates - complete aggregate to execute.
* @param maxActive - maximum concurrent child count.
* @param execute - child-process executor.
* @param execute - child-process executor; receives the abort signal only when
* fail-fast is enabled, so ordinary runs keep their children in the host
* process group.
* @param observe - result observer invoked when each gate settles.
* @param options - scheduling options; fail-fast aborts the aggregate at the
* first blocking gate failure by killing running children and skipping every
* not-yet-run gate.
* @returns results in aggregate order.
*/
export async function runGates(
@@ -851,54 +886,105 @@ export async function runGates(
maxActive: number,
execute: GateExecutor,
observe: ResultObserver = () => {},
options: RunGatesOptions = {},
): Promise<GateResult[]> {
validateGateGraph(gates)
if (!Number.isSafeInteger(maxActive) || maxActive < 1) {
throw new Error(`run-gates: max concurrency must be a positive integer, got ${JSON.stringify(maxActive)}.`)
}
if (options.forwardProcessSignals === true && options.failFast !== true) {
throw new Error('run-gates: forwardProcessSignals requires failFast, otherwise no child is detached or killed.')
}
const states = new Map<string, GateState>(gates.map(gate => [gate.id, 'pending']))
const results = new Map<string, GateResult>()
const running: RunningGate[] = []
for (;;) {
let madeProgress = false
while (running.length < maxActive) {
const ready = gates.find(gate => states.get(gate.id) === 'pending' && predecessorsReady(gate, states))
if (ready === undefined) break
states.set(ready.id, 'running')
running.push({ gate: ready, promise: execute(ready) })
console.log(`run-gates: start ${ready.label}`)
madeProgress = true
const abort = new AbortController()
let abortCause: string | undefined
// Host interruption (terminal Ctrl+C, runner cancellation) drains through
// the same abort path as a gate failure, so detached trees are killed and
// never orphaned. Handlers are removed before returning.
const hostSignals = options.forwardProcessSignals === true ? ['SIGINT', 'SIGTERM'] as const : []
const hostHandlers = hostSignals.map((name) => {
const handler = () => {
abortCause = abortCause ?? 'host interruption'
abort.abort()
}
process.on(name, handler)
return { name, handler }
})
const failFastSignal = options.failFast === true ? abort.signal : undefined
if (running.length === 0) {
const pending = gates.filter(gate => states.get(gate.id) === 'pending')
if (pending.length === 0) break
const gate = pending.find(item => (item.needs ?? []).some(id => gateFailed(states.get(id))))
if (gate === undefined) throw new Error('run-gates: validated graph stalled without a failed dependency.')
const failedDeps = (gate.needs ?? []).filter(id => gateFailed(states.get(id)))
const result: GateResult = {
gate,
status: 'skipped',
durationMs: 0,
output: [],
exitCode: null,
signalCode: null,
error: `dependency failed or skipped: ${failedDeps.join(', ')}`,
try {
for (;;) {
let madeProgress = false
if (abortCause === undefined) {
while (running.length < maxActive) {
const ready = gates.find(gate => states.get(gate.id) === 'pending' && predecessorsReady(gate, states))
if (ready === undefined) break
states.set(ready.id, 'running')
running.push({ gate: ready, promise: execute(ready, failFastSignal) })
console.log(`run-gates: start ${ready.label}`)
madeProgress = true
}
}
states.set(gate.id, 'skipped')
results.set(gate.id, result)
observe(result)
continue
}
if (!madeProgress) {
const settled = await Promise.race(running.map(async item => ({ item, result: await item.promise })))
running.splice(running.indexOf(settled.item), 1)
states.set(settled.item.gate.id, settled.result.status)
results.set(settled.item.gate.id, settled.result)
observe(settled.result)
if (running.length === 0) {
if (abortCause !== undefined) {
for (const gate of gates) {
if (states.get(gate.id) !== 'pending') continue
const skipped = skippedByFailFast(gate, abortCause)
states.set(gate.id, 'skipped')
results.set(gate.id, skipped)
observe(skipped)
}
break
}
const pending = gates.filter(gate => states.get(gate.id) === 'pending')
if (pending.length === 0) break
const gate = pending.find(item => (item.needs ?? []).some(id => gateFailed(states.get(id))))
if (gate === undefined) throw new Error('run-gates: validated graph stalled without a failed dependency.')
const failedDeps = (gate.needs ?? []).filter(id => gateFailed(states.get(id)))
const result: GateResult = {
gate,
status: 'skipped',
durationMs: 0,
output: [],
exitCode: null,
signalCode: null,
error: `dependency failed or skipped: ${failedDeps.join(', ')}`,
}
states.set(gate.id, 'skipped')
results.set(gate.id, result)
observe(result)
continue
}
if (!madeProgress) {
const settled = await Promise.race(running.map(async item => ({ item, result: await item.promise })))
running.splice(running.indexOf(settled.item), 1)
const observed = abortCause === undefined || settled.result.aborted !== true
? settled.result
: skippedByFailFast(settled.item.gate, abortCause)
states.set(settled.item.gate.id, observed.status)
results.set(settled.item.gate.id, observed)
observe(observed)
if (abortCause === undefined && options.failFast === true
&& observed.status === 'failed' && settled.item.gate.allowFailure !== true) {
abortCause = `${observed.gate.label} failed`
abort.abort()
console.error(`run-gates: fail-fast aborting: ${abortCause}.`)
for (const gate of gates) {
if (states.get(gate.id) !== 'pending') continue
const skipped = skippedByFailFast(gate, abortCause)
states.set(gate.id, 'skipped')
results.set(gate.id, skipped)
observe(skipped)
}
}
}
}
} finally {
for (const { name, handler } of hostHandlers) process.removeListener(name, handler)
}
return gates.map((gate) => {
@@ -908,6 +994,31 @@ export async function runGates(
})
}
/**
* The result of a gate that produced no evidence because fail-fast aborted.
* A gate whose process settled before the abort took effect keeps its real
* result instead: it did produce evidence, and the summary must say so. Any
* result settling after the abort — including a genuine independent failure
* in the race window, and a child that trapped the signal and exited zero —
* is recorded skipped with its partial output discarded, because on Windows a
* killed process is indistinguishable from a failed one by exit code alone.
* @param gate - the gate that produced no evidence.
* @param cause - the full clause naming what aborted the aggregate, e.g.
* `typecheck failed` or `host interruption`.
* @returns the skipped record with the fail-fast error.
*/
function skippedByFailFast(gate: Gate, cause: string): GateResult {
return {
gate,
status: 'skipped',
durationMs: 0,
output: [],
exitCode: null,
signalCode: null,
error: `aborted by fail-fast: ${cause}`,
}
}
function predecessorsReady(gate: Gate, states: Map<string, GateState>): boolean {
return (gate.needs ?? []).every(id => states.get(id) === 'passed')
&& (gate.after ?? []).every(id => gateSettled(states.get(id)))
@@ -924,12 +1035,17 @@ function gateFailed(state: GateState | undefined): boolean {
/**
* Execute one gate through the real shell-free child-process boundary.
* @param gate - command and scheduler environment to execute.
* @param signal - abort signal that terminates the whole gate process tree when
* the aggregate fails fast; an already-aborted signal terminates it
* immediately. A provided signal spawns the child detached so POSIX can signal
* its process group and Windows can reach its tree through taskkill.
* @returns the complete process outcome.
*/
export async function runGate(gate: Gate): Promise<GateResult> {
export async function runGate(gate: Gate, signal?: AbortSignal): Promise<GateResult> {
const started = performance.now()
const output: GateOutputChunk[] = []
let spawnError: string | undefined
let aborted = false
const outcome = await new Promise<{
exitCode: number | null
@@ -939,6 +1055,7 @@ export async function runGate(gate: Gate): Promise<GateResult> {
cwd: root,
env: { ...process.env, ...gate.env },
stdio: ['pipe', 'pipe', 'pipe'],
detached: signal !== undefined && process.platform !== 'win32',
})
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
@@ -950,11 +1067,187 @@ export async function runGate(gate: Gate): Promise<GateResult> {
if (gate.streamOutput === true) process.stderr.write(chunk)
else output.push({ stream: 'stderr', text: chunk })
})
// Deliver one signal to the entire gate tree: the negative pid targets the
// POSIX process group the detached child leads; Windows has no groups, so
// taskkill walks the tree rooted at the child and force-terminates (a
// taskkill without `/F` does not terminate console processes, which is
// what gate commands are). Outcomes are deliberately unchecked because
// delivery races tree exit, and a missing taskkill binary is as tolerable
// as ESRCH. Mirrors the subprocess package's teardown contract
// (packages/subprocess/subprocess-local/src/spawn.ts).
const treeKill = (signalToSend: 'SIGTERM' | 'SIGKILL') => {
const pid = child.pid
if (pid === undefined) return
if (process.platform === 'win32') {
for (const args of taskkillArgs(pid, descendants)) {
spawnSync('taskkill', args, { stdio: 'ignore' })
}
return
}
try {
process.kill(-pid, signalToSend)
} catch {
// The group is gone; the direct child may still be alive alone.
child.kill(signalToSend)
}
// The captured list stays valid after the group kill reparents the
// detached descendants of a nested run-gates (the `check:node-compat`
// and `check:ci:lint:contracts-ready` gates in ci-consumers): pids do
// not change on reparenting, so the escalation reaches leaves that
// ignored SIGTERM without re-enumerating.
for (const descendantPid of descendants) {
try {
process.kill(descendantPid, signalToSend)
} catch {
// The descendant exited between the enumeration and the signal.
}
}
}
let escalation: ReturnType<typeof setTimeout> | undefined
let terminatedAt = 0
// Captured once at terminate and re-signalled on escalation: the group
// kill reaps the direct child, after which its detached descendants are
// reparented and unreachable by parent id, so the escalation cannot
// re-enumerate them.
let descendants: number[] = []
let pipeDrain: ReturnType<typeof setTimeout> | undefined
const terminate = () => {
aborted = true
const pid = child.pid
// Merge while the child is still alive: re-enumerating alone would drop
// a descendant that an exited intermediate reparented out of the parent
// chain, and replacing the list entirely would lose the sampler's
// last-known entries when the child already exited. Union preserves both.
// The sampler runs on every platform (including Windows, where an
// exited intermediate's table record vanishes and a fresh enumeration
// cannot cross the gap), so the cache is the source of truth once the
// child is gone.
if (pid !== undefined && child.exitCode === null && child.signalCode === null) {
descendants = [...new Set([...descendants, ...descendantPids(pid)])]
}
treeKill('SIGTERM')
if (escalation === undefined) {
terminatedAt = Date.now()
// Force-kill at the deadline regardless of the direct child's exit
// state: when the wrapper dies but a grandchild ignores SIGTERM and
// still holds the stdio pipes, `close` has not fired and the tree must
// still be killed. treeKill swallows an already-absent group.
escalation = setTimeout(() => { treeKill('SIGKILL') }, 5000)
}
if (pipeDrain === undefined) {
// `close` can stay pending past the direct child's exit when a
// descendant holds the stdio write ends (escaped process group, or
// uninterruptible I/O that keeps the SIGKILL pending). Bound the wait
// past the 5-second SIGKILL grace and force the streams closed so
// fail-fast settles instead of hanging to the job timeout. Only the
// abort path arms it: on an ordinary run a gate that outlives its
// descendants must keep waiting rather than report passed over a live
// leak. Armed in terminate (not only at `exit`) so the window where
// the child already exited before the abort is covered too.
pipeDrain = setTimeout(() => {
child.stdout.destroy()
child.stderr.destroy()
child.stdin.destroy()
}, 10000)
}
}
if (signal !== undefined) {
if (signal.aborted) terminate()
else signal.addEventListener('abort', terminate, { once: true })
}
// Refresh the descendant cache while the child runs, so an abort that
// arrives after the child already exited can still reach a detached
// descendant the child left behind: once the child is gone, its
// descendants are reparented (POSIX) or their intermediate's table record
// is gone (Windows), so a fresh enumeration cannot cross the gap. The
// cache is primed at spawn and refreshed every 5 seconds, so a descendant
// is captured once it appears in any enumeration whose parent chain is
// still fully present in the table; the residual window is a descendant
// that never appears in such a snapshot — created after one enumeration
// and orphaned before the next. Enumeration is asynchronous (a slow
// WMI/CIM call is bounded by its own 10-second timeout), so a gate's
// output draining and exit handling are never blocked while the sampler
// reads the process table. Fail-fast runs only; ordinary runs never
// abort.
let descendantSampler: ReturnType<typeof setInterval> | undefined
if (signal !== undefined) {
let enumerationInFlight: { cancel: () => void } | undefined
const refreshDescendants = () => {
const pid = child.pid
if (pid === undefined || child.exitCode !== null || child.signalCode !== null) return
if (enumerationInFlight !== undefined) return
const handle = descendantPidsAsync(pid, process.platform)
enumerationInFlight = handle
void handle.promise.then((fresh) => {
if (enumerationInFlight === handle) enumerationInFlight = undefined
// Merge regardless of the child's exit state: the enumeration
// started while the child was alive, so its snapshot is the last
// reliable view of the tree. The child may exit (its intermediate
// gone, its table record vanished) before the promise settles while
// a grandchild still holds the stdio write ends and keeps `close`
// pending — exactly when terminate needs this list.
// Merge instead of replacing, like terminate: an intermediate that
// exited since the last tick reparented its detached descendants
// out of the parent chain, so a fresh enumeration alone would drop
// them. Filter the cache to the still-executing so a long gate
// does not accumulate stale pids; while sampler ticks still run the
// live filter also keeps the escalation from signalling a reused
// pid, but once ticks stop (child exited) the cache can go stale,
// and a pid reused after that is the accepted sampling window.
descendants = [...new Set([...descendants.filter(processAlive), ...fresh])]
})
}
const cancelInFlightEnumeration = () => {
if (enumerationInFlight !== undefined) enumerationInFlight.cancel()
enumerationInFlight = undefined
}
refreshDescendants()
descendantSampler = setInterval(refreshDescendants, 5000)
// A gate that settles while an enumeration is still running must not
// leave the PowerShell subprocess holding stdio handles until its own
// timeout: stop it as soon as the child's outcome is known.
child.once('close', cancelInFlightEnumeration)
child.once('error', cancelInFlightEnumeration)
}
child.on('error', (error) => {
if (escalation !== undefined) clearTimeout(escalation)
if (pipeDrain !== undefined) clearTimeout(pipeDrain)
if (descendantSampler !== undefined) clearInterval(descendantSampler)
if (signal !== undefined) signal.removeEventListener('abort', terminate)
spawnError = `failed to start command: ${error.message}`
resolveExit({ exitCode: null, signalCode: null })
})
child.on('close', (exitCode, signalCode) => {
if (pipeDrain !== undefined) clearTimeout(pipeDrain)
if (descendantSampler !== undefined) clearInterval(descendantSampler)
if (signal !== undefined) signal.removeEventListener('abort', terminate)
if (escalation !== undefined && process.platform !== 'win32') {
// `close` only means the direct child's stdio closed; a grandchild
// that ignored SIGTERM and redirected its stdio can outlive it. Do
// not settle until the process group and the captured descendants are
// confirmed gone — the deadline SIGKILL covers members still alive at
// the grace end — so runGate returns only once the tree is quiescent.
const confirmGroupGone = () => {
if (!groupAlive(child.pid) && descendants.every(descendantPid => !processAlive(descendantPid))) {
clearTimeout(escalation)
resolveExit({ exitCode, signalCode })
return
}
if (Date.now() - terminatedAt < 8000) {
setTimeout(confirmGroupGone, 50)
return
}
// The grace ended with members still alive (e.g. uninterruptible
// I/O that even SIGKILL cannot cut). Fail loud instead of reporting
// a quiescent tree: the gate is recorded failed either way.
console.error(`run-gates: gate tree not quiescent after 8s (${gate.label}).`)
clearTimeout(escalation)
resolveExit({ exitCode, signalCode })
}
confirmGroupGone()
return
}
if (escalation !== undefined) clearTimeout(escalation)
resolveExit({ exitCode, signalCode })
})
child.stdin.end()
@@ -970,10 +1263,255 @@ export async function runGate(gate: Gate): Promise<GateResult> {
exitCode,
signalCode,
}
result.aborted = aborted
if (spawnError !== undefined) result.error = spawnError
return result
}
/**
* Parse the state, parent, and process-group fields from a `/proc/<pid>/stat`
* line. The comm field may contain spaces and parentheses, so the state starts
* after the last closing parenthesis.
* @param stat - one `/proc/<pid>/stat` line.
* @returns state, parent pid, and process-group pid; undefined when truncated.
*/
function procStatFields(stat: string): { state: string; ppid: number; pgrp: number } | undefined {
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ')
const state = fields[0]
const ppid = fields[1]
const pgrp = fields[2]
if (state === undefined || ppid === undefined || pgrp === undefined) return undefined
return { state, ppid: Number(ppid), pgrp: Number(pgrp) }
}
/**
* Whether one process is still executing. Zombies (state `Z`) do not count:
* they are dead records awaiting reaping, and kill(pid, 0) would report them
* as alive. Linux reads /proc/<pid>/stat to distinguish; other platforms fall
* back to the signal probe.
* @param pid - the process to probe.
*/
function processAlive(pid: number): boolean {
if (process.platform === 'linux') {
try {
const parsed = procStatFields(readFileSync(`/proc/${pid}/stat`, 'utf8'))
return parsed !== undefined && parsed.state !== 'Z'
} catch {
return false
}
}
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
/**
* Whether any member of the child's POSIX process group is still executing.
* Zombie entries (state `Z`) do not count: they are dead records awaiting
* reaping, and the kill(-pid, 0) group probe would report them as alive.
* Linux enumerates /proc to distinguish after a fast-path group probe; other
* POSIX platforms fall back to the probe alone.
* @param pid - the group leader's pid; undefined or non-positive means the
* spawn failed and nothing is alive.
*/
function groupAlive(pid: number | undefined): boolean {
if (pid === undefined || pid <= 0) return false
if (process.platform === 'linux') {
try {
process.kill(-pid, 0)
} catch {
// ESRCH: the group has no entries at all.
return false
}
try {
for (const entry of readdirSync('/proc')) {
if (!/^\d+$/.test(entry)) continue
try {
const parsed = procStatFields(readFileSync(`/proc/${entry}/stat`, 'utf8'))
if (parsed !== undefined && parsed.pgrp === pid && parsed.state !== 'Z') return true
} catch {
// The process exited mid-scan; it is not a live member.
}
}
return false
} catch {
return false
}
}
try {
process.kill(-pid, 0)
return true
} catch {
return false
}
}
/**
* The pids of every transitive descendant of `root`, read from the live
* process table. Linux walks /proc/<pid>/stat parent fields; other platforms
* parse `ps` (POSIX) or the CIM process table (Windows) output. This is one
* snapshot, not the full tree-ownership mechanism: terminate and the sampler
* rely on the 5-second cache to cross an intermediate that exited between
* ticks (reparented on POSIX, table record gone on Windows), so a single
* enumeration reaches only the descendants whose parent chain is still fully
* present in the table.
* @param root - the pid whose descendants are wanted.
* @returns descendant pids in breadth-first order; empty on enumeration failure.
*/
function descendantPids(root: number): number[] {
if (root <= 0) return []
if (process.platform === 'linux') {
const rows: Array<[number, number]> = []
try {
for (const entry of readdirSync('/proc')) {
if (!/^\d+$/.test(entry)) continue
try {
const parsed = procStatFields(readFileSync(`/proc/${entry}/stat`, 'utf8'))
if (parsed !== undefined) rows.push([Number(entry), parsed.ppid])
} catch {
// The process exited mid-scan; skip it.
}
}
} catch {
return []
}
return collectDescendants(root, rows)
}
let ps: { error?: Error; stdout: string }
if (process.platform === 'win32') {
// taskkill /T covers the tree only while the root is alive; once the
// direct child exits (a descendant still holding the stdio write ends
// keeps `close` pending), abort must reach the survivors from a fresh
// enumeration. Windows keeps the exited parent's pid in its descendants'
// parent column, so this walk still finds the whole tree. A hung
// PowerShell (WMI/CIM service trouble) must not stall the abort path
// indefinitely, so the enumeration is bounded.
ps = spawnSync('powershell', processTableArgs('win32'), { encoding: 'utf8', timeout: 10000 })
} else {
ps = spawnSync('ps', processTableArgs('posix'), { encoding: 'utf8' })
}
if (ps.error !== undefined) return []
return collectDescendants(root, parsePidPpidLines(ps.stdout))
}
/**
* The process-table enumeration command for one platform. Windows queries the
* CIM provider through PowerShell (each line `pid ppid`); other platforms use
* `ps -axo pid=,ppid=`.
* @param platform - the target platform.
* @returns the command arguments to enumerate every live process's pid/ppid.
*/
function processTableArgs(platform: 'win32' | 'posix'): string[] {
if (platform === 'win32') {
return ['-NoProfile', '-NonInteractive', '-Command', 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId)" }']
}
return ['-axo', 'pid=,ppid=']
}
/**
* Asynchronous descendant enumeration, so a slow WMI/CIM call (bounded by a
* 10-second timeout) cannot block the event loop: the sampler runs it while
* the gate's output streams and exit handling must keep flowing. Returns the
* same descendant list as {@link descendantPids}; used by the fail-fast
* sampler only, never on the abort path (which needs the synchronous walk to
* capture the tree before any member exits).
* @param root - the pid whose descendants are wanted.
* @param platform - the platform whose table the enumeration reads.
* @returns a promise of descendant pids in breadth-first order; empty on
* enumeration failure.
*/
function descendantPidsAsync(root: number, platform: NodeJS.Platform): { promise: Promise<number[]>; cancel: () => void } {
if (root <= 0 || platform === 'linux') {
// The /proc walk is synchronous inside the async wrapper so the sampler
// keeps the same contract on every platform; /proc reads are fast and
// need no subprocess, and a completed enumeration needs no cancellation.
return { promise: Promise.resolve(descendantPids(root)), cancel: () => {} }
}
const [command, args] = platform === 'win32'
? ['powershell', processTableArgs('win32')]
: ['ps', processTableArgs('posix')]
const child = spawn(command, args, {
stdio: ['ignore', 'pipe', 'ignore'],
timeout: platform === 'win32' ? 10000 : undefined,
})
child.stdout.setEncoding('utf8')
let stdout = ''
let settled = false
let settle!: (value: number[]) => void
const promise = new Promise<number[]>((resolve) => { settle = resolve })
const finish = (value: number[]) => {
if (settled) return
settled = true
// The enumeration completed (or was cancelled): stop the subprocess so
// the gate does not wait on its stdio handles.
child.kill('SIGTERM')
settle(value)
}
child.stdout.on('data', (chunk: string) => { stdout += chunk })
child.on('error', () => { finish([]) })
child.on('close', () => { finish(collectDescendants(root, parsePidPpidLines(stdout))) })
return {
promise,
cancel: () => { finish([]) },
}
}
/** Parse `pid ppid` rows from a process-table dump. Both the POSIX `ps -axo
* pid=,ppid=` output and the Windows PowerShell `Get-CimInstance Win32_Process`
* projection emit one `pid ppid` pair per line.
* @param output - the raw dump text.
* @returns the parsed pid/ppid rows in line order; blank and malformed lines
* are dropped.
*/
export function parsePidPpidLines(output: string): Array<[number, number]> {
const rows: Array<[number, number]> = []
for (const line of output.split('\n')) {
const match = line.trim().match(/^(\d+)\s+(\d+)$/)
if (match !== null) rows.push([Number(match[1]), Number(match[2])])
}
return rows
}
/**
* The taskkill invocations that terminate one Windows gate tree. The direct
* child leads, because a live `taskkill /T` walks its whole subtree in one
* call; each captured descendant follows individually, because when the root
* already exited (a descendant holding the stdio write ends keeps `close`
* pending) `taskkill /T` rooted at the dead pid finds nothing — Windows never
* reparents, so the ppid chain captured at terminate still reaches the whole
* tree, and `/T` lets a surviving intermediate carry its own subtree. A pid
* that exited between capture and termination is as tolerable as ESRCH on
* POSIX: taskkill reports a nonzero status that is deliberately unchecked.
* @param rootPid - the direct child's pid.
* @param descendants - the captured descendant pids.
* @returns one `taskkill` argument list per pid, in termination order.
*/
export function taskkillArgs(rootPid: number, descendants: number[]): string[][] {
return [rootPid, ...descendants].map(pid => ['/PID', String(pid), '/T', '/F'])
}
/** Breadth-first walk of the pid/ppid rows starting at `root`. */
function collectDescendants(root: number, rows: Array<[number, number]>): number[] {
const byParent = new Map<number, number[]>()
for (const [pid, ppid] of rows) {
const children = byParent.get(ppid) ?? []
children.push(pid)
byParent.set(ppid, children)
}
const result: number[] = []
const queue = byParent.get(root) ?? []
for (let index = 0; index < queue.length; index += 1) {
const pid = queue[index]
if (pid === undefined) continue
result.push(pid)
queue.push(...(byParent.get(pid) ?? []))
}
return result
}
/**
* Format every independently observed failure fact for the aggregate summary.
* @param result - unsuccessful gate result.
-1
View File
@@ -155,7 +155,6 @@ describe('global test invariant host', () => {
expect(usesManualInvariantTree('/repo/packages/core/session/tests/invariant.spec.ts')).toBe(true)
expect(usesManualInvariantTree('/repo/packages/core/session/tests/request-invariant-hmr.spec.ts')).toBe(true)
expect(usesManualInvariantTree('C:\\repo\\packages\\runtime-diagnostics\\invariants\\tests\\service.spec.ts')).toBe(true)
expect(usesManualInvariantTree('/repo/packages/examples/agent-spine-demo/tests/agent-core.spec.ts')).toBe(true)
expect(usesManualInvariantTree('/repo/packages/core/session/tests/session.spec.ts')).toBe(false)
})
-1
View File
@@ -48,7 +48,6 @@ export const testInvariantCompanions: Readonly<Record<string, () => Promise<Test
/** Manual-topology suites whose names cannot follow the focused invariant convention. */
const MANUAL_INVARIANT_TEST_EXCEPTIONS = [
'/packages/runtime-diagnostics/invariants/tests/service.spec.ts',
'/packages/examples/agent-spine-demo/tests/agent-core.spec.ts',
] as const
interface InvariantHost {
@@ -113,7 +113,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/client/ui-settings-plugin-inventory': { kind: 'none', reason: 'Browser-side inventory projection; registers nothing model-facing.' },
'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' },
'packages/context/file-reference': { kind: 'indirect', reason: 'The discovery seam and grammar delegate model guidance to the composed provider.' },
'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' },
'packages/e2b/fs-e2b': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
@@ -143,6 +142,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/session/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers nothing model-facing.' },
'packages/session/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers nothing model-facing.' },
'packages/session/session-stats': { kind: 'none', reason: 'The sessionStats unit folds already-logged step boundaries into a client-facing read model and registers nothing model-facing.' },
'packages/session/session-turn-outline': { kind: 'none', reason: 'The turnOutline unit folds already-logged turn boundaries into a client-facing read model and registers nothing model-facing.' },
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers nothing model-facing.' },
'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers nothing model-facing.' },
'packages/settings/settings': { kind: 'indirect', reason: 'The seam stores and resolves user settings; consumer plugins own any model-facing content fed by a value.' },
-1
View File
@@ -18,7 +18,6 @@ export const GROUPS_WITHOUT_SUBSYSTEM_PAGE: Readonly<Record<string, string>> = {
acp: 'Protocol transport entry point; the server package README owns its interoperability contract.',
boot: 'Shared application-bin boot library rather than a runtime subsystem.',
bundle: 'Composition patch carriers whose mounted packages own all runtime contracts.',
examples: 'Non-product demonstration compositions whose mounted packages own all runtime contracts.',
hooks: 'External hook-protocol bridges over existing interception points, not a new Harness service.',
sdk: 'Out-of-process protocol and client packages whose package READMEs own the SDK contracts.',
util: 'Low-level primitives whose business semantics remain with their consuming subsystems.',