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
460 changes: 454 additions & 6 deletions apps/server/src/mcp/toolkits/sessions/handlers.test.ts

Large diffs are not rendered by default.

154 changes: 133 additions & 21 deletions apps/server/src/mcp/toolkits/sessions/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
checkReportSupersession,
CommandId,
isProviderAvailable,
MessageId,
Expand All @@ -10,16 +11,19 @@ import {
READ_REPORT_MAX_CHARS,
type ReadReportInput,
type ReadSessionResult,
reportAlreadySupersededMessage,
type RuntimeMode,
SESSION_SPAWN_MAX_CHILDREN,
SESSION_SPAWN_MAX_DEPTH,
SessionOrchestrationDeniedError,
SessionOrchestrationInvalidInputError,
SessionOrchestrationOperationError,
SessionOrchestrationReportAlreadySupersededError,
SessionOrchestrationUnavailableError,
SessionOrchestrationWorktreeNotEmptyError,
type ServerProvider,
type SessionUsageSnapshot,
supersededReportNotice,
type SettleSessionInput,
type SettleSessionWorktreeOutcome,
type SpawnSessionInput,
Expand Down Expand Up @@ -144,6 +148,15 @@ export const canReadThreadReports = (input: {
export const REPORT_NOT_ACCESSIBLE_MESSAGE =
"Report not accessible: it does not exist, has not been posted yet, or belongs to a session outside this session's read scope (own spawned sessions and their siblings).";

// One message for both ways a supersedesReportId can fail to resolve — no
// such report, or a report on another thread. Amending another session's
// report is not a weaker version of amending your own: a report is a
// session's account of its own work, so only the thread that posted one may
// replace it. Saying which of the two went wrong would also turn post_report
// into a probe for which report ids exist elsewhere.
export const SUPERSEDES_REPORT_NOT_FOUND_MESSAGE =
"supersedesReportId does not name a report posted by this session. Pass the reportId returned by your own earlier post_report call on this thread; a report can only be amended by the session that posted it.";

const isHighSurrogate = (code: number) => code >= 0xd800 && code <= 0xdbff;
const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff;

Expand Down Expand Up @@ -210,7 +223,7 @@ export const buildPingSessionSnapshot = (input: {
// contract holds across providers without the parent having to remember to
// ask for it. post_report is what wakes the parent up.
const SPAWNED_SESSION_REPORT_INSTRUCTIONS =
"\n\n---\nYou were spawned by another Phoenix agent session to do the work above. When the work is complete — or you determine it cannot be completed — call the `post_report` tool exactly once with status (success/failure/partial), a concise markdown summary of what you did, and any artifacts (files, branches, PR URLs). If the summary is long, also pass a 1-3 sentence `abstract`. The report is delivered to the session that spawned you.";
"\n\n---\nYou were spawned by another Phoenix agent session to do the work above. When the work is complete — or you determine it cannot be completed — call the `post_report` tool exactly once with status (success/failure/partial), a concise markdown summary of what you did, and any artifacts (files, branches, PR URLs). If the summary is long, also pass a 1-3 sentence `abstract`. The report is delivered to the session that spawned you.\n\nIf you receive a further instruction AFTER you have already posted your report, do the new work and then post an AMENDING report: call `post_report` again with `supersedesReportId` set to the reportId of the report you are replacing. The amended report becomes the record. Never claim in a report that you did something you had not yet done when that report was written — describe what the late instruction was and what you did about it.";

// Enough to tell the caller what is at stake without turning a refusal into a
// transcript of a large working tree.
Expand Down Expand Up @@ -1004,6 +1017,86 @@ export const make = Effect.gen(function* () {
const postReport = Effect.fn("SessionsToolkit.postReport")(function* (input: PostReportInput) {
const scope = yield* requireSessionsCapability;
const caller = yield* requireShell(scope.threadId);

// Friendly pre-check. The decider runs the same check against the folded
// read model and is the authority — this one exists so the common case
// fails with a specific, structured error instead of a dispatch failure.
// Reading the whole thread's reports (rather than one row) is what makes
// the chain-head answer available.
const supersedesReportId = input.supersedesReportId;
if (supersedesReportId !== undefined) {
const reports = yield* reportRepository
.listByThreadId({ threadId: scope.threadId })
.pipe(Effect.mapError(operationError("Failed to read this session's reports")));
const check = checkReportSupersession(reports, supersedesReportId);
if (check._tag === "unknown-report") {
return yield* new SessionOrchestrationInvalidInputError({
message: SUPERSEDES_REPORT_NOT_FOUND_MESSAGE,
});
}
if (check._tag === "already-superseded") {
return yield* new SessionOrchestrationReportAlreadySupersededError({
message: reportAlreadySupersededMessage({
reportId: supersedesReportId,
supersededByReportId: check.supersededByReportId,
chainHeadReportId: check.chainHeadReportId,
}),
reportId: supersedesReportId,
supersededByReportId: check.supersededByReportId,
chainHeadReportId: check.chainHeadReportId,
});
}
}

/**
* Turn a lost amendment race into the same actionable error the pre-check
* would have given.
*
* Between the pre-check and the decider, another amendment can take the
* chain head. The decider rejects this command — correctly — but through
* dispatch that surfaces as a generic operation failure, which tells the
* caller nothing about where to re-attach. So on failure, re-read the
* chain: if it moved, report that; otherwise the dispatch failed for some
* other reason and that error stands.
*/
const withSupersessionRaceDetail = (
effect: Effect.Effect<{ readonly sequence: number }, SessionOrchestrationOperationError>,
): Effect.Effect<
{ readonly sequence: number },
SessionOrchestrationOperationError | SessionOrchestrationReportAlreadySupersededError
> =>
supersedesReportId === undefined
? effect
: effect.pipe(
Effect.catch((dispatchError) =>
reportRepository.listByThreadId({ threadId: scope.threadId }).pipe(
// The recheck is diagnostic only; if it fails, the original
// dispatch error is still the truthful thing to report.
Effect.catch(() => Effect.succeed<ReadonlyArray<ProjectionThreadReport>>([])),
Effect.flatMap((reports) => {
const check = checkReportSupersession(reports, supersedesReportId);
return check._tag === "already-superseded"
? Effect.fail<
| SessionOrchestrationOperationError
| SessionOrchestrationReportAlreadySupersededError
>(
new SessionOrchestrationReportAlreadySupersededError({
message: reportAlreadySupersededMessage({
reportId: supersedesReportId,
supersededByReportId: check.supersededByReportId,
chainHeadReportId: check.chainHeadReportId,
}),
reportId: supersedesReportId,
supersededByReportId: check.supersededByReportId,
chainHeadReportId: check.chainHeadReportId,
}),
)
: Effect.fail(dispatchError);
}),
),
),
);

const createdAt = yield* nowIso;
const reportId = yield* randomUUID;
// Captured now, not agent-supplied: what this session cost by the time
Expand All @@ -1013,26 +1106,31 @@ export const make = Effect.gen(function* () {
createdAt: caller.createdAt,
latestTurn: caller.latestTurn,
});
yield* enqueue(
engine.dispatch({
type: "thread.report.post",
commandId: yield* serverCommandId("mcp-post-report"),
threadId: scope.threadId,
reportId,
status: input.status,
title: input.title,
summary: input.summary,
...(input.abstract !== undefined ? { abstract: input.abstract } : {}),
artifacts: input.artifacts ?? [],
...(input.findings !== undefined ? { findings: input.findings } : {}),
...(input.validation !== undefined ? { validation: input.validation } : {}),
...(input.recommendation !== undefined ? { recommendation: input.recommendation } : {}),
...(input.completionPercent !== undefined
? { completionPercent: input.completionPercent }
: {}),
usage,
createdAt,
}),
yield* withSupersessionRaceDetail(
enqueue(
engine.dispatch({
type: "thread.report.post",
commandId: yield* serverCommandId("mcp-post-report"),
threadId: scope.threadId,
reportId,
status: input.status,
title: input.title,
summary: input.summary,
...(input.abstract !== undefined ? { abstract: input.abstract } : {}),
artifacts: input.artifacts ?? [],
...(input.findings !== undefined ? { findings: input.findings } : {}),
...(input.validation !== undefined ? { validation: input.validation } : {}),
...(input.recommendation !== undefined ? { recommendation: input.recommendation } : {}),
...(input.completionPercent !== undefined
? { completionPercent: input.completionPercent }
: {}),
usage,
...(input.supersedesReportId !== undefined
? { supersedesReportId: input.supersedesReportId }
: {}),
createdAt,
}),
),
);
return {
reportId,
Expand All @@ -1049,6 +1147,9 @@ export const make = Effect.gen(function* () {
? { completionPercent: input.completionPercent }
: {}),
usage,
...(input.supersedesReportId !== undefined
? { supersedesReportId: input.supersedesReportId }
: {}),
// post_report is by definition the agent speaking for itself; only the
// reactor's terminal reports are system-origin.
origin: "agent" as const,
Expand Down Expand Up @@ -1149,6 +1250,17 @@ export const make = Effect.gen(function* () {
: {}),
artifacts: report.artifacts,
...(report.usage !== undefined ? { usage: report.usage } : {}),
...(report.supersedesReportId !== null
? { supersedesReportId: report.supersedesReportId }
: {}),
// A caller paging an old body must learn a newer account exists — both
// as an id it can follow and as prose it cannot skim past.
...(report.supersededByReportId !== undefined
? {
supersededByReportId: report.supersededByReportId,
supersededNotice: supersededReportNotice(report.supersededByReportId),
}
: {}),
createdAt: report.createdAt,
};
});
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/mcp/toolkits/sessions/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export const SettleSessionTool = Tool.make("settle_session", {

export const ReadReportTool = Tool.make("read_report", {
description:
"Read the full body of a completion report posted by a session this session spawned, or by a sibling session (one spawned by the same parent). Pass the reportId from a report envelope, or a threadId to get that thread's latest report. Large reports paginate via offset/maxChars; the result also carries the report's origin (agent vs Phoenix-synthesized) and full findings/validation/recommendation.",
"Read the full body of a completion report posted by a session this session spawned, or by a sibling session (one spawned by the same parent). Pass the reportId from a report envelope, or a threadId to get that thread's latest report. Large reports paginate via offset/maxChars; the result also carries the report's origin (agent vs Phoenix-synthesized) and full findings/validation/recommendation. A report that has been amended comes back with supersededByReportId and a supersededNotice: read that newer report instead, it is the session's current account.",
parameters: ReadReportInput,
success: ReadReportResult,
failure: SessionOrchestrationError,
Expand All @@ -131,7 +131,7 @@ export const ReadReportTool = Tool.make("read_report", {

export const PostReportTool = Tool.make("post_report", {
description:
"Post a completion report for THIS session's work: status, a concise markdown summary, and any artifacts (files, branches, PR URLs). For a long summary, also pass a 1-3 sentence abstract: large reports are delivered to the spawning session as a compact envelope, and the abstract is what it sees first. If another session spawned this one, the report is delivered to it automatically; the user also sees the report as a card in this thread. Call once when your assigned work is finished (or clearly failed). Optionally include machine-readable fields: findings (array of {title, severity: info|low|medium|high|critical, detail?}), validation ({performed: string[], gaps: string[]}), recommendation (short string), and completionPercent (0-100). The result also carries a best-effort usage snapshot (tokens, turn count, elapsed time since spawn) captured automatically at post time — this is not something you supply.",
"Post a completion report for THIS session's work: status, a concise markdown summary, and any artifacts (files, branches, PR URLs). For a long summary, also pass a 1-3 sentence abstract: large reports are delivered to the spawning session as a compact envelope, and the abstract is what it sees first. If another session spawned this one, the report is delivered to it automatically; the user also sees the report as a card in this thread. Call once when your assigned work is finished (or clearly failed). If an instruction reaches you AFTER you already reported, do the work and post an AMENDING report: call post_report again with supersedesReportId set to your earlier reportId (it must be a report you posted on this thread, and one that has not itself been superseded — amendments form a single linear chain, so always amend the newest report). The amendment becomes the session's current report and is delivered to the spawning session marked as an amendment; the superseded report stays readable and is flagged as superseded. Never describe work in a report as done when it was not done at the time that report was written. Optionally include machine-readable fields: findings (array of {title, severity: info|low|medium|high|critical, detail?}), validation ({performed: string[], gaps: string[]}), recommendation (short string), and completionPercent (0-100). The result also carries a best-effort usage snapshot (tokens, turn count, elapsed time since spawn) captured automatically at post time — this is not something you supply.",
parameters: PostReportInput,
success: SessionReport,
failure: SessionOrchestrationError,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1109,6 +1109,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
? { usage: event.payload.report.usage }
: {}),
origin: event.payload.report.origin,
supersedesReportId: event.payload.report.supersedesReportId ?? null,
createdAt: event.payload.report.createdAt,
});
return;
Expand Down
25 changes: 25 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
OrchestrationThreadDetailSnapshot,
ProjectScript,
SessionReportArtifact,
TrimmedNonEmptyString,
TurnId,
type OrchestrationCheckpointSummary,
type OrchestrationLatestTurn,
Expand Down Expand Up @@ -96,10 +97,14 @@ const ProjectionThreadReportDbRowSchema = ProjectionThreadReport.mapFields((fiel
"summary",
"abstract",
"origin",
"supersedesReportId",
"createdAt",
]),
{
artifacts: Schema.fromJsonString(Schema.Array(SessionReportArtifact)),
// Derived reverse amendment link; NULL unless a later report on the
// thread names this one.
supersededByReportId: Schema.NullOr(TrimmedNonEmptyString),
// Optional findings/validation/recommendation/completionPercent,
// stored together as one JSON column. Decoded leniently (see
// decodeStructuredReportFields) rather than through the schema, so a
Expand Down Expand Up @@ -392,6 +397,10 @@ function mapReportRow(
artifacts: row.artifacts,
...decodeStructuredReportFields(row.structuredJson),
origin: row.origin,
...(row.supersedesReportId !== null ? { supersedesReportId: row.supersedesReportId } : {}),
...(row.supersededByReportId !== null
? { supersededByReportId: row.supersededByReportId }
: {}),
createdAt: row.createdAt,
};
}
Expand Down Expand Up @@ -631,6 +640,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
artifacts_json AS "artifacts",
structured_json AS "structuredJson",
origin,
supersedes_report_id AS "supersedesReportId",
(
SELECT amendment.report_id
FROM projection_thread_reports AS amendment
WHERE amendment.supersedes_report_id = projection_thread_reports.report_id
ORDER BY amendment.created_at ASC, amendment.report_id ASC
LIMIT 1
) AS "supersededByReportId",
created_at AS "createdAt"
FROM projection_thread_reports
ORDER BY thread_id ASC, created_at ASC, report_id ASC
Expand Down Expand Up @@ -1136,6 +1153,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
artifacts_json AS "artifacts",
structured_json AS "structuredJson",
origin,
supersedes_report_id AS "supersedesReportId",
(
SELECT amendment.report_id
FROM projection_thread_reports AS amendment
WHERE amendment.supersedes_report_id = projection_thread_reports.report_id
ORDER BY amendment.created_at ASC, amendment.report_id ASC
LIMIT 1
) AS "supersededByReportId",
created_at AS "createdAt"
FROM projection_thread_reports
WHERE thread_id = ${threadId}
Expand Down
Loading
Loading