Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions packages/api/src/routes/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,20 +84,25 @@ export const scheduleRoutes: FastifyPluginAsync<ScheduleRoutesOptions> = async (
threadSubjectKeys.add(`thread:${threadId}`);

// P1-2 fix: don't rely solely on lastRun — query ledger for ANY matching run.
// Also include tasks whose subjectKind matches even with zero runs (newly registered).
// Also include tasks whose subjectKind matches active thread task kinds.
const ledger = taskRunner.getLedger();
const filtered = summaries.filter((s) => {
const filtered = summaries.flatMap((s) => {
// Quick path: if lastRun matches, include immediately
if (s.lastRun && threadSubjectKeys.has(s.lastRun.subject_key)) return true;
if (s.lastRun && threadSubjectKeys.has(s.lastRun.subject_key)) return [s];
// Slow path: check if ANY run for this task matches thread's subject keys
for (const sk of threadSubjectKeys) {
const runs = ledger.queryBySubject(s.id, sk, 1);
if (runs.length > 0) return true;
if (runs.length > 0) return [s];
}
// Zero-run path only: newly registered tasks should show up immediately,
// but once a task has runs we must not leak it across threads by subject kind.
if (!s.lastRun && s.display?.subjectKind && activeThreadSubjectKinds.has(s.display.subjectKind)) return true;
return false;
// Kind-match path (#320 P1): thread has active task of matching kind → include,
// but scrub run metadata that belongs to other threads/PRs.
if (s.display?.subjectKind && activeThreadSubjectKinds.has(s.display.subjectKind)) {
const { lastRun: _, subjectPreview: __, runStats: ___, ...rest } = s;
return [
{ ...rest, lastRun: null, subjectPreview: null, runStats: { total: 0, delivered: 0, failed: 0, skipped: 0 } },
];
}
return [];
});

return { tasks: filtered };
Expand Down
20 changes: 19 additions & 1 deletion packages/api/test/schedule-route.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ describe('Schedule Routes', () => {
assert.ok(!body.tasks.some((task) => task.id === 'review-feedback'));
});

it('does not leak cross-thread PR summaries via subjectKind fallback once a task has runs', async () => {
it('includes PR scheduler tasks for threads with active PR tracking even if task has prior runs (#320 P1)', async () => {
taskStore.upsertBySubject({
kind: 'pr_tracking',
subjectKey: 'pr:another/repo#7',
Expand All @@ -162,6 +162,24 @@ describe('Schedule Routes', () => {
const res = await app.inject({ method: 'GET', url: '/api/schedule/tasks?threadId=abc123' });
assert.equal(res.statusCode, 200);
const body = JSON.parse(res.payload);
// cicd-check has subjectKind='pr' and thread has active pr_tracking → must appear
const cicd = body.tasks.find((task) => task.id === 'cicd-check');
assert.ok(cicd, 'cicd-check should appear for thread with active PR tracking');
// Kind-match included tasks must have foreign run metadata scrubbed
assert.equal(cicd.lastRun, null, 'lastRun from other PR must be scrubbed');
assert.equal(cicd.subjectPreview, null, 'subjectPreview from other PR must be scrubbed');
assert.deepEqual(
cicd.runStats,
{ total: 0, delivered: 0, failed: 0, skipped: 0 },
'runStats from other PRs must be zeroed',
);
});

it('excludes PR scheduler tasks for threads without any PR tracking', async () => {
// thread xyz999 has no tasks at all
const res = await app.inject({ method: 'GET', url: '/api/schedule/tasks?threadId=xyz999' });
assert.equal(res.statusCode, 200);
const body = JSON.parse(res.payload);
assert.ok(!body.tasks.some((task) => task.id === 'cicd-check'));
});

Expand Down
14 changes: 2 additions & 12 deletions packages/web/src/stores/taskStore.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,7 @@
import type { TaskItem } from '@cat-cafe/shared';
import { create } from 'zustand';

export interface TaskItem {
id: string;
kind: 'work' | 'pr_tracking';
threadId: string;
title: string;
ownerCatId: string | null;
status: 'todo' | 'doing' | 'blocked' | 'done';
why: string;
createdBy: string;
createdAt: number;
updatedAt: number;
}
export type { TaskItem } from '@cat-cafe/shared';

interface TaskState {
tasks: TaskItem[];
Expand Down
Loading