diff --git a/packages/hooks/src/auto-claim.ts b/packages/hooks/src/auto-claim.ts index 8f37ba3..281fad9 100644 --- a/packages/hooks/src/auto-claim.ts +++ b/packages/hooks/src/auto-claim.ts @@ -12,6 +12,11 @@ export interface ActiveTaskCandidate { } export type AutoClaimObservationKind = 'claim' | 'auto-claim'; +export type AutoClaimFailureCode = + | 'ACTIVE_TASK_NOT_FOUND' + | 'AMBIGUOUS_ACTIVE_TASK' + | 'SESSION_NOT_FOUND' + | 'COLONY_UNAVAILABLE'; export interface AutoClaimFileForSessionInput { session_id: string; @@ -45,7 +50,7 @@ export type AutoClaimFileForSessionResult = } | { ok: false; - code: 'ACTIVE_TASK_NOT_FOUND' | 'AMBIGUOUS_ACTIVE_TASK'; + code: AutoClaimFailureCode; error: string; candidates: ActiveTaskCandidate[]; }; @@ -93,81 +98,101 @@ export function autoClaimFileForSession( ? (storeOrInput as MemoryStore) : (storeOrInput as AutoClaimFileForSessionCall).store; const input = maybeInput ?? (storeOrInput as AutoClaimFileForSessionCall); - const candidates = activeTaskCandidatesForSession(store, input); - if (candidates.length !== 1) { - const code = candidates.length === 0 ? 'ACTIVE_TASK_NOT_FOUND' : 'AMBIGUOUS_ACTIVE_TASK'; - return { - ok: false, - code, - error: - code === 'ACTIVE_TASK_NOT_FOUND' - ? 'no active Colony task matched session/repo/branch' - : 'multiple active Colony tasks matched session/repo/branch', - candidates, - }; - } + try { + if (!store.storage.getSession(input.session_id)) { + return { + ok: false, + code: 'SESSION_NOT_FOUND', + error: `Colony session ${input.session_id} was not found`, + candidates: [], + }; + } - const candidate = candidates[0]; - if (!candidate) throw new Error('active task resolution lost its only candidate'); + const candidates = activeTaskCandidatesForSession(store, input); + if (candidates.length !== 1) { + const code = candidates.length === 0 ? 'ACTIVE_TASK_NOT_FOUND' : 'AMBIGUOUS_ACTIVE_TASK'; + return { + ok: false, + code, + error: + code === 'ACTIVE_TASK_NOT_FOUND' + ? 'no active Colony task matched session/repo/branch' + : 'multiple active Colony tasks matched session/repo/branch', + candidates, + }; + } - const existing = store.storage.getClaim(candidate.task_id, input.file_path); - if (existing?.session_id === input.session_id) { - return { - ok: true, - status: 'already_claimed', - task_id: candidate.task_id, - observation_id: null, - candidate, - }; - } + const candidate = candidates[0]; + if (!candidate) throw new Error('active task resolution lost its only candidate'); + + const existing = store.storage.getClaim(candidate.task_id, input.file_path); + if (existing?.session_id === input.session_id) { + return { + ok: true, + status: 'already_claimed', + task_id: candidate.task_id, + observation_id: null, + candidate, + }; + } - const previousClaimSession = existing?.session_id; - const kind = input.observation_kind ?? 'claim'; - const observationId = store.storage.transaction(() => { - if (previousClaimSession && input.record_conflict === true) { - store.addObservation({ + const previousClaimSession = existing?.session_id; + const kind = input.observation_kind ?? 'claim'; + const observationId = store.storage.transaction(() => { + if (previousClaimSession && input.record_conflict === true) { + store.addObservation({ + session_id: input.session_id, + kind: 'claim-conflict', + content: `${input.session_id} edited ${input.file_path} while ${previousClaimSession} held the claim`, + task_id: candidate.task_id, + metadata: { + source: input.source ?? 'autoClaimFileForSession', + file_path: input.file_path, + ...(input.tool !== undefined ? { tool: input.tool } : {}), + other_session: previousClaimSession, + }, + }); + } + + store.storage.claimFile({ + task_id: candidate.task_id, + file_path: input.file_path, + session_id: input.session_id, + }); + return store.addObservation({ session_id: input.session_id, - kind: 'claim-conflict', - content: `${input.session_id} edited ${input.file_path} while ${previousClaimSession} held the claim`, + kind, + content: claimContent(kind, input), task_id: candidate.task_id, metadata: { + kind, source: input.source ?? 'autoClaimFileForSession', file_path: input.file_path, + resolved_by: input.resolved_by ?? 'autoClaimFileForSession', + ...(input.auto_claimed_before_edit === true ? { auto_claimed_before_edit: true } : {}), ...(input.tool !== undefined ? { tool: input.tool } : {}), - other_session: previousClaimSession, }, }); - } - - store.storage.claimFile({ - task_id: candidate.task_id, - file_path: input.file_path, - session_id: input.session_id, }); - return store.addObservation({ - session_id: input.session_id, - kind, - content: claimContent(kind, input), - task_id: candidate.task_id, - metadata: { - kind, - source: input.source ?? 'autoClaimFileForSession', - file_path: input.file_path, - resolved_by: input.resolved_by ?? 'autoClaimFileForSession', - ...(input.auto_claimed_before_edit === true ? { auto_claimed_before_edit: true } : {}), - ...(input.tool !== undefined ? { tool: input.tool } : {}), - }, - }); - }); - return { - ok: true, - status: 'claimed', - task_id: candidate.task_id, - observation_id: observationId, - candidate, - ...(previousClaimSession !== undefined ? { previous_claim_session: previousClaimSession } : {}), - }; + return { + ok: true, + status: 'claimed', + task_id: candidate.task_id, + observation_id: observationId, + candidate, + ...(previousClaimSession !== undefined + ? { previous_claim_session: previousClaimSession } + : {}), + }; + } catch (err) { + return { + ok: false, + code: 'COLONY_UNAVAILABLE', + error: err instanceof Error ? err.message : String(err), + candidates: [], + }; + } } function isActiveStatus(status: string): boolean { diff --git a/packages/hooks/src/handlers/pre-tool-use.ts b/packages/hooks/src/handlers/pre-tool-use.ts index 0f8913d..26cc67e 100644 --- a/packages/hooks/src/handlers/pre-tool-use.ts +++ b/packages/hooks/src/handlers/pre-tool-use.ts @@ -1,21 +1,65 @@ import { type MemoryStore, detectRepoBranch } from '@colony/core'; -import { type AutoClaimFileForSessionResult, autoClaimFileBeforeEdit } from '../auto-claim.js'; +import { + type ActiveTaskCandidate, + type AutoClaimFailureCode, + type AutoClaimFileForSessionResult, + autoClaimFileBeforeEdit, +} from '../auto-claim.js'; import type { HookInput } from '../types.js'; import { extractTouchedFiles } from './post-tool-use.js'; +const CLAIM_WARNING_DEBOUNCE_MS = 60_000; +const claimWarningDebounceByStore = new WeakMap>(); + +export interface ClaimBeforeEditFallbackWarning { + code: AutoClaimFailureCode; + message: string; + next_tool: 'task_claim_file'; + suggested_args: { + task_id: number | '' | ''; + session_id: string; + file_path: string; + note: string; + }; + candidates?: CompactCandidate[]; +} + export interface ClaimBeforeEditResult { files: string[]; edits_with_claim: string[]; edits_missing_claim: string[]; auto_claimed_before_edit: string[]; - warnings: string[]; + warnings: ClaimBeforeEditFallbackWarning[]; } type PreToolUseInput = Pick; type AutoClaimFailure = Extract; +type CompactCandidate = Pick< + ActiveTaskCandidate, + 'task_id' | 'repo_root' | 'branch' | 'status' | 'updated_at' +>; export function preToolUse(store: MemoryStore, input: HookInput): string { - return claimBeforeEditWarning(claimBeforeEditFromToolUse(store, input)); + try { + return claimBeforeEditWarning(claimBeforeEditFromToolUse(store, input)); + } catch { + const toolName = input.tool_name ?? input.tool ?? ''; + const files = extractTouchedFiles(toolName, input.tool_input); + return claimBeforeEditWarning({ + files, + edits_with_claim: [], + edits_missing_claim: files, + auto_claimed_before_edit: [], + warnings: files.map((file_path) => + claimWarning(input.session_id, file_path, { + ok: false, + code: 'COLONY_UNAVAILABLE', + error: 'Colony unavailable for auto-claim', + candidates: [], + }), + ), + }); + } } export function claimBeforeEditFromToolUse( @@ -69,6 +113,7 @@ export function claimBeforeEditFromToolUse( } result.edits_missing_claim.push(file_path); + const warningDebounced = claimWarningDebounced(store, input.session_id, file_path, claim.code); recordClaimBeforeEditFailure(store, input.session_id, { file_path, tool: toolName, @@ -76,7 +121,7 @@ export function claimBeforeEditFromToolUse( error: claim.error, candidates: claim.candidates, }); - result.warnings.push(claimWarning(toolName, file_path, claim)); + if (!warningDebounced) result.warnings.push(claimWarning(input.session_id, file_path, claim)); } return result; @@ -89,10 +134,14 @@ function taskScopeForToolUse( repo_root?: string; branch?: string; } { - const session = store.storage.getSession(input.session_id); - const cwd = input.cwd ?? session?.cwd ?? undefined; - const detected = cwd ? detectRepoBranch(cwd) : null; - return detected ? { repo_root: detected.repo_root, branch: detected.branch } : {}; + try { + const session = store.storage.getSession(input.session_id); + const cwd = input.cwd ?? session?.cwd ?? undefined; + const detected = cwd ? detectRepoBranch(cwd) : null; + return detected ? { repo_root: detected.repo_root, branch: detected.branch } : {}; + } catch { + return {}; + } } function recordClaimBeforeEditFailure( @@ -101,43 +150,46 @@ function recordClaimBeforeEditFailure( metadata: { file_path: string; tool: string; - code: 'ACTIVE_TASK_NOT_FOUND' | 'AMBIGUOUS_ACTIVE_TASK'; + code: AutoClaimFailureCode; error: string; candidates: AutoClaimFailure['candidates']; }, ): void { - store.addObservation({ - session_id, - kind: 'claim-before-edit', - content: `edits_missing_claim: ${metadata.file_path}`, - metadata: { + if (metadata.code === 'SESSION_NOT_FOUND' || metadata.code === 'COLONY_UNAVAILABLE') return; + try { + store.addObservation({ + session_id, kind: 'claim-before-edit', - source: 'pre-tool-use', - outcome: 'edits_missing_claim', - file_path: metadata.file_path, - tool: metadata.tool, - code: metadata.code, - error: metadata.error, - candidates: compactCandidates(metadata.candidates), - }, - }); + content: `edits_missing_claim: ${metadata.file_path}`, + metadata: { + kind: 'claim-before-edit', + source: 'pre-tool-use', + outcome: 'edits_missing_claim', + file_path: metadata.file_path, + tool: metadata.tool, + code: metadata.code, + error: metadata.error, + candidates: compactCandidates(metadata.candidates), + }, + }); + } catch { + // Warning output is the fallback path when Colony cannot persist telemetry. + } } function compactCandidates(candidates: AutoClaimFailure['candidates']): Array<{ task_id: number; - title: string; repo_root: string; branch: string; status: string; - agent: string; + updated_at: number; }> { return candidates.slice(0, 5).map((candidate) => ({ task_id: candidate.task_id, - title: candidate.title, repo_root: candidate.repo_root, branch: candidate.branch, status: candidate.status, - agent: candidate.agent, + updated_at: candidate.updated_at, })); } @@ -163,13 +215,54 @@ function recordClaimBeforeEdit( } function claimBeforeEditWarning(result: ClaimBeforeEditResult): string { - return result.warnings.join('\n'); + return result.warnings.map((warning) => JSON.stringify(warning)).join('\n'); } -function claimWarning(toolName: string, filePath: string, claim: AutoClaimFailure): string { - return [ - `${claim.code}: ${toolName || 'write tool'} target ${filePath}`, - claim.error, - `candidates=${JSON.stringify(compactCandidates(claim.candidates))}`, - ].join('\n'); +function claimWarning( + session_id: string, + file_path: string, + claim: AutoClaimFailure, +): ClaimBeforeEditFallbackWarning { + const candidates = compactCandidates(claim.candidates); + return { + code: claim.code, + message: `Missing Colony claim before edit. Call task_claim_file for ${file_path} before editing.`, + next_tool: 'task_claim_file', + suggested_args: { + task_id: candidates.length > 0 ? '' : '', + session_id, + file_path, + note: 'pre-edit claim', + }, + ...(candidates.length > 0 ? { candidates } : {}), + }; +} + +function claimWarningDebounced( + store: MemoryStore, + session_id: string, + file_path: string, + code: AutoClaimFailureCode, +): boolean { + const now = Date.now(); + const cutoff = now - CLAIM_WARNING_DEBOUNCE_MS; + const key = `${session_id}\0${file_path}\0${code}`; + const claimWarningDebounce = claimWarningDebounceByStore.get(store) ?? new Map(); + claimWarningDebounceByStore.set(store, claimWarningDebounce); + const lastEmittedAt = claimWarningDebounce.get(key); + claimWarningDebounce.set(key, now); + if (lastEmittedAt !== undefined && lastEmittedAt >= cutoff) return true; + try { + return store.timeline(session_id, undefined, 20).some((row) => { + if (row.kind !== 'claim-before-edit' || row.ts < cutoff) return false; + const metadata = row.metadata ?? {}; + return ( + metadata.outcome === 'edits_missing_claim' && + metadata.file_path === file_path && + metadata.code === code + ); + }); + } catch { + return false; + } } diff --git a/packages/hooks/test/auto-claim.test.ts b/packages/hooks/test/auto-claim.test.ts index 556b8f6..dfa3947 100644 --- a/packages/hooks/test/auto-claim.test.ts +++ b/packages/hooks/test/auto-claim.test.ts @@ -6,7 +6,7 @@ import { MemoryStore, TaskThread } from '@colony/core'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { autoClaimFileBeforeEdit, autoClaimFileForSession } from '../src/auto-claim.js'; import { autoClaimFromToolUse, extractTouchedFiles } from '../src/handlers/post-tool-use.js'; -import { claimBeforeEditFromToolUse } from '../src/handlers/pre-tool-use.js'; +import { claimBeforeEditFromToolUse, preToolUse } from '../src/handlers/pre-tool-use.js'; import { buildConflictPreface } from '../src/handlers/user-prompt-submit.js'; import { runHook } from '../src/runner.js'; @@ -138,6 +138,21 @@ describe('autoClaimFileForSession', () => { }); expect(store.storage.findActiveTaskForSession('A')).toBeUndefined(); }); + + it('returns SESSION_NOT_FOUND when the session row is missing', () => { + const result = autoClaimFileForSession(store, { + session_id: 'missing', + repo_root: '/repo', + branch: 'feat/missing', + file_path: 'src/viewer.tsx', + }); + + expect(result).toMatchObject({ + ok: false, + code: 'SESSION_NOT_FOUND', + candidates: [], + }); + }); }); describe('autoClaimFileBeforeEdit', () => { @@ -207,6 +222,21 @@ describe('autoClaimFileBeforeEdit', () => { }); expect(store.storage.findActiveTaskForSession('A')).toBeUndefined(); }); + + it('returns SESSION_NOT_FOUND when the session row is missing', () => { + const result = autoClaimFileBeforeEdit(store, { + session_id: 'missing', + repo_root: '/repo', + branch: 'feat/missing', + file_path: 'src/viewer.tsx', + }); + + expect(result).toMatchObject({ + ok: false, + code: 'SESSION_NOT_FOUND', + candidates: [], + }); + }); }); describe('autoClaimFromToolUse', () => { @@ -375,7 +405,20 @@ describe('claimBeforeEditFromToolUse', () => { expect(result.auto_claimed_before_edit).toEqual([]); expect(result.edits_missing_claim).toEqual(['src/x.ts']); - expect(result.warnings.join('\n')).toContain('ACTIVE_TASK_NOT_FOUND'); + expect(result.warnings).toEqual([ + { + code: 'ACTIVE_TASK_NOT_FOUND', + message: + 'Missing Colony claim before edit. Call task_claim_file for src/x.ts before editing.', + next_tool: 'task_claim_file', + suggested_args: { + task_id: '', + session_id: 'solo', + file_path: 'src/x.ts', + note: 'pre-edit claim', + }, + }, + ]); expect(store.storage.findActiveTaskForSession('solo')).toBeUndefined(); const telemetry = store.timeline('solo').filter((row) => row.kind === 'claim-before-edit'); expect(telemetry).toHaveLength(1); @@ -405,11 +448,150 @@ describe('claimBeforeEditFromToolUse', () => { expect(result.auto_claimed_before_edit).toEqual([]); expect(result.edits_missing_claim).toEqual(['src/x.ts']); - expect(result.warnings.join('\n')).toContain('AMBIGUOUS_ACTIVE_TASK'); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatchObject({ + code: 'AMBIGUOUS_ACTIVE_TASK', + message: + 'Missing Colony claim before edit. Call task_claim_file for src/x.ts before editing.', + next_tool: 'task_claim_file', + suggested_args: { + task_id: '', + session_id: 'A', + file_path: 'src/x.ts', + note: 'pre-edit claim', + }, + }); + expect(result.warnings[0]?.candidates).toHaveLength(2); + expect(result.warnings[0]?.candidates).toEqual( + expect.arrayContaining([ + { + task_id: expect.any(Number), + repo_root: '/repo', + branch: 'feat/one', + status: 'open', + updated_at: expect.any(Number), + }, + { + task_id: expect.any(Number), + repo_root: '/repo', + branch: 'feat/two', + status: 'open', + updated_at: expect.any(Number), + }, + ]), + ); + expect(result.warnings[0]?.candidates?.[0]).toEqual( + expect.objectContaining({ + task_id: expect.any(Number), + repo_root: '/repo', + branch: expect.stringMatching(/^feat\/(one|two)$/), + status: 'open', + updated_at: expect.any(Number), + }), + ); expect( store.storage.listTasks(10).flatMap((task) => store.storage.listClaims(task.id)), ).toEqual([]); }); + + it('emits SESSION_NOT_FOUND when a pre-edit hook has no session row', () => { + const result = claimBeforeEditFromToolUse(store, { + session_id: 'missing', + tool_name: 'Edit', + tool_input: { file_path: 'src/x.ts' }, + }); + + expect(result.auto_claimed_before_edit).toEqual([]); + expect(result.edits_missing_claim).toEqual(['src/x.ts']); + expect(result.warnings).toEqual([ + { + code: 'SESSION_NOT_FOUND', + message: + 'Missing Colony claim before edit. Call task_claim_file for src/x.ts before editing.', + next_tool: 'task_claim_file', + suggested_args: { + task_id: '', + session_id: 'missing', + file_path: 'src/x.ts', + note: 'pre-edit claim', + }, + }, + ]); + }); + + it('debounces repeated warning output for the same session, file, and code', () => { + store.startSession({ id: 'debounce-session', ide: 'codex', cwd: '/repo' }); + + const first = claimBeforeEditFromToolUse(store, { + session_id: 'debounce-session', + tool_name: 'Edit', + tool_input: { file_path: 'src/x.ts' }, + }); + const second = claimBeforeEditFromToolUse(store, { + session_id: 'debounce-session', + tool_name: 'Edit', + tool_input: { file_path: 'src/x.ts' }, + }); + + expect(first.warnings).toHaveLength(1); + expect(second.edits_missing_claim).toEqual(['src/x.ts']); + expect(second.warnings).toEqual([]); + expect( + store.timeline('debounce-session').filter((row) => row.kind === 'claim-before-edit'), + ).toHaveLength(2); + }); + + it('formats pre-tool-use warnings as compact JSON lines', () => { + store.startSession({ id: 'json-session', ide: 'codex', cwd: '/repo' }); + + const context = preToolUse(store, { + session_id: 'json-session', + tool_name: 'Edit', + tool_input: { file_path: 'src/x.ts' }, + }); + + expect(JSON.parse(context)).toMatchObject({ + code: 'ACTIVE_TASK_NOT_FOUND', + message: + 'Missing Colony claim before edit. Call task_claim_file for src/x.ts before editing.', + next_tool: 'task_claim_file', + suggested_args: { + task_id: '', + session_id: 'json-session', + file_path: 'src/x.ts', + note: 'pre-edit claim', + }, + }); + }); + + it('emits COLONY_UNAVAILABLE as an advisory warning when pre-tool-use storage fails', () => { + const brokenStore = { + storage: { + getSession() { + throw new Error('db unavailable'); + }, + }, + } as unknown as MemoryStore; + + const context = preToolUse(brokenStore, { + session_id: 'A', + tool_name: 'Edit', + tool_input: { file_path: 'src/x.ts' }, + }); + + expect(JSON.parse(context)).toEqual({ + code: 'COLONY_UNAVAILABLE', + message: + 'Missing Colony claim before edit. Call task_claim_file for src/x.ts before editing.', + next_tool: 'task_claim_file', + suggested_args: { + task_id: '', + session_id: 'A', + file_path: 'src/x.ts', + note: 'pre-edit claim', + }, + }); + }); }); describe('buildConflictPreface', () => {