Skip to content

Commit 36ca9a7

Browse files
authored
feat: Implement ProcessTrackingService (#740)
1 parent 16138bd commit 36ca9a7

File tree

19 files changed

+1109
-63
lines changed

19 files changed

+1109
-63
lines changed

apps/twig/src/main/di/container.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { FoldersService } from "../services/folders/service.js";
1414
import { FsService } from "../services/fs/service.js";
1515
import { GitService } from "../services/git/service.js";
1616
import { OAuthService } from "../services/oauth/service.js";
17+
import { ProcessTrackingService } from "../services/process-tracking/service.js";
1718
import { ShellService } from "../services/shell/service.js";
1819
import { TaskLinkService } from "../services/task-link/service.js";
1920
import { UIService } from "../services/ui/service.js";
@@ -39,6 +40,7 @@ container.bind(MAIN_TOKENS.FoldersService).to(FoldersService);
3940
container.bind(MAIN_TOKENS.FsService).to(FsService);
4041
container.bind(MAIN_TOKENS.GitService).to(GitService);
4142
container.bind(MAIN_TOKENS.OAuthService).to(OAuthService);
43+
container.bind(MAIN_TOKENS.ProcessTrackingService).to(ProcessTrackingService);
4244
container.bind(MAIN_TOKENS.ShellService).to(ShellService);
4345
container.bind(MAIN_TOKENS.UIService).to(UIService);
4446
container.bind(MAIN_TOKENS.UpdatesService).to(UpdatesService);

apps/twig/src/main/di/tokens.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const MAIN_TOKENS = Object.freeze({
2020
GitService: Symbol.for("Main.GitService"),
2121
DeepLinkService: Symbol.for("Main.DeepLinkService"),
2222
OAuthService: Symbol.for("Main.OAuthService"),
23+
ProcessTrackingService: Symbol.for("Main.ProcessTrackingService"),
2324
ShellService: Symbol.for("Main.ShellService"),
2425
UIService: Symbol.for("Main.UIService"),
2526
UpdatesService: Symbol.for("Main.UpdatesService"),
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { execSync } from "node:child_process";
2+
import { platform } from "node:os";
3+
import { logger } from "./logger.js";
4+
5+
const log = logger.scope("process-utils");
6+
7+
/**
8+
* Kill a process and all its children by killing the process group.
9+
* On Unix, we use process.kill(-pid) to kill the entire process group.
10+
* On Windows, we use taskkill with /T flag to kill the process tree.
11+
*/
12+
export function killProcessTree(pid: number): void {
13+
try {
14+
if (platform() === "win32") {
15+
// Windows: use taskkill with /T to kill process tree
16+
execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" });
17+
} else {
18+
// Unix: kill the process group by using negative PID
19+
// This sends SIGTERM to all processes in the group
20+
try {
21+
process.kill(-pid, "SIGTERM");
22+
} catch {
23+
// If SIGTERM fails (process may have already exited), try SIGKILL
24+
try {
25+
process.kill(-pid, "SIGKILL");
26+
} catch (err) {
27+
log.warn(`Failed to kill process group for PID ${pid}`, err);
28+
}
29+
}
30+
}
31+
} catch (err) {
32+
log.warn(`Failed to kill process tree for PID ${pid}`, err);
33+
}
34+
}
35+
36+
/**
37+
* Check if a process is alive using signal 0.
38+
*/
39+
export function isProcessAlive(pid: number): boolean {
40+
try {
41+
process.kill(pid, 0);
42+
return true;
43+
} catch {
44+
return false;
45+
}
46+
}

apps/twig/src/main/services/agent/service.ts

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ import {
1919
import { getLlmGatewayUrl } from "@posthog/agent/posthog-api";
2020
import type { OnLogCallback } from "@posthog/agent/types";
2121
import { app } from "electron";
22-
import { injectable, preDestroy } from "inversify";
22+
import { inject, injectable, preDestroy } from "inversify";
2323
import type { ExecutionMode } from "@/shared/types.js";
2424
import type { AcpMessage } from "../../../shared/types/session-events.js";
25+
import { MAIN_TOKENS } from "../../di/tokens.js";
2526
import { logger } from "../../lib/logger.js";
2627
import { TypedEventEmitter } from "../../lib/typed-event-emitter.js";
28+
import type { ProcessTrackingService } from "../process-tracking/service.js";
2729
import {
2830
AgentServiceEvent,
2931
type AgentServiceEvents,
@@ -185,6 +187,7 @@ interface ManagedSession {
185187
config: SessionConfig;
186188
interruptReason?: InterruptReason;
187189
needsRecreation: boolean;
190+
recreationPromise?: Promise<ManagedSession>;
188191
promptPending: boolean;
189192
pendingContext?: string;
190193
availableModels?: Array<{
@@ -214,6 +217,15 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
214217
private sessions = new Map<string, ManagedSession>();
215218
private currentToken: string | null = null;
216219
private pendingPermissions = new Map<string, PendingPermission>();
220+
private processTracking: ProcessTrackingService;
221+
222+
constructor(
223+
@inject(MAIN_TOKENS.ProcessTrackingService)
224+
processTracking: ProcessTrackingService,
225+
) {
226+
super();
227+
this.processTracking = processTracking;
228+
}
217229

218230
public updateToken(newToken: string): void {
219231
this.currentToken = newToken;
@@ -394,6 +406,9 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
394406
return existing;
395407
}
396408

409+
// Kill any lingering processes from previous runs of this task
410+
this.processTracking.killByTaskId(taskId);
411+
397412
// Clean up any prior session for this taskRunId before creating a new one
398413
await this.cleanupSession(taskRunId);
399414
}
@@ -413,7 +428,26 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
413428
});
414429

415430
try {
416-
const acpConnection = await agent.run(taskId, taskRunId);
431+
const acpConnection = await agent.run(taskId, taskRunId, {
432+
processCallbacks: {
433+
onProcessSpawned: (info) => {
434+
this.processTracking.register(
435+
info.pid,
436+
"agent",
437+
`agent:${taskRunId}`,
438+
{
439+
taskRunId,
440+
taskId,
441+
command: info.command,
442+
},
443+
taskId,
444+
);
445+
},
446+
onProcessExited: (pid) => {
447+
this.processTracking.unregister(pid, "agent-exited");
448+
},
449+
},
450+
});
417451
const { clientStreams } = acpConnection;
418452

419453
const connection = this.createClientConnection(
@@ -572,10 +606,18 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
572606

573607
// Recreate session if marked (token was refreshed while session was active)
574608
if (session.needsRecreation) {
575-
log.info("Recreating session before prompt (token refreshed)", {
576-
sessionId,
577-
});
578-
session = await this.recreateSession(sessionId);
609+
if (!session.recreationPromise) {
610+
log.info("Recreating session before prompt (token refreshed)", {
611+
sessionId,
612+
});
613+
session.recreationPromise = this.recreateSession(sessionId).finally(
614+
() => {
615+
const s = this.sessions.get(sessionId);
616+
if (s) s.recreationPromise = undefined;
617+
},
618+
);
619+
}
620+
session = await session.recreationPromise;
579621
}
580622

581623
// Prepend pending context if present
@@ -636,6 +678,14 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
636678
}
637679
}
638680

681+
async cancelSessionsByTaskId(taskId: string): Promise<void> {
682+
for (const [taskRunId, session] of this.sessions) {
683+
if (session.taskId === taskId) {
684+
await this.cleanupSession(taskRunId);
685+
}
686+
}
687+
}
688+
639689
async cancelPrompt(
640690
sessionId: string,
641691
reason?: InterruptReason,

apps/twig/src/main/services/app-lifecycle/service.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import { app } from "electron";
22
import { injectable } from "inversify";
33
import { ANALYTICS_EVENTS } from "../../../types/analytics.js";
44
import { container } from "../../di/container.js";
5+
import { MAIN_TOKENS } from "../../di/tokens.js";
56
import { withTimeout } from "../../lib/async.js";
67
import { logger } from "../../lib/logger.js";
78
import { shutdownPostHog, trackAppEvent } from "../posthog-analytics.js";
9+
import type { ProcessTrackingService } from "../process-tracking/service.js";
810

911
const log = logger.scope("app-lifecycle");
1012

@@ -53,7 +55,38 @@ export class AppLifecycleService {
5355
}
5456

5557
private async doShutdown(): Promise<void> {
56-
log.info("Shutdown started: unbinding container");
58+
log.info("Shutdown started");
59+
60+
try {
61+
const processTracking = container.get<ProcessTrackingService>(
62+
MAIN_TOKENS.ProcessTrackingService,
63+
);
64+
const snapshot = await processTracking.getSnapshot(true);
65+
log.info("Process snapshot at shutdown", {
66+
tracked: {
67+
shell: snapshot.tracked.shell.length,
68+
agent: snapshot.tracked.agent.length,
69+
child: snapshot.tracked.child.length,
70+
},
71+
discovered: snapshot.discovered?.length ?? 0,
72+
untrackedDiscovered:
73+
snapshot.discovered?.filter((p) => !p.tracked).length ?? 0,
74+
});
75+
76+
if (
77+
snapshot.tracked.shell.length +
78+
snapshot.tracked.agent.length +
79+
snapshot.tracked.child.length >
80+
0
81+
) {
82+
log.info("Killing all tracked processes before container unbind");
83+
processTracking.killAll();
84+
}
85+
} catch (error) {
86+
log.warn("Failed to get process snapshot at shutdown", error);
87+
}
88+
89+
log.info("Unbinding container");
5790
try {
5891
await container.unbindAll();
5992
log.info("Container unbound successfully");

0 commit comments

Comments
 (0)