u-foo 3.0.21 → 3.0.22

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.
@@ -13,7 +13,11 @@ const { generateInstanceId, subscriberToSafeName } = require("../../coordination
13
13
  const { createDaemonIpcServer } = require("./ipcServer");
14
14
  const { IPC_REQUEST_TYPES, IPC_RESPONSE_TYPES, BUS_STATUS_PHASES } = require("../contracts/eventContract");
15
15
  const { getUfooPaths } = require("../../coordination/state/paths");
16
- const { upsertProjectRuntime, markProjectStopped } = require("../projects");
16
+ const {
17
+ upsertProjectRuntime,
18
+ markProjectDormant,
19
+ markProjectStopped,
20
+ } = require("../projects");
17
21
  const { scheduleProviderSessionResolve, resolveSessionFromFile, persistProviderSession, loadProviderSessionCache } = require("./providerSessions");
18
22
  const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
19
23
  const { createDaemonCronController } = require("./cronOps");
@@ -48,12 +52,18 @@ const { resolveNodeExecutable } = require("../process/nodeExecutable");
48
52
  const {
49
53
  createProjectRuntimeControlPlane,
50
54
  } = require("./projectRuntimeControlPlane");
51
- const { loadConfig, normalizeMcpPort } = require("../../config");
55
+ const { createProjectContext } = require("./projectContext");
56
+ const { createProjectRuntime } = require("./projectRuntime");
57
+ const { registerDaemonRuntimeLifecycle } = require("./processLifecycle");
58
+ const {
59
+ createManagedProjectRuntimeGateway,
60
+ } = require("./projectRuntimeGateway");
61
+ const {
62
+ loadConfig,
63
+ normalizeDaemonTopology,
64
+ normalizeMcpPort,
65
+ } = require("../../config");
52
66
 
53
- let providerSessions = null;
54
- let sessionResolveHandles = new Map();
55
- let daemonCronController = null;
56
- let daemonGroupOrchestrator = null;
57
67
  const PROJECT_RUNTIME_HEARTBEAT_MS = 10 * 1000;
58
68
 
59
69
  function sleep(ms) {
@@ -522,7 +532,10 @@ function resolveSubscriberNickname(projectRoot, subscriberId) {
522
532
  }
523
533
  }
524
534
 
525
- async function handleOps(projectRoot, ops = [], processManager = null) {
535
+ async function handleOps(projectRoot, ops = [], processManager = null, runtimeServices = {}) {
536
+ const providerSessions = runtimeServices.providerSessions || null;
537
+ const sessionResolveHandles = runtimeServices.sessionResolveHandles || new Map();
538
+ const daemonCronController = runtimeServices.cronController || null;
526
539
  const results = [];
527
540
  for (const op of ops) {
528
541
  if (op.action === "launch") {
@@ -575,6 +588,9 @@ async function handleOps(projectRoot, ops = [], processManager = null) {
575
588
  scopedNickname,
576
589
  launchScope: op.launch_scope || "",
577
590
  terminalApp: op.terminal_app || "",
591
+ tmuxTarget: op.tmux_target || op.tmuxTarget || "",
592
+ tmuxPane: op.tmux_pane || op.tmuxPane || "",
593
+ tmuxSession: op.tmux_session || op.tmuxSession || "",
578
594
  tmuxLayoutContext:
579
595
  op.tmux_layout_context && typeof op.tmux_layout_context === "object"
580
596
  ? op.tmux_layout_context
@@ -1151,7 +1167,19 @@ function startBusBridge(projectRoot, provider, onEvent, onStatus, shouldDrain, o
1151
1167
  };
1152
1168
  }
1153
1169
 
1154
- function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1170
+ function startDaemon({
1171
+ projectRoot,
1172
+ provider,
1173
+ model,
1174
+ resumeMode = "auto",
1175
+ daemonTopology = "project",
1176
+ runtimeGeneration = 1,
1177
+ globalRuntimeRouter = null,
1178
+ beforeCleanup = null,
1179
+ manageProcessState = true,
1180
+ listenProjectSocket = true,
1181
+ registrySocketPath = "",
1182
+ }) {
1155
1183
  const paths = getUfooPaths(projectRoot);
1156
1184
  if (!fs.existsSync(paths.ufooDir)) {
1157
1185
  throw new Error("Missing .ufoo. Run: ufoo init");
@@ -1160,57 +1188,59 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1160
1188
  const runDir = paths.runDir;
1161
1189
  ensureDir(runDir);
1162
1190
 
1163
- // 文件锁机制:防止多个 daemon 同时启动
1164
1191
  const lockFile = path.join(runDir, "daemon.lock");
1165
1192
  let lockFd;
1166
1193
  let recoveredStaleLock = false;
1167
- try {
1168
- // 尝试独占方式打开锁文件(如果已存在且被锁定则失败)
1169
- lockFd = fs.openSync(lockFile, "wx");
1170
- fs.writeSync(lockFd, `${process.pid}\n`);
1171
- } catch (err) {
1172
- if (err.code === "EEXIST") {
1173
- // 锁文件已存在,检查是否仍有效
1174
- let existingPid = null;
1175
- try {
1176
- const raw = fs.readFileSync(lockFile, "utf8").trim();
1177
- const parsed = parseInt(raw, 10);
1178
- if (Number.isFinite(parsed) && parsed > 0) {
1179
- existingPid = parsed;
1194
+ if (manageProcessState) {
1195
+ // 文件锁机制:防止多个 daemon 同时启动
1196
+ try {
1197
+ // 尝试独占方式打开锁文件(如果已存在且被锁定则失败)
1198
+ lockFd = fs.openSync(lockFile, "wx");
1199
+ fs.writeSync(lockFd, `${process.pid}\n`);
1200
+ } catch (err) {
1201
+ if (err.code === "EEXIST") {
1202
+ // 锁文件已存在,检查是否仍有效
1203
+ let existingPid = null;
1204
+ try {
1205
+ const raw = fs.readFileSync(lockFile, "utf8").trim();
1206
+ const parsed = parseInt(raw, 10);
1207
+ if (Number.isFinite(parsed) && parsed > 0) {
1208
+ existingPid = parsed;
1209
+ }
1210
+ } catch {
1211
+ // ignore malformed lock file and treat as stale
1180
1212
  }
1181
- } catch {
1182
- // ignore malformed lock file and treat as stale
1183
- }
1184
1213
 
1185
- let lockHeld = false;
1186
- if (existingPid) {
1187
- lockHeld = looksLikeRunningDaemon(projectRoot, existingPid);
1188
- }
1214
+ let lockHeld = false;
1215
+ if (existingPid) {
1216
+ lockHeld = looksLikeRunningDaemon(projectRoot, existingPid);
1217
+ }
1189
1218
 
1190
- if (lockHeld) {
1191
- throw new Error(`Daemon already running with PID ${existingPid}`);
1192
- }
1219
+ if (lockHeld) {
1220
+ throw new Error(`Daemon already running with PID ${existingPid}`);
1221
+ }
1193
1222
 
1194
- // 进程已死或锁文件损坏,清理旧锁后重试
1195
- try {
1196
- fs.unlinkSync(lockFile);
1197
- recoveredStaleLock = true;
1198
- } catch (unlinkErr) {
1199
- throw new Error(`Failed to remove stale daemon lock: ${unlinkErr.message}`);
1200
- }
1201
- try {
1202
- lockFd = fs.openSync(lockFile, "wx");
1203
- fs.writeSync(lockFd, `${process.pid}\n`);
1204
- } catch (retryErr) {
1205
- throw new Error(`Failed to acquire daemon lock: ${retryErr.message}`);
1223
+ // 进程已死或锁文件损坏,清理旧锁后重试
1224
+ try {
1225
+ fs.unlinkSync(lockFile);
1226
+ recoveredStaleLock = true;
1227
+ } catch (unlinkErr) {
1228
+ throw new Error(`Failed to remove stale daemon lock: ${unlinkErr.message}`);
1229
+ }
1230
+ try {
1231
+ lockFd = fs.openSync(lockFile, "wx");
1232
+ fs.writeSync(lockFd, `${process.pid}\n`);
1233
+ } catch (retryErr) {
1234
+ throw new Error(`Failed to acquire daemon lock: ${retryErr.message}`);
1235
+ }
1236
+ } else {
1237
+ throw err;
1206
1238
  }
1207
- } else {
1208
- throw err;
1209
1239
  }
1210
1240
  }
1211
1241
 
1212
- removeSocket(projectRoot);
1213
- writePid(projectRoot);
1242
+ if (listenProjectSocket) removeSocket(projectRoot);
1243
+ if (manageProcessState) writePid(projectRoot);
1214
1244
 
1215
1245
  const logFile = fs.createWriteStream(logPath(projectRoot), { flags: "a" });
1216
1246
  const formatLogLine = (msg) => `[daemon] ${new Date().toISOString()} ${msg}\n`;
@@ -1229,6 +1259,21 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1229
1259
  }
1230
1260
  }
1231
1261
  };
1262
+ const runtimeConfig = loadConfig(projectRoot);
1263
+ const runtimeContext = createProjectContext({
1264
+ projectRoot,
1265
+ config: {
1266
+ ...runtimeConfig,
1267
+ daemonTopology: normalizeDaemonTopology(
1268
+ daemonTopology || process.env.UFOO_DAEMON_TOPOLOGY || runtimeConfig.daemonTopology
1269
+ ),
1270
+ },
1271
+ provider,
1272
+ model,
1273
+ runtimeGeneration,
1274
+ });
1275
+ const projectRuntime = createProjectRuntime(runtimeContext);
1276
+ void projectRuntime.activate();
1232
1277
  const formatFatalReason = (err) => {
1233
1278
  if (!err) return "unknown";
1234
1279
  if (err instanceof Error) return err.stack || err.message;
@@ -1242,7 +1287,7 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1242
1287
  upsertProjectRuntime({
1243
1288
  projectRoot,
1244
1289
  daemonPid: process.pid,
1245
- socketPath: socketPath(projectRoot),
1290
+ socketPath: registrySocketPath || socketPath(projectRoot),
1246
1291
  status,
1247
1292
  lastSeen: new Date().toISOString(),
1248
1293
  });
@@ -1252,30 +1297,54 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1252
1297
  };
1253
1298
 
1254
1299
  // 创建进程管理器 - daemon 作为父进程监控所有 internal agents
1255
- const processManager = new AgentProcessManager(projectRoot);
1300
+ const processManager = projectRuntime.own(
1301
+ "processManager",
1302
+ new AgentProcessManager(projectRoot)
1303
+ );
1256
1304
  log(`Process manager initialized`);
1257
1305
 
1258
1306
  // Provider session cache (in-memory)
1259
- providerSessions = loadProviderSessionCache(projectRoot);
1260
- sessionResolveHandles = new Map();
1261
- daemonCronController = createDaemonCronController({
1307
+ const providerSessions = projectRuntime.own(
1308
+ "providerSessions",
1309
+ loadProviderSessionCache(projectRoot)
1310
+ );
1311
+ const sessionResolveHandles = projectRuntime.own(
1312
+ "sessionResolveHandles",
1313
+ new Map()
1314
+ );
1315
+ const runtimeServices = {
1316
+ providerSessions,
1317
+ sessionResolveHandles,
1318
+ cronController: null,
1319
+ groupOrchestrator: null,
1320
+ };
1321
+ const handleRuntimeOps = (root, ops, manager = processManager) =>
1322
+ handleOps(root, ops, manager, runtimeServices);
1323
+ const daemonCronController = projectRuntime.own("cronController", createDaemonCronController({
1262
1324
  projectRoot,
1263
1325
  dispatch: async ({ taskId, target, message }) => {
1264
1326
  await dispatchMessages(projectRoot, [{ target, message }]);
1265
1327
  log(`cron:${taskId} -> ${target}`);
1266
1328
  },
1267
1329
  log,
1268
- });
1269
- daemonGroupOrchestrator = createGroupOrchestrator({
1330
+ }));
1331
+ runtimeServices.cronController = daemonCronController;
1332
+ const daemonGroupOrchestrator = projectRuntime.own("groupOrchestrator", createGroupOrchestrator({
1270
1333
  projectRoot,
1271
- handleOps,
1334
+ handleOps: handleRuntimeOps,
1272
1335
  processManager,
1273
- });
1336
+ }));
1337
+ runtimeServices.groupOrchestrator = daemonGroupOrchestrator;
1274
1338
 
1275
- const buildRuntimeStatus = () =>
1276
- buildStatus(projectRoot, {
1339
+ const buildRuntimeStatus = () => ({
1340
+ ...buildStatus(projectRoot, {
1277
1341
  cronTasks: daemonCronController ? daemonCronController.listTasks() : [],
1278
- });
1342
+ }),
1343
+ runtime: projectRuntime.status(),
1344
+ ...(globalRuntimeRouter && typeof globalRuntimeRouter.status === "function"
1345
+ ? { global_daemon: globalRuntimeRouter.status() }
1346
+ : {}),
1347
+ });
1279
1348
 
1280
1349
  const cleanupInactiveSubscribers = () => {
1281
1350
  try {
@@ -1298,6 +1367,7 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1298
1367
  cleanupInactive: cleanupInactiveSubscribers,
1299
1368
  log,
1300
1369
  });
1370
+ projectRuntime.own("ipcServer", ipcServer);
1301
1371
 
1302
1372
  const publishAgentReportResult = (entry) => {
1303
1373
  ipcServer.sendToSockets({
@@ -1346,6 +1416,7 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1346
1416
  log(`report bus event failed request=${meta.requestId || ""} error=${err.message || String(err)}`);
1347
1417
  }
1348
1418
  });
1419
+ projectRuntime.own("busBridge", busBridge);
1349
1420
  const deliveryScheduler = new DeliveryScheduler(projectRoot, {
1350
1421
  log,
1351
1422
  emitDelivery: async ({ subscriber, status, error } = {}) => {
@@ -1355,16 +1426,35 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1355
1426
  },
1356
1427
  });
1357
1428
  deliveryScheduler.start();
1429
+ projectRuntime.own("deliveryScheduler", deliveryScheduler);
1358
1430
  const runtimeControlPlane = createProjectRuntimeControlPlane({ projectRoot });
1431
+ projectRuntime.own("runtimeControlPlane", runtimeControlPlane);
1359
1432
  let mcpHttpServer = null;
1433
+ let globalProjectRuntimeGateway = null;
1434
+ let ownsGlobalProjectRuntimeGateway = false;
1360
1435
  if (isGlobalControllerProjectRoot(projectRoot) && process.env.UFOO_MCP_HTTP_DISABLED !== "1") {
1361
1436
  const { createGlobalMcpHttpServer } = require("./mcpHttpServer");
1362
1437
  const config = loadConfig(projectRoot);
1363
1438
  const configuredPort = process.env.UFOO_MCP_PORT || config.mcpPort;
1439
+ globalProjectRuntimeGateway =
1440
+ globalRuntimeRouter
1441
+ && globalRuntimeRouter.projectRuntimeGateway
1442
+ ? globalRuntimeRouter.projectRuntimeGateway
1443
+ : createManagedProjectRuntimeGateway({
1444
+ managerOptions: {
1445
+ authorizeProjectRoot: (targetRoot) =>
1446
+ fs.existsSync(getUfooPaths(targetRoot).ufooDir),
1447
+ },
1448
+ });
1449
+ ownsGlobalProjectRuntimeGateway =
1450
+ globalProjectRuntimeGateway !== (
1451
+ globalRuntimeRouter && globalRuntimeRouter.projectRuntimeGateway
1452
+ );
1364
1453
  mcpHttpServer = createGlobalMcpHttpServer({
1365
1454
  projectRoot,
1366
1455
  port: normalizeMcpPort(configuredPort),
1367
1456
  log,
1457
+ projectRuntimeGateway: globalProjectRuntimeGateway,
1368
1458
  });
1369
1459
  mcpHttpServer.start().catch((err) => {
1370
1460
  logSync(`MCP HTTP startup failed: ${formatFatalReason(err)}`);
@@ -1376,6 +1466,37 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1376
1466
 
1377
1467
  handleIpcRequest = async (req, socket) => {
1378
1468
  if (!req || typeof req !== "object") return;
1469
+ const routedProjectRoot = String(req.project_root || req.projectRoot || "").trim();
1470
+ if (
1471
+ globalRuntimeRouter
1472
+ && req.type === IPC_REQUEST_TYPES.CLOSE_PROJECT_RUNTIME
1473
+ ) {
1474
+ try {
1475
+ const result = globalRuntimeRouter.closeProject(routedProjectRoot, {
1476
+ terminateAgents:
1477
+ req.terminate_agents === true || req.terminateAgents === true,
1478
+ });
1479
+ socket.write(`${JSON.stringify({
1480
+ type: IPC_RESPONSE_TYPES.RESPONSE,
1481
+ data: result,
1482
+ })}\n`);
1483
+ } catch (err) {
1484
+ socket.write(`${JSON.stringify({
1485
+ type: IPC_RESPONSE_TYPES.ERROR,
1486
+ error: err.message || String(err),
1487
+ code: err.code || "close_project_runtime_failed",
1488
+ })}\n`);
1489
+ }
1490
+ return;
1491
+ }
1492
+ if (
1493
+ globalRuntimeRouter
1494
+ && routedProjectRoot
1495
+ && !sameProjectRoot(routedProjectRoot, projectRoot)
1496
+ ) {
1497
+ await globalRuntimeRouter.handleRequest(routedProjectRoot, req, socket);
1498
+ return;
1499
+ }
1379
1500
  if (await runtimeControlPlane.handleRequest(req, socket)) return;
1380
1501
  if (req.type === IPC_REQUEST_TYPES.MCP_STATUS || req.type === IPC_REQUEST_TYPES.MCP_RESTART) {
1381
1502
  if (!isGlobalControllerProjectRoot(projectRoot)) {
@@ -1431,7 +1552,7 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1431
1552
  runUfooAgent,
1432
1553
  runUfooRouteAgent,
1433
1554
  dispatchMessages,
1434
- handleOps,
1555
+ handleOps: handleRuntimeOps,
1435
1556
  markPending: (target) => busBridge.markPending(target),
1436
1557
  reportTaskStatus: async (report) => {
1437
1558
  await recordAgentReport({
@@ -1466,7 +1587,7 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1466
1587
  const init = new (require("../../app/cli/features/init"))(repoRoot);
1467
1588
  await init.init({ targets: "context,bus", project: root });
1468
1589
  }
1469
- if (!isRunning(root)) {
1590
+ if (!globalRuntimeRouter && !isRunning(root)) {
1470
1591
  cleanupStaleState(root);
1471
1592
  const daemonBin = path.join(__dirname, "..", "..", "..", "bin", "ufoo.js");
1472
1593
  const child = spawn(resolveNodeExecutable(), [daemonBin, "daemon", "--start"], {
@@ -1490,11 +1611,15 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1490
1611
  delete nextMeta.force_project_root;
1491
1612
  delete nextMeta.force_project_name;
1492
1613
 
1493
- return sendPromptRequestToProject(root, {
1614
+ const routedRequest = {
1494
1615
  type: IPC_REQUEST_TYPES.PROMPT,
1495
1616
  text: String(prompt || ""),
1496
1617
  request_meta: nextMeta,
1497
- });
1618
+ };
1619
+ if (globalRuntimeRouter && typeof globalRuntimeRouter.request === "function") {
1620
+ return globalRuntimeRouter.request(root, routedRequest, { timeoutMs: 12000 });
1621
+ }
1622
+ return sendPromptRequestToProject(root, routedRequest);
1498
1623
  },
1499
1624
  log,
1500
1625
  });
@@ -1656,7 +1781,7 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1656
1781
  }
1657
1782
  try {
1658
1783
  const op = { action: "close", agent_id };
1659
- const opsResults = await handleOps(projectRoot, [op], processManager);
1784
+ const opsResults = await handleRuntimeOps(projectRoot, [op], processManager);
1660
1785
  const closeResult = opsResults.find((r) => r.action === "close");
1661
1786
  const ok = closeResult ? closeResult.ok !== false : true;
1662
1787
  const reply = ok
@@ -1697,6 +1822,9 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1697
1822
  prompt_profile,
1698
1823
  launch_scope,
1699
1824
  terminal_app,
1825
+ tmux_target,
1826
+ tmux_pane,
1827
+ tmux_session,
1700
1828
  host_inject_sock,
1701
1829
  host_daemon_sock,
1702
1830
  host_name,
@@ -1735,6 +1863,9 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1735
1863
  nickname: explicitNickname,
1736
1864
  launch_scope: launch_scope || "",
1737
1865
  terminal_app: terminal_app || "",
1866
+ tmux_target: tmux_target || req.tmuxTarget || "",
1867
+ tmux_pane: tmux_pane || req.tmuxPane || "",
1868
+ tmux_session: tmux_session || req.tmuxSession || "",
1738
1869
  host_inject_sock: host_inject_sock || "",
1739
1870
  host_daemon_sock: host_daemon_sock || "",
1740
1871
  host_name: host_name || "",
@@ -1830,7 +1961,7 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1830
1961
  };
1831
1962
  }
1832
1963
  try {
1833
- const opsResults = await handleOps(projectRoot, [op], processManager);
1964
+ const opsResults = await handleRuntimeOps(projectRoot, [op], processManager);
1834
1965
  const launchResult = opsResults.find((r) => r.action === "launch");
1835
1966
  if (soloLaunchBootstrap && launchResult && launchResult.ok !== false) {
1836
1967
  const subscriberId = pickLaunchSubscriber(projectRoot, launchResult, explicitNickname || "");
@@ -1862,7 +1993,7 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1862
1993
  projectRoot,
1863
1994
  launchResult,
1864
1995
  roleTarget,
1865
- handleOps,
1996
+ handleRuntimeOps,
1866
1997
  processManager
1867
1998
  );
1868
1999
  const roleError = roleResult.error || "role assignment failed";
@@ -1992,6 +2123,9 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1992
2123
  const hostName = req.host_name || req.hostName || "";
1993
2124
  const hostSessionId = req.host_session_id || req.hostSessionId || "";
1994
2125
  const terminalApp = req.terminal_app || req.terminalApp || "";
2126
+ const tmuxTarget = req.tmux_target || req.tmuxTarget || "";
2127
+ const tmuxPane = req.tmux_pane || req.tmuxPane || "";
2128
+ const tmuxSession = req.tmux_session || req.tmuxSession || "";
1995
2129
  const hostCapabilities =
1996
2130
  req.host_capabilities && typeof req.host_capabilities === "object"
1997
2131
  ? req.host_capabilities
@@ -2008,6 +2142,9 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
2008
2142
  host_name: hostName,
2009
2143
  host_session_id: hostSessionId,
2010
2144
  terminal_app: terminalApp,
2145
+ tmux_target: tmuxTarget,
2146
+ tmux_pane: tmuxPane,
2147
+ tmux_session: tmuxSession,
2011
2148
  host_capabilities: hostCapabilities,
2012
2149
  });
2013
2150
  const ok = result && result.ok !== false;
@@ -2523,7 +2660,7 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
2523
2660
  }
2524
2661
  };
2525
2662
 
2526
- ipcServer.listen(socketPath(projectRoot));
2663
+ if (listenProjectSocket) ipcServer.listen(socketPath(projectRoot));
2527
2664
  publishProjectRuntime("running");
2528
2665
  const runtimeHeartbeat = setInterval(() => {
2529
2666
  publishProjectRuntime("running");
@@ -2531,11 +2668,14 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
2531
2668
 
2532
2669
  log(`Started pid=${process.pid}`);
2533
2670
 
2534
- // 清理旧 daemon 留下的孤儿 internal agent 进程
2535
- const EventBus = require("../../coordination/bus");
2536
- const { spawnSync } = require("child_process");
2537
- const eventBus = new EventBus(projectRoot);
2538
- try {
2671
+ // Legacy project daemons own internal runners as child processes. A
2672
+ // global/hybrid runtime deliberately does not: runners must survive host
2673
+ // replacement and keep consuming their durable queues.
2674
+ if (runtimeContext.daemonTopology === "project") {
2675
+ const EventBus = require("../../coordination/bus");
2676
+ const { spawnSync } = require("child_process");
2677
+ const eventBus = new EventBus(projectRoot);
2678
+ try {
2539
2679
  eventBus.ensureBus();
2540
2680
  eventBus.loadBusData();
2541
2681
  const agents = eventBus.busData.agents || {};
@@ -2616,9 +2756,10 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
2616
2756
  }
2617
2757
  }
2618
2758
  }
2619
- eventBus.saveBusData();
2620
- } catch (err) {
2621
- log(`Failed to cleanup orphan agents: ${err.message}`);
2759
+ eventBus.saveBusData();
2760
+ } catch (err) {
2761
+ log(`Failed to cleanup orphan agents: ${err.message}`);
2762
+ }
2622
2763
  }
2623
2764
 
2624
2765
  const shouldResume = resumeMode === "force" || (resumeMode === "auto" && recoveredStaleLock);
@@ -2633,15 +2774,28 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
2633
2774
  }
2634
2775
 
2635
2776
  let cleanedUp = false;
2777
+ let unregisterProcessLifecycle = () => {};
2636
2778
  const cleanup = (reason = "exit", options = {}) => {
2637
2779
  if (cleanedUp) return;
2638
2780
  cleanedUp = true;
2781
+ unregisterProcessLifecycle();
2639
2782
  const writeLog = options.sync ? logSync : log;
2640
2783
  writeLog(`Shutting down daemon reason=${reason} (managed agents: ${processManager.count()})`);
2784
+ if (typeof beforeCleanup === "function") {
2785
+ try {
2786
+ beforeCleanup({ reason, options, projectRoot, runtime: projectRuntime });
2787
+ } catch (err) {
2788
+ writeLog(`before cleanup hook failed: ${formatFatalReason(err)}`);
2789
+ }
2790
+ }
2641
2791
  clearInterval(runtimeHeartbeat);
2642
2792
  try {
2643
2793
  if (!isGlobalControllerProjectRoot(projectRoot)) {
2644
- markProjectStopped(projectRoot);
2794
+ if (reason === "global-runtime-idle") {
2795
+ markProjectDormant(projectRoot);
2796
+ } else {
2797
+ markProjectStopped(projectRoot);
2798
+ }
2645
2799
  }
2646
2800
  } catch {
2647
2801
  // ignore cleanup errors
@@ -2649,9 +2803,7 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
2649
2803
 
2650
2804
  if (daemonCronController) {
2651
2805
  daemonCronController.stopAll();
2652
- daemonCronController = null;
2653
2806
  }
2654
- daemonGroupOrchestrator = null;
2655
2807
 
2656
2808
  runtimeControlPlane.stop();
2657
2809
  if (mcpHttpServer) {
@@ -2659,54 +2811,65 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
2659
2811
  writeLog(`MCP HTTP shutdown failed: ${formatFatalReason(err)}`);
2660
2812
  });
2661
2813
  }
2814
+ if (globalProjectRuntimeGateway && ownsGlobalProjectRuntimeGateway) {
2815
+ globalProjectRuntimeGateway.dispose();
2816
+ }
2817
+ globalProjectRuntimeGateway = null;
2662
2818
 
2663
- // 清理所有子进程
2664
- processManager.cleanup();
2819
+ // Project topology preserves the legacy parent-owned runner contract.
2820
+ // Global/hybrid runtimes detach so daemon restart does not terminate work.
2821
+ processManager.cleanup({
2822
+ terminate: runtimeContext.daemonTopology === "project",
2823
+ });
2665
2824
 
2666
2825
  ipcServer.stop();
2667
2826
  deliveryScheduler.stop();
2668
2827
  busBridge.stop();
2669
- removeSocket(projectRoot);
2670
- removePidIfOwned(projectRoot);
2828
+ projectRuntime.dispose();
2829
+ if (listenProjectSocket) removeSocket(projectRoot);
2830
+ if (manageProcessState) removePidIfOwned(projectRoot);
2671
2831
 
2672
2832
  // 释放锁文件
2673
2833
  try {
2674
2834
  if (lockFd !== undefined) {
2675
2835
  fs.closeSync(lockFd);
2676
2836
  }
2677
- const lockFile = path.join(getUfooPaths(projectRoot).runDir, "daemon.lock");
2678
- if (fs.existsSync(lockFile)) {
2837
+ if (manageProcessState && fs.existsSync(lockFile)) {
2679
2838
  fs.unlinkSync(lockFile);
2680
2839
  }
2681
2840
  } catch {
2682
2841
  // ignore cleanup errors
2683
2842
  }
2843
+ try {
2844
+ logFile.end();
2845
+ } catch {
2846
+ // ignore log stream shutdown errors
2847
+ }
2684
2848
  };
2685
2849
 
2686
- process.on("beforeExit", (code) => {
2687
- logSync(`beforeExit code=${code}`);
2688
- });
2689
- process.on("exit", (code) => {
2690
- cleanup(`exit code=${code}`, { sync: true });
2691
- });
2692
- process.on("SIGTERM", () => {
2693
- cleanup("SIGTERM", { sync: true });
2694
- process.exit(0);
2695
- });
2696
- process.on("SIGINT", () => {
2697
- cleanup("SIGINT", { sync: true });
2698
- process.exit(0);
2699
- });
2700
- process.on("uncaughtException", (err) => {
2701
- logSync(`uncaughtException: ${formatFatalReason(err)}`);
2702
- cleanup("uncaughtException", { sync: true });
2703
- process.exit(1);
2704
- });
2705
- process.on("unhandledRejection", (reason) => {
2706
- logSync(`unhandledRejection: ${formatFatalReason(reason)}`);
2707
- cleanup("unhandledRejection", { sync: true });
2708
- process.exit(1);
2709
- });
2850
+ unregisterProcessLifecycle = registerDaemonRuntimeLifecycle(
2851
+ `${runtimeContext.projectId}:${runtimeContext.runtimeGeneration}`,
2852
+ {
2853
+ onBeforeExit: (code) => {
2854
+ logSync(`beforeExit code=${code}`);
2855
+ },
2856
+ onFatal: ({ kind, error }) => {
2857
+ logSync(`${kind}: ${formatFatalReason(error)}`);
2858
+ },
2859
+ cleanup,
2860
+ }
2861
+ );
2862
+
2863
+ return {
2864
+ context: runtimeContext,
2865
+ runtime: projectRuntime,
2866
+ cleanup,
2867
+ status: buildRuntimeStatus,
2868
+ handleRequest: handleIpcRequest,
2869
+ socket_path: listenProjectSocket
2870
+ ? socketPath(projectRoot)
2871
+ : (registrySocketPath || ""),
2872
+ };
2710
2873
  }
2711
2874
 
2712
2875
  function stopDaemon(projectRoot, options = {}) {
@@ -182,6 +182,11 @@ class GlobalMcpHttpServer {
182
182
  active_wait_count: activeWaits,
183
183
  http_request_count: this.httpRequestCount,
184
184
  started_at: this.startedAt || null,
185
+ project_runtime_manager:
186
+ this.projectRuntimeGateway
187
+ && typeof this.projectRuntimeGateway.status === "function"
188
+ ? this.projectRuntimeGateway.status()
189
+ : null,
185
190
  };
186
191
  }
187
192