Skip to content

Commit faf84b2

Browse files
committed
fix(ci): resolve remaining E2E instability root causes
- crossWindowSync: replace watch-fs marker wait with file content polling step to correctly verify browser-originated syncer writes - workspace DnD: hybrid rect (dnd-kit top + live DOM height) prevents stale cached heights from misclassifying center drops as reorder-after - agent cleanup: mergeWithDefaultAgent on read, partial-update undefined filter, deleteAgentDef temp-only guard with AgentInstanceService lifecycle cleanup, ref-based unmount-only cleanup in CreateNewAgentContent - add unit tests for default merge semantics and deleteAgentDef temp guard
1 parent d9a6cfe commit faf84b2

6 files changed

Lines changed: 189 additions & 56 deletions

File tree

features/crossWindowSync.feature

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Feature: Cross-Window Synchronization
2222
# Edit Index tiddler in window A via TW syncer (this triggers save to server → disk)
2323
When I execute TiddlyWiki code in browser view: "$tw.wiki.addTiddler(new $tw.Tiddler({title: 'Index', text: 'CrossWindowSyncTestContent123'}))"
2424
# Wait for the syncer to save the tiddler to disk via server, so window B sees it
25-
Then I wait for tiddler "Index" to be updated by watch-fs
25+
Then tiddler "Index" in workspace "wiki" should be saved to disk with text "CrossWindowSyncTestContent123"
2626

2727
# Open workspace in a new window (window B)
2828
When I open workspace "wiki" in a new window

features/stepDefinitions/wiki.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,43 @@ Then('I wait for tiddler {string} to be deleted by watch-fs', async function(thi
756756
);
757757
});
758758

759+
/**
760+
* Poll disk until a tiddler .tid file exists and contains expected text.
761+
* Replaces watch-fs log-marker waits for browser-originated writes, which are
762+
* self-writes through the syncer and correctly suppressed by watch-fs echo prevention.
763+
*/
764+
Then('tiddler {string} in workspace {string} should be saved to disk with text {string}', async function(
765+
this: ApplicationWorld,
766+
tiddlerTitle: string,
767+
workspaceName: string,
768+
expectedText: string,
769+
) {
770+
const wikiPath = getWikiTestWikiPath(this);
771+
const tiddlerPath = path.join(wikiPath, 'tiddlers', `${tiddlerTitle}.tid`);
772+
773+
try {
774+
await backOff(
775+
async () => {
776+
if (!await fs.pathExists(tiddlerPath)) {
777+
throw new Error(`Tiddler file not found yet: ${tiddlerPath}`);
778+
}
779+
const content = await fs.readFile(tiddlerPath, 'utf-8');
780+
if (!content.includes(expectedText)) {
781+
throw new Error(`Tiddler file does not contain expected text yet: ${tiddlerPath}`);
782+
}
783+
},
784+
BACKOFF_OPTIONS,
785+
);
786+
} catch {
787+
const exists = await fs.pathExists(tiddlerPath);
788+
const content = exists ? await fs.readFile(tiddlerPath, 'utf-8') : '(file does not exist)';
789+
throw new Error(
790+
`Tiddler "${tiddlerTitle}" was not saved to disk with text "${expectedText}" in workspace "${workspaceName}". ` +
791+
`Path: ${tiddlerPath}\nFile content:\n${content}`,
792+
);
793+
}
794+
});
795+
759796
// File manipulation step definitions
760797

761798
When('I create file {string} with content:', async function(this: ApplicationWorld, filePath: string, content: string) {

src/pages/Agent/TabContent/TabTypes/CreateNewAgentContent.tsx

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { AgentDefinition } from '@services/agentDefinition/interface';
77
import { AgentFrameworkConfig } from '@services/agentInstance/promptConcat/promptConcatSchema';
88
import useDebouncedCallback from 'beautiful-react-hooks/useDebouncedCallback';
99
import { nanoid } from 'nanoid';
10-
import React, { useCallback, useEffect, useState } from 'react';
10+
import React, { useCallback, useEffect, useRef, useState } from 'react';
1111
import { useTranslation } from 'react-i18next';
1212
import { TemplateSearch } from '../../components/Search/TemplateSearch';
1313
import { useTabStore } from '../../store/tabStore';
@@ -63,6 +63,16 @@ export const CreateNewAgentContent: React.FC<CreateNewAgentContentProps> = ({ ta
6363
const [previewAgentId, setPreviewAgentId] = useState<string | null>(null);
6464
const [isLoading, setIsLoading] = useState(false);
6565
const [promptSchema, setPromptSchema] = useState<RJSFSchema | null>(null);
66+
const temporaryAgentDefinitionIdReference = useRef<string | null>(tab.agentDefId ?? null);
67+
const previewAgentIdReference = useRef<string | null>(null);
68+
69+
useEffect(() => {
70+
temporaryAgentDefinitionIdReference.current = temporaryAgentDefinition?.id ?? tab.agentDefId ?? null;
71+
}, [tab.agentDefId, temporaryAgentDefinition?.id]);
72+
73+
useEffect(() => {
74+
previewAgentIdReference.current = previewAgentId;
75+
}, [previewAgentId]);
6676

6777
// Restore state from backend when component mounts
6878
useEffect(() => {
@@ -173,26 +183,30 @@ export const CreateNewAgentContent: React.FC<CreateNewAgentContentProps> = ({ ta
173183
// Cleanup when component unmounts or tab closes
174184
useEffect(() => {
175185
return () => {
176-
// Cleanup temporary agent definition and preview agent when tab closes
186+
// Cleanup temporary agent definition and preview agent when tab closes.
187+
// Delete preview agent instance BEFORE definition to avoid FK constraint failures.
177188
const cleanup = async () => {
178-
if (temporaryAgentDefinition?.id && temporaryAgentDefinition.id.startsWith('temp-')) {
189+
const currentPreviewAgentId = previewAgentIdReference.current;
190+
const currentTemporaryAgentDefinitionId = temporaryAgentDefinitionIdReference.current;
191+
192+
if (currentPreviewAgentId) {
179193
try {
180-
await window.service.agentDefinition.deleteAgentDef(temporaryAgentDefinition.id);
194+
await window.service.agentInstance.deleteAgent(currentPreviewAgentId);
181195
} catch (error) {
182-
console.error('Failed to cleanup temporary agent definition:', error);
196+
console.error('Failed to cleanup preview agent:', error);
183197
}
184198
}
185-
if (previewAgentId) {
199+
if (currentTemporaryAgentDefinitionId?.startsWith('temp-')) {
186200
try {
187-
await window.service.agentInstance.deleteAgent(previewAgentId);
201+
await window.service.agentDefinition.deleteAgentDef(currentTemporaryAgentDefinitionId);
188202
} catch (error) {
189-
console.error('Failed to cleanup preview agent:', error);
203+
console.error('Failed to cleanup temporary agent definition:', error);
190204
}
191205
}
192206
};
193207
void cleanup();
194208
};
195-
}, [temporaryAgentDefinition?.id, previewAgentId]);
209+
}, []);
196210

197211
const handleNext = async () => {
198212
if (currentStep < STEPS.length - 1) {

src/pages/Main/WorkspaceIconAndSelector/SortableWorkspaceSelectorList.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -833,13 +833,17 @@ export function SortableWorkspaceSelectorList({ workspacesList, showSideBarText,
833833
const canGroup = !isSameGroup && isGroupableWorkspace(activeWorkspace) && isGroupableWorkspace(resolvedOverWorkspace);
834834
let intent: TDragIntent;
835835

836+
const liveRect = _getLiveSortableRect(resolvedOverId, overRect);
837+
// Keep dnd-kit's coordinate frame for pointer-vs-top math, but refresh
838+
// height from the DOM so stale cached heights do not skew zone boundaries.
839+
const intentRect = { top: overRect.top, height: liveRect?.height ?? overRect.height };
836840
const reorderIntent = getReorderIntentFromPointer({
837841
pointerY: referenceY,
838-
rect: overRect,
842+
rect: intentRect,
839843
});
840-
const relativeY = Math.min(Math.max(referenceY - overRect.top, 0), overRect.height);
841-
const beforeBoundary = overRect.height / 3;
842-
const afterBoundary = overRect.height - beforeBoundary;
844+
const relativeY = Math.min(Math.max(referenceY - intentRect.top, 0), intentRect.height);
845+
const beforeBoundary = intentRect.height / 3;
846+
const afterBoundary = intentRect.height - beforeBoundary;
843847

844848
if (relativeY > beforeBoundary && relativeY < afterBoundary && canGroup) {
845849
intent = 'group';

src/services/agentDefinition/__tests__/index.test.ts

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import defaultAgents from '@services/agentInstance/agentFrameworks/taskAgents.js
44
import type { IAgentInstanceService } from '@services/agentInstance/interface';
55
import { container } from '@services/container';
66
import type { IDatabaseService } from '@services/database/interface';
7-
import { AgentDefinitionEntity } from '@services/database/schema/agent';
7+
import { AgentDefinitionEntity, AgentInstanceEntity } from '@services/database/schema/agent';
88
import serviceIdentifier from '@services/serviceIdentifier';
99
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
1010

@@ -99,13 +99,14 @@ describe('AgentDefinitionService getAgentDefs integration', () => {
9999
expect(typeof exampleAgent!.agentFrameworkConfig).toBe('object');
100100
});
101101

102-
it('should return only database data without fallback to defaultAgents', async () => {
102+
it('should merge default agent fields when database stores only user overrides', async () => {
103103
// Get the real database repository that the service uses
104104
const realDatabaseService = container.get<IDatabaseService>(serviceIdentifier.Database);
105105
const realDataSource = await realDatabaseService.getDatabase('agent');
106106
const agentDefRepo = realDataSource.getRepository(AgentDefinitionEntity);
107107

108-
// Save only minimal record (id only) to test new behavior
108+
// Save only minimal record (id only). Nullable fields mean "inherit from
109+
// the built-in default agent", while the raw DB row stays minimal.
109110
const example = (defaultAgents as unknown as AgentDefinition[])[0];
110111
await agentDefRepo.save({
111112
id: example.id,
@@ -115,15 +116,14 @@ describe('AgentDefinitionService getAgentDefs integration', () => {
115116

116117
const found = defs.find(d => d.id === example.id);
117118
expect(found).toBeDefined();
118-
// With new behavior, only id should be present, other fields should be undefined or empty
119119
expect(found!.id).toBe(example.id);
120-
expect(found!.agentFrameworkID).toBeUndefined();
121-
expect(found!.name).toBeUndefined();
122-
expect(found!.description).toBeUndefined();
123-
expect(found!.avatarUrl).toBeUndefined();
124-
expect(found!.agentFrameworkConfig).toEqual({});
125-
expect(found!.aiApiConfig).toBeUndefined();
126-
expect(found!.agentTools).toBeUndefined();
120+
expect(found!.agentFrameworkID).toBe(example.agentFrameworkID);
121+
expect(found!.name).toBe(example.name);
122+
expect(found!.description).toBe(example.description);
123+
expect(found!.avatarUrl).toBe(example.avatarUrl);
124+
expect(found!.agentFrameworkConfig).toEqual(example.agentFrameworkConfig);
125+
expect(found!.aiApiConfig).toEqual(example.aiApiConfig);
126+
expect(found!.agentTools).toEqual(example.agentTools);
127127
});
128128

129129
it('should have only id field populated when directly querying database entity for build-in agent', async () => {
@@ -188,4 +188,56 @@ describe('AgentDefinitionService getAgentDefs integration', () => {
188188
const templates = await agentDefinitionService.getAgentTemplates();
189189
expect(templates.length).toBe((defaultAgents as unknown as AgentDefinition[]).length);
190190
});
191+
192+
it('should reject deleteAgentDef for non-temporary IDs', async () => {
193+
await expect(agentDefinitionService.deleteAgentDef('real-agent-id')).rejects.toThrow(
194+
'Refusing to delete non-temporary agent definition via cleanup path',
195+
);
196+
});
197+
198+
it('should delete temporary agent definition and dependent instances', async () => {
199+
const realDatabaseService = container.get<IDatabaseService>(serviceIdentifier.Database);
200+
const realDataSource = await realDatabaseService.getDatabase('agent');
201+
const agentDefRepo = realDataSource.getRepository(AgentDefinitionEntity);
202+
const instanceRepo = realDataSource.getRepository(AgentInstanceEntity);
203+
204+
// Create a temp agent definition
205+
const tempId = 'temp-test-agent';
206+
await agentDefRepo.save({
207+
id: tempId,
208+
name: 'Temp Agent',
209+
agentFrameworkID: 'test-framework',
210+
});
211+
212+
// Create a dependent instance
213+
const instanceId = 'test-instance';
214+
await instanceRepo.save({
215+
id: instanceId,
216+
agentDefId: tempId,
217+
name: 'Test Instance',
218+
status: { state: 'completed' },
219+
});
220+
221+
// Mock agentInstanceService.deleteAgent to perform DB deletion without full lifecycle teardown
222+
const serviceWithPrivate = agentDefinitionService as unknown as {
223+
agentInstanceService: { deleteAgent: (...args: unknown[]) => Promise<unknown> };
224+
};
225+
const deleteAgentMock = vi.fn().mockImplementation(async (instanceId: string) => {
226+
await instanceRepo.delete(instanceId);
227+
});
228+
serviceWithPrivate.agentInstanceService.deleteAgent = deleteAgentMock;
229+
230+
await agentDefinitionService.deleteAgentDef(tempId);
231+
232+
// Verify instance service was called for cleanup
233+
expect(deleteAgentMock).toHaveBeenCalledWith(instanceId);
234+
235+
// Verify definition is removed
236+
const remainingDef = await agentDefRepo.findOne({ where: { id: tempId } });
237+
expect(remainingDef).toBeNull();
238+
239+
// Verify instance is removed
240+
const remainingInstance = await instanceRepo.findOne({ where: { id: instanceId } });
241+
expect(remainingInstance).toBeNull();
242+
});
191243
});

src/services/agentDefinition/index.ts

Lines changed: 57 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,34 @@ import defaultAgents from '@services/agentInstance/agentFrameworks/taskAgents.js
88
import type { IAgentInstanceService } from '@services/agentInstance/interface';
99
import { container } from '@services/container';
1010
import type { IDatabaseService } from '@services/database/interface';
11-
import { AgentDefinitionEntity } from '@services/database/schema/agent';
11+
import { AgentDefinitionEntity, AgentInstanceEntity, ScheduledTaskEntity } from '@services/database/schema/agent';
1212
import { logger } from '@services/libs/log';
1313
import serviceIdentifier from '@services/serviceIdentifier';
1414
import { getWikiAgentTemplates } from './getAgentDefinitionTemplatesFromWikis';
1515
import type { AgentDefinition, IAgentDefinitionService } from './interface';
1616

17+
const defaultAgentsList = defaultAgents as AgentDefinition[];
18+
19+
function mergeTextOverride(value: string | null | undefined, fallback: string | undefined): string | undefined {
20+
return value?.trim() ? value : fallback;
21+
}
22+
23+
function mergeWithDefaultAgent(entity: AgentDefinitionEntity): AgentDefinition {
24+
const defaultAgent = defaultAgentsList.find(agent => agent.id === entity.id);
25+
26+
return {
27+
id: entity.id,
28+
name: mergeTextOverride(entity.name, defaultAgent?.name),
29+
description: mergeTextOverride(entity.description, defaultAgent?.description),
30+
avatarUrl: mergeTextOverride(entity.avatarUrl, defaultAgent?.avatarUrl),
31+
agentFrameworkID: mergeTextOverride(entity.agentFrameworkID, defaultAgent?.agentFrameworkID),
32+
agentFrameworkConfig: entity.agentFrameworkConfig ?? defaultAgent?.agentFrameworkConfig ?? {},
33+
aiApiConfig: entity.aiApiConfig ?? defaultAgent?.aiApiConfig,
34+
agentTools: entity.agentTools ?? defaultAgent?.agentTools,
35+
heartbeat: entity.heartbeat ?? defaultAgent?.heartbeat,
36+
};
37+
}
38+
1739
@injectable()
1840
export class AgentDefinitionService implements IAgentDefinitionService {
1941
@inject(serviceIdentifier.Database)
@@ -70,7 +92,6 @@ export class AgentDefinitionService implements IAgentDefinitionService {
7092
const existingCount = await this.agentDefRepository.count();
7193
if (existingCount === 0) {
7294
logger.info('Agent database is empty, initializing with default agents');
73-
const defaultAgentsList = defaultAgents as AgentDefinition[];
7495
// Create agent definition entities with complete data from taskAgents.json
7596
const agentDefinitionEntities = defaultAgentsList.map(defaultAgent =>
7697
this.agentDefRepository!.create({
@@ -144,7 +165,10 @@ export class AgentDefinitionService implements IAgentDefinitionService {
144165
throw new Error(`Agent definition not found: ${agent.id}`);
145166
}
146167

147-
const pickedProperties = pick(agent, ['name', 'description', 'avatarUrl', 'agentFrameworkID', 'agentFrameworkConfig', 'aiApiConfig', 'heartbeat']);
168+
const pickedProperties = Object.fromEntries(
169+
Object.entries(pick(agent, ['name', 'description', 'avatarUrl', 'agentFrameworkID', 'agentFrameworkConfig', 'aiApiConfig', 'heartbeat']))
170+
.filter(([, value]) => value !== undefined),
171+
);
148172
Object.assign(existingAgent, pickedProperties);
149173

150174
await this.agentDefRepository!.save(existingAgent);
@@ -167,17 +191,7 @@ export class AgentDefinitionService implements IAgentDefinitionService {
167191
const agentDefsFromDB = await this.agentDefRepository!.find();
168192

169193
// Convert entities to agent definitions
170-
const agentDefs: AgentDefinition[] = agentDefsFromDB.map(entity => ({
171-
id: entity.id,
172-
name: entity.name || undefined,
173-
description: entity.description || undefined,
174-
avatarUrl: entity.avatarUrl || undefined,
175-
agentFrameworkID: entity.agentFrameworkID || undefined,
176-
agentFrameworkConfig: entity.agentFrameworkConfig || {},
177-
aiApiConfig: entity.aiApiConfig || undefined,
178-
agentTools: entity.agentTools || undefined,
179-
heartbeat: entity.heartbeat || undefined,
180-
}));
194+
const agentDefs: AgentDefinition[] = agentDefsFromDB.map(mergeWithDefaultAgent);
181195

182196
return agentDefs;
183197
} catch (error) {
@@ -209,32 +223,45 @@ export class AgentDefinitionService implements IAgentDefinitionService {
209223
}
210224

211225
// Convert entity to agent definition
212-
const agentDefinition: AgentDefinition = {
213-
id: entity.id,
214-
name: entity.name || undefined,
215-
description: entity.description || undefined,
216-
avatarUrl: entity.avatarUrl || undefined,
217-
agentFrameworkID: entity.agentFrameworkID || undefined,
218-
agentFrameworkConfig: entity.agentFrameworkConfig || {},
219-
aiApiConfig: entity.aiApiConfig || undefined,
220-
agentTools: entity.agentTools || undefined,
221-
heartbeat: entity.heartbeat || undefined,
222-
};
223-
224-
return agentDefinition;
226+
return mergeWithDefaultAgent(entity);
225227
} catch (error) {
226228
logger.error(`Failed to get agent definition: ${error as Error}`);
227229
throw error;
228230
}
229231
}
230232

231-
// Delete agent definition and all associated instances
232-
// Note: This will delegate instance deletion to AgentInstanceService
233+
// Delete agent definition and all associated instances.
234+
// Deletes dependent agent instances first to avoid FK constraint failures.
233235
public async deleteAgentDef(id: string): Promise<void> {
234236
this.ensureRepositories();
235237

238+
if (!id.startsWith('temp-')) {
239+
throw new Error(`Refusing to delete non-temporary agent definition via cleanup path: ${id}`);
240+
}
241+
236242
try {
237-
// Delete the agent definition - instances will be handled by cleanup processes
243+
// Delete dependent agent instances before the definition. Use AgentInstanceService
244+
// so runtime resources (heartbeats, alarms, scheduled tasks, MCP clients, and
245+
// subscriptions) are cleaned up together with database rows.
246+
const instanceRepo = this.dataSource!.getRepository(AgentInstanceEntity);
247+
const scheduledTaskRepo = this.dataSource!.getRepository(ScheduledTaskEntity);
248+
const agentInstanceService = container.get<IAgentInstanceService>(serviceIdentifier.AgentInstance);
249+
250+
const dependentInstances = await instanceRepo.find({
251+
where: { agentDefId: id },
252+
});
253+
254+
for (const instance of dependentInstances) {
255+
await agentInstanceService.deleteAgent(instance.id);
256+
}
257+
258+
await scheduledTaskRepo.delete({ agentDefinitionId: id });
259+
260+
if (dependentInstances.length > 0) {
261+
logger.info(`Cleaned up ${dependentInstances.length} dependent agent instances before deleting definition: ${id}`);
262+
}
263+
264+
// Now safe to delete the agent definition
238265
await this.agentDefRepository!.delete(id);
239266
logger.info(`Deleted agent definition: ${id}`);
240267
} catch (error) {
@@ -248,7 +275,6 @@ export class AgentDefinitionService implements IAgentDefinitionService {
248275
const templates: AgentDefinition[] = [];
249276

250277
// Add default agents from JSON
251-
const defaultAgentsList = defaultAgents as AgentDefinition[];
252278
templates.push(...defaultAgentsList);
253279

254280
// Get templates from active main workspaces

0 commit comments

Comments
 (0)