Fix: child tasks only from spec/breakdown agents; live review badge

Two fixes from real usage:
- Child-task creation is now gated to story-producing skills (spec-writing,
  story-breakdown). A code/design/test agent's output is the artifact — a numbered list
  in it (e.g. file names from an engineer) is no longer mistaken for child stories.
- The review-inbox badge now updates without a refresh: it polls more often (6s),
  refetches on window focus, and reacts to a REVIEWS_CHANGED event the board fires after
  Run (with a couple of delayed pulses to catch the ~5s completion) and the review page
  fires after approve / send back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-16 22:22:57 +03:30
parent 9993ebb2b4
commit 1f562fd633
4 changed files with 35 additions and 7 deletions
+16 -3
View File
@@ -25,11 +25,17 @@ import { api } from '@/lib/api'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useAuth } from '@/store/auth' import { useAuth } from '@/store/auth'
/** Polls the count of held actions awaiting review, so the nav can flag work is waiting. */ /** Event other pages fire after dispatching a run or deciding a review, so the badge updates at once. */
export const REVIEWS_CHANGED = 'teamup:reviews-changed'
/** Tracks the count of held actions awaiting review: polls, and refetches on focus / a change event. */
function usePendingReviewCount(organizationId: string | null): number { function usePendingReviewCount(organizationId: string | null): number {
const [count, setCount] = useState(0) const [count, setCount] = useState(0)
useEffect(() => { useEffect(() => {
if (!organizationId) return if (!organizationId) {
setCount(0)
return
}
let cancelled = false let cancelled = false
const tick = async () => { const tick = async () => {
try { try {
@@ -40,10 +46,17 @@ function usePendingReviewCount(organizationId: string | null): number {
} }
} }
void tick() void tick()
const id = setInterval(tick, 12000) const id = setInterval(tick, 6000)
const refetch = () => void tick()
window.addEventListener(REVIEWS_CHANGED, refetch)
window.addEventListener('focus', refetch)
document.addEventListener('visibilitychange', refetch)
return () => { return () => {
cancelled = true cancelled = true
clearInterval(id) clearInterval(id)
window.removeEventListener(REVIEWS_CHANGED, refetch)
window.removeEventListener('focus', refetch)
document.removeEventListener('visibilitychange', refetch)
} }
}, [organizationId]) }, [organizationId])
return count return count
+8 -3
View File
@@ -10,7 +10,7 @@ import {
} from '@dnd-kit/core' } from '@dnd-kit/core'
import { Bot, Plus, Trash2 } from 'lucide-react' import { Bot, Plus, Trash2 } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell' import { AppShell, REVIEWS_CHANGED } from '@/components/AppShell'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@@ -514,8 +514,13 @@ function TaskDrawer({
onClick={() => onClick={() =>
act( act(
() => api.post('/api/assembler/runs', { seatId, workItemId: task.id }), () => api.post('/api/assembler/runs', { seatId, workItemId: task.id }),
'Dispatched — the proposal will land in the review inbox.', 'Dispatched — it will appear in the review inbox shortly.',
) ).then(() => {
// The proposal lands ~a few seconds later; nudge the review badge to refetch.
window.dispatchEvent(new Event(REVIEWS_CHANGED))
setTimeout(() => window.dispatchEvent(new Event(REVIEWS_CHANGED)), 4000)
setTimeout(() => window.dispatchEvent(new Event(REVIEWS_CHANGED)), 9000)
})
} }
> >
<Bot data-icon="inline-start" /> <Bot data-icon="inline-start" />
+2
View File
@@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { REVIEWS_CHANGED } from '@/components/AppShell'
import { AgentFace } from '@/components/AgentFace' import { AgentFace } from '@/components/AgentFace'
import { MarkdownEditor } from '@/components/MarkdownEditor' import { MarkdownEditor } from '@/components/MarkdownEditor'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@@ -169,6 +170,7 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str
toast.info('Sent back to the agent') toast.info('Sent back to the agent')
} }
onDecided(item.id) onDecided(item.id)
window.dispatchEvent(new Event(REVIEWS_CHANGED))
} catch (err) { } catch (err) {
toast.error((err as Error).message) toast.error((err as Error).message)
} finally { } finally {
@@ -94,12 +94,20 @@ internal sealed class AgentRunExecutor(
run.Complete(output, assembled.PrimaryAction, assembled.PrimaryActionRisk, result, completion.LatencyMs, clock.GetUtcNow()); run.Complete(output, assembled.PrimaryAction, assembled.PrimaryActionRisk, result, completion.LatencyMs, clock.GetUtcNow());
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
// Only a spec / story-breakdown agent proposes child stories. Other actions (code, design,
// tests) produce an artifact, not a backlog — so don't mistake a numbered list in their
// output (e.g. a list of file names) for child tasks.
var primarySkill = context.SkillKeys.Count > 0 ? context.SkillKeys[0] : null;
var childTitles = primarySkill is "spec-writing" or "story-breakdown"
? OutputParser.ExtractChildTitles(output)
: Array.Empty<string>();
// Hand the parsed action to the gate: autonomy vs risk → execute now or hold in review. // Hand the parsed action to the gate: autonomy vs risk → execute now or hold in review.
var gate = await actionGate.EvaluateAsync( var gate = await actionGate.EvaluateAsync(
new AgentActionProposal( new AgentActionProposal(
run.Id, run.SeatId, context.AgentId, run.WorkItemId, context.TeamId, context.OrganizationId, run.Id, run.SeatId, context.AgentId, run.WorkItemId, context.TeamId, context.OrganizationId,
context.Autonomy, assembled.PrimaryAction, assembled.PrimaryActionRisk, context.Autonomy, assembled.PrimaryAction, assembled.PrimaryActionRisk,
context.TaskTitle, output, OutputParser.ExtractChildTitles(output), assembled.Trace), context.TaskTitle, output, childTitles, assembled.Trace),
cancellationToken); cancellationToken);
logger.LogInformation( logger.LogInformation(
"Run {RunId}: {Action} ({Risk}) → {Outcome}.", "Run {RunId}: {Action} ({Risk}) → {Outcome}.",