diff --git a/core/reducer.ts b/core/reducer.ts index 766d128..179165c 100644 --- a/core/reducer.ts +++ b/core/reducer.ts @@ -262,10 +262,11 @@ function applyTaskCreated(state: SystemState, event: Extract, task: Task): void { + const ts = event.ts ?? Date.now(); const t = deepCloneTask(task); t.currentFenceToken = event.fenceToken; t.leasedTo = event.agentId; - t.leaseExpiresAt = event.ts + event.leaseTimeout; + t.leaseExpiresAt = ts + event.leaseTimeout; t.phase = event.phase; t.condition = "active"; t.retryAfter = null; @@ -273,7 +274,7 @@ function applyLeaseGranted(state: SystemState, event: Extract 0) { + const children = core.getChildren(task.id); + const pending = children.filter((c) => c.terminal === null); + if (pending.length > 0) { + return { + status: 422, + body: { + error: "children_pending", + message: + `Cannot mark done: ${pending.length} child(ren) still pending: ` + + pending.map((c) => c.id).join(", ") + + ". Cancel or complete them first.", + }, + }; + } + + // (c) Layer 2: completionRule="and" requires all terminal children to be "done" + if (task.completionRule === "and") { + const notDone = children.filter((c) => c.terminal !== "done"); + if (notDone.length > 0) { + return { + status: 422, + body: { + error: "children_not_done", + message: + `Task has completionRule='and' but ${notDone.length} child(ren) are not done: ` + + notDone.map((c) => c.id).join(", "), + }, + }; + } + } + } + + return null; +} + function applyDoneTransition( core: Core, config: Config, @@ -1732,6 +1800,10 @@ function applyDoneTransition( evidence?: string, stateRef?: StateRef, ): RouteResult { + // Pre-flight completion checks (children guard, stateRef guard) + const completionErr = verifyCompletion(core, task, stateRef); + if (completionErr) return completionErr; + if (task.phase === "execution" && task.condition === "active" && task.reviewConfig === null) { // Verify artifacts before allowing completion const verification = verifyArtifacts(task, config); @@ -2535,10 +2607,17 @@ function handleDecomposeCommit( } } - const parentPriority = (task.metadata["priority"] as string | undefined) ?? "medium"; - const metadata: Record = {}; + // Inherit all parent metadata, then override with child-specific values + const metadata: Record = { ...task.metadata }; + // Remove parent-specific bookkeeping that shouldn't propagate + delete metadata["createdBy"]; delete metadata["createdAt"]; + delete metadata["claimedAt"]; delete metadata["claimedBy"]; + delete metadata["claimSessionId"]; delete metadata["claimSource"]; + delete metadata["last_update"]; delete metadata["last_update_at"]; + // Apply child overrides if (child.assignee) metadata["assignee"] = child.assignee; if (child.reviewer) metadata["reviewer"] = child.reviewer; + const parentPriority = (task.metadata["priority"] as string | undefined) ?? "medium"; const childPriority = ("priority" in child ? (child as { priority?: string }).priority : undefined) ?? "medium"; metadata["priority"] = maxPriority(parentPriority, childPriority); @@ -2779,10 +2858,17 @@ function handleDecompose( } } - const parentPriority = (task.metadata["priority"] as string | undefined) ?? "medium"; - const metadata: Record = {}; + // Inherit all parent metadata, then override with child-specific values + const metadata: Record = { ...task.metadata }; + // Remove parent-specific bookkeeping that shouldn't propagate + delete metadata["createdBy"]; delete metadata["createdAt"]; + delete metadata["claimedAt"]; delete metadata["claimedBy"]; + delete metadata["claimSessionId"]; delete metadata["claimSource"]; + delete metadata["last_update"]; delete metadata["last_update_at"]; + // Apply child overrides if (child.assignee) metadata["assignee"] = child.assignee; if (child.reviewer) metadata["reviewer"] = child.reviewer; + const parentPriority = (task.metadata["priority"] as string | undefined) ?? "medium"; const childPriority = ("priority" in child ? (child as { priority?: string }).priority : undefined) ?? "medium"; metadata["priority"] = maxPriority(parentPriority, childPriority);