Files
Dragan Spiridonov ca714ac1a7 chore(release): v3.9.24 (#450)
* fix(hooks): post-task runs Stream B/D/F when --task-id is missing (#449)

The shipped PostToolUse hook command for Task/Agent matchers is
  `npx agentic-qe hooks post-task --task-id "$TOOL_RESULT_agent_id" --success --json`
Claude Code does not populate $TOOL_RESULT_agent_id, so the flag arrives
empty and options.taskId is falsy on every real hook invocation. The
`if (options.taskId)` gate inside post-task then short-circuited the
entire Stream B/D/F learning chain:

- reasoningBank.recordOutcome — never called
- persistTaskOutcome — never called (no captured_experiences,
  experience_applications, qe_trajectories, dream_insights.applied)
- updateRoutingOutcomeQuality — never called (routing_outcomes
  sentinels accumulated unfilled at success=0/quality_score=-1)
- updateHookRouterQValue — never called (rl_q_values stayed empty
  forever)

Same regression was fixed in v3.9.21 (patch 030) and silently came
back in v3.9.23.

Fix:
- Remove the `if (options.taskId)` gate in post-task. Use
  `hook-${Date.now()}` fallback for the patternId / experience id
  when the caller didn't supply one. The bridge lookup in
  persistTaskOutcome keys by `ORDER BY created_at DESC LIMIT 1`
  (not by taskId), so Stream F's Bellman Q-update still finds the
  bridge payload it needs.
- Remove the parallel `&& options.taskId` clause from the pre-task
  routing_outcomes sentinel write at line 195 — same root cause.
- Add `DO NOT REINTRODUCE` comment blocks at both sites pointing
  at #449 and patch 030.

Test: tests/unit/cli/commands/task-hooks-no-taskid.test.ts mocks
hooks-shared and asserts persistTaskOutcome / updateHookRouterQValue
fire when --task-id is absent. Bug-introduce verification:
re-introducing the gate makes the no-taskId test fail with the
expected "called 1 times, but got 0 times".

Co-authored-by: Jordi Izquierdo <noreply@github.com>

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(release): bump version to v3.9.24

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:54:33 +02:00

132 lines
4.6 KiB
TypeScript

/**
* Regression: issue #449
*
* post-task must run the full Stream B/D/F learning chain even when
* --task-id is missing.
*
* The shipped PostToolUse hook command is
* `npx agentic-qe hooks post-task --task-id "$TOOL_RESULT_agent_id" --success --json`
* Claude Code does not populate $TOOL_RESULT_agent_id for Task/Agent tools, so
* options.taskId is empty on every real invocation. The pre-fix code wrapped
* persistTaskOutcome / updateRoutingOutcomeQuality / updateHookRouterQValue in
* `if (options.taskId)`, silently killing all three. rl_q_values stayed empty
* forever.
*
* This test mocks hooks-shared and asserts the pipeline still fires with no
* --task-id. If the gate is reintroduced, persistTaskOutcome / Q-update spies
* will never be called and the test will fail.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Command } from 'commander';
const {
persistTaskOutcomeMock,
updateHookRouterQValueMock,
updateRoutingOutcomeQualityMock,
recordOutcomeMock,
} = vi.hoisted(() => ({
persistTaskOutcomeMock: vi.fn(),
updateHookRouterQValueMock: vi.fn(),
updateRoutingOutcomeQualityMock: vi.fn(),
recordOutcomeMock: vi.fn(),
}));
vi.mock('../../../../src/cli/commands/hooks-handlers/hooks-shared.js', () => ({
getHooksSystem: vi.fn().mockResolvedValue({
hookRegistry: { emit: vi.fn().mockResolvedValue([]) },
reasoningBank: {
recordOutcome: recordOutcomeMock,
routeTask: vi.fn().mockResolvedValue({ success: false }),
},
}),
applyHookBusyTimeout: vi.fn(),
createHybridBackendWithTimeout: vi.fn().mockResolvedValue({
store: vi.fn(),
retrieve: vi.fn().mockResolvedValue(null),
delete: vi.fn(),
close: vi.fn(),
}),
incrementDreamExperience: vi.fn().mockResolvedValue(0),
checkAndTriggerDream: vi.fn().mockResolvedValue({ triggered: false }),
persistTaskOutcome: persistTaskOutcomeMock,
updateHookRouterQValue: updateHookRouterQValueMock,
updateRoutingOutcomeQuality: updateRoutingOutcomeQualityMock,
printJson: vi.fn(),
printSuccess: vi.fn(),
}));
import { registerTaskHooks } from '../../../../src/cli/commands/hooks-handlers/task-hooks.js';
describe('post-task without --task-id (issue #449)', () => {
beforeEach(() => {
persistTaskOutcomeMock.mockReset();
updateHookRouterQValueMock.mockReset();
updateRoutingOutcomeQualityMock.mockReset();
recordOutcomeMock.mockReset();
persistTaskOutcomeMock.mockResolvedValue({
experienceId: 'exp-test',
qualityScore: 0.575,
bridge: {
selectedPatternIds: ['p1'],
agent: 'qe-test-architect',
description: 'd',
taskType: 'test-generation',
priority: 'normal',
domain: 'general',
complexityBucket: 3,
estimatedTokenSavings: 0,
ts: Date.now(),
},
stitchedSiblings: 0,
insightsApplied: 0,
});
recordOutcomeMock.mockResolvedValue(undefined);
updateHookRouterQValueMock.mockResolvedValue(undefined);
updateRoutingOutcomeQualityMock.mockResolvedValue(undefined);
});
it('runs persistTaskOutcome and updateHookRouterQValue when --task-id is absent', async () => {
const hooks = new Command('hooks');
registerTaskHooks(hooks);
await hooks.parseAsync(
['post-task', '--success', 'true', '--agent', 'qe-test-architect', '--json'],
{ from: 'user' },
);
expect(persistTaskOutcomeMock).toHaveBeenCalledTimes(1);
const call = persistTaskOutcomeMock.mock.calls[0][0];
expect(call.agent).toBe('qe-test-architect');
expect(call.success).toBe(true);
expect(call.taskId).toMatch(/^hook-\d+$/);
expect(updateRoutingOutcomeQualityMock).toHaveBeenCalledTimes(1);
expect(updateHookRouterQValueMock).toHaveBeenCalledTimes(1);
expect(updateHookRouterQValueMock.mock.calls[0][0]).toMatchObject({
taskType: 'test-generation',
domain: 'general',
agent: 'qe-test-architect',
success: true,
});
expect(recordOutcomeMock).toHaveBeenCalledTimes(1);
expect(recordOutcomeMock.mock.calls[0][0].patternId).toMatch(/^task:qe-test-architect:hook-\d+$/);
});
it('still uses the real --task-id when one is provided', async () => {
const hooks = new Command('hooks');
registerTaskHooks(hooks);
await hooks.parseAsync(
['post-task', '--task-id', 'real-task-42', '--success', 'true', '--agent', 'coder', '--json'],
{ from: 'user' },
);
expect(persistTaskOutcomeMock).toHaveBeenCalledTimes(1);
expect(persistTaskOutcomeMock.mock.calls[0][0].taskId).toBe('real-task-42');
expect(recordOutcomeMock.mock.calls[0][0].patternId).toBe('task:coder:real-task-42');
});
});