mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-02 17:40:02 +02:00
- C0/C1: the five never-green evals (skill-e2e-autoplan-chain and
skill-e2e-plan-{ceo,eng,design,devex}-finding-count) failed on harness and
budget, never on skill behavior; delete them, their touchfile/tier ids,
AUTOPLAN_CHAIN_BUDGET and the dedicated eighth periodic slice (--slices 7).
- C2: delete the helper groups whose only paid consumers were those files
(11 modules), trim claude-pty-runner and eng-seeded-coverage to the paid
closure, and delete the free replay tests whose assertions exercised only
that dead code (89 files, 135 orphaned fixtures). Blocks that used dead code
only as input for a live subject keep their assertions: the multiSelect
default moved to plan-review-decisions, runner PTY tests use inline caller
policies, and the timer-safe budget checks moved to eng-finding-retry-budget.
- The eight production-touching files stay except ceo-current-decision-record
(its template read only feeds the retired counter).
- CARVE_GUARDS.autoplan is behavioral 'none'; TODOS records the lost chain
and per-finding cadence coverage with their re-entry tests.
515 lines
19 KiB
TypeScript
515 lines
19 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
||
import * as fs from 'node:fs';
|
||
import * as os from 'node:os';
|
||
import * as path from 'node:path';
|
||
import { pathToFileURL } from 'node:url';
|
||
import {
|
||
capturePlanCountQuestion,
|
||
planCountQuestionInput,
|
||
} from './helpers/claude-pty-runner';
|
||
import type { NativePlanQuestionCall } from './helpers/plan-count-transcript';
|
||
import {
|
||
nextCeoModeNavigation,
|
||
nextCeoPostureContinuation,
|
||
hasNativePostAnswerCeoPosture,
|
||
} from './helpers/ceo-mode-option';
|
||
const designOutsideQuestions = [
|
||
{
|
||
"question": "D3 (Step 0D) — I've rated this plan 5/10 on design completeness. The three biggest gaps are: (1) the 5 identified implementation gaps describe the problem but not the solution, (2) no explicit state coverage table, (3) no user journey emotional arc. I'll skip mockups and review all 7 dimensions as you requested. Any specific areas to prioritize, or cover all 7 equally? <gstack-qid:plan-design-focus>",
|
||
"header": "Focus areas",
|
||
"multiSelect": false,
|
||
"options": [
|
||
{
|
||
"label": "Cover all 7 equally (Recommended)",
|
||
"description": "Standard review: all 7 design dimensions get full treatment. Takes longer but produces a complete plan."
|
||
},
|
||
{
|
||
"label": "Focus on the 5 identified gaps first",
|
||
"description": "Prioritize Pass 5 (Design System Alignment) to close the gap descriptions into actionable specs, then cover remaining passes more quickly."
|
||
},
|
||
{
|
||
"label": "Prioritize accessibility and states",
|
||
"description": "Focus on Pass 2 (Interaction States) and Pass 6 (Responsive/A11y), since the form has sensitive UX requirements (ARIA, contrast, keyboard)."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"question": "D4 — Want outside design voices before the detailed review? Codex evaluates against OpenAI's design hard rules + litmus checks; a Claude subagent does an independent completeness review. (Requires Codex CLI to be installed.) <gstack-qid:outside-voices-design>",
|
||
"header": "Outside voices",
|
||
"multiSelect": false,
|
||
"options": [
|
||
{
|
||
"label": "Yes, run outside design voices",
|
||
"description": "Launches Codex design critique + Claude subagent completeness review in parallel before the 7 passes. Adds 1–2 minutes."
|
||
},
|
||
{
|
||
"label": "No, proceed without (Recommended)",
|
||
"description": "Skip outside voices and go straight to the 7 review passes. Faster; sufficient for most plans."
|
||
}
|
||
]
|
||
}
|
||
];
|
||
|
||
const questions = [
|
||
{
|
||
header: 'Routing rules',
|
||
question: 'Add skill routing rules? <gstack-qid:routing-injection>',
|
||
options: [{ label: 'Enable' }, { label: 'Skip' }],
|
||
},
|
||
{
|
||
header: 'Cross-project',
|
||
question: 'Enable cross-project learnings?',
|
||
options: [{ label: 'Enable' }, { label: 'Skip' }],
|
||
},
|
||
];
|
||
function menu(index: number) {
|
||
return (
|
||
'← ☐ Routing rules ☐ Cross-project ✔ Submit →\n│ ' +
|
||
questions[index]!.question +
|
||
'\n❯1.Enable\n2.Skip\n3.Type something.\n4.Chat about this\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel\n'
|
||
);
|
||
}
|
||
const pending = (): NativePlanQuestionCall => ({
|
||
sessionId: 'native',
|
||
toolUseId: 'packet',
|
||
answered: false,
|
||
questions,
|
||
});
|
||
|
||
describe('native AUQ accepts one action per displayed question', () => {
|
||
test('mode permission history releases a fresh identical viewport grant', () => {
|
||
const seen = new Set<string>();
|
||
const permission = 'Do you want to create PLAN.md?\n❯1.Yes\n2.Yes, and switch to accept edits (auto-approve file edits and common file commands) for this session (shift+tab)\n3.No\nEsc to cancel · Tab to amend\n';
|
||
expect(nextCeoModeNavigation(permission, 'HOLD SCOPE', seen, undefined, '')).toEqual({
|
||
kind: 'permission', input: '1\r',
|
||
});
|
||
expect(nextCeoModeNavigation(permission, 'HOLD SCOPE', seen, undefined, '').kind).toBe('wait');
|
||
const completed = '⎿ Wrote1line\n';
|
||
expect(nextCeoModeNavigation(permission, 'HOLD SCOPE', seen, undefined, completed)).toEqual({
|
||
kind: 'permission', input: '1\r',
|
||
});
|
||
expect(nextCeoModeNavigation(permission, 'HOLD SCOPE', seen, undefined, completed).kind).toBe('wait');
|
||
});
|
||
test('each native tab has separate UI identity, while retaining one call identity', () => {
|
||
const seen = new Set<string>();
|
||
const first = capturePlanCountQuestion(menu(0), seen, 0, true, pending())!;
|
||
const second = capturePlanCountQuestion(menu(1), seen, 1, true, pending())!;
|
||
expect(first.nativeQuestionIndex).toBe(0);
|
||
expect(second.nativeQuestionIndex).toBe(1);
|
||
expect(first.signature).not.toBe(second.signature);
|
||
expect(first.nativeCall!.toolUseId).toBe(second.nativeCall!.toolUseId);
|
||
expect(planCountQuestionInput(menu(0), first, 2)).toBe('2');
|
||
expect(planCountQuestionInput(menu(1), second, 1)).toBe('1');
|
||
expect(
|
||
capturePlanCountQuestion(menu(0), seen, 2, true, pending()),
|
||
).toBeNull();
|
||
expect(
|
||
capturePlanCountQuestion(menu(1), seen, 3, true, pending()),
|
||
).toBeNull();
|
||
});
|
||
test('a delayed native record binds an already answered UI tab without another input', () => {
|
||
const seen = new Set<string>();
|
||
const ui = capturePlanCountQuestion(menu(0), seen, 0, true)!;
|
||
expect(ui.nativeCall).toBeUndefined();
|
||
expect(planCountQuestionInput(menu(0), ui, 1)).toBe('1');
|
||
expect(
|
||
capturePlanCountQuestion(menu(0), seen, 1, true, pending()),
|
||
).toBeNull();
|
||
expect(
|
||
capturePlanCountQuestion(menu(1), seen, 2, true, pending())!
|
||
.nativeQuestionIndex,
|
||
).toBe(1);
|
||
});
|
||
test('shared headers or options cannot borrow an unrelated native question', () => {
|
||
const foreign = menu(0).replace(
|
||
questions[0]!.question,
|
||
'Which release should we inspect?',
|
||
);
|
||
const fp = capturePlanCountQuestion(
|
||
foreign,
|
||
new Set(),
|
||
0,
|
||
true,
|
||
pending(),
|
||
)!;
|
||
expect(fp.nativeCall).toBeUndefined();
|
||
const prose = 'Which release?\n❯1.First\n2.Second\n';
|
||
const plain = capturePlanCountQuestion(prose, new Set(), 0, true)!;
|
||
expect(planCountQuestionInput(prose, plain, 2)).toBe('2\r');
|
||
});
|
||
test('known multi-select toggles once before its separate Submit control', () => {
|
||
const fp = capturePlanCountQuestion(
|
||
menu(0),
|
||
new Set(),
|
||
0,
|
||
true,
|
||
pending(),
|
||
)!;
|
||
fp.nativeCall = {
|
||
...fp.nativeCall!,
|
||
questions: fp.nativeCall!.questions.map((q) => ({
|
||
...q,
|
||
multiSelect: true,
|
||
})),
|
||
};
|
||
expect(planCountQuestionInput(menu(0), fp, 1)).toBe('1');
|
||
});
|
||
test('an incomplete checkbox panel without its submit control retains the legacy fallback', () => {
|
||
for (const mark of [' ', '✓']) {
|
||
const screen = menu(0)
|
||
.replace('1.Enable', `1.[${mark}] Enable`)
|
||
.replace('2.Skip', '2.[ ] Skip');
|
||
const fp = capturePlanCountQuestion(screen, new Set(), 0, true)!;
|
||
expect(fp.nativeCall).toBeUndefined();
|
||
expect(planCountQuestionInput(screen, fp, 1)).toBe('1\r');
|
||
}
|
||
});
|
||
test('CEO mode and its next native question accept one digit; permission stays separate', () => {
|
||
const mode = {
|
||
header: 'Review mode',
|
||
question: 'Which review posture? <gstack-qid:plan-ceo-mode>',
|
||
options: [{ label: 'HOLD SCOPE' }, { label: 'SCOPE EXPANSION' }],
|
||
};
|
||
const call = { ...pending(), questions: [mode] };
|
||
const screen =
|
||
'☐ Review mode\n' +
|
||
mode.question +
|
||
'\n❯1.HOLD SCOPE\n2.SCOPE EXPANSION\nEnter to select · ↑/↓ to navigate · Esc to cancel\n';
|
||
const seen = new Set<string>();
|
||
const action = nextCeoModeNavigation(screen, 'SCOPE EXPANSION', seen, call);
|
||
expect(action.kind).toBe('mode');
|
||
if (action.kind !== 'mode') throw Error('Expected native mode');
|
||
expect(planCountQuestionInput(screen, action.question, action.index)).toBe(
|
||
'2',
|
||
);
|
||
expect(
|
||
nextCeoModeNavigation(screen, 'SCOPE EXPANSION', seen, call).kind,
|
||
).toBe('wait');
|
||
const selected = {
|
||
...call,
|
||
answered: true,
|
||
answeredAt: '2026-09-08T20:00:01Z',
|
||
answers: { [mode.question]: 'SCOPE EXPANSION' },
|
||
};
|
||
const next = {
|
||
...pending(),
|
||
toolUseId: 'next',
|
||
questions: [
|
||
{
|
||
header: 'Expansion',
|
||
question: 'Which idea would create 10x delight?',
|
||
options: [{ label: 'First idea' }, { label: 'Second idea' }],
|
||
},
|
||
],
|
||
};
|
||
const transcript = {
|
||
status: 'ready' as const,
|
||
calls: [selected, next],
|
||
assistantMessages: [],
|
||
};
|
||
const nextScreen =
|
||
'☐ Expansion\n' +
|
||
next.questions[0]!.question +
|
||
'\n❯1.First idea\n2.Second idea\nEnter to select · ↑/↓ to navigate · Esc to cancel\n';
|
||
expect(
|
||
hasNativePostAnswerCeoPosture(
|
||
transcript,
|
||
'SCOPE EXPANSION',
|
||
/delight/,
|
||
Date.parse('2026-09-08T20:00:00Z'),
|
||
),
|
||
).toBe(false);
|
||
expect(
|
||
nextCeoPostureContinuation(
|
||
nextScreen,
|
||
transcript,
|
||
'SCOPE EXPANSION',
|
||
Date.parse('2026-09-08T20:00:00Z'),
|
||
new Set(),
|
||
false,
|
||
),
|
||
).toBe('question');
|
||
const fp = capturePlanCountQuestion(nextScreen, new Set(), 0, false, next)!;
|
||
expect(planCountQuestionInput(nextScreen, fp, 1)).toBe('1');
|
||
const permission =
|
||
'Do you want to create PLAN.md?\n❯1.Yes\n2.Yes, and switch to accept edits (auto-approve file edits and common file commands) for this session (shift+tab)\n3.No\nEsc to cancel · Tab to amend\n';
|
||
const grant = nextCeoModeNavigation(
|
||
permission,
|
||
'SCOPE EXPANSION',
|
||
new Set(),
|
||
next,
|
||
);
|
||
expect(grant).toEqual({ kind: 'permission', input: '1\r' });
|
||
});
|
||
test.skipIf(process.platform === 'win32')(
|
||
'real PTY completes two and four tabs once without skipping, overshooting or queuing Enter',
|
||
async () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'native-auq-input-'));
|
||
const fake = path.join(dir, 'fake-claude');
|
||
const worker = path.join(dir, 'worker.ts');
|
||
const results = path.join(dir, 'results.json');
|
||
const cases = [
|
||
{ name: 'two-tabs', count: 2, late: false, permission: true },
|
||
{
|
||
name: 'four-tabs',
|
||
count: 4,
|
||
late: false,
|
||
permission: false,
|
||
long: true,
|
||
},
|
||
{ name: 'late-packet', count: 2, late: true, permission: false },
|
||
{ name: 'design-outside-tab', count: 2, late: false, permission: false, designQuestions: designOutsideQuestions },
|
||
{ name: 'design-outside-late', count: 2, late: true, permission: false, designQuestions: designOutsideQuestions },
|
||
].map((item) => ({
|
||
...item,
|
||
record: path.join(dir, item.name + '.jsonl'),
|
||
}));
|
||
fs.writeFileSync(
|
||
fake,
|
||
`#!${process.execPath}\n` +
|
||
String.raw`
|
||
import * as fs from 'node:fs';
|
||
import * as path from 'node:path';
|
||
const item = JSON.parse(process.env.NATIVE_INPUT_CASE);
|
||
const record = (event) =>
|
||
fs.appendFileSync(item.record, JSON.stringify(event) + '\n');
|
||
const sessionId = 'native-input-' + process.pid;
|
||
const project = path.join(process.env.CLAUDE_CONFIG_DIR, 'projects', sessionId);
|
||
fs.mkdirSync(project, { recursive: true });
|
||
const native = (role, content, extra = {}) =>
|
||
fs.appendFileSync(
|
||
path.join(project, sessionId + '.jsonl'),
|
||
JSON.stringify({
|
||
cwd: process.cwd(),
|
||
sessionId,
|
||
isSidechain: false,
|
||
message: { role, content },
|
||
...extra,
|
||
}) + '\n',
|
||
);
|
||
native('assistant', [{ type: 'text', text: 'Fixture started.' }]);
|
||
record({ type: 'startup', pid: process.pid, cwd: process.cwd() });
|
||
const questions = item.designQuestions ?? Array.from({ length: item.count }, (_, i) => ({
|
||
header: 'Decision ' + i,
|
||
question:
|
||
(item.long && i === 0
|
||
? 'Additional concrete context for this decision. '.repeat(140)
|
||
: '') +
|
||
'Which implementation fixes gap ' +
|
||
i +
|
||
'? <gstack-qid:input-gap-' +
|
||
i +
|
||
'>',
|
||
options: [{ label: 'First remedy' }, { label: 'Second remedy' }],
|
||
}));
|
||
const ask = () =>
|
||
native('assistant', [
|
||
{
|
||
type: 'tool_use',
|
||
id: 'packet',
|
||
name: 'AskUserQuestion',
|
||
input: { questions },
|
||
},
|
||
]);
|
||
let started = false,
|
||
permission = item.permission,
|
||
index = 0,
|
||
answers = {},
|
||
done = false;
|
||
const render = () => {
|
||
let screen = '';
|
||
if (permission)
|
||
screen =
|
||
'Do you want to create PLAN.md?\n❯1.Yes\n2.Yes, and switch to accept edits (auto-approve file edits and common file commands) for this session (shift+tab)\n3.No\nEsc to cancel · Tab to amend\n';
|
||
else if (index < questions.length)
|
||
screen =
|
||
'← ' +
|
||
questions
|
||
.map((q) => (answers[q.question] ? '☒' : '☐') + ' ' + q.header)
|
||
.join(' ') +
|
||
' ✔ Submit →\n│ ' +
|
||
questions[index].question +
|
||
'\n' + questions[index].options.map((option, i) => (i === 0 ? '❯' : '') + (i + 1) + '.' + option.label).join('\n') +
|
||
'\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel\n';
|
||
else if (index === questions.length)
|
||
screen =
|
||
'← ' +
|
||
questions
|
||
.map((q) => (answers[q.question] ? '☒' : '☐') + ' ' + q.header)
|
||
.join(' ') +
|
||
' ✔ Submit →\nReview your answers\n' +
|
||
(Object.keys(answers).length < questions.length
|
||
? '⚠ You have not answered all questions\n'
|
||
: '') +
|
||
'Ready to submit your answers?\n❯1.Submit answers\n2.Cancel\n';
|
||
record({
|
||
type: 'screen',
|
||
index,
|
||
answers: Object.keys(answers),
|
||
blank: !screen,
|
||
});
|
||
process.stdout.write('\x1b[2J\x1b[H' + screen.replace(/\n/g, '\r\n'));
|
||
};
|
||
process.stdin.setRawMode?.(true);
|
||
process.stdin.on('data', (data) => {
|
||
const input = data.toString();
|
||
record({ type: 'input', input });
|
||
if (!started) {
|
||
started = true;
|
||
if (!item.late) ask();
|
||
render();
|
||
return;
|
||
}
|
||
if (done) {
|
||
record({ type: 'unexpected-after-completion', input });
|
||
return;
|
||
}
|
||
if (permission) {
|
||
if (input !== '1\r') throw Error('permission protocol changed');
|
||
permission = false;
|
||
render();
|
||
return;
|
||
}
|
||
const rendered = index;
|
||
if (input === '\x1b[Z') {
|
||
index = Math.max(0, index - 1);
|
||
render();
|
||
return;
|
||
}
|
||
if (rendered === questions.length) {
|
||
if (input !== '\r') throw Error('submit protocol changed');
|
||
if (item.late) ask();
|
||
native(
|
||
'user',
|
||
[{ type: 'tool_result', tool_use_id: 'packet', content: 'Answered.' }],
|
||
{ toolUseResult: { answers } },
|
||
);
|
||
record({ type: 'submitted', answers });
|
||
if (item.designQuestions && Object.values(answers)[1] === item.designQuestions[1].options[0].label) record({ type: 'outside-dispatched' });
|
||
done = true;
|
||
process.stdout.write('\x1b[2J\x1b[HGSTACK REVIEW REPORT\r\n');
|
||
return;
|
||
}
|
||
// CLI2.1.257's numeric shortcut and select:accept both call onChange,
|
||
// whose set-answer reducer increments the current question index. Events
|
||
// in one chunk retain the rendered question closure until the next paint.
|
||
for (const key of input) {
|
||
if (rendered >= questions.length) break;
|
||
if (/[1-2]/.test(key) || key === '\r') {
|
||
answers[questions[rendered].question] =
|
||
questions[rendered].options[key === '2' ? 1 : 0].label;
|
||
index++;
|
||
}
|
||
}
|
||
render();
|
||
});
|
||
process.on('SIGINT', () => process.exit(0));
|
||
process.stdin.resume();
|
||
process.stdout.write('PTY_READY:' + item.record + '\x1b[2J\x1b[H');
|
||
`,
|
||
);
|
||
fs.chmodSync(fake, 0o755);
|
||
fs.writeFileSync(
|
||
worker,
|
||
`import {runPlanSkillCounting} from ${JSON.stringify(pathToFileURL(path.resolve(import.meta.dir, 'helpers/claude-pty-runner.ts')).href)};
|
||
// Caller policy for the fixture's outside-voices tab; the runner's tab binding is the subject.
|
||
const pickOutsideVoices = (_routing, active) => {
|
||
const q = active.nativeCall?.questions[active.nativeQuestionIndex ?? 0];
|
||
if (!/outside(?: design)? voices/i.test(q ? q.header : active.promptSnippet)) return null;
|
||
const index = (q ? q.options : active.options).findIndex(option => /^No,?\\s+proceed without/i.test(option.label.trim()));
|
||
return index < 0 ? null : index + 1;
|
||
};
|
||
const cases=${JSON.stringify(cases)};
|
||
const results = await Promise.all(cases.map(async item => ({
|
||
name: item.name,
|
||
observation: await runPlanSkillCounting({
|
||
skillName: 'plan-eng-review',
|
||
slashCommand: '/plan-eng-review',
|
||
followUpPrompt: 'Review only this fixture.',
|
||
startupReadyMarker: 'PTY_READY:' + item.record,
|
||
isLastStep0AUQ: () => false,
|
||
isReviewAUQ: () => true,
|
||
firstAUQPick: item.designQuestions ? undefined : () => 2,
|
||
pickAUQ: item.designQuestions ? pickOutsideVoices : undefined,
|
||
reviewCountCeiling: 8,
|
||
timeoutMs: 35000,
|
||
env: { NATIVE_INPUT_CASE: JSON.stringify(item) },
|
||
}),
|
||
})));
|
||
await Bun.write(${JSON.stringify(results)},JSON.stringify(results));`,
|
||
);
|
||
const child = Bun.spawn([process.execPath, worker], {
|
||
env: {
|
||
...process.env,
|
||
BROWSE_TERMINAL_BINARY: fake,
|
||
EVALS_HERMETIC: '1',
|
||
},
|
||
stdout: 'pipe',
|
||
stderr: 'pipe',
|
||
});
|
||
const timer = setTimeout(() => child.kill('SIGKILL'), 35000);
|
||
try {
|
||
const [code, out, err] = await Promise.all([
|
||
child.exited,
|
||
new Response(child.stdout).text(),
|
||
new Response(child.stderr).text(),
|
||
]);
|
||
expect(code, out + err).toBe(0);
|
||
const report = JSON.parse(fs.readFileSync(results, 'utf8'));
|
||
for (const item of cases) {
|
||
const row = report.find((r) => r.name === item.name);
|
||
expect(row.observation.outcome, JSON.stringify(row.observation)).toBe(
|
||
'completion_summary',
|
||
);
|
||
expect(row.observation.reviewCount).toBe(1);
|
||
expect(row.observation.transcript.calls).toHaveLength(1);
|
||
expect(
|
||
row.observation.transcript.calls[0].unansweredQuestionIndices,
|
||
).toEqual([]);
|
||
const events = fs
|
||
.readFileSync(item.record, 'utf8')
|
||
.trim()
|
||
.split('\n')
|
||
.map((line) => JSON.parse(line));
|
||
expect(
|
||
events.filter((e) => e.type === 'input').map((e) => e.input),
|
||
).toEqual([
|
||
'/plan-eng-review\r',
|
||
...(item.permission ? ['1\r'] : []),
|
||
...(item.designQuestions ? ['1', '2'] : ['2', ...Array(item.count - 1).fill('1')]),
|
||
'\r',
|
||
]);
|
||
expect(events.filter((e) => e.type === 'screen' && e.blank)).toEqual(
|
||
[],
|
||
);
|
||
expect(
|
||
events.filter((e) => e.type === 'unexpected-after-completion'),
|
||
).toEqual([]);
|
||
const submitted = events.filter((e) => e.type === 'submitted');
|
||
expect(submitted).toHaveLength(1);
|
||
expect(Object.values(submitted[0].answers)).toEqual(item.designQuestions
|
||
? [item.designQuestions[0].options[0].label, item.designQuestions[1].options[1].label]
|
||
: ['Second remedy', ...Array(item.count - 1).fill('First remedy')]);
|
||
expect(events.filter(e => e.type === 'outside-dispatched')).toEqual([]);
|
||
expect(() => process.kill(events[0].pid, 0)).toThrow();
|
||
expect(fs.existsSync(events[0].cwd)).toBe(false);
|
||
}
|
||
} finally {
|
||
clearTimeout(timer);
|
||
child.kill('SIGKILL');
|
||
for (const item of cases) {
|
||
if (!fs.existsSync(item.record)) continue;
|
||
const pid = JSON.parse(
|
||
fs.readFileSync(item.record, 'utf8').split('\n')[0]!,
|
||
).pid;
|
||
try {
|
||
process.kill(pid, 'SIGKILL');
|
||
} catch {}
|
||
}
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
},
|
||
40000,
|
||
);
|
||
});
|