Files
proffesor-for-testing__agen…/tests/unit/init/phases/10-workers-daemon-pidfile.test.ts
Dragan Spiridonov 4c0bdfafb9 feat(observability): daemon pidfile fix + learning loop-health (#488 Phase 1)
Phase 1 of the #488 production-readiness work, scoped to additive changes
that don't need new ADRs. Two parts:

B.1 — Daemon pidfile points at the npx wrapper, not the MCP server
=================================================================

When neither `node_modules/.bin/aqe-mcp` nor the local
`node_modules/agentic-qe/dist/mcp/bundle.js` exists, the generated
`.agentic-qe/workers/start-daemon.cjs` falls back to
`npx --yes agentic-qe mcp`. `npx` exits as soon as it has forked the real
bundle, so `child.pid` is the wrapper PID and the pidfile becomes stale
within seconds. `aqe daemon status` then misreports liveness, and the
idempotency check at the top of the script spawns duplicate daemons —
the reporter hit this twice in two days.

Adds `require.resolve('agentic-qe/dist/mcp/bundle.js')` as a 3rd
candidate (try/catch-guarded) before the npx fallback. Globally-installed
shops (`npm install -g agentic-qe` — the typical case the npx fallback
was silently covering) now spawn the real bundle directly; `child.pid`
is the long-lived MCP server.

The npx fallback remains as a last resort for environments where neither
local nor global install is reachable. When taken, it now logs a WARNING
to daemon.log so operators can see the degraded pidfile contract.

B.2 — `learning:loop-health` observability
==========================================

Operators previously had no way to answer "is the self-learning loop
closing end-to-end?" without DB-mining `dream_cycles`, `kv_store`, and
`routing_outcomes` separately. Cumulative counters in `aqe learning
stats` grow forever and can't distinguish "healthy loop" from "loop
wedged 3 hours ago with stale totals".

Adds a single `learning:loop-health` kv key with per-component liveness:

  { overallLastSuccess, bootedAt,
    components: {
      bridge?:          { lastSuccessAt, ticksSinceBoot, ... },
      learningWorker?:  { lastSuccessAt, ticksSinceBoot, ... },
      dreamScheduler?:  { lastSuccessAt, ticksSinceBoot, ... },
    } }

Writers (best-effort — observability MUST NOT crash the observed
component):
  - CapturedExperienceBridge.drainSafe — every poll, success or failure
  - LearningConsolidationWorker.doExecute — end of every tick
  - DreamScheduler reserved for #488 Phase 2 (the field stays
    undefined until Phase 2 wires the scheduler in-kernel)

Surface:
  - new `aqe learning loop-health [--json]` CLI subcommand with
    component verdicts (live/stale/never-ran) based on per-component
    staleness thresholds (bridge: 30s; worker/scheduler: 2h)
  - distinct from the existing `aqe learning health` (per-domain
    regret analysis); the new command is pipeline liveness, not
    agent outcome quality

Tests:
  - 12 tests in tests/unit/learning/loop-health.test.ts for the
    utility: fresh record creation, tick counters, error capture +
    clear-on-success, multi-component isolation, overallLastSuccess
    max-computation, throw-safety when the memory backend is broken,
    staleness verdicts
  - 4 tests in tests/unit/init/phases/10-workers-daemon-pidfile.test.ts
    asserting the require.resolve probe is present, try/catch-guarded,
    ordered before the npx fallback, and that the warning string is
    in place

541/541 tests pass across CLI commands, workers, bridge, learning,
and init/phases suites; build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:11:32 +00:00

74 lines
3.1 KiB
TypeScript

/**
* Issue #488 B.1 — daemon pidfile points at the npx wrapper, not the
* long-lived MCP server.
*
* Root cause: `start-daemon.cjs` falls back to `npx --yes agentic-qe mcp`
* when neither the local `.bin/aqe-mcp` nor the local
* `node_modules/agentic-qe/dist/mcp/bundle.js` exists. `npx` exits as soon
* as it has forked the real bundle, so `child.pid` is the wrapper PID and
* the pidfile becomes stale within seconds — `aqe daemon status` and the
* idempotency check at the top of the script both misbehave.
*
* The fix: add `require.resolve('agentic-qe/dist/mcp/bundle.js')` as a 3rd
* candidate before the npx fallback. Global installs (`npm install -g`)
* are now found directly; `child.pid` is the real long-lived process.
*
* This test asserts the candidate is present in the generated daemon
* script template inside `10-workers.ts` and that the npx fallback now
* emits a warning so operators can spot the degraded case.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
const PHASE_FILE = path.resolve(
__dirname,
'../../../../src/init/phases/10-workers.ts',
);
describe('start-daemon.cjs generator (#488 B.1)', () => {
const source = readFileSync(PHASE_FILE, 'utf-8');
it('the generated script probes for a global install via require.resolve', () => {
// Catches the case where someone removes the require.resolve probe —
// which would silently re-introduce the npx-wrapper-PID bug for users
// who installed agentic-qe globally.
expect(source).toContain(
"require.resolve('agentic-qe/dist/mcp/bundle.js')",
);
});
it('the require.resolve probe is guarded by try/catch (so missing global install does not crash)', () => {
// Use a regex anchored on the candidates.push line so we know the
// try/catch surrounds the actual probe, not some unrelated code.
const probeBlock =
/try\s*\{\s*candidates\.push\(require\.resolve\('agentic-qe\/dist\/mcp\/bundle\.js'\)\);\s*\}\s*catch\s*\{[\s\S]*?\}/;
expect(source).toMatch(probeBlock);
});
it('the npx fallback now warns operators about the pidfile limitation', () => {
// The npx path remains as a last resort, but operators must be able
// to spot it in daemon.log so they can fix their install.
expect(source).toMatch(/WARNING.*npx fallback/);
expect(source).toMatch(/daemon\.pid will point at the npx wrapper/);
});
it('candidate ordering: bundle.js paths come before npx fallback', () => {
// The require.resolve probe must run BEFORE the existsSync(c) call
// that picks the first candidate, otherwise the npx fallback wins
// even when a global install is reachable.
const requireResolveIdx = source.indexOf(
"candidates.push(require.resolve('agentic-qe/dist/mcp/bundle.js'))",
);
const binCandidateIdx = source.indexOf(
'const binCandidate = candidates.find',
);
const npxFallbackIdx = source.indexOf("'--yes', 'agentic-qe', 'mcp'");
expect(requireResolveIdx).toBeGreaterThan(0);
expect(binCandidateIdx).toBeGreaterThan(requireResolveIdx);
expect(npxFallbackIdx).toBeGreaterThan(binCandidateIdx);
});
});