quadwork 1.11.1 → 1.12.1

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.
Files changed (76) hide show
  1. package/README.md +7 -0
  2. package/bin/quadwork.js +3 -1
  3. package/out/404.html +1 -1
  4. package/out/__next.__PAGE__.txt +1 -1
  5. package/out/__next._full.txt +2 -2
  6. package/out/__next._head.txt +1 -1
  7. package/out/__next._index.txt +2 -2
  8. package/out/__next._tree.txt +2 -2
  9. package/out/_next/static/chunks/0nk3kw~5j75~v.css +2 -0
  10. package/out/_next/static/chunks/0wuucfn72wx0t.js +1 -0
  11. package/out/_next/static/chunks/{0a5314ra5t9bs.js → 11h7y0f5o9.hx.js} +1 -1
  12. package/out/_next/static/chunks/{0ge87xt6a9j~..js → 13w.n.3zipzvz.js} +8 -8
  13. package/out/_not-found/__next._full.txt +2 -2
  14. package/out/_not-found/__next._head.txt +1 -1
  15. package/out/_not-found/__next._index.txt +2 -2
  16. package/out/_not-found/__next._not-found.__PAGE__.txt +1 -1
  17. package/out/_not-found/__next._not-found.txt +1 -1
  18. package/out/_not-found/__next._tree.txt +2 -2
  19. package/out/_not-found.html +1 -1
  20. package/out/_not-found.txt +2 -2
  21. package/out/app-shell/__next._full.txt +2 -2
  22. package/out/app-shell/__next._head.txt +1 -1
  23. package/out/app-shell/__next._index.txt +2 -2
  24. package/out/app-shell/__next._tree.txt +2 -2
  25. package/out/app-shell/__next.app-shell.__PAGE__.txt +1 -1
  26. package/out/app-shell/__next.app-shell.txt +1 -1
  27. package/out/app-shell.html +1 -1
  28. package/out/app-shell.txt +2 -2
  29. package/out/index.html +1 -1
  30. package/out/index.txt +2 -2
  31. package/out/project/_/__next._full.txt +3 -3
  32. package/out/project/_/__next._head.txt +1 -1
  33. package/out/project/_/__next._index.txt +2 -2
  34. package/out/project/_/__next._tree.txt +2 -2
  35. package/out/project/_/__next.project.$d$id.__PAGE__.txt +2 -2
  36. package/out/project/_/__next.project.$d$id.txt +1 -1
  37. package/out/project/_/__next.project.txt +1 -1
  38. package/out/project/_/queue/__next._full.txt +2 -2
  39. package/out/project/_/queue/__next._head.txt +1 -1
  40. package/out/project/_/queue/__next._index.txt +2 -2
  41. package/out/project/_/queue/__next._tree.txt +2 -2
  42. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +1 -1
  43. package/out/project/_/queue/__next.project.$d$id.queue.txt +1 -1
  44. package/out/project/_/queue/__next.project.$d$id.txt +1 -1
  45. package/out/project/_/queue/__next.project.txt +1 -1
  46. package/out/project/_/queue.html +1 -1
  47. package/out/project/_/queue.txt +2 -2
  48. package/out/project/_.html +1 -1
  49. package/out/project/_.txt +3 -3
  50. package/out/settings/__next._full.txt +2 -2
  51. package/out/settings/__next._head.txt +1 -1
  52. package/out/settings/__next._index.txt +2 -2
  53. package/out/settings/__next._tree.txt +2 -2
  54. package/out/settings/__next.settings.__PAGE__.txt +1 -1
  55. package/out/settings/__next.settings.txt +1 -1
  56. package/out/settings.html +1 -1
  57. package/out/settings.txt +2 -2
  58. package/out/setup/__next._full.txt +3 -3
  59. package/out/setup/__next._head.txt +1 -1
  60. package/out/setup/__next._index.txt +2 -2
  61. package/out/setup/__next._tree.txt +2 -2
  62. package/out/setup/__next.setup.__PAGE__.txt +2 -2
  63. package/out/setup/__next.setup.txt +1 -1
  64. package/out/setup.html +1 -1
  65. package/out/setup.txt +3 -3
  66. package/package.json +7 -7
  67. package/server/__tests__/rate-limit-handling.test.js +168 -0
  68. package/server/agentchattr-registry.js +17 -0
  69. package/server/config.js +3 -1
  70. package/server/index.js +64 -23
  71. package/server/routes.js +192 -74
  72. package/out/_next/static/chunks/09h0i4gh79na..js +0 -1
  73. package/out/_next/static/chunks/0a4.d381szseh.css +0 -2
  74. /package/out/_next/static/{QmshV04af9o06krSyFHwf → FC6TKxnxD2CZcQuW_um5N}/_buildManifest.js +0 -0
  75. /package/out/_next/static/{QmshV04af9o06krSyFHwf → FC6TKxnxD2CZcQuW_um5N}/_clientMiddlewareManifest.js +0 -0
  76. /package/out/_next/static/{QmshV04af9o06krSyFHwf → FC6TKxnxD2CZcQuW_um5N}/_ssgManifest.js +0 -0
@@ -0,0 +1,168 @@
1
+ /**
2
+ * #554 — Verify rate-limit-aware caching and backoff in server/routes.js.
3
+ *
4
+ * Tests verify:
5
+ * 1. Rate limit state variables exist and are initialised
6
+ * 2. adaptiveTTL returns extended TTLs when rate is low/critical
7
+ * 3. cachedGhEndpoint serves stale data with _rateLimited flag
8
+ * 4. GitHub endpoints use the cached helper
9
+ * 5. /api/github/rate-limit endpoint is registered
10
+ * 6. Batch progress handler has rate-limit guard
11
+ */
12
+
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+
16
+ const ROUTES_PATH = path.join(__dirname, "..", "routes.js");
17
+ const src = fs.readFileSync(ROUTES_PATH, "utf-8");
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // 1. Rate limit state and constants
21
+ // ---------------------------------------------------------------------------
22
+
23
+ describe("#554 rate limit infrastructure (code analysis)", () => {
24
+ test("_rateLimit state object is defined with expected fields", () => {
25
+ expect(src).toContain("const _rateLimit = {");
26
+ expect(src).toContain("remaining:");
27
+ expect(src).toContain("resetAt:");
28
+ });
29
+
30
+ test("RATE_LIMIT_LOW_THRESHOLD and RATE_LIMIT_CRITICAL are defined", () => {
31
+ expect(src).toMatch(/RATE_LIMIT_LOW_THRESHOLD\s*=\s*\d+/);
32
+ expect(src).toMatch(/RATE_LIMIT_CRITICAL\s*=\s*\d+/);
33
+ });
34
+
35
+ test("refreshRateLimit calls gh api rate_limit", () => {
36
+ expect(src).toContain("gh");
37
+ expect(src).toContain("api");
38
+ expect(src).toContain("rate_limit");
39
+ });
40
+
41
+ test("startRateLimitPolling is called at module load", () => {
42
+ expect(src).toContain("startRateLimitPolling()");
43
+ });
44
+ });
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // 2. Adaptive TTL
48
+ // ---------------------------------------------------------------------------
49
+
50
+ describe("#554 adaptive TTL logic", () => {
51
+ test("adaptiveTTL function is defined", () => {
52
+ expect(src).toContain("function adaptiveTTL(baseTTL)");
53
+ });
54
+
55
+ test("adaptiveTTL returns Infinity when critically rate-limited", () => {
56
+ expect(src).toMatch(/isRateLimited\(\).*Infinity/s);
57
+ });
58
+
59
+ test("adaptiveTTL extends TTL when rate is low", () => {
60
+ expect(src).toMatch(/isRateLow\(\).*120[_,]?000/s);
61
+ });
62
+ });
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // 3. Cached endpoint helper
66
+ // ---------------------------------------------------------------------------
67
+
68
+ describe("#554 cachedGhEndpoint helper", () => {
69
+ test("cachedGhEndpoint function is defined", () => {
70
+ expect(src).toContain("function cachedGhEndpoint(");
71
+ });
72
+
73
+ test("serves stale data with _rateLimited flag when critical", () => {
74
+ const fnStart = src.indexOf("function cachedGhEndpoint(");
75
+ const fnBody = src.slice(fnStart, fnStart + 800);
76
+ expect(fnBody).toContain("_rateLimited");
77
+ expect(fnBody).toContain("_stale");
78
+ });
79
+
80
+ test("uses adaptiveTTL for cache checks", () => {
81
+ const fnStart = src.indexOf("function cachedGhEndpoint(");
82
+ const fnBody = src.slice(fnStart, fnStart + 600);
83
+ expect(fnBody).toContain("adaptiveTTL");
84
+ });
85
+ });
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // 4. GitHub endpoints use cached helper
89
+ // ---------------------------------------------------------------------------
90
+
91
+ describe("#554 GitHub endpoints use cachedGhEndpoint", () => {
92
+ test("/api/github/issues uses cachedGhEndpoint", () => {
93
+ const section = src.slice(
94
+ src.indexOf('"/api/github/issues"'),
95
+ src.indexOf('"/api/github/issues"') + 300,
96
+ );
97
+ expect(section).toContain("cachedGhEndpoint");
98
+ });
99
+
100
+ test("/api/github/prs uses cachedGhEndpoint", () => {
101
+ const section = src.slice(
102
+ src.indexOf('"/api/github/prs"'),
103
+ src.indexOf('"/api/github/prs"') + 300,
104
+ );
105
+ expect(section).toContain("cachedGhEndpoint");
106
+ });
107
+
108
+ test("/api/github/closed-issues uses cachedGhEndpoint", () => {
109
+ const section = src.slice(
110
+ src.indexOf('"/api/github/closed-issues"'),
111
+ src.indexOf('"/api/github/closed-issues"') + 500,
112
+ );
113
+ expect(section).toContain("cachedGhEndpoint");
114
+ });
115
+
116
+ test("/api/github/merged-prs uses cachedGhEndpoint", () => {
117
+ const section = src.slice(
118
+ src.indexOf('"/api/github/merged-prs"'),
119
+ src.indexOf('"/api/github/merged-prs"') + 500,
120
+ );
121
+ expect(section).toContain("cachedGhEndpoint");
122
+ });
123
+ });
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // 5. Rate limit API endpoint
127
+ // ---------------------------------------------------------------------------
128
+
129
+ describe("#554 /api/github/rate-limit endpoint", () => {
130
+ test("endpoint is registered", () => {
131
+ expect(src).toContain('"/api/github/rate-limit"');
132
+ });
133
+
134
+ test("returns remaining, limit, resetInMinutes, low, critical fields", () => {
135
+ const idx = src.indexOf('"/api/github/rate-limit"');
136
+ const section = src.slice(idx, idx + 500);
137
+ expect(section).toContain("remaining");
138
+ expect(section).toContain("limit");
139
+ expect(section).toContain("resetInMinutes");
140
+ expect(section).toContain("low");
141
+ expect(section).toContain("critical");
142
+ });
143
+ });
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // 6. Batch progress rate limit guard
147
+ // ---------------------------------------------------------------------------
148
+
149
+ describe("#554 batch progress rate limit awareness", () => {
150
+ test("batch progress handler checks isRateLimited before gh calls", () => {
151
+ const batchStart = src.indexOf('"/api/batch-progress"');
152
+ const batchSection = src.slice(batchStart, batchStart + 600);
153
+ expect(batchSection).toContain("isRateLimited()");
154
+ expect(batchSection).toContain("_rateLimited");
155
+ });
156
+
157
+ test("batch progress uses adaptiveTTL for cache", () => {
158
+ const batchStart = src.indexOf('"/api/batch-progress"');
159
+ const batchSection = src.slice(batchStart, batchStart + 400);
160
+ expect(batchSection).toContain("adaptiveTTL");
161
+ });
162
+
163
+ test("projects endpoint uses adaptiveTTL for cache", () => {
164
+ const projStart = src.indexOf('"/api/projects"');
165
+ const projSection = src.slice(projStart, projStart + 400);
166
+ expect(projSection).toContain("adaptiveTTL");
167
+ });
168
+ });
@@ -162,9 +162,26 @@ function stopHeartbeat(handle) {
162
162
  if (handle) clearInterval(handle);
163
163
  }
164
164
 
165
+ /**
166
+ * Register an agent with retries and exponential backoff.
167
+ * Returns {name, token, slot} on success, null after all attempts fail.
168
+ * Uses registerAgent internally so registerAgent.lastError is populated.
169
+ */
170
+ async function registerAgentWithRetry(serverPort, base, label = null, { force = false, attempts = 3, delayMs = 2000 } = {}) {
171
+ for (let i = 0; i < attempts; i++) {
172
+ const result = await registerAgent(serverPort, base, label, { force });
173
+ if (result) return result;
174
+ if (i < attempts - 1) {
175
+ await new Promise((res) => setTimeout(res, delayMs * Math.pow(2, i)));
176
+ }
177
+ }
178
+ return null;
179
+ }
180
+
165
181
  module.exports = {
166
182
  waitForAgentChattrReady,
167
183
  registerAgent,
184
+ registerAgentWithRetry,
168
185
  deregisterAgent,
169
186
  startHeartbeat,
170
187
  stopHeartbeat,
package/server/config.js CHANGED
@@ -55,7 +55,9 @@ function sanitizeOperatorName(value) {
55
55
  return truncated;
56
56
  }
57
57
 
58
- // Migration: rename old agent keys to new ones
58
+ // Migration: rename old agent keys to new ones.
59
+ // Keep this map — it migrates pre-v1.8 configs on startup so existing
60
+ // installs transition to the canonical head/dev/re1/re2 slugs.
59
61
  const AGENT_KEY_MAP = { t1: "head", t2a: "re1", t2b: "re2", t3: "dev", reviewer1: "re1", reviewer2: "re2" };
60
62
 
61
63
  function migrateAgentKeys(config) {
package/server/index.js CHANGED
@@ -13,7 +13,7 @@ const {
13
13
  patchAgentchattrConfigForTelegramBridge,
14
14
  projectAgentchattrConfigPath,
15
15
  } = routes;
16
- const { waitForAgentChattrReady, registerAgent, deregisterAgent, startHeartbeat, stopHeartbeat } = require("./agentchattr-registry");
16
+ const { waitForAgentChattrReady, registerAgent, registerAgentWithRetry, deregisterAgent, startHeartbeat, stopHeartbeat } = require("./agentchattr-registry");
17
17
  const { patchAgentchattrCss } = require("./install-agentchattr");
18
18
  const { startQueueWatcher, stopQueueWatcher } = require("./queue-watcher");
19
19
 
@@ -356,7 +356,15 @@ async function buildAgentArgs(projectId, agentId) {
356
356
  // write that into the per-agent MCP config file.
357
357
  const chattrInfo = resolveProjectChattr(projectId);
358
358
  acServerPort = Number(new URL(chattrInfo.url).port) || 8300;
359
- await waitForAgentChattrReady(acServerPort);
359
+ // #565: extend timeout to 30s — first setup may need AC to install
360
+ // (git clone + venv + pip install) before it can bind a port.
361
+ const acReady = await waitForAgentChattrReady(acServerPort, 30000);
362
+ if (!acReady) {
363
+ console.warn(`[#565] Agent ${agentId}: AC not reachable on port ${acServerPort} after 30s. Spawning without chat integration.`);
364
+ // #565: preserve acServerPort and acInjectMode so deferred
365
+ // recovery in spawnAgentPty can retry registration later.
366
+ return { args, acRegistrationName: null, acServerPort, acRegistrationToken: null, acInjectMode: injectMode, acMcpHttpPort: mcpHttpPort || null };
367
+ }
360
368
  // #242: best-effort deregister any stale registration of the
361
369
  // canonical name (left over by a crashed previous QuadWork
362
370
  // session) so the fresh register lands at slot 1 instead of
@@ -370,16 +378,18 @@ async function buildAgentArgs(projectId, agentId) {
370
378
  clearPersistedAgentToken(projectId, agentId);
371
379
  }
372
380
  // #478: force-replace so AC expires any ghost slots for this base
373
- const registration = await registerAgent(acServerPort, agentId, agentCfg.display_name || null, { force: true });
381
+ // #565: retry with backoff and degrade gracefully if AC is not ready
382
+ const registration = await registerAgentWithRetry(acServerPort, agentId, agentCfg.display_name || null, { force: true });
374
383
  if (!registration) {
375
- throw new Error(`Failed to register ${agentId}: ${registerAgent.lastError}`);
384
+ console.warn(`[#565] Agent ${agentId}: AC registration failed after retries (${registerAgent.lastError}). Spawning without chat integration.`);
385
+ } else {
386
+ acRegistrationName = registration.name;
387
+ acRegistrationToken = registration.token;
388
+ writePersistedAgentToken(projectId, agentId, registration.token);
389
+ const mcpConfigPath = writeMcpConfigFile(projectId, agentId, mcpHttpPort, registration.token);
390
+ const flag = agentCfg.mcp_flag || "--mcp-config";
391
+ args.push(flag, mcpConfigPath);
376
392
  }
377
- acRegistrationName = registration.name;
378
- acRegistrationToken = registration.token;
379
- writePersistedAgentToken(projectId, agentId, registration.token);
380
- const mcpConfigPath = writeMcpConfigFile(projectId, agentId, mcpHttpPort, registration.token);
381
- const flag = agentCfg.mcp_flag || "--mcp-config";
382
- args.push(flag, mcpConfigPath);
383
393
  } else if (injectMode === "proxy_flag") {
384
394
  // Codex: register with AgentChattr first (#240) so the proxy
385
395
  // injects a real per-agent token, not the global session token.
@@ -387,7 +397,14 @@ async function buildAgentArgs(projectId, agentId) {
387
397
  // projects without a per-project agentchattr_url still work.
388
398
  const chattrInfo = resolveProjectChattr(projectId);
389
399
  acServerPort = Number(new URL(chattrInfo.url).port) || 8300;
390
- await waitForAgentChattrReady(acServerPort);
400
+ // #565: extend timeout to 30s for first-setup scenario
401
+ const acReady = await waitForAgentChattrReady(acServerPort, 30000);
402
+ if (!acReady) {
403
+ console.warn(`[#565] Agent ${agentId}: AC not reachable on port ${acServerPort} after 30s. Spawning without chat integration.`);
404
+ // #565: preserve acServerPort and acInjectMode so deferred
405
+ // recovery in spawnAgentPty can retry registration later.
406
+ return { args, acRegistrationName: null, acServerPort, acRegistrationToken: null, acInjectMode: injectMode, acMcpHttpPort: mcpHttpPort || null };
407
+ }
391
408
  // #242: best-effort deregister stale canonical name first using
392
409
  // the persisted bearer token from a previous session.
393
410
  const stalePersistedToken = readPersistedAgentToken(projectId, agentId);
@@ -396,17 +413,19 @@ async function buildAgentArgs(projectId, agentId) {
396
413
  clearPersistedAgentToken(projectId, agentId);
397
414
  }
398
415
  // #478: force-replace so AC expires any ghost slots for this base
399
- const registration = await registerAgent(acServerPort, agentId, agentCfg.display_name || null, { force: true });
416
+ // #565: retry with backoff and degrade gracefully if AC is not ready
417
+ const registration = await registerAgentWithRetry(acServerPort, agentId, agentCfg.display_name || null, { force: true });
400
418
  if (!registration) {
401
- throw new Error(`Failed to register ${agentId}: ${registerAgent.lastError}`);
402
- }
403
- acRegistrationName = registration.name;
404
- acRegistrationToken = registration.token;
405
- writePersistedAgentToken(projectId, agentId, registration.token);
406
- const upstreamUrl = `http://127.0.0.1:${mcpHttpPort}`;
407
- const proxyUrl = await startMcpProxy(projectId, agentId, upstreamUrl, registration.token);
408
- if (proxyUrl) {
409
- args.push("-c", `mcp_servers.agentchattr.url="${proxyUrl}"`);
419
+ console.warn(`[#565] Agent ${agentId}: AC registration failed after retries (${registerAgent.lastError}). Spawning without chat integration.`);
420
+ } else {
421
+ acRegistrationName = registration.name;
422
+ acRegistrationToken = registration.token;
423
+ writePersistedAgentToken(projectId, agentId, registration.token);
424
+ const upstreamUrl = `http://127.0.0.1:${mcpHttpPort}`;
425
+ const proxyUrl = await startMcpProxy(projectId, agentId, upstreamUrl, registration.token);
426
+ if (proxyUrl) {
427
+ args.push("-c", `mcp_servers.agentchattr.url="${proxyUrl}"`);
428
+ }
410
429
  }
411
430
  }
412
431
  }
@@ -526,11 +545,14 @@ async function spawnAgentPty(project, agent) {
526
545
  if (!cwd) return { ok: false, error: `Unknown agent: ${key}` };
527
546
 
528
547
  const command = resolveAgentCommand(project, agent) || (process.env.SHELL || "/bin/zsh");
529
- const built = await buildAgentArgs(project, agent);
530
- const args = built.args;
531
548
  const extraEnv = buildAgentEnv(project, agent);
532
549
 
533
550
  try {
551
+ // #565: buildAgentArgs is inside try-catch so registration failures
552
+ // cannot crash the server as an unhandled rejection.
553
+ const built = await buildAgentArgs(project, agent);
554
+ const args = built.args;
555
+
534
556
  const term = pty.spawn(command, args, {
535
557
  name: "xterm-256color",
536
558
  cols: 120,
@@ -611,6 +633,25 @@ async function spawnAgentPty(project, agent) {
611
633
  }
612
634
  }
613
635
 
636
+ // #565: deferred restart — if the agent spawned without AC
637
+ // registration (AC wasn't ready or registration failed), wait for
638
+ // AC to come up then stop + respawn the agent so it gets the full
639
+ // MCP CLI args (--mcp-config / -c mcp_servers...url) that can only
640
+ // be set at process launch time.
641
+ if (!session.acRegistrationName && session.acServerPort && session.acInjectMode) {
642
+ const deferredRestart = async () => {
643
+ const ready = await waitForAgentChattrReady(session.acServerPort, 60000);
644
+ if (!ready) return;
645
+ // Guard: agent may have been stopped manually while we waited.
646
+ const current = agentSessions.get(key);
647
+ if (!current || !current.term || current.state !== "running") return;
648
+ console.log(`[#565] Agent ${agent}: AC is now reachable — restarting agent to gain chat integration.`);
649
+ await stopAgentSession(key);
650
+ await spawnAgentPty(project, agent);
651
+ };
652
+ deferredRestart().catch(() => {});
653
+ }
654
+
614
655
  term.onExit(({ exitCode }) => {
615
656
  const current = agentSessions.get(key);
616
657
  if (current && current.term === term) {