Skip to content

Commit e1c5ec7

Browse files
committed
chore(core): P3 self-review R1 — align worktree suffix wording + 6 test gaps
R1 of pre-push adversarial self-review on PR #5034 surfaced 6 confirmed findings across 6 diverse lenses (correctness / security / reuse-altitude / self-invariant / consumer-breakage / test-gaps). Each finding faced 2 independent skeptics defaulting to refuted=true; 6 survived majority challenge. Source code: - Worktree-preserved suffix wording now matches AgentTool's formatWorktreeSuffix (agent.ts:1700-1719) verbatim, including the `git worktree add <path> <branch>` recovery hint for the directory- removed-but-branch-preserved race. Test gaps closed: - schema-mode success after 1 nudge (round-2 args captured) - schema-mode success after 2 nudges (round-3 args captured) - schema-mode + agentType together — floor disallowedTools still unioned - schema-mode caller-abort takes priority over the StructuredOutput terminal error (signal.aborted check at workflow-orchestrator.ts:489-490) - override path dispose() runs in finally on the success path - override path dispose() runs in finally on the terminate-mode-error path Declined R1 finding: negative tests for invalid opt types (schema/model/ agentType passed null/number/empty-string). Adding upfront type validation is scope creep — upstream does not, P1/P2 do not, and the workflow tool is model-authored where these inputs are extremely unlikely. Existing AJV / SubagentManager downstream errors are descriptive enough. Will revisit if R2 makes a stronger case. 166/166 tests pass (workflow suite + adjacent + workflow-orchestrator). typecheck + lint clean across packages/core, packages/cli, integration-tests, sdk, webui.
1 parent 6b4d721 commit e1c5ec7

2 files changed

Lines changed: 255 additions & 2 deletions

File tree

packages/core/src/agents/runtime/workflow-orchestrator.test.ts

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,4 +1062,247 @@ describe('WorkflowOrchestrator P3 — agentType / model / isolation / schema', (
10621062
await dispatch('extract', { schema: { type: 'object' } });
10631063
expect(calls[0].eventEmitterAttached).toBe(true);
10641064
});
1065+
1066+
// R1 self-review (P3-T6 gap): the schema-mode state machine in
1067+
// createSchemaEventEmitter has a `state.result === null` guard that
1068+
// allows the model to RECOVER from earlier failed attempts. Only the
1069+
// 0-failure and 3+-failure boundaries were tested; the 1-failure and
1070+
// 2-failure recovery transitions had no coverage. A regression
1071+
// inverting the guard, or one where pendingArgs cleanup discards the
1072+
// recovered args, would slip past the previous tests.
1073+
it('schema-mode: success on 2nd attempt (1 nudge then valid) captures round-2 args', async () => {
1074+
const { config } = fakeConfigWithMgr({
1075+
onCreate: async () => ({
1076+
finalText: '',
1077+
terminateMode: 'CANCELLED',
1078+
runWithEmitter: (emitter) => {
1079+
// Round 1: invalid args, validation fails.
1080+
emitter.emit('tool_call', {
1081+
subagentId: 'sub',
1082+
round: 1,
1083+
callId: 'c1',
1084+
name: 'structured_output',
1085+
args: { bad: 'shape' },
1086+
description: '',
1087+
isOutputMarkdown: false,
1088+
timestamp: 1,
1089+
});
1090+
emitter.emit('tool_result', {
1091+
subagentId: 'sub',
1092+
round: 1,
1093+
callId: 'c1',
1094+
name: 'structured_output',
1095+
success: false,
1096+
error: 'validation failed',
1097+
responseParts: [],
1098+
resultDisplay: '',
1099+
durationMs: 1,
1100+
timestamp: 1,
1101+
});
1102+
// Round 2: corrected args, validation passes. Must be captured
1103+
// as the result, not the round-1 args.
1104+
emitter.emit('tool_call', {
1105+
subagentId: 'sub',
1106+
round: 2,
1107+
callId: 'c2',
1108+
name: 'structured_output',
1109+
args: { ok: true, attempt: 2 },
1110+
description: '',
1111+
isOutputMarkdown: false,
1112+
timestamp: 2,
1113+
});
1114+
emitter.emit('tool_result', {
1115+
subagentId: 'sub',
1116+
round: 2,
1117+
callId: 'c2',
1118+
name: 'structured_output',
1119+
success: true,
1120+
responseParts: [],
1121+
resultDisplay: '',
1122+
durationMs: 1,
1123+
timestamp: 2,
1124+
});
1125+
},
1126+
}),
1127+
});
1128+
const dispatch = createProductionDispatch(config);
1129+
const result = await dispatch('extract', {
1130+
schema: { type: 'object' },
1131+
});
1132+
expect(result).toEqual({ ok: true, attempt: 2 });
1133+
});
1134+
1135+
it('schema-mode: success on 3rd attempt (2 nudges then valid) captures round-3 args', async () => {
1136+
const { config } = fakeConfigWithMgr({
1137+
onCreate: async () => ({
1138+
finalText: '',
1139+
terminateMode: 'CANCELLED',
1140+
runWithEmitter: (emitter) => {
1141+
for (let r = 1; r <= 2; r++) {
1142+
emitter.emit('tool_call', {
1143+
subagentId: 'sub',
1144+
round: r,
1145+
callId: `c${r}`,
1146+
name: 'structured_output',
1147+
args: { bad: r },
1148+
description: '',
1149+
isOutputMarkdown: false,
1150+
timestamp: r,
1151+
});
1152+
emitter.emit('tool_result', {
1153+
subagentId: 'sub',
1154+
round: r,
1155+
callId: `c${r}`,
1156+
name: 'structured_output',
1157+
success: false,
1158+
error: 'validation failed',
1159+
responseParts: [],
1160+
resultDisplay: '',
1161+
durationMs: 1,
1162+
timestamp: r,
1163+
});
1164+
}
1165+
emitter.emit('tool_call', {
1166+
subagentId: 'sub',
1167+
round: 3,
1168+
callId: 'c3',
1169+
name: 'structured_output',
1170+
args: { ok: true, attempt: 3 },
1171+
description: '',
1172+
isOutputMarkdown: false,
1173+
timestamp: 3,
1174+
});
1175+
emitter.emit('tool_result', {
1176+
subagentId: 'sub',
1177+
round: 3,
1178+
callId: 'c3',
1179+
name: 'structured_output',
1180+
success: true,
1181+
responseParts: [],
1182+
resultDisplay: '',
1183+
durationMs: 1,
1184+
timestamp: 3,
1185+
});
1186+
},
1187+
}),
1188+
});
1189+
const dispatch = createProductionDispatch(config);
1190+
const result = await dispatch('extract', {
1191+
schema: { type: 'object' },
1192+
});
1193+
expect(result).toEqual({ ok: true, attempt: 3 });
1194+
});
1195+
1196+
// R1 self-review (P3-T6 gap): the disallowed-tool floor invariant
1197+
// declares "ALWAYS applies regardless of agentType". The
1198+
// single-option tests above exercise floor+agentType and schema
1199+
// separately, but not their composition. A regression making the
1200+
// floor conditional on schema being unset (e.g. mistakenly moving
1201+
// the union inside an `if (opts.schema === undefined)` branch)
1202+
// would pass the existing tests.
1203+
it('schema-mode + agentType: floor disallowedTools still unioned', async () => {
1204+
const { config, calls } = fakeConfigWithMgr({
1205+
findSubagentByName: async () => ({
1206+
name: 'Permissive',
1207+
description: 'allows SendMessage explicitly',
1208+
systemPrompt: 'permissive',
1209+
level: 'project',
1210+
disallowedTools: ['Foo'],
1211+
}),
1212+
onCreate: async (_call, _ee) => ({
1213+
finalText: '',
1214+
terminateMode: 'CANCELLED',
1215+
runWithEmitter: (emitter) => {
1216+
emitter.emit('tool_call', {
1217+
subagentId: 'sub',
1218+
round: 1,
1219+
callId: 'c1',
1220+
name: 'structured_output',
1221+
args: { ok: true },
1222+
description: '',
1223+
isOutputMarkdown: false,
1224+
timestamp: 1,
1225+
});
1226+
emitter.emit('tool_result', {
1227+
subagentId: 'sub',
1228+
round: 1,
1229+
callId: 'c1',
1230+
name: 'structured_output',
1231+
success: true,
1232+
responseParts: [],
1233+
resultDisplay: '',
1234+
durationMs: 1,
1235+
timestamp: 1,
1236+
});
1237+
},
1238+
}),
1239+
});
1240+
const dispatch = createProductionDispatch(config);
1241+
await dispatch('extract', {
1242+
agentType: 'Permissive',
1243+
schema: { type: 'object' },
1244+
});
1245+
const disallowed = calls[0].config.disallowedTools ?? [];
1246+
expect(disallowed).toEqual(
1247+
expect.arrayContaining(['Foo', 'send_message', 'exit_plan_mode']),
1248+
);
1249+
});
1250+
1251+
// R1 self-review (P3-T6 gap): caller-abort taking priority over
1252+
// "completed without StructuredOutput" is a contract boundary the
1253+
// dispatch enforces at the explicit `if (signal?.aborted)` check.
1254+
// Without this test, a refactor removing the check would silently
1255+
// convert user-cancelled schema runs into schema-failure errors.
1256+
it('schema-mode: caller abort takes priority over terminal "no structured_output" error', async () => {
1257+
const externalAbort = new AbortController();
1258+
const { config } = fakeConfigWithMgr({
1259+
onCreate: async () => ({
1260+
finalText: '',
1261+
terminateMode: 'CANCELLED',
1262+
runWithEmitter: (_emitter) => {
1263+
// Caller-side abort fires while the subagent is in flight but
1264+
// before any structured_output call. After execute() returns,
1265+
// signal.aborted is true AND state.result is still null — the
1266+
// dispatch must throw AbortError, not the StructuredOutput
1267+
// terminal error.
1268+
externalAbort.abort();
1269+
},
1270+
}),
1271+
});
1272+
const dispatch = createProductionDispatch(config, externalAbort.signal);
1273+
await expect(
1274+
dispatch('extract', { schema: { type: 'object' } }),
1275+
).rejects.toThrow(/aborted/i);
1276+
});
1277+
1278+
// R1 self-review (P3-T6 gap): the override path's dispose() must run
1279+
// in a finally so per-agent MCP processes / hooks don't leak past the
1280+
// dispatch — including on the exception path. The test harness has a
1281+
// `disposed` counter that no test asserts on; this closes that gap on
1282+
// both the success and the thrown-from-execute paths.
1283+
it('override path always calls dispose() on the success path', async () => {
1284+
// Use model-only override (no agentType) so we don't go through the
1285+
// SubagentManager resolution path. The ephemeral-default branch
1286+
// still routes through createAgentHeadless and therefore dispose().
1287+
const helper = fakeConfigWithMgr({
1288+
onCreate: async () => ({ finalText: 'done', terminateMode: 'GOAL' }),
1289+
});
1290+
const dispatch = createProductionDispatch(helper.config);
1291+
await dispatch('hi', { model: 'qwen3-max' });
1292+
expect(helper.disposed).toBeGreaterThanOrEqual(1);
1293+
});
1294+
1295+
it('override path always calls dispose() even when terminateMode is non-GOAL', async () => {
1296+
const helper = fakeConfigWithMgr({
1297+
onCreate: async () => ({
1298+
finalText: '',
1299+
terminateMode: 'ERROR', // non-GOAL → dispatch throws after execute
1300+
}),
1301+
});
1302+
const dispatch = createProductionDispatch(helper.config);
1303+
await expect(dispatch('hi', { model: 'qwen3-max' })).rejects.toThrow(
1304+
/terminate mode: ERROR/,
1305+
);
1306+
expect(helper.disposed).toBeGreaterThanOrEqual(1);
1307+
});
10651308
});

packages/core/src/agents/runtime/workflow-orchestrator.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -764,11 +764,21 @@ function appendWorktreePreservedSuffix(
764764
finalText: string,
765765
preserved: WorktreePreservedInfo,
766766
): string {
767+
// Wording mirrors AgentTool's formatWorktreeSuffix (agent.ts:1700-1719)
768+
// verbatim so a user who has seen both tools' worktree-preserved messages
769+
// sees one consistent shape. AgentTool's version includes the
770+
// `git worktree add <path> <branch>` recovery hint for the
771+
// directory-removed-but-branch-preserved race; the Workflow path hits the
772+
// same race (cleanupWorkflowWorktree's result.branchPreserved branch) so
773+
// it gets the same hint.
767774
const sep = finalText.endsWith('\n') ? '\n' : '\n\n';
768775
if (preserved.path) {
769-
return `${finalText}${sep}[worktree preserved at ${preserved.path} on branch ${preserved.branch}]`;
776+
return `${finalText}${sep}[worktree preserved: ${preserved.path} (branch ${preserved.branch})]`;
770777
}
771-
return `${finalText}${sep}[worktree branch preserved: ${preserved.branch} (directory already removed)]`;
778+
return (
779+
`${finalText}${sep}[worktree directory removed; branch ${preserved.branch} ` +
780+
`preserved — recover with \`git worktree add <path> ${preserved.branch}\`]`
781+
);
772782
}
773783

774784
/**

0 commit comments

Comments
 (0)