Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e1b562b
feat: native subagent & workflow observability (Agents panel, quiet t…
t3dotgg Aug 2, 2026
9be170a
feat(web): agent spawn CTA row replaces live strip and inline workflo…
t3dotgg Aug 2, 2026
16cdc33
fix(web): honest workflow status, shell exclusion, flat panel rows
t3dotgg Aug 2, 2026
4622c3a
fix(web): spawn CTA rows survive turn folding and work-group overflow
t3dotgg Aug 2, 2026
3ee8054
fix(web): all in-flight subagent states present as Working
t3dotgg Aug 2, 2026
4947acc
fix(web): show spawn CTA during the run, anchored at the spawn point
t3dotgg Aug 2, 2026
8ebdb72
feat(web): sidebar liveness beyond the turn — Working and Monitoring
t3dotgg Aug 2, 2026
3573c9c
fix(web): standard subagents join the roster — task-type denylist
t3dotgg Aug 2, 2026
95c2047
fix: Codex collab children reach the CTA and panel with real names
t3dotgg Aug 2, 2026
0984bab
fix: idle Codex agents present as settled; names survive progress rows
t3dotgg Aug 2, 2026
23f75ed
fix(server): never intercept the Codex root thread as its own child
t3dotgg Aug 2, 2026
eb0fd9c
fix(web): one CTA per Claude parallel batch — pin membership at first…
t3dotgg Aug 2, 2026
a779285
fix(server): keep subagent-owned Claude traffic out of the parent tra…
t3dotgg Aug 2, 2026
9262161
fix: subagent-internal shells attributed to their owning agent
t3dotgg Aug 2, 2026
b16ee95
feat(web): workflow phase rail + read-only script access
t3dotgg Aug 2, 2026
a266b19
feat: stop-everything interrupt + collapsible phase sections
t3dotgg Aug 2, 2026
8d20b68
fix: CI + review round — lint, service conventions, TOCTOU, stop binding
t3dotgg Aug 2, 2026
239a013
fix: effect-diagnostics conformance for CI Check
t3dotgg Aug 2, 2026
77fa3f4
refactor(server): canonical ThreadBackgroundLiveness module, visible …
t3dotgg Aug 2, 2026
3585e63
refactor(server): canonical make/layer exports + bucket reclassificat…
t3dotgg Aug 2, 2026
2758593
refactor(contracts): structured reason on OrchestrationGetWorkflowScr…
t3dotgg Aug 3, 2026
25de5b7
feat(web): background-work banner above composer with stop action
t3dotgg Aug 3, 2026
3b84a19
feat: per-subagent model + reasoning effort, persisted and shown in p…
t3dotgg Aug 3, 2026
4b6d276
fix: external review round — fold correctness, legacy rows, Codex bat…
t3dotgg Aug 3, 2026
5ff1638
fix: terminal enrichment, Codex status linkage, mobile task.updated c…
t3dotgg Aug 4, 2026
5d68958
feat(server): server-stamped task data shapes (stacked on #5219) (#5316)
t3dotgg Aug 4, 2026
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
88 changes: 87 additions & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ interface WorkLogEntry {
interface DerivedWorkLogEntry extends WorkLogEntry {
activityKind: OrchestrationThreadActivity["kind"];
collapseKey?: string;
/** Grouping key for subagent lifecycle rows (one row per agent). */
taskId?: string;
}

type RawThreadFeedEntry =
Expand Down Expand Up @@ -235,6 +237,57 @@ function resolvePendingUserInputAnswer(
return normalizeDraftAnswer(draft?.selectedOptionLabel);
}

/** Codex children settle via task.updated (idle/failed/interrupted), never
* task.completed — these rows are mobile's only terminal signal for them. */
const MOBILE_TERMINAL_UPDATE_STATUSES: ReadonlySet<string> = new Set([
"idle",
"completed",
"failed",
"cancelled",
"interrupted",
]);

function isTerminalBypassUpdate(activity: OrchestrationThreadActivity): boolean {
if (activity.kind !== "task.updated") {
return false;
}
const payload =
activity.payload && typeof activity.payload === "object"
? (activity.payload as Record<string, unknown>)
: null;
return (
payload?.timelineBypass === true &&
typeof payload.status === "string" &&
MOBILE_TERMINAL_UPDATE_STATUSES.has(payload.status)
);
}

/**
* Quiet-timeline guarantee (mirrors web's session-logic): agent-internal
* activity lives in the Agents sheet, not the work log. Terminal rows are
* kept — with no Agents surface on mobile they are the terminal signal
* (a surface that hides rows must keep its own terminal signal). That means
* task.completed (Claude) AND terminal bypassed task.updated (Codex, whose
* children never emit task.completed — review finding).
*/
function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean {
const payload =
activity.payload && typeof activity.payload === "object"
? (activity.payload as Record<string, unknown>)
: null;
if (!payload) {
return false;
}
if (
payload.timelineBypass === true &&
activity.kind !== "task.completed" &&
!isTerminalBypassUpdate(activity)
) {
return true;
}
return typeof payload.agentId === "string" && payload.agentId.trim().length > 0;
}

function deriveWorkLogEntries(
activities: ReadonlyArray<OrchestrationThreadActivity>,
): DerivedWorkLogEntry[] {
Expand All @@ -243,9 +296,13 @@ function deriveWorkLogEntries(
for (const activity of ordered) {
if (activity.kind === "tool.started") continue;
if (activity.kind === "task.started") continue;
// Terminal bypassed updates pass: Codex children's only terminal signal.
if (activity.kind === "task.updated" && !isTerminalBypassUpdate(activity)) continue;
Comment thread
cursor[bot] marked this conversation as resolved.
if (activity.kind === "tool.progress") continue;
if (activity.kind === "context-window.updated") continue;
if (activity.summary === "Checkpoint captured") continue;
if (isPlanBoundaryToolActivity(activity)) continue;
if (isAgentInternalActivity(activity)) continue;
entries.push(toDerivedWorkLogEntry(activity));
}
return collapseDerivedWorkLogEntries(entries);
Expand All @@ -271,7 +328,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
const commandPreview = extractToolCommand(payload);
const changedFiles = extractChangedFiles(payload);
const title = extractToolTitle(payload);
const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed";
// task.updated included: terminal bypassed updates (Codex children's only
// terminal signal) must carry task identity so they collapse per child
// instead of stacking anonymous "Task idle" rows.
const isTaskActivity =
activity.kind === "task.progress" ||
activity.kind === "task.completed" ||
activity.kind === "task.updated";
const taskSummary =
isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0
? payload.summary
Expand All @@ -284,10 +347,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
? payload.detail
: null;
const taskLabel = taskSummary || taskDetailAsLabel;
const taskId =
isTaskActivity && typeof payload?.taskId === "string" && payload.taskId.length > 0
? payload.taskId
: undefined;
const entry: DerivedWorkLogEntry = {
id: activity.id,
createdAt: activity.createdAt,
turnId: activity.turnId,
...(taskId ? { taskId } : {}),
label: taskLabel || activity.summary,
tone:
activity.kind === "task.progress"
Expand Down Expand Up @@ -352,7 +420,25 @@ function collapseDerivedWorkLogEntries(
entries: ReadonlyArray<DerivedWorkLogEntry>,
): DerivedWorkLogEntry[] {
const collapsed: DerivedWorkLogEntry[] = [];
// Subagent rows collapse by identity, not adjacency (quiet-timeline
// guarantee; mirrors web's session-logic).
const taskRowIndex = new Map<string, number>();
for (const entry of entries) {
const isTaskRow =
entry.taskId !== undefined &&
(entry.activityKind === "task.progress" ||
entry.activityKind === "task.completed" ||
entry.activityKind === "task.updated");
if (isTaskRow && entry.taskId !== undefined) {
const existingIndex = taskRowIndex.get(entry.taskId);
if (existingIndex !== undefined) {
collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry);
continue;
}
taskRowIndex.set(entry.taskId, collapsed.length);
collapsed.push(entry);
continue;
}
const previous = collapsed.at(-1);
if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityRe
import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";
import * as ThreadBackgroundLiveness from "../src/orchestration/ThreadBackgroundLiveness.ts";
import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts";
import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts";
import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts";
Expand Down Expand Up @@ -305,7 +306,7 @@ export const makeOrchestrationIntegrationHarness = (
checkpointStoreLayer,
providerLayer,
RuntimeReceiptBusTest,
);
).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer));
const serverSettingsLayer = ServerSettingsService.layerTest();
const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
Layer.provideMerge(runtimeServicesLayer),
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];
*/
export const RPC_REQUIRED_SCOPES = {
[ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,
[ORCHESTRATION_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope,
[ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,
[ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope,
[ORCHESTRATION_WS_METHODS.searchThreads]: AuthOrchestrationReadScope,
Expand Down
63 changes: 63 additions & 0 deletions apps/server/src/orchestration/ActivityPayloadProjection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vite-plus/test";
import type { OrchestrationThreadActivity } from "@t3tools/contracts";
import { projectActivityPayload } from "./ActivityPayloadProjection.ts";

function activity(payload: Record<string, unknown>): OrchestrationThreadActivity {
return {
id: "activity-1",
tone: "tool",
kind: "tool.completed",
summary: "Tool",
payload,
turnId: null,
createdAt: "2026-08-01T10:00:00.000Z",
} as unknown as OrchestrationThreadActivity;
}

/**
* Wire-survival regression: the slimming pass rewrites payload.data but must
* never strip the top-level per-agent fields the subagent fold depends on.
* If slimming ever moves to an allowlist over the whole payload, these
* assertions are the tripwire.
*/
describe("projectActivityPayload agent-field survival", () => {
it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => {
const projected = projectActivityPayload(
activity({
itemType: "command_execution",
agentId: "task-123",
parentToolUseId: "toolu_abc",
data: {
toolName: "Bash",
input: { command: "ls" },
command: "ls",
rawOutput: { content: "x".repeat(10) },
somethingClientNeverReads: { big: "blob" },
},
}),
);
const payload = projected.payload as Record<string, unknown>;
expect(payload.agentId).toBe("task-123");
expect(payload.parentToolUseId).toBe("toolu_abc");
// Slimming itself still applies to data.
const data = payload.data as Record<string, unknown>;
expect(data.somethingClientNeverReads).toBeUndefined();
});

it("passes task lifecycle payloads (no data field) through untouched", () => {
const source = activity({
taskId: "task-9",
title: "Audit auth",
role: "explorer",
model: "opus",
effort: "high",
workflowName: "audit-flow",
phases: [{ index: 0, title: "Audit" }],
typedUsage: { totalTokens: 1200 },
runHandles: { runId: "run-1", scriptPath: "/tmp/wf.js" },
timelineBypass: true,
});
const projected = projectActivityPayload(source);
expect(projected.payload).toEqual(source.payload);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { CheckpointReactorLive } from "./CheckpointReactor.ts";
import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts";
import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
Expand Down Expand Up @@ -294,13 +295,15 @@ describe("CheckpointReactor", () => {
);
const orchestrationLayer = OrchestrationEngineLive.pipe(
Layer.provide(OrchestrationProjectionSnapshotQueryLive),
Layer.provide(ThreadBackgroundLiveness.layer),
Layer.provide(OrchestrationProjectionPipelineLive),
Layer.provide(OrchestrationEventStoreLive),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
Layer.provide(RepositoryIdentityResolver.layer),
Layer.provide(SqlitePersistenceMemory),
);
const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
Layer.provide(ThreadBackgroundLiveness.layer),
Layer.provide(RepositoryIdentityResolver.layer),
Layer.provide(SqlitePersistenceMemory),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes
import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
import {
OrchestrationProjectionPipeline,
Expand All @@ -55,6 +56,7 @@ async function createOrchestrationSystem() {
),
OrchestrationProjectionSnapshotQueryLive,
).pipe(
Layer.provide(ThreadBackgroundLiveness.layer),
Layer.provide(OrchestrationEventStoreLive),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
Layer.provide(RepositoryIdentityResolver.layer),
Expand Down Expand Up @@ -817,6 +819,7 @@ describe("OrchestrationEngine", () => {
const runtime = ManagedRuntime.make(
OrchestrationEngineLive.pipe(
Layer.provide(OrchestrationProjectionSnapshotQueryLive),
Layer.provide(ThreadBackgroundLiveness.layer),
Layer.provide(OrchestrationProjectionPipelineLive),
Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
Expand Down Expand Up @@ -922,6 +925,7 @@ describe("OrchestrationEngine", () => {
const runtime = ManagedRuntime.make(
OrchestrationEngineLive.pipe(
Layer.provide(OrchestrationProjectionSnapshotQueryLive),
Layer.provide(ThreadBackgroundLiveness.layer),
Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
Layer.provide(OrchestrationEventStoreLive),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
Expand Down Expand Up @@ -1065,6 +1069,7 @@ describe("OrchestrationEngine", () => {
const runtime = ManagedRuntime.make(
OrchestrationEngineLive.pipe(
Layer.provide(OrchestrationProjectionSnapshotQueryLive),
Layer.provide(ThreadBackgroundLiveness.layer),
Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
OrchestrationProjectionPipelineLive,
} from "./ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
import { ServerConfig } from "../../config.ts";
Expand Down Expand Up @@ -2664,6 +2665,7 @@ it.effect("restores pending turn-start metadata across projection pipeline resta
const engineLayer = it.layer(
OrchestrationEngineLive.pipe(
Layer.provide(OrchestrationProjectionSnapshotQueryLive),
Layer.provide(ThreadBackgroundLiveness.layer),
Layer.provide(OrchestrationProjectionPipelineLive),
Layer.provide(OrchestrationEventStoreLive),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";

const asProjectId = (value: string): ProjectId => ProjectId.make(value);
Expand All @@ -27,6 +28,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val

const projectionSnapshotLayer = it.layer(
OrchestrationProjectionSnapshotQueryLive.pipe(
Layer.provide(ThreadBackgroundLiveness.layer),
Layer.provideMerge(RepositoryIdentityResolver.layer),
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provideMerge(NodeServices.layer),
Expand Down Expand Up @@ -441,6 +443,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
hasPendingApprovals: true,
hasPendingUserInput: false,
hasActionableProposedPlan: false,
backgroundLiveness: null,
},
]);

Expand Down Expand Up @@ -1823,6 +1826,7 @@ it.effect(
() => {
const resolveCalls: string[] = [];
const layer = OrchestrationProjectionSnapshotQueryLive.pipe(
Layer.provide(ThreadBackgroundLiveness.layer),
Layer.provideMerge(
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
resolve: (cwd: string) =>
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
type ProjectionRepositoryError,
} from "../../persistence/Errors.ts";
import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts";
import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";
import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts";
import { ProjectionState } from "../../persistence/Services/ProjectionState.ts";
import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts";
Expand Down Expand Up @@ -308,6 +309,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st
}

const makeProjectionSnapshotQuery = Effect.gen(function* () {
const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService;
const sql = yield* SqlClient.SqlClient;
const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const repositoryIdentityResolutionConcurrency = 4;
Expand Down Expand Up @@ -1671,6 +1673,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
hasPendingApprovals: row.pendingApprovalCount > 0,
hasPendingUserInput: row.pendingUserInputCount > 0,
hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
row.threadId,
),
} satisfies OrchestrationThreadShell)
: Result.failVoid,
),
Expand Down Expand Up @@ -1810,6 +1815,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
hasPendingApprovals: row.pendingApprovalCount > 0,
hasPendingUserInput: row.pendingUserInputCount > 0,
hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
row.threadId,
),
}),
),
updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
Expand Down Expand Up @@ -2081,6 +2089,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
hasPendingApprovals: threadRow.value.pendingApprovalCount > 0,
hasPendingUserInput: threadRow.value.pendingUserInputCount > 0,
hasActionableProposedPlan: threadRow.value.hasActionableProposedPlan > 0,
backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
threadRow.value.threadId,
),
} satisfies OrchestrationThreadShell);
});

Expand Down
Loading
Loading