mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-11 04:00:38 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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.' },
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user