Merge ticket-T06 (T06 Storm Guard Isolation #23) into integration/v0.2

This commit is contained in:
BillyOutlast committed 2026-10-12 00:42:02 -04:00
commit e862a0087f
4 files changed
+137 -13

No files matched your search

@@ -6,6 +6,15 @@ import { emitStudioDebugEvent } from './DebugEvents';
/**
* The local loop guard: a circuit breaker for a BYOK agent.
*
* Scope is strictly single-request (T06 #23): one guard instance per
* `aiRequest.id` (see `loopGuardsRef` in Utils.js), counting only that
* request's own turns — consecutive identical calls (repeat), consecutive
* failures (error storm), turns without user input, and turns without
* project progress (repeat-without-progress, sub-agents only). A child's
* startup failure never touches its parent's streak and never constrains
* the parent: `spawnSubAgent` failures fail only their own `spawn_agent`
* tool call (`success:false`) so the parent stays alive for serial fallback.
*
* Ported from munder-difflin's `src/main/breaker.ts`, narrowed to what this
* editor can actually observe. Under BYOK there is no server, so nothing counts
* a runaway local agent: the hosted `repeated-tool-call-loop` error can never
@@ -44,6 +53,13 @@ export type LoopGuardDecision = {|
changed: boolean,
|};
/**
* T06 (#23) isolation scope marker: guards are per-request only. A test
* asserts this constant exists so the single-request + repeat-without-
* progress contract cannot silently regress to a shared/global guard.
*/
export const LOOP_GUARD_SCOPE: string = 'single-request';
export const DEFAULT_LOOP_GUARD_CONFIG = {
enabled: true,
/** Trips on the Nth consecutive identical call; N-1 are tolerated. */
+2 -2
View File
@@ -26,9 +26,9 @@
"sha256": "708de36210d000af56e795280eaa5dd450e32cedb4a54b7b031353a6cc4a679f"
},
"AiGeneration/Studio/LoopGuard.js": {
"bytes": 10420,
"bytes": 11296,
"origin": "newIDE/app/src/AiGeneration/Studio/LoopGuard.js",
"sha256": "09bde299559edc5b9c5f74d7605c7c4ef8ba0eeb4f044168171183f51676b86d"
"sha256": "8fd346b75556051e7f03a15066ab562168f2028d5fca028583d9e7449be9b1ee"
},
"AiGeneration/Studio/NudgePolicy.js": {
"bytes": 3821,
+24 -11
View File
@@ -254,7 +254,7 @@
!isCallApproved(functionCall)
);
@@ -554,13 +681,406 @@
@@ -554,13 +681,409 @@
}
}
@@ -567,6 +567,9 @@
+ });
+ } catch (error) {
+ console.error('Error while spawning a studio sub-agent:', error);
+ // T06 (#23): a child startup failure fails ONLY this tool call.
+ // It never touches the parent guard streak and never suspends or
+ // constrains the parent: the parent stays alive for serial fallback.
+ spawnResult = { error: 'The sub-agent could not be started.' };
+ }
+
@@ -662,7 +665,7 @@
// The "modified outside editor" callbacks each refresh the editor and can
// trigger an in-game editor hot reload. Firing them once per function
@@ -669,7 +1189,7 @@
@@ -669,7 +1192,7 @@
// Explorer sub-agent scripts are read-only: restrict their
// `run_script` to non-mutating functions (defense in depth).
runScriptReadOnly: isReadOnlyScriptContext,
@@ -671,15 +674,25 @@
name: functionCall.name,
arguments: functionCall.arguments,
call_id: functionCall.call_id,
@@ -761,13 +1281,55 @@
@@ -761,13 +1284,65 @@
return;
}
+ // Record outcomes for the loop guard's error-storm trip (a real success
+ // clears it; a failure advances it).
+ // T06 (#23) isolation: ONLY editor-dispatched calls feed the parent
+ // guard. Spawn failures, offline-docs, local-plan, no-project, role
+ // denials, and loop-guard warnings already have terminal results and
+ // must never advance the parent error streak nor constrain it — a
+ // child that fails to start fails its own call; the parent retries
+ // serially instead of suspending.
+ if (guard) {
+ const editorCallIds = new Set(
+ callsForEditor.map(functionCall => functionCall.call_id)
+ );
+ results.forEach(result => {
+ if (result.status !== 'finished') return;
+ if (!editorCallIds.has(result.call_id)) return;
+ if (result.success) guard.recordSuccess();
+ else guard.recordError();
+ });
@@ -732,7 +745,7 @@
} finally {
// Flush the coalesced editor notifications for everything modified in
// this batch (one hot reload instead of one per call). In `finally` so
@@ -787,7 +1349,9 @@
@@ -787,7 +1362,9 @@
}
// Release the lock so these calls can be retried if needed
@@ -743,7 +756,7 @@
functionCallsToProcess.forEach(functionCall => {
inFlightFunctionCallIdsRef.current.delete(
`${aiRequest.id}:${functionCall.call_id}`
@@ -824,9 +1388,12 @@
@@ -824,9 +1401,12 @@
getAssetStoreTagForNewObject,
generateEvents,
onSendEditorFunctionCallResults,
@@ -756,7 +769,7 @@
]
);
@@ -870,9 +1437,50 @@
@@ -870,9 +1450,50 @@
[onProcessFunctionCalls, allFunctionCallsToProcess]
);
@@ -807,7 +820,7 @@
};
};
@@ -977,7 +1585,6 @@
@@ -977,7 +1598,6 @@
editorFunctionCallResultsStorage,
isFetchingSuggestions,
setIsFetchingSuggestions,
@@ -815,7 +828,7 @@
setSelectedAiRequestId,
selectedAiRequest,
} = React.useContext(AiRequestContext);
@@ -1036,20 +1643,19 @@
@@ -1036,20 +1656,19 @@
// Then ask for some.
if (
!selectedAiRequest ||
@@ -847,7 +860,7 @@
// Check if there are tools being run. If so, no suggestions at this time.
const hasFunctionsCallsToProcess =
@@ -1139,7 +1745,7 @@
@@ -1139,7 +1758,7 @@
: null;
const preparedAiUserContent = await prepareAiUserContent({
getAuthorizationHeader,
@@ -856,7 +869,7 @@
simplifiedProjectJson,
projectSpecificExtensionsSummaryJson,
eventsJson: null,
@@ -1153,7 +1759,7 @@
@@ -1153,7 +1772,7 @@
const aiRequestWorkingForSuggestions = await getAiRequestSuggestions(
getAuthorizationHeader,
{
@@ -865,7 +878,7 @@
aiRequestId: selectedAiRequest.id,
suggestionsType: isLastMessageFunctionCallOutputProjectInitialization
? 'list-with-explanations'
@@ -1525,39 +2131,12 @@
@@ -1525,39 +2144,12 @@
[profile, setSelectedAiRequestId]
);
+95
View File
@@ -690,3 +690,98 @@ describe('integration: T05 suspended UI and resume (terminal status card)', () =
}
});
});
describe('integration: T06 storm guard isolation (child failure never suspends parent)', () => {
it('LoopGuard is single-request with repeat-without-progress isolation', async () => {
const { readFileSync } = await import('node:fs');
const { fileURLToPath } = await import('node:url');
const { dirname, resolve } = await import('node:path');
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
const src = readFileSync(
resolve(root, 'packages/ai-studio/AiGeneration/Studio/LoopGuard.js'),
'utf8'
);
assert.ok(src.includes('LOOP_GUARD_SCOPE'), 'LOOP_GUARD_SCOPE marker missing');
assert.ok(src.includes("'single-request'"), 'guard scope must be single-request');
assert.ok(src.includes('maxTurnsWithoutProgress'), 'repeat-without-progress trip missing');
assert.ok(
src.includes("child") || src.includes('parent'),
'isolation note must name child/parent separation'
);
});
it('child spawn startup failure fails only its tool call (success:false, no parent suspend)', async () => {
const { readFileSync } = await import('node:fs');
const { fileURLToPath } = await import('node:url');
const { dirname, resolve } = await import('node:path');
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
const patch14 = readFileSync(resolve(root, 'patches/14-ai-utils.patch'), 'utf8');
// Failure shape: failed tool call the model sees.
assert.ok(
patch14.includes('The sub-agent could not be started.'),
'spawn startup failure message missing'
);
// Isolation markers.
for (const marker of ['T06 (#23)', 'editorCallIds', 'spawnFailureResults']) {
assert.ok(patch14.includes(marker), `patch 14 missing T06 marker: ${marker}`);
}
// Parent guard feeds ONLY editor-dispatched calls — never spawn/local failures.
assert.ok(
patch14.includes('if (!editorCallIds.has(result.call_id)) return;'),
'parent guard must skip non-editor (child/local) results'
);
// The spawn failure path never suspends or constrains the parent.
const spawnAt = patch14.indexOf('The sub-agent could not be started.');
assert.ok(spawnAt !== -1, 'spawn failure site missing');
const spawnSlice = patch14.slice(Math.max(0, spawnAt - 2000), spawnAt + 2000);
assert.ok(!spawnSlice.includes('suspendAiRequest'), 'spawn failure must not suspend the parent');
assert.ok(!spawnSlice.includes("status: 'error'"), 'spawn failure must not mark the parent error');
assert.ok(!spawnSlice.includes('guard.recordError'), 'spawn failure must not touch the parent streak');
// Parent stays alive: the failure is recorded AND sent as a normal tool error.
assert.ok(
patch14.includes('spawnFailureResults.push(failure)'),
'spawn failure must be pushed as a tool result'
);
assert.ok(
patch14.includes('...spawnFailureResults,'),
'spawn failures must be sent to the model for serial fallback'
);
});
it('patch 14 still dry-runs clean with isolation (real I/O, anti-revert intact)', async () => {
const { spawnSync } = await import('node:child_process');
const fs = await import('node:fs');
const os = await import('node:os');
const path = await import('node:path');
const { fileURLToPath } = await import('node:url');
const root = path.dirname(
path.dirname(path.dirname(fileURLToPath(import.meta.url)))
);
const upstream =
process.env.UPSTREAM_DIR || path.join(root, 'review', 'GDevelop');
const rel = 'newIDE/app/src/AiGeneration/Utils.js';
assert.ok(
fs.existsSync(path.join(upstream, rel)),
`missing fixture ${rel} (set UPSTREAM_DIR)`
);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 't06-p14-'));
try {
const dest = path.join(dir, rel);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(path.join(upstream, rel), dest);
const r = spawnSync(
'patch',
['-p1', '-s', '--dry-run', '-i', path.join(root, 'patches/14-ai-utils.patch')],
{ cwd: dir, encoding: 'utf8' }
);
assert.equal(r.status, 0, `patch 14 dry-run failed:\n${r.stdout}\n${r.stderr}`);
const patchText = fs.readFileSync(path.join(root, 'patches/14-ai-utils.patch'), 'utf8');
assert.ok(
!/^-[^-].*onSaveProjectAsWithStorageProvider/m.test(patchText),
'patch 14 deletes upstream-new symbol onSaveProjectAsWithStorageProvider'
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});