appback-remoteagent 0.15.7 → 0.17.0

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.
@@ -62,18 +62,41 @@ process.env.TELEGRAM_EMPTY_RESPONSE_RETRIES = "0";
62
62
  process.env.TELEGRAM_RETRYABLE_ERROR_RETRIES = "0";
63
63
  process.env.LOCAL_UI_ENABLED = "false";
64
64
 
65
- const [{ createBot }, { BridgeService }, { BotManagementService }, { FileStore }, { AgentMemoryService }] = await Promise.all([
65
+ const [
66
+ { createBot },
67
+ { BridgeService },
68
+ { BotManagementService },
69
+ { FileStore },
70
+ { AgentMemoryService },
71
+ { WorkspaceCleanupService },
72
+ { buildFallbackBotInfo },
73
+ ] = await Promise.all([
66
74
  import(path.join(root, "dist", "bot.js")),
67
75
  import(path.join(root, "dist", "services", "bridge-service.js")),
68
76
  import(path.join(root, "dist", "services", "bot-management-service.js")),
69
77
  import(path.join(root, "dist", "store", "file-store.js")),
70
78
  import(path.join(root, "dist", "services", "agent-memory-service.js")),
79
+ import(path.join(root, "dist", "services", "workspace-cleanup-service.js")),
80
+ import(path.join(root, "dist", "telegram-bot-identity.js")),
71
81
  ]);
72
82
 
83
+ const persistedBotIdentity = buildFallbackBotInfo(
84
+ "8966593034:test-token",
85
+ 0,
86
+ "@appbackadmin_bot",
87
+ );
88
+ if (persistedBotIdentity.username !== "appbackadmin_bot") {
89
+ throw new Error(`Configured bot username was not preserved during getMe fallback: ${persistedBotIdentity.username}`);
90
+ }
91
+
73
92
  const providerCalls = [];
74
93
  let providerMode = "success";
75
94
  let untaggedIntentCalls = 0;
76
95
  let missingEvidenceCalls = 0;
96
+ let queueHoldStartedResolve;
97
+ let queueHoldReleaseResolve;
98
+ let queueHoldStartedPromise = Promise.resolve();
99
+ let queueHoldReleasePromise = Promise.resolve();
77
100
  const provider = {
78
101
  async send(request) {
79
102
  providerCalls.push(request);
@@ -104,6 +127,17 @@ const provider = {
104
127
  : "REPORT:result\n수정 완료했습니다.\n\n근거:\n- 변경 파일: `src/example.ts`\n- 검증: `npm run check` 통과",
105
128
  };
106
129
  }
130
+ if (providerMode === "queue-hold") {
131
+ queueHoldStartedResolve?.();
132
+ await queueHoldReleasePromise;
133
+ return {
134
+ provider: "codex",
135
+ sessionId: request.sessionId || "mock-thread",
136
+ publicSessionId: request.publicSessionId,
137
+ cwd: request.cwd,
138
+ output: "REPORT:result\nactive queue test completed",
139
+ };
140
+ }
107
141
  return {
108
142
  provider: "codex",
109
143
  sessionId: request.sessionId || "mock-thread",
@@ -161,6 +195,34 @@ async function send(text) {
161
195
  await injectedBot.handleUpdates([update(text)]);
162
196
  }
163
197
 
198
+ async function readTelegramCalls() {
199
+ return (await fs.readFile(telegramCalls, "utf8"))
200
+ .trim()
201
+ .split("\n")
202
+ .filter(Boolean)
203
+ .map((line) => {
204
+ const [method, chatId, textB64 = ""] = line.split("\t");
205
+ return {
206
+ method,
207
+ chat_id: chatId,
208
+ text: Buffer.from(textB64, "base64").toString("utf8"),
209
+ };
210
+ });
211
+ }
212
+
213
+ async function waitForTelegramCall(predicate, timeoutMs = 3000) {
214
+ const deadline = Date.now() + timeoutMs;
215
+ while (Date.now() < deadline) {
216
+ const calls = await readTelegramCalls();
217
+ const match = [...calls].reverse().find(predicate);
218
+ if (match) {
219
+ return match;
220
+ }
221
+ await new Promise((resolve) => setTimeout(resolve, 20));
222
+ }
223
+ throw new Error("Timed out waiting for Telegram self-test call.");
224
+ }
225
+
164
226
  await send("/start codex");
165
227
  await send("/option retry 6");
166
228
  await send("/option timeout 600");
@@ -188,6 +250,59 @@ if (!/^TELEGRAM_UNTAGGED_INTENT_RETRIES=4$/m.test(envText)) {
188
250
  throw new Error(`Option command did not persist untagged intent retry limit to .env: ${envText}`);
189
251
  }
190
252
 
253
+ const sessionWorkspace = session.workspace;
254
+ await fs.mkdir(path.join(sessionWorkspace, "node_modules", "left-pad"), { recursive: true });
255
+ await fs.mkdir(path.join(sessionWorkspace, "src"), { recursive: true });
256
+ await fs.writeFile(path.join(sessionWorkspace, "node_modules", "left-pad", "index.js"), "module.exports = 1;\n", "utf8");
257
+ await fs.writeFile(path.join(sessionWorkspace, "src", "keep.ts"), "export const keep = true;\n", "utf8");
258
+ await fs.writeFile(path.join(sessionWorkspace, "debug.log"), "temporary log\n", "utf8");
259
+ await fs.writeFile(path.join(sessionWorkspace, "TODO.md"), "- keep cleanup notes\n", "utf8");
260
+ await send("/cleanup");
261
+ await fs.access(path.join(sessionWorkspace, "TODO.md"));
262
+ if (await pathExists(path.join(sessionWorkspace, "node_modules"))) {
263
+ throw new Error("/cleanup did not remove node_modules");
264
+ }
265
+ if (await pathExists(path.join(sessionWorkspace, "debug.log"))) {
266
+ throw new Error("/cleanup did not remove log file");
267
+ }
268
+ if (await pathExists(path.join(sessionWorkspace, "src"))) {
269
+ throw new Error("/cleanup did not remove regular workspace contents");
270
+ }
271
+
272
+ const orphanWorkspace = path.join(workspaceRoot, "orphan123");
273
+ const referencedWorkspace = sessionWorkspace;
274
+ await fs.mkdir(orphanWorkspace, { recursive: true });
275
+ await fs.writeFile(path.join(orphanWorkspace, "artifact.tmp"), "orphan\n", "utf8");
276
+ const workspaceCleanup = new WorkspaceCleanupService(dataDir, workspaceRoot);
277
+ const orphanResult = await workspaceCleanup.cleanupOrphanWorkspaces();
278
+ if (!/removed=1/.test(orphanResult)) {
279
+ throw new Error(`Expected one orphan workspace removal, got: ${orphanResult}`);
280
+ }
281
+ if (await pathExists(orphanWorkspace)) {
282
+ throw new Error("Orphan workspace was not removed");
283
+ }
284
+ if (!(await pathExists(referencedWorkspace))) {
285
+ throw new Error("Referenced workspace was removed unexpectedly");
286
+ }
287
+
288
+ const missingStateDataDir = path.join(tmp, "missing-state-data");
289
+ const missingStateWorkspaceRoot = path.join(tmp, "missing-state-workspaces");
290
+ const shouldRemain = path.join(missingStateWorkspaceRoot, "should-remain");
291
+ await fs.mkdir(shouldRemain, { recursive: true });
292
+ const missingStateCleanup = new WorkspaceCleanupService(missingStateDataDir, missingStateWorkspaceRoot);
293
+ let refusedMissingState = false;
294
+ try {
295
+ await missingStateCleanup.cleanupOrphanWorkspaces();
296
+ } catch {
297
+ refusedMissingState = true;
298
+ }
299
+ if (!refusedMissingState) {
300
+ throw new Error("Workspace cleanup should refuse to run when state.json is missing");
301
+ }
302
+ if (!(await pathExists(shouldRemain))) {
303
+ throw new Error("Workspace cleanup removed a workspace when state.json was missing");
304
+ }
305
+
191
306
  const memory = new AgentMemoryService(dataDir);
192
307
  const developmentSession = {
193
308
  ...session,
@@ -258,6 +373,9 @@ if (!calls.some((call) => call.method === "sendMessage" && /Set provider executi
258
373
  if (!calls.some((call) => call.method === "sendMessage" && /Set untagged intent retry limit to 4/.test(call.text))) {
259
374
  throw new Error(`Did not see option intent acknowledgement. Calls: ${JSON.stringify(calls, null, 2)}`);
260
375
  }
376
+ if (!calls.some((call) => call.method === "sendMessage" && /Workspace cleanup finished for S001/.test(call.text))) {
377
+ throw new Error(`Did not see workspace cleanup acknowledgement. Calls: ${JSON.stringify(calls, null, 2)}`);
378
+ }
261
379
  if (calls.some((call) => /미완료 TODO|\/task|새 작업으로 접수/.test(call.text))) {
262
380
  throw new Error(`Task gate language leaked to Telegram replies. Calls: ${JSON.stringify(calls, null, 2)}`);
263
381
  }
@@ -333,13 +451,70 @@ const evidenceCalls = (await fs.readFile(telegramCalls, "utf8"))
333
451
  if (missingEvidenceCalls !== 2) {
334
452
  throw new Error(`Expected missing evidence result to be retried once, got ${missingEvidenceCalls}`);
335
453
  }
336
- if (!evidenceCalls.some((call) => /변경 파일: `src\/example\.ts`/.test(call.text) && /`npm run check` 통과/.test(call.text))) {
454
+ if (!evidenceCalls.some((call) =>
455
+ /변경 파일: (?:`|<code>)src\/example\.ts(?:`|<\/code>)/.test(call.text)
456
+ && /(?:`|<code>)npm run check(?:`|<\/code>) 통과/.test(call.text)
457
+ )) {
337
458
  throw new Error(`Did not see recovered result with concrete evidence. Calls: ${JSON.stringify(evidenceCalls, null, 2)}`);
338
459
  }
339
460
  if (evidenceCalls.some((call) => call.method === "sendMessage" && /^수정 완료했습니다\.$/.test(call.text.trim()))) {
340
461
  throw new Error(`Evidence-free completion leaked as final Telegram message. Calls: ${JSON.stringify(evidenceCalls, null, 2)}`);
341
462
  }
342
463
 
464
+ const queueProviderCallsBefore = providerCalls.length;
465
+ providerMode = "queue-hold";
466
+ queueHoldStartedPromise = new Promise((resolve) => {
467
+ queueHoldStartedResolve = resolve;
468
+ });
469
+ queueHoldReleasePromise = new Promise((resolve) => {
470
+ queueHoldReleaseResolve = resolve;
471
+ });
472
+
473
+ await send("/batch start");
474
+ await send("active queue regression test");
475
+ const activeQueueSend = send("/batch send");
476
+ await queueHoldStartedPromise;
477
+
478
+ await send("/batch start");
479
+ await send("first queued instruction");
480
+ const firstQueuedSend = send("/batch send");
481
+ const firstQueueNotice = await waitForTelegramCall((call) => /Queued instruction Q\d+/.test(call.text));
482
+ const firstQueueId = firstQueueNotice.text.match(/Queued instruction (Q\d+)/)?.[1];
483
+ if (!firstQueueId) {
484
+ throw new Error(`First queued instruction did not receive an id: ${firstQueueNotice.text}`);
485
+ }
486
+
487
+ await send("/batch start");
488
+ await send("second queued instruction");
489
+ const secondQueuedSend = send("/batch send");
490
+ const secondQueueNotice = await waitForTelegramCall((call) => {
491
+ const queueId = call.text.match(/Queued instruction (Q\d+)/)?.[1];
492
+ return Boolean(queueId && queueId !== firstQueueId);
493
+ });
494
+ const secondQueueId = secondQueueNotice.text.match(/Queued instruction (Q\d+)/)?.[1];
495
+ if (!secondQueueId) {
496
+ throw new Error(`Second queued instruction did not receive an id: ${secondQueueNotice.text}`);
497
+ }
498
+
499
+ await send("/queue");
500
+ const queueListCall = await waitForTelegramCall((call) =>
501
+ call.text.includes(firstQueueId) && call.text.includes(secondQueueId)
502
+ );
503
+ if (!/Queued instructions for S001 \(2\)/.test(queueListCall.text)) {
504
+ throw new Error(`Queue list did not report both entries: ${queueListCall.text}`);
505
+ }
506
+
507
+ await send(`/queue remove ${firstQueueId}`);
508
+ await waitForTelegramCall((call) => call.text.includes(`Removed queued instruction ${firstQueueId}`));
509
+ await send("/queue del");
510
+ await waitForTelegramCall((call) => call.text.includes(`Removed queued instruction ${secondQueueId}`));
511
+
512
+ queueHoldReleaseResolve?.();
513
+ await Promise.all([activeQueueSend, firstQueuedSend, secondQueuedSend]);
514
+ if (providerCalls.length !== queueProviderCallsBefore + 1) {
515
+ throw new Error(`Removed queued instructions reached the provider: ${providerCalls.length - queueProviderCallsBefore} calls`);
516
+ }
517
+
343
518
  console.log(JSON.stringify({
344
519
  ok: true,
345
520
  dataDir,
@@ -352,8 +527,14 @@ console.log(JSON.stringify({
352
527
  providerCalls: providerCalls.length,
353
528
  untaggedIntentCalls,
354
529
  missingEvidenceCalls,
530
+ queueRemoveById: firstQueueId,
531
+ queueRemoveLatest: secondQueueId,
355
532
  timeoutFinalMessage: true,
356
533
  telegramSendMessages: evidenceCalls.filter((call) => call.method === "sendMessage").length,
357
534
  }, null, 2));
358
535
 
359
536
  process.exit(0);
537
+
538
+ async function pathExists(target) {
539
+ return fs.access(target).then(() => true, () => false);
540
+ }