fix: per-task timer exclusivity, not global

Multiple tasks can each have running timers simultaneously — this supports
multi-agent workflows where different agents track different tasks.

Removed:
- getRunningTimerTask() method (global scan)
- Auto-stop of other tasks' timers in startTimer()
- otherRunningTask UI check that hid the Start button

Kept: per-task guard (can't start a timer on a task that already has one running).
This commit is contained in:
Brad Groux 2026-01-31 04:49:58 -06:00
parent 6d63c94c51
commit b13ecce67a
3 changed files with 6 additions and 49 deletions

View file

@ -27,15 +27,8 @@ router.get(
router.post(
'/:id/time/start',
asyncHandler(async (req, res) => {
const { task, stoppedTaskId } = await taskService.startTimer(req.params.id as string);
// Broadcast WS event for the auto-stopped task (if any) so other clients refresh
if (stoppedTaskId) {
broadcastTaskChange('updated', stoppedTaskId);
}
// Broadcast WS event for the started task
const task = await taskService.startTimer(req.params.id as string);
broadcastTaskChange('updated', task.id);
res.json(task);
})
);

View file

@ -680,48 +680,21 @@ export class TaskService {
// ============ Time Tracking Methods ============
/**
* Find the task that currently has a running timer (if any).
*/
async getRunningTimerTask(): Promise<Task | null> {
await this.initCache();
for (const task of this.cache.values()) {
if (task.timeTracking?.isRunning) {
return task;
}
}
return null;
}
/**
* Start a timer for a task.
* Enforces global exclusivity: only one timer may run at a time.
* If another task has a running timer, it is auto-stopped first.
* Returns { task, stoppedTaskId? } so the caller can broadcast both.
* Per-task exclusivity: only one timer per task (but multiple tasks can
* each have their own running timer — supports multi-agent workflows).
*/
async startTimer(taskId: string): Promise<{ task: Task; stoppedTaskId?: string }> {
async startTimer(taskId: string): Promise<Task> {
const task = await this.getTask(taskId);
if (!task) {
throw new NotFoundError('Task not found');
}
// Check if timer is already running on THIS task
if (task.timeTracking?.isRunning) {
throw new ConflictError('Timer is already running for this task');
}
// Global exclusivity: stop any other running timer first
let stoppedTaskId: string | undefined;
const runningTask = await this.getRunningTimerTask();
if (runningTask && runningTask.id !== taskId) {
await this.stopTimer(runningTask.id);
stoppedTaskId = runningTask.id;
log.info(
{ stoppedTaskId, startedTaskId: taskId },
'Auto-stopped timer for global exclusivity'
);
}
const entryId = `time_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const now = new Date().toISOString();
@ -737,8 +710,7 @@ export class TaskService {
activeEntryId: entryId,
};
const updated = (await this.updateTask(taskId, { timeTracking })) as Task;
return { task: updated, stoppedTaskId };
return (await this.updateTask(taskId, { timeTracking })) as Task;
}
/**

View file

@ -103,9 +103,6 @@ export function TimeTrackingSection({ task }: TimeTrackingSectionProps) {
const entries = timeTracking?.entries ?? [];
const activeEntry = entries.find((e) => e.id === timeTracking?.activeEntryId);
// Global exclusivity: is another task's timer running?
const otherRunningTask = allTasks?.find((t) => t.id !== task.id && t.timeTracking?.isRunning);
// ── Handlers ──
const handleStartStop = async () => {
@ -218,13 +215,8 @@ export function TimeTrackingSection({ task }: TimeTrackingSectionProps) {
</>
)}
</Button>
) : otherRunningTask ? (
/* Another task has the active timer — Start is blocked */
<span className="text-xs text-muted-foreground italic">
Timer active on another task
</span>
) : (
/* No timer running anywhere — show Start */
/* Timer not running on this task — show Start */
<Button variant="default" size="sm" onClick={handleStartStop} disabled={busy}>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin" />