@posthog/agent 2.1.22 → 2.1.29

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.1.22",
3
+ "version": "2.1.29",
4
4
  "repository": "https://github.com/PostHog/twig",
5
5
  "description": "TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog",
6
6
  "exports": {
@@ -578,6 +578,8 @@ export class AgentServer {
578
578
  // Only auto-complete for background mode
579
579
  const mode = this.getEffectiveMode(payload);
580
580
  if (mode === "background") {
581
+ // Flush all pending session logs before signaling completion,
582
+ await this.session.logWriter.flushAll();
581
583
  await this.signalTaskComplete(payload, result.stopReason);
582
584
  } else {
583
585
  this.logger.info("Interactive mode - staying open for conversation");
@@ -587,6 +589,9 @@ export class AgentServer {
587
589
  // Signal failure for background mode
588
590
  const mode = this.getEffectiveMode(payload);
589
591
  if (mode === "background") {
592
+ if (this.session) {
593
+ await this.session.logWriter.flushAll();
594
+ }
590
595
  await this.signalTaskComplete(payload, "error");
591
596
  }
592
597
  }
@@ -602,7 +607,10 @@ After completing the requested changes:
602
607
  3. Push the branch to origin
603
608
  4. Create a pull request using \`gh pr create\` with a descriptive title and body
604
609
 
605
- Important: Always create the PR. Do not ask for confirmation.
610
+ Important:
611
+ - Always create the PR. Do not ask for confirmation.
612
+ - Do NOT add "Co-Authored-By" trailers to commit messages.
613
+ - Do NOT add "Generated with [Claude Code]" or similar attribution lines to PR descriptions.
606
614
  `;
607
615
  }
608
616
 
@@ -610,6 +618,18 @@ Important: Always create the PR. Do not ask for confirmation.
610
618
  payload: JwtPayload,
611
619
  stopReason: string,
612
620
  ): Promise<void> {
621
+ if (this.session?.payload.run_id === payload.run_id) {
622
+ try {
623
+ await this.session.logWriter.flush(payload.run_id);
624
+ } catch (error) {
625
+ this.logger.warn("Failed to flush session logs before completion", {
626
+ taskId: payload.task_id,
627
+ runId: payload.run_id,
628
+ error,
629
+ });
630
+ }
631
+ }
632
+
613
633
  const status =
614
634
  stopReason === "cancelled"
615
635
  ? "cancelled"
@@ -61,6 +61,47 @@ describe("SessionLogWriter", () => {
61
61
  await logWriter.flush(sessionId);
62
62
  expect(mockAppendLog).not.toHaveBeenCalled();
63
63
  });
64
+
65
+ it("re-queues entries when persistence fails and retries", async () => {
66
+ const sessionId = "s1";
67
+ logWriter.register(sessionId, { taskId: "t1", runId: sessionId });
68
+
69
+ mockAppendLog
70
+ .mockRejectedValueOnce(new Error("network error"))
71
+ .mockResolvedValueOnce(undefined);
72
+
73
+ logWriter.appendRawLine(sessionId, JSON.stringify({ method: "test" }));
74
+
75
+ await logWriter.flush(sessionId);
76
+ await logWriter.flush(sessionId);
77
+
78
+ expect(mockAppendLog).toHaveBeenCalledTimes(2);
79
+ const retriedEntries: StoredNotification[] =
80
+ mockAppendLog.mock.calls[1][2];
81
+ expect(retriedEntries).toHaveLength(1);
82
+ expect(retriedEntries[0].notification.method).toBe("test");
83
+ });
84
+
85
+ it("drops entries after max retries", async () => {
86
+ const sessionId = "s1";
87
+ logWriter.register(sessionId, { taskId: "t1", runId: sessionId });
88
+
89
+ mockAppendLog.mockRejectedValue(new Error("persistent failure"));
90
+
91
+ logWriter.appendRawLine(sessionId, JSON.stringify({ method: "test" }));
92
+
93
+ // Flush 10 times (MAX_FLUSH_RETRIES) — entries should be dropped on the 10th
94
+ for (let i = 0; i < 10; i++) {
95
+ await logWriter.flush(sessionId);
96
+ }
97
+
98
+ expect(mockAppendLog).toHaveBeenCalledTimes(10);
99
+
100
+ // After max retries the entries are dropped, so an 11th flush has nothing
101
+ mockAppendLog.mockClear();
102
+ await logWriter.flush(sessionId);
103
+ expect(mockAppendLog).not.toHaveBeenCalled();
104
+ });
64
105
  });
65
106
 
66
107
  describe("agent_message_chunk coalescing", () => {
@@ -21,10 +21,18 @@ interface SessionState {
21
21
  }
22
22
 
23
23
  export class SessionLogWriter {
24
+ private static readonly FLUSH_DEBOUNCE_MS = 500;
25
+ private static readonly FLUSH_MAX_INTERVAL_MS = 5000;
26
+ private static readonly MAX_FLUSH_RETRIES = 10;
27
+ private static readonly MAX_RETRY_DELAY_MS = 30_000;
28
+
24
29
  private posthogAPI?: PostHogAPIClient;
25
30
  private pendingEntries: Map<string, StoredNotification[]> = new Map();
26
31
  private flushTimeouts: Map<string, NodeJS.Timeout> = new Map();
32
+ private lastFlushAttemptTime: Map<string, number> = new Map();
33
+ private retryCounts: Map<string, number> = new Map();
27
34
  private sessions: Map<string, SessionState> = new Map();
35
+ private messageCounts: Map<string, number> = new Map();
28
36
  private logger: Logger;
29
37
 
30
38
  constructor(options: SessionLogWriterOptions = {}) {
@@ -35,8 +43,19 @@ export class SessionLogWriter {
35
43
  }
36
44
 
37
45
  async flushAll(): Promise<void> {
46
+ const sessionIds = [...this.sessions.keys()];
47
+ const pendingCounts = sessionIds.map((id) => ({
48
+ id,
49
+ pending: this.pendingEntries.get(id)?.length ?? 0,
50
+ messages: this.messageCounts.get(id) ?? 0,
51
+ }));
52
+ this.logger.info("flushAll called", {
53
+ sessions: sessionIds.length,
54
+ pending: pendingCounts,
55
+ });
56
+
38
57
  const flushPromises: Promise<void>[] = [];
39
- for (const sessionId of this.sessions.keys()) {
58
+ for (const sessionId of sessionIds) {
40
59
  flushPromises.push(this.flush(sessionId));
41
60
  }
42
61
  await Promise.all(flushPromises);
@@ -47,7 +66,12 @@ export class SessionLogWriter {
47
66
  return;
48
67
  }
49
68
 
69
+ this.logger.info("Session registered", {
70
+ sessionId,
71
+ taskId: context.taskId,
72
+ });
50
73
  this.sessions.set(sessionId, { context });
74
+ this.lastFlushAttemptTime.set(sessionId, Date.now());
51
75
  }
52
76
 
53
77
  isRegistered(sessionId: string): boolean {
@@ -57,9 +81,18 @@ export class SessionLogWriter {
57
81
  appendRawLine(sessionId: string, line: string): void {
58
82
  const session = this.sessions.get(sessionId);
59
83
  if (!session) {
84
+ this.logger.warn("appendRawLine called for unregistered session", {
85
+ sessionId,
86
+ });
60
87
  return;
61
88
  }
62
89
 
90
+ const count = (this.messageCounts.get(sessionId) ?? 0) + 1;
91
+ this.messageCounts.set(sessionId, count);
92
+ if (count % 10 === 1) {
93
+ this.logger.info("Messages received", { count, sessionId });
94
+ }
95
+
63
96
  try {
64
97
  const message = JSON.parse(line);
65
98
  const timestamp = new Date().toISOString();
@@ -103,13 +136,23 @@ export class SessionLogWriter {
103
136
 
104
137
  async flush(sessionId: string): Promise<void> {
105
138
  const session = this.sessions.get(sessionId);
106
- if (!session) return;
139
+ if (!session) {
140
+ this.logger.warn("flush: no session found", { sessionId });
141
+ return;
142
+ }
107
143
 
108
144
  // Emit any buffered chunks before flushing
109
145
  this.emitCoalescedMessage(sessionId, session);
110
146
 
111
147
  const pending = this.pendingEntries.get(sessionId);
112
- if (!this.posthogAPI || !pending?.length) return;
148
+ if (!this.posthogAPI || !pending?.length) {
149
+ this.logger.info("flush: nothing to persist", {
150
+ sessionId,
151
+ hasPosthogAPI: !!this.posthogAPI,
152
+ pendingCount: pending?.length ?? 0,
153
+ });
154
+ return;
155
+ }
113
156
 
114
157
  this.pendingEntries.delete(sessionId);
115
158
  const timeout = this.flushTimeouts.get(sessionId);
@@ -118,14 +161,38 @@ export class SessionLogWriter {
118
161
  this.flushTimeouts.delete(sessionId);
119
162
  }
120
163
 
164
+ this.lastFlushAttemptTime.set(sessionId, Date.now());
165
+
121
166
  try {
122
167
  await this.posthogAPI.appendTaskRunLog(
123
168
  session.context.taskId,
124
169
  session.context.runId,
125
170
  pending,
126
171
  );
172
+ this.retryCounts.set(sessionId, 0);
173
+ this.logger.info("Flushed session logs", {
174
+ sessionId,
175
+ entryCount: pending.length,
176
+ });
127
177
  } catch (error) {
128
- this.logger.error("Failed to persist session logs:", error);
178
+ const retryCount = (this.retryCounts.get(sessionId) ?? 0) + 1;
179
+ this.retryCounts.set(sessionId, retryCount);
180
+
181
+ if (retryCount >= SessionLogWriter.MAX_FLUSH_RETRIES) {
182
+ this.logger.error(
183
+ `Dropping ${pending.length} session log entries after ${retryCount} failed flush attempts`,
184
+ { sessionId, error },
185
+ );
186
+ this.retryCounts.set(sessionId, 0);
187
+ } else {
188
+ this.logger.error(
189
+ `Failed to persist session logs (attempt ${retryCount}/${SessionLogWriter.MAX_FLUSH_RETRIES}):`,
190
+ error,
191
+ );
192
+ const currentPending = this.pendingEntries.get(sessionId) ?? [];
193
+ this.pendingEntries.set(sessionId, [...pending, ...currentPending]);
194
+ this.scheduleFlush(sessionId);
195
+ }
129
196
  }
130
197
  }
131
198
 
@@ -180,7 +247,26 @@ export class SessionLogWriter {
180
247
  private scheduleFlush(sessionId: string): void {
181
248
  const existing = this.flushTimeouts.get(sessionId);
182
249
  if (existing) clearTimeout(existing);
183
- const timeout = setTimeout(() => this.flush(sessionId), 500);
250
+
251
+ const retryCount = this.retryCounts.get(sessionId) ?? 0;
252
+ const lastAttempt = this.lastFlushAttemptTime.get(sessionId) ?? 0;
253
+ const elapsed = Date.now() - lastAttempt;
254
+
255
+ let delay: number;
256
+ if (retryCount > 0) {
257
+ // Exponential backoff on retries: FLUSH_DEBOUNCE_MS * 2^retryCount, capped
258
+ delay = Math.min(
259
+ SessionLogWriter.FLUSH_DEBOUNCE_MS * 2 ** retryCount,
260
+ SessionLogWriter.MAX_RETRY_DELAY_MS,
261
+ );
262
+ } else if (elapsed >= SessionLogWriter.FLUSH_MAX_INTERVAL_MS) {
263
+ // If we've been accumulating for longer than the max interval, flush immediately
264
+ delay = 0;
265
+ } else {
266
+ delay = SessionLogWriter.FLUSH_DEBOUNCE_MS;
267
+ }
268
+
269
+ const timeout = setTimeout(() => this.flush(sessionId), delay);
184
270
  this.flushTimeouts.set(sessionId, timeout);
185
271
  }
186
272
  }