kushi-agents 5.7.3 → 5.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "kushi-agents",
|
|
3
|
-
"version": "5.7.
|
|
4
|
-
"description": "Install Kushi — multi-source project evidence agent with Comprehensive Structured Capture (CSC) into weekly-only files across Email, Teams, OneNote, Loop, SharePoint, Meetings, CRM, ADO. Meetings retain a sibling verbatim/ audit folder. WorkIQ-only for M365 sources (Graph / m365_* FORBIDDEN as fallbacks; user-paste is first-class). Host-agnostic.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"kushi-agents": "./bin/cli.mjs"
|
|
8
|
-
},
|
|
9
|
-
"files": [
|
|
10
|
-
"bin/",
|
|
11
|
-
"src/",
|
|
12
|
-
"plugin/",
|
|
13
|
-
".github/copilot-instructions.kushi.md"
|
|
14
|
-
],
|
|
15
|
-
"engines": {
|
|
16
|
-
"node": ">=18.0.0"
|
|
17
|
-
},
|
|
18
|
-
"dependencies": {
|
|
19
|
-
"@azure/identity": "^4.5.0",
|
|
20
|
-
"@mozilla/readability": "^0.6.0",
|
|
21
|
-
"jsdom": "^29.1.1",
|
|
22
|
-
"jsonc-parser": "^3.3.1",
|
|
23
|
-
"yaml": "^2.6.0"
|
|
24
|
-
},
|
|
25
|
-
"keywords": [
|
|
26
|
-
"vscode",
|
|
27
|
-
"copilot",
|
|
28
|
-
"agents",
|
|
29
|
-
"kushi",
|
|
30
|
-
"project-evidence",
|
|
31
|
-
"workiq",
|
|
32
|
-
"m365",
|
|
33
|
-
"ai",
|
|
34
|
-
"cli"
|
|
35
|
-
],
|
|
36
|
-
"repository": {
|
|
37
|
-
"type": "git",
|
|
38
|
-
"url": "git+https://github.com/gim-home/kushi.git"
|
|
39
|
-
},
|
|
40
|
-
"homepage": "https://gim-home.github.io/kushi/",
|
|
41
|
-
"bugs": {
|
|
42
|
-
"url": "https://github.com/gim-home/kushi/issues"
|
|
43
|
-
},
|
|
44
|
-
"license": "MIT",
|
|
45
|
-
"scripts": {
|
|
46
|
-
"test": "node --test src/check-workiq.test.mjs src/seed-config.test.mjs src/sanitize-workiq-input.test.mjs src/detect-vertex-repo.test.mjs src/vertex-validate.test.mjs src/emit-vertex.e2e.test.mjs src/config-root-resolve.test.mjs src/forbidden-workiq-phrasings.test.mjs src/multi-host-install.test.mjs src/eval-aggregator.test.mjs src/eval-runner.test.mjs src/skill-creator.test.mjs src/skill-checker.test.mjs src/hooks-dispatcher.test.mjs src/parallel-refresh.test.mjs src/otel-emit.test.mjs src/teach.test.mjs src/schema-evolve.test.mjs src/global-wiki.test.mjs src/promote.test.mjs src/doctor.test.mjs src/setup-wizard.test.mjs src/cli-no-args.test.mjs src/cli-no-args-tty.test.mjs src/per-user-files.test.mjs src/layout-portable.test.mjs src/profile-coverage.test.mjs src/get-kushi-config.test.mjs src/seed-config-derived.test.mjs plugin/runners/test/unit/*.test.mjs",
|
|
47
|
-
"test:runners": "node --test plugin/runners/test/unit/*.test.mjs",
|
|
48
|
-
"test:runners:integration": "node --test plugin/runners/test/integration/*.test.mjs",
|
|
49
|
-
"test:integration:bootstrap": "node src/bootstrap-dryrun.integration.test.mjs",
|
|
50
|
-
"smoke": "node scripts/smoke.mjs",
|
|
51
|
-
"eval": "pwsh plugin/skills/eval/run-evals.ps1 -Skill",
|
|
52
|
-
"eval:all": "pwsh plugin/skills/eval/run-evals.ps1 -All",
|
|
53
|
-
"eval:canary": "pwsh plugin/skills/eval/run-evals.ps1 -Canary",
|
|
54
|
-
"eval:baseline": "pwsh plugin/skills/eval/run-evals.ps1 -All -UpdateBaseline",
|
|
55
|
-
"prepublishOnly": "npm test && npm run smoke"
|
|
56
|
-
},
|
|
57
|
-
"publishConfig": {
|
|
58
|
-
"access": "public"
|
|
59
|
-
}
|
|
60
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "kushi-agents",
|
|
3
|
+
"version": "5.7.5",
|
|
4
|
+
"description": "Install Kushi — multi-source project evidence agent with Comprehensive Structured Capture (CSC) into weekly-only files across Email, Teams, OneNote, Loop, SharePoint, Meetings, CRM, ADO. Meetings retain a sibling verbatim/ audit folder. WorkIQ-only for M365 sources (Graph / m365_* FORBIDDEN as fallbacks; user-paste is first-class). Host-agnostic.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"kushi-agents": "./bin/cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"plugin/",
|
|
13
|
+
".github/copilot-instructions.kushi.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18.0.0"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@azure/identity": "^4.5.0",
|
|
20
|
+
"@mozilla/readability": "^0.6.0",
|
|
21
|
+
"jsdom": "^29.1.1",
|
|
22
|
+
"jsonc-parser": "^3.3.1",
|
|
23
|
+
"yaml": "^2.6.0"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"vscode",
|
|
27
|
+
"copilot",
|
|
28
|
+
"agents",
|
|
29
|
+
"kushi",
|
|
30
|
+
"project-evidence",
|
|
31
|
+
"workiq",
|
|
32
|
+
"m365",
|
|
33
|
+
"ai",
|
|
34
|
+
"cli"
|
|
35
|
+
],
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/gim-home/kushi.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://gim-home.github.io/kushi/",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/gim-home/kushi/issues"
|
|
43
|
+
},
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "node --test src/check-workiq.test.mjs src/seed-config.test.mjs src/sanitize-workiq-input.test.mjs src/detect-vertex-repo.test.mjs src/vertex-validate.test.mjs src/emit-vertex.e2e.test.mjs src/config-root-resolve.test.mjs src/forbidden-workiq-phrasings.test.mjs src/multi-host-install.test.mjs src/eval-aggregator.test.mjs src/eval-runner.test.mjs src/skill-creator.test.mjs src/skill-checker.test.mjs src/hooks-dispatcher.test.mjs src/parallel-refresh.test.mjs src/otel-emit.test.mjs src/teach.test.mjs src/schema-evolve.test.mjs src/global-wiki.test.mjs src/promote.test.mjs src/doctor.test.mjs src/setup-wizard.test.mjs src/cli-no-args.test.mjs src/cli-no-args-tty.test.mjs src/per-user-files.test.mjs src/layout-portable.test.mjs src/profile-coverage.test.mjs src/get-kushi-config.test.mjs src/seed-config-derived.test.mjs plugin/runners/test/unit/*.test.mjs",
|
|
47
|
+
"test:runners": "node --test plugin/runners/test/unit/*.test.mjs",
|
|
48
|
+
"test:runners:integration": "node --test plugin/runners/test/integration/*.test.mjs",
|
|
49
|
+
"test:integration:bootstrap": "node src/bootstrap-dryrun.integration.test.mjs",
|
|
50
|
+
"smoke": "node scripts/smoke.mjs",
|
|
51
|
+
"eval": "pwsh plugin/skills/eval/run-evals.ps1 -Skill",
|
|
52
|
+
"eval:all": "pwsh plugin/skills/eval/run-evals.ps1 -All",
|
|
53
|
+
"eval:canary": "pwsh plugin/skills/eval/run-evals.ps1 -Canary",
|
|
54
|
+
"eval:baseline": "pwsh plugin/skills/eval/run-evals.ps1 -All -UpdateBaseline",
|
|
55
|
+
"prepublishOnly": "npm test && npm run smoke"
|
|
56
|
+
},
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -4,6 +4,66 @@ Newest on top. Format defined in [`README.md`](./README.md). Use this file when
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
### 2026-05-29 — Per-step stderr alone is not enough; long single steps need heartbeat ticks
|
|
8
|
+
|
|
9
|
+
**Symptom**: After v5.7.4 added per-source `[discover] → email ...` stderr lines, a user re-ran discover from VS Code Copilot Chat. Each WorkIQ call still took 30–90s, and the host **still** killed the runner because between sources there was no output for 30+ seconds. The single `[discover] → email ...` line at the start of each source wasn't enough — the host watchdog measures *idle output* (no bytes on either stream for N seconds), not just *no progress at all*.
|
|
10
|
+
|
|
11
|
+
**Root cause**: A single line of stderr at the start of a long-running operation doesn't satisfy host idle-output watchdogs. They typically measure the gap between successive output bytes. With 30s+ WorkIQ calls and no output in between, the gap exceeds the watchdog threshold (typically 30–60s in Copilot Chat).
|
|
12
|
+
|
|
13
|
+
**Fix shipped (v5.7.5, 2026-05-29)**:
|
|
14
|
+
|
|
15
|
+
1. **`workiq.mjs runProcess()` accepts an `onHeartbeat` callback + `heartbeatMs` interval** (default 10s). While the child workiq process runs, the callback fires every `heartbeatMs` with `{ elapsedMs, stdoutBytes, stderrBytes }`.
|
|
16
|
+
2. **`discover.mjs` passes a heartbeat that emits intelligent ticks**:
|
|
17
|
+
```
|
|
18
|
+
[discover] [1/7] email: querying workiq (180000ms budget)...
|
|
19
|
+
[discover] email: ...waiting for first byte (10s/180s, no output yet)
|
|
20
|
+
[discover] email: ...still working (20s/180s, 1247 bytes received)
|
|
21
|
+
[discover] email: ✓ 28412ms, 4892 bytes, 4 row(s), 4 block(s)
|
|
22
|
+
```
|
|
23
|
+
Tells the user (and host watchdog): which source, how long it's been running, how much budget is left, whether anything has come back yet.
|
|
24
|
+
3. **Distinguish empty result from timeout**: previously a fast `exit 0` with empty stdout *and* a 90s SIGKILL both produced `WORKIQ_TIMEOUT`. v5.7.5 reports the empty case as `workiq-empty-response` (with byte count in the stderr log) so users can immediately tell "WorkIQ found nothing" vs "WorkIQ is hanging".
|
|
25
|
+
4. **Default timeout bumped 90s → 180s** since real kushi-style queries (deeper than `who am i`) can legitimately take 30–90s each.
|
|
26
|
+
5. **New regression tests** (2): empty response is reported distinctly from timeout; heartbeat ticks fire on slow workiq calls.
|
|
27
|
+
|
|
28
|
+
**Lesson — long-running steps need active liveness signals**:
|
|
29
|
+
- A startup line ("starting X...") is necessary but not sufficient.
|
|
30
|
+
- Anything that can take >10s should emit a tick at least every 10s with **useful information**: elapsed/budget, bytes received so far, what the runner is waiting on.
|
|
31
|
+
- Heartbeats should be *parameterized* (env var or option) so tests can run them at sub-second intervals without slowing the suite.
|
|
32
|
+
- "Intelligent" beats "noisy": `...waiting for first byte (10s/180s)` is more useful than `...still working (10s)` because it tells you whether progress has been made.
|
|
33
|
+
|
|
34
|
+
**Files changed**: `plugin/runners/lib/workiq.mjs` (added heartbeat plumbing), `plugin/runners/discover.mjs` (heartbeat + telemetry + empty-vs-timeout split + default 180s timeout), `plugin/runners/test/unit/discover.test.mjs` (+2 tests).
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
### 2026-05-29 — Silent runners get killed by host watchdogs (GitHub Copilot Chat)
|
|
39
|
+
|
|
40
|
+
**Symptom**: User invoked `discover.mjs` from inside GitHub Copilot Chat in VS Code. The runner appeared to hang for 60+ seconds, then the host announced "discover did not return completion output (it stayed silent/hung)" and gave up. Direct PowerShell invocation outside the host showed the same long pause but eventually returned.
|
|
41
|
+
|
|
42
|
+
**Root cause**: `discover.mjs` runs 7 sequential WorkIQ calls (email, teams, meetings, onenote, sharepoint, crm, ado). Each call is 5–60s. The runner emitted **zero output** during the loop and only printed the JSON envelope at the end, so total silent time was up to 7 × 60s = 7 minutes. GitHub Copilot Chat's process watchdog interpreted the silence as a hung process and SIGKILLed it well before the runner finished.
|
|
43
|
+
|
|
44
|
+
**Fix shipped (v5.7.4, 2026-05-29)**:
|
|
45
|
+
|
|
46
|
+
1. **`discover.mjs` streams per-source progress to stderr** before and after each WorkIQ call:
|
|
47
|
+
```
|
|
48
|
+
[discover] workiq: <path>
|
|
49
|
+
[discover] sources: email, teams, meetings, ... (timeout 90000ms each)
|
|
50
|
+
[discover] → email ...
|
|
51
|
+
[discover] email ok in 12340ms (3 row(s))
|
|
52
|
+
[discover] → teams ...
|
|
53
|
+
```
|
|
54
|
+
stdout stays reserved for the final JSON envelope (so machine consumers are unaffected).
|
|
55
|
+
2. **Default `--timeout-ms` bumped from 60s to 90s** to absorb WorkIQ cold-start auth latency on the first call.
|
|
56
|
+
3. **New regression test**: spawns discover with a fake workiq shim and asserts `[discover]` lines hit stderr.
|
|
57
|
+
|
|
58
|
+
**Lesson — applies to every long-running runner**: any runner that takes >5s without emitting output is a host-watchdog timebomb. Two-channel discipline:
|
|
59
|
+
|
|
60
|
+
- **stdout** = final structured envelope only (one JSON line, machine-readable).
|
|
61
|
+
- **stderr** = streaming human-readable progress (every step, every long-running call).
|
|
62
|
+
|
|
63
|
+
Adopting this convention now for all `plugin/runners/*.mjs` that loop over remote calls. Future runners should `process.stderr.write('[<runner>] step description\n')` before/after each external operation.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
7
67
|
### 2026-05-29 — `<auto>` placeholder check was blocking fresh installs
|
|
8
68
|
|
|
9
69
|
**Symptom**: After a clean `npx kushi-agents@latest --all-hosts` install, the very first command users tried (`& '.\.kushi\lib\Get-KushiConfig.ps1' -Name 'm365-auth'`) threw a placeholder error citing `<auto>`. But `<auto>` is the explicit "Kushi will resolve this at runtime from identity / OS / WorkIQ" sentinel, by design — not an unfilled placeholder.
|
|
@@ -24,7 +24,7 @@ import { ask as workiqAsk, resolveWorkiqBin } from './lib/workiq.mjs';
|
|
|
24
24
|
const ALL_SOURCES = ['email', 'teams', 'meetings', 'onenote', 'sharepoint', 'crm', 'ado'];
|
|
25
25
|
|
|
26
26
|
function parseArgs(argv) {
|
|
27
|
-
const args = { force: false, dryRun: false, timeoutMs:
|
|
27
|
+
const args = { force: false, dryRun: false, timeoutMs: 180_000, sources: null };
|
|
28
28
|
for (let i = 0; i < argv.length; i++) {
|
|
29
29
|
const a = argv[i];
|
|
30
30
|
if (a === '--project') args.project = argv[++i];
|
|
@@ -32,7 +32,7 @@ function parseArgs(argv) {
|
|
|
32
32
|
else if (a === '--source') (args.sources ??= []).push(argv[++i]);
|
|
33
33
|
else if (a === '--force') args.force = true;
|
|
34
34
|
else if (a === '--dry-run') args.dryRun = true;
|
|
35
|
-
else if (a === '--timeout-ms') args.timeoutMs = Number(argv[++i]) ||
|
|
35
|
+
else if (a === '--timeout-ms') args.timeoutMs = Number(argv[++i]) || 180_000;
|
|
36
36
|
else if (a === '--help' || a === '-h') args.help = true;
|
|
37
37
|
}
|
|
38
38
|
return args;
|
|
@@ -201,17 +201,54 @@ async function main() {
|
|
|
201
201
|
let boundsDirty = false;
|
|
202
202
|
let integDirty = false;
|
|
203
203
|
|
|
204
|
+
// Stream per-source progress to stderr so users (and host watchdogs) see the runner is alive.
|
|
205
|
+
// stdout is reserved for the final JSON envelope. Heartbeat ticks every 10s during long
|
|
206
|
+
// workiq calls keep the runner visibly alive — Copilot Chat / Cursor watchdogs typically
|
|
207
|
+
// kill processes silent on output for 30–60s.
|
|
208
|
+
const log = (msg) => process.stderr.write(`[discover] ${msg}\n`);
|
|
209
|
+
log(`workiq: ${workiqBin}`);
|
|
210
|
+
log(`sources: ${sourcesToRun.join(', ')} (timeout ${args.timeoutMs}ms each)`);
|
|
211
|
+
|
|
212
|
+
const total = sourcesToRun.length;
|
|
213
|
+
const heartbeatMs = Number(process.env.KUSHI_DISCOVER_HEARTBEAT_MS) || 10_000;
|
|
214
|
+
let idx = 0;
|
|
204
215
|
for (const source of sourcesToRun) {
|
|
216
|
+
idx++;
|
|
205
217
|
const prompt = buildPrompt(source, path.basename(root));
|
|
206
218
|
let rows = [];
|
|
207
219
|
let asked = true;
|
|
208
220
|
let skipReason = null;
|
|
221
|
+
const t0 = Date.now();
|
|
222
|
+
log(`[${idx}/${total}] ${source}: querying workiq (${args.timeoutMs}ms budget)...`);
|
|
223
|
+
const onHeartbeat = ({ elapsedMs, stdoutBytes }) => {
|
|
224
|
+
const sec = Math.round(elapsedMs / 1000);
|
|
225
|
+
const budget = Math.round(args.timeoutMs / 1000);
|
|
226
|
+
if (stdoutBytes === 0) {
|
|
227
|
+
log(` ${source}: ...waiting for first byte (${sec}s/${budget}s, no output yet)`);
|
|
228
|
+
} else {
|
|
229
|
+
log(` ${source}: ...still working (${sec}s/${budget}s, ${stdoutBytes} bytes received)`);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
209
232
|
try {
|
|
210
|
-
const { blocks } = await workiqAsk(prompt, { bin: workiqBin, timeoutMs: args.timeoutMs });
|
|
233
|
+
const { blocks, stdout: workiqStdout } = await workiqAsk(prompt, { bin: workiqBin, timeoutMs: args.timeoutMs, onHeartbeat, heartbeatMs });
|
|
211
234
|
rows = rowsFromBlocks(blocks, source);
|
|
235
|
+
const elapsed = Date.now() - t0;
|
|
236
|
+
const bytes = Buffer.byteLength(workiqStdout || '', 'utf8');
|
|
237
|
+
if (rows.length === 0 && bytes < 8) {
|
|
238
|
+
// Distinguish "workiq returned empty" from "timeout" — both used to look the same.
|
|
239
|
+
skipReason = 'workiq-empty-response';
|
|
240
|
+
log(` ${source}: empty response in ${elapsed}ms (${bytes} bytes) — no data found`);
|
|
241
|
+
} else {
|
|
242
|
+
log(` ${source}: ✓ ${elapsed}ms, ${bytes} bytes, ${rows.length} row(s), ${blocks.length} block(s)`);
|
|
243
|
+
}
|
|
212
244
|
} catch (e) {
|
|
213
245
|
asked = true;
|
|
214
246
|
skipReason = e.code || 'workiq-error';
|
|
247
|
+
const elapsed = Date.now() - t0;
|
|
248
|
+
const detail = e.code === 'WORKIQ_TIMEOUT'
|
|
249
|
+
? `TIMEOUT after ${elapsed}ms (received ${e.stdoutBytes ?? 0} bytes before kill)`
|
|
250
|
+
: `${skipReason} after ${elapsed}ms: ${(e.message || '').split('\n')[0].slice(0, 200)}`;
|
|
251
|
+
log(` ${source}: ✗ ${detail}`);
|
|
215
252
|
}
|
|
216
253
|
const { boundariesPatch, integrationsPatch, accepted } = applyRows(source, rows, bounds, integ);
|
|
217
254
|
if (boundariesPatch) {
|
|
@@ -225,6 +262,8 @@ async function main() {
|
|
|
225
262
|
sourceResults.push({ source, asked, found: rows.length, accepted, skipped_reason: skipReason });
|
|
226
263
|
}
|
|
227
264
|
|
|
265
|
+
log(`done: ${sourceResults.filter(r => r.found > 0).length}/${total} sources returned data`);
|
|
266
|
+
|
|
228
267
|
if (!args.dryRun) {
|
|
229
268
|
if (boundsDirty) await writeAtomic(boundsPath, YAML.stringify(bounds), { skipIfUnchanged: true });
|
|
230
269
|
if (integDirty) await writeAtomic(integPath, YAML.stringify(integ), { skipIfUnchanged: true });
|
|
@@ -47,16 +47,19 @@ function whichSync(name) {
|
|
|
47
47
|
* Ask WorkIQ a question. Returns the raw stdout text and any parsed CSC blocks.
|
|
48
48
|
*
|
|
49
49
|
* @param {string} prompt
|
|
50
|
-
* @param {object} opts { bin, timeoutMs, env }
|
|
50
|
+
* @param {object} opts { bin, timeoutMs, env, onHeartbeat }
|
|
51
|
+
* onHeartbeat: optional ({ elapsedMs, stdoutBytes, stderrBytes }) => void
|
|
52
|
+
* called every ~heartbeatMs while the child is running.
|
|
53
|
+
* heartbeatMs: heartbeat interval in ms (default 10000).
|
|
51
54
|
*/
|
|
52
|
-
export async function ask(prompt, { bin, timeoutMs = 120_000, env = process.env } = {}) {
|
|
55
|
+
export async function ask(prompt, { bin, timeoutMs = 120_000, env = process.env, onHeartbeat = null, heartbeatMs = 10_000 } = {}) {
|
|
53
56
|
const exe = bin || resolveWorkiqBin();
|
|
54
57
|
if (!await pathExists(exe)) {
|
|
55
58
|
const err = new Error(`workiq: binary not found at ${exe}. Set KUSHI_WORKIQ_BIN or install workiq.`);
|
|
56
59
|
err.code = 'WORKIQ_NOT_FOUND';
|
|
57
60
|
throw err;
|
|
58
61
|
}
|
|
59
|
-
const { stdout, stderr, exitCode } = await runProcess(exe, ['ask', '-q', prompt], { timeoutMs, env });
|
|
62
|
+
const { stdout, stderr, exitCode } = await runProcess(exe, ['ask', '-q', prompt], { timeoutMs, env, onHeartbeat, heartbeatMs });
|
|
60
63
|
if (exitCode !== 0) {
|
|
61
64
|
const err = new Error(`workiq exited ${exitCode}: ${stderr.slice(0, 1000)}`);
|
|
62
65
|
err.code = 'WORKIQ_EXIT_NONZERO';
|
|
@@ -114,7 +117,7 @@ function parseKvLines(s) {
|
|
|
114
117
|
|
|
115
118
|
async function pathExists(p) { try { await fs.access(p); return true; } catch { return false; } }
|
|
116
119
|
|
|
117
|
-
function runProcess(exe, args, { timeoutMs, env }) {
|
|
120
|
+
function runProcess(exe, args, { timeoutMs, env, onHeartbeat = null, heartbeatMs = 10_000 }) {
|
|
118
121
|
return new Promise((resolve, reject) => {
|
|
119
122
|
// Node 20.12+ refuses to spawn .cmd/.bat on Windows without shell:true
|
|
120
123
|
// (CVE-2024-27980). Detect and route through cmd.exe explicitly with
|
|
@@ -132,17 +135,36 @@ function runProcess(exe, args, { timeoutMs, env }) {
|
|
|
132
135
|
const child = spawn(spawnExe, spawnArgs, spawnOpts);
|
|
133
136
|
let stdout = '';
|
|
134
137
|
let stderr = '';
|
|
138
|
+
let stdoutBytes = 0;
|
|
139
|
+
let stderrBytes = 0;
|
|
140
|
+
const t0 = Date.now();
|
|
135
141
|
let timeoutTimer = null;
|
|
142
|
+
let heartbeatTimer = null;
|
|
143
|
+
const cleanup = () => {
|
|
144
|
+
if (timeoutTimer) { clearTimeout(timeoutTimer); timeoutTimer = null; }
|
|
145
|
+
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
146
|
+
};
|
|
136
147
|
if (timeoutMs > 0) {
|
|
137
148
|
timeoutTimer = setTimeout(() => {
|
|
149
|
+
cleanup();
|
|
138
150
|
try { child.kill('SIGKILL'); } catch {}
|
|
139
|
-
reject(Object.assign(new Error(`workiq: timeout after ${timeoutMs}ms`), {
|
|
151
|
+
reject(Object.assign(new Error(`workiq: timeout after ${timeoutMs}ms (received ${stdoutBytes} bytes)`), {
|
|
152
|
+
code: 'WORKIQ_TIMEOUT',
|
|
153
|
+
elapsedMs: Date.now() - t0,
|
|
154
|
+
stdoutBytes,
|
|
155
|
+
stderrBytes,
|
|
156
|
+
}));
|
|
140
157
|
}, timeoutMs);
|
|
141
158
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
159
|
+
if (typeof onHeartbeat === 'function' && heartbeatMs > 0) {
|
|
160
|
+
heartbeatTimer = setInterval(() => {
|
|
161
|
+
try { onHeartbeat({ elapsedMs: Date.now() - t0, stdoutBytes, stderrBytes }); } catch {}
|
|
162
|
+
}, heartbeatMs);
|
|
163
|
+
}
|
|
164
|
+
child.stdout.on('data', d => { const s = d.toString('utf8'); stdout += s; stdoutBytes += Buffer.byteLength(s, 'utf8'); });
|
|
165
|
+
child.stderr.on('data', d => { const s = d.toString('utf8'); stderr += s; stderrBytes += Buffer.byteLength(s, 'utf8'); });
|
|
166
|
+
child.on('error', e => { cleanup(); reject(e); });
|
|
167
|
+
child.on('close', code => { cleanup(); resolve({ stdout, stderr, exitCode: code ?? 0, elapsedMs: Date.now() - t0, stdoutBytes, stderrBytes }); });
|
|
146
168
|
});
|
|
147
169
|
}
|
|
148
170
|
|
|
@@ -128,3 +128,73 @@ test('discover: --dry-run does not write files', async () => {
|
|
|
128
128
|
// skipped path doesn't even check dry_run, so just confirm files_written is absent or empty
|
|
129
129
|
assert.ok(!r.json.files_written || r.json.files_written.length === 0);
|
|
130
130
|
});
|
|
131
|
+
|
|
132
|
+
test('discover: streams [discover] progress to stderr (host-watchdog regression v5.7.4)', async () => {
|
|
133
|
+
// Without per-source stderr progress, hosts like GitHub Copilot Chat kill
|
|
134
|
+
// the process when stdout stays silent during slow workiq calls. Fix: emit
|
|
135
|
+
// `[discover] ...` lines to stderr so the host sees activity.
|
|
136
|
+
const bs = runRunner(BOOTSTRAP, ['--project', 'progress-proj', '--alias', 'alice']);
|
|
137
|
+
assert.equal(bs.code, 0, bs.stderr);
|
|
138
|
+
|
|
139
|
+
// Create a fake workiq shim that returns an empty response immediately.
|
|
140
|
+
const fakeBin = path.join(tmpRoot, process.platform === 'win32' ? 'fake-workiq.cmd' : 'fake-workiq');
|
|
141
|
+
const fakeBody = process.platform === 'win32' ? '@echo off\r\necho.\r\n' : '#!/bin/sh\necho ""\n';
|
|
142
|
+
await fs.writeFile(fakeBin, fakeBody);
|
|
143
|
+
if (process.platform !== 'win32') await fs.chmod(fakeBin, 0o755);
|
|
144
|
+
|
|
145
|
+
const r = runRunner(RUNNER, ['--project', 'progress-proj', '--alias', 'alice', '--source', 'email'], {
|
|
146
|
+
KUSHI_WORKIQ_BIN: fakeBin,
|
|
147
|
+
});
|
|
148
|
+
assert.equal(r.code, 0, r.stderr);
|
|
149
|
+
assert.match(r.stderr, /\[discover\] workiq:/, `expected workiq header on stderr, got: ${r.stderr}`);
|
|
150
|
+
assert.match(r.stderr, /\[discover\] sources: email/, `expected sources header on stderr, got: ${r.stderr}`);
|
|
151
|
+
assert.match(r.stderr, /\[discover\] \[1\/1\] email: querying workiq/, `expected per-source progress on stderr, got: ${r.stderr}`);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('discover: empty workiq response is reported as workiq-empty-response, not WORKIQ_TIMEOUT (v5.7.5)', async () => {
|
|
155
|
+
// Regression: previously `found: 0` with a fast-empty workiq response and a hard timeout
|
|
156
|
+
// both produced WORKIQ_TIMEOUT in the run logs, making it impossible to distinguish
|
|
157
|
+
// "workiq found nothing" from "workiq is hanging". v5.7.5 adds workiq-empty-response.
|
|
158
|
+
const bs = runRunner(BOOTSTRAP, ['--project', 'empty-proj', '--alias', 'alice']);
|
|
159
|
+
assert.equal(bs.code, 0, bs.stderr);
|
|
160
|
+
|
|
161
|
+
const fakeBin = path.join(tmpRoot, process.platform === 'win32' ? 'fake-empty.cmd' : 'fake-empty');
|
|
162
|
+
const fakeBody = process.platform === 'win32' ? '@echo off\r\nexit /b 0\r\n' : '#!/bin/sh\nexit 0\n';
|
|
163
|
+
await fs.writeFile(fakeBin, fakeBody);
|
|
164
|
+
if (process.platform !== 'win32') await fs.chmod(fakeBin, 0o755);
|
|
165
|
+
|
|
166
|
+
const r = runRunner(RUNNER, ['--project', 'empty-proj', '--alias', 'alice', '--source', 'email'], {
|
|
167
|
+
KUSHI_WORKIQ_BIN: fakeBin,
|
|
168
|
+
});
|
|
169
|
+
assert.equal(r.code, 0, r.stderr);
|
|
170
|
+
assert.equal(r.json.status, 'ok');
|
|
171
|
+
assert.equal(r.json.sources.length, 1);
|
|
172
|
+
assert.equal(r.json.sources[0].skipped_reason, 'workiq-empty-response',
|
|
173
|
+
`expected workiq-empty-response, got: ${JSON.stringify(r.json.sources[0])}`);
|
|
174
|
+
assert.match(r.stderr, /empty response/, `expected 'empty response' in stderr, got: ${r.stderr}`);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('discover: heartbeat ticks emit during slow workiq calls (v5.7.5 host-watchdog defense)', async () => {
|
|
178
|
+
// Hosts like GitHub Copilot Chat kill processes silent on output for ~30-60s.
|
|
179
|
+
// For slow workiq queries (some kushi-style queries take 60-120s), we MUST emit
|
|
180
|
+
// periodic heartbeats. v5.7.5 emits a heartbeat every ~10s; this test uses a
|
|
181
|
+
// sleeping fake-workiq and a 1s heartbeat to assert the mechanism works.
|
|
182
|
+
const bs = runRunner(BOOTSTRAP, ['--project', 'hb-proj', '--alias', 'alice']);
|
|
183
|
+
assert.equal(bs.code, 0, bs.stderr);
|
|
184
|
+
|
|
185
|
+
// Sleep 4s, then exit 0 with empty stdout — gives heartbeat time to fire.
|
|
186
|
+
const fakeBin = path.join(tmpRoot, process.platform === 'win32' ? 'fake-slow.cmd' : 'fake-slow');
|
|
187
|
+
const fakeBody = process.platform === 'win32'
|
|
188
|
+
? '@echo off\r\nping -n 5 127.0.0.1 >nul\r\nexit /b 0\r\n'
|
|
189
|
+
: '#!/bin/sh\nsleep 4\nexit 0\n';
|
|
190
|
+
await fs.writeFile(fakeBin, fakeBody);
|
|
191
|
+
if (process.platform !== 'win32') await fs.chmod(fakeBin, 0o755);
|
|
192
|
+
|
|
193
|
+
const r = runRunner(RUNNER, ['--project', 'hb-proj', '--alias', 'alice', '--source', 'email'], {
|
|
194
|
+
KUSHI_WORKIQ_BIN: fakeBin,
|
|
195
|
+
KUSHI_DISCOVER_HEARTBEAT_MS: '1000', // 1s heartbeat for the test
|
|
196
|
+
});
|
|
197
|
+
assert.equal(r.code, 0, r.stderr);
|
|
198
|
+
assert.match(r.stderr, /still working|waiting for first byte/,
|
|
199
|
+
`expected heartbeat tick on stderr, got: ${r.stderr}`);
|
|
200
|
+
});
|
package/src/doctor.test.mjs
CHANGED
|
@@ -20,7 +20,7 @@ if (!fs.existsSync(TESTTMP)) fs.mkdirSync(TESTTMP, { recursive: true });
|
|
|
20
20
|
|
|
21
21
|
function runDoctor(extraArgs = []) {
|
|
22
22
|
const args = ['-NoProfile', '-File', doctorPs1, '-Repo', repoRoot, ...extraArgs];
|
|
23
|
-
const r = spawnSync('pwsh', args, { encoding: 'utf-8', timeout:
|
|
23
|
+
const r = spawnSync('pwsh', args, { encoding: 'utf-8', timeout: 300_000 });
|
|
24
24
|
return { stdout: r.stdout || '', stderr: r.stderr || '', status: r.status ?? 1 };
|
|
25
25
|
}
|
|
26
26
|
|