mirror of
https://github.com/proffesor-for-testing/agentic-qe.git
synced 2026-09-19 08:45:47 +08:00
6236a2cdc7
Moves dream cycles out of hook subprocesses into the long-lived kernel
process via the DreamScheduler that already existed but was never wired.
Single cutover, no feature flag — per maintainer decision on ADR-094.
Why this matters
================
Before this commit, every ~20 Edit/Write tool uses triggered a dream cycle
inside a short-lived `npx aqe hooks post-edit` subprocess. The cycle held
a SQLite write transaction for up to 10 seconds while the kernel-owning
MCP server, other hook subprocesses, and user MCP tool calls queued
behind it. stderr from the cycle was consumed by Claude Code's hook
reader and lost from operator visibility. Cold DreamEngine init paid
every ~20 edits.
Now the kernel's `QEKernelImpl.initialize()` boots a `DreamScheduler`
that runs cycles on its own cadence inside the long-lived process. Hook
subprocesses keep only `incrementDreamExperience` (a counter bump that
returns in ~50ms). Operators see `aqe learning loop-health` report the
`dreamScheduler` component as live within one tick.
What landed
===========
src/kernel/kernel.ts
- new private `_dreamScheduler?: DreamScheduler`
- new config flag `enableDreamScheduler` (default `true`, mirrors
`enableExperienceBridge`)
- initialize() constructs DreamEngine + DreamScheduler after the
bridge starts, wraps in try/catch (best-effort), failure logs a
warning and continues (kernel boot never blocks on this)
- dispose() stops the scheduler BEFORE bus/memory cleanup so a dream
in-progress doesn't write to a disposed backend
src/learning/dream/dream-scheduler.ts
- executeDream() now records loop-health on every tick (success or
failure) via the helper from #488 Phase 1 B.2. Operators see
`learning:loop-health.components.dreamScheduler.successesSinceBoot`
grow on each cycle.
src/cli/commands/hooks-handlers/editing-hooks.ts
src/cli/commands/hooks-handlers/task-hooks.ts
- dropped `checkAndTriggerDream` import + call
- kept `incrementDreamExperience` (the counter is the producer-side
signal the kernel scheduler reads)
- JSON output now emits `dreamTriggered: false, dreamReason:
'deferred-to-kernel'` so operator scripts grepping the field see
*where* the cycle actually runs
src/cli/commands/hooks-handlers/hooks-shared.ts
- removed the `checkAndTriggerDream` re-export. The function itself
is still defined in hooks-dream-learning.ts and exported there for
direct test access and the documented rollback path.
tests/unit/architecture/hooks-boundary.test.ts (NEW)
- greps `src/cli/commands/hooks-handlers/` for forbidden patterns:
imports from `src/learning/dream/` and references to
`checkAndTriggerDream`. Skips comment lines so explanatory mentions
don't false-positive. `hooks-dream-learning.ts` is in `allowedFiles`
since it DEFINES the function.
- This is the enforcement mechanism for the boundary contract. A
contributor accidentally re-adding heavy hook-side work gets a
clear CI failure pointing them at ADR-094.
tests/unit/kernel/dream-scheduler-wiring.test.ts (NEW)
- asserts `enableDreamScheduler` default, opt-out path, init+dispose
cleanliness with and without the scheduler, idempotent dispose
tests/unit/cli/commands/post-edit-dream-trigger.test.ts (REWRITTEN)
- was asserting checkAndTriggerDream was called from post-edit (the
#480 fix). That contract is now wrong — the kernel is the only
place that triggers dreams.
- now asserts: counter is bumped (the kernel reads it), no inline
cycle trigger, JSON emits `deferred-to-kernel`, hook stays robust
when the memory backend throws.
docs/implementation/adrs/ADR-094-kernel-side-dream-cycles.md
- status: Implemented
- migration section replaced with the single-cutover rationale and
a documented 4-line rollback path
583/583 tests pass across CLI, workers, bridge, learning, init/phases,
architecture, and kernel suites. Build clean.
Boundary contract (now enforced in CI)
======================================
Hook subprocesses MUST NOT do work that exceeds ~100ms. Acceptable
hook-side work: producer SQLite writes, counter increments, in-memory
routing lookups. Unacceptable: DreamEngine init, multi-second SQLite
transactions, anything that depends on long-lived state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
129 lines
4.8 KiB
TypeScript
129 lines
4.8 KiB
TypeScript
/**
|
|
* Issue #480 (history) + ADR-094 (current contract)
|
|
*
|
|
* History — #480 fixed in v3.9.30 commit 66553141:
|
|
* post-edit used to set `dreamTriggered = false` literally and never
|
|
* invoke `checkAndTriggerDream`. The hook-driven dream loop was starved.
|
|
*
|
|
* Current contract — ADR-094 / #488 Phase 2:
|
|
* Dream cycles run in the long-lived kernel via QEKernelImpl._dreamScheduler,
|
|
* NOT in hook subprocesses. post-edit bumps the experience counter
|
|
* (`incrementDreamExperience`) so the scheduler knows fresh experiences
|
|
* accumulated, but does NOT invoke `checkAndTriggerDream` — that path is
|
|
* forbidden by the hooks-as-producers boundary contract (enforced by
|
|
* tests/unit/architecture/hooks-boundary.test.ts).
|
|
*
|
|
* The JSON output retains `dreamTriggered` / `dreamReason` fields for
|
|
* backwards-compat with operator scripts that grep for them. dreamReason
|
|
* is now always 'deferred-to-kernel' so operators can see WHERE the cycle
|
|
* actually runs.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { Command } from 'commander';
|
|
|
|
const {
|
|
incrementDreamExperienceMock,
|
|
printJsonMock,
|
|
} = vi.hoisted(() => ({
|
|
incrementDreamExperienceMock: vi.fn(),
|
|
printJsonMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../../../src/cli/commands/hooks-handlers/hooks-shared.js', () => ({
|
|
getHooksSystem: vi.fn().mockResolvedValue({
|
|
hookRegistry: {
|
|
emit: vi.fn().mockResolvedValue([{ success: true, patternsLearned: 0 }]),
|
|
},
|
|
reasoningBank: {
|
|
recordOutcome: vi.fn().mockResolvedValue(undefined),
|
|
},
|
|
}),
|
|
createHybridBackendWithTimeout: vi.fn().mockResolvedValue({
|
|
store: vi.fn(),
|
|
retrieve: vi.fn().mockResolvedValue(null),
|
|
delete: vi.fn(),
|
|
close: vi.fn(),
|
|
}),
|
|
incrementDreamExperience: incrementDreamExperienceMock,
|
|
persistCommandExperience: vi.fn().mockResolvedValue(undefined),
|
|
printJson: printJsonMock,
|
|
printSuccess: vi.fn(),
|
|
printError: vi.fn(),
|
|
printGuidance: vi.fn(),
|
|
readStdinJsonEvent: vi.fn().mockResolvedValue(null),
|
|
extractFilePathFromEvent: vi.fn().mockReturnValue(null),
|
|
}));
|
|
|
|
vi.mock('../../../../src/kernel/unified-memory.js', () => ({
|
|
findProjectRoot: vi.fn(() => '/tmp/post-edit-dream-trigger-test'),
|
|
}));
|
|
|
|
import { registerEditingHooks } from '../../../../src/cli/commands/hooks-handlers/editing-hooks.js';
|
|
|
|
describe('post-edit dream trigger contract (ADR-094)', () => {
|
|
beforeEach(() => {
|
|
incrementDreamExperienceMock.mockReset();
|
|
printJsonMock.mockReset();
|
|
|
|
incrementDreamExperienceMock.mockResolvedValue(20);
|
|
});
|
|
|
|
it('bumps the experience counter so the kernel scheduler sees fresh activity', async () => {
|
|
const hooks = new Command('hooks');
|
|
registerEditingHooks(hooks);
|
|
|
|
await hooks.parseAsync(
|
|
['post-edit', '--file', '/tmp/test.ts', '--success', '--json'],
|
|
{ from: 'user' },
|
|
);
|
|
|
|
// The counter bump is the producer-side signal the kernel scheduler reads.
|
|
// Without it, kernel-side dream cycles would only fire on the time-based
|
|
// cadence and would miss the experience-threshold trigger.
|
|
expect(incrementDreamExperienceMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does NOT trigger a dream cycle from the hook subprocess (ADR-094 boundary)', async () => {
|
|
// The post-edit hook no longer imports checkAndTriggerDream — verified
|
|
// structurally by tests/unit/architecture/hooks-boundary.test.ts. This
|
|
// test asserts the observable behavior: JSON output reflects that the
|
|
// cycle is deferred, never that it was triggered inline.
|
|
const hooks = new Command('hooks');
|
|
registerEditingHooks(hooks);
|
|
|
|
await hooks.parseAsync(
|
|
['post-edit', '--file', '/tmp/test.ts', '--success', '--json'],
|
|
{ from: 'user' },
|
|
);
|
|
|
|
expect(printJsonMock).toHaveBeenCalledTimes(1);
|
|
const payload = printJsonMock.mock.calls[0][0];
|
|
|
|
expect(payload.dreamTriggered).toBe(false);
|
|
// 'deferred-to-kernel' is the contract: operators reading hook JSON see
|
|
// *where* the cycle actually runs, instead of being misled by a bare
|
|
// `false` literal that doesn't tell them anything.
|
|
expect(payload.dreamReason).toBe('deferred-to-kernel');
|
|
});
|
|
|
|
it('counter is bumped even when the memory backend init throws (best-effort)', async () => {
|
|
// If the hook can't reach memory at all (unusual but possible during
|
|
// init races), the JSON still emits the deferred-to-kernel signal so
|
|
// operators don't see "fields missing — is the hook broken?".
|
|
incrementDreamExperienceMock.mockRejectedValueOnce(new Error('memory down'));
|
|
|
|
const hooks = new Command('hooks');
|
|
registerEditingHooks(hooks);
|
|
|
|
await hooks.parseAsync(
|
|
['post-edit', '--file', '/tmp/test.ts', '--success', '--json'],
|
|
{ from: 'user' },
|
|
);
|
|
|
|
const payload = printJsonMock.mock.calls[0][0];
|
|
expect(payload.success).toBe(true);
|
|
expect(payload.dreamReason).toBe('deferred-to-kernel');
|
|
});
|
|
});
|