@posthog/agent 2.3.387 → 2.3.388

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/agent",
3
- "version": "2.3.387",
3
+ "version": "2.3.388",
4
4
  "repository": "https://github.com/PostHog/code",
5
5
  "description": "TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog",
6
6
  "exports": {
@@ -102,8 +102,8 @@
102
102
  "tsx": "^4.20.6",
103
103
  "typescript": "^5.5.0",
104
104
  "vitest": "^2.1.8",
105
- "@posthog/shared": "1.0.0",
106
105
  "@posthog/git": "1.0.0",
106
+ "@posthog/shared": "1.0.0",
107
107
  "@posthog/enricher": "1.0.0"
108
108
  },
109
109
  "dependencies": {
@@ -113,7 +113,7 @@ export class HandoffCheckpointTracker {
113
113
  divergence: GitHandoffBranchDivergence,
114
114
  ) => Promise<boolean>;
115
115
  },
116
- ): Promise<void> {
116
+ ): Promise<{ packBytes: number; indexBytes: number; totalBytes: number }> {
117
117
  if (!this.apiClient) {
118
118
  throw new Error(
119
119
  "Cannot apply handoff checkpoint: API client not configured",
@@ -152,6 +152,12 @@ export class HandoffCheckpointTracker {
152
152
  });
153
153
 
154
154
  this.logApplyMetrics(checkpoint, downloads, applyResult.totalBytes);
155
+
156
+ return {
157
+ packBytes: downloads.pack?.rawBytes ?? 0,
158
+ indexBytes: downloads.index?.rawBytes ?? 0,
159
+ totalBytes: applyResult.totalBytes,
160
+ };
155
161
  } finally {
156
162
  await this.removeIfPresent(packPath);
157
163
  await this.removeIfPresent(indexPath);
@@ -207,23 +213,24 @@ export class HandoffCheckpointTracker {
207
213
  }
208
214
 
209
215
  private async uploadArtifacts(specs: UploadArtifactSpec[]): Promise<Uploads> {
210
- const uploads = await Promise.all(
211
- specs.map(async (spec) => {
212
- if (!spec.filePath) {
213
- return [spec.key, undefined] as const;
214
- }
215
- return [
216
- spec.key,
217
- await this.uploadArtifactFile(
218
- spec.filePath,
219
- spec.name,
220
- spec.contentType,
221
- ),
222
- ] as const;
223
- }),
224
- );
216
+ const results: Array<readonly [ArtifactKey, UploadedArtifact | undefined]> =
217
+ [];
218
+ for (const spec of specs) {
219
+ if (!spec.filePath) {
220
+ results.push([spec.key, undefined] as const);
221
+ continue;
222
+ }
223
+ results.push([
224
+ spec.key,
225
+ await this.uploadArtifactFile(
226
+ spec.filePath,
227
+ spec.name,
228
+ spec.contentType,
229
+ ),
230
+ ] as const);
231
+ }
225
232
 
226
- return Object.fromEntries(uploads) as Uploads;
233
+ return Object.fromEntries(results) as Uploads;
227
234
  }
228
235
 
229
236
  private async downloadArtifactToFile(
@@ -241,9 +248,8 @@ export class HandoffCheckpointTracker {
241
248
  artifactPath,
242
249
  );
243
250
  if (!arrayBuffer) {
244
- throw new Error(`Failed to download ${label}`);
251
+ throw new Error(`Failed to download ${label} from ${artifactPath}`);
245
252
  }
246
-
247
253
  const base64Content = Buffer.from(arrayBuffer).toString("utf-8");
248
254
  const binaryContent = Buffer.from(base64Content, "base64");
249
255
  await writeFile(filePath, binaryContent);
@@ -153,6 +153,14 @@ export class PostHogAPIClient {
153
153
  );
154
154
  }
155
155
 
156
+ async resumeRunInCloud(taskId: string, runId: string): Promise<TaskRun> {
157
+ const teamId = this.getTeamId();
158
+ return this.apiRequest<TaskRun>(
159
+ `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/resume_in_cloud/`,
160
+ { method: "POST" },
161
+ );
162
+ }
163
+
156
164
  async updateTaskRun(
157
165
  taskId: string,
158
166
  runId: string,
package/src/resume.ts CHANGED
@@ -30,8 +30,6 @@ export interface ResumeState {
30
30
  conversation: ConversationTurn[];
31
31
  latestSnapshot: TreeSnapshotEvent | null;
32
32
  latestGitCheckpoint: GitCheckpointEvent | null;
33
- /** Whether the tree snapshot was successfully applied (files restored) */
34
- snapshotApplied: boolean;
35
33
  interrupted: boolean;
36
34
  lastDevice?: DeviceInfo;
37
35
  logEntryCount: number;
@@ -61,11 +59,7 @@ export interface ResumeConfig {
61
59
  /**
62
60
  * Resume a task from its persisted log.
63
61
  * Returns the rebuilt state for the agent to continue from.
64
- *
65
- * Uses Saga pattern internally for atomic operations.
66
- * Note: snapshotApplied field indicates if files were actually restored -
67
- * even if latestSnapshot is non-null, files may not have been restored if
68
- * the snapshot had no archive URL or download/extraction failed.
62
+ * Snapshot and checkpoint application happens in the agent server after SSE connects.
69
63
  */
70
64
  export async function resumeFromLog(
71
65
  config: ResumeConfig,
@@ -102,7 +96,6 @@ export async function resumeFromLog(
102
96
  conversation: result.data.conversation as ConversationTurn[],
103
97
  latestSnapshot: result.data.latestSnapshot,
104
98
  latestGitCheckpoint: result.data.latestGitCheckpoint,
105
- snapshotApplied: result.data.snapshotApplied,
106
99
  interrupted: result.data.interrupted,
107
100
  lastDevice: result.data.lastDevice,
108
101
  logEntryCount: result.data.logEntryCount,
@@ -124,15 +117,31 @@ export function conversationToPromptHistory(
124
117
  const RESUME_HISTORY_TOKEN_BUDGET = 50_000;
125
118
  const TOOL_RESULT_MAX_CHARS = 2000;
126
119
 
120
+ const RESUME_CONTEXT_MARKERS = [
121
+ "You are resuming a previous conversation",
122
+ "Here is the conversation history from the",
123
+ "Continue from where you left off",
124
+ ];
125
+
126
+ function isResumeContextTurn(turn: ConversationTurn): boolean {
127
+ if (turn.role !== "user") return false;
128
+ const text = turn.content
129
+ .filter((b) => b.type === "text")
130
+ .map((b) => (b as { type: "text"; text: string }).text)
131
+ .join("");
132
+ return RESUME_CONTEXT_MARKERS.some((marker) => text.includes(marker));
133
+ }
134
+
127
135
  export function formatConversationForResume(
128
136
  conversation: ConversationTurn[],
129
137
  ): string {
130
- const selected = selectRecentTurns(conversation, RESUME_HISTORY_TOKEN_BUDGET);
138
+ const filtered = conversation.filter((turn) => !isResumeContextTurn(turn));
139
+ const selected = selectRecentTurns(filtered, RESUME_HISTORY_TOKEN_BUDGET);
131
140
  const parts: string[] = [];
132
141
 
133
- if (selected.length < conversation.length) {
142
+ if (selected.length < filtered.length) {
134
143
  parts.push(
135
- `*(${conversation.length - selected.length} earlier turns omitted)*`,
144
+ `*(${filtered.length - selected.length} earlier turns omitted)*`,
136
145
  );
137
146
  }
138
147
 
@@ -6,7 +6,6 @@ import { ResumeSaga } from "./resume-saga";
6
6
  import {
7
7
  createAgentChunk,
8
8
  createAgentMessage,
9
- createArchiveBuffer,
10
9
  createMockApiClient,
11
10
  createMockLogger,
12
11
  createNotification,
@@ -52,7 +51,6 @@ describe("ResumeSaga", () => {
52
51
  if (result.success) {
53
52
  expect(result.data.conversation).toHaveLength(0);
54
53
  expect(result.data.latestSnapshot).toBeNull();
55
- expect(result.data.snapshotApplied).toBe(false);
56
54
  expect(result.data.logEntryCount).toBe(0);
57
55
  }
58
56
  });
@@ -468,14 +466,10 @@ describe("ResumeSaga", () => {
468
466
  });
469
467
  });
470
468
 
471
- describe("snapshot application", () => {
472
- it("applies snapshot when archive URL present", async () => {
469
+ describe("snapshot metadata", () => {
470
+ it("returns latest snapshot metadata when archive URL present", async () => {
473
471
  const baseCommit = await repo.git(["rev-parse", "HEAD"]);
474
472
 
475
- const archive = await createArchiveBuffer([
476
- { path: "restored.ts", content: "restored content" },
477
- ]);
478
-
479
473
  (mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
480
474
  createTaskRun(),
481
475
  );
@@ -490,9 +484,6 @@ describe("ResumeSaga", () => {
490
484
  timestamp: new Date().toISOString(),
491
485
  }),
492
486
  ]);
493
- (
494
- mockApiClient.downloadArtifact as ReturnType<typeof vi.fn>
495
- ).mockResolvedValue(archive);
496
487
 
497
488
  const saga = new ResumeSaga(mockLogger);
498
489
  const result = await saga.run({
@@ -505,13 +496,13 @@ describe("ResumeSaga", () => {
505
496
  expect(result.success).toBe(true);
506
497
  if (!result.success) return;
507
498
 
508
- expect(result.data.snapshotApplied).toBe(true);
509
-
510
- const content = await repo.readFile("restored.ts");
511
- expect(content).toBe("restored content");
499
+ expect(result.data.latestSnapshot?.treeHash).toBe("hash-1");
500
+ expect(result.data.latestSnapshot?.archiveUrl).toBe(
501
+ "gs://bucket/hash-1.tar.gz",
502
+ );
512
503
  });
513
504
 
514
- it("continues without snapshot when no archive URL", async () => {
505
+ it("returns snapshot metadata even when no archive URL", async () => {
515
506
  (mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
516
507
  createTaskRun(),
517
508
  );
@@ -533,40 +524,9 @@ describe("ResumeSaga", () => {
533
524
  expect(result.success).toBe(true);
534
525
  if (!result.success) return;
535
526
 
536
- expect(result.data.snapshotApplied).toBe(false);
537
527
  expect(result.data.latestSnapshot).not.toBeNull();
538
528
  expect(result.data.conversation).toHaveLength(1);
539
529
  });
540
-
541
- it("continues without snapshot when apply fails", async () => {
542
- (mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
543
- createTaskRun(),
544
- );
545
- (
546
- mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
547
- ).mockResolvedValue([
548
- createTreeSnapshotNotification("hash-1", "gs://bucket/hash-1.tar.gz"),
549
- createUserMessage("hello"),
550
- ]);
551
- (
552
- mockApiClient.downloadArtifact as ReturnType<typeof vi.fn>
553
- ).mockRejectedValue(new Error("Download failed"));
554
-
555
- const saga = new ResumeSaga(mockLogger);
556
- const result = await saga.run({
557
- taskId: "task-1",
558
- runId: "run-1",
559
- repositoryPath: repo.path,
560
- apiClient: mockApiClient,
561
- });
562
-
563
- expect(result.success).toBe(true);
564
- if (!result.success) return;
565
-
566
- expect(result.data.snapshotApplied).toBe(false);
567
- expect(result.data.conversation).toHaveLength(1);
568
- expect(mockLogger.warn).toHaveBeenCalled();
569
- });
570
530
  });
571
531
 
572
532
  describe("device info", () => {
@@ -2,14 +2,13 @@ import type { ContentBlock } from "@agentclientprotocol/sdk";
2
2
  import { Saga } from "@posthog/shared";
3
3
  import { isNotification, POSTHOG_NOTIFICATIONS } from "../acp-extensions";
4
4
  import type { PostHogAPIClient } from "../posthog-api";
5
- import { TreeTracker } from "../tree-tracker";
6
5
  import type {
7
6
  DeviceInfo,
8
7
  GitCheckpointEvent,
9
8
  StoredNotification,
10
9
  TreeSnapshotEvent,
11
10
  } from "../types";
12
- import { Logger } from "../utils/logger";
11
+ import type { Logger } from "../utils/logger";
13
12
 
14
13
  export interface ConversationTurn {
15
14
  role: "user" | "assistant";
@@ -36,7 +35,6 @@ export interface ResumeOutput {
36
35
  conversation: ConversationTurn[];
37
36
  latestSnapshot: TreeSnapshotEvent | null;
38
37
  latestGitCheckpoint: GitCheckpointEvent | null;
39
- snapshotApplied: boolean;
40
38
  interrupted: boolean;
41
39
  lastDevice?: DeviceInfo;
42
40
  logEntryCount: number;
@@ -46,9 +44,7 @@ export class ResumeSaga extends Saga<ResumeInput, ResumeOutput> {
46
44
  readonly sagaName = "ResumeSaga";
47
45
 
48
46
  protected async execute(input: ResumeInput): Promise<ResumeOutput> {
49
- const { taskId, runId, repositoryPath, apiClient } = input;
50
- const logger =
51
- input.logger || new Logger({ debug: false, prefix: "[Resume]" });
47
+ const { taskId, runId, apiClient } = input;
52
48
 
53
49
  // Step 1: Fetch task run (read-only)
54
50
  const taskRun = await this.readOnlyStep("fetch_task_run", () =>
@@ -83,69 +79,21 @@ export class ResumeSaga extends Saga<ResumeInput, ResumeOutput> {
83
79
  );
84
80
 
85
81
  // Step 4: Apply snapshot if present (wrapped in step for consistent logging)
86
- // Note: We use a try/catch inside the step because snapshot failure should NOT fail the saga
87
- let snapshotApplied = false;
88
- if (latestSnapshot?.archiveUrl && repositoryPath) {
82
+ if (latestSnapshot) {
89
83
  this.log.info("Found tree snapshot", {
90
84
  treeHash: latestSnapshot.treeHash,
91
- hasArchiveUrl: true,
85
+ hasArchiveUrl: !!latestSnapshot.archiveUrl,
92
86
  changes: latestSnapshot.changes?.length ?? 0,
93
- interrupted: latestSnapshot.interrupted,
94
87
  });
88
+ }
95
89
 
96
- await this.step({
97
- name: "apply_snapshot",
98
- execute: async () => {
99
- const treeTracker = new TreeTracker({
100
- repositoryPath,
101
- taskId,
102
- runId,
103
- apiClient,
104
- logger: logger.child("TreeTracker"),
105
- });
106
-
107
- try {
108
- await treeTracker.applyTreeSnapshot(latestSnapshot);
109
- treeTracker.setLastTreeHash(latestSnapshot.treeHash);
110
- snapshotApplied = true;
111
- this.log.info("Tree snapshot applied successfully", {
112
- treeHash: latestSnapshot.treeHash,
113
- });
114
- } catch (error) {
115
- // Log but don't fail - continue with conversation rebuild
116
- // ApplySnapshotSaga handles its own rollback internally
117
- this.log.warn(
118
- "Failed to apply tree snapshot, continuing without it",
119
- {
120
- error: error instanceof Error ? error.message : String(error),
121
- treeHash: latestSnapshot.treeHash,
122
- },
123
- );
124
- }
125
- },
126
- rollback: async () => {
127
- // Inner ApplySnapshotSaga handles its own rollback
128
- },
90
+ if (latestGitCheckpoint) {
91
+ this.log.info("Found git checkpoint", {
92
+ checkpointId: latestGitCheckpoint.checkpointId,
93
+ branch: latestGitCheckpoint.branch,
129
94
  });
130
- } else if (latestSnapshot?.archiveUrl && !repositoryPath) {
131
- this.log.warn(
132
- "Snapshot found but no repositoryPath configured - files cannot be restored",
133
- {
134
- treeHash: latestSnapshot.treeHash,
135
- changes: latestSnapshot.changes?.length ?? 0,
136
- },
137
- );
138
- } else if (latestSnapshot) {
139
- this.log.warn(
140
- "Snapshot found but has no archive URL - files cannot be restored",
141
- {
142
- treeHash: latestSnapshot.treeHash,
143
- changes: latestSnapshot.changes?.length ?? 0,
144
- },
145
- );
146
95
  }
147
96
 
148
- // Step 5: Rebuild conversation (read-only, pure computation)
149
97
  const conversation = await this.readOnlyStep("rebuild_conversation", () =>
150
98
  Promise.resolve(this.rebuildConversation(entries)),
151
99
  );
@@ -158,7 +106,7 @@ export class ResumeSaga extends Saga<ResumeInput, ResumeOutput> {
158
106
  this.log.info("Resume state rebuilt", {
159
107
  turns: conversation.length,
160
108
  hasSnapshot: !!latestSnapshot,
161
- snapshotApplied,
109
+ hasGitCheckpoint: !!latestGitCheckpoint,
162
110
  interrupted: latestSnapshot?.interrupted ?? false,
163
111
  });
164
112
 
@@ -166,7 +114,6 @@ export class ResumeSaga extends Saga<ResumeInput, ResumeOutput> {
166
114
  conversation,
167
115
  latestSnapshot,
168
116
  latestGitCheckpoint,
169
- snapshotApplied,
170
117
  interrupted: latestSnapshot?.interrupted ?? false,
171
118
  lastDevice,
172
119
  logEntryCount: entries.length,
@@ -178,7 +125,6 @@ export class ResumeSaga extends Saga<ResumeInput, ResumeOutput> {
178
125
  conversation: [],
179
126
  latestSnapshot: null,
180
127
  latestGitCheckpoint: null,
181
- snapshotApplied: false,
182
128
  interrupted: false,
183
129
  logEntryCount: 0,
184
130
  };
@@ -478,50 +478,34 @@ export class AgentServer {
478
478
  await this.autoInitializeSession();
479
479
  }
480
480
 
481
- private async autoInitializeSession(): Promise<void> {
482
- const { taskId, runId, mode, projectId } = this.config;
483
-
484
- this.logger.debug("Auto-initializing session", { taskId, runId, mode });
485
-
486
- // Check if this is a resume from a previous run
487
- const resumeRunId = process.env.POSTHOG_RESUME_RUN_ID;
488
- if (resumeRunId) {
489
- this.logger.debug("Resuming from previous run", {
490
- resumeRunId,
491
- currentRunId: runId,
481
+ private async loadResumeState(
482
+ taskId: string,
483
+ resumeRunId: string,
484
+ currentRunId: string,
485
+ ): Promise<void> {
486
+ this.logger.debug("Loading resume state", { resumeRunId, currentRunId });
487
+ try {
488
+ this.resumeState = await resumeFromLog({
489
+ taskId,
490
+ runId: resumeRunId,
491
+ repositoryPath: this.config.repositoryPath,
492
+ apiClient: this.posthogAPI,
493
+ logger: new Logger({ debug: true, prefix: "[Resume]" }),
492
494
  });
493
- try {
494
- this.resumeState = await resumeFromLog({
495
- taskId,
496
- runId: resumeRunId,
497
- repositoryPath: this.config.repositoryPath,
498
- apiClient: this.posthogAPI,
499
- logger: new Logger({ debug: true, prefix: "[Resume]" }),
500
- });
501
- this.logger.debug("Resume state loaded", {
502
- conversationTurns: this.resumeState.conversation.length,
503
- snapshotApplied: this.resumeState.snapshotApplied,
504
- logEntries: this.resumeState.logEntryCount,
505
- });
506
- } catch (error) {
507
- this.logger.debug("Failed to load resume state, starting fresh", {
508
- error,
509
- });
510
- this.resumeState = null;
511
- }
495
+ this.logger.debug("Resume state loaded", {
496
+ conversationTurns: this.resumeState.conversation.length,
497
+ hasSnapshot: !!this.resumeState.latestSnapshot,
498
+ hasGitCheckpoint: !!this.resumeState.latestGitCheckpoint,
499
+ gitCheckpointBranch:
500
+ this.resumeState.latestGitCheckpoint?.branch ?? null,
501
+ logEntries: this.resumeState.logEntryCount,
502
+ });
503
+ } catch (error) {
504
+ this.logger.debug("Failed to load resume state, starting fresh", {
505
+ error,
506
+ });
507
+ this.resumeState = null;
512
508
  }
513
-
514
- // Create a synthetic payload from config (no JWT needed for auto-init)
515
- const payload: JwtPayload = {
516
- task_id: taskId,
517
- run_id: runId,
518
- team_id: projectId,
519
- user_id: 0, // System-initiated
520
- distinct_id: "agent-server",
521
- mode,
522
- };
523
-
524
- await this.initializeSession(payload, null);
525
509
  }
526
510
 
527
511
  async stop(): Promise<void> {
@@ -1057,33 +1041,14 @@ export class AgentServer {
1057
1041
  }
1058
1042
  }
1059
1043
 
1060
- // Check for resume if not already loaded from env var in autoInitializeSession
1061
1044
  if (!this.resumeState) {
1062
1045
  const resumeRunId = this.getResumeRunId(taskRun);
1063
1046
  if (resumeRunId) {
1064
- this.logger.debug("Resuming from previous run (via TaskRun state)", {
1047
+ await this.loadResumeState(
1048
+ payload.task_id,
1065
1049
  resumeRunId,
1066
- currentRunId: payload.run_id,
1067
- });
1068
- try {
1069
- this.resumeState = await resumeFromLog({
1070
- taskId: payload.task_id,
1071
- runId: resumeRunId,
1072
- repositoryPath: this.config.repositoryPath,
1073
- apiClient: this.posthogAPI,
1074
- logger: new Logger({ debug: true, prefix: "[Resume]" }),
1075
- });
1076
- this.logger.debug("Resume state loaded (via TaskRun state)", {
1077
- conversationTurns: this.resumeState.conversation.length,
1078
- snapshotApplied: this.resumeState.snapshotApplied,
1079
- logEntries: this.resumeState.logEntryCount,
1080
- });
1081
- } catch (error) {
1082
- this.logger.debug("Failed to load resume state, starting fresh", {
1083
- error,
1084
- });
1085
- this.resumeState = null;
1086
- }
1050
+ payload.run_id,
1051
+ );
1087
1052
  }
1088
1053
  }
1089
1054
 
@@ -1163,11 +1128,70 @@ export class AgentServer {
1163
1128
  this.resumeState.conversation,
1164
1129
  );
1165
1130
 
1166
- // Read the pending user prompt from TaskRun state (set by the workflow
1167
- // when the user sends a follow-up message that triggers a resume).
1131
+ let snapshotApplied = false;
1132
+ if (
1133
+ this.resumeState.latestSnapshot?.archiveUrl &&
1134
+ this.config.repositoryPath &&
1135
+ this.posthogAPI
1136
+ ) {
1137
+ try {
1138
+ const treeTracker = new TreeTracker({
1139
+ repositoryPath: this.config.repositoryPath,
1140
+ taskId: payload.task_id,
1141
+ runId: payload.run_id,
1142
+ apiClient: this.posthogAPI,
1143
+ logger: this.logger.child("TreeTracker"),
1144
+ });
1145
+ await treeTracker.applyTreeSnapshot(this.resumeState.latestSnapshot);
1146
+ treeTracker.setLastTreeHash(this.resumeState.latestSnapshot.treeHash);
1147
+ snapshotApplied = true;
1148
+ this.logger.info("Tree snapshot applied", {
1149
+ treeHash: this.resumeState.latestSnapshot.treeHash,
1150
+ changes: this.resumeState.latestSnapshot.changes?.length ?? 0,
1151
+ hasArchiveUrl: !!this.resumeState.latestSnapshot.archiveUrl,
1152
+ });
1153
+ } catch (error) {
1154
+ this.logger.warn("Failed to apply tree snapshot", {
1155
+ error: error instanceof Error ? error.message : String(error),
1156
+ treeHash: this.resumeState.latestSnapshot.treeHash,
1157
+ });
1158
+ }
1159
+ }
1160
+
1161
+ if (
1162
+ this.resumeState.latestGitCheckpoint &&
1163
+ this.config.repositoryPath &&
1164
+ this.posthogAPI
1165
+ ) {
1166
+ try {
1167
+ const checkpointTracker = new HandoffCheckpointTracker({
1168
+ repositoryPath: this.config.repositoryPath,
1169
+ taskId: payload.task_id,
1170
+ runId: payload.run_id,
1171
+ apiClient: this.posthogAPI,
1172
+ logger: this.logger.child("HandoffCheckpoint"),
1173
+ });
1174
+ const metrics = await checkpointTracker.applyFromHandoff(
1175
+ this.resumeState.latestGitCheckpoint,
1176
+ );
1177
+ this.logger.info("Git checkpoint applied", {
1178
+ branch: this.resumeState.latestGitCheckpoint.branch,
1179
+ head: this.resumeState.latestGitCheckpoint.head,
1180
+ packBytes: metrics.packBytes,
1181
+ indexBytes: metrics.indexBytes,
1182
+ totalBytes: metrics.totalBytes,
1183
+ });
1184
+ } catch (error) {
1185
+ this.logger.warn("Failed to apply git checkpoint", {
1186
+ error: error instanceof Error ? error.message : String(error),
1187
+ branch: this.resumeState.latestGitCheckpoint.branch,
1188
+ });
1189
+ }
1190
+ }
1191
+
1168
1192
  const pendingUserPrompt = await this.getPendingUserPrompt(taskRun);
1169
1193
 
1170
- const sandboxContext = this.resumeState.snapshotApplied
1194
+ const sandboxContext = snapshotApplied
1171
1195
  ? `The workspace environment (all files, packages, and code changes) has been fully restored from where you left off.`
1172
1196
  : `The workspace files from the previous session were not restored (the file snapshot may have expired), so you are starting with a fresh environment. Your conversation history is fully preserved below.`;
1173
1197
 
@@ -1206,7 +1230,10 @@ export class AgentServer {
1206
1230
  conversationTurns: this.resumeState.conversation.length,
1207
1231
  promptLength: promptBlocksToText(resumePromptBlocks).length,
1208
1232
  hasPendingUserMessage: !!pendingUserPrompt?.length,
1209
- snapshotApplied: this.resumeState.snapshotApplied,
1233
+ snapshotApplied,
1234
+ hasGitCheckpoint: !!this.resumeState.latestGitCheckpoint,
1235
+ gitCheckpointBranch:
1236
+ this.resumeState.latestGitCheckpoint?.branch ?? null,
1210
1237
  });
1211
1238
 
1212
1239
  // Clear resume state so it's not reused
@@ -1434,6 +1461,29 @@ export class AgentServer {
1434
1461
  return normalizedName.length > 0 ? normalizedName : "attachment";
1435
1462
  }
1436
1463
 
1464
+ private async autoInitializeSession(): Promise<void> {
1465
+ const { taskId, runId, mode, projectId } = this.config;
1466
+
1467
+ this.logger.debug("Auto-initializing session", { taskId, runId, mode });
1468
+
1469
+ const resumeRunId = process.env.POSTHOG_RESUME_RUN_ID;
1470
+ if (resumeRunId) {
1471
+ await this.loadResumeState(taskId, resumeRunId, runId);
1472
+ }
1473
+
1474
+ // Create a synthetic payload from config (no JWT needed for auto-init)
1475
+ const payload: JwtPayload = {
1476
+ task_id: taskId,
1477
+ run_id: runId,
1478
+ team_id: projectId,
1479
+ user_id: 0, // System-initiated
1480
+ distinct_id: "agent-server",
1481
+ mode,
1482
+ };
1483
+
1484
+ await this.initializeSession(payload, null);
1485
+ }
1486
+
1437
1487
  private getResumeRunId(taskRun: TaskRun | null): string | null {
1438
1488
  // Env var takes precedence (set by backend infra)
1439
1489
  const envRunId = process.env.POSTHOG_RESUME_RUN_ID;