quadwork 1.8.5 → 1.10.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.
- package/README.md +4 -0
- package/bin/quadwork.js +8 -0
- package/bridges/discord/discord_bridge.py +104 -7
- package/out/404.html +1 -1
- package/out/__next.__PAGE__.txt +3 -3
- package/out/__next._full.txt +13 -13
- package/out/__next._head.txt +4 -4
- package/out/__next._index.txt +7 -7
- package/out/__next._tree.txt +2 -2
- package/out/_next/static/chunks/{04jznxp9-kut_.js → 04ui63kyoqv4t.js} +1 -1
- package/out/_next/static/chunks/{0q71bcdksran1.js → 0aldkx8l9xukk.js} +1 -1
- package/out/_next/static/chunks/{0m439m2ljf2gz.js → 0uf3o~m9.vrpj.js} +14 -14
- package/out/_next/static/chunks/0yt_bs94icoma.js +1 -0
- package/out/_next/static/chunks/11r-w4ngz479i.css +2 -0
- package/out/_not-found/__next._full.txt +12 -12
- package/out/_not-found/__next._head.txt +4 -4
- package/out/_not-found/__next._index.txt +7 -7
- package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
- package/out/_not-found/__next._not-found.txt +3 -3
- package/out/_not-found/__next._tree.txt +2 -2
- package/out/_not-found.html +1 -1
- package/out/_not-found.txt +12 -12
- package/out/app-shell/__next._full.txt +12 -12
- package/out/app-shell/__next._head.txt +4 -4
- package/out/app-shell/__next._index.txt +7 -7
- package/out/app-shell/__next._tree.txt +2 -2
- package/out/app-shell/__next.app-shell.__PAGE__.txt +2 -2
- package/out/app-shell/__next.app-shell.txt +3 -3
- package/out/app-shell.html +1 -1
- package/out/app-shell.txt +12 -12
- package/out/index.html +1 -1
- package/out/index.txt +13 -13
- package/out/project/_/__next._full.txt +13 -13
- package/out/project/_/__next._head.txt +4 -4
- package/out/project/_/__next._index.txt +7 -7
- package/out/project/_/__next._tree.txt +2 -2
- package/out/project/_/__next.project.$d$id.__PAGE__.txt +3 -3
- package/out/project/_/__next.project.$d$id.txt +3 -3
- package/out/project/_/__next.project.txt +3 -3
- package/out/project/_/queue/__next._full.txt +13 -13
- package/out/project/_/queue/__next._head.txt +4 -4
- package/out/project/_/queue/__next._index.txt +7 -7
- package/out/project/_/queue/__next._tree.txt +2 -2
- package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +3 -3
- package/out/project/_/queue/__next.project.$d$id.queue.txt +3 -3
- package/out/project/_/queue/__next.project.$d$id.txt +3 -3
- package/out/project/_/queue/__next.project.txt +3 -3
- package/out/project/_/queue.html +1 -1
- package/out/project/_/queue.txt +13 -13
- package/out/project/_.html +1 -1
- package/out/project/_.txt +13 -13
- package/out/settings/__next._full.txt +13 -13
- package/out/settings/__next._head.txt +4 -4
- package/out/settings/__next._index.txt +7 -7
- package/out/settings/__next._tree.txt +2 -2
- package/out/settings/__next.settings.__PAGE__.txt +3 -3
- package/out/settings/__next.settings.txt +3 -3
- package/out/settings.html +1 -1
- package/out/settings.txt +13 -13
- package/out/setup/__next._full.txt +13 -13
- package/out/setup/__next._head.txt +4 -4
- package/out/setup/__next._index.txt +7 -7
- package/out/setup/__next._tree.txt +2 -2
- package/out/setup/__next.setup.__PAGE__.txt +3 -3
- package/out/setup/__next.setup.txt +3 -3
- package/out/setup.html +1 -1
- package/out/setup.txt +13 -13
- package/package.json +1 -1
- package/server/agentchattr-registry.js +1 -1
- package/server/index.js +232 -6
- package/server/routes.js +69 -48
- package/out/_next/static/chunks/0bigpiw-1zroq.css +0 -2
- package/out/_next/static/chunks/152f2hu-ivy6f.js +0 -1
- /package/out/_next/static/{_-PNrmPXTJt_5nZEhsDM3 → mxlP6esPG86fhzv01dzCW}/_buildManifest.js +0 -0
- /package/out/_next/static/{_-PNrmPXTJt_5nZEhsDM3 → mxlP6esPG86fhzv01dzCW}/_clientMiddlewareManifest.js +0 -0
- /package/out/_next/static/{_-PNrmPXTJt_5nZEhsDM3 → mxlP6esPG86fhzv01dzCW}/_ssgManifest.js +0 -0
package/out/setup.txt
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
1:"$Sreact.fragment"
|
|
2
|
-
2:I[43688,["/_next/static/chunks/
|
|
3
|
-
3:I[26704,["/_next/static/chunks/
|
|
4
|
-
4:I[22140,["/_next/static/chunks/
|
|
5
|
-
5:I[39756,["/_next/static/chunks/
|
|
6
|
-
6:I[37457,["/_next/static/chunks/
|
|
7
|
-
7:I[94810,["/_next/static/chunks/
|
|
8
|
-
8:I[97367,["/_next/static/chunks/
|
|
2
|
+
2:I[43688,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"default"]
|
|
3
|
+
3:I[26704,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"default"]
|
|
4
|
+
4:I[22140,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"default"]
|
|
5
|
+
5:I[39756,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"default"]
|
|
6
|
+
6:I[37457,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"default"]
|
|
7
|
+
7:I[94810,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/09h0i4gh79na..js"],"default"]
|
|
8
|
+
8:I[97367,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"OutletBoundary"]
|
|
9
9
|
9:"$Sreact.suspense"
|
|
10
|
-
c:I[97367,["/_next/static/chunks/
|
|
11
|
-
e:I[97367,["/_next/static/chunks/
|
|
12
|
-
10:I[68027,["/_next/static/chunks/
|
|
13
|
-
:HL["/_next/static/chunks/
|
|
10
|
+
c:I[97367,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"ViewportBoundary"]
|
|
11
|
+
e:I[97367,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"MetadataBoundary"]
|
|
12
|
+
10:I[68027,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"default",1]
|
|
13
|
+
:HL["/_next/static/chunks/11r-w4ngz479i.css","style"]
|
|
14
14
|
:HL["/_next/static/media/797e433ab948586e-s.p.0.q-h669a_dqa.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
|
15
|
-
0:{"P":null,"c":["","setup"],"q":"","i":false,"f":[[["",{"children":["setup",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/
|
|
15
|
+
0:{"P":null,"c":["","setup"],"q":"","i":false,"f":[[["",{"children":["setup",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/11r-w4ngz479i.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/0yt_bs94icoma.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/0d3shmwh5_nmn.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","className":"geist_mono_8d43a2aa-module__8Li5zG__variable h-full","children":["$","body",null,{"className":"h-full flex flex-col","children":[["$","$L2",null,{}],["$","$L3",null,{}],["$","div",null,{"className":"flex flex-1 min-h-0","children":[["$","$L4",null,{}],["$","main",null,{"className":"flex-1 min-w-0 overflow-auto","children":["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]]}]]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[["$","$L7",null,{}],[["$","script","script-0",{"src":"/_next/static/chunks/09h0i4gh79na..js","async":true,"nonce":"$undefined"}]],["$","$L8",null,{"children":["$","$9",null,{"name":"Next.MetadataOutlet","children":"$@a"}]}]]}],{},null,false,null]},null,false,"$@b"]},null,false,null],["$","$1","h",{"children":[null,["$","$Lc",null,{"children":"$Ld"}],["$","div",null,{"hidden":true,"children":["$","$Le",null,{"children":["$","$9",null,{"name":"Next.Metadata","children":"$Lf"}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],false]],"m":"$undefined","G":["$10",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/11r-w4ngz479i.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"mxlP6esPG86fhzv01dzCW"}
|
|
16
16
|
11:[]
|
|
17
17
|
b:"$W11"
|
|
18
18
|
d:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
|
19
|
-
12:I[27201,["/_next/static/chunks/
|
|
19
|
+
12:I[27201,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"IconMark"]
|
|
20
20
|
a:null
|
|
21
21
|
f:[["$","title","0",{"children":"QuadWork"}],["$","meta","1",{"name":"description","content":"Unified dashboard for multi-agent coding teams"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.05o2q2p4kvnq_.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L12","3",{}]]
|
package/package.json
CHANGED
|
@@ -101,7 +101,7 @@ async function deregisterAgent(serverPort, name, token) {
|
|
|
101
101
|
/**
|
|
102
102
|
* Start a per-agent heartbeat that POSTs /api/heartbeat/{name} every 5s
|
|
103
103
|
* with bearer auth. AgentChattr considers an agent crashed and removes
|
|
104
|
-
* it after ~
|
|
104
|
+
* it after ~120s without a heartbeat, so without this every registered
|
|
105
105
|
* QuadWork agent silently disappears from the channel one minute after
|
|
106
106
|
* registration.
|
|
107
107
|
*
|
package/server/index.js
CHANGED
|
@@ -1298,9 +1298,121 @@ Dev: Work on assigned ticket or address review feedback.
|
|
|
1298
1298
|
RE1/RE2: Review open PRs. If Dev pushed fixes, re-review. Post verdict on PR AND notify here.
|
|
1299
1299
|
ALL: Communicate via this chat by tagging agents. Your terminal is NOT visible.`;
|
|
1300
1300
|
|
|
1301
|
+
// #518: server-side bridge lifecycle helpers. Stop and start Telegram +
|
|
1302
|
+
// Discord bridges so they respond to batch transitions even when the
|
|
1303
|
+
// operator is on a different project page.
|
|
1304
|
+
|
|
1305
|
+
async function autoStopBridges(projectId, project, qwPort) {
|
|
1306
|
+
if (project?.telegram_auto) {
|
|
1307
|
+
try {
|
|
1308
|
+
await fetch(`http://127.0.0.1:${qwPort}/api/telegram?action=stop`, {
|
|
1309
|
+
method: "POST",
|
|
1310
|
+
headers: { "Content-Type": "application/json" },
|
|
1311
|
+
body: JSON.stringify({ project_id: projectId }),
|
|
1312
|
+
signal: AbortSignal.timeout(5000),
|
|
1313
|
+
});
|
|
1314
|
+
console.log(`[auto-bridge] ${projectId}: telegram bridge auto-stopped`);
|
|
1315
|
+
} catch { /* non-fatal */ }
|
|
1316
|
+
}
|
|
1317
|
+
if (project?.discord_auto) {
|
|
1318
|
+
try {
|
|
1319
|
+
await fetch(`http://127.0.0.1:${qwPort}/api/discord?action=stop`, {
|
|
1320
|
+
method: "POST",
|
|
1321
|
+
headers: { "Content-Type": "application/json" },
|
|
1322
|
+
body: JSON.stringify({ project_id: projectId }),
|
|
1323
|
+
signal: AbortSignal.timeout(5000),
|
|
1324
|
+
});
|
|
1325
|
+
console.log(`[auto-bridge] ${projectId}: discord bridge auto-stopped`);
|
|
1326
|
+
} catch { /* non-fatal */ }
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
async function autoStartBridges(projectId, project, qwPort) {
|
|
1331
|
+
if (project?.telegram_auto) {
|
|
1332
|
+
try {
|
|
1333
|
+
// Check if already running before starting
|
|
1334
|
+
const st = await fetch(
|
|
1335
|
+
`http://127.0.0.1:${qwPort}/api/telegram?project=${encodeURIComponent(projectId)}`,
|
|
1336
|
+
{ signal: AbortSignal.timeout(5000) }
|
|
1337
|
+
);
|
|
1338
|
+
if (st.ok) {
|
|
1339
|
+
const data = await st.json();
|
|
1340
|
+
if (data.running) return; // already running
|
|
1341
|
+
if (!data.configured) return; // not configured — can't start
|
|
1342
|
+
}
|
|
1343
|
+
await fetch(`http://127.0.0.1:${qwPort}/api/telegram?action=start`, {
|
|
1344
|
+
method: "POST",
|
|
1345
|
+
headers: { "Content-Type": "application/json" },
|
|
1346
|
+
body: JSON.stringify({ project_id: projectId }),
|
|
1347
|
+
signal: AbortSignal.timeout(10000),
|
|
1348
|
+
});
|
|
1349
|
+
console.log(`[auto-bridge] ${projectId}: telegram bridge auto-started`);
|
|
1350
|
+
} catch { /* non-fatal */ }
|
|
1351
|
+
}
|
|
1352
|
+
if (project?.discord_auto) {
|
|
1353
|
+
try {
|
|
1354
|
+
const st = await fetch(
|
|
1355
|
+
`http://127.0.0.1:${qwPort}/api/discord?project=${encodeURIComponent(projectId)}`,
|
|
1356
|
+
{ signal: AbortSignal.timeout(5000) }
|
|
1357
|
+
);
|
|
1358
|
+
if (st.ok) {
|
|
1359
|
+
const data = await st.json();
|
|
1360
|
+
if (data.running) return;
|
|
1361
|
+
if (!data.configured) return;
|
|
1362
|
+
}
|
|
1363
|
+
await fetch(`http://127.0.0.1:${qwPort}/api/discord?action=start`, {
|
|
1364
|
+
method: "POST",
|
|
1365
|
+
headers: { "Content-Type": "application/json" },
|
|
1366
|
+
body: JSON.stringify({ project_id: projectId }),
|
|
1367
|
+
signal: AbortSignal.timeout(10000),
|
|
1368
|
+
});
|
|
1369
|
+
console.log(`[auto-bridge] ${projectId}: discord bridge auto-started`);
|
|
1370
|
+
} catch { /* non-fatal */ }
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// Track previous batch state per project for bridge auto-start detection
|
|
1375
|
+
const _bridgeBatchPrev = new Map();
|
|
1376
|
+
|
|
1301
1377
|
async function sendTriggerMessage(projectId) {
|
|
1302
1378
|
const cfg = readConfig();
|
|
1303
1379
|
const project = cfg.projects && cfg.projects.find((p) => p.id === projectId);
|
|
1380
|
+
|
|
1381
|
+
// #516: server-side auto-stop — check batch progress before sending.
|
|
1382
|
+
// When trigger_auto is enabled, skip the message and stop the trigger
|
|
1383
|
+
// (plus caffeinate) if the batch is already complete. This covers the
|
|
1384
|
+
// case where the operator is on a different page and the client-side
|
|
1385
|
+
// ScheduledTriggerWidget is not mounted to detect completion.
|
|
1386
|
+
if (project && project.trigger_auto) {
|
|
1387
|
+
const qwPort = cfg.port || 8400;
|
|
1388
|
+
try {
|
|
1389
|
+
const bpRes = await fetch(
|
|
1390
|
+
`http://127.0.0.1:${qwPort}/api/batch-progress?project=${encodeURIComponent(projectId)}`
|
|
1391
|
+
);
|
|
1392
|
+
if (bpRes.ok) {
|
|
1393
|
+
const bp = await bpRes.json();
|
|
1394
|
+
if (bp && bp.complete) {
|
|
1395
|
+
console.log(`[auto-trigger] ${projectId}: batch complete, auto-stopped`);
|
|
1396
|
+
stopTrigger(projectId);
|
|
1397
|
+
// Also stop caffeinate if no other triggers remain running
|
|
1398
|
+
// (#441 companion fix). caffeinateProcess is global (not
|
|
1399
|
+
// project-scoped), so only kill it when all work is done.
|
|
1400
|
+
if (caffeinateProcess.process && triggers.size === 0) {
|
|
1401
|
+
try { caffeinateProcess.process.kill("SIGTERM"); } catch {}
|
|
1402
|
+
caffeinateProcess = { process: null, pid: null, startedAt: null, duration: null };
|
|
1403
|
+
console.log(`[auto-trigger] ${projectId}: caffeinate auto-stopped (no active triggers remain)`);
|
|
1404
|
+
}
|
|
1405
|
+
// #518: also stop bridges when batch completes
|
|
1406
|
+
await autoStopBridges(projectId, project, qwPort);
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
} catch (err) {
|
|
1411
|
+
// Non-fatal — if batch-progress fails, proceed with the message
|
|
1412
|
+
console.error(`[auto-trigger] ${projectId}: batch-progress check failed:`, err.message);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1304
1416
|
const message = (project && project.trigger_message) || DEFAULT_MESSAGE;
|
|
1305
1417
|
|
|
1306
1418
|
// #401 / quadwork#277: route trigger sends through the local
|
|
@@ -1721,6 +1833,67 @@ function syncTriggersFromConfig() {
|
|
|
1721
1833
|
}
|
|
1722
1834
|
}
|
|
1723
1835
|
|
|
1836
|
+
// #516: server-side batch-completion poller. Checks every 30s whether
|
|
1837
|
+
// any trigger_auto project's batch is complete, and auto-stops the
|
|
1838
|
+
// trigger (plus caffeinate when no triggers remain). This runs
|
|
1839
|
+
// independently of the trigger tick interval, so completion is
|
|
1840
|
+
// detected within 30s even if the operator is on a different page.
|
|
1841
|
+
// #518: also handles telegram_auto / discord_auto bridge lifecycle
|
|
1842
|
+
// (both start and stop) so bridges respond to batch transitions
|
|
1843
|
+
// even when the operator is viewing a different project page.
|
|
1844
|
+
|
|
1845
|
+
const AUTO_STOP_POLL_INTERVAL_MS = 30_000;
|
|
1846
|
+
|
|
1847
|
+
async function autoStopPollingTick() {
|
|
1848
|
+
const cfg = readConfig();
|
|
1849
|
+
if (!cfg.projects) return;
|
|
1850
|
+
|
|
1851
|
+
for (const project of cfg.projects) {
|
|
1852
|
+
const hasTriggerAuto = project.trigger_auto && triggers.has(project.id);
|
|
1853
|
+
const hasBridgeAuto = project.telegram_auto || project.discord_auto;
|
|
1854
|
+
if (!hasTriggerAuto && !hasBridgeAuto) continue;
|
|
1855
|
+
const qwPort = cfg.port || 8400;
|
|
1856
|
+
try {
|
|
1857
|
+
const res = await fetch(
|
|
1858
|
+
`http://127.0.0.1:${qwPort}/api/batch-progress?project=${encodeURIComponent(project.id)}`
|
|
1859
|
+
);
|
|
1860
|
+
if (!res.ok) continue;
|
|
1861
|
+
const bp = await res.json();
|
|
1862
|
+
const hasItems = bp.items && bp.items.length > 0;
|
|
1863
|
+
const prev = _bridgeBatchPrev.get(project.id);
|
|
1864
|
+
_bridgeBatchPrev.set(project.id, { complete: bp.complete, hasItems });
|
|
1865
|
+
|
|
1866
|
+
if (bp && bp.complete) {
|
|
1867
|
+
if (hasTriggerAuto) {
|
|
1868
|
+
console.log(`[auto-trigger] ${project.id}: batch complete, auto-stopped (poller)`);
|
|
1869
|
+
stopTrigger(project.id);
|
|
1870
|
+
if (caffeinateProcess.process && triggers.size === 0) {
|
|
1871
|
+
try { caffeinateProcess.process.kill("SIGTERM"); } catch {}
|
|
1872
|
+
caffeinateProcess = { process: null, pid: null, startedAt: null, duration: null };
|
|
1873
|
+
console.log(`[auto-trigger] ${project.id}: caffeinate auto-stopped (no active triggers remain)`);
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
// #518: also stop bridges when batch completes
|
|
1877
|
+
if (hasBridgeAuto) {
|
|
1878
|
+
await autoStopBridges(project.id, project, qwPort);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// #518: detect batch-start transition → auto-start bridges
|
|
1883
|
+
if (hasBridgeAuto && hasItems && !bp.complete) {
|
|
1884
|
+
const isNewBatch = !prev || prev.complete || !prev.hasItems;
|
|
1885
|
+
if (isNewBatch) {
|
|
1886
|
+
await autoStartBridges(project.id, project, qwPort);
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
} catch {
|
|
1890
|
+
// Non-fatal — retry on next tick
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
setInterval(autoStopPollingTick, AUTO_STOP_POLL_INTERVAL_MS);
|
|
1896
|
+
|
|
1724
1897
|
// #422 / quadwork#310: auto-continue after loop guard.
|
|
1725
1898
|
//
|
|
1726
1899
|
// Per opted-in project, poll AC's /api/status every 10s. When we see
|
|
@@ -1983,6 +2156,20 @@ server.listen(PORT, "127.0.0.1", () => {
|
|
|
1983
2156
|
}
|
|
1984
2157
|
} catch {}
|
|
1985
2158
|
}
|
|
2159
|
+
// #506: refresh Discord bridge script from the npm package on startup.
|
|
2160
|
+
// The Telegram bridge uses git-fetch + pin, but Discord uses a file-copy
|
|
2161
|
+
// pattern. Without this, upgrading QuadWork leaves a stale on-disk script
|
|
2162
|
+
// missing fixes shipped in newer versions.
|
|
2163
|
+
const DISCORD_BRIDGE_SRC = path.join(__dirname, "..", "bridges", "discord", "discord_bridge.py");
|
|
2164
|
+
const DISCORD_BRIDGE_DEST = path.join(os.homedir(), ".quadwork", "agentchattr-discord", "discord_bridge.py");
|
|
2165
|
+
if (fs.existsSync(DISCORD_BRIDGE_SRC) && fs.existsSync(path.dirname(DISCORD_BRIDGE_DEST))) {
|
|
2166
|
+
try {
|
|
2167
|
+
fs.copyFileSync(DISCORD_BRIDGE_SRC, DISCORD_BRIDGE_DEST);
|
|
2168
|
+
console.log("[bridge-refresh] refreshed Discord bridge script from package");
|
|
2169
|
+
} catch (err) {
|
|
2170
|
+
console.warn(`[bridge-refresh] failed to refresh Discord bridge script: ${err.message || err}`);
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
1986
2173
|
// #470: patch stale bridge_sender defaults in on-disk bridge scripts.
|
|
1987
2174
|
// The AC config migration (#457) renames the agent sections, but the
|
|
1988
2175
|
// bridge scripts themselves may still have old defaults if the operator
|
|
@@ -2039,10 +2226,10 @@ server.listen(PORT, "127.0.0.1", () => {
|
|
|
2039
2226
|
}
|
|
2040
2227
|
}
|
|
2041
2228
|
}
|
|
2042
|
-
// #478: patch deployed AgentChattr instances to support force-replace
|
|
2043
|
-
// on register
|
|
2229
|
+
// #478 + #502: patch deployed AgentChattr instances to support force-replace
|
|
2230
|
+
// on register and fix idle-agent crash timeout.
|
|
2044
2231
|
for (const p of (startupCfg.projects || [])) {
|
|
2045
|
-
const acDir =
|
|
2232
|
+
const acDir = resolveProjectChattr(p.id).dir;
|
|
2046
2233
|
// Patch registry.py: add force parameter to register()
|
|
2047
2234
|
const regPath = path.join(acDir, "registry.py");
|
|
2048
2235
|
if (fs.existsSync(regPath)) {
|
|
@@ -2058,17 +2245,35 @@ server.listen(PORT, "127.0.0.1", () => {
|
|
|
2058
2245
|
reg = reg.replace(
|
|
2059
2246
|
" self._expire_reserved()\n\n # Find next free slot",
|
|
2060
2247
|
" self._expire_reserved()\n\n" +
|
|
2061
|
-
" # quadwork#478: force-replace — expire all existing slots
|
|
2062
|
-
" # base so the new registration always lands at slot 1.\n" +
|
|
2248
|
+
" # quadwork#478 + #502: force-replace — expire all existing slots\n" +
|
|
2249
|
+
" # for this base so the new registration always lands at slot 1.\n" +
|
|
2250
|
+
" # Also clear _reserved entries: after a crash-timeout the old name\n" +
|
|
2251
|
+
" # lives only in _reserved, so without this the grace period still\n" +
|
|
2252
|
+
" # blocks slot 1 and the agent gets a -2 suffix.\n" +
|
|
2063
2253
|
" if force:\n" +
|
|
2064
2254
|
" ghosts = [n for n, i in self._instances.items() if i.base == base]\n" +
|
|
2065
2255
|
" for name in ghosts:\n" +
|
|
2066
2256
|
" del self._instances[name]\n" +
|
|
2067
|
-
"
|
|
2257
|
+
" stale_reserved = [rn for rn in self._reserved\n" +
|
|
2258
|
+
" if self._parse_name(rn)[0] == base]\n" +
|
|
2259
|
+
" for rn in stale_reserved:\n" +
|
|
2260
|
+
" del self._reserved[rn]\n\n" +
|
|
2068
2261
|
" # Find next free slot",
|
|
2069
2262
|
);
|
|
2070
2263
|
fs.writeFileSync(regPath, reg);
|
|
2071
2264
|
console.log(`[ghost-fix] ${p.id}: patched registry.py with force-replace support`);
|
|
2265
|
+
} else if (!reg.includes("stale_reserved")) {
|
|
2266
|
+
// #502: upgrade existing force-replace patch to also clear _reserved
|
|
2267
|
+
reg = reg.replace(
|
|
2268
|
+
/( +)for name in ghosts:\n\1 del self\._instances\[name\]\n\1 self\._reserved\[name\] = time\.time\(\)/,
|
|
2269
|
+
"$1for name in ghosts:\n$1 del self._instances[name]\n" +
|
|
2270
|
+
"$1stale_reserved = [rn for rn in self._reserved\n" +
|
|
2271
|
+
"$1 if self._parse_name(rn)[0] == base]\n" +
|
|
2272
|
+
"$1for rn in stale_reserved:\n" +
|
|
2273
|
+
"$1 del self._reserved[rn]",
|
|
2274
|
+
);
|
|
2275
|
+
fs.writeFileSync(regPath, reg);
|
|
2276
|
+
console.log(`[ghost-fix] ${p.id}: upgraded registry.py force-replace to clear _reserved (#502)`);
|
|
2072
2277
|
}
|
|
2073
2278
|
} catch (err) {
|
|
2074
2279
|
console.warn(`[ghost-fix] ${p.id}: failed to patch registry.py: ${err.message}`);
|
|
@@ -2091,6 +2296,27 @@ server.listen(PORT, "127.0.0.1", () => {
|
|
|
2091
2296
|
console.warn(`[ghost-fix] ${p.id}: failed to patch app.py: ${err.message}`);
|
|
2092
2297
|
}
|
|
2093
2298
|
}
|
|
2299
|
+
// #502: increase crash timeout from 15s to 120s for idle agent tolerance
|
|
2300
|
+
if (fs.existsSync(appPath)) {
|
|
2301
|
+
try {
|
|
2302
|
+
let app = fs.readFileSync(appPath, "utf-8");
|
|
2303
|
+
if (app.includes("_CRASH_TIMEOUT = 15")) {
|
|
2304
|
+
app = app.replace(
|
|
2305
|
+
"_CRASH_TIMEOUT = 15",
|
|
2306
|
+
"_CRASH_TIMEOUT = 120",
|
|
2307
|
+
);
|
|
2308
|
+
// Fix the misleading comment too
|
|
2309
|
+
app = app.replace(
|
|
2310
|
+
"# Crash timeout: if a wrapper hasn't heartbeated for 60s,\n",
|
|
2311
|
+
"# Crash timeout: if a wrapper hasn't heartbeated for 120s,\n",
|
|
2312
|
+
);
|
|
2313
|
+
fs.writeFileSync(appPath, app);
|
|
2314
|
+
console.log(`[idle-fix] ${p.id}: increased crash timeout to 120s (#502)`);
|
|
2315
|
+
}
|
|
2316
|
+
} catch (err) {
|
|
2317
|
+
console.warn(`[idle-fix] ${p.id}: failed to patch app.py crash timeout: ${err.message}`);
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2094
2320
|
}
|
|
2095
2321
|
// #416: start the AC health monitor
|
|
2096
2322
|
startAcHealthMonitor();
|
package/server/routes.js
CHANGED
|
@@ -1069,17 +1069,17 @@ router.get("/api/uploads/:project/:filename", (req, res) => {
|
|
|
1069
1069
|
|
|
1070
1070
|
// ─── Projects (dashboard aggregation) ──────────────────────────────────────
|
|
1071
1071
|
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
} catch {
|
|
1078
|
-
return [];
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1072
|
+
// #512: cache /api/projects results for 60s to eliminate repeated
|
|
1073
|
+
// slow gh CLI calls on every dashboard poll.
|
|
1074
|
+
let _projectsCache = null;
|
|
1075
|
+
let _projectsCacheTs = 0;
|
|
1076
|
+
const PROJECTS_CACHE_TTL = 60_000;
|
|
1081
1077
|
|
|
1082
1078
|
router.get("/api/projects", async (req, res) => {
|
|
1079
|
+
if (_projectsCache && Date.now() - _projectsCacheTs < PROJECTS_CACHE_TTL) {
|
|
1080
|
+
return res.json(_projectsCache);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
1083
|
const cfg = readConfigFile();
|
|
1084
1084
|
|
|
1085
1085
|
// Fetch active sessions from our own in-memory state (only running PTYs)
|
|
@@ -1113,24 +1113,34 @@ router.get("/api/projects", async (req, res) => {
|
|
|
1113
1113
|
.slice(-10)
|
|
1114
1114
|
.reverse();
|
|
1115
1115
|
|
|
1116
|
-
|
|
1117
|
-
|
|
1116
|
+
// #512: build project-id-to-name map from config and a reverse
|
|
1117
|
+
// lookup from chat message to project name via chatMsgsByProject
|
|
1118
|
+
// (which already knows which AC instance each message came from).
|
|
1119
|
+
// This replaces the expensive allPrs/allIssues gh CLI calls that
|
|
1120
|
+
// were only used for the numberToProject mapping.
|
|
1121
|
+
const projectIdToName = {};
|
|
1122
|
+
for (const p of cfg.projects || []) projectIdToName[p.id] = p.name;
|
|
1123
|
+
const msgToProject = new Map();
|
|
1124
|
+
for (const [pid, msgs] of Object.entries(chatMsgsByProject)) {
|
|
1125
|
+
for (const m of msgs) msgToProject.set(m, projectIdToName[pid]);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// #512: parallelize gh CLI calls across projects using async exec.
|
|
1129
|
+
// Only fetch open PR count and most recent PR activity — drop the
|
|
1130
|
+
// allPrs/allIssues calls that were only used for numberToProject.
|
|
1131
|
+
async function fetchProjectGhData(p) {
|
|
1118
1132
|
let openPrs = 0;
|
|
1119
1133
|
let lastActivity = null;
|
|
1120
|
-
|
|
1121
1134
|
if (REPO_RE.test(p.repo)) {
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
const allIssues = ghJson(["issue", "list", "-R", p.repo, "--state", "all", "--json", "number", "--limit", "100"]);
|
|
1131
|
-
for (const issue of allIssues) numberToProject[issue.number] = p.name;
|
|
1135
|
+
try {
|
|
1136
|
+
const [prs, recentPrs] = await Promise.allSettled([
|
|
1137
|
+
ghJsonExecAsync(["pr", "list", "-R", p.repo, "--json", "number", "--limit", "100"]),
|
|
1138
|
+
ghJsonExecAsync(["pr", "list", "-R", p.repo, "--state", "all", "--json", "updatedAt", "--limit", "1"]),
|
|
1139
|
+
]);
|
|
1140
|
+
if (prs.status === "fulfilled") openPrs = prs.value.length;
|
|
1141
|
+
if (recentPrs.status === "fulfilled") lastActivity = recentPrs.value[0]?.updatedAt || null;
|
|
1142
|
+
} catch {}
|
|
1132
1143
|
}
|
|
1133
|
-
|
|
1134
1144
|
const hasAgents = p.agents && Object.keys(p.agents).length > 0;
|
|
1135
1145
|
return {
|
|
1136
1146
|
id: p.id,
|
|
@@ -1141,20 +1151,21 @@ router.get("/api/projects", async (req, res) => {
|
|
|
1141
1151
|
state: hasAgents && activeProjectIds.has(p.id) ? "active" : "idle",
|
|
1142
1152
|
lastActivity,
|
|
1143
1153
|
};
|
|
1144
|
-
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const projectResults = await Promise.all(
|
|
1157
|
+
(cfg.projects || []).map((p) => fetchProjectGhData(p))
|
|
1158
|
+
);
|
|
1145
1159
|
|
|
1146
|
-
// Build activity feed
|
|
1160
|
+
// Build activity feed — use chat-based project association instead
|
|
1161
|
+
// of the dropped numberToProject gh lookup.
|
|
1147
1162
|
const recentEvents = [];
|
|
1148
1163
|
for (const m of workflowMsgs) {
|
|
1164
|
+
// First: try text match against repo/project name
|
|
1149
1165
|
let projectName = (cfg.projects || []).find((p) => m.text.includes(p.repo) || m.text.includes(p.name))?.name;
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
}
|
|
1154
|
-
if (!projectName) {
|
|
1155
|
-
const branchMatch = m.text.match(/task\/(\d+)/);
|
|
1156
|
-
if (branchMatch) projectName = numberToProject[parseInt(branchMatch[1], 10)];
|
|
1157
|
-
}
|
|
1166
|
+
// Second: use the AC instance the message came from
|
|
1167
|
+
if (!projectName) projectName = msgToProject.get(m);
|
|
1168
|
+
// Fallback: single-project installs
|
|
1158
1169
|
if (!projectName && cfg.projects && cfg.projects.length === 1) {
|
|
1159
1170
|
projectName = cfg.projects[0].name;
|
|
1160
1171
|
}
|
|
@@ -1169,7 +1180,10 @@ router.get("/api/projects", async (req, res) => {
|
|
|
1169
1180
|
if (recentEvents.length >= 10) break;
|
|
1170
1181
|
}
|
|
1171
1182
|
|
|
1172
|
-
|
|
1183
|
+
const result = { projects: projectResults, recentEvents };
|
|
1184
|
+
_projectsCache = result;
|
|
1185
|
+
_projectsCacheTs = Date.now();
|
|
1186
|
+
res.json(result);
|
|
1173
1187
|
});
|
|
1174
1188
|
|
|
1175
1189
|
// ─── GitHub Issues / PRs ───────────────────────────────────────────────────
|
|
@@ -1881,6 +1895,14 @@ router.post("/api/setup", (req, res) => {
|
|
|
1881
1895
|
const clone = exec("gh", ["repo", "clone", body.repo, workingDir]);
|
|
1882
1896
|
if (!clone.ok) return res.json({ ok: false, error: `Clone failed: ${clone.output}` });
|
|
1883
1897
|
}
|
|
1898
|
+
// Empty repos have no commits — git worktree add requires at least one.
|
|
1899
|
+
const headCheck = exec("git", ["rev-parse", "HEAD"], { cwd: workingDir });
|
|
1900
|
+
if (!headCheck.ok) {
|
|
1901
|
+
exec("git", ["commit", "--allow-empty", "-m", "Initial commit (created by QuadWork setup)"], { cwd: workingDir });
|
|
1902
|
+
const branchResult = exec("git", ["symbolic-ref", "--short", "HEAD"], { cwd: workingDir });
|
|
1903
|
+
const defaultBranch = branchResult.ok ? branchResult.output : "main";
|
|
1904
|
+
exec("git", ["push", "origin", defaultBranch], { cwd: workingDir });
|
|
1905
|
+
}
|
|
1884
1906
|
// Sibling dirs: ../projectName-head/, ../projectName-re1/, etc. (matches CLI wizard)
|
|
1885
1907
|
const projectName = path.basename(workingDir);
|
|
1886
1908
|
const parentDir = path.dirname(workingDir);
|
|
@@ -2293,7 +2315,7 @@ router.post("/api/rename", (req, res) => {
|
|
|
2293
2315
|
const BRIDGE_DIR = path.join(CONFIG_DIR, "agentchattr-telegram");
|
|
2294
2316
|
// #444: pin agentchattr-telegram to a known commit (same pattern as
|
|
2295
2317
|
// AGENTCHATTR_PIN in bin/quadwork.js for bcurts/agentchattr).
|
|
2296
|
-
const AGENTCHATTR_TELEGRAM_PIN = "
|
|
2318
|
+
const AGENTCHATTR_TELEGRAM_PIN = "03753c5e4f4497fb7a4a4da639faf31a61d9a4ac";
|
|
2297
2319
|
|
|
2298
2320
|
function telegramPidFile(projectId) {
|
|
2299
2321
|
return path.join(CONFIG_DIR, `tg-bridge-${projectId}.pid`);
|
|
@@ -3058,20 +3080,19 @@ router.post("/api/discord", async (req, res) => {
|
|
|
3058
3080
|
const venvPip = path.join(venvDir, "bin", "pip");
|
|
3059
3081
|
let pipOutput = "";
|
|
3060
3082
|
try {
|
|
3061
|
-
//
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
// On upgrade: overwrite script, keep venv
|
|
3066
|
-
fs.cpSync(
|
|
3067
|
-
path.join(DISCORD_BRIDGE_SRC, "discord_bridge.py"),
|
|
3068
|
-
path.join(DISCORD_BRIDGE_DIR, "discord_bridge.py"),
|
|
3069
|
-
);
|
|
3070
|
-
fs.cpSync(
|
|
3071
|
-
path.join(DISCORD_BRIDGE_SRC, "requirements.txt"),
|
|
3072
|
-
path.join(DISCORD_BRIDGE_DIR, "requirements.txt"),
|
|
3073
|
-
);
|
|
3083
|
+
// #506: always copy bundled bridge files (not just on first install)
|
|
3084
|
+
// so re-installing after a QuadWork upgrade refreshes the script.
|
|
3085
|
+
if (!fs.existsSync(DISCORD_BRIDGE_DIR)) {
|
|
3086
|
+
fs.mkdirSync(DISCORD_BRIDGE_DIR, { recursive: true });
|
|
3074
3087
|
}
|
|
3088
|
+
fs.cpSync(
|
|
3089
|
+
path.join(DISCORD_BRIDGE_SRC, "discord_bridge.py"),
|
|
3090
|
+
path.join(DISCORD_BRIDGE_DIR, "discord_bridge.py"),
|
|
3091
|
+
);
|
|
3092
|
+
fs.cpSync(
|
|
3093
|
+
path.join(DISCORD_BRIDGE_SRC, "requirements.txt"),
|
|
3094
|
+
path.join(DISCORD_BRIDGE_DIR, "requirements.txt"),
|
|
3095
|
+
);
|
|
3075
3096
|
if (!fs.existsSync(venvPython)) {
|
|
3076
3097
|
execFileSync("python3", ["-m", "venv", venvDir], { encoding: "utf-8", timeout: 60000 });
|
|
3077
3098
|
}
|